From d2ee007444d7496bc5753ab6c3d3e99d6edc916d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=9F=83=E5=8D=9A=E6=8B=89=E9=85=B1?= Date: Sat, 7 Mar 2026 23:47:50 +0800 Subject: [PATCH 01/10] feat: improve terminal install, recovery, and proot compatibility - switch terminal setup to a staged install pipeline with cache markers, native download actions, and selective uninstall behavior so repeated installs and repairs avoid unnecessary downloads - replace process-only startup checks with HTTP readiness probing, add automatic repair when axs is running but not reachable, and retry terminal creation after refreshing the embedded axs binary on PTY open failures - improve Alpine bootstrap scripts by hardening package checks, moving command-not-found handling through /bin/sh, exposing the acode CLI as shell functions, enabling allow-any-origin for local requests, disabling proot seccomp, and removing --sysvipc for kernel stability - unify terminal default settings and validation for font options, ligatures, image support, and letter spacing, and improve terminal resize and mount behavior around fit timing and observer updates - persist terminal sessions and active tabs, clean up failed terminal tabs more aggressively, and automatically recover from relocation or symbol resolution failures by reinstalling the runtime when needed - expose copyAsset from the system bridge for debug axs refresh flows, keep Android executor download support wired through the JS bridge, reduce noisy auth logging for expected unauthenticated states, and update package-lock.json to reflect the integrated dependency state --- package-lock.json | 32 +- src/components/terminal/terminal.js | 164 ++++++-- src/components/terminal/terminalDefaults.js | 8 +- src/components/terminal/terminalManager.js | 109 ++++-- src/lib/auth.js | 3 +- .../android/com/foxdebug/system/System.java | 18 + src/plugins/system/www/plugin.js | 55 +-- src/plugins/terminal/scripts/init-alpine.sh | 246 ++++++------ src/plugins/terminal/scripts/init-sandbox.sh | 16 +- .../src/android/BackgroundExecutor.java | 6 + .../terminal/src/android/DownloadHelper.java | 70 ++++ .../terminal/src/android/Executor.java | 5 + src/plugins/terminal/www/Executor.js | 23 ++ src/plugins/terminal/www/Terminal.js | 351 +++++++++++++----- src/settings/terminalSettings.js | 32 +- 15 files changed, 811 insertions(+), 327 deletions(-) create mode 100644 src/plugins/terminal/src/android/DownloadHelper.java diff --git a/package-lock.json b/package-lock.json index 889339b63..dbbd8b82c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -153,6 +153,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1978,6 +1979,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz", "integrity": "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", @@ -1990,6 +1992,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.2.tgz", "integrity": "sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.4.0", @@ -2026,6 +2029,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", @@ -2052,6 +2056,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz", "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-css": "^6.0.0", @@ -2279,6 +2284,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.1.tgz", "integrity": "sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", @@ -2371,6 +2377,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.4.tgz", "integrity": "sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==", "license": "MIT", + "peer": true, "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } @@ -2392,6 +2399,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.15.tgz", "integrity": "sha512-aCWjgweIIXLBHh7bY6cACvXuyrZ0xGafjQ2VInjp4RM4gMfscK5uESiNdrH0pE+e1lZr2B4ONGsjchl2KsKZzg==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -4204,6 +4212,7 @@ "integrity": "sha512-Iax6UhrfZqJajA778c1d5DBFbSIqPOSrI34kpNIiNpWd8Jq7mFIa+Z60SQb5ZQDZuUxcCZikjz5BxinFjTkg7Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@module-federation/runtime-tools": "0.22.0", "@rspack/binding": "1.7.6", @@ -4493,8 +4502,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/markdown-it": { "version": "14.1.2", @@ -4511,8 +4519,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/mime": { "version": "1.3.5", @@ -4929,7 +4936,8 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", @@ -5006,6 +5014,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5054,6 +5063,7 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -5570,6 +5580,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5732,6 +5743,7 @@ "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", @@ -8965,6 +8977,7 @@ "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", "license": "MIT", + "peer": true, "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", @@ -10063,6 +10076,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -10183,6 +10197,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -10326,6 +10341,7 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -10596,6 +10612,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -12022,7 +12039,8 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tuf-js": { "version": "4.1.0", @@ -12090,6 +12108,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12392,6 +12411,7 @@ "integrity": "sha512-dRXm0a2qcHPUBEzVk8uph0xWSjV/xZxenQQbLwnwP7caQCYpqG1qddwlyEkIDkYn0K8tvmcrZ+bOrzoQ3HxCDw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", diff --git a/src/components/terminal/terminal.js b/src/components/terminal/terminal.js index 39ede08c1..7935b9ebf 100644 --- a/src/components/terminal/terminal.js +++ b/src/components/terminal/terminal.js @@ -34,7 +34,7 @@ export default class TerminalComponent { port: options.port || 8767, renderer: options.renderer || "auto", // 'auto' | 'canvas' | 'webgl' fontSize: terminalSettings.fontSize, - fontFamily: terminalSettings.fontFamily, + fontFamily: `"${terminalSettings.fontFamily}", monospace`, fontWeight: terminalSettings.fontWeight, theme: TerminalThemeManager.getTheme(terminalSettings.theme), cursorBlink: terminalSettings.cursorBlink, @@ -181,17 +181,13 @@ export default class TerminalComponent { let resizeTimeout = null; let lastKnownScrollPosition = 0; let isResizing = false; - let resizeCount = 0; const RESIZE_DEBOUNCE = 100; - const MAX_RAPID_RESIZES = 3; // Store original dimensions for comparison let originalRows = this.terminal.rows; let originalCols = this.terminal.cols; this.terminal.onResize((size) => { - // Track resize events - resizeCount++; isResizing = true; // Store current scroll position before resize @@ -207,19 +203,22 @@ export default class TerminalComponent { // Debounced resize handling resizeTimeout = setTimeout(async () => { try { - // Only proceed with server resize if dimensions actually changed significantly - const rowDiff = Math.abs(size.rows - originalRows); - const colDiff = Math.abs(size.cols - originalCols); - - // If this is a minor resize (likely intermediate state), skip server update - if (rowDiff < 2 && colDiff < 2 && resizeCount > 1) { - console.log("Skipping minor resize to prevent instability"); - isResizing = false; - resizeCount = 0; - return; - } - - // Handle server resize + // Always sync cols/rows to backend PTY on every client resize. + // + // The original code only synced when heightRatio < 0.75 (the "keyboard resize" + // heuristic below). That fails in several real scenarios on mobile: + // 1. Small keyboard height changes (e.g. switching input methods, suggestion + // bar appearing/disappearing) shrink height by <25%, bypassing the threshold. + // 2. Width-only changes (screen rotation, split-screen toggle) leave height + // unchanged so heightRatio ≈ 1.0, but cols change and PTY must know. + // 3. Animated keyboard open/close fires many incremental resize events; each + // individual step is a tiny delta that never crosses 0.75, yet the + // accumulated drift corrupts output. + // 4. Different ROMs / WebView versions report slightly different viewport sizes + // for the same keyboard, making any fixed threshold unreliable. + // + // Unconditionally syncing is cheap (one small HTTP POST per debounced resize) + // and guarantees the PTY always matches the client grid. if (this.serverMode) { await this.resizeTerminal(size.cols, size.rows); } @@ -262,7 +261,6 @@ export default class TerminalComponent { // Mark resize as complete isResizing = false; - resizeCount = 0; // Notify touch selection if it exists if (this.touchSelection) { @@ -271,7 +269,6 @@ export default class TerminalComponent { } catch (error) { console.error("Resize handling failed:", error); isResizing = false; - resizeCount = 0; } }, RESIZE_DEBOUNCE); }); @@ -581,31 +578,57 @@ export default class TerminalComponent { try { // Check if terminal is installed before starting AXS - if (!(await Terminal.isInstalled())) { + const installed = await Terminal.isInstalled(); + if (!installed) { throw new Error( "Terminal not installed. Please install terminal first.", ); } // Start AXS if not running - if (!(await Terminal.isAxsRunning())) { + const axsRunning = await Terminal.isAxsRunning(); + + // Poll by hitting the actual HTTP endpoint, not just checking PID liveness. + // isAxsRunning() only does kill -0 on the PID file, which can return true + // while the HTTP server inside proot is still booting. + const pollAxs = async (maxRetries = 30, intervalMs = 1000) => { + for (let i = 0; i < maxRetries; i++) { + await new Promise((r) => setTimeout(r, intervalMs)); + try { + const resp = await fetch(`http://localhost:${this.options.port}/`, { method: 'GET', signal: AbortSignal.timeout(2000) }); + if (resp.ok || resp.status < 500) return true; + } catch (_) { + // HTTP not yet reachable + } + } + return false; + }; + + if (!axsRunning) { + // In debug builds, refresh axs binary from assets before starting + await Terminal.refreshAxsBinary(); await Terminal.startAxs(false, () => {}, console.error); - // Check if AXS started with interval polling - const maxRetries = 10; - let retries = 0; - while (retries < maxRetries) { - await new Promise((resolve) => setTimeout(resolve, 1000)); - if (await Terminal.isAxsRunning()) { - break; + // Wait for axs HTTP server to become reachable + const pollResult = await pollAxs(30); + if (!pollResult) { + // AXS failed to start — attempt auto-repair + toast("Repairing terminal environment..."); + + try { await Terminal.stopAxs(); } catch (_) { /* ignore */ } + + // Re-run installing flow to repair packages / config + const repairOk = await Terminal.startAxs(true, console.log, console.error); + if (repairOk) { + // Start AXS again after repair + await Terminal.startAxs(false, () => {}, console.error); } - retries++; - } - // If AXS still not running after retries, throw error - if (!(await Terminal.isAxsRunning())) { - toast("Failed to start AXS server after multiple attempts"); - //throw new Error("Failed to start AXS server after multiple attempts"); + if (!(await pollAxs(30))) { + // Still broken — clear .configured so next open re-triggers install + try { await Terminal.resetConfigured(); } catch (_) { /* ignore */ } + throw new Error("Failed to start AXS server after repair attempt"); + } } } @@ -630,6 +653,32 @@ export default class TerminalComponent { } const data = await response.text(); + + // Detect PTY errors from axs server (e.g. incompatible binary) + if (data.includes('"error"') && data.includes('Failed to open PTY')) { + const refreshed = await Terminal.refreshAxsBinary(); + if (refreshed) { + // Kill old axs, restart with fresh binary, and retry once + try { await Terminal.stopAxs(); } catch (_) {} + await Terminal.startAxs(false, () => {}, console.error); + const pollResult = await pollAxs(30); + if (pollResult) { + const retryResp = await fetch( + `http://localhost:${this.options.port}/terminals`, + { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) }, + ); + if (retryResp.ok) { + const retryData = await retryResp.text(); + if (!retryData.includes('"error"')) { + this.pid = retryData.trim(); + return this.pid; + } + } + } + } + throw new Error('Failed to open PTY even after refreshing AXS binary'); + } + this.pid = data.trim(); return this.pid; } catch (error) { @@ -663,6 +712,14 @@ export default class TerminalComponent { this.isConnected = true; this.onConnect?.(); + // Explicitly dispose old AttachAddon before replacing it. + // Reassigning this.attachAddon does NOT auto-clean listeners bound by the old instance, + // which can cause duplicate socket handlers and leaks after reconnects. + if (this.attachAddon) { + try { this.attachAddon.dispose(); } catch (_) {} + this.attachAddon = null; + } + // Load attach addon after connection this.attachAddon = new AttachAddon(this.websocket); this.terminal.loadAddon(this.attachAddon); @@ -689,13 +746,31 @@ export default class TerminalComponent { // For binary data or non-exit text messages, let attachAddon handle them }; + // Also sniff the data to detect critical Alpine container corruption (e.g. bash/readline broken) + this.websocket.addEventListener("message", async (event) => { + try { + let text = ""; + if (typeof event.data === "string") { + text = event.data; + } else if (event.data instanceof ArrayBuffer || event.data instanceof Blob) { + text = await new Response(event.data).text(); + } + + if (text.includes("Error relocating") && text.includes("symbol not found")) { + console.error("Detected critical Alpine libc corruption! Terminating and triggering reinstall."); + if (this.onCrashData) { + this.onCrashData("relocation_error"); + } + } + } catch (err) {} + }); + this.websocket.onclose = (event) => { this.isConnected = false; this.onDisconnect?.(); }; this.websocket.onerror = (error) => { - console.error("WebSocket error:", error); this.onError?.(error); }; } @@ -922,10 +997,21 @@ export default class TerminalComponent { * Load terminal font if it's not already loaded */ async loadTerminalFont() { - const fontFamily = this.options.fontFamily; + // Use original name without quotes for Acode fonts.get + const fontFamily = this.options.fontFamily.replace(/^"|"$/g, '').replace(/",\s*monospace$/, ''); if (fontFamily && fonts.get(fontFamily)) { try { await fonts.loadFont(fontFamily); + // Make Xterm.js aware that the font is fully loaded + // Setting options.fontFamily triggers a re-eval of character dimensions + if (this.terminal) { + this.terminal.options.fontFamily = `"${fontFamily}", monospace`; + if (this.webglAddon) { + try { this.webglAddon.clearTextureAtlas(); } catch (e) {} + } + // Ensure terminal dimensions are updated after font load changes char size + setTimeout(() => this.fit(), 100); + } } catch (error) { console.warn(`Failed to load terminal font ${fontFamily}:`, error); } @@ -998,8 +1084,8 @@ export default class TerminalComponent { method: "POST", }, ); - } catch (error) { - console.error("Failed to terminate terminal:", error); + } catch { + // Expected: terminal process may have already exited and acodex-server disconnected } } } diff --git a/src/components/terminal/terminalDefaults.js b/src/components/terminal/terminalDefaults.js index 4f42242e7..30bd8c101 100644 --- a/src/components/terminal/terminalDefaults.js +++ b/src/components/terminal/terminalDefaults.js @@ -25,8 +25,14 @@ export const DEFAULT_TERMINAL_SETTINGS = { export function getTerminalSettings() { const settings = appSettings.value.terminalSettings || {}; - return { + const merged = { ...DEFAULT_TERMINAL_SETTINGS, ...settings, }; + + let spacing = Number.parseFloat(merged.letterSpacing); + if (!Number.isFinite(spacing)) spacing = DEFAULT_TERMINAL_SETTINGS.letterSpacing; + merged.letterSpacing = Math.max(0, Math.min(2, spacing)); + + return merged; } diff --git a/src/components/terminal/terminalManager.js b/src/components/terminal/terminalManager.js index 8dc394948..29c173bdf 100644 --- a/src/components/terminal/terminalManager.js +++ b/src/components/terminal/terminalManager.js @@ -199,14 +199,6 @@ class TerminalManager { ? `Terminal ${terminalNumber}` : terminalName; - // Check if terminal is installed before proceeding - if (isServerMode) { - const installationResult = await this.checkAndInstallTerminal(); - if (!installationResult.success) { - throw new Error(installationResult.error); - } - } - // Create terminal component const terminalComponent = new TerminalComponent({ serverMode: isServerMode, @@ -244,6 +236,20 @@ class TerminalManager { // Mount terminal component terminalComponent.mount(terminalContainer); + if (terminalComponent.serverMode) { + // Run install check after mount so install logs can stream into this + // exact terminal tab (via progressTerminal.component), instead of + // opening a separate "Terminal Installation" tab. Keeping it inside + // this init try/catch also reuses the same cleanup path on failure + // (dispose component + remove broken tab). + const installationResult = await this.checkAndInstallTerminal(false, { + component: terminalComponent, + }); + if (!installationResult.success) { + throw new Error(installationResult.error); + } + } + // Connect to session if in server mode if (terminalComponent.serverMode) { await terminalComponent.connectToSession(terminalOptions.pid); @@ -323,13 +329,17 @@ class TerminalManager { /** * Check if terminal is installed and install if needed + * @param {boolean} [forceReinstall=false] - Whether to force reinstall even if already installed. + * Usually false for normal terminal creation. Set to true only for recovery flows + * (currently relocation_error auto-repair) to bypass the "already installed" + * early return and run a full reinstall. * @returns {Promise<{success: boolean, error?: string}>} */ - async checkAndInstallTerminal() { + async checkAndInstallTerminal(forceReinstall = false, progressTerminal = null) { try { // Check if terminal is already installed const isInstalled = await Terminal.isInstalled(); - if (isInstalled) { + if (isInstalled && !forceReinstall) { return { success: true }; } @@ -342,8 +352,11 @@ class TerminalManager { }; } - // Create installation progress terminal - const installTerminal = await this.createInstallationTerminal(); + // Create installation progress terminal (or reuse current one) + const installTerminal = progressTerminal || await this.createInstallationTerminal(); + if (progressTerminal?.component) { + installTerminal.component.write("\x1b[33mInstalling terminal environment...\x1b[0m\r\n"); + } // Install terminal with progress logging const installResult = await Terminal.install( @@ -503,25 +516,11 @@ class TerminalManager { // Handle tab focus/blur terminalFile.onfocus = () => { - // Guarded fit on focus: only fit if cols/rows would change, then focus - const run = () => { - try { - const pd = terminalComponent.fitAddon?.proposeDimensions?.(); - if ( - pd && - (pd.cols !== terminalComponent.terminal.cols || - pd.rows !== terminalComponent.terminal.rows) - ) { - terminalComponent.fitAddon.fit(); - } - } catch {} - terminalComponent.focus(); - }; - if (typeof requestAnimationFrame === "function") { - requestAnimationFrame(run); - } else { - setTimeout(run, 0); - } + // Do NOT forcefully call fit() here, as the DOM might still be animating + // or transitioning from display:none. + // terminalFile._resizeObserver (ResizeObserver) already handles fitting + // securely when the container's true dimensions are realized. + terminalComponent.focus(); }; // Handle tab close @@ -572,6 +571,11 @@ class TerminalManager { return; } + // Ignore resizes where the container is hidden or too small + if (width < 10 || height < 10) { + return; + } + // Only fit if actual size changed to reduce reflows if ( Math.abs(width - lastWidth) > 0.5 || @@ -663,6 +667,49 @@ class TerminalManager { toast(message); }; + // Auto-recovery for corrupted rootfs (in-place, no new tab or toast) + terminalComponent.onCrashData = async (reason) => { + if (reason === "relocation_error") { + try { + // Disconnect websocket to stop feeding garbage + if (terminalComponent.websocket) { + terminalComponent.websocket.close(); + } + terminalComponent.isConnected = false; + + // Write recovery status directly into the current terminal + terminalComponent.clear(); + terminalComponent.write("\x1b[33m⚠ Detected terminal environment corruption (libc/readline).\x1b[0m\r\n"); + terminalComponent.write("\x1b[33m Starting automatic repair...\x1b[0m\r\n\r\n"); + + // Uninstall corrupted rootfs + terminalComponent.write("Removing corrupted rootfs...\r\n"); + if (window.Terminal && typeof window.Terminal.uninstall === "function") { + await window.Terminal.uninstall(); + } + terminalComponent.write("Rootfs removed. Reinstalling...\r\n\r\n"); + + // Reinstall, routing all progress output to this terminal + const result = await this.checkAndInstallTerminal(true, { + component: terminalComponent, + }); + + if (result.success) { + terminalComponent.write("\r\n\x1b[32m✔ Recovery complete. Reconnecting session...\x1b[0m\r\n"); + // Clear the terminal buffer so the new shell prompt starts clean + terminalComponent.clear(); + // Reconnect a fresh session in the same terminal + await terminalComponent.connectToSession(); + } else { + terminalComponent.write(`\r\n\x1b[31m✘ Recovery failed: ${result.error}\x1b[0m\r\n`); + } + } catch (e) { + console.error("In-place terminal recovery failed:", e); + terminalComponent.write(`\r\n\x1b[31m✘ Recovery error: ${e.message}\x1b[0m\r\n`); + } + } + }; + // Handle acode CLI open commands (OSC 7777) terminalComponent.onOscOpen = async (type, path) => { if (!path) return; diff --git a/src/lib/auth.js b/src/lib/auth.js index c8911be88..4014aabe0 100644 --- a/src/lib/auth.js +++ b/src/lib/auth.js @@ -78,8 +78,7 @@ class AuthService { await this._exec("isLoggedIn"); return true; } catch (error) { - console.error(error); - // error is typically the status code (0 if no token, 401 if invalid) + // Not an error — native rejects when not logged in (0 = no token, 401 = expired) return false; } } diff --git a/src/plugins/system/android/com/foxdebug/system/System.java b/src/plugins/system/android/com/foxdebug/system/System.java index 69edbffb8..a2271e44e 100644 --- a/src/plugins/system/android/com/foxdebug/system/System.java +++ b/src/plugins/system/android/com/foxdebug/system/System.java @@ -384,6 +384,24 @@ public void run() { } return true; + case "copyAsset": { + String assetName = args.getString(0); + String destPath = args.getString(1); + try { + java.io.InputStream in = cordova.getActivity().getAssets().open(assetName); + java.io.FileOutputStream out = new java.io.FileOutputStream(destPath); + byte[] buf = new byte[65536]; + int len; + while ((len = in.read(buf)) != -1) out.write(buf, 0, len); + out.close(); + in.close(); + new File(destPath).setExecutable(true); + callbackContext.success(); + } catch (Exception e) { + callbackContext.error(e.getMessage()); + } + return true; + } default: return false; } diff --git a/src/plugins/system/www/plugin.js b/src/plugins/system/www/plugin.js index 01d77ecb3..00cea819f 100644 --- a/src/plugins/system/www/plugin.js +++ b/src/plugins/system/www/plugin.js @@ -27,6 +27,9 @@ module.exports = { setExec: function (path, executable, success, error) { cordova.exec(success, error, 'System', 'setExec', [path, String(executable)]); }, + copyAsset: function (assetName, destPath, success, error) { + cordova.exec(success, error, 'System', 'copyAsset', [assetName, destPath]); + }, getNativeLibraryPath: function (success, error) { @@ -135,32 +138,32 @@ module.exports = { onError: null, }; - cordova.exec(function (data) { - if (typeof data !== 'string') { - console.warn('System.inAppBrowser: invalid callback payload', data); - return; - } - var separatorIndex = data.indexOf(':'); - if (separatorIndex < 0) { - console.warn('System.inAppBrowser: malformed callback payload', data); - return; - } - var dataTag = data.slice(0, separatorIndex); - var dataUrl = data.slice(separatorIndex + 1); - if (dataTag === 'onOpenExternalBrowser') { - if (typeof myInAppBrowser.onOpenExternalBrowser === 'function') { - myInAppBrowser.onOpenExternalBrowser(dataUrl); - } else { - console.warn('System.inAppBrowser: onOpenExternalBrowser handler is not set'); - } - } - }, function (err) { - if (typeof myInAppBrowser.onError === 'function') { - myInAppBrowser.onError(err); - return; - } - console.warn('System.inAppBrowser error callback not handled', err); - }, 'System', 'in-app-browser', [url, title, !!showButtons, disableCache]); + cordova.exec(function (data) { + if (typeof data !== 'string') { + console.warn('System.inAppBrowser: invalid callback payload', data); + return; + } + var separatorIndex = data.indexOf(':'); + if (separatorIndex < 0) { + console.warn('System.inAppBrowser: malformed callback payload', data); + return; + } + var dataTag = data.slice(0, separatorIndex); + var dataUrl = data.slice(separatorIndex + 1); + if (dataTag === 'onOpenExternalBrowser') { + if (typeof myInAppBrowser.onOpenExternalBrowser === 'function') { + myInAppBrowser.onOpenExternalBrowser(dataUrl); + } else { + console.warn('System.inAppBrowser: onOpenExternalBrowser handler is not set'); + } + } + }, function (err) { + if (typeof myInAppBrowser.onError === 'function') { + myInAppBrowser.onError(err); + return; + } + console.warn('System.inAppBrowser error callback not handled', err); + }, 'System', 'in-app-browser', [url, title, !!showButtons, disableCache]); return myInAppBrowser; }, setUiTheme: function (systemBarColor, theme, onSuccess, onFail) { diff --git a/src/plugins/terminal/scripts/init-alpine.sh b/src/plugins/terminal/scripts/init-alpine.sh index 88c071fd1..53b45262e 100644 --- a/src/plugins/terminal/scripts/init-alpine.sh +++ b/src/plugins/terminal/scripts/init-alpine.sh @@ -1,26 +1,51 @@ export PATH=/bin:/sbin:/usr/bin:/usr/sbin:/usr/share/bin:/usr/share/sbin:/usr/local/bin:/usr/local/sbin:/system/bin:/system/xbin:$PREFIX/local/bin -export PS1="\[\e[38;5;46m\]\u\[\033[39m\]@localhost \[\033[39m\]\w \[\033[0m\]\\$ " export HOME=/home export TERM=xterm-256color -required_packages="bash command-not-found tzdata wget" +# ── Package check (runs every startup, file-stat only — negligible cost) ── +# Check by file existence rather than apk info (which is unreliable in proot) +is_apk_installed() { + local package_name="$1" + [ -f /lib/apk/db/installed ] && grep -q "^P:${package_name}$" /lib/apk/db/installed +} + missing_packages="" +[ ! -f /usr/bin/bash ] && [ ! -f /bin/bash ] && missing_packages="$missing_packages bash" +[ ! -f /usr/share/zoneinfo/UTC ] && missing_packages="$missing_packages tzdata" +[ ! -f /usr/bin/wget ] && missing_packages="$missing_packages wget" +! is_apk_installed command-not-found && missing_packages="$missing_packages command-not-found" -for pkg in $required_packages; do - if ! apk info -e "$pkg" >/dev/null 2>&1; then - missing_packages="$missing_packages $pkg" +if [ -n "$missing_packages" ]; then + echo -e "\e[34;1m[*] \e[0mInstalling packages:$missing_packages\e[0m" + + # In proot, post-install scripts may fail with error 127; + # manual fixup below compensates if needed. + apk update 2>/dev/null + + apk add $missing_packages 2>/dev/null + if [ $? -ne 0 ]; then + echo -e "\e[33;1m[!] \e[0mRetrying with mirror...\e[0m" + cp /etc/apk/repositories /etc/apk/repositories.bak + echo "https://mirrors.tuna.tsinghua.edu.cn/alpine/v3.21/main" > /etc/apk/repositories + echo "https://mirrors.tuna.tsinghua.edu.cn/alpine/v3.21/community" >> /etc/apk/repositories + apk update 2>/dev/null + apk add $missing_packages 2>/dev/null + mv /etc/apk/repositories.bak /etc/apk/repositories 2>/dev/null fi -done -if [ -n "$missing_packages" ]; then - echo -e "\e[34;1m[*] \e[0mInstalling important packages\e[0m" - apk update && apk upgrade - apk add $missing_packages - if [ $? -eq 0 ]; then - echo -e "\e[32;1m[+] \e[0mSuccessfully installed\e[0m" + # Post-install fixup: ensure bash is usable even if scripts failed + if [ -f /usr/bin/bash ] && [ ! -e /bin/bash ]; then + ln -sf /usr/bin/bash /bin/bash 2>/dev/null + fi + # Ensure /etc/shells has bash + if [ -f /usr/bin/bash ] && ! grep -q "/bin/bash" /etc/shells 2>/dev/null; then + echo "/bin/bash" >> /etc/shells 2>/dev/null fi - echo -e "\e[34m[*] \e[0mUse \e[32mapk\e[0m to install new packages\e[0m" + + # Verify + [ ! -f /usr/bin/bash ] && [ ! -f /bin/bash ] && echo -e "\e[31;1m[!] \e[0mbash still missing\e[0m" + [ ! -f /usr/bin/wget ] && echo -e "\e[31;1m[!] \e[0mwget still missing\e[0m" fi @@ -41,7 +66,9 @@ if [ "$1" = "--installing" ]; then echo "Failed to detect timezone" fi - mkdir -p "$PREFIX/.configured" + # .configured marker is created by JS layer (system.writeText) after proot exits. + # Do NOT create it here — proot bind-mount mkdir causes Java mkdirs() to fail + # because it sees the directory already exists. echo "Installation completed." exit 0 fi @@ -65,90 +92,9 @@ Working with packages: EOF fi - # Create acode CLI tool - if [ ! -e "$PREFIX/alpine/usr/local/bin/acode" ]; then - mkdir -p "$PREFIX/alpine/usr/local/bin" - cat <<'ACODE_CLI' > "$PREFIX/alpine/usr/local/bin/acode" -#!/bin/bash -# acode - Open files/folders in Acode editor -# Uses OSC escape sequences to communicate with the Acode terminal - -usage() { - echo "Usage: acode [file/folder...]" - echo "" - echo "Open files or folders in Acode editor." - echo "" - echo "Examples:" - echo " acode file.txt # Open a file" - echo " acode . # Open current folder" - echo " acode ~/project # Open a folder" - echo " acode -h, --help # Show this help" -} - -get_abs_path() { - local path="$1" - local abs_path="" - - if command -v realpath >/dev/null 2>&1; then - abs_path=$(realpath -- "$path" 2>/dev/null) - fi - - if [[ -z "$abs_path" ]]; then - if [[ -d "$path" ]]; then - abs_path=$(cd -- "$path" 2>/dev/null && pwd -P) - elif [[ -e "$path" ]]; then - local dir_name file_name - dir_name=$(dirname -- "$path") - file_name=$(basename -- "$path") - abs_path="$(cd -- "$dir_name" 2>/dev/null && pwd -P)/$file_name" - elif [[ "$path" == /* ]]; then - abs_path="$path" - else - abs_path="$PWD/$path" - fi - fi - - echo "$abs_path" -} - -open_in_acode() { - local path=$(get_abs_path "$1") - local type="file" - [[ -d "$path" ]] && type="folder" - - # Send OSC 7777 escape sequence: \e]7777;cmd;type;path\a - # The terminal component will intercept and handle this - printf '\e]7777;open;%s;%s\a' "$type" "$path" -} - -if [[ $# -eq 0 ]]; then - open_in_acode "." - exit 0 -fi - -for arg in "$@"; do - case "$arg" in - -h|--help) - usage - exit 0 - ;; - *) - if [[ -e "$arg" ]]; then - open_in_acode "$arg" - else - echo "Error: '$arg' does not exist" >&2 - exit 1 - fi - ;; - esac -done -ACODE_CLI - chmod +x "$PREFIX/alpine/usr/local/bin/acode" - fi - - # Create initrc if it doesn't exist + # Create/update initrc (always overwrite to keep in sync with app updates) + # Cost: ~3KB heredoc write per startup, sub-millisecond — negligible. #initrc runs in bash so we can use bash features -if [ ! -e "$PREFIX/alpine/initrc" ]; then cat <<'EOF' > "$PREFIX/alpine/initrc" # Source rc files if they exist @@ -195,7 +141,16 @@ _shorten_path() { [[ "$path" == /* ]] && echo "/$result" || echo "$result" } -PROMPT_COMMAND='_PS1_PATH=$(_shorten_path); _PS1_EXIT=$?' +_PS1_PATH="$(_shorten_path)" +_PS1_EXIT=0 + +_update_prompt_state() { + local last_exit=$? + _PS1_PATH="$(_shorten_path)" + _PS1_EXIT=$last_exit +} + +PROMPT_COMMAND='_update_prompt_state' # Source user configs AFTER defaults (so user can override PROMPT_COMMAND) if [ -f "$HOME/.bashrc" ]; then @@ -206,37 +161,95 @@ if [ -f /etc/bash/bashrc ]; then source /etc/bash/bashrc fi - -# Display MOTD if available +# Display MOTD (only source that reliably runs in proot bash) if [ -s /etc/acode_motd ]; then cat /etc/acode_motd fi -# Command-not-found handler +# Work around proot shebang execution failures for Bash's missing-command hook. +# Bash normally auto-executes /usr/libexec/command-not-found when a command is +# missing, but direct shebang execution can fail in proot with +# "bad interpreter: Bad address". Run the original script explicitly through +# /bin/sh so the script keeps working without relying on the broken shebang path. command_not_found_handle() { - cmd="$1" - pkg="" - green="\e[1;32m" - reset="\e[0m" - - pkg=$(apk search -x "cmd:$cmd" 2>/dev/null | awk -F'-[0-9]' '{print $1}' | head -n 1) - - if [ -n "$pkg" ]; then - echo -e "The program '$cmd' is not installed.\nInstall it by executing:\n ${green}apk add $pkg${reset}" >&2 - else - echo "The program '$cmd' is not installed and no package provides it." >&2 + if [ -x /usr/libexec/command-not-found ]; then + /bin/sh /usr/libexec/command-not-found "$@" + return $? fi + printf '%s: command not found\n' "$1" >&2 return 127 } +# acode CLI: defined as a bash function instead of a standalone script. +# In proot, a script with #!/bin/bash triggers execve("/bin/bash"), which the +# kernel handles in kernel-space. proot relies on ptrace to intercept execve and +# translate paths, but the kernel's shebang-triggered second execve can bypass +# proot's path translation (especially with --link2symlink or Android's ptrace +# restrictions), causing "bad interpreter: No such file or directory". +# A bash function runs in the current process — no execve, no shebang, no issue. +_acode_get_abs_path() { + local path="$1" abs_path="" + if command -v realpath >/dev/null 2>&1; then + abs_path=$(realpath -- "$path" 2>/dev/null) + fi + if [[ -z "$abs_path" ]]; then + if [[ -d "$path" ]]; then + abs_path=$(cd -- "$path" 2>/dev/null && pwd -P) + elif [[ -e "$path" ]]; then + abs_path="$(cd -- "$(dirname -- "$path")" 2>/dev/null && pwd -P)/$(basename -- "$path")" + elif [[ "$path" == /* ]]; then + abs_path="$path" + else + abs_path="$PWD/$path" + fi + fi + echo "$abs_path" +} +_acode_open() { + local path=$(_acode_get_abs_path "$1") + local type="file" + [[ -d "$path" ]] && type="folder" + printf '\e]7777;open;%s;%s\a' "$type" "$path" +} +acode() { + if [[ $# -eq 0 ]]; then + _acode_open "." + return 0 + fi + local arg + for arg in "$@"; do + case "$arg" in + -h|--help) + echo "Usage: acode [file/folder...]" + echo "" + echo "Open files or folders in Acode editor." + echo "" + echo "Examples:" + echo " acode file.txt # Open a file" + echo " acode . # Open current folder" + echo " acode ~/project # Open a folder" + echo " acode -h, --help # Show this help" + return 0 + ;; + *) + if [[ -e "$arg" ]]; then + _acode_open "$arg" + else + echo "Error: '$arg' does not exist" >&2 + return 1 + fi + ;; + esac + done +} + EOF -fi # Add PS1 only if not already present if ! grep -q 'PS1=' "$PREFIX/alpine/initrc"; then # Smart path shortening (fish-style: ~/p/s/components) - echo 'PS1="\[\033[1;32m\]\u\[\033[0m\]@localhost \[\033[1;34m\]\$_PS1_PATH\[\033[0m\] \[\$([ \$_PS1_EXIT -ne 0 ] && echo \"\033[31m\")\]\$\[\033[0m\] "' >> "$PREFIX/alpine/initrc" + echo 'PS1="\[\033[1;32m\]\u\[\033[0m\]@localhost \[\033[1;34m\]\$_PS1_PATH\[\033[0m\] \[\$([ "${_PS1_EXIT:-0}" -ne 0 ] && echo \"\033[31m\")\]\$\[\033[0m\] "' >> "$PREFIX/alpine/initrc" # Simple prompt (uncomment below and comment above if you prefer full paths) # echo 'PS1="\[\033[1;32m\]\u\[\033[0m\]@localhost \[\033[1;34m\]\w\[\033[0m\] \$ "' >> "$PREFIX/alpine/initrc" fi @@ -245,7 +258,10 @@ chmod +x "$PREFIX/alpine/initrc" #actual source #everytime a terminal is started initrc will run -"$PREFIX/axs" -c "bash --rcfile /initrc -i" +# Required for the WebView's HTTP probe and terminal requests to localhost:8767. +# Without this CORS allowance, fetch() fails with "TypeError: Failed to fetch" +# even though axs is already listening, which triggers false repair/reinstall loops. +"$PREFIX/axs" --allow-any-origin -c "bash --rcfile /initrc -i" else exec "$@" diff --git a/src/plugins/terminal/scripts/init-sandbox.sh b/src/plugins/terminal/scripts/init-sandbox.sh index 3b8a7c3d8..58a441d82 100644 --- a/src/plugins/terminal/scripts/init-sandbox.sh +++ b/src/plugins/terminal/scripts/init-sandbox.sh @@ -6,6 +6,14 @@ mkdir -p "$PREFIX/public" export PROOT_TMP_DIR=$PREFIX/tmp +# Disable seccomp filter in proot to avoid SIGSEGV/SIGBUS on kernels +# with strict seccomp policies. +# Impact: may slightly reduce syscall-level sandboxing on permissive kernels. +# Rationale: proot's seccomp filter is a performance optimization, not a security +# boundary; disabling it universally is the only way to prevent hard crashes on +# affected devices, with negligible downside on unaffected ones. +export PROOT_NO_SECCOMP=1 + if [ "$FDROID" = "true" ]; then if [ -f "$PREFIX/libproot.so" ]; then @@ -86,8 +94,12 @@ fi ARGS="$ARGS -r $PREFIX/alpine" ARGS="$ARGS -0" ARGS="$ARGS --link2symlink" -ARGS="$ARGS --sysvipc" +# --sysvipc removed: SysV IPC emulation causes Bus Error on some Android kernels. +# Impact: programs relying on SysV semaphores/shared-memory (e.g. PostgreSQL) +# will fail; most CLI tools are unaffected. +# Rationale: Bus Error is an unrecoverable crash that kills the entire proot +# session; the few programs needing SysV IPC are niche in a mobile editor +# context, whereas the crash affects all users on vulnerable kernels. ARGS="$ARGS -L" - $PROOT $ARGS /bin/sh $PREFIX/init-alpine.sh "$@" diff --git a/src/plugins/terminal/src/android/BackgroundExecutor.java b/src/plugins/terminal/src/android/BackgroundExecutor.java index 79e3f33ad..582bc1278 100644 --- a/src/plugins/terminal/src/android/BackgroundExecutor.java +++ b/src/plugins/terminal/src/android/BackgroundExecutor.java @@ -42,6 +42,11 @@ public boolean execute(String action, JSONArray args, CallbackContext callbackCo case "loadLibrary": loadLibrary(args.getString(0), callbackContext); return true; + case "download": + String downloadUrl = args.getString(0); + String downloadDest = args.getString(1); + cordova.getThreadPool().execute(() -> DownloadHelper.download(downloadUrl, downloadDest, callbackContext)); + return true; default: callbackContext.error("Unknown action: " + action); return false; @@ -161,4 +166,5 @@ private void cleanup(String pid) { processInputs.remove(pid); processCallbacks.remove(pid); } + } \ No newline at end of file diff --git a/src/plugins/terminal/src/android/DownloadHelper.java b/src/plugins/terminal/src/android/DownloadHelper.java new file mode 100644 index 000000000..e43c31311 --- /dev/null +++ b/src/plugins/terminal/src/android/DownloadHelper.java @@ -0,0 +1,70 @@ +package com.foxdebug.acode.rk.exec.terminal; + +import org.apache.cordova.*; +import org.json.*; + +class DownloadHelper { + + static void download(String url, String dst, CallbackContext callbackContext) { + try { + java.net.URL u = java.net.URI.create(url).toURL(); + java.net.HttpURLConnection conn = (java.net.HttpURLConnection) u.openConnection(); + conn.setInstanceFollowRedirects(true); + conn.setConnectTimeout(30000); + conn.setReadTimeout(60000); + conn.connect(); + int code = conn.getResponseCode(); + // Follow redirects across protocols (HTTP→HTTPS) + if (code == 301 || code == 302 || code == 303 || code == 307 || code == 308) { + String loc = conn.getHeaderField("Location"); + conn.disconnect(); + u = java.net.URI.create(loc).toURL(); + conn = (java.net.HttpURLConnection) u.openConnection(); + conn.setInstanceFollowRedirects(true); + conn.setConnectTimeout(30000); + conn.setReadTimeout(60000); + conn.connect(); + code = conn.getResponseCode(); + } + if (code != 200) { + conn.disconnect(); + callbackContext.error("HTTP " + code); + return; + } + long contentLength = conn.getContentLength(); + java.io.File dstFile = new java.io.File(dst); + byte[] buf = new byte[65536]; + long downloaded = 0; + long startTime = System.currentTimeMillis(); + long lastReportTime = 0; + try (java.io.InputStream is = conn.getInputStream(); + java.io.FileOutputStream fos = new java.io.FileOutputStream(dstFile)) { + int len; + while ((len = is.read(buf)) > 0) { + fos.write(buf, 0, len); + downloaded += len; + long now = System.currentTimeMillis(); + if (now - lastReportTime >= 500) { + lastReportTime = now; + long elapsed = now - startTime; + long speed = elapsed > 0 ? downloaded * 1000 / elapsed : 0; + long eta = (speed > 0 && contentLength > 0) ? (contentLength - downloaded) / speed : -1; + JSONObject progress = new JSONObject(); + progress.put("type", "progress"); + progress.put("downloaded", downloaded); + progress.put("total", contentLength); + progress.put("speed", speed); + progress.put("eta", eta); + PluginResult pr = new PluginResult(PluginResult.Status.OK, progress.toString()); + pr.setKeepCallback(true); + callbackContext.sendPluginResult(pr); + } + } + } + conn.disconnect(); + callbackContext.success(dst); + } catch (Exception e) { + callbackContext.error("download failed: " + e.getMessage()); + } + } +} diff --git a/src/plugins/terminal/src/android/Executor.java b/src/plugins/terminal/src/android/Executor.java index 5d0e90777..f6409ea30 100644 --- a/src/plugins/terminal/src/android/Executor.java +++ b/src/plugins/terminal/src/android/Executor.java @@ -284,6 +284,11 @@ public boolean execute(String action, JSONArray args, CallbackContext callbackCo callbackContextMap.put(pidCheck, callbackContext); isProcessRunning(pidCheck); return true; + case "download": + String downloadUrl = args.getString(0); + String downloadDest = args.getString(1); + cordova.getThreadPool().execute(() -> DownloadHelper.download(downloadUrl, downloadDest, callbackContext)); + return true; default: callbackContext.error("Unknown action: " + action); return false; diff --git a/src/plugins/terminal/www/Executor.js b/src/plugins/terminal/www/Executor.js index 4cb6c970c..1b30bd591 100644 --- a/src/plugins/terminal/www/Executor.js +++ b/src/plugins/terminal/www/Executor.js @@ -192,6 +192,29 @@ class Executor { exec(resolve, reject, this.ExecutorType, "loadLibrary", [path]); }); } + + download(url, dst, onProgress) { + return new Promise((resolve, reject) => { + exec( + (msg) => { + if (onProgress && typeof msg === "string") { + try { + const data = JSON.parse(msg); + if (data.type === "progress") { + onProgress(data); + return; + } + } catch (_) {} + } + resolve(msg); + }, + reject, + this.ExecutorType, + "download", + [url, dst] + ); + }); + } } //backward compatibility diff --git a/src/plugins/terminal/www/Terminal.js b/src/plugins/terminal/www/Terminal.js index d63c29c16..387b2113a 100644 --- a/src/plugins/terminal/www/Terminal.js +++ b/src/plugins/terminal/www/Terminal.js @@ -1,6 +1,25 @@ const Executor = require("./Executor"); const Terminal = { + /** + * In debug builds, overwrite the axs binary from bundled assets to ensure + * the running version always matches the build. Returns true if replaced. + */ + async refreshAxsBinary() { + if (typeof BuildInfo === 'undefined' || !BuildInfo.debug) return false; + const filesDir = await new Promise((resolve, reject) => { + system.getFilesDir(resolve, reject); + }); + try { + await new Promise((resolve, reject) => { + system.copyAsset("axs", `${filesDir}/axs`, resolve, reject); + }); + return true; + } catch (e) { + return false; + } + }, + /** * Starts the AXS environment by writing init scripts and executing the sandbox. * @param {boolean} [installing=false] - Whether AXS is being started during installation. @@ -9,6 +28,9 @@ const Terminal = { * @returns {Promise} - Returns true if installation completes with exit code 0, void if not installing */ async startAxs(installing = false, logger = console.log, err_logger = console.error) { + // Keep app alive in background + await Executor.moveToForeground().catch(() => {}); + const filesDir = await new Promise((resolve, reject) => { system.getFilesDir(resolve, reject); }); @@ -33,7 +55,22 @@ const Terminal = { // Check for exit code during installation if (type === "exit") { - resolve(data === "0"); + const success = data === "0"; + if (success) { + // Delete old .configured if it's a directory (from earlier proot mkdir -p). + // Then create it as a file via writeText (idempotent). + const writeMarker = () => { + system.writeText(`${filesDir}/.configured`, "1", () => { + resolve(true); + }, (err) => { + resolve(true); // still consider install OK + }); + }; + // Try to remove old directory first, ignore errors + Executor.execute(`rm -rf "${filesDir}/.configured"`).then(writeMarker).catch(writeMarker); + } else { + resolve(false); + } } }).then(async (uuid) => { await Executor.write(uuid, `source ${filesDir}/init-sandbox.sh ${installing ? "--installing" : ""}; exit`); @@ -60,7 +97,11 @@ const Terminal = { Executor.start("sh", (type, data) => { logger(`${type} ${data}`); }).then(async (uuid) => { - await Executor.write(uuid, `source ${filesDir}/init-sandbox.sh ${installing ? "--installing" : ""}; exit`); + // Normal startup path: do not pass --installing. + // This avoids entering install-time setup when just launching AXS. + await Executor.write(uuid, `source ${filesDir}/init-sandbox.sh; exit`); + }).catch((error) => { + err_logger("Failed to start AXS:", error); }); }); } @@ -71,7 +112,7 @@ const Terminal = { * @returns {Promise} */ async stopAxs() { - await Executor.execute(`kill -KILL $(cat $PREFIX/pid)`); + await Executor.execute(`kill -KILL $(cat $PREFIX/pid) 2>/dev/null`); }, /** @@ -98,19 +139,18 @@ const Terminal = { /** * Installs Alpine by downloading binaries and extracting the root filesystem. * Also sets up additional dependencies for F-Droid variant. + * Supports incremental install: skips already-completed steps based on + * marker files (.downloaded, .extracted, .configured) and existing binaries. * @param {Function} [logger=console.log] - Function to log standard output. * @param {Function} [err_logger=console.error] - Function to log errors. * @returns {Promise} - Returns true if installation completes with exit code 0 */ - async install(logger = console.log, err_logger = console.error) { + async install(logger = console.log, err_logger = console.error, _retried = false) { if (!(await this.isSupported())) return false; - try { - //cleanup before insatll - await this.uninstall(); - } catch (e) { - //supress error - } + // Start foreground service to prevent Android from killing the app + // during lengthy downloads/extraction when user switches away + await Executor.moveToForeground().catch(() => {}); const filesDir = await new Promise((resolve, reject) => { system.getFilesDir(resolve, reject); @@ -120,6 +160,27 @@ const Terminal = { system.getArch(resolve, reject); }); + // Helper: check if a file exists + const fileExists = (path) => new Promise((resolve) => { + system.fileExists(path, false, (result) => resolve(result == 1), () => resolve(false)); + }); + + const formatBytes = (bytes) => { + if (bytes < 1024) return bytes + " B"; + if (bytes < 1048576) return (bytes / 1024).toFixed(1) + " KB"; + return (bytes / 1048576).toFixed(1) + " MB"; + }; + const formatEta = (seconds) => { + if (seconds < 60) return seconds + "s"; + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return m + "m" + (s > 0 ? s + "s" : ""); + }; + + // Check which stages are already done + let alreadyDownloaded = await fileExists(`${filesDir}/.downloaded`); + const alreadyExtracted = await fileExists(`${filesDir}/.extracted`); + try { let alpineUrl; let axsUrl; @@ -152,106 +213,149 @@ const Terminal = { throw new Error(`Unsupported architecture: ${arch}`); } + // Invalidate download cache if URLs changed (e.g. version bump) + if (alreadyDownloaded) { + const currentManifest = [alpineUrl, axsUrl].join("\n"); + const savedManifest = await Executor.execute(`cat "${filesDir}/.download-manifest" 2>/dev/null || echo ""`); + if (savedManifest !== currentManifest) { + logger("🔄 Update detected, clearing download cache..."); + await Executor.execute(`rm -rf "${filesDir}/.downloaded" "${filesDir}/.extracted" "${filesDir}/alpine" "${filesDir}/alpine.tar.gz" "${filesDir}/axs" "${filesDir}/.download-manifest"`).catch(() => {}); + alreadyDownloaded = false; + } + } - logger("⬇️ Downloading sandbox filesystem..."); - await new Promise((resolve, reject) => { - cordova.plugin.http.downloadFile( - alpineUrl, {}, {}, - cordova.file.dataDirectory + "alpine.tar.gz", - resolve, reject - ); - }); - - logger("⬇️ Downloading axs..."); - await new Promise((resolve, reject) => { - cordova.plugin.http.downloadFile( - axsUrl, {}, {}, - cordova.file.dataDirectory + "axs", - resolve, reject - ); - }); - - const isFdroid = await Executor.execute("echo $FDROID"); - if (isFdroid === "true") { - logger("🐧 F-Droid flavor detected, downloading additional files..."); - logger("⬇️ Downloading compatibility layer..."); - await new Promise((resolve, reject) => { - cordova.plugin.http.downloadFile( - prootUrl, {}, {}, - cordova.file.dataDirectory + "libproot-xed.so", - resolve, reject - ); - }); - - logger("⬇️ Downloading supporting library..."); - await new Promise((resolve, reject) => { - cordova.plugin.http.downloadFile( - libTalloc, {}, {}, - cordova.file.dataDirectory + "libtalloc.so.2", - resolve, reject - ); - }); - - if (libproot != null) { - await new Promise((resolve, reject) => { - cordova.plugin.http.downloadFile( - libproot, {}, {}, - cordova.file.dataDirectory + "libproot.so", - resolve, reject - ); + // ── Phase 1: Download (skip if .downloaded marker exists) ── + if (!alreadyDownloaded) { + // Check individual files and only download what's missing + const hasAlpineTar = await fileExists(`${filesDir}/alpine.tar.gz`); + const hasAxs = await fileExists(`${filesDir}/axs`); + + if (!hasAlpineTar) { + logger("⬇️ Downloading sandbox filesystem..."); + await Executor.download(alpineUrl, `${filesDir}/alpine.tar.gz`, (p) => { + const dl = formatBytes(p.downloaded); + const total = p.total > 0 ? formatBytes(p.total) : "?"; + const speed = formatBytes(p.speed) + "/s"; + const eta = p.eta > 0 ? formatEta(p.eta) : "--"; + logger(`⬇️ ${dl} / ${total} ${speed} ETA ${eta}`); }); + } else { + logger("✅ Sandbox filesystem already downloaded"); } - if (libproot32 != null) { - await new Promise((resolve, reject) => { - cordova.plugin.http.downloadFile( - libproot32, {}, {}, - cordova.file.dataDirectory + "libproot32.so", - resolve, reject - ); - }); + if (!hasAxs) { + let copiedFromAsset = false; + // Only use bundled axs in debug builds; release builds always download latest + if (typeof BuildInfo !== 'undefined' && BuildInfo.debug) { + try { + logger("📦 Copying bundled axs from assets..."); + await new Promise((resolve, reject) => { + system.copyAsset("axs", `${filesDir}/axs`, resolve, reject); + }); + copiedFromAsset = true; + logger("✅ Bundled AXS copied from assets"); + } catch (assetError) { + logger("⚠️ Asset copy failed, will download instead"); + } + } + + if (!copiedFromAsset) { + logger("⬇️ Downloading axs..."); + await Executor.download(axsUrl, `${filesDir}/axs`, (p) => { + const dl = formatBytes(p.downloaded); + const total = p.total > 0 ? formatBytes(p.total) : "?"; + const speed = formatBytes(p.speed) + "/s"; + const eta = p.eta > 0 ? formatEta(p.eta) : "--"; + logger(`⬇️ ${dl} / ${total} ${speed} ETA ${eta}`); + }); + } + } else { + logger("✅ AXS binary already downloaded"); } - } + const isFdroid = await Executor.execute("echo $FDROID"); + if (isFdroid === "true") { + logger("🐧 F-Droid flavor detected, checking additional files..."); + + const hasProot = await fileExists(`${filesDir}/libproot-xed.so`); + if (!hasProot) { + logger("⬇️ Downloading compatibility layer..."); + await Executor.download(prootUrl, `${filesDir}/libproot-xed.so`); + } + + const hasTalloc = await fileExists(`${filesDir}/libtalloc.so.2`); + if (!hasTalloc) { + logger("⬇️ Downloading supporting library..."); + await Executor.download(libTalloc, `${filesDir}/libtalloc.so.2`); + } + + if (libproot != null && !(await fileExists(`${filesDir}/libproot.so`))) { + await Executor.download(libproot, `${filesDir}/libproot.so`); + } + + if (libproot32 != null && !(await fileExists(`${filesDir}/libproot32.so`))) { + await Executor.download(libproot32, `${filesDir}/libproot32.so`); + } + } - logger("✅ All downloads completed"); + logger("✅ All downloads completed"); - logger("📁 Setting up directories..."); + // Save URL manifest for cache invalidation on version change + system.writeText(`${filesDir}/.download-manifest`, [alpineUrl, axsUrl].join("\n")); - await new Promise((resolve, reject) => { - system.mkdirs(`${filesDir}/.downloaded`, resolve, reject); - }); + logger("📁 Setting up directories..."); + await new Promise((resolve, reject) => { + system.mkdirs(`${filesDir}/.downloaded`, resolve, reject); + }); + } else { + logger("✅ Downloads cached, skipping download phase"); + } - const alpineDir = `${filesDir}/alpine`; + // ── Phase 2: Extract (skip if .extracted marker exists) ── + if (!alreadyExtracted) { + const alpineDir = `${filesDir}/alpine`; - await new Promise((resolve, reject) => { - system.mkdirs(alpineDir, resolve, reject); - }); + // Clean up partial extraction from previous failed attempt + await Executor.execute(`rm -rf ${alpineDir}`).catch(() => {}); + await new Promise((resolve, reject) => { + system.mkdirs(alpineDir, resolve, reject); + }); - logger("📦 Extracting sandbox filesystem..."); - await Executor.execute(`tar --no-same-owner -xf ${filesDir}/alpine.tar.gz -C ${alpineDir}`); + logger("📦 Extracting sandbox filesystem..."); + await Executor.execute(`tar --no-same-owner -xf ${filesDir}/alpine.tar.gz -C ${alpineDir}`); - logger("⚙️ Applying basic configuration..."); - system.writeText(`${alpineDir}/etc/resolv.conf`, `nameserver 8.8.4.4 \nnameserver 8.8.8.8`); + logger("⚙️ Applying basic configuration..."); + system.writeText(`${alpineDir}/etc/resolv.conf`, `nameserver 8.8.4.4\nnameserver 8.8.8.8`); - readAsset("rm-wrapper.sh", async (content) => { - system.deleteFile(`${alpineDir}/bin/rm`, logger, err_logger); - system.writeText(`${alpineDir}/bin/rm`, content, logger, err_logger); - system.setExec(`${alpineDir}/bin/rm`, true, logger, err_logger); - }); + readAsset("rm-wrapper.sh", async (content) => { + system.deleteFile(`${alpineDir}/bin/rm`, logger, err_logger); + system.writeText(`${alpineDir}/bin/rm`, content, logger, err_logger); + system.setExec(`${alpineDir}/bin/rm`, true, logger, err_logger); + }); - logger("✅ Extraction complete"); - await new Promise((resolve, reject) => { - system.mkdirs(`${filesDir}/.extracted`, resolve, reject); - }); + logger("✅ Extraction complete"); + await new Promise((resolve, reject) => { + system.mkdirs(`${filesDir}/.extracted`, resolve, reject); + }); + } else { + logger("✅ Extraction cached, skipping extraction phase"); + } + // ── Phase 3: Configure (always run — installs packages, creates configs) ── logger("⚙️ Updating sandbox enviroment..."); const installResult = await this.startAxs(true, logger, err_logger); + // .configured marker is now created inside startAxs(true) via system.writeText return installResult; } catch (e) { err_logger("Installation failed:", e); console.error("Installation failed:", e); + // Clean up everything so retry starts fresh (including potentially corrupted downloads) + await Executor.execute(`rm -rf ${filesDir}/.downloaded ${filesDir}/.extracted ${filesDir}/.configured ${filesDir}/alpine ${filesDir}/alpine.tar.gz ${filesDir}/alpine.tar ${filesDir}/.download-manifest`).catch(() => {}); + if (!_retried) { + logger("🔄 Retrying installation from scratch..."); + return this.install(logger, err_logger, true); + } return false; } }, @@ -399,22 +503,39 @@ const Terminal = { } }); }, + /** + * Removes the .configured marker so the next terminal open triggers re-install. + * Does NOT delete the rootfs or downloaded files — only the config flag. + * @returns {Promise} - `true` if marker is removed, `false` otherwise. + */ + async resetConfigured() { + const filesDir = await new Promise((resolve, reject) => { + system.getFilesDir(resolve, reject); + }); + + try { + await Executor.execute(`rm -rf "$PREFIX/.configured" "${filesDir}/.configured"`); + } catch (error) { + // continue to existence check below + } + + const stillExists = await new Promise((resolve, reject) => { + system.fileExists(`${filesDir}/.configured`, false, (result) => { + resolve(result == 1); + }, reject); + }); + + return !stillExists; + }, + /** * Uninstalls the Alpine Linux installation * @async * @function uninstall - * @description Completely removes the Alpine Linux installation from the device by deleting all - * Alpine-related files and directories. This function stops any running Alpine processes before - * removal. NOTE: This does not perform cleanup of $PREFIX + * @description Removes the Alpine Linux rootfs and config markers, but preserves + * downloaded binaries (alpine.tar.gz, axs) as cache for faster re-install. * @returns {Promise} Promise that resolves to "ok" when uninstallation completes successfully * @throws {string} Rejects with command output if uninstallation fails - * @example - * try { - * await uninstall(); - * console.log("Alpine installation removed successfully"); - * } catch (error) { - * console.error(`Uninstall failed: ${error}`); - * } */ uninstall() { return new Promise(async (resolve, reject) => { @@ -422,19 +543,45 @@ const Terminal = { await this.stopAxs(); } + // Remove rootfs and markers, but keep downloaded files as cache + // (alpine.tar.gz, axs binary, libproot*.so, libtalloc.so.2) const cmd = ` set -e - INCLUDE_FILES="$PREFIX/alpine $PREFIX/.downloaded $PREFIX/.extracted $PREFIX/axs" - - if [ "$FDROID" = "true" ]; then - INCLUDE_FILES="$INCLUDE_FILES $PREFIX/libtalloc.so.2 $PREFIX/libproot-xed.so" - fi + INCLUDE_FILES="$PREFIX/alpine $PREFIX/.downloaded $PREFIX/.extracted $PREFIX/.configured" for item in $INCLUDE_FILES; do rm -rf -- "$item" done + echo "ok" + `; + const result = await Executor.execute(cmd); + if (result === "ok") { + resolve(result); + } else { + reject(result); + } + }); + }, + + /** + * Fully uninstalls Alpine including download cache. + * @returns {Promise} Resolves to "ok" when complete. + */ + uninstallFull() { + return new Promise(async (resolve, reject) => { + if (await this.isAxsRunning()) { + await this.stopAxs(); + } + + const filesDir = await new Promise((resolve, reject) => { + system.getFilesDir(resolve, reject); + }); + + const cmd = ` + set -e + rm -rf "${filesDir}/alpine" "${filesDir}/.downloaded" "${filesDir}/.extracted" "${filesDir}/.configured" "${filesDir}/alpine.tar.gz" "${filesDir}/alpine.tar" "${filesDir}/axs" "${filesDir}/libproot-xed.so" "${filesDir}/libtalloc.so.2" "${filesDir}/libproot.so" "${filesDir}/libproot32.so" "${filesDir}/.download-manifest" echo "ok" `; const result = await Executor.execute(cmd); diff --git a/src/settings/terminalSettings.js b/src/settings/terminalSettings.js index 8821658c9..9eaf1b5cc 100644 --- a/src/settings/terminalSettings.js +++ b/src/settings/terminalSettings.js @@ -146,6 +146,12 @@ export default function terminalSettings() { value: terminalValues.letterSpacing, prompt: strings["letter spacing"], promptType: "number", + promptOptions: { + test(value) { + value = Number.parseFloat(value); + return Number.isFinite(value) && value >= 0 && value <= 2; + }, + }, info: strings["info-letterSpacing"], }, { @@ -227,8 +233,12 @@ export default function terminalSettings() { "Are you sure you want to uninstall the terminal?", ); if (confirmation) { + const deleteCache = await confirm( + strings.confirm, + "Also delete download cache? Keeping it makes re-install faster.", + ); loader.showTitleLoader(); - Terminal.uninstall() + (deleteCache ? Terminal.uninstallFull() : Terminal.uninstall()) .then(() => { loader.removeTitleLoader(); alert( @@ -245,6 +255,12 @@ export default function terminalSettings() { return; default: + if (key === "letterSpacing") { + value = Number.parseFloat(value); + if (!Number.isFinite(value)) value = DEFAULT_TERMINAL_SETTINGS.letterSpacing; + value = Math.max(0, Math.min(2, value)); + } + appSettings.update({ terminalSettings: { ...values.terminalSettings, @@ -354,11 +370,16 @@ async function updateActiveTerminals(key, value) { } catch (error) { console.warn(`Failed to load font ${value}:`, error); } - tab.terminalComponent.terminal.options.fontFamily = value; + tab.terminalComponent.options.fontFamily = `"${value}", monospace`; + tab.terminalComponent.terminal.options.fontFamily = `"${value}", monospace`; + if (tab.terminalComponent.webglAddon) { + try { tab.terminalComponent.webglAddon.clearTextureAtlas(); } catch (e) {} + } tab.terminalComponent.terminal.refresh( 0, tab.terminalComponent.terminal.rows - 1, ); + setTimeout(() => tab.terminalComponent.fit(), 100); break; case "fontWeight": tab.terminalComponent.terminal.options.fontWeight = value; @@ -382,7 +403,12 @@ async function updateActiveTerminals(key, value) { tab.terminalComponent.terminal.options.convertEol = value; break; case "letterSpacing": - tab.terminalComponent.terminal.options.letterSpacing = value; + { + let spacing = Number.parseFloat(value); + if (!Number.isFinite(spacing)) spacing = DEFAULT_TERMINAL_SETTINGS.letterSpacing; + spacing = Math.max(0, Math.min(2, spacing)); + tab.terminalComponent.terminal.options.letterSpacing = spacing; + } break; case "theme": tab.terminalComponent.terminal.options.theme = From 778c3e3c39c3204ab6bd5349234ff5e20c0b3781 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=9F=83=E5=8D=9A=E6=8B=89=E9=85=B1?= Date: Sun, 8 Mar 2026 10:28:33 +0800 Subject: [PATCH 02/10] fix(terminal): address review follow-ups for install and recovery Tighten terminal startup probing and relocation-error sniffing so review feedback does not regress startup reliability on older WebView builds. Harden Android-side download and asset copy helpers with safer redirect handling, proper resource cleanup, and threaded asset extraction. Quote shell paths consistently, refine bash availability detection in init-alpine.sh, and keep the allow-any-origin choice documented until axs exposes a real origin allowlist. --- src/components/terminal/terminal.js | 123 +++++++++++++++--- src/components/terminal/terminalDefaults.js | 3 +- src/components/terminal/terminalManager.js | 48 +++++-- .../android/com/foxdebug/system/System.java | 53 +++++--- src/plugins/terminal/scripts/init-alpine.sh | 23 +++- .../terminal/src/android/DownloadHelper.java | 74 ++++++++--- src/plugins/terminal/www/Terminal.js | 10 +- src/settings/terminalSettings.js | 10 +- 8 files changed, 268 insertions(+), 76 deletions(-) diff --git a/src/components/terminal/terminal.js b/src/components/terminal/terminal.js index 7935b9ebf..01971e710 100644 --- a/src/components/terminal/terminal.js +++ b/src/components/terminal/terminal.js @@ -591,11 +591,43 @@ export default class TerminalComponent { // Poll by hitting the actual HTTP endpoint, not just checking PID liveness. // isAxsRunning() only does kill -0 on the PID file, which can return true // while the HTTP server inside proot is still booting. + const fetchWithTimeout = async (url, options = {}, timeoutMs = 2000) => { + const hasAbortSignalTimeout = + typeof AbortSignal !== "undefined" && + typeof AbortSignal.timeout === "function"; + + if (hasAbortSignalTimeout) { + return fetch(url, { + ...options, + signal: AbortSignal.timeout(timeoutMs), + }); + } + + if (typeof AbortController === "undefined") { + return fetch(url, options); + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { + ...options, + signal: controller.signal, + }); + } finally { + clearTimeout(timeoutId); + } + }; + const pollAxs = async (maxRetries = 30, intervalMs = 1000) => { for (let i = 0; i < maxRetries; i++) { await new Promise((r) => setTimeout(r, intervalMs)); try { - const resp = await fetch(`http://localhost:${this.options.port}/`, { method: 'GET', signal: AbortSignal.timeout(2000) }); + const resp = await fetchWithTimeout( + `http://localhost:${this.options.port}/`, + { method: "GET" }, + 2000, + ); if (resp.ok || resp.status < 500) return true; } catch (_) { // HTTP not yet reachable @@ -615,10 +647,18 @@ export default class TerminalComponent { // AXS failed to start — attempt auto-repair toast("Repairing terminal environment..."); - try { await Terminal.stopAxs(); } catch (_) { /* ignore */ } + try { + await Terminal.stopAxs(); + } catch (_) { + /* ignore */ + } // Re-run installing flow to repair packages / config - const repairOk = await Terminal.startAxs(true, console.log, console.error); + const repairOk = await Terminal.startAxs( + true, + console.log, + console.error, + ); if (repairOk) { // Start AXS again after repair await Terminal.startAxs(false, () => {}, console.error); @@ -626,7 +666,11 @@ export default class TerminalComponent { if (!(await pollAxs(30))) { // Still broken — clear .configured so next open re-triggers install - try { await Terminal.resetConfigured(); } catch (_) { /* ignore */ } + try { + await Terminal.resetConfigured(); + } catch (_) { + /* ignore */ + } throw new Error("Failed to start AXS server after repair attempt"); } } @@ -655,17 +699,23 @@ export default class TerminalComponent { const data = await response.text(); // Detect PTY errors from axs server (e.g. incompatible binary) - if (data.includes('"error"') && data.includes('Failed to open PTY')) { + if (data.includes('"error"') && data.includes("Failed to open PTY")) { const refreshed = await Terminal.refreshAxsBinary(); if (refreshed) { // Kill old axs, restart with fresh binary, and retry once - try { await Terminal.stopAxs(); } catch (_) {} + try { + await Terminal.stopAxs(); + } catch (_) {} await Terminal.startAxs(false, () => {}, console.error); const pollResult = await pollAxs(30); if (pollResult) { const retryResp = await fetch( `http://localhost:${this.options.port}/terminals`, - { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) }, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(requestBody), + }, ); if (retryResp.ok) { const retryData = await retryResp.text(); @@ -676,7 +726,7 @@ export default class TerminalComponent { } } } - throw new Error('Failed to open PTY even after refreshing AXS binary'); + throw new Error("Failed to open PTY even after refreshing AXS binary"); } this.pid = data.trim(); @@ -703,6 +753,11 @@ export default class TerminalComponent { } this.pid = pid; + this._relocationSniffDisabled = false; + clearTimeout(this._relocationSniffTimer); + this._relocationSniffTimer = setTimeout(() => { + this._relocationSniffDisabled = true; + }, 15000); const wsUrl = `ws://localhost:${this.options.port}/terminals/${pid}`; @@ -716,7 +771,9 @@ export default class TerminalComponent { // Reassigning this.attachAddon does NOT auto-clean listeners bound by the old instance, // which can cause duplicate socket handlers and leaks after reconnects. if (this.attachAddon) { - try { this.attachAddon.dispose(); } catch (_) {} + try { + this.attachAddon.dispose(); + } catch (_) {} this.attachAddon = null; } @@ -748,19 +805,44 @@ export default class TerminalComponent { // Also sniff the data to detect critical Alpine container corruption (e.g. bash/readline broken) this.websocket.addEventListener("message", async (event) => { + if (this._relocationSniffDisabled) { + return; + } + + const MAX_SNIFF_BYTES = 4096; + try { let text = ""; if (typeof event.data === "string") { - text = event.data; - } else if (event.data instanceof ArrayBuffer || event.data instanceof Blob) { - text = await new Response(event.data).text(); + text = event.data.slice(0, MAX_SNIFF_BYTES); + } else if (event.data instanceof ArrayBuffer) { + const byteLength = Math.min(event.data.byteLength, MAX_SNIFF_BYTES); + const view = new Uint8Array(event.data, 0, byteLength); + text = new TextDecoder("utf-8", { fatal: false }).decode(view); + } else if (event.data instanceof Blob) { + const slice = + event.data.size > MAX_SNIFF_BYTES + ? event.data.slice(0, MAX_SNIFF_BYTES) + : event.data; + text = await new Response(slice).text(); + } + + if (!text) { + return; } - - if (text.includes("Error relocating") && text.includes("symbol not found")) { - console.error("Detected critical Alpine libc corruption! Terminating and triggering reinstall."); + + if ( + text.includes("Error relocating") && + text.includes("symbol not found") + ) { + console.error( + "Detected critical Alpine libc corruption! Terminating and triggering reinstall.", + ); if (this.onCrashData) { this.onCrashData("relocation_error"); } + this._relocationSniffDisabled = true; + clearTimeout(this._relocationSniffTimer); } } catch (err) {} }); @@ -998,7 +1080,9 @@ export default class TerminalComponent { */ async loadTerminalFont() { // Use original name without quotes for Acode fonts.get - const fontFamily = this.options.fontFamily.replace(/^"|"$/g, '').replace(/",\s*monospace$/, ''); + const fontFamily = this.options.fontFamily + .replace(/^"|"$/g, "") + .replace(/",\s*monospace$/, ""); if (fontFamily && fonts.get(fontFamily)) { try { await fonts.loadFont(fontFamily); @@ -1007,7 +1091,9 @@ export default class TerminalComponent { if (this.terminal) { this.terminal.options.fontFamily = `"${fontFamily}", monospace`; if (this.webglAddon) { - try { this.webglAddon.clearTextureAtlas(); } catch (e) {} + try { + this.webglAddon.clearTextureAtlas(); + } catch (e) {} } // Ensure terminal dimensions are updated after font load changes char size setTimeout(() => this.fit(), 100); @@ -1072,6 +1158,9 @@ export default class TerminalComponent { * Terminate terminal session */ async terminate() { + clearTimeout(this._relocationSniffTimer); + this._relocationSniffDisabled = true; + if (this.websocket) { this.websocket.close(); } diff --git a/src/components/terminal/terminalDefaults.js b/src/components/terminal/terminalDefaults.js index 30bd8c101..b16f53fbe 100644 --- a/src/components/terminal/terminalDefaults.js +++ b/src/components/terminal/terminalDefaults.js @@ -31,7 +31,8 @@ export function getTerminalSettings() { }; let spacing = Number.parseFloat(merged.letterSpacing); - if (!Number.isFinite(spacing)) spacing = DEFAULT_TERMINAL_SETTINGS.letterSpacing; + if (!Number.isFinite(spacing)) + spacing = DEFAULT_TERMINAL_SETTINGS.letterSpacing; merged.letterSpacing = Math.max(0, Math.min(2, spacing)); return merged; diff --git a/src/components/terminal/terminalManager.js b/src/components/terminal/terminalManager.js index 29c173bdf..c60913583 100644 --- a/src/components/terminal/terminalManager.js +++ b/src/components/terminal/terminalManager.js @@ -242,9 +242,12 @@ class TerminalManager { // opening a separate "Terminal Installation" tab. Keeping it inside // this init try/catch also reuses the same cleanup path on failure // (dispose component + remove broken tab). - const installationResult = await this.checkAndInstallTerminal(false, { - component: terminalComponent, - }); + const installationResult = await this.checkAndInstallTerminal( + false, + { + component: terminalComponent, + }, + ); if (!installationResult.success) { throw new Error(installationResult.error); } @@ -335,7 +338,10 @@ class TerminalManager { * early return and run a full reinstall. * @returns {Promise<{success: boolean, error?: string}>} */ - async checkAndInstallTerminal(forceReinstall = false, progressTerminal = null) { + async checkAndInstallTerminal( + forceReinstall = false, + progressTerminal = null, + ) { try { // Check if terminal is already installed const isInstalled = await Terminal.isInstalled(); @@ -353,9 +359,12 @@ class TerminalManager { } // Create installation progress terminal (or reuse current one) - const installTerminal = progressTerminal || await this.createInstallationTerminal(); + const installTerminal = + progressTerminal || (await this.createInstallationTerminal()); if (progressTerminal?.component) { - installTerminal.component.write("\x1b[33mInstalling terminal environment...\x1b[0m\r\n"); + installTerminal.component.write( + "\x1b[33mInstalling terminal environment...\x1b[0m\r\n", + ); } // Install terminal with progress logging @@ -518,7 +527,7 @@ class TerminalManager { terminalFile.onfocus = () => { // Do NOT forcefully call fit() here, as the DOM might still be animating // or transitioning from display:none. - // terminalFile._resizeObserver (ResizeObserver) already handles fitting + // terminalFile._resizeObserver (ResizeObserver) already handles fitting // securely when the container's true dimensions are realized. terminalComponent.focus(); }; @@ -679,12 +688,19 @@ class TerminalManager { // Write recovery status directly into the current terminal terminalComponent.clear(); - terminalComponent.write("\x1b[33m⚠ Detected terminal environment corruption (libc/readline).\x1b[0m\r\n"); - terminalComponent.write("\x1b[33m Starting automatic repair...\x1b[0m\r\n\r\n"); + terminalComponent.write( + "\x1b[33m⚠ Detected terminal environment corruption (libc/readline).\x1b[0m\r\n", + ); + terminalComponent.write( + "\x1b[33m Starting automatic repair...\x1b[0m\r\n\r\n", + ); // Uninstall corrupted rootfs terminalComponent.write("Removing corrupted rootfs...\r\n"); - if (window.Terminal && typeof window.Terminal.uninstall === "function") { + if ( + window.Terminal && + typeof window.Terminal.uninstall === "function" + ) { await window.Terminal.uninstall(); } terminalComponent.write("Rootfs removed. Reinstalling...\r\n\r\n"); @@ -695,17 +711,23 @@ class TerminalManager { }); if (result.success) { - terminalComponent.write("\r\n\x1b[32m✔ Recovery complete. Reconnecting session...\x1b[0m\r\n"); + terminalComponent.write( + "\r\n\x1b[32m✔ Recovery complete. Reconnecting session...\x1b[0m\r\n", + ); // Clear the terminal buffer so the new shell prompt starts clean terminalComponent.clear(); // Reconnect a fresh session in the same terminal await terminalComponent.connectToSession(); } else { - terminalComponent.write(`\r\n\x1b[31m✘ Recovery failed: ${result.error}\x1b[0m\r\n`); + terminalComponent.write( + `\r\n\x1b[31m✘ Recovery failed: ${result.error}\x1b[0m\r\n`, + ); } } catch (e) { console.error("In-place terminal recovery failed:", e); - terminalComponent.write(`\r\n\x1b[31m✘ Recovery error: ${e.message}\x1b[0m\r\n`); + terminalComponent.write( + `\r\n\x1b[31m✘ Recovery error: ${e.message}\x1b[0m\r\n`, + ); } } }; diff --git a/src/plugins/system/android/com/foxdebug/system/System.java b/src/plugins/system/android/com/foxdebug/system/System.java index a2271e44e..66b430311 100644 --- a/src/plugins/system/android/com/foxdebug/system/System.java +++ b/src/plugins/system/android/com/foxdebug/system/System.java @@ -385,21 +385,44 @@ public void run() { return true; case "copyAsset": { - String assetName = args.getString(0); - String destPath = args.getString(1); - try { - java.io.InputStream in = cordova.getActivity().getAssets().open(assetName); - java.io.FileOutputStream out = new java.io.FileOutputStream(destPath); - byte[] buf = new byte[65536]; - int len; - while ((len = in.read(buf)) != -1) out.write(buf, 0, len); - out.close(); - in.close(); - new File(destPath).setExecutable(true); - callbackContext.success(); - } catch (Exception e) { - callbackContext.error(e.getMessage()); - } + final String assetName = args.getString(0); + final String destPath = args.getString(1); + cordova + .getThreadPool() + .execute( + new Runnable() { + @Override + public void run() { + File destFile = new File(destPath); + File parentDir = destFile.getParentFile(); + if (parentDir != null && !parentDir.exists() && !parentDir.mkdirs()) { + callbackContext.error("Failed to create destination directory: " + parentDir.getAbsolutePath()); + return; + } + + try (java.io.InputStream in = cordova.getActivity().getAssets().open(assetName); + java.io.FileOutputStream out = new java.io.FileOutputStream(destFile)) { + byte[] buf = new byte[65536]; + int len; + while ((len = in.read(buf)) != -1) { + out.write(buf, 0, len); + } + } catch (Exception e) { + if (destFile.exists()) { + destFile.delete(); + } + callbackContext.error(e.getMessage()); + return; + } + + if (!destFile.setExecutable(true)) { + callbackContext.error("Failed to set executable permission on: " + destFile.getAbsolutePath()); + return; + } + + callbackContext.success(); + } + }); return true; } default: diff --git a/src/plugins/terminal/scripts/init-alpine.sh b/src/plugins/terminal/scripts/init-alpine.sh index 53b45262e..d3746775e 100644 --- a/src/plugins/terminal/scripts/init-alpine.sh +++ b/src/plugins/terminal/scripts/init-alpine.sh @@ -10,8 +10,12 @@ is_apk_installed() { [ -f /lib/apk/db/installed ] && grep -q "^P:${package_name}$" /lib/apk/db/installed } +find_bash_path() { + command -v bash 2>/dev/null || true +} + missing_packages="" -[ ! -f /usr/bin/bash ] && [ ! -f /bin/bash ] && missing_packages="$missing_packages bash" +[ -z "$(find_bash_path)" ] && missing_packages="$missing_packages bash" [ ! -f /usr/share/zoneinfo/UTC ] && missing_packages="$missing_packages tzdata" [ ! -f /usr/bin/wget ] && missing_packages="$missing_packages wget" ! is_apk_installed command-not-found && missing_packages="$missing_packages command-not-found" @@ -34,17 +38,20 @@ if [ -n "$missing_packages" ]; then mv /etc/apk/repositories.bak /etc/apk/repositories 2>/dev/null fi - # Post-install fixup: ensure bash is usable even if scripts failed - if [ -f /usr/bin/bash ] && [ ! -e /bin/bash ]; then - ln -sf /usr/bin/bash /bin/bash 2>/dev/null + bash_path="$(find_bash_path)" + + # Post-install fixup: ensure /bin/bash exists if bash resolves elsewhere. + if [ -n "$bash_path" ] && [ ! -e /bin/bash ]; then + ln -sf "$bash_path" /bin/bash 2>/dev/null fi + # Ensure /etc/shells has bash - if [ -f /usr/bin/bash ] && ! grep -q "/bin/bash" /etc/shells 2>/dev/null; then + if [ -n "$bash_path" ] && ! grep -q "/bin/bash" /etc/shells 2>/dev/null; then echo "/bin/bash" >> /etc/shells 2>/dev/null fi # Verify - [ ! -f /usr/bin/bash ] && [ ! -f /bin/bash ] && echo -e "\e[31;1m[!] \e[0mbash still missing\e[0m" + [ -z "$bash_path" ] && echo -e "\e[31;1m[!] \e[0mbash still missing\e[0m" [ ! -f /usr/bin/wget ] && echo -e "\e[31;1m[!] \e[0mwget still missing\e[0m" fi @@ -261,6 +268,10 @@ chmod +x "$PREFIX/alpine/initrc" # Required for the WebView's HTTP probe and terminal requests to localhost:8767. # Without this CORS allowance, fetch() fails with "TypeError: Failed to fetch" # even though axs is already listening, which triggers false repair/reinstall loops. +# axs currently exposes only its default https://localhost policy or a global +# allow-any-origin switch; it does not support an explicit origin allowlist yet. +# Keep this until axs gains per-origin CORS configuration that can express the +# WebView origin without breaking the localhost probe. "$PREFIX/axs" --allow-any-origin -c "bash --rcfile /initrc -i" else diff --git a/src/plugins/terminal/src/android/DownloadHelper.java b/src/plugins/terminal/src/android/DownloadHelper.java index e43c31311..6830266fb 100644 --- a/src/plugins/terminal/src/android/DownloadHelper.java +++ b/src/plugins/terminal/src/android/DownloadHelper.java @@ -6,32 +6,71 @@ class DownloadHelper { static void download(String url, String dst, CallbackContext callbackContext) { + java.net.HttpURLConnection conn = null; try { - java.net.URL u = java.net.URI.create(url).toURL(); - java.net.HttpURLConnection conn = (java.net.HttpURLConnection) u.openConnection(); - conn.setInstanceFollowRedirects(true); - conn.setConnectTimeout(30000); - conn.setReadTimeout(60000); - conn.connect(); - int code = conn.getResponseCode(); - // Follow redirects across protocols (HTTP→HTTPS) - if (code == 301 || code == 302 || code == 303 || code == 307 || code == 308) { - String loc = conn.getHeaderField("Location"); - conn.disconnect(); - u = java.net.URI.create(loc).toURL(); + java.net.URI originalUri = java.net.URI.create(url); + java.net.URI currentUri = originalUri; + boolean originalHttps = "https".equalsIgnoreCase(originalUri.getScheme()); + int redirectCount = 0; + final int maxRedirects = 5; + int code; + + while (true) { + if (conn != null) { + conn.disconnect(); + } + + java.net.URL u = currentUri.toURL(); conn = (java.net.HttpURLConnection) u.openConnection(); - conn.setInstanceFollowRedirects(true); + conn.setInstanceFollowRedirects(false); conn.setConnectTimeout(30000); conn.setReadTimeout(60000); conn.connect(); code = conn.getResponseCode(); + + if (code == 301 || code == 302 || code == 303 || code == 307 || code == 308) { + if (redirectCount >= maxRedirects) { + callbackContext.error("Too many redirects"); + return; + } + + String loc = conn.getHeaderField("Location"); + if (loc == null || loc.isEmpty()) { + callbackContext.error("Redirect with no Location header (HTTP " + code + ")"); + return; + } + + java.net.URI redirectUri = java.net.URI.create(loc); + if (!redirectUri.isAbsolute()) { + redirectUri = currentUri.resolve(redirectUri); + } + + String scheme = redirectUri.getScheme(); + if (scheme == null || + (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme))) { + callbackContext.error("Unsupported redirect scheme: " + scheme); + return; + } + + if (originalHttps && "http".equalsIgnoreCase(scheme)) { + callbackContext.error("Refusing to follow HTTPS to HTTP redirect"); + return; + } + + currentUri = redirectUri; + redirectCount++; + continue; + } + + break; } + if (code != 200) { - conn.disconnect(); callbackContext.error("HTTP " + code); return; } - long contentLength = conn.getContentLength(); + + long contentLength = conn.getContentLengthLong(); java.io.File dstFile = new java.io.File(dst); byte[] buf = new byte[65536]; long downloaded = 0; @@ -61,10 +100,13 @@ static void download(String url, String dst, CallbackContext callbackContext) { } } } - conn.disconnect(); callbackContext.success(dst); } catch (Exception e) { callbackContext.error("download failed: " + e.getMessage()); + } finally { + if (conn != null) { + conn.disconnect(); + } } } } diff --git a/src/plugins/terminal/www/Terminal.js b/src/plugins/terminal/www/Terminal.js index 387b2113a..3c407f3bd 100644 --- a/src/plugins/terminal/www/Terminal.js +++ b/src/plugins/terminal/www/Terminal.js @@ -73,7 +73,7 @@ const Terminal = { } } }).then(async (uuid) => { - await Executor.write(uuid, `source ${filesDir}/init-sandbox.sh ${installing ? "--installing" : ""}; exit`); + await Executor.write(uuid, `source "${filesDir}/init-sandbox.sh" ${installing ? "--installing" : ""}; exit`); }).catch((error) => { err_logger("Failed to start AXS:", error); resolve(false); @@ -99,7 +99,7 @@ const Terminal = { }).then(async (uuid) => { // Normal startup path: do not pass --installing. // This avoids entering install-time setup when just launching AXS. - await Executor.write(uuid, `source ${filesDir}/init-sandbox.sh; exit`); + await Executor.write(uuid, `source "${filesDir}/init-sandbox.sh"; exit`); }).catch((error) => { err_logger("Failed to start AXS:", error); }); @@ -316,13 +316,13 @@ const Terminal = { const alpineDir = `${filesDir}/alpine`; // Clean up partial extraction from previous failed attempt - await Executor.execute(`rm -rf ${alpineDir}`).catch(() => {}); + await Executor.execute(`rm -rf "${alpineDir}"`).catch(() => {}); await new Promise((resolve, reject) => { system.mkdirs(alpineDir, resolve, reject); }); logger("📦 Extracting sandbox filesystem..."); - await Executor.execute(`tar --no-same-owner -xf ${filesDir}/alpine.tar.gz -C ${alpineDir}`); + await Executor.execute(`tar --no-same-owner -xf "${filesDir}/alpine.tar.gz" -C "${alpineDir}"`); logger("⚙️ Applying basic configuration..."); system.writeText(`${alpineDir}/etc/resolv.conf`, `nameserver 8.8.4.4\nnameserver 8.8.8.8`); @@ -351,7 +351,7 @@ const Terminal = { err_logger("Installation failed:", e); console.error("Installation failed:", e); // Clean up everything so retry starts fresh (including potentially corrupted downloads) - await Executor.execute(`rm -rf ${filesDir}/.downloaded ${filesDir}/.extracted ${filesDir}/.configured ${filesDir}/alpine ${filesDir}/alpine.tar.gz ${filesDir}/alpine.tar ${filesDir}/.download-manifest`).catch(() => {}); + await Executor.execute(`rm -rf "${filesDir}/.downloaded" "${filesDir}/.extracted" "${filesDir}/.configured" "${filesDir}/alpine" "${filesDir}/alpine.tar.gz" "${filesDir}/alpine.tar" "${filesDir}/.download-manifest"`).catch(() => {}); if (!_retried) { logger("🔄 Retrying installation from scratch..."); return this.install(logger, err_logger, true); diff --git a/src/settings/terminalSettings.js b/src/settings/terminalSettings.js index 9eaf1b5cc..08bb92a4f 100644 --- a/src/settings/terminalSettings.js +++ b/src/settings/terminalSettings.js @@ -257,7 +257,8 @@ export default function terminalSettings() { default: if (key === "letterSpacing") { value = Number.parseFloat(value); - if (!Number.isFinite(value)) value = DEFAULT_TERMINAL_SETTINGS.letterSpacing; + if (!Number.isFinite(value)) + value = DEFAULT_TERMINAL_SETTINGS.letterSpacing; value = Math.max(0, Math.min(2, value)); } @@ -373,7 +374,9 @@ async function updateActiveTerminals(key, value) { tab.terminalComponent.options.fontFamily = `"${value}", monospace`; tab.terminalComponent.terminal.options.fontFamily = `"${value}", monospace`; if (tab.terminalComponent.webglAddon) { - try { tab.terminalComponent.webglAddon.clearTextureAtlas(); } catch (e) {} + try { + tab.terminalComponent.webglAddon.clearTextureAtlas(); + } catch (e) {} } tab.terminalComponent.terminal.refresh( 0, @@ -405,7 +408,8 @@ async function updateActiveTerminals(key, value) { case "letterSpacing": { let spacing = Number.parseFloat(value); - if (!Number.isFinite(spacing)) spacing = DEFAULT_TERMINAL_SETTINGS.letterSpacing; + if (!Number.isFinite(spacing)) + spacing = DEFAULT_TERMINAL_SETTINGS.letterSpacing; spacing = Math.max(0, Math.min(2, spacing)); tab.terminalComponent.terminal.options.letterSpacing = spacing; } From 55aec07650521c2a100ec4f0e7ad0f9a3240aa97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=9F=83=E5=8D=9A=E6=8B=89=E9=85=B1?= Date: Sun, 8 Mar 2026 15:51:19 +0800 Subject: [PATCH 03/10] fix: surface terminal session restore state and include download helper Include DownloadHelper.java in the terminal plugin's Android source-file list so Cordova prepare brings it into the generated platform project and CI Java compilation can resolve the helper references. Document why redirect handling does not enforce same-host redirects for signed GitHub release assets, while still restricting redirects to HTTP(S) targets and blocking HTTPS downgrade. Clarify restored terminal behavior by printing an explicit restored-session notice when reconnecting to an existing PTY, so missing MOTD is not misdiagnosed as a startup regression. --- src/components/terminal/terminalManager.js | 6 ++++++ src/plugins/terminal/plugin.xml | 1 + src/plugins/terminal/src/android/DownloadHelper.java | 3 +++ 3 files changed, 10 insertions(+) diff --git a/src/components/terminal/terminalManager.js b/src/components/terminal/terminalManager.js index c60913583..b8cf5a98e 100644 --- a/src/components/terminal/terminalManager.js +++ b/src/components/terminal/terminalManager.js @@ -187,6 +187,7 @@ class TerminalManager { const { render, serverMode, ...terminalOptions } = options; const shouldRender = render !== false; const isServerMode = serverMode !== false; + const isReconnecting = terminalOptions.reconnecting === true; const terminalId = `terminal_${++this.terminalCounter}`; const providedName = @@ -256,6 +257,11 @@ class TerminalManager { // Connect to session if in server mode if (terminalComponent.serverMode) { await terminalComponent.connectToSession(terminalOptions.pid); + if (isReconnecting) { + terminalComponent.write( + "\x1b[36m[Restored existing terminal session. MOTD is only shown when a new shell starts.]\x1b[0m\r\n", + ); + } } else { // For local mode, just write a welcome message terminalComponent.write( diff --git a/src/plugins/terminal/plugin.xml b/src/plugins/terminal/plugin.xml index 72273257f..f0b9be33c 100644 --- a/src/plugins/terminal/plugin.xml +++ b/src/plugins/terminal/plugin.xml @@ -26,6 +26,7 @@ + diff --git a/src/plugins/terminal/src/android/DownloadHelper.java b/src/plugins/terminal/src/android/DownloadHelper.java index 6830266fb..3667dda93 100644 --- a/src/plugins/terminal/src/android/DownloadHelper.java +++ b/src/plugins/terminal/src/android/DownloadHelper.java @@ -57,6 +57,9 @@ static void download(String url, String dst, CallbackContext callbackContext) { return; } + // GitHub release assets routinely redirect to signed CDN hosts, so + // same-host enforcement would break valid downloads. We only allow + // HTTP(S) targets and block HTTPS downgrade instead of pinning hosts. currentUri = redirectUri; redirectCount++; continue; From 6dfa3b8b36a5e3f14067d4811bd47b49fcc27cbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=9F=83=E5=8D=9A=E6=8B=89=E9=85=B1?= Date: Sun, 8 Mar 2026 16:07:38 +0800 Subject: [PATCH 04/10] fix: serialize terminal install markers and harden repair flow Await the download manifest write before creating the .downloaded marker so cache state cannot observe a downloaded marker without the matching manifest payload on disk. Fail the in-place corrupted-rootfs recovery path immediately when the terminal uninstall API is unavailable, instead of pretending the rootfs was removed and continuing into a misleading reinstall sequence. --- src/components/terminal/terminalManager.js | 9 ++++++--- src/plugins/terminal/www/Terminal.js | 5 ++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/components/terminal/terminalManager.js b/src/components/terminal/terminalManager.js index b8cf5a98e..5ea59e445 100644 --- a/src/components/terminal/terminalManager.js +++ b/src/components/terminal/terminalManager.js @@ -704,11 +704,14 @@ class TerminalManager { // Uninstall corrupted rootfs terminalComponent.write("Removing corrupted rootfs...\r\n"); if ( - window.Terminal && - typeof window.Terminal.uninstall === "function" + !window.Terminal || + typeof window.Terminal.uninstall !== "function" ) { - await window.Terminal.uninstall(); + throw new Error( + "Terminal uninstall API is unavailable; cannot repair corrupted rootfs.", + ); } + await window.Terminal.uninstall(); terminalComponent.write("Rootfs removed. Reinstalling...\r\n\r\n"); // Reinstall, routing all progress output to this terminal diff --git a/src/plugins/terminal/www/Terminal.js b/src/plugins/terminal/www/Terminal.js index 3c407f3bd..ded042d4f 100644 --- a/src/plugins/terminal/www/Terminal.js +++ b/src/plugins/terminal/www/Terminal.js @@ -164,6 +164,9 @@ const Terminal = { const fileExists = (path) => new Promise((resolve) => { system.fileExists(path, false, (result) => resolve(result == 1), () => resolve(false)); }); + const writeText = (path, content) => new Promise((resolve, reject) => { + system.writeText(path, content, resolve, reject); + }); const formatBytes = (bytes) => { if (bytes < 1024) return bytes + " B"; @@ -301,7 +304,7 @@ const Terminal = { logger("✅ All downloads completed"); // Save URL manifest for cache invalidation on version change - system.writeText(`${filesDir}/.download-manifest`, [alpineUrl, axsUrl].join("\n")); + await writeText(`${filesDir}/.download-manifest`, [alpineUrl, axsUrl].join("\n")); logger("📁 Setting up directories..."); await new Promise((resolve, reject) => { From 9e10b4b7556aa24609ab61f4c406f58e5766b69b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=9F=83=E5=8D=9A=E6=8B=89=E9=85=B1?= Date: Sun, 8 Mar 2026 16:44:05 +0800 Subject: [PATCH 05/10] fix: harden terminal session startup checks and review rationale Update terminal session startup so createSession always waits for the axs HTTP endpoint to become reachable even when a stale or still-booting PID already exists. This removes the race where POST /terminals could run before the embedded server was actually ready during slow startup or crash recovery. Replace the fragile PTY open error substring detection with structured JSON parsing so normal output that happens to contain overlapping text does not trigger the binary refresh and retry path. Clarify in init-alpine.sh that the temporary allow-any-origin switch cannot be tightened from the shell wrapper because Origin validation must be implemented inside axs itself. --- src/components/terminal/terminal.js | 79 +++++++++++++-------- src/plugins/terminal/scripts/init-alpine.sh | 6 +- 2 files changed, 54 insertions(+), 31 deletions(-) diff --git a/src/components/terminal/terminal.js b/src/components/terminal/terminal.js index 01971e710..293238624 100644 --- a/src/components/terminal/terminal.js +++ b/src/components/terminal/terminal.js @@ -640,39 +640,41 @@ export default class TerminalComponent { // In debug builds, refresh axs binary from assets before starting await Terminal.refreshAxsBinary(); await Terminal.startAxs(false, () => {}, console.error); + } + + // Always wait for the HTTP endpoint, even if the PID is already alive. + // kill -0 only tells us the outer process exists; the embedded server may + // still be starting, especially after crash recovery on slower devices. + const initialPollRetries = axsRunning ? 10 : 30; + if (!(await pollAxs(initialPollRetries))) { + // AXS failed to become reachable — attempt auto-repair + toast("Repairing terminal environment..."); + + try { + await Terminal.stopAxs(); + } catch (_) { + /* ignore */ + } - // Wait for axs HTTP server to become reachable - const pollResult = await pollAxs(30); - if (!pollResult) { - // AXS failed to start — attempt auto-repair - toast("Repairing terminal environment..."); + // Re-run installing flow to repair packages / config + const repairOk = await Terminal.startAxs( + true, + console.log, + console.error, + ); + if (repairOk) { + // Start AXS again after repair + await Terminal.startAxs(false, () => {}, console.error); + } + if (!(await pollAxs(30))) { + // Still broken — clear .configured so next open re-triggers install try { - await Terminal.stopAxs(); + await Terminal.resetConfigured(); } catch (_) { /* ignore */ } - - // Re-run installing flow to repair packages / config - const repairOk = await Terminal.startAxs( - true, - console.log, - console.error, - ); - if (repairOk) { - // Start AXS again after repair - await Terminal.startAxs(false, () => {}, console.error); - } - - if (!(await pollAxs(30))) { - // Still broken — clear .configured so next open re-triggers install - try { - await Terminal.resetConfigured(); - } catch (_) { - /* ignore */ - } - throw new Error("Failed to start AXS server after repair attempt"); - } + throw new Error("Failed to start AXS server after repair attempt"); } } @@ -681,6 +683,24 @@ export default class TerminalComponent { rows: this.terminal.rows, }; + const parsePtyOpenError = (payload) => { + if (typeof payload !== "string") { + return null; + } + + const trimmed = payload.trim(); + if (!trimmed.startsWith("{")) { + return null; + } + + try { + const parsed = JSON.parse(trimmed); + return typeof parsed?.error === "string" ? parsed.error : null; + } catch { + return null; + } + }; + const response = await fetch( `http://localhost:${this.options.port}/terminals`, { @@ -697,9 +717,10 @@ export default class TerminalComponent { } const data = await response.text(); + const ptyOpenError = parsePtyOpenError(data); // Detect PTY errors from axs server (e.g. incompatible binary) - if (data.includes('"error"') && data.includes("Failed to open PTY")) { + if (ptyOpenError?.includes("Failed to open PTY")) { const refreshed = await Terminal.refreshAxsBinary(); if (refreshed) { // Kill old axs, restart with fresh binary, and retry once @@ -719,7 +740,7 @@ export default class TerminalComponent { ); if (retryResp.ok) { const retryData = await retryResp.text(); - if (!retryData.includes('"error"')) { + if (!parsePtyOpenError(retryData)) { this.pid = retryData.trim(); return this.pid; } diff --git a/src/plugins/terminal/scripts/init-alpine.sh b/src/plugins/terminal/scripts/init-alpine.sh index d3746775e..dbfb98fac 100644 --- a/src/plugins/terminal/scripts/init-alpine.sh +++ b/src/plugins/terminal/scripts/init-alpine.sh @@ -270,8 +270,10 @@ chmod +x "$PREFIX/alpine/initrc" # even though axs is already listening, which triggers false repair/reinstall loops. # axs currently exposes only its default https://localhost policy or a global # allow-any-origin switch; it does not support an explicit origin allowlist yet. -# Keep this until axs gains per-origin CORS configuration that can express the -# WebView origin without breaking the localhost probe. +# Tightening this inside the shell wrapper is not possible: Origin validation has +# to happen inside axs itself, where the HTTP request is handled. Until axs gains +# per-origin CORS or an equivalent auth gate, keep this stopgap so terminal +# startup and localhost readiness probes remain functional. "$PREFIX/axs" --allow-any-origin -c "bash --rcfile /initrc -i" else From 3f6cf8646e63e93f65c766820bd1e4974ae2f0ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=9F=83=E5=8D=9A=E6=8B=89=E9=85=B1?= Date: Sun, 8 Mar 2026 19:37:32 +0800 Subject: [PATCH 06/10] docs: clarify terminal CORS context and fix formatting Adjust the init-alpine.sh comment so it distinguishes upstream Cordova's default https://localhost origin from this repo's build pipeline, which rewrites the scheme to http for ws:// terminal sockets. Also fix the remaining terminal.js formatting issue that was flagged by Biome in CI. --- src/components/terminal/terminal.js | 2 +- src/plugins/terminal/scripts/init-alpine.sh | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/terminal/terminal.js b/src/components/terminal/terminal.js index 293238624..b7539cba4 100644 --- a/src/components/terminal/terminal.js +++ b/src/components/terminal/terminal.js @@ -740,7 +740,7 @@ export default class TerminalComponent { ); if (retryResp.ok) { const retryData = await retryResp.text(); - if (!parsePtyOpenError(retryData)) { + if (!parsePtyOpenError(retryData)) { this.pid = retryData.trim(); return this.pid; } diff --git a/src/plugins/terminal/scripts/init-alpine.sh b/src/plugins/terminal/scripts/init-alpine.sh index dbfb98fac..307003459 100644 --- a/src/plugins/terminal/scripts/init-alpine.sh +++ b/src/plugins/terminal/scripts/init-alpine.sh @@ -266,6 +266,9 @@ chmod +x "$PREFIX/alpine/initrc" #actual source #everytime a terminal is started initrc will run # Required for the WebView's HTTP probe and terminal requests to localhost:8767. +# Upstream Cordova defaults to https://localhost, and axs already allows that +# origin by default. However, this repo's build pipeline rewrites the Cordova +# Scheme to http so the app can use ws://localhost terminal sockets. # Without this CORS allowance, fetch() fails with "TypeError: Failed to fetch" # even though axs is already listening, which triggers false repair/reinstall loops. # axs currently exposes only its default https://localhost policy or a global From 5084651c47eb2186c575c4b1e561a40829645824 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=9F=83=E5=8D=9A=E6=8B=89=E9=85=B1?= Date: Sun, 8 Mar 2026 23:19:29 +0800 Subject: [PATCH 07/10] Clarify terminal allow-any-origin rationale Update the init-alpine.sh comment around axs --allow-any-origin so it matches the current build behavior. Remove the outdated claim that the build pipeline rewrites Cordova scheme to http and document the actual reason this stopgap remains in place until axs supports a narrower origin or auth gate. --- src/plugins/terminal/scripts/init-alpine.sh | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/plugins/terminal/scripts/init-alpine.sh b/src/plugins/terminal/scripts/init-alpine.sh index 307003459..2950fffe3 100644 --- a/src/plugins/terminal/scripts/init-alpine.sh +++ b/src/plugins/terminal/scripts/init-alpine.sh @@ -266,11 +266,12 @@ chmod +x "$PREFIX/alpine/initrc" #actual source #everytime a terminal is started initrc will run # Required for the WebView's HTTP probe and terminal requests to localhost:8767. -# Upstream Cordova defaults to https://localhost, and axs already allows that -# origin by default. However, this repo's build pipeline rewrites the Cordova -# Scheme to http so the app can use ws://localhost terminal sockets. -# Without this CORS allowance, fetch() fails with "TypeError: Failed to fetch" -# even though axs is already listening, which triggers false repair/reinstall loops. +# Upstream Cordova commonly runs under https://localhost, which axs already +# allows by default. In this repo, terminal startup and localhost readiness +# probes can still originate from contexts outside axs's built-in default +# allowlist. Without this CORS allowance, fetch() fails with +# "TypeError: Failed to fetch" even though axs is already listening, which +# triggers false repair/reinstall loops. # axs currently exposes only its default https://localhost policy or a global # allow-any-origin switch; it does not support an explicit origin allowlist yet. # Tightening this inside the shell wrapper is not possible: Origin validation has From 0f2b92a6bbea70214cec086d67ee36b30093ccff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=9F=83=E5=8D=9A=E6=8B=89=E9=85=B1?= Date: Mon, 9 Mar 2026 00:50:11 +0800 Subject: [PATCH 08/10] Stabilize debug terminal packaging and install diagnostics Keep Android resource post-processing compatible with the current Cordova Android output while preserving the existing Android 14 build path. Remove the allow-any-origin flag from the bundled terminal startup script for the current runtime validation build. Add detailed bundled AXS copy failure logging so LAN debug output shows the exact asset-copy error instead of a generic fallback message. --- hooks/post-process.js | 58 ++++++++++++++++++--- package-lock.json | 6 +-- src/plugins/terminal/scripts/init-alpine.sh | 2 +- src/plugins/terminal/www/Terminal.js | 16 +++++- 4 files changed, 69 insertions(+), 13 deletions(-) diff --git a/hooks/post-process.js b/hooks/post-process.js index e0b7f5a08..f5e01fd0f 100644 --- a/hooks/post-process.js +++ b/hooks/post-process.js @@ -21,19 +21,61 @@ if ( if (fs.existsSync(androidGradleFilePath)) fs.unlinkSync(androidGradleFilePath); fs.copyFileSync(gradleFilePath, androidGradleFilePath); -deleteDirRecursively(resPath, [ - path.join('values', 'strings.xml'), - path.join('values', 'colors.xml'), - path.join('values', 'styles.xml'), - 'anim', - 'xml', -]); -copyDirRecursively(localResPath, resPath); +const preservedRes = collectPreservedAndroidRes(resPath); +deleteDirRecursively(resPath, preservedRes); + +const localResSkip = collectLocalResourceSkipList(resPath); + +copyDirRecursively(localResPath, resPath, localResSkip); enableLegacyJni(); enableStaticContext(); patchTargetSdkVersion(); +function collectPreservedAndroidRes(androidResPath) { + const preserved = [ + path.join('values', 'styles.xml'), + 'anim', + 'xml', + ]; + + const optionalEntries = [ + path.join('values', 'strings.xml'), + path.join('values', 'colors.xml'), + path.join('values', 'themes.xml'), + path.join('values', 'cdv_strings.xml'), + path.join('values', 'cdv_colors.xml'), + path.join('values', 'cdv_themes.xml'), + 'values-night', + 'values-night-v34', + 'values-v34', + ]; + + for (const entry of optionalEntries) { + if (fs.existsSync(path.join(androidResPath, entry))) { + preserved.push(entry); + } + } + + return preserved; +} + +function collectLocalResourceSkipList(androidResPath) { + const skip = []; + + // Cordova Android 15+ provides splash/theme defaults in cdv_* resources. + // Keep using local colors/themes on older layouts where those files do not exist. + if (fs.existsSync(path.join(androidResPath, 'values', 'cdv_colors.xml'))) { + skip.push(path.join('values', 'colors.xml')); + } + if (fs.existsSync(path.join(androidResPath, 'values', 'cdv_themes.xml'))) { + skip.push(path.join('values', 'themes.xml')); + } + + return skip; +} + + function getTmpDir() { const tmpdirEnv = process.env.TMPDIR; diff --git a/package-lock.json b/package-lock.json index dbbd8b82c..3d5fb4af7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6752,9 +6752,9 @@ } }, "node_modules/dedent": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", - "integrity": "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", "dev": true, "license": "MIT", "peerDependencies": { diff --git a/src/plugins/terminal/scripts/init-alpine.sh b/src/plugins/terminal/scripts/init-alpine.sh index 2950fffe3..787996c1f 100644 --- a/src/plugins/terminal/scripts/init-alpine.sh +++ b/src/plugins/terminal/scripts/init-alpine.sh @@ -278,7 +278,7 @@ chmod +x "$PREFIX/alpine/initrc" # to happen inside axs itself, where the HTTP request is handled. Until axs gains # per-origin CORS or an equivalent auth gate, keep this stopgap so terminal # startup and localhost readiness probes remain functional. -"$PREFIX/axs" --allow-any-origin -c "bash --rcfile /initrc -i" +"$PREFIX/axs" -c "bash --rcfile /initrc -i" else exec "$@" diff --git a/src/plugins/terminal/www/Terminal.js b/src/plugins/terminal/www/Terminal.js index ded042d4f..657ccb369 100644 --- a/src/plugins/terminal/www/Terminal.js +++ b/src/plugins/terminal/www/Terminal.js @@ -1,5 +1,18 @@ const Executor = require("./Executor"); +function formatAxsAssetError(error) { + if (!error) return "unknown error"; + if (typeof error === "string") return error; + if (error instanceof Error) { + return error.stack || error.message || String(error); + } + try { + return JSON.stringify(error); + } catch (_) { + return String(error); + } +} + const Terminal = { /** * In debug builds, overwrite the axs binary from bundled assets to ensure @@ -16,6 +29,7 @@ const Terminal = { }); return true; } catch (e) { + console.warn("Failed to refresh bundled AXS from assets:", formatAxsAssetError(e)); return false; } }, @@ -258,7 +272,7 @@ const Terminal = { copiedFromAsset = true; logger("✅ Bundled AXS copied from assets"); } catch (assetError) { - logger("⚠️ Asset copy failed, will download instead"); + logger(`⚠️ Asset copy failed, will download instead: ${formatAxsAssetError(assetError)}`); } } From 62cf100ca4853c0e5a058e0e72d4114d378aaea8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=9F=83=E5=8D=9A=E6=8B=89=E9=85=B1?= Date: Mon, 9 Mar 2026 21:19:21 +0800 Subject: [PATCH 09/10] Revert static terminal CORS injection - restore the AXS launch section in init-alpine.sh to the earlier source state without the explanatory allow-any-origin comments - keep the default source startup command as plain axs invocation so allow-any-origin is no longer baked into source - leave dynamic allow-any-origin handling to the outer build/deploy script when LAN debug injection is explicitly enabled --- src/plugins/terminal/scripts/init-alpine.sh | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/plugins/terminal/scripts/init-alpine.sh b/src/plugins/terminal/scripts/init-alpine.sh index 787996c1f..e5cfab195 100644 --- a/src/plugins/terminal/scripts/init-alpine.sh +++ b/src/plugins/terminal/scripts/init-alpine.sh @@ -265,19 +265,6 @@ chmod +x "$PREFIX/alpine/initrc" #actual source #everytime a terminal is started initrc will run -# Required for the WebView's HTTP probe and terminal requests to localhost:8767. -# Upstream Cordova commonly runs under https://localhost, which axs already -# allows by default. In this repo, terminal startup and localhost readiness -# probes can still originate from contexts outside axs's built-in default -# allowlist. Without this CORS allowance, fetch() fails with -# "TypeError: Failed to fetch" even though axs is already listening, which -# triggers false repair/reinstall loops. -# axs currently exposes only its default https://localhost policy or a global -# allow-any-origin switch; it does not support an explicit origin allowlist yet. -# Tightening this inside the shell wrapper is not possible: Origin validation has -# to happen inside axs itself, where the HTTP request is handled. Until axs gains -# per-origin CORS or an equivalent auth gate, keep this stopgap so terminal -# startup and localhost readiness probes remain functional. "$PREFIX/axs" -c "bash --rcfile /initrc -i" else From de0c4a296c067903eb7a52559b445785fc57cade Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=9F=83=E5=8D=9A=E6=8B=89=E9=85=B1?= Date: Tue, 10 Mar 2026 18:02:29 +0800 Subject: [PATCH 10/10] refactor: remove embedded AXS debug path Remove the debug-only asset refresh flow from the terminal plugin and terminal session startup. Keep AXS acquisition on the normal download path so debug and release share the same runtime behavior. This also drops the PTY retry path that depended on replacing the local binary from bundled assets. --- src/components/terminal/terminal.js | 31 +------------ src/plugins/terminal/www/Terminal.js | 66 ++++------------------------ 2 files changed, 9 insertions(+), 88 deletions(-) diff --git a/src/components/terminal/terminal.js b/src/components/terminal/terminal.js index b7539cba4..476232f10 100644 --- a/src/components/terminal/terminal.js +++ b/src/components/terminal/terminal.js @@ -637,8 +637,6 @@ export default class TerminalComponent { }; if (!axsRunning) { - // In debug builds, refresh axs binary from assets before starting - await Terminal.refreshAxsBinary(); await Terminal.startAxs(false, () => {}, console.error); } @@ -719,35 +717,8 @@ export default class TerminalComponent { const data = await response.text(); const ptyOpenError = parsePtyOpenError(data); - // Detect PTY errors from axs server (e.g. incompatible binary) if (ptyOpenError?.includes("Failed to open PTY")) { - const refreshed = await Terminal.refreshAxsBinary(); - if (refreshed) { - // Kill old axs, restart with fresh binary, and retry once - try { - await Terminal.stopAxs(); - } catch (_) {} - await Terminal.startAxs(false, () => {}, console.error); - const pollResult = await pollAxs(30); - if (pollResult) { - const retryResp = await fetch( - `http://localhost:${this.options.port}/terminals`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(requestBody), - }, - ); - if (retryResp.ok) { - const retryData = await retryResp.text(); - if (!parsePtyOpenError(retryData)) { - this.pid = retryData.trim(); - return this.pid; - } - } - } - } - throw new Error("Failed to open PTY even after refreshing AXS binary"); + throw new Error("Failed to open PTY"); } this.pid = data.trim(); diff --git a/src/plugins/terminal/www/Terminal.js b/src/plugins/terminal/www/Terminal.js index 657ccb369..78afdd9b1 100644 --- a/src/plugins/terminal/www/Terminal.js +++ b/src/plugins/terminal/www/Terminal.js @@ -1,39 +1,6 @@ const Executor = require("./Executor"); -function formatAxsAssetError(error) { - if (!error) return "unknown error"; - if (typeof error === "string") return error; - if (error instanceof Error) { - return error.stack || error.message || String(error); - } - try { - return JSON.stringify(error); - } catch (_) { - return String(error); - } -} - const Terminal = { - /** - * In debug builds, overwrite the axs binary from bundled assets to ensure - * the running version always matches the build. Returns true if replaced. - */ - async refreshAxsBinary() { - if (typeof BuildInfo === 'undefined' || !BuildInfo.debug) return false; - const filesDir = await new Promise((resolve, reject) => { - system.getFilesDir(resolve, reject); - }); - try { - await new Promise((resolve, reject) => { - system.copyAsset("axs", `${filesDir}/axs`, resolve, reject); - }); - return true; - } catch (e) { - console.warn("Failed to refresh bundled AXS from assets:", formatAxsAssetError(e)); - return false; - } - }, - /** * Starts the AXS environment by writing init scripts and executing the sandbox. * @param {boolean} [installing=false] - Whether AXS is being started during installation. @@ -261,31 +228,14 @@ const Terminal = { } if (!hasAxs) { - let copiedFromAsset = false; - // Only use bundled axs in debug builds; release builds always download latest - if (typeof BuildInfo !== 'undefined' && BuildInfo.debug) { - try { - logger("📦 Copying bundled axs from assets..."); - await new Promise((resolve, reject) => { - system.copyAsset("axs", `${filesDir}/axs`, resolve, reject); - }); - copiedFromAsset = true; - logger("✅ Bundled AXS copied from assets"); - } catch (assetError) { - logger(`⚠️ Asset copy failed, will download instead: ${formatAxsAssetError(assetError)}`); - } - } - - if (!copiedFromAsset) { - logger("⬇️ Downloading axs..."); - await Executor.download(axsUrl, `${filesDir}/axs`, (p) => { - const dl = formatBytes(p.downloaded); - const total = p.total > 0 ? formatBytes(p.total) : "?"; - const speed = formatBytes(p.speed) + "/s"; - const eta = p.eta > 0 ? formatEta(p.eta) : "--"; - logger(`⬇️ ${dl} / ${total} ${speed} ETA ${eta}`); - }); - } + logger("⬇️ Downloading axs..."); + await Executor.download(axsUrl, `${filesDir}/axs`, (p) => { + const dl = formatBytes(p.downloaded); + const total = p.total > 0 ? formatBytes(p.total) : "?"; + const speed = formatBytes(p.speed) + "/s"; + const eta = p.eta > 0 ? formatEta(p.eta) : "--"; + logger(`⬇️ ${dl} / ${total} ${speed} ETA ${eta}`); + }); } else { logger("✅ AXS binary already downloaded"); }