From 5893a9ff7bc32e6cec9a047642dddd46b437d691 Mon Sep 17 00:00:00 2001
From: Amr Ayman <48989143+ulite-Amr@users.noreply.github.com>
Date: Sat, 22 Aug 2026 02:34:10 +0000
Subject: [PATCH 01/38] fix: fall back to internal storage when external
storage is unavailable (#2781)
* fix: fall back to internal storage when external storage is unavailable
The app used to pick the external data directory unconditionally and then
create the plugins directory without guarding against failures. On devices
where the external filesystem cannot be created (e.g. Android/data dir is
not creatable), the unhandled rejection aborted startup, leaving users stuck
on the splash screen with a misleading "Update Android System WebView or
Chrome" message.
- Probe external storage with fs.stat() and fall back to internal storage
- Wrap plugins directory creation in try/catch and fall back to internal
- Request storage permissions only on SDK < 33, guarded by hasPermission
- Guard editorManager.hasUnsavedFiles() access in exitAppMessage
- Show an honest "storage is unavailable" startup message for storage errors
* fix: detect storage errors via FileError instanceof instead of numeric code range
---
src/lib/acode.js | 2 +-
src/main.js | 66 +++++++++++++++++++++++++++++++++++++++++++-----
www/index.html | 25 +++++++++++++++---
3 files changed, 82 insertions(+), 11 deletions(-)
diff --git a/src/lib/acode.js b/src/lib/acode.js
index 6d9310d245..d5a0f2de62 100644
--- a/src/lib/acode.js
+++ b/src/lib/acode.js
@@ -718,7 +718,7 @@ class Acode {
}
get exitAppMessage() {
- const numFiles = editorManager.hasUnsavedFiles();
+ const numFiles = editorManager?.hasUnsavedFiles?.() ?? 0;
if (numFiles) {
return strings["unsaved files close app"];
}
diff --git a/src/main.js b/src/main.js
index 0717578039..ebc2871227 100644
--- a/src/main.js
+++ b/src/main.js
@@ -100,6 +100,20 @@ document.addEventListener("deviceready", onDeviceReady);
document.addEventListener("backbutton", backButtonHandler);
document.addEventListener("menubutton", menuButtonHandler);
+async function ensurePermission(permission) {
+ try {
+ const granted = await helpers.promisify(system.hasPermission, permission);
+ if (!granted) {
+ await helpers.promisify(system.requestPermission, permission);
+ }
+ } catch (error) {
+ logger.log(
+ "error",
+ `Failed to request permission ${permission}: ${error.message || error}`,
+ );
+ }
+}
+
async function onDeviceReady() {
await initEncodings(); // important to load encodings before anything else
@@ -112,14 +126,37 @@ async function onDeviceReady() {
dataDirectory,
} = cordova.file;
+ async function resolveStorageDir(preferred, fallback) {
+ if (!preferred) return fallback;
+ const fs = fsOperation(preferred);
+ if (!fs) return fallback;
+ try {
+ await fs.stat();
+ return preferred;
+ } catch (error) {
+ logger.log(
+ "warn",
+ `Storage dir unavailable (${preferred}), falling back to ${fallback}: ${error.message || error}`,
+ );
+ return fallback;
+ }
+ }
+
window.app = document.body;
window.root = tag.get("#root");
window.addedFolder = addedFolder;
window.editorManager = null;
window.toast = toast;
window.ASSETS_DIRECTORY = Url.join(cordova.file.applicationDirectory, "www");
- window.DATA_STORAGE = externalDataDirectory || dataDirectory;
- window.CACHE_STORAGE = externalCacheDirectory || cacheDirectory;
+ window.DATA_STORAGE = await resolveStorageDir(
+ externalDataDirectory,
+ dataDirectory,
+ );
+ window.CACHE_STORAGE = await resolveStorageDir(
+ externalCacheDirectory,
+ cacheDirectory,
+ );
+
window.PLUGIN_DIR = Url.join(DATA_STORAGE, "plugins");
window.KEYBINDING_FILE = Url.join(DATA_STORAGE, ".key-bindings.json");
window.log = logger.log.bind(logger);
@@ -212,9 +249,11 @@ async function onDeviceReady() {
await adRewards.init();
ensureAceCompatApi();
- system.requestPermission("android.permission.READ_EXTERNAL_STORAGE");
- system.requestPermission("android.permission.WRITE_EXTERNAL_STORAGE");
- system.requestPermission("android.permission.POST_NOTIFICATIONS");
+ if (Number.isInteger(window.ANDROID_SDK_INT) && window.ANDROID_SDK_INT < 33) {
+ await ensurePermission("android.permission.READ_EXTERNAL_STORAGE");
+ await ensurePermission("android.permission.WRITE_EXTERNAL_STORAGE");
+ }
+ await ensurePermission("android.permission.POST_NOTIFICATIONS");
const { versionCode } = BuildInfo;
@@ -227,7 +266,22 @@ async function onDeviceReady() {
}
if (!(await fsOperation(PLUGIN_DIR).exists())) {
- await fsOperation(DATA_STORAGE).createDirectory("plugins");
+ try {
+ await fsOperation(DATA_STORAGE).createDirectory("plugins");
+ } catch (error) {
+ logger.log(
+ "error",
+ `Failed to create plugins directory, falling back to internal storage: ${error.message || error}`,
+ );
+ window.DATA_STORAGE = dataDirectory;
+ window.CACHE_STORAGE = cacheDirectory;
+ window.PLUGIN_DIR = Url.join(window.DATA_STORAGE, "plugins");
+ window.KEYBINDING_FILE = Url.join(
+ window.DATA_STORAGE,
+ ".key-bindings.json",
+ );
+ await fsOperation(window.DATA_STORAGE).createDirectory("plugins");
+ }
}
localStorage.versionCode = versionCode;
diff --git a/www/index.html b/www/index.html
index 07f170909f..78fbbfd2b2 100644
--- a/www/index.html
+++ b/www/index.html
@@ -141,12 +141,26 @@
);
}
+ function isStorageError(reason) {
+ if (!reason) return false;
+ return (
+ reason.name === "FileError" ||
+ reason.name === "NotFoundError" ||
+ reason.name === "SecurityError" ||
+ (typeof window.FileError !== "undefined" &&
+ reason instanceof window.FileError)
+ );
+ }
+
function handleStartupError(event) {
if (!isStartupLoading()) return;
var message = event && event.message ? event.message : "Startup error";
setStartupMessage(
- "Acode failed to start. Update Android System WebView or Chrome. " +
- message
+ isStorageError(event && event.error)
+ ? "Acode failed to start: storage is unavailable. Try restarting your device, or clearing the app data. " +
+ message
+ : "Acode failed to start. Update Android System WebView or Chrome. " +
+ message
);
}
@@ -156,8 +170,11 @@
var message =
reason && reason.message ? reason.message : "Startup promise failed";
setStartupMessage(
- "Acode failed to start. Update Android System WebView or Chrome. " +
- message
+ isStorageError(reason)
+ ? "Acode failed to start: storage is unavailable. Try restarting your device, or clearing the app data. " +
+ message
+ : "Acode failed to start. Update Android System WebView or Chrome. " +
+ message
);
}
From d1a62491430ee73d10f77b3a8ad0c822d66742e6 Mon Sep 17 00:00:00 2001
From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com>
Date: Sat, 22 Aug 2026 02:49:37 +0000
Subject: [PATCH 02/38] fix(theme): prevent freeze when switching to system
theme (#2790)
---
src/theme/list.js | 14 +++++++++++---
src/theme/preInstalled.js | 5 +++--
2 files changed, 14 insertions(+), 5 deletions(-)
diff --git a/src/theme/list.js b/src/theme/list.js
index fca3f9c2cd..890ea6cd94 100644
--- a/src/theme/list.js
+++ b/src/theme/list.js
@@ -87,6 +87,11 @@ export async function apply(id, init) {
if (!DOES_SUPPORT_THEME) {
id = "default";
}
+ if (id.toLowerCase() === "system") {
+ // Refresh the mutable System theme before reading its preferred editor
+ // theme. Do not re-enter apply() while appTheme is being updated below.
+ updateSystemTheme(isDeviceDarkTheme(), false);
+ }
themeApplied = true;
const theme = get(id);
@@ -164,10 +169,10 @@ export function update(theme) {
});
}
-function syncSystemTheme(event) {
+function syncSystemTheme(event, applyTheme = true) {
if (settings.value.appTheme.toLowerCase() !== "system") return;
const isDark = event ? event.matches : darkModeMediaQuery.matches;
- updateSystemTheme(isDark);
+ updateSystemTheme(isDark, applyTheme);
}
function startSystemThemeWatcher() {
@@ -197,7 +202,10 @@ function stopSystemThemeWatcher() {
export function updateSystemThemeWatcher(theme) {
if (String(theme).toLowerCase() === "system") {
startSystemThemeWatcher();
- syncSystemTheme();
+ // Starting the watcher happens from the appTheme update listener. Applying
+ // the theme here would update appTheme again before the first settings
+ // update is saved, causing unbounded synchronous recursion.
+ syncSystemTheme(undefined, false);
return;
}
stopSystemThemeWatcher();
diff --git a/src/theme/preInstalled.js b/src/theme/preInstalled.js
index 8eb266e962..76120a1b16 100644
--- a/src/theme/preInstalled.js
+++ b/src/theme/preInstalled.js
@@ -243,8 +243,9 @@ export function getSystemEditorTheme(darkTheme) {
/**
* Update the system theme based on the user's preference.
* @param {boolean} darkTheme Whether the user prefers a dark theme.
+ * @param {boolean} applyTheme Whether to apply the refreshed theme immediately.
*/
-export function updateSystemTheme(darkTheme) {
+export function updateSystemTheme(darkTheme, applyTheme = true) {
if (darkTheme) {
system.type = "dark";
system.primaryColor = "rgb(35, 39, 42)";
@@ -278,7 +279,7 @@ export function updateSystemTheme(darkTheme) {
system.preferredEditorTheme = getSystemEditorTheme(darkTheme);
- if (appSettings?.value?.appTheme === "system") {
+ if (applyTheme && appSettings?.value?.appTheme === "system") {
apply(system.id);
appSettings.update({ editorTheme: system.preferredEditorTheme }, false);
}
From f90fc1d9d35b9096f0d4279410d7fa3d7cbf3835 Mon Sep 17 00:00:00 2001
From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com>
Date: Sat, 22 Aug 2026 03:41:23 +0000
Subject: [PATCH 03/38] fix(sftp): cancel stalled connections and prevent
delayed errors (#2791)
* fix(sftp): cancel stalled connections and prevent delayed errors
* fix race cases
---
src/dialogs/loader.js | 34 ++-
src/fileSystem/sftp.js | 17 ++
src/lib/remoteStorage.js | 16 +-
src/pages/fileBrowser/fileBrowser.js | 3 +-
src/plugins/sftp/index.d.ts | 4 +
.../sftp/src/com/foxdebug/sftp/Sftp.java | 259 +++++++++++++++---
src/plugins/sftp/www/sftp.js | 6 +
7 files changed, 282 insertions(+), 57 deletions(-)
diff --git a/src/dialogs/loader.js b/src/dialogs/loader.js
index e7a34cea40..dfb98946fe 100644
--- a/src/dialogs/loader.js
+++ b/src/dialogs/loader.js
@@ -6,14 +6,15 @@ import restoreTheme from "lib/restoreTheme";
let loaderIsImmortal = false;
let onCancelCallback = null;
+let cancelButtonTimeout = null;
let $currentDialog = null;
let $currentMask = null;
const titleLoaderId = "__title-loader";
/**
* @typedef {object} LoaderOptions
- * @property {number} timeout Timeout in milliseconds after which the loader will be shown
- * @property {function():void} oncancel Callback function to be called when the loader is shown
+ * @property {number} timeout Delay before the cancel button is shown, in milliseconds
+ * @property {function():void} oncancel Callback invoked only when the user cancels
*/
/**
@@ -64,19 +65,21 @@ function create(titleText, message = "", options = {}) {
);
- const { timeout, oncancel } = options;
- if (typeof oncancel === "function") {
- onCancelCallback = oncancel;
- }
+ clearTimeout(cancelButtonTimeout);
+ cancelButtonTimeout = null;
+ onCancelCallback =
+ typeof options.oncancel === "function" ? options.oncancel : null;
- if (typeof timeout === "number") {
- setTimeout(() => {
+ if (typeof options.timeout === "number") {
+ cancelButtonTimeout = setTimeout(() => {
+ cancelButtonTimeout = null;
+ if (!$dialog.isConnected) return;
$dialog.append(
-
+
,
);
- }, timeout);
+ }, options.timeout);
}
if (!$oldLoader) {
@@ -98,6 +101,13 @@ function create(titleText, message = "", options = {}) {
};
}
+function cancel() {
+ const callback = onCancelCallback;
+ onCancelCallback = null;
+ destroy();
+ callback?.();
+}
+
function createTitleLoader() {
const $titleLoader = tag.get(`#${titleLoaderId}`) || (
@@ -116,6 +126,9 @@ function createTitleLoader() {
function destroy() {
const loaderDiv = tag.get("#__loader");
const mask = tag.get("#__loader-mask");
+ clearTimeout(cancelButtonTimeout);
+ cancelButtonTimeout = null;
+ onCancelCallback = null;
restoreTheme();
if (!loaderDiv && !mask) {
@@ -128,7 +141,6 @@ function destroy() {
actionStack.unfreeze();
if (loaderDiv?.isConnected) loaderDiv.remove();
if (mask?.isConnected) mask.remove();
- onCancelCallback?.();
}, 300);
}
diff --git a/src/fileSystem/sftp.js b/src/fileSystem/sftp.js
index 41bff24af7..803be8af45 100644
--- a/src/fileSystem/sftp.js
+++ b/src/fileSystem/sftp.js
@@ -407,6 +407,23 @@ class SftpClient {
}
}
+ /**
+ * Tests a profile once without the normal remote-filesystem retry policy.
+ * @param {string} requestID Native request ID used for cancellation
+ */
+ testConnection(requestID) {
+ return new Promise((resolve, reject) => {
+ sftp.testProfile(this.#profileID, requestID, 10000, resolve, reject);
+ });
+ }
+
+ /** Cancel an in-flight profile test. */
+ cancelConnection(requestID) {
+ return new Promise((resolve) => {
+ sftp.cancelConnection(requestID, resolve, resolve);
+ });
+ }
+
async #connectWithRetry() {
const attempts = settings.value.retryRemoteFsAfterFail
? this.#MAX_TRY + 1
diff --git a/src/lib/remoteStorage.js b/src/lib/remoteStorage.js
index 83c8077b7f..967f5fcb5d 100644
--- a/src/lib/remoteStorage.js
+++ b/src/lib/remoteStorage.js
@@ -35,7 +35,7 @@ export default {
try {
loader.create(strings["add ftp"], strings["connecting..."], {
timeout: 10000,
- callback() {
+ oncancel() {
stopConnection = true;
},
});
@@ -176,6 +176,8 @@ export default {
existingProfile = null,
} = {}) {
let stopConnection = false;
+ let connection;
+ const connectionRequestID = `add-sftp-${helpers.uuid()}`;
if (existingProfile?.profileId) {
try {
const saved = await getSftpProfileInfo(existingProfile.profileId);
@@ -242,17 +244,21 @@ export default {
const url = createSftpProfileUrl(profile.profileId);
loader.create(strings["add sftp"], strings["connecting..."], {
- timeout: 10000,
- callback() {
+ timeout: 0,
+ oncancel() {
stopConnection = true;
+ connection?.cancelConnection(connectionRequestID);
},
});
- const connection = Sftp(null, 22, null, {
+ connection = Sftp(null, 22, null, {
profileID: profile.profileId,
});
try {
- const [home] = await Promise.all([connection.pwd(), loadAd()]);
+ const [home] = await Promise.all([
+ connection.testConnection(connectionRequestID),
+ loadAd(),
+ ]);
if (stopConnection) {
stopConnection = false;
diff --git a/src/pages/fileBrowser/fileBrowser.js b/src/pages/fileBrowser/fileBrowser.js
index c7c6ffaf9f..b26758b189 100644
--- a/src/pages/fileBrowser/fileBrowser.js
+++ b/src/pages/fileBrowser/fileBrowser.js
@@ -1468,8 +1468,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) {
const timeout = setTimeout(() => {
loader.create(name, strings.loading + "...", {
timeout: loaderTimeout,
- callback() {
- loader.destroy();
+ oncancel() {
navigate("/", "/");
progress[id] = false;
},
diff --git a/src/plugins/sftp/index.d.ts b/src/plugins/sftp/index.d.ts
index dc75b35360..ca7db1d098 100644
--- a/src/plugins/sftp/index.d.ts
+++ b/src/plugins/sftp/index.d.ts
@@ -42,6 +42,10 @@ interface Sftp {
exec(command: String, onSucess: (res: ExecResult)=>void, onFail: (err: any) => void): void;
/** Connects using credentials held by the native profile store. */
connectUsingProfile(profileId: String, onSuccess: () => void, onFail: (err: any) => void): void;
+ /** Tests a profile once and returns its remote working directory. */
+ testProfile(profileId: String, requestId: String, timeout: Number, onSuccess: (home: String) => void, onFail: (err: any) => void): void;
+ /** Cancels an in-flight profile connection or test. */
+ cancelConnection(requestId: String, onSuccess: () => void, onFail: (err: any) => void): void;
saveProfile(profileId: String | null, host: String, port: Number, username: String, authType: String, password: String, keyFile: String, passphrase: String, onSuccess: (profileId: String) => void, onFail: (err: any) => void): void;
editProfile(profileId: String | null, host: String, port: Number, username: String, authType: String, password: String, keyFile: String, passphrase: String, onSuccess: (profile: SftpProfileInfo & {profileId: string}) => void, onFail: (err: any) => void): void;
getProfileInfo(profileId: String, onSuccess: (profile: SftpProfileInfo & {profileId: string}) => void, onFail: (err: any) => void): void;
diff --git a/src/plugins/sftp/src/com/foxdebug/sftp/Sftp.java b/src/plugins/sftp/src/com/foxdebug/sftp/Sftp.java
index b676c1f059..a72466607d 100644
--- a/src/plugins/sftp/src/com/foxdebug/sftp/Sftp.java
+++ b/src/plugins/sftp/src/com/foxdebug/sftp/Sftp.java
@@ -53,6 +53,8 @@
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.FutureTask;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.atomic.AtomicBoolean;
import org.apache.cordova.CallbackContext;
@@ -72,8 +74,13 @@ public class Sftp extends CordovaPlugin {
// A smaller mobile window prevents connection bursts from exhausting the heap.
private static final long SFTP_MAX_WINDOW_SIZE = 1024L * 1024L;
private static final long SFTP_MIN_WINDOW_SIZE = 128L * 1024L;
+ private static final long DEFAULT_CONNECT_TIMEOUT_MS = 10000L;
+ private static final long MIN_CONNECT_TIMEOUT_MS = 1000L;
+ private static final long MAX_CONNECT_TIMEOUT_MS = 30000L;
private static boolean cryptoProviderConfigured;
private final Object connectionLock = new Object();
+ private final Map connectionAttempts =
+ new ConcurrentHashMap<>();
private final Map remoteShells = new ConcurrentHashMap<>();
private SshClient ssh;
private SftpClient sftp;
@@ -82,6 +89,50 @@ public class Sftp extends CordovaPlugin {
private String connectionID;
private SftpSecurityStore securityStore;
+ private final class ConnectionAttempt {
+
+ private final CallbackContext callback;
+ private final AtomicBoolean cancelled = new AtomicBoolean(false);
+ private final AtomicBoolean completed = new AtomicBoolean(false);
+ private volatile Future> future;
+
+ private ConnectionAttempt(CallbackContext callback) {
+ this.callback = callback;
+ }
+
+ private void setFuture(Future> future) {
+ this.future = future;
+ if (cancelled.get()) future.cancel(true);
+ }
+
+ private boolean isCancelled() {
+ return cancelled.get();
+ }
+
+ private void cancel() {
+ cancelled.set(true);
+ Future> current = future;
+ if (current != null) current.cancel(true);
+ error(connectionCancelledError());
+ }
+
+ private void success() {
+ if (completed.compareAndSet(false, true)) callback.success();
+ }
+
+ private void success(String value) {
+ if (completed.compareAndSet(false, true)) callback.success(value);
+ }
+
+ private void error(String message) {
+ if (completed.compareAndSet(false, true)) callback.error(message);
+ }
+
+ private void error(JSONObject error) {
+ if (completed.compareAndSet(false, true)) callback.error(error);
+ }
+ }
+
private final class ConnectionSecurity {
private final String hostname;
@@ -162,12 +213,17 @@ public boolean verifyHost(String host, SshPublicKey publicKey)
}
private boolean report(CallbackContext callback) {
- JSONObject error = failure;
- failure = null;
+ JSONObject error = takeFailure();
if (error == null) return false;
callback.error(error);
return true;
}
+
+ private JSONObject takeFailure() {
+ JSONObject error = failure;
+ failure = null;
+ return error;
+ }
}
private final class RemoteShell {
@@ -318,11 +374,16 @@ private void closeConnectionQuietly() {
private boolean establishConnection(
SshClientBuilder builder,
String newConnectionID,
- ConnectionSecurity security
+ ConnectionSecurity security,
+ ConnectionAttempt attempt
) throws IOException, SshException, PermissionDeniedException {
synchronized (connectionLock) {
closeConnectionQuietly();
ssh = builder.onConfigure(security::configure).build();
+ if (attempt.isCancelled()) {
+ closeConnectionQuietly();
+ return false;
+ }
if (!ssh.isConnected()) {
closeConnectionQuietly();
return false;
@@ -336,6 +397,11 @@ private boolean establishConnection(
throw e;
}
+ if (attempt.isCancelled()) {
+ closeConnectionQuietly();
+ return false;
+ }
+
try {
sftp.getSubsystemChannel().setCharsetEncoding("UTF-8");
} catch (UnsupportedEncodingException | SshException e) {
@@ -371,6 +437,17 @@ private static JSONObject hostKeyFailure(
return error;
}
+ private static JSONObject connectionCancelledError() {
+ JSONObject error = new JSONObject();
+ try {
+ error.put("code", "SFTP_CONNECT_CANCELLED");
+ error.put("message", "SFTP connection cancelled");
+ error.put("cancelled", true);
+ error.put("nonRetryable", true);
+ } catch (JSONException ignored) {}
+ return error;
+ }
+
private boolean confirmUnknownHost(
String endpoint,
String algorithm,
@@ -440,23 +517,40 @@ private void closeRemoteShells() {
remoteShells.clear();
}
+ private void cancelConnectionAttempts() {
+ for (ConnectionAttempt attempt : connectionAttempts.values()) {
+ attempt.cancel();
+ }
+ connectionAttempts.clear();
+ }
+
@Override
public void onReset() {
+ cancelConnectionAttempts();
closeRemoteShells();
super.onReset();
}
@Override
public void onDestroy() {
+ cancelConnectionAttempts();
closeRemoteShells();
super.onDestroy();
}
private SshClientBuilder buildProfileBuilder(JSONObject profile)
throws IOException, InvalidPassphraseException, JSONException {
+ return buildProfileBuilder(profile, DEFAULT_CONNECT_TIMEOUT_MS);
+ }
+
+ private SshClientBuilder buildProfileBuilder(
+ JSONObject profile,
+ long connectTimeout
+ ) throws IOException, InvalidPassphraseException, JSONException {
SshClientBuilder builder = SshClientBuilder.create()
.withHostname(profile.getString("hostname"))
.withPort(profile.optInt("port", 22))
+ .withConnectTimeout(connectTimeout)
.withUsername(profile.getString("username"));
if ("key".equals(profile.optString("authType"))) {
@@ -868,6 +962,8 @@ private static boolean isAllowedAction(String action) {
switch (action) {
case "exec":
case "connectUsingProfile":
+ case "testProfile":
+ case "cancelConnection":
case "saveProfile":
case "editProfile":
case "getProfileInfo":
@@ -894,47 +990,132 @@ private static boolean isAllowedAction(String action) {
}
public void connectUsingProfile(JSONArray args, CallbackContext callback) {
- cordova
- .getThreadPool()
- .execute(
- new Runnable() {
- public void run() {
- ConnectionSecurity security = null;
- String profileID = args.optString(0);
- try {
- JSONObject profile = securityStore.getProfile(profileID);
- ConnectionSecurity profileSecurity = new ConnectionSecurity(
- profile.getString("hostname"),
- profile.optInt("port", 22)
+ startProfileConnection(args, callback, false);
+ }
+
+ public void testProfile(JSONArray args, CallbackContext callback) {
+ startProfileConnection(args, callback, true);
+ }
+
+ public void cancelConnection(JSONArray args, CallbackContext callback) {
+ ConnectionAttempt attempt = connectionAttempts.get(args.optString(0));
+ if (attempt != null) attempt.cancel();
+ callback.success();
+ }
+
+ private void startProfileConnection(
+ JSONArray args,
+ CallbackContext callback,
+ boolean returnWorkingDirectory
+ ) {
+ String suppliedRequestID = args.optString(1);
+ String requestID = suppliedRequestID.isEmpty()
+ ? UUID.randomUUID().toString()
+ : suppliedRequestID;
+ long requestedTimeout = args.optLong(2, DEFAULT_CONNECT_TIMEOUT_MS);
+ long connectTimeout = Math.max(
+ MIN_CONNECT_TIMEOUT_MS,
+ Math.min(requestedTimeout, MAX_CONNECT_TIMEOUT_MS)
+ );
+ ConnectionAttempt attempt = new ConnectionAttempt(callback);
+ if (connectionAttempts.putIfAbsent(requestID, attempt) != null) {
+ callback.error("An SFTP connection with this request ID is already running");
+ return;
+ }
+
+ FutureTask task = new FutureTask(
+ () -> {
+ ConnectionSecurity security = null;
+ String profileID = args.optString(0);
+ try {
+ JSONObject profile = securityStore.getProfile(profileID);
+ ConnectionSecurity profileSecurity = new ConnectionSecurity(
+ profile.getString("hostname"),
+ profile.optInt("port", 22)
+ );
+ security = profileSecurity;
+ SshClientBuilder builder = buildProfileBuilder(
+ profile,
+ connectTimeout
+ );
+ boolean connected;
+ String workingDirectory = null;
+ if (returnWorkingDirectory) {
+ synchronized (connectionLock) {
+ connected = establishConnection(
+ builder,
+ profileID,
+ profileSecurity,
+ attempt
);
- security = profileSecurity;
- if (
- establishConnection(
- buildProfileBuilder(profile),
- profileID,
- profileSecurity
- )
- ) {
- callback.success();
- return;
- }
- if (security.report(callback)) return;
- callback.error("Failed to establish SSH connection");
- } catch (InvalidPassphraseException e) {
- callback.error("Invalid passphrase for stored key");
- } catch (Exception e) {
- if (security != null && security.report(callback)) return;
- callback.error("Failed to connect SFTP profile: " + errMessage(e));
- Log.e(TAG, "Failed to connect SFTP profile", e);
- } catch (OutOfMemoryError e) {
- synchronized (connectionLock) {
- closeConnectionQuietly();
+ if (connected && !attempt.isCancelled()) {
+ SftpClient activeSftp = sftp;
+ if (activeSftp == null) {
+ throw new IOException(
+ "SFTP connection was closed before validation"
+ );
+ }
+ workingDirectory = activeSftp.pwd();
}
- callback.error("Not enough memory to initialize SFTP");
}
+ } else {
+ connected = establishConnection(
+ builder,
+ profileID,
+ profileSecurity,
+ attempt
+ );
+ }
+ if (connected) {
+ if (attempt.isCancelled()) return null;
+ if (returnWorkingDirectory) {
+ attempt.success(workingDirectory);
+ } else {
+ attempt.success();
+ }
+ return null;
+ }
+ if (attempt.isCancelled()) return null;
+ JSONObject securityError = security.takeFailure();
+ if (securityError != null) {
+ attempt.error(securityError);
+ return null;
+ }
+ attempt.error("Failed to establish SSH connection");
+ } catch (InvalidPassphraseException e) {
+ if (!attempt.isCancelled()) {
+ attempt.error("Invalid passphrase for stored key");
+ }
+ } catch (Exception e) {
+ if (attempt.isCancelled()) return null;
+ JSONObject securityError = security == null
+ ? null
+ : security.takeFailure();
+ if (securityError != null) {
+ attempt.error(securityError);
+ return null;
+ }
+ attempt.error("Failed to connect SFTP profile: " + errMessage(e));
+ Log.e(TAG, "Failed to connect SFTP profile", e);
+ } catch (OutOfMemoryError e) {
+ synchronized (connectionLock) {
+ closeConnectionQuietly();
+ }
+ if (!attempt.isCancelled()) {
+ attempt.error("Not enough memory to initialize SFTP");
}
}
- );
+ return null;
+ }
+ ) {
+ @Override
+ protected void done() {
+ connectionAttempts.remove(requestID, attempt);
+ }
+ };
+
+ attempt.setFuture(task);
+ cordova.getThreadPool().execute(task);
}
public void exec(JSONArray args, CallbackContext callback) {
diff --git a/src/plugins/sftp/www/sftp.js b/src/plugins/sftp/www/sftp.js
index 1fbbb76e31..855cf1cdf6 100644
--- a/src/plugins/sftp/www/sftp.js
+++ b/src/plugins/sftp/www/sftp.js
@@ -5,6 +5,12 @@ module.exports = {
connectUsingProfile: function (profileId, onSuccess, onFail) {
cordova.exec(onSuccess, onFail, 'Sftp', 'connectUsingProfile', [profileId]);
},
+ testProfile: function (profileId, requestId, timeout, onSuccess, onFail) {
+ cordova.exec(onSuccess, onFail, 'Sftp', 'testProfile', [profileId, requestId, timeout]);
+ },
+ cancelConnection: function (requestId, onSuccess, onFail) {
+ cordova.exec(onSuccess, onFail, 'Sftp', 'cancelConnection', [requestId]);
+ },
saveProfile: function (profileId, host, port, username, authType, password, keyFile, passphrase, onSuccess, onFail) {
cordova.exec(onSuccess, onFail, 'Sftp', 'saveProfile', [profileId, host, port, username, authType, password, keyFile, passphrase]);
},
From 9c3e86911107ebb1520c7aa83e46514d83d08e68 Mon Sep 17 00:00:00 2001
From: Ajit Kumar
Date: Mon, 24 Aug 2026 09:51:12 +0000
Subject: [PATCH 04/38] fix: admob issues (#2800)
* fix: admob issues
* fix: restore Android build commands
---------
Co-authored-by: Ajit Kumar
---
src/components/settingsPage.scss | 5 +
src/lib/adConsentCoordinator.mjs | 10 +-
src/lib/bannerVisibilityController.mjs | 99 ++++++++-
src/lib/startAd.js | 4 +-
.../admob/src/android/cordova/Privacy.kt | 24 ++-
src/settings/mainSettings.js | 11 +-
src/sidebarApps/extensions/index.js | 21 +-
tests/admob/adConsentCoordinator.test.js | 73 ++++++-
.../admob/bannerVisibilityController.test.js | 202 +++++++++++++++++-
9 files changed, 420 insertions(+), 29 deletions(-)
diff --git a/src/components/settingsPage.scss b/src/components/settingsPage.scss
index bfe722e73e..e0e9f34b55 100644
--- a/src/components/settingsPage.scss
+++ b/src/components/settingsPage.scss
@@ -60,6 +60,11 @@ wc-page.main-settings-page {
.main-settings-list>.list-item,
.settings-section-card>.list-item {
display: flex;
+
+ &[hidden] {
+ display: none;
+ }
+
width: 100%;
min-height: 64px;
margin: 0;
diff --git a/src/lib/adConsentCoordinator.mjs b/src/lib/adConsentCoordinator.mjs
index 5a23de1737..4391e22aac 100644
--- a/src/lib/adConsentCoordinator.mjs
+++ b/src/lib/adConsentCoordinator.mjs
@@ -35,6 +35,7 @@ export class AdConsentCoordinator {
#state = EMPTY_PRIVACY_STATE;
#consentPromise;
#adStartPromise;
+ #privacyOptionsPromise;
#listeners = new Set();
constructor({ privacy, initializeAds, onError = console.error }) {
@@ -51,7 +52,14 @@ export class AdConsentCoordinator {
return (this.#consentPromise ??= this.#gatherAndStart());
}
- async showPrivacyOptions() {
+ showPrivacyOptions() {
+ this.#privacyOptionsPromise ??= this.#showPrivacyOptions().finally(() => {
+ this.#privacyOptionsPromise = undefined;
+ });
+ return this.#privacyOptionsPromise;
+ }
+
+ async #showPrivacyOptions() {
const state = await this.#privacy.showOptions();
this.#setState(state);
await this.#startAdsIfAllowed();
diff --git a/src/lib/bannerVisibilityController.mjs b/src/lib/bannerVisibilityController.mjs
index c02df5b80b..517e914d02 100644
--- a/src/lib/bannerVisibilityController.mjs
+++ b/src/lib/bannerVisibilityController.mjs
@@ -10,6 +10,9 @@ export const BANNER_SUPPRESSION_REASON = Object.freeze({
REWARDED_PASS: "rewarded-pass",
});
+const DEFAULT_RETRY_DELAY_MS = 500;
+const TRANSIENT_LOAD_ERROR_CODES = new Set([0, 2, 3, 9]);
+
export class BannerVisibilityController {
#banner = null;
#registeredPages = new WeakSet();
@@ -20,9 +23,13 @@ export class BannerVisibilityController {
#suppressions = new Set();
#nativeVisible = false;
#scheduledVisible = false;
+ #desiredVisible = false;
#operation = Promise.resolve();
#revision = 0;
#onError;
+ #retryTimer = null;
+ #retryUsed = false;
+ #stopBannerListeners = [];
constructor({
getActivePage,
@@ -35,9 +42,13 @@ export class BannerVisibilityController {
}
setBanner(banner) {
+ this.#stopListeningToBanner();
+ this.#resetRetry();
+ this.#revision++;
this.#banner = banner;
this.#nativeVisible = false;
this.#scheduledVisible = false;
+ this.#listenToBanner(banner);
this.reconcile();
}
@@ -83,6 +94,12 @@ export class BannerVisibilityController {
activePage !== null &&
this.#registeredPages.has(activePage);
const shouldShow = pageRequestsBanner && !this.#keyboardVisible;
+ const eligibilityChanged = shouldShow !== this.#desiredVisible;
+ this.#desiredVisible = shouldShow;
+
+ if (!shouldShow || eligibilityChanged) {
+ this.#resetRetry();
+ }
if (this.#banner) {
this.#banner.active = pageRequestsBanner;
@@ -98,7 +115,12 @@ export class BannerVisibilityController {
dispose() {
this.#stopObserving?.();
this.#stopObserving = null;
+ this.#stopListeningToBanner();
+ this.#resetRetry();
this.#banner = null;
+ this.#desiredVisible = false;
+ this.#nativeVisible = false;
+ this.#scheduledVisible = false;
this.#revision++;
}
@@ -107,9 +129,16 @@ export class BannerVisibilityController {
this.#stopObserving = this.#observePageChanges(() => this.reconcile());
}
- #queueNativeVisibility(shouldShow) {
+ #queueNativeVisibility(shouldShow, isRetry = false) {
const banner = this.#banner;
- if (!banner || shouldShow === this.#scheduledVisible) return;
+ if (
+ !banner ||
+ shouldShow === this.#scheduledVisible ||
+ (shouldShow &&
+ (this.#retryTimer !== null || (this.#retryUsed && !isRetry)))
+ ) {
+ return;
+ }
this.#scheduledVisible = shouldShow;
const revision = ++this.#revision;
@@ -124,14 +153,80 @@ export class BannerVisibilityController {
} else {
await banner.hide?.();
}
+ if (revision !== this.#revision || banner !== this.#banner) return;
this.#nativeVisible = shouldShow;
} catch (error) {
+ if (revision !== this.#revision || banner !== this.#banner) return;
this.#nativeVisible = !shouldShow;
this.#scheduledVisible = !shouldShow;
this.#onError(error);
+ if (shouldShow) this.#scheduleRetry();
}
});
}
+
+ #listenToBanner(banner) {
+ if (typeof banner?.on !== "function") return;
+
+ for (const [eventName, listener] of [
+ ["load", () => this.#handleBannerLoad()],
+ ["loadfail", (event) => this.#handleBannerLoadFailure(event)],
+ ]) {
+ const stopListening = banner.on(eventName, listener);
+ if (typeof stopListening === "function") {
+ this.#stopBannerListeners.push(stopListening);
+ }
+ }
+ }
+
+ #stopListeningToBanner() {
+ for (const stopListening of this.#stopBannerListeners.splice(0)) {
+ stopListening();
+ }
+ }
+
+ #handleBannerLoad() {
+ this.#resetRetry();
+ if (!this.#desiredVisible) return;
+
+ this.#nativeVisible = true;
+ this.#scheduledVisible = true;
+ }
+
+ #handleBannerLoadFailure(event) {
+ this.#nativeVisible = false;
+ this.#scheduledVisible = false;
+ this.#onError(event);
+
+ const errorCode = Number(event?.code);
+ if (TRANSIENT_LOAD_ERROR_CODES.has(errorCode)) {
+ this.#scheduleRetry();
+ } else {
+ this.#retryUsed = true;
+ }
+ }
+
+ #scheduleRetry() {
+ if (!this.#desiredVisible || this.#retryTimer !== null || this.#retryUsed) {
+ return;
+ }
+
+ this.#retryUsed = true;
+ const banner = this.#banner;
+ this.#retryTimer = setTimeout(() => {
+ this.#retryTimer = null;
+ if (!this.#desiredVisible || banner !== this.#banner) return;
+ this.#queueNativeVisibility(true, true);
+ }, DEFAULT_RETRY_DELAY_MS);
+ }
+
+ #resetRetry() {
+ if (this.#retryTimer !== null) {
+ clearTimeout(this.#retryTimer);
+ this.#retryTimer = null;
+ }
+ this.#retryUsed = false;
+ }
}
function getActivePage() {
diff --git a/src/lib/startAd.js b/src/lib/startAd.js
index a9dd6a74d5..757dc69429 100644
--- a/src/lib/startAd.js
+++ b/src/lib/startAd.js
@@ -63,7 +63,9 @@ export function subscribePrivacyState(listener) {
}
export async function showPrivacyOptions() {
- if (!canUseAdmob()) return getPrivacyState();
+ if (!canUseAdmob()) {
+ throw new Error("AdMob Privacy Choices are unavailable.");
+ }
return getConsentCoordinator().showPrivacyOptions();
}
diff --git a/src/plugins/admob/src/android/cordova/Privacy.kt b/src/plugins/admob/src/android/cordova/Privacy.kt
index 0d34ccf3c6..b2c2264a53 100644
--- a/src/plugins/admob/src/android/cordova/Privacy.kt
+++ b/src/plugins/admob/src/android/cordova/Privacy.kt
@@ -80,12 +80,26 @@ internal class Privacy(private val plugin: AdMob) {
fun showOptions(ctx: ExecuteContext) {
plugin.activity.runOnUiThread {
- UserMessagingPlatform.showPrivacyOptionsForm(plugin.activity) { formError ->
- if (formError != null) {
- ctx.reject("UMP ${formError.errorCode}: ${formError.message}")
- } else {
- ctx.resolve(currentState().toMap())
+ val activity = plugin.activity
+ val state = currentState()
+ if (!state.privacyOptionsRequired) {
+ return@runOnUiThread ctx.resolve(state.toMap())
+ }
+
+ if (activity.isFinishing || activity.isDestroyed) {
+ return@runOnUiThread ctx.reject("Privacy Choices are temporarily unavailable")
+ }
+
+ try {
+ UserMessagingPlatform.showPrivacyOptionsForm(activity) { formError ->
+ if (formError != null) {
+ ctx.reject("UMP ${formError.errorCode}: ${formError.message}")
+ } else {
+ ctx.resolve(currentState().toMap())
+ }
}
+ } catch (error: Exception) {
+ ctx.reject(error.message ?: "Unable to open Privacy Choices")
}
}
}
diff --git a/src/settings/mainSettings.js b/src/settings/mainSettings.js
index 2946f57c72..7ce6cf196b 100644
--- a/src/settings/mainSettings.js
+++ b/src/settings/mainSettings.js
@@ -251,10 +251,19 @@ export default function mainSettings() {
break;
case "privacyChoices":
+ loader.create(
+ strings["privacy choices"] || "Privacy Choices",
+ strings["loading..."] || "Loading...",
+ );
try {
await showPrivacyOptions();
} catch (error) {
- helpers.error(error);
+ console.warn("Unable to open AdMob Privacy Choices:", error);
+ helpers.error(
+ "Unable to open Privacy Choices. Check your connection and try again.",
+ );
+ } finally {
+ loader.destroy();
}
break;
diff --git a/src/sidebarApps/extensions/index.js b/src/sidebarApps/extensions/index.js
index e83360dcf8..72ed4378e7 100644
--- a/src/sidebarApps/extensions/index.js
+++ b/src/sidebarApps/extensions/index.js
@@ -836,11 +836,14 @@ function ListItem({
});
const { default: installPlugin } = await import("lib/installPlugin");
- await installPlugin(
- id,
- remotePlugin.name,
- purchaseToken ? purchaseToken : undefined,
- );
+ await Promise.all([
+ loadAd(),
+ installPlugin(
+ id,
+ remotePlugin.name,
+ purchaseToken ? purchaseToken : undefined,
+ ),
+ ]);
const searchInput = container.querySelector('input[name="search-ext"]');
if (searchInput) {
@@ -862,6 +865,7 @@ function ListItem({
if (!$installed.collapsed) {
$installed.ontoggle();
}
+ await helpers.showInterstitialIfReady();
async function getPurchase(sku) {
const purchases = await helpers.promisify(iap.getPurchases);
@@ -910,14 +914,11 @@ function ListItem({
return $el;
}
-async function loadAd(el) {
+async function loadAd() {
if (!helpers.canShowAds()) return;
try {
if (!(await interstitialAd?.isLoaded())) {
- const oldText = el.textContent;
- el.textContent = strings["loading..."];
await interstitialAd?.load();
- el.textContent = oldText;
}
} catch (error) {
console.error(error);
@@ -929,7 +930,7 @@ async function uninstall(id) {
const pluginDir = Url.join(PLUGIN_DIR, id);
const state = await InstallState.new(id);
await Promise.all([
- loadAd(this),
+ loadAd(),
fsOperation(pluginDir).delete(),
state.delete(state.storeUrl),
]);
diff --git a/tests/admob/adConsentCoordinator.test.js b/tests/admob/adConsentCoordinator.test.js
index cb4fe07c70..ecd5a2f7fc 100644
--- a/tests/admob/adConsentCoordinator.test.js
+++ b/tests/admob/adConsentCoordinator.test.js
@@ -9,6 +9,7 @@ function createHarness({
gatherState,
previousState = EMPTY_PRIVACY_STATE,
gatherError,
+ showOptions,
}) {
let gatherCalls = 0;
let getStateCalls = 0;
@@ -25,6 +26,7 @@ function createHarness({
return previousState;
},
async showOptions() {
+ if (showOptions) return showOptions();
return gatherState;
},
};
@@ -141,12 +143,12 @@ test("deduplicates consent collection and ad initialization", async () => {
assert.equal(harness.initializeCalls, 1);
});
-test("updates privacy-option visibility and keeps ads single-started", async () => {
+test("hides a stale privacy-option entry and keeps ads single-started", async () => {
const harness = createHarness({
gatherState: {
- consentStatus: "notRequired",
+ consentStatus: "obtained",
canRequestAds: true,
- privacyOptionsRequired: false,
+ privacyOptionsRequired: true,
},
});
const states = [];
@@ -154,15 +156,15 @@ test("updates privacy-option visibility and keeps ads single-started", async ()
await harness.coordinator.start();
harness.privacy.showOptions = async () => ({
- consentStatus: "obtained",
+ consentStatus: "notRequired",
canRequestAds: true,
- privacyOptionsRequired: true,
+ privacyOptionsRequired: false,
});
await harness.coordinator.showPrivacyOptions();
unsubscribe();
assert.equal(harness.initializeCalls, 1);
- assert.equal(states.at(-1).privacyOptionsRequired, true);
+ assert.equal(states.at(-1).privacyOptionsRequired, false);
});
test("normalizes an invalid native state instead of starting ads", async () => {
@@ -177,3 +179,62 @@ test("normalizes an invalid native state instead of starting ads", async () => {
assert.deepEqual(await harness.coordinator.start(), EMPTY_PRIVACY_STATE);
assert.equal(harness.initializeCalls, 0);
});
+
+test("deduplicates concurrent privacy-option requests", async () => {
+ let resolveOptions;
+ let showOptionsCalls = 0;
+ const optionsResult = new Promise((resolve) => {
+ resolveOptions = resolve;
+ });
+ const harness = createHarness({
+ gatherState: {
+ consentStatus: "obtained",
+ canRequestAds: true,
+ privacyOptionsRequired: true,
+ },
+ showOptions() {
+ showOptionsCalls++;
+ return optionsResult;
+ },
+ });
+ await harness.coordinator.start();
+
+ const firstRequest = harness.coordinator.showPrivacyOptions();
+ const secondRequest = harness.coordinator.showPrivacyOptions();
+ assert.equal(firstRequest, secondRequest);
+ resolveOptions({
+ consentStatus: "obtained",
+ canRequestAds: true,
+ privacyOptionsRequired: false,
+ });
+
+ assert.deepEqual(await firstRequest, {
+ consentStatus: "obtained",
+ canRequestAds: true,
+ privacyOptionsRequired: false,
+ });
+ assert.equal(showOptionsCalls, 1);
+});
+
+test("preserves consent state when privacy options reject", async () => {
+ const gatherState = {
+ consentStatus: "obtained",
+ canRequestAds: true,
+ privacyOptionsRequired: true,
+ };
+ const optionsError = new Error("native failure");
+ const harness = createHarness({
+ gatherState,
+ showOptions: async () => {
+ throw optionsError;
+ },
+ });
+ await harness.coordinator.start();
+
+ await assert.rejects(
+ harness.coordinator.showPrivacyOptions(),
+ (error) => error === optionsError,
+ );
+ assert.deepEqual(harness.coordinator.state, gatherState);
+ assert.equal(harness.initializeCalls, 1);
+});
diff --git a/tests/admob/bannerVisibilityController.test.js b/tests/admob/bannerVisibilityController.test.js
index cb9d52c347..1ad63d396c 100644
--- a/tests/admob/bannerVisibilityController.test.js
+++ b/tests/admob/bannerVisibilityController.test.js
@@ -1,21 +1,34 @@
import assert from "node:assert/strict";
-import { test } from "vitest";
+import { afterEach, test, vi } from "vitest";
import {
BANNER_SUPPRESSION_REASON,
BannerVisibilityController,
} from "../../src/lib/bannerVisibilityController.mjs";
-function createHarness() {
+function createHarness({ show, hide } = {}) {
let activePage = null;
let notifyPageChange = () => {};
const calls = [];
+ const errors = [];
+ const listeners = new Map();
const banner = {
active: false,
async show() {
calls.push("show");
+ return show?.();
},
async hide() {
calls.push("hide");
+ return hide?.();
+ },
+ on(eventName, listener) {
+ let eventListeners = listeners.get(eventName);
+ if (!eventListeners) {
+ eventListeners = new Set();
+ listeners.set(eventName, eventListeners);
+ }
+ eventListeners.add(listener);
+ return () => eventListeners.delete(listener);
},
};
const controller = new BannerVisibilityController({
@@ -27,7 +40,7 @@ function createHarness() {
};
},
onError(error) {
- throw error;
+ errors.push(error);
},
});
controller.setBanner(banner);
@@ -36,6 +49,10 @@ function createHarness() {
banner,
calls,
controller,
+ emit(eventName, event = {}) {
+ for (const listener of listeners.get(eventName) ?? []) listener(event);
+ },
+ errors,
changePage(page) {
activePage = page;
notifyPageChange();
@@ -46,6 +63,19 @@ function createHarness() {
};
}
+async function createEligibleHarness(options) {
+ const harness = createHarness(options);
+ const page = {};
+ harness.setActivePage(page);
+ harness.controller.registerPage(page);
+ await harness.controller.whenIdle();
+ return { harness, page };
+}
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
test("shows only for a registered active page and ignores repeated syncs", async () => {
const harness = createHarness();
const page = {};
@@ -234,3 +264,169 @@ test("serializes an in-flight show before the latest hide request", async () =>
assert.equal(banner.active, false);
assert.deepEqual(calls, ["show", "hide"]);
});
+
+test("retries one transient failure after 500ms and caps requests at two", async () => {
+ vi.useFakeTimers();
+ const { harness } = await createEligibleHarness();
+
+ harness.emit("loadfail", { code: 3 });
+ harness.emit("loadfail", { code: 3 });
+ await vi.advanceTimersByTimeAsync(499);
+ assert.deepEqual(harness.calls, ["show"]);
+ await vi.advanceTimersByTimeAsync(1);
+ await harness.controller.whenIdle();
+
+ harness.emit("loadfail", { code: 3 });
+ harness.controller.reconcile();
+ await vi.advanceTimersByTimeAsync(500);
+ await harness.controller.whenIdle();
+
+ assert.deepEqual(harness.calls, ["show", "show"]);
+ assert.equal(harness.errors.length, 3);
+});
+
+test("successful load restores the retry allowance", async () => {
+ vi.useFakeTimers();
+ const { harness } = await createEligibleHarness();
+
+ harness.emit("loadfail", { code: 3 });
+ await vi.advanceTimersByTimeAsync(500);
+ await harness.controller.whenIdle();
+ harness.emit("load");
+ harness.emit("loadfail", { code: 3 });
+ await vi.advanceTimersByTimeAsync(500);
+ await harness.controller.whenIdle();
+
+ assert.deepEqual(harness.calls, ["show", "show", "show"]);
+});
+
+test("cancels a retry on navigation and restores it when the page returns", async () => {
+ vi.useFakeTimers();
+ const { harness, page } = await createEligibleHarness();
+
+ harness.emit("loadfail", { code: 3 });
+ harness.changePage({});
+ await harness.controller.whenIdle();
+ await vi.advanceTimersByTimeAsync(500);
+ assert.deepEqual(harness.calls, ["show"]);
+
+ harness.changePage(page);
+ await harness.controller.whenIdle();
+ harness.emit("loadfail", { code: 3 });
+ await vi.advanceTimersByTimeAsync(500);
+ await harness.controller.whenIdle();
+
+ assert.deepEqual(harness.calls, ["show", "show", "show"]);
+});
+
+test("does not retry non-transient load failures", async () => {
+ vi.useFakeTimers();
+ const { harness } = await createEligibleHarness();
+
+ harness.emit("loadfail", { code: 1 });
+ harness.controller.reconcile();
+ await vi.advanceTimersByTimeAsync(500);
+ await harness.controller.whenIdle();
+
+ assert.deepEqual(harness.calls, ["show"]);
+});
+
+test("retries a rejected native show call", async () => {
+ vi.useFakeTimers();
+ let showCalls = 0;
+ const { harness } = await createEligibleHarness({
+ show() {
+ showCalls++;
+ if (showCalls === 1) throw new Error("bridge failure");
+ },
+ });
+
+ await vi.advanceTimersByTimeAsync(500);
+ await harness.controller.whenIdle();
+
+ assert.deepEqual(harness.calls, ["show", "show"]);
+ assert.equal(harness.errors.length, 1);
+});
+
+test("keeps a rejected hide operation eligible for reconciliation", async () => {
+ let hideCalls = 0;
+ const { harness } = await createEligibleHarness({
+ hide() {
+ hideCalls++;
+ if (hideCalls === 1) throw new Error("bridge failure");
+ },
+ });
+
+ harness.controller.setKeyboardVisible(true);
+ await harness.controller.whenIdle();
+ harness.controller.reconcile();
+ await harness.controller.whenIdle();
+
+ assert.deepEqual(harness.calls, ["show", "hide", "hide"]);
+ assert.equal(harness.errors.length, 1);
+});
+
+test("replacement and disposal cancel retries and detach listeners", async () => {
+ vi.useFakeTimers();
+ const { harness } = await createEligibleHarness();
+ harness.emit("loadfail", { code: 3 });
+
+ const replacementCalls = [];
+ const replacementListeners = new Map();
+ harness.controller.setBanner({
+ active: false,
+ async hide() {
+ replacementCalls.push("hide");
+ },
+ on(eventName, listener) {
+ replacementListeners.set(eventName, listener);
+ return () => replacementListeners.delete(eventName);
+ },
+ async show() {
+ replacementCalls.push("show");
+ },
+ });
+ await harness.controller.whenIdle();
+ const errorsBeforeStaleEvent = harness.errors.length;
+ harness.emit("loadfail", { code: 3 });
+ replacementListeners.get("loadfail")({ code: 3 });
+ harness.controller.dispose();
+ replacementListeners.get("loadfail")?.({ code: 3 });
+ await vi.advanceTimersByTimeAsync(500);
+
+ assert.deepEqual(replacementCalls, ["show"]);
+ assert.equal(harness.errors.length, errorsBeforeStaleEvent + 1);
+});
+
+test("ignores a stale show rejection after replacing the banner", async () => {
+ let rejectOldShow;
+ const harness = createHarness({
+ show: () =>
+ new Promise((_, reject) => {
+ rejectOldShow = reject;
+ }),
+ });
+ const page = {};
+ harness.setActivePage(page);
+ harness.controller.registerPage(page);
+ await new Promise((resolve) => setImmediate(resolve));
+
+ const replacementCalls = [];
+ harness.controller.setBanner({
+ active: false,
+ async hide() {
+ replacementCalls.push("hide");
+ },
+ on() {
+ return () => {};
+ },
+ async show() {
+ replacementCalls.push("show");
+ },
+ });
+ rejectOldShow(new Error("stale bridge failure"));
+ await harness.controller.whenIdle();
+
+ assert.deepEqual(replacementCalls, ["show"]);
+ assert.deepEqual(harness.errors, []);
+});
From 4ee4fa382b2c2ad89525e8403587d0921fa1fe37 Mon Sep 17 00:00:00 2001
From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com>
Date: Tue, 25 Aug 2026 14:25:08 +0000
Subject: [PATCH 05/38] fix(android): avoid splash surface crash on realme
Android 13 (#2805)
---
hooks/post-process.js | 74 +++++++++++++++++++++++++++++++++++++++++++
1 file changed, 74 insertions(+)
diff --git a/hooks/post-process.js b/hooks/post-process.js
index 16f310469e..51bd6b3901 100644
--- a/hooks/post-process.js
+++ b/hooks/post-process.js
@@ -27,6 +27,7 @@ fs.copyFileSync(gradleFilePath, androidGradleFilePath);
copyDirRecursively(localResPath, resPath);
enableLegacyJni();
enableStaticContext();
+disableSplashFadeOnRealmeAndroid13();
removeLegacyKeyboardWorkaround();
patchTargetSdkVersion();
@@ -210,6 +211,79 @@ function enableStaticContext() {
}
}
+function disableSplashFadeOnRealmeAndroid13() {
+ try {
+ const prefix = execSync('npm prefix').toString().trim();
+ const packageName = getPackageName();
+ const mainActivityPath = path.join(
+ prefix,
+ 'platforms/android/app/src/main/java',
+ packageName.replace(/\./g, '/'),
+ 'MainActivity.java'
+ );
+
+ if (!fs.existsSync(mainActivityPath)) {
+ console.warn('[Cordova Hook] ⚠️ MainActivity.java not found at', mainActivityPath);
+ return;
+ }
+
+ let content = fs.readFileSync(mainActivityPath, 'utf-8');
+
+ if (
+ content.includes('hasBrokenRealmeSplashTransfer()') &&
+ content.includes('preferences.set("FadeSplashScreen", false);')
+ ) {
+ console.log('[Cordova Hook] ✅ realme splash workaround already enabled, skipping');
+ return;
+ }
+
+ if (!content.includes('import android.os.Build;')) {
+ content = content.replace(
+ /import android\.os\.Bundle;/,
+ 'import android.os.Build;\nimport android.os.Bundle;'
+ );
+ }
+
+ content = content.replace(
+ /\n\s*@Override\n\s*public void onCreate\(Bundle savedInstanceState\)/,
+ `
+
+ private static boolean hasBrokenRealmeSplashTransfer() {
+ return Build.VERSION.SDK_INT == Build.VERSION_CODES.TIRAMISU
+ && ("realme".equalsIgnoreCase(Build.MANUFACTURER)
+ || "realme".equalsIgnoreCase(Build.BRAND));
+ }
+
+ @Override
+ protected void loadConfig() {
+ super.loadConfig();
+
+ // Some realme Android 13 builds pass a null SurfaceControl while handing the
+ // splash view to an app-provided exit animation. Avoid only that handoff.
+ if (hasBrokenRealmeSplashTransfer()) {
+ preferences.set("FadeSplashScreen", false);
+ }
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState)`
+ );
+
+ if (!content.includes('preferences.set("FadeSplashScreen", false);')) {
+ console.warn('[Cordova Hook] ⚠️ Unable to inject realme splash workaround');
+ return;
+ }
+
+ fs.writeFileSync(mainActivityPath, content, 'utf-8');
+ console.log('[Cordova Hook] ✅ Disabled splash fade on realme Android 13');
+ } catch (err) {
+ console.error(
+ '[Cordova Hook] ❌ Failed to patch realme splash workaround:',
+ err.message
+ );
+ }
+}
+
function removeLegacyKeyboardWorkaround() {
try {
const prefix = execSync('npm prefix').toString().trim();
From 8d7148a6d5f8640e78f90290d6f1a5131a22d0ca Mon Sep 17 00:00:00 2001
From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com>
Date: Wed, 26 Aug 2026 06:43:45 +0000
Subject: [PATCH 06/38] fix(session): restore picker documents and skip intent
files tab (#2809)
* fix(session): restore picker documents and skip intent files tab
* feat(file-browser): expose document picker(icon) from nested folders
* fix
---
src/handlers/intent.js | 23 +--
src/lib/editorFile.js | 3 +
src/lib/fileSessionPersistence.js | 13 ++
src/lib/openFile.js | 15 +-
src/lib/saveState.js | 2 +-
src/pages/fileBrowser/fileBrowser.js | 64 +++++---
src/pages/fileBrowser/index.js | 7 +-
src/plugins/sdcard/index.d.ts | 1 +
src/plugins/sdcard/src/android/SDcard.java | 36 +++--
tests/unit/sessionPersistence.test.js | 165 +++++++++++++++++++++
10 files changed, 284 insertions(+), 45 deletions(-)
create mode 100644 src/lib/fileSessionPersistence.js
create mode 100644 tests/unit/sessionPersistence.test.js
diff --git a/src/handlers/intent.js b/src/handlers/intent.js
index fc5f5b9cb1..78c0009ab6 100644
--- a/src/handlers/intent.js
+++ b/src/handlers/intent.js
@@ -17,11 +17,14 @@ const pendingIntents = [];
* @param {Intent} intent
*/
export default async function HandleIntent(intent = {}) {
- const type = intent.action.split(".").slice(-1)[0];
+ const type = intent.action?.split(".").slice(-1)[0];
if (["SEND", "VIEW", "EDIT"].includes(type)) {
/**@type {string} */
- const url = intent.fileUri || intent.data;
+ const url =
+ intent.fileUri ||
+ intent.data ||
+ intent.extras?.["android.intent.extra.STREAM"];
if (!url) return;
if (url.startsWith("acode://")) {
@@ -72,19 +75,19 @@ export default async function HandleIntent(intent = {}) {
return;
}
+ const options = {
+ mode: "single",
+ render: true,
+ persistInSession: false,
+ };
+
if (sessionStorage.getItem("isfilesRestored") === "true") {
- await openFile(url, {
- mode: "single",
- render: true,
- });
+ await openFile(url, options);
} else {
// Store the intent for later processing when files are restored
pendingIntents.push({
url,
- options: {
- mode: "single",
- render: true,
- },
+ options,
});
}
}
diff --git a/src/lib/editorFile.js b/src/lib/editorFile.js
index 2aee9b500f..df7abed821 100644
--- a/src/lib/editorFile.js
+++ b/src/lib/editorFile.js
@@ -330,6 +330,7 @@ function maybeRecommendLanguageModeExtension(file, modeInfo) {
* @property {string} [paneId] target editor pane id
* @property {object} [pane] target editor pane
* @property {boolean} [isPanePlaceholder] temporary empty tab for an empty pane
+ * @property {boolean} [persistInSession] restore the tab in a future app session
* @property {boolean} [highlightStyles] adopt static CodeMirror highlight CSS into the custom tab shadow root
*/
@@ -500,6 +501,7 @@ export default class EditorFile {
diskMtime = null;
hasDiskConflict = false;
isPanePlaceholder = false;
+ persistInSession = true;
/**
*
@@ -513,6 +515,7 @@ export default class EditorFile {
this.hideQuickTools = options?.hideQuickTools || false;
this.paneId = options?.paneId || options?.pane?.id || null;
this.isPanePlaceholder = !!options?.isPanePlaceholder;
+ this.persistInSession = options?.persistInSession !== false;
// if options are passed
if (options) {
diff --git a/src/lib/fileSessionPersistence.js b/src/lib/fileSessionPersistence.js
new file mode 100644
index 0000000000..e14c639402
--- /dev/null
+++ b/src/lib/fileSessionPersistence.js
@@ -0,0 +1,13 @@
+/**
+ * Promote an open file to durable session persistence when a later open source
+ * proves that its URI will remain accessible. Less durable sources, such as
+ * external intents, must not revoke an existing durable grant.
+ *
+ * @param {{persistInSession?: boolean}} file
+ * @param {boolean | undefined} requestedPersistence
+ */
+export function promoteSessionPersistence(file, requestedPersistence) {
+ if (requestedPersistence === true) {
+ file.persistInSession = true;
+ }
+}
diff --git a/src/lib/openFile.js b/src/lib/openFile.js
index bbb5e5812f..020c028558 100644
--- a/src/lib/openFile.js
+++ b/src/lib/openFile.js
@@ -8,6 +8,7 @@ import { reopenWithNewEncoding } from "palettes/changeEncoding";
import { decode, detectEncoding } from "utils/encodings";
import helpers from "utils/helpers";
import EditorFile from "./editorFile";
+import { promoteSessionPersistence } from "./fileSessionPersistence";
import fileTypeHandler from "./fileTypeHandler";
import recents from "./recents";
import appSettings from "./settings";
@@ -22,6 +23,7 @@ import appSettings from "./settings";
* @property {string} mode
* @property {string} uri
* @property {string} paneId
+ * @property {boolean} persistInSession
*/
/**
@@ -37,9 +39,19 @@ export default async function openFile(file, options = {}) {
/**@type {EditorFile} */
const existingFile = editorManager.getFile(uri, "uri");
- const { cursorPos, render, onsave, text, mode, encoding, paneId } = options;
+ const {
+ cursorPos,
+ render,
+ onsave,
+ text,
+ mode,
+ encoding,
+ paneId,
+ persistInSession,
+ } = options;
if (existingFile) {
+ promoteSessionPersistence(existingFile, persistInSession);
// If file is already opened and new text is provided
const incomingDoc =
text != null ? Text.of(String(text).split("\n")) : null;
@@ -120,6 +132,7 @@ export default async function openFile(file, options = {}) {
savedMtime: helpers.getStatMtime(fileInfo),
diskMtime: helpers.getStatMtime(fileInfo),
paneId,
+ persistInSession,
});
};
diff --git a/src/lib/saveState.js b/src/lib/saveState.js
index 784304d475..7e0ea6790b 100644
--- a/src/lib/saveState.js
+++ b/src/lib/saveState.js
@@ -14,7 +14,7 @@ export default () => {
files.forEach((file) => {
if (file.type !== "editor") return;
if (file.id === config.DEFAULT_FILE_SESSION) return;
- if (file.SAFMode === "single") return;
+ if (file.persistInSession === false) return;
// Selection per file:
// - Active file uses live EditorView selection
diff --git a/src/pages/fileBrowser/fileBrowser.js b/src/pages/fileBrowser/fileBrowser.js
index b26758b189..ef9c59ce28 100644
--- a/src/pages/fileBrowser/fileBrowser.js
+++ b/src/pages/fileBrowser/fileBrowser.js
@@ -60,6 +60,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) {
const IS_FOLDER_MODE = ["folder", "both"].includes(mode);
const IS_FILE_MODE = ["file", "both"].includes(mode);
+ const SELECT_DOCUMENT_LABEL = "Select document";
const storedState = helpers.parseJSON(localStorage.fileBrowserState) || [];
/**@type {Array} */
const state = [];
@@ -106,6 +107,16 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) {
);
const $search = ;
+ const $selectDocument = (
+
+ );
const $lead = ;
const $page = Page(strings["file browser"].capitalize(), {
lead: $lead,
@@ -178,8 +189,9 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) {
$content.addEventListener("click", handleClick);
$content.addEventListener("contextmenu", handleContextMenu, true);
$page.body = $content;
+ $page.header.append($search);
+ if (IS_FILE_MODE) $page.header.append($selectDocument);
$page.header.append(
- $search,
$pasteToggler,
$selectionModeToggler,
$addMenuToggler,
@@ -222,6 +234,12 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) {
};
$pasteToggler.onclick = pasteCopiedItems;
+ $selectDocument.onclick = selectDocument;
+ $selectDocument.onkeydown = (event) => {
+ if (event.key !== "Enter" && event.key !== " ") return;
+ event.preventDefault();
+ selectDocument();
+ };
$fbMenu.onclick = function (e) {
$fbMenu.hide();
@@ -653,6 +671,25 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) {
$page.hide();
}
+ function selectDocument() {
+ checkFiles.check = false;
+ sdcard.openDocumentFile(
+ (res) => {
+ res.url = res.uri;
+ resolve({
+ type: "file",
+ ...res,
+ name: res.filename,
+ mode: "single",
+ });
+ $page.hide();
+ },
+ (err) => {
+ helpers.error(err);
+ },
+ );
+ }
+
/**
* @param {string} url
*/
@@ -872,6 +909,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) {
$addMenuToggler.style.display = "none";
$menuToggler.style.display = "none";
+ $selectDocument.style.display = "none";
$selectionMenuToggler.style.display = "";
updatePasteToggler();
@@ -899,6 +937,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) {
$addMenuToggler.style.display = "";
$menuToggler.style.display = "";
+ $selectDocument.style.display = "";
$selectionMenuToggler.style.display = "none";
updatePasteToggler();
@@ -998,7 +1037,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) {
else if (!$el.hasAttribute("disabled")) file();
break;
case "openDoc":
- openDoc();
+ selectDocument();
break;
}
@@ -1304,25 +1343,6 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) {
localStorage.storageList = JSON.stringify(storageList);
reload();
}
-
- function openDoc() {
- checkFiles.check = false;
- sdcard.openDocumentFile(
- (res) => {
- res.url = res.uri;
- resolve({
- type: "file",
- ...res,
- name: res.filename,
- mode: "single",
- });
- $page.hide();
- },
- (err) => {
- helpers.error(err);
- },
- );
- }
}
function handleContextMenu(e) {
@@ -1431,7 +1451,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) {
}
if (IS_FILE_MODE) {
- util.pushFolder(allStorages, "Select document", null, {
+ util.pushFolder(allStorages, SELECT_DOCUMENT_LABEL, null, {
openDoc: true,
notSelectable: true,
});
diff --git a/src/pages/fileBrowser/index.js b/src/pages/fileBrowser/index.js
index 63becfb329..bd46f597c8 100644
--- a/src/pages/fileBrowser/index.js
+++ b/src/pages/fileBrowser/index.js
@@ -31,7 +31,7 @@ function FileBrowser(mode, info, doesOpenLast, ...args) {
}
FileBrowser.openFile = (res) => {
- const { url, name, mode } = res;
+ const { url, name, mode, persistedUriPermission } = res;
const createOption = {
uri: url,
name,
@@ -41,8 +41,11 @@ FileBrowser.openFile = (res) => {
if (mode) {
createOption.mode = mode;
}
+ if (mode === "single") {
+ createOption.persistInSession = persistedUriPermission !== false;
+ }
- openFile(url, createOption);
+ return openFile(url, createOption);
};
FileBrowser.openFileError = (err) => {
diff --git a/src/plugins/sdcard/index.d.ts b/src/plugins/sdcard/index.d.ts
index f151eab349..fd6a6b7db8 100644
--- a/src/plugins/sdcard/index.d.ts
+++ b/src/plugins/sdcard/index.d.ts
@@ -35,6 +35,7 @@ interface DocumentFile {
canWrite: boolean;
filename: string;
length: number;
+ persistedUriPermission: boolean;
type: string;
uri: string;
}
diff --git a/src/plugins/sdcard/src/android/SDcard.java b/src/plugins/sdcard/src/android/SDcard.java
index 1b33e7edef..578d33dda3 100644
--- a/src/plugins/sdcard/src/android/SDcard.java
+++ b/src/plugins/sdcard/src/android/SDcard.java
@@ -266,6 +266,11 @@ public void openDocumentFile(String mimeType, CallbackContext callback) {
intent.setAction(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType(mimeType);
+ intent.addFlags(
+ Intent.FLAG_GRANT_READ_URI_PERMISSION |
+ Intent.FLAG_GRANT_WRITE_URI_PERMISSION |
+ Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
+ );
activityResultCallback = callback;
cordova.startActivityForResult(this, intent, this.OPEN_DOCUMENT);
}
@@ -409,7 +414,10 @@ public void onActivityResult(int requestCode, int resultCode, Intent data) {
return;
}
- takePermission(uri);
+ boolean persistedUriPermission = takePermission(
+ uri,
+ data.getFlags()
+ );
DocumentFile file = DocumentFile.fromSingleUri(context, uri);
JSONObject res = new JSONObject();
@@ -418,6 +426,7 @@ public void onActivityResult(int requestCode, int resultCode, Intent data) {
res.put("filename", file.getName());
res.put("canWrite", canWrite(file.getUri()));
res.put("uri", uri.toString());
+ res.put("persistedUriPermission", persistedUriPermission);
activityResultCallback.success(res);
} catch (JSONException e) {
activityResultCallback.error(e.toString());
@@ -447,7 +456,7 @@ public void onActivityResult(int requestCode, int resultCode, Intent data) {
return;
}
- takePermission(uri);
+ takePermission(uri, data.getFlags());
DocumentFile file = DocumentFile.fromTreeUri(context, uri);
if (file != null && file.canWrite()) {
activityResultCallback.success(uri.toString());
@@ -1152,13 +1161,22 @@ private DocumentFile getFile(String filePath) {
return documentFile;
}
- private void takePermission(Uri uri) {
- contentResolver = context.getContentResolver();
- contentResolver.takePersistableUriPermission(
- uri,
- Intent.FLAG_GRANT_WRITE_URI_PERMISSION |
- Intent.FLAG_GRANT_READ_URI_PERMISSION
- );
+ private boolean takePermission(Uri uri, int intentFlags) {
+ int permissionFlags =
+ intentFlags &
+ (Intent.FLAG_GRANT_WRITE_URI_PERMISSION |
+ Intent.FLAG_GRANT_READ_URI_PERMISSION);
+
+ if (permissionFlags == 0) return false;
+
+ try {
+ contentResolver = context.getContentResolver();
+ contentResolver.takePersistableUriPermission(uri, permissionFlags);
+ return true;
+ } catch (SecurityException | IllegalArgumentException error) {
+ Log.w("SDcard", "Unable to persist URI permission for " + uri, error);
+ return false;
+ }
}
public boolean canWrite(Uri uri) {
diff --git a/tests/unit/sessionPersistence.test.js b/tests/unit/sessionPersistence.test.js
new file mode 100644
index 0000000000..938cc63a60
--- /dev/null
+++ b/tests/unit/sessionPersistence.test.js
@@ -0,0 +1,165 @@
+// @vitest-environment happy-dom
+
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const runtime = vi.hoisted(() => {
+ const settings = {
+ value: {
+ rememberFiles: true,
+ rememberFolders: false,
+ },
+ };
+
+ return {
+ settings,
+ openFile: vi.fn(),
+ };
+});
+
+vi.mock("cm/editorUtils", () => ({
+ getAllFolds: vi.fn(() => []),
+ getScrollPosition: vi.fn(() => ({ scrollTop: 0, scrollLeft: 0 })),
+ getSelection: vi.fn(() => ({
+ ranges: [{ from: 0, to: 0 }],
+ mainIndex: 0,
+ })),
+}));
+vi.mock("dialogs/alert", () => ({ default: vi.fn() }));
+vi.mock("fileSystem", () => ({ default: vi.fn() }));
+vi.mock("lib/auth", () => ({ default: {} }));
+vi.mock("lib/config", () => ({
+ default: { DEFAULT_FILE_SESSION: "default-session" },
+}));
+vi.mock("lib/openFile", () => ({ default: runtime.openFile }));
+vi.mock("lib/openFolder", () => ({
+ default: vi.fn(),
+ addedFolder: [],
+}));
+vi.mock("lib/settings", () => ({ default: runtime.settings }));
+vi.mock("lib/startAd", () => ({
+ BANNER_SUPPRESSION_REASON: {},
+ setBannerSuppressed: vi.fn(),
+}));
+vi.mock("utils/helpers", () => ({
+ default: { error: vi.fn(), errorMessage: vi.fn() },
+}));
+
+import HandleIntent from "handlers/intent";
+import { promoteSessionPersistence } from "lib/fileSessionPersistence";
+import saveState from "lib/saveState";
+import FileBrowser from "pages/fileBrowser";
+
+describe("file session persistence", () => {
+ beforeEach(() => {
+ runtime.openFile.mockReset();
+ localStorage.clear();
+ sessionStorage.clear();
+ sessionStorage.setItem("isfilesRestored", "true");
+ globalThis.editorManager = {
+ editor: {},
+ files: [],
+ activeFile: null,
+ getFile(value, type = "id") {
+ return this.files.find((file) => file[type] === value);
+ },
+ };
+ runtime.openFile.mockImplementation(async (uri, options = {}) => {
+ const existingFile = globalThis.editorManager.getFile(uri, "uri");
+ if (existingFile) {
+ promoteSessionPersistence(existingFile, options.persistInSession);
+ globalThis.editorManager.activeFile = existingFile;
+ return;
+ }
+
+ const file = createOpenFile(uri, options);
+ globalThis.editorManager.files.push(file);
+ globalThis.editorManager.activeFile = file;
+ });
+ });
+
+ it("persists only a picker document with durable URI access", async () => {
+ const selectedUri = "content://documents/document/selected";
+ const unpersistableUri = "content://documents/document/no-grant";
+ const intentUri = "content://external-app/shared/temporary";
+
+ await FileBrowser.openFile({
+ type: "file",
+ url: selectedUri,
+ name: "selected.js",
+ mode: "single",
+ persistedUriPermission: true,
+ });
+ await FileBrowser.openFile({
+ type: "file",
+ url: unpersistableUri,
+ name: "no-grant.js",
+ mode: "single",
+ persistedUriPermission: false,
+ });
+ await HandleIntent({
+ action: "android.intent.action.VIEW",
+ data: intentUri,
+ });
+ await HandleIntent({
+ action: "android.intent.action.VIEW",
+ data: selectedUri,
+ });
+
+ saveState();
+
+ const restoredUris = JSON.parse(localStorage.files).map(
+ (file) => file.uri,
+ );
+ expect(restoredUris).toEqual([selectedUri]);
+ });
+
+ it("promotes an intent tab when the same document is later selected", async () => {
+ const uri = "content://documents/document/shared";
+
+ await HandleIntent({
+ action: "android.intent.action.VIEW",
+ data: uri,
+ });
+ await FileBrowser.openFile({
+ type: "file",
+ url: uri,
+ name: "shared.js",
+ mode: "single",
+ persistedUriPermission: true,
+ });
+
+ saveState();
+
+ expect(JSON.parse(localStorage.files).map((file) => file.uri)).toEqual([
+ uri,
+ ]);
+ });
+});
+
+function createOpenFile(uri, options) {
+ return {
+ id: `file-${globalThis.editorManager.files.length + 1}`,
+ uri,
+ type: "editor",
+ filename: options.name || uri.split("/").pop(),
+ pinned: false,
+ isUnsaved: false,
+ docVersion: 0,
+ savedVersion: 0,
+ cacheVersion: 0,
+ savedMtime: 1,
+ diskMtime: 1,
+ hasDiskConflict: false,
+ readOnly: false,
+ SAFMode: options.mode,
+ deletedFile: false,
+ session: {
+ selection: { ranges: [{ from: 0, to: 0 }], mainIndex: 0 },
+ },
+ lastScrollTop: 0,
+ lastScrollLeft: 0,
+ editable: true,
+ encoding: "UTF-8",
+ persistInSession: options.persistInSession,
+ };
+}
From ad016a43d4e26ad6b9e237d26a742e84ba4b20aa Mon Sep 17 00:00:00 2001
From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com>
Date: Wed, 26 Aug 2026 13:22:32 +0000
Subject: [PATCH 07/38] feat(console): isolate execution and improve mobile
REPL UX (#2808)
* feat(console): isolate execution and improve mobile REPL UX
- run console JavaScript in a cancellable Web Worker
- protect the app from infinite loops and recursion
- preserve history with variable-height virtualization
- restore the inline REPL layout
- fix mobile keyboard resizing and viewport gaps
- add clear and stop controls
* fix(console): address execution context and bottom pinning
* address the pinning regression
* remove stop, run btn
---
rspack.config.js | 1 +
src/components/virtualList/index.js | 2 +
src/components/virtualList/style.scss | 10 +
src/components/virtualList/variable.js | 411 +++++++++++++++++
src/lib/console.js | 238 ++++++++--
src/lib/consoleRuntime.js | 186 ++++++++
src/lib/consoleWorker.js | 168 +++++++
src/lib/run.js | 10 +-
.../com/foxdebug/browser/BrowserActivity.java | 38 +-
src/styles/console.m.scss | 419 ++++++++++--------
src/views/console.hbs | 11 +-
tests/unit/consoleExecutionContext.test.js | 52 +++
tests/unit/consoleExecutor.test.js | 134 ++++++
tests/unit/consoleViewport.test.js | 41 ++
tests/unit/variableVirtualList.test.js | 194 ++++++++
webpack.config.js | 1 +
16 files changed, 1676 insertions(+), 240 deletions(-)
create mode 100644 src/components/virtualList/variable.js
create mode 100644 src/lib/consoleRuntime.js
create mode 100644 src/lib/consoleWorker.js
create mode 100644 tests/unit/consoleExecutionContext.test.js
create mode 100644 tests/unit/consoleExecutor.test.js
create mode 100644 tests/unit/consoleViewport.test.js
create mode 100644 tests/unit/variableVirtualList.test.js
diff --git a/rspack.config.js b/rspack.config.js
index a30e31154b..9efbf30197 100644
--- a/rspack.config.js
+++ b/rspack.config.js
@@ -144,6 +144,7 @@ module.exports = (env, options) => {
boot: './src/boot.js',
main: './src/main.js',
console: './src/lib/console.js',
+ consoleWorker: './src/lib/consoleWorker.js',
searchInFilesWorker: './src/sidebarApps/searchInFiles/worker.js',
searchIndexWorker: './src/sidebarApps/searchInFiles/indexWorker.js',
htmlLspWorker: './src/cm/lsp/workers/html.worker.ts',
diff --git a/src/components/virtualList/index.js b/src/components/virtualList/index.js
index bf7b7db4b1..c1ff5bafcf 100644
--- a/src/components/virtualList/index.js
+++ b/src/components/virtualList/index.js
@@ -1,6 +1,8 @@
import "./style.scss";
import tag from "html-tag-js";
+export { default as VariableVirtualList } from "./variable";
+
/**
* @typedef {object} VirtualListOptions
* @property {number} [itemHeight=30] - Height of each item in pixels
diff --git a/src/components/virtualList/style.scss b/src/components/virtualList/style.scss
index 9af4e0df05..3ef86ce953 100644
--- a/src/components/virtualList/style.scss
+++ b/src/components/virtualList/style.scss
@@ -7,3 +7,13 @@
position: relative;
min-width: 100%;
}
+
+.variable-virtual-spacer {
+ width: 100%;
+ pointer-events: none;
+}
+
+.variable-virtual-items {
+ position: relative;
+ min-width: 100%;
+}
diff --git a/src/components/virtualList/variable.js b/src/components/virtualList/variable.js
new file mode 100644
index 0000000000..e2cf193c6d
--- /dev/null
+++ b/src/components/virtualList/variable.js
@@ -0,0 +1,411 @@
+const DEFAULT_ESTIMATED_HEIGHT = 52;
+const DEFAULT_OVERSCAN = 360;
+const BOTTOM_THRESHOLD = 48;
+
+/**
+ * Variable-height virtual list for retained HTMLElement items.
+ *
+ * Every item stays in the model, while only the visible window and a small pixel
+ * overscan are mounted. ResizeObserver keeps changing item heights measured
+ * without losing the reader's scroll anchor.
+ */
+export default class VariableVirtualList {
+ constructor(
+ container,
+ {
+ estimatedHeight = DEFAULT_ESTIMATED_HEIGHT,
+ overscan = DEFAULT_OVERSCAN,
+ activeOverscan = overscan * 4,
+ maxOverscan = overscan * 10,
+ footer = null,
+ } = {},
+ ) {
+ this.container = container;
+ this.estimatedHeight = estimatedHeight;
+ this.overscan = overscan;
+ this.activeOverscan = Math.max(overscan, activeOverscan);
+ this.maxOverscan = Math.max(this.activeOverscan, maxOverscan);
+ this.dynamicOverscan = overscan;
+ this.footer = footer;
+ this.footerHeight = 0;
+ this.viewportHeight = this.container.clientHeight;
+ this.items = [];
+ this.itemByElement = new WeakMap();
+ this.offsets = [0];
+ this.offsetsDirty = false;
+ this.renderedRange = { start: 0, end: 0 };
+ this.frame = null;
+ this.stickToBottom = true;
+ this.lastScrollTop = this.container.scrollTop;
+ this.lastScrollTime = this.now();
+ this.touching = false;
+ this.releaseTimer = null;
+ this.destroyed = false;
+
+ this.topSpacer = document.createElement("div");
+ this.topSpacer.className = "variable-virtual-spacer";
+ this.itemContainer = document.createElement("div");
+ this.itemContainer.className = "variable-virtual-items";
+ this.bottomSpacer = document.createElement("div");
+ this.bottomSpacer.className = "variable-virtual-spacer";
+ this.container.append(
+ this.topSpacer,
+ this.itemContainer,
+ this.bottomSpacer,
+ );
+ if (this.footer) this.container.append(this.footer);
+
+ this.onScroll = () => {
+ this.stickToBottom = this.isNearBottom();
+ const now = this.now();
+ const elapsed = Math.max(8, now - this.lastScrollTime);
+ const distance = Math.abs(this.container.scrollTop - this.lastScrollTop);
+ const projectedDistance = distance * (32 / elapsed);
+ this.dynamicOverscan = Math.min(
+ this.maxOverscan,
+ this.activeOverscan + projectedDistance * 2,
+ );
+ this.lastScrollTop = this.container.scrollTop;
+ this.lastScrollTime = now;
+ // Scroll rendering is intentionally synchronous. Android WebView can move
+ // the compositor several rows before the next animation frame.
+ this.render();
+ this.scheduleOverscanRelease();
+ };
+ this.onTouchStart = () => {
+ this.touching = true;
+ this.clearOverscanRelease();
+ this.dynamicOverscan = this.activeOverscan;
+ // Pre-paint the fling guard before compositor scrolling begins.
+ this.render();
+ };
+ this.onTouchEnd = () => {
+ this.touching = false;
+ this.scheduleOverscanRelease();
+ };
+ this.container.addEventListener("scroll", this.onScroll, { passive: true });
+ this.container.addEventListener("touchstart", this.onTouchStart, {
+ passive: true,
+ });
+ this.container.addEventListener("touchend", this.onTouchEnd, {
+ passive: true,
+ });
+ this.container.addEventListener("touchcancel", this.onTouchEnd, {
+ passive: true,
+ });
+
+ this.resizeObserver =
+ typeof ResizeObserver === "function"
+ ? new ResizeObserver((entries) => this.onResize(entries))
+ : null;
+ this.resizeObserver?.observe(this.container);
+ }
+
+ now() {
+ return (
+ this.container.ownerDocument.defaultView?.performance?.now?.() ??
+ Date.now()
+ );
+ }
+
+ scheduleOverscanRelease() {
+ this.clearOverscanRelease();
+ this.releaseTimer = this.container.ownerDocument.defaultView?.setTimeout(
+ () => {
+ this.releaseTimer = null;
+ if (this.destroyed || this.touching) return;
+ this.dynamicOverscan = this.overscan;
+ this.render();
+ },
+ 500,
+ );
+ }
+
+ clearOverscanRelease() {
+ if (this.releaseTimer === null) return;
+ this.container.ownerDocument.defaultView?.clearTimeout(this.releaseTimer);
+ this.releaseTimer = null;
+ }
+
+ get length() {
+ return this.items.length;
+ }
+
+ get mountedCount() {
+ return this.itemContainer.childElementCount;
+ }
+
+ append(element) {
+ const wasNearBottom = this.isNearBottom();
+ const item = {
+ element,
+ height: this.estimatedHeight,
+ index: this.items.length,
+ };
+ this.items.push(item);
+ this.itemByElement.set(element, item);
+ this.offsetsDirty = true;
+ if (wasNearBottom || this.items.length === 1) this.stickToBottom = true;
+ this.scheduleRender();
+ }
+
+ clear() {
+ this.items = [];
+ this.itemByElement = new WeakMap();
+ this.offsets = [0];
+ this.offsetsDirty = false;
+ this.renderedRange = { start: 0, end: 0 };
+ this.stickToBottom = true;
+ this.dynamicOverscan = this.overscan;
+ this.topSpacer.style.height = "0px";
+ this.bottomSpacer.style.height = "0px";
+ this.itemContainer.replaceChildren();
+ this.container.scrollTop = 0;
+ this.footerHeight = this.getFooterHeight();
+ this.observeResizeTargets();
+ }
+
+ invalidate() {
+ this.scheduleRender();
+ }
+
+ scrollToBottom() {
+ this.stickToBottom = true;
+ this.scheduleRender();
+ }
+
+ scheduleRender() {
+ if (this.frame !== null || this.destroyed) return;
+ this.frame = requestAnimationFrame(() => {
+ this.frame = null;
+ this.render();
+ });
+ }
+
+ rebuildOffsets() {
+ if (!this.offsetsDirty) return;
+
+ const offsets = new Array(this.items.length + 1);
+ offsets[0] = 0;
+ for (let index = 0; index < this.items.length; index++) {
+ offsets[index + 1] = offsets[index] + this.items[index].height;
+ }
+ this.offsets = offsets;
+ this.offsetsDirty = false;
+ }
+
+ findIndexAt(offset) {
+ let low = 0;
+ let high = this.items.length;
+ while (low < high) {
+ const middle = Math.floor((low + high + 1) / 2);
+ if (this.offsets[middle] <= offset) low = middle;
+ else high = middle - 1;
+ }
+ return Math.min(low, Math.max(0, this.items.length - 1));
+ }
+
+ getFooterHeight() {
+ if (!this.footer) return 0;
+ return Math.ceil(
+ this.footer.getBoundingClientRect().height ||
+ this.footer.offsetHeight ||
+ 0,
+ );
+ }
+
+ observeResizeTargets() {
+ if (!this.resizeObserver) return;
+ this.resizeObserver.disconnect();
+ this.resizeObserver.observe(this.container);
+ for (const element of this.itemContainer.children) {
+ this.resizeObserver.observe(element);
+ }
+ if (this.footer) this.resizeObserver.observe(this.footer);
+ }
+
+ render() {
+ if (this.destroyed) return;
+ this.rebuildOffsets();
+ if (!this.items.length) {
+ this.footerHeight = this.getFooterHeight();
+ this.itemContainer.replaceChildren();
+ this.topSpacer.style.height = "0px";
+ this.bottomSpacer.style.height = "0px";
+ this.renderedRange = { start: 0, end: 0 };
+ this.observeResizeTargets();
+ return;
+ }
+
+ const viewportHeight = Math.max(
+ 1,
+ this.container.clientHeight || window.innerHeight,
+ );
+ this.viewportHeight = viewportHeight;
+ const totalHeight = this.offsets[this.items.length];
+ this.footerHeight = this.getFooterHeight();
+ const targetScrollTop = this.stickToBottom
+ ? Math.max(0, totalHeight + this.footerHeight - viewportHeight)
+ : this.container.scrollTop;
+ const start = this.findIndexAt(
+ Math.max(0, targetScrollTop - this.dynamicOverscan),
+ );
+ const end = Math.min(
+ this.items.length,
+ this.findIndexAt(
+ targetScrollTop + viewportHeight + this.dynamicOverscan,
+ ) + 1,
+ );
+
+ this.updateMountedRange(start, end);
+ this.observeResizeTargets();
+ this.topSpacer.style.height = `${this.offsets[start]}px`;
+ this.bottomSpacer.style.height = `${Math.max(
+ 0,
+ totalHeight - this.offsets[end],
+ )}px`;
+ this.renderedRange = { start, end };
+
+ if (this.stickToBottom) {
+ this.container.scrollTop = Math.max(
+ 0,
+ this.container.scrollHeight - viewportHeight,
+ );
+ }
+ if (!this.resizeObserver) this.measureMountedItems();
+ }
+
+ updateMountedRange(start, end) {
+ let oldStart = this.renderedRange.start;
+ let oldEnd = this.renderedRange.end;
+ const rangesOverlap = oldStart < end && start < oldEnd;
+
+ if (!this.itemContainer.childElementCount || !rangesOverlap) {
+ const fragment = document.createDocumentFragment();
+ for (let index = start; index < end; index++) {
+ fragment.append(this.items[index].element);
+ }
+ this.itemContainer.replaceChildren(fragment);
+ return;
+ }
+
+ while (oldStart < start) {
+ this.itemContainer.firstElementChild?.remove();
+ oldStart++;
+ }
+ while (oldEnd > end) {
+ this.itemContainer.lastElementChild?.remove();
+ oldEnd--;
+ }
+
+ if (start < oldStart) {
+ const leading = document.createDocumentFragment();
+ for (let index = start; index < oldStart; index++) {
+ leading.append(this.items[index].element);
+ }
+ this.itemContainer.insertBefore(leading, this.itemContainer.firstChild);
+ }
+ if (end > oldEnd) {
+ const trailing = document.createDocumentFragment();
+ for (let index = oldEnd; index < end; index++) {
+ trailing.append(this.items[index].element);
+ }
+ this.itemContainer.append(trailing);
+ }
+ }
+
+ measureMountedItems() {
+ for (const element of this.itemContainer.children) {
+ const item = this.itemByElement.get(element);
+ if (item) this.updateHeight(item, element.getBoundingClientRect().height);
+ }
+ }
+
+ onResize(entries) {
+ const wasNearBottom = this.stickToBottom || this.isNearBottom();
+ let footerChanged = false;
+ let viewportChanged = false;
+ for (const entry of entries) {
+ if (entry.target === this.container) {
+ const borderBox = Array.isArray(entry.borderBoxSize)
+ ? entry.borderBoxSize[0]
+ : entry.borderBoxSize;
+ const height = Math.ceil(
+ borderBox?.blockSize ||
+ entry.contentRect?.height ||
+ this.container.clientHeight,
+ );
+ viewportChanged = Math.abs(height - this.viewportHeight) >= 1;
+ this.viewportHeight = height;
+ continue;
+ }
+ if (entry.target === this.footer) {
+ const borderBox = Array.isArray(entry.borderBoxSize)
+ ? entry.borderBoxSize[0]
+ : entry.borderBoxSize;
+ const height = Math.ceil(
+ borderBox?.blockSize ||
+ entry.contentRect?.height ||
+ this.getFooterHeight(),
+ );
+ footerChanged = Math.abs(height - this.footerHeight) >= 1;
+ this.footerHeight = height;
+ continue;
+ }
+ const item = this.itemByElement.get(entry.target);
+ if (!item) continue;
+ const borderBox = Array.isArray(entry.borderBoxSize)
+ ? entry.borderBoxSize[0]
+ : entry.borderBoxSize;
+ this.updateHeight(
+ item,
+ borderBox?.blockSize ||
+ entry.contentRect?.height ||
+ entry.target.offsetHeight,
+ );
+ }
+ if (wasNearBottom) this.stickToBottom = true;
+ if (viewportChanged || (wasNearBottom && footerChanged)) {
+ this.scheduleRender();
+ }
+ }
+
+ updateHeight(item, measuredHeight) {
+ const height = Math.max(1, Math.ceil(measuredHeight || 0));
+ if (Math.abs(height - item.height) < 1) return;
+
+ const delta = height - item.height;
+ item.height = height;
+ this.offsetsDirty = true;
+ if (!this.stickToBottom && item.index < this.renderedRange.start) {
+ this.container.scrollTop += delta;
+ }
+ this.scheduleRender();
+ }
+
+ isNearBottom() {
+ if (!this.items.length) return true;
+ return (
+ this.container.scrollHeight -
+ this.container.scrollTop -
+ this.container.clientHeight <=
+ BOTTOM_THRESHOLD
+ );
+ }
+
+ destroy() {
+ this.destroyed = true;
+ if (this.frame !== null) cancelAnimationFrame(this.frame);
+ this.frame = null;
+ this.clearOverscanRelease();
+ this.resizeObserver?.disconnect();
+ this.container.removeEventListener("scroll", this.onScroll);
+ this.container.removeEventListener("touchstart", this.onTouchStart);
+ this.container.removeEventListener("touchend", this.onTouchEnd);
+ this.container.removeEventListener("touchcancel", this.onTouchEnd);
+ this.topSpacer.remove();
+ this.itemContainer.remove();
+ this.bottomSpacer.remove();
+ this.items = [];
+ }
+}
diff --git a/src/lib/console.js b/src/lib/console.js
index 3e10948585..8d399ea315 100644
--- a/src/lib/console.js
+++ b/src/lib/console.js
@@ -1,24 +1,102 @@
import "core-js/stable";
import "html-tag-js/dist/polyfill";
import { parse } from "acorn";
+import { VariableVirtualList } from "components/virtualList";
import css from "styles/console.m.scss";
import loadPolyFill from "utils/polyfill";
+import ConsoleExecutor, {
+ applyConsoleViewport,
+ executeConsoleCommand,
+ resolveConsoleExecutionContext,
+} from "./consoleRuntime";
(function () {
loadPolyFill.apply(window);
let consoleVisible = false;
+ let isFocused = false;
+ let isExecuting = false;
+ let viewportFrame = null;
+ let viewportObserver = null;
+ const isStandaloneConsole = sessionStorage.getItem("__mode") === "console";
const originalConsole = console;
const $input = tag("textarea", {
id: "__c-input",
+ attr: {
+ rows: "1",
+ placeholder: "Run JavaScript…",
+ "aria-label": "JavaScript console input",
+ spellcheck: "false",
+ autocomplete: "off",
+ autocapitalize: "off",
+ },
+ oninput: resizeInput,
+ onfocus() {
+ isFocused = true;
+ virtualMessages.scrollToBottom();
+ },
onblur() {
setTimeout(() => {
isFocused = false;
}, 0);
},
});
+ const $stopExecution = tag("button", {
+ className: "__c-action __c-action-danger",
+ textContent: "Stop",
+ attr: {
+ type: "button",
+ "aria-label": "Stop JavaScript execution",
+ },
+ onclick() {
+ executor.cancel();
+ },
+ });
+ $stopExecution.hidden = true;
+ const $executionContext = tag("select", {
+ className: "__c-context",
+ attr: {
+ "aria-label": "JavaScript execution context",
+ title: "Choose isolated Worker execution or live page access",
+ },
+ children: [
+ tag("option", {
+ textContent: "Worker",
+ attr: { value: "worker" },
+ }),
+ tag("option", {
+ textContent: "Page (unsafe)",
+ attr: { value: "page" },
+ }),
+ ],
+ onchange() {
+ if (this.value !== "page") return;
+ log(
+ "warn",
+ {},
+ "Page mode can access window and document, but it runs on the preview thread and cannot stop infinite code.",
+ );
+ },
+ });
+ const $output = tag("c-output", {
+ attr: {
+ role: "region",
+ "aria-label": "JavaScript console",
+ },
+ });
const $inputContainer = tag("c-input", {
- child: $input,
+ children: [
+ $input,
+ tag("c-input-actions", {
+ children: [
+ ...(isStandaloneConsole ? [] : [$executionContext]),
+ $stopExecution,
+ ],
+ }),
+ ],
+ });
+ const virtualMessages = new VariableVirtualList($output, {
+ footer: $inputContainer,
});
const $toggler = tag("c-toggler", {
style: {
@@ -46,9 +124,13 @@ import loadPolyFill from "utils/polyfill";
},
});
const $console = tag("c-console", {
- child: $inputContainer,
+ child: $output,
+ attr: {
+ role: "dialog",
+ "aria-label": "JavaScript console",
+ },
onclick(e) {
- const el = e.target;
+ const el = e.target.closest?.("[action]") || e.target;
const action = el.getAttribute("action");
switch (action) {
@@ -64,9 +146,22 @@ import loadPolyFill from "utils/polyfill";
}
},
});
+ if (isStandaloneConsole) {
+ $console.setAttribute("standalone", "");
+ document.documentElement.setAttribute("console-only", "");
+ }
const counter = {};
const timers = {};
- let isFocused = false;
+ const executor = new ConsoleExecutor({
+ workerUrl: window.__consoleWorkerScript || "build/consoleWorker.js",
+ onConsole(message) {
+ if (message.action === "clear") {
+ window.console.clear();
+ return;
+ }
+ log(message.level, {}, ...message.args);
+ },
+ });
if (!window.__objs) window.__objs = {};
@@ -77,13 +172,12 @@ import loadPolyFill from "utils/polyfill";
window.addEventListener("error", onError);
assignCustomConsole();
- if (sessionStorage.getItem("__mode") === "console") {
+ if (isStandaloneConsole) {
showConsole();
return;
}
tag.get("html").append($toggler);
- $console.setAttribute("title", "Console");
sessionStorage.setItem("__console_available", true);
document.addEventListener("showconsole", showConsole);
document.addEventListener("hideconsole", hideConsole);
@@ -105,10 +199,7 @@ import loadPolyFill from "utils/polyfill";
}
},
clear() {
- originalConsole.clear();
- if (isFocused) $input.focus();
- $console.textContent = "";
- $console.appendChild($inputContainer);
+ clearConsole();
},
count(hash = "default") {
originalConsole.count(hash);
@@ -207,35 +298,104 @@ import loadPolyFill from "utils/polyfill";
function showConsole() {
tag.get("html").append($console);
$input.addEventListener("keydown", onCodeInput);
+ if (!isStandaloneConsole) {
+ bindViewportListeners();
+ updateConsoleViewport();
+ }
+ virtualMessages.invalidate();
}
function hideConsole() {
$console.remove();
$input.removeEventListener("keydown", onCodeInput);
+ unbindViewportListeners();
+ }
+
+ function bindViewportListeners() {
+ window.addEventListener("resize", updateConsoleViewport);
+ window.visualViewport?.addEventListener("resize", updateConsoleViewport);
+ window.visualViewport?.addEventListener("scroll", updateConsoleViewport);
+ if (typeof ResizeObserver === "function") {
+ viewportObserver = new ResizeObserver(updateConsoleViewport);
+ viewportObserver.observe(document.documentElement);
+ }
+ }
+
+ function unbindViewportListeners() {
+ window.removeEventListener("resize", updateConsoleViewport);
+ window.visualViewport?.removeEventListener("resize", updateConsoleViewport);
+ window.visualViewport?.removeEventListener("scroll", updateConsoleViewport);
+ viewportObserver?.disconnect();
+ viewportObserver = null;
+ if (viewportFrame !== null) cancelAnimationFrame(viewportFrame);
+ viewportFrame = null;
+ }
+
+ function updateConsoleViewport() {
+ if (viewportFrame !== null) cancelAnimationFrame(viewportFrame);
+ viewportFrame = requestAnimationFrame(() => {
+ viewportFrame = null;
+ applyConsoleViewport($console);
+ virtualMessages.invalidate();
+ });
}
function onCodeInput(e) {
- const key = e.key;
isFocused = true;
- if (key === "Enter") {
- const regex = /[\[|{\(\)\}\]]/g;
- let code = this.value.trim();
- let isOdd = (code.length - code.replace(regex, "").length) % 2;
-
- if (!code || isOdd) return;
- e.preventDefault();
- e.stopPropagation();
- e.stopImmediatePropagation();
-
- log("code", {}, code);
- $input.value = "";
- const res = execute(code);
- if (res.type === "error") {
- log("error", getStack(new Error()), res.value);
- } else {
- log("log", getStack(new Error()), res.value);
- }
+ if (e.key !== "Enter" || e.shiftKey) return;
+
+ const code = $input.value.trim();
+ const brackets = /[\[|{\(\)\}\]]/g;
+ const isIncomplete =
+ (code.length - code.replace(brackets, "").length) % 2 !== 0;
+ if (!code || isIncomplete) return;
+
+ e.preventDefault();
+ e.stopPropagation();
+ e.stopImmediatePropagation();
+ runInputCode();
+ }
+
+ async function runInputCode() {
+ const code = $input.value.trim();
+ if (!code) return;
+ if (isExecuting) {
+ log("warn", {}, "Another console command is still running.");
+ return;
+ }
+
+ log("code", {}, code);
+ $input.value = "";
+ resizeInput();
+ setExecutionState(true);
+ const res = await executeCommand(code);
+ setExecutionState(false);
+
+ if (res.type === "error") {
+ log("error", getStack(new Error()), res.value);
+ } else {
+ log("log", getStack(new Error()), res.value);
}
+ $input.focus();
+ }
+
+ function setExecutionState(running) {
+ isExecuting = running;
+ $console.toggleAttribute("running", running);
+ $stopExecution.hidden = !running || $executionContext.value !== "worker";
+ $input.disabled = running;
+ $executionContext.disabled = running;
+ }
+
+ function resizeInput() {
+ $input.style.height = "0px";
+ $input.style.height = `${Math.min(120, Math.max(38, $input.scrollHeight))}px`;
+ }
+
+ function clearConsole() {
+ originalConsole.clear();
+ virtualMessages.clear();
+ if (isFocused) $input.focus();
}
function getBody(obj, ...keys) {
@@ -454,7 +614,7 @@ import loadPolyFill from "utils/polyfill";
let $msg;
if (mode === "code") {
$msg = tag("c-code");
- $msg.textContent = arg.length > 50 ? arg.substring(0, 50) + "..." : arg;
+ $msg.textContent = arg;
$msg.setAttribute("data-code", arg);
$msg.setAttribute("action", "use code");
} else {
@@ -486,11 +646,7 @@ import loadPolyFill from "utils/polyfill";
$messages.appendChild($stack);
}
- $console.insertBefore($messages, $inputContainer);
-
- while ($console.childElementCount > 100) {
- $console.firstElementChild.remove();
- }
+ virtualMessages.append($messages);
}
/**
@@ -634,6 +790,18 @@ import loadPolyFill from "utils/polyfill";
};
}
+ function executeCommand(code) {
+ return executeConsoleCommand({
+ context: resolveConsoleExecutionContext(
+ isStandaloneConsole,
+ $executionContext.value,
+ ),
+ code,
+ workerExecutor: executor,
+ pageExecutor: execute,
+ });
+ }
+
function execute(code) {
let res = null;
try {
diff --git a/src/lib/consoleRuntime.js b/src/lib/consoleRuntime.js
new file mode 100644
index 0000000000..383ddd0946
--- /dev/null
+++ b/src/lib/consoleRuntime.js
@@ -0,0 +1,186 @@
+export const DEFAULT_CONSOLE_TIMEOUT = 30000;
+
+/**
+ * Runs console commands in a worker so user code can never block the page UI.
+ */
+export default class ConsoleExecutor {
+ constructor({
+ workerUrl,
+ timeout = DEFAULT_CONSOLE_TIMEOUT,
+ onConsole = () => {},
+ WorkerClass = globalThis.Worker,
+ } = {}) {
+ this.workerUrl = workerUrl;
+ this.timeout = timeout;
+ this.onConsole = onConsole;
+ this.WorkerClass = WorkerClass;
+ this.worker = null;
+ this.pending = null;
+ this.nextId = 0;
+ }
+
+ execute(code) {
+ if (this.pending) {
+ return Promise.resolve({
+ type: "error",
+ value: new Error("Another console command is still running."),
+ });
+ }
+
+ if (!this.WorkerClass) {
+ return Promise.resolve({
+ type: "error",
+ value: new Error(
+ "This WebView does not support isolated console execution.",
+ ),
+ });
+ }
+
+ let worker;
+ try {
+ worker = this.getWorker();
+ } catch (error) {
+ return Promise.resolve({ type: "error", value: error });
+ }
+ const id = ++this.nextId;
+
+ return new Promise((resolve) => {
+ const timer = setTimeout(() => {
+ this.finish(id, {
+ type: "error",
+ value: new Error(
+ `Execution stopped after ${this.timeout / 1000} seconds.`,
+ ),
+ });
+ this.resetWorker();
+ }, this.timeout);
+
+ this.pending = { id, resolve, timer };
+ try {
+ worker.postMessage({ id, code });
+ } catch (error) {
+ this.finish(id, { type: "error", value: error });
+ this.resetWorker();
+ }
+ });
+ }
+
+ getWorker() {
+ if (this.worker) return this.worker;
+
+ const worker = new this.WorkerClass(this.workerUrl);
+ worker.onmessage = ({ data }) => this.handleMessage(data);
+ worker.onerror = (event) => {
+ const error = new Error(event.message || "Console worker failed.");
+ this.finish(this.pending?.id, { type: "error", value: error });
+ this.resetWorker();
+ };
+ this.worker = worker;
+ return worker;
+ }
+
+ handleMessage(message) {
+ if (message.type === "console") {
+ this.onConsole(message);
+ return;
+ }
+
+ if (message.type === "result") {
+ this.finish(message.id, { type: "result", value: message.value });
+ return;
+ }
+
+ if (message.type === "error") {
+ const error = new Error(
+ message.error?.message || "Console command failed.",
+ );
+ error.name = message.error?.name || "Error";
+ if (message.error?.stack) error.stack = message.error.stack;
+ this.finish(message.id, { type: "error", value: error });
+ }
+ }
+
+ finish(id, result) {
+ if (!this.pending || this.pending.id !== id) return;
+
+ const { resolve, timer } = this.pending;
+ this.pending = null;
+ clearTimeout(timer);
+ resolve(result);
+ }
+
+ resetWorker() {
+ this.worker?.terminate();
+ this.worker = null;
+ }
+
+ cancel() {
+ if (!this.pending) return false;
+
+ this.finish(this.pending.id, {
+ type: "error",
+ value: new Error("Execution stopped."),
+ });
+ this.resetWorker();
+ return true;
+ }
+
+ destroy() {
+ if (this.pending) {
+ this.finish(this.pending.id, {
+ type: "error",
+ value: new Error("Console execution was cancelled."),
+ });
+ }
+ this.resetWorker();
+ }
+}
+
+/**
+ * Returns the actually visible browser rectangle. On mobile this shrinks with
+ * the software keyboard even when the layout viewport remains full height.
+ */
+export function getConsoleViewportRect(windowObject = window) {
+ const viewport = windowObject.visualViewport;
+ const documentHeight = windowObject.document?.documentElement?.clientHeight;
+ const visibleHeights = [
+ viewport?.height,
+ windowObject.innerHeight,
+ documentHeight,
+ ].filter((height) => Number.isFinite(height) && height > 0);
+ return {
+ height: Math.round(Math.min(...visibleHeights)),
+ width: Math.round(viewport?.width || windowObject.innerWidth),
+ top: Math.round(viewport?.offsetTop || 0),
+ left: Math.round(viewport?.offsetLeft || 0),
+ };
+}
+
+export function applyConsoleViewport(element, windowObject = window) {
+ const { height, width, top, left } = getConsoleViewportRect(windowObject);
+ element.style.setProperty("--console-viewport-height", `${height}px`);
+ element.style.setProperty("--console-viewport-width", `${width}px`);
+ element.style.setProperty("--console-viewport-top", `${top}px`);
+ element.style.setProperty("--console-viewport-left", `${left}px`);
+}
+
+export function resolveConsoleExecutionContext(
+ isStandaloneConsole,
+ selectedContext,
+) {
+ return isStandaloneConsole ? "worker" : selectedContext;
+}
+
+/**
+ * Routes a console command to the isolated worker or the live preview page.
+ * Page execution is deliberately opt-in because it runs on the preview thread.
+ */
+export function executeConsoleCommand({
+ context,
+ code,
+ workerExecutor,
+ pageExecutor,
+}) {
+ if (context === "page") return Promise.resolve(pageExecutor(code));
+ return workerExecutor.execute(code);
+}
diff --git a/src/lib/consoleWorker.js b/src/lib/consoleWorker.js
new file mode 100644
index 0000000000..c5d911fe8c
--- /dev/null
+++ b/src/lib/consoleWorker.js
@@ -0,0 +1,168 @@
+const MAX_MESSAGES_PER_SECOND = 100;
+const MESSAGE_WINDOW_MS = 1000;
+const MAX_PROPERTIES = 100;
+const MAX_DEPTH = 4;
+const MAX_SNAPSHOT_VALUES = 500;
+
+let activeCommandId = 0;
+let messageWindowStartedAt = 0;
+let emittedInWindow = 0;
+let suppressedInWindow = 0;
+const counts = new Map();
+const timers = new Map();
+
+function snapshot(
+ value,
+ depth = 0,
+ seen = new WeakSet(),
+ budget = { remaining: MAX_SNAPSHOT_VALUES },
+) {
+ const type = typeof value;
+
+ if (
+ value === null ||
+ ["boolean", "number", "string", "undefined"].includes(type)
+ ) {
+ return value;
+ }
+ if (type === "bigint") return `${value}n`;
+ if (type === "symbol") return value.toString();
+ if (type === "function")
+ return `[Function${value.name ? `: ${value.name}` : ""}]`;
+ if (budget.remaining-- <= 0) return "[Truncated]";
+ if (value instanceof Error) return snapshotError(value);
+ if (value instanceof Date) return value.toISOString();
+ if (value instanceof RegExp) return value.toString();
+ if (value instanceof Promise) return "Promise { }";
+ if (depth >= MAX_DEPTH) return `[${value.constructor?.name || "Object"}]`;
+ if (seen.has(value)) return "[Circular]";
+
+ seen.add(value);
+ if (Array.isArray(value)) {
+ const result = value
+ .slice(0, MAX_PROPERTIES)
+ .map((item) => snapshot(item, depth + 1, seen, budget));
+ if (value.length > MAX_PROPERTIES) result.push("…");
+ return result;
+ }
+
+ const result = {};
+ let keys;
+ try {
+ keys = Reflect.ownKeys(value).slice(0, MAX_PROPERTIES);
+ } catch (error) {
+ return `[Uninspectable: ${error.message}]`;
+ }
+
+ for (const key of keys) {
+ const outputKey = typeof key === "symbol" ? key.toString() : key;
+ try {
+ result[outputKey] = snapshot(value[key], depth + 1, seen, budget);
+ } catch (error) {
+ result[outputKey] = `[Thrown: ${error.message}]`;
+ }
+ }
+ if (Reflect.ownKeys(value).length > MAX_PROPERTIES) result["…"] = "…";
+ return result;
+}
+
+function snapshotError(error) {
+ return {
+ name: error?.name || "Error",
+ message: error?.message || String(error),
+ stack: error?.stack || "",
+ };
+}
+
+function discardSuppressedMessages() {
+ suppressedInWindow = 0;
+}
+
+function emitMessage(message) {
+ const now = performance.now();
+ if (now - messageWindowStartedAt >= MESSAGE_WINDOW_MS) {
+ discardSuppressedMessages();
+ messageWindowStartedAt = now;
+ emittedInWindow = 0;
+ }
+
+ if (emittedInWindow >= MAX_MESSAGES_PER_SECOND) {
+ suppressedInWindow++;
+ return;
+ }
+
+ emittedInWindow++;
+ self.postMessage({ type: "console", id: activeCommandId, ...message });
+}
+
+function emit(level, args) {
+ emitMessage({
+ level,
+ args: args.map((value) => snapshot(value)),
+ });
+}
+
+self.console = {
+ assert(condition, ...args) {
+ if (!condition) emit("error", args.length ? args : ["Assertion failed"]);
+ },
+ clear() {
+ emitMessage({ action: "clear" });
+ },
+ count(label = "default") {
+ const value = (counts.get(label) || 0) + 1;
+ counts.set(label, value);
+ emit("log", [`${label}: ${value}`]);
+ },
+ countReset(label = "default") {
+ counts.delete(label);
+ },
+ debug: (...args) => emit("log", args),
+ dir: (...args) => emit("log", args),
+ dirxml: (...args) => emit("log", args),
+ error: (...args) => emit("error", args),
+ group: (...args) => emit("log", args),
+ groupCollapsed: (...args) => emit("log", args),
+ groupEnd() {},
+ info: (...args) => emit("info", args),
+ log: (...args) => emit("log", args),
+ table: (...args) => emit("table", args),
+ time(label = "default") {
+ timers.set(label, performance.now());
+ },
+ timeEnd(label = "default") {
+ if (!timers.has(label)) return emit("warn", [`No such label: ${label}`]);
+ emit("log", [
+ `${label}: ${(performance.now() - timers.get(label)).toFixed(2)}ms`,
+ ]);
+ timers.delete(label);
+ },
+ timeLog(label = "default") {
+ if (!timers.has(label)) return emit("warn", [`No such label: ${label}`]);
+ emit("log", [
+ `${label}: ${(performance.now() - timers.get(label)).toFixed(2)}ms`,
+ ]);
+ },
+ trace(...args) {
+ emit("trace", [...args, new Error().stack]);
+ },
+ warn: (...args) => emit("warn", args),
+};
+
+self.onmessage = ({ data: { id, code } }) => {
+ activeCommandId = id;
+ messageWindowStartedAt = performance.now();
+ emittedInWindow = 0;
+ suppressedInWindow = 0;
+
+ try {
+ // Intentional: this local REPL runs the command in its isolated worker.
+ // codeql[js/code-injection]
+ const value = (0, eval)(code);
+ discardSuppressedMessages();
+ self.postMessage({ type: "result", id, value: snapshot(value) });
+ } catch (error) {
+ discardSuppressedMessages();
+ self.postMessage({ type: "error", id, error: snapshotError(error) });
+ }
+};
diff --git a/src/lib/run.js b/src/lib/run.js
index 7978c4b6a7..a92a085c52 100644
--- a/src/lib/run.js
+++ b/src/lib/run.js
@@ -72,6 +72,7 @@ async function run(
let EXECUTING_SCRIPT = uuid + "_script.js";
const MIMETYPE_HTML = mimeType.lookup("html");
const CONSOLE_SCRIPT = uuid + "_console.js";
+ const CONSOLE_WORKER_SCRIPT = uuid + "_console_worker.js";
const MARKDOWN_STYLE = uuid + "_md.css";
const queue = [];
@@ -205,6 +206,11 @@ async function run(
sendFileContent(url, reqId, "application/javascript");
break;
+ case CONSOLE_WORKER_SCRIPT:
+ url = `${ASSETS_DIRECTORY}/build/consoleWorker.js`;
+ sendFileContent(url, reqId, "application/javascript");
+ break;
+
case EXECUTING_SCRIPT: {
const text = getDocText(activeFile?.session?.doc);
sendText(text, reqId, "application/javascript");
@@ -228,6 +234,7 @@ async function run(
sendText(
mustache.render($_console, {
CONSOLE_SCRIPT,
+ CONSOLE_WORKER_SCRIPT,
EXECUTING_SCRIPT,
}),
reqId,
@@ -384,7 +391,8 @@ async function run(
* @param {string} id
*/
function sendHTML(text, id) {
- const js = `
+ const js = `
+
+
-