import { spawnSync } from 'node:child_process' import { error as logError, log } from 'node:console' import { existsSync } from 'node:fs' import { chmod, mkdir, mkdtemp, readFile, rename, rm, writeFile, } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import process from 'node:process' import { fileURLToPath } from 'node:url' export const BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE = 'this_dir="$(readlink -f "$(dirname "$0")")"' export const FIXED_LINUXDEPLOY_APPRUN_DIR_LINE = 'this_dir="$(dirname "$(readlink -f "$0")")"' export const APPIMAGE_PLUGIN_WRAPPER_NAME = 'linuxdeploy-plugin-appimage.AppImage' export const REAL_APPIMAGE_PLUGIN_NAME = 'tolaria-real-linuxdeploy-plugin-appimage/linuxdeploy-plugin-appimage.AppImage' export const APPIMAGE_FCITX_GTK3_IM_MODULE_PATH = 'usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/immodules/im-fcitx5.so' export const APPIMAGE_FCITX_GCLIENT_LIBRARY_PATH = 'usr/lib/x86_64-linux-gnu/libFcitx5GClient.so.2' export const DEFAULT_APPIMAGE_PLUGIN_URL = 'https://github.com/linuxdeploy/linuxdeploy-plugin-appimage/releases/download/continuous/linuxdeploy-plugin-appimage-x86_64.AppImage' const WRAPPER_MARKER = 'Tolaria AppImage symlink launcher shim' const REQUIRED_APPIMAGE_PATHS = [ 'AppRun', APPIMAGE_FCITX_GTK3_IM_MODULE_PATH, APPIMAGE_FCITX_GCLIENT_LIBRARY_PATH, ] export function tauriToolsCacheDir(env = process.env) { if (env.TOLARIA_TAURI_TOOLS_DIR) { return resolve(env.TOLARIA_TAURI_TOOLS_DIR) } if (env.XDG_CACHE_HOME) { return resolve(env.XDG_CACHE_HOME, 'tauri') } if (!env.HOME) { throw new Error('HOME or XDG_CACHE_HOME is required to locate the Tauri tools cache') } return resolve(env.HOME, '.cache', 'tauri') } export function patchAppRunText(text) { if (text.includes(BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE)) { return { changed: true, text: text.replaceAll( BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE, FIXED_LINUXDEPLOY_APPRUN_DIR_LINE, ), } } return { changed: false, text } } export function assertSymlinkSafeAppRunText(text, label = 'AppRun') { if (text.includes(BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE)) { throw new Error(`${label} still resolves dirname before following AppRun symlinks`) } if (!text.includes(FIXED_LINUXDEPLOY_APPRUN_DIR_LINE)) { throw new Error(`${label} is missing the symlink-safe AppRun directory resolver`) } } export function appImagePluginWrapperSource({ pluginUrl = DEFAULT_APPIMAGE_PLUGIN_URL, } = {}) { return `#!/usr/bin/env bash set -euo pipefail # ${WRAPPER_MARKER} PLUGIN_URL="\${TOLARIA_APPIMAGE_PLUGIN_URL:-${pluginUrl}}" SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" REAL_PLUGIN="\${TOLARIA_APPIMAGE_REAL_PLUGIN:-"$SCRIPT_DIR/${REAL_APPIMAGE_PLUGIN_NAME}"}" FCITX_GTK3_IM_MODULE="\${TOLARIA_FCITX_GTK3_IM_MODULE:-/usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/immodules/im-fcitx5.so}" FCITX_LIBRARY_DIR="\${TOLARIA_FCITX_LIBRARY_DIR:-/usr/lib/x86_64-linux-gnu}" appdir_from_args() { local previous="" for arg in "$@"; do if [ "$previous" = "--appdir" ]; then printf '%s\\n' "$arg" return 0 fi case "$arg" in --appdir=*) printf '%s\\n' "\${arg#--appdir=}" return 0 ;; esac previous="$arg" done } download_real_plugin() { if [ -x "$REAL_PLUGIN" ]; then return 0 fi local tmp_plugin="$REAL_PLUGIN.tmp.$$" rm -f "$tmp_plugin" mkdir -p "$(dirname -- "$REAL_PLUGIN")" if command -v curl >/dev/null 2>&1; then curl -fsSL -o "$tmp_plugin" "$PLUGIN_URL" elif command -v wget >/dev/null 2>&1; then wget -q -O "$tmp_plugin" "$PLUGIN_URL" else echo "curl or wget is required to fetch the real linuxdeploy AppImage output plugin" >&2 return 1 fi chmod +x "$tmp_plugin" mv "$tmp_plugin" "$REAL_PLUGIN" } patch_apprun() { local appdir="\${APPDIR:-}" if [ -z "$appdir" ]; then appdir="$(appdir_from_args "$@" || true)" fi if [ -z "$appdir" ] || [ ! -f "$appdir/AppRun" ]; then return 0 fi python3 - "$appdir/AppRun" <<'PY' from pathlib import Path import sys path = Path(sys.argv[1]) broken = 'this_dir="$(readlink -f "$(dirname "$0")")"' fixed = 'this_dir="$(dirname "$(readlink -f "$0")")"' text = path.read_text(encoding="utf-8") if broken in text: path.write_text(text.replace(broken, fixed), encoding="utf-8") print(f"Patched linuxdeploy AppRun symlink resolution in {path}", file=sys.stderr) elif fixed in text: pass elif "autogenerated by linuxdeploy" in text and "AppRun.wrapped" in text: raise SystemExit(f"{path} is a linuxdeploy wrapper but does not contain the expected AppRun resolver") PY } bundle_fcitx_gtk3_module() { local appdir="\${APPDIR:-}" if [ -z "$appdir" ]; then appdir="$(appdir_from_args "$@" || true)" fi if [ -z "$appdir" ] || [ ! -d "$appdir" ] || [ ! -f "$FCITX_GTK3_IM_MODULE" ]; then return 0 fi local module_dest="$appdir/${APPIMAGE_FCITX_GTK3_IM_MODULE_PATH}" local library_dest_dir="$appdir/usr/lib/x86_64-linux-gnu" mkdir -p "$(dirname -- "$module_dest")" "$library_dest_dir" cp -a "$FCITX_GTK3_IM_MODULE" "$module_dest" local copied_library=0 shopt -s nullglob for lib in "$FCITX_LIBRARY_DIR"/libFcitx5GClient.so* "$FCITX_LIBRARY_DIR"/libFcitx5Utils.so*; do cp -a "$lib" "$library_dest_dir/" copied_library=1 done shopt -u nullglob if [ "$copied_library" -eq 0 ]; then echo "No fcitx GTK client libraries found in $FCITX_LIBRARY_DIR" >&2 fi } download_real_plugin patch_apprun "$@" bundle_fcitx_gtk3_module "$@" exec "$REAL_PLUGIN" "$@" ` } export async function preparePluginWrapper({ env = process.env, toolsDir = tauriToolsCacheDir(env), } = {}) { await mkdir(toolsDir, { recursive: true }) const wrapperPath = join(toolsDir, APPIMAGE_PLUGIN_WRAPPER_NAME) const realPluginPath = join(toolsDir, REAL_APPIMAGE_PLUGIN_NAME) if (existsSync(wrapperPath) && !existsSync(realPluginPath)) { const existing = await readFile(wrapperPath, 'utf8').catch(() => '') if (!existing.includes(WRAPPER_MARKER)) { await mkdir(dirname(realPluginPath), { recursive: true }) await rename(wrapperPath, realPluginPath) } } await writeFile(wrapperPath, appImagePluginWrapperSource(), 'utf8') await chmod(wrapperPath, 0o755) return { realPluginPath, wrapperPath } } export async function validateAppRunFile(path) { const text = await readFile(path, 'utf8') assertSymlinkSafeAppRunText(text, path) } function extractAppImagePath(appImage, requiredPath, tempDir) { const result = spawnSync(appImage, ['--appimage-extract', requiredPath], { cwd: tempDir, encoding: 'utf8', }) if (result.status === 0) { return } throw new Error( [ `Failed to extract ${requiredPath} from ${appImage}`, result.stdout.trim(), result.stderr.trim(), ] .filter(Boolean) .join('\n'), ) } function assertAppImagePathsExtracted(appImage, tempDir, requiredPaths) { for (const requiredPath of requiredPaths) { const extractedPath = join(tempDir, 'squashfs-root', requiredPath) if (!existsSync(extractedPath)) { throw new Error(`${appImage} is missing ${requiredPath}`) } } } async function validateExtractedAppImage(appImage, tempDir) { for (const requiredPath of REQUIRED_APPIMAGE_PATHS) { extractAppImagePath(appImage, requiredPath, tempDir) } await validateAppRunFile(join(tempDir, 'squashfs-root', 'AppRun')) assertAppImagePathsExtracted(appImage, tempDir, REQUIRED_APPIMAGE_PATHS.slice(1)) } export async function validateAppImages(paths) { if (paths.length === 0) { throw new Error('At least one AppImage path is required for launcher validation') } for (const appImage of paths.map((path) => resolve(path))) { const tempDir = await mkdtemp(join(tmpdir(), 'tolaria-appimage-')) try { await validateExtractedAppImage(appImage, tempDir) } finally { await rm(tempDir, { recursive: true, force: true }) } } } async function preparePluginCommand() { const { wrapperPath, realPluginPath } = await preparePluginWrapper() log(`Prepared ${wrapperPath}`) log(`Real plugin cache: ${realPluginPath}`) } async function validateAppRunFilesCommand(paths) { for (const path of paths) { await validateAppRunFile(path) log(`Validated ${path}`) } } async function validateAppImagesCommand(paths) { await validateAppImages(paths) for (const path of paths) { log(`Validated AppImage launcher in ${path}`) } } const COMMANDS = new Map([ ['prepare-plugin', preparePluginCommand], ['validate-apprun-file', validateAppRunFilesCommand], ['validate-appimages', validateAppImagesCommand], ]) function usage() { return 'Usage: node scripts/appimage-launcher-tools.mjs prepare-plugin | validate-apprun-file | validate-appimages ' } async function main() { const [command, ...args] = process.argv.slice(2) const handler = COMMANDS.get(command) if (!handler) { throw new Error(usage()) } await handler(args) } if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { main().catch((error) => { logError(error.message) process.exit(1) }) }