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 d6abc9a78..5fce2d7dd 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",
@@ -6740,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": {
@@ -8968,6 +8980,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",
@@ -10066,6 +10079,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -10186,6 +10200,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -10329,6 +10344,7 @@
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -10599,6 +10615,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",
@@ -12025,7 +12042,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",
@@ -12093,6 +12111,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -12395,6 +12414,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..476232f10 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,101 @@ 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())) {
- await Terminal.startAxs(false, () => {}, console.error);
+ 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 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);
+ }
- // 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;
+ 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 fetchWithTimeout(
+ `http://localhost:${this.options.port}/`,
+ { method: "GET" },
+ 2000,
+ );
+ if (resp.ok || resp.status < 500) return true;
+ } catch (_) {
+ // HTTP not yet reachable
}
- retries++;
+ }
+ return false;
+ };
+
+ if (!axsRunning) {
+ 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 */
}
- // 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");
+ // 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");
}
}
@@ -614,6 +681,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`,
{
@@ -630,6 +715,12 @@ export default class TerminalComponent {
}
const data = await response.text();
+ const ptyOpenError = parsePtyOpenError(data);
+
+ if (ptyOpenError?.includes("Failed to open PTY")) {
+ throw new Error("Failed to open PTY");
+ }
+
this.pid = data.trim();
return this.pid;
} catch (error) {
@@ -654,6 +745,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}`;
@@ -663,6 +759,16 @@ 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 +795,56 @@ 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) => {
+ if (this._relocationSniffDisabled) {
+ return;
+ }
+
+ const MAX_SNIFF_BYTES = 4096;
+
+ try {
+ let text = "";
+ if (typeof event.data === "string") {
+ 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 (this.onCrashData) {
+ this.onCrashData("relocation_error");
+ }
+ this._relocationSniffDisabled = true;
+ clearTimeout(this._relocationSniffTimer);
+ }
+ } 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 +1071,25 @@ 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);
}
@@ -986,6 +1150,9 @@ export default class TerminalComponent {
* Terminate terminal session
*/
async terminate() {
+ clearTimeout(this._relocationSniffTimer);
+ this._relocationSniffDisabled = true;
+
if (this.websocket) {
this.websocket.close();
}
@@ -998,8 +1165,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..b16f53fbe 100644
--- a/src/components/terminal/terminalDefaults.js
+++ b/src/components/terminal/terminalDefaults.js
@@ -25,8 +25,15 @@ 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..5ea59e445 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 =
@@ -199,14 +200,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,9 +237,31 @@ 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);
+ 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(
@@ -323,13 +338,20 @@ 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 +364,14 @@ 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 +531,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 +586,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 +682,65 @@ 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"
+ ) {
+ 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
+ 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..66b430311 100644
--- a/src/plugins/system/android/com/foxdebug/system/System.java
+++ b/src/plugins/system/android/com/foxdebug/system/System.java
@@ -384,6 +384,47 @@ public void run() {
}
return true;
+ case "copyAsset": {
+ 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:
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/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/scripts/init-alpine.sh b/src/plugins/terminal/scripts/init-alpine.sh
index 88c071fd1..e5cfab195 100644
--- a/src/plugins/terminal/scripts/init-alpine.sh
+++ b/src/plugins/terminal/scripts/init-alpine.sh
@@ -1,26 +1,58 @@
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
+}
+
+find_bash_path() {
+ command -v bash 2>/dev/null || true
+}
+
missing_packages=""
+[ -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"
+
+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
+
+ bash_path="$(find_bash_path)"
-for pkg in $required_packages; do
- if ! apk info -e "$pkg" >/dev/null 2>&1; then
- missing_packages="$missing_packages $pkg"
+ # 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
-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"
+ # Ensure /etc/shells has bash
+ if [ -n "$bash_path" ] && ! 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
+ [ -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
@@ -41,7 +73,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 +99,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 +148,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 +168,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
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..3667dda93
--- /dev/null
+++ b/src/plugins/terminal/src/android/DownloadHelper.java
@@ -0,0 +1,115 @@
+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) {
+ java.net.HttpURLConnection conn = null;
+ try {
+ 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(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;
+ }
+
+ // 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;
+ }
+
+ break;
+ }
+
+ if (code != 200) {
+ callbackContext.error("HTTP " + code);
+ return;
+ }
+
+ long contentLength = conn.getContentLengthLong();
+ 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);
+ }
+ }
+ }
+ callbackContext.success(dst);
+ } catch (Exception e) {
+ callbackContext.error("download failed: " + e.getMessage());
+ } finally {
+ if (conn != null) {
+ conn.disconnect();
+ }
+ }
+ }
+}
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..78afdd9b1 100644
--- a/src/plugins/terminal/www/Terminal.js
+++ b/src/plugins/terminal/www/Terminal.js
@@ -9,6 +9,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,10 +36,25 @@ 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`);
+ await Executor.write(uuid, `source "${filesDir}/init-sandbox.sh" ${installing ? "--installing" : ""}; exit`);
}).catch((error) => {
err_logger("Failed to start AXS:", error);
resolve(false);
@@ -60,7 +78,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 +93,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 +120,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 +141,30 @@ 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 writeText = (path, content) => new Promise((resolve, reject) => {
+ system.writeText(path, content, resolve, reject);
+ });
+
+ 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 +197,132 @@ 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) {
+ 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
+ await 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 +470,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 +510,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 ca435600e..773baa543 100644
--- a/src/settings/terminalSettings.js
+++ b/src/settings/terminalSettings.js
@@ -111,6 +111,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"],
category: categories.display,
},
@@ -261,8 +267,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(
@@ -279,6 +289,13 @@ 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,
@@ -388,11 +405,18 @@ 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;
@@ -416,7 +440,13 @@ 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 =