diff --git a/docs/advanced-apis/action-stack.md b/docs/advanced-apis/action-stack.md index be4276a..4f1a751 100644 --- a/docs/advanced-apis/action-stack.md +++ b/docs/advanced-apis/action-stack.md @@ -2,6 +2,8 @@ The Action Stack is a crucial component for managing back button behavior in Acode. It allows you to handle navigation and state management by maintaining a stack of actions that can be executed when users press the back button. +Verified against Acode **v1.13.5** (versionCode `1011`): `src/lib/actionStack.js` in full, plus its call sites in `src/main.js`, `src/lib/loadPlugin.js`, `src/dialogs/*` and `src/pages/*`. Registered in `src/lib/acode.js:427` as `this.define("actionStack", actionStack)`. + ## Getting Started To use the Action Stack in your plugin, first require it: @@ -10,40 +12,89 @@ To use the Action Stack in your plugin, first require it: const actionStack = acode.require('actionStack'); ``` +::: warning One global stack, shared by everything +`src/lib/actionStack.js` holds a single module-level array. Every page, dialog, drawer, the search bar, the file browser and every other plugin share it, in one LIFO order. There is no per-plugin stack and no namespacing — an `id` is the only thing that keeps your entries addressable. +::: + ## Core Concepts The Action Stack works by maintaining a LIFO (Last In First Out) queue of actions. When the back button is pressed, the most recently added action is executed and removed from the stack. +The hardware / gesture back button is wired to exactly one function in `src/main.js`: + +```js +function backButtonHandler() { + if (keydownState.esc) { + keydownState.esc = false; + return; + } + actionStack.pop(); +} +``` + +::: warning Escape is treated as Back +`keydownState.esc` makes the very next back press a no-op, so pressing Escape and then Back does nothing on the first press. Pop from your own code instead of relying on this. +::: + +When the stack is **empty**, `pop()` does not fail — it asks for confirmation (when `settings.confirmOnExit` is on) and then calls `navigator.app.exitApp()`, first awaiting `actionStack.onCloseApp` if you set one. Acode itself sets `actionStack.onCloseApp = () => acode.exec("save-state")` in `src/main.js`. + ## API Reference +`src/lib/actionStack.js` default-exports a single object literal with these members: + +| Member | Type | Signature | +| --- | --- | --- | +| `length` | getter | `number` — current stack depth | +| `onCloseApp` | accessor | get/set a `Function` called before the app exits | +| `push(action)` | method | `(action: { id: string, action: Function }) => void` | +| `pop(repeat?)` | method | `(repeat?: number) => Promise` | +| `get(id)` | method | `(id: string) => object \| undefined` | +| `remove(id)` | method | `(id: string) => boolean` | +| `has(id)` | method | `(id: string) => boolean` | +| `setMark()` | method | `() => void` | +| `clearFromMark()` | method | `() => void` | +| `freeze()` | method | `() => void` | +| `unfreeze()` | method | `() => void` | +| `windowCopy()` | method | `() => object` — **deprecated**, see below | + ### push(action) -Adds a new action to the stack. +Adds a new action to the stack. Returns `undefined`. + +The `action` object is stored **by reference**, not cloned, and nothing about it is validated. `id` is used by `get()`, `has()` and `remove()`; `action` is the callback `pop()` invokes. **Parameters:** + - `action` (Object) - - `id` (string): Unique identifier for the action - - `action` (Function): Callback function to execute when back is pressed + - `id` (string): Unique identifier for the action. Not enforced — the same id may be pushed twice, and `remove()` then removes only the first match. + - `action` (Function): Callback function to execute when back is pressed. + +::: warning Push is unconditional +`push()` never dedupes, never replaces and never throws — not even while frozen. If you push the same id twice, back will run the newest entry, and `remove(id)` removes the *oldest* one. Call `has()` (or `remove()`) before pushing if you manage the entry yourself. +::: **Example:** + ```js actionStack.push({ - id: 'close-search', - action() { - searchPanel.hide(); - editor.focus(); - } + id: 'close-search', + action() { + searchPanel.hide(); + editor.focus(); + } }); ``` ### pop(repeat?) -Executes and removes the most recent action from the stack. +Executes and removes the most recent action from the stack. Returns a `Promise` (it is `async`). **Parameters:** -- `repeat` (number, optional): Number of actions to pop and execute + +- `repeat` (number, optional): Number of actions to pop and execute. **Example:** + ```js // Pop single action actionStack.pop(); @@ -52,20 +103,38 @@ actionStack.pop(); actionStack.pop(3); ``` +**What actually happens, step by step:** + +1. If the stack is **frozen**, `pop()` returns immediately — it does nothing at all, not even the exit flow. +2. If `repeat` is a number **greater than 1**, it recurses `repeat` times (`this.pop()` with no argument each time) and returns. Each recursive call is `async` but is *not* awaited, so the deeper pops are not sequential in practice. +3. Otherwise it pops the newest entry and calls `fun.action()` **synchronously and without awaiting it**. If that callback returns a promise, the promise is ignored and `pop()` resolves anyway. +4. If nothing was on the stack, it optionally shows a confirm dialog (`settings.confirmOnExit`) and, on confirmation, runs `onCloseApp` and `navigator.app.exitApp()`. A returned promise from `onCloseApp` is awaited via `.finally(exitApp)`. + +::: danger Your action must not throw +`fun.action()` is called with no `try`/`catch` and no `.catch()`. A throwing action escapes into `pop()`'s promise and **leaves the entry already removed from the stack** — the state you meant to restore is gone and the exception escapes into the back-button handler. Wrap your own teardown in `try { ... } catch (e) { console.error(e); }`. +::: + +::: warning `repeat` only matters when it is greater than 1 +The multi-pop branch is guarded by `typeof repeat === "number" && repeat > 1`. So `pop(1)` and `pop(0)` take the normal single-action path, and a non-numeric `repeat` is ignored entirely. +::: + ### get(id) Retrieves an action from the stack by its ID. **Parameters:** -- `id` (string): The action identifier -**Returns:** Action object if found, undefined otherwise +- `id` (string): The action identifier. + +**Returns:** The stored action object if found, `undefined` otherwise. This is a **reference** to the live object, so mutating it mutates the stack entry. **Example:** + ```js const searchAction = actionStack.get('close-search'); if (searchAction) { - // Action exists + // Action exists — mutate or invoke it + searchAction.action(); } ``` @@ -74,11 +143,21 @@ if (searchAction) { Removes an action from the stack without executing it. **Parameters:** -- `id` (string): The action identifier -**Returns:** boolean - True if action was found and removed +- `id` (string): The action identifier. + +**Returns:** `boolean` — `true` if an action was found and removed. + +Only the **first** match is removed, scanning from the bottom of the stack. Acode's own `handlers/quickToolsState.js` shows the idiom for removing every duplicate: + +```js +export function removeActionStackEntries(actionStack, id) { + while (actionStack.remove(id)) {} +} +``` **Example:** + ```js actionStack.remove('close-search'); ``` @@ -88,72 +167,357 @@ actionStack.remove('close-search'); Checks if an action exists in the stack. **Parameters:** -- `id` (string): The action identifier -**Returns:** boolean - True if action exists +- `id` (string): The action identifier. + +**Returns:** `boolean` — `true` if any entry with that id exists. **Example:** + ```js if (actionStack.has('close-search')) { - // Action exists in stack + // Action exists in stack } ``` +### length + +A getter, not a function. Read it directly: + +```js +console.log(actionStack.length); // e.g. 3 +``` + +::: warning Read it, do not assign +`length` is defined with a getter and no setter. Assigning to it has no effect (and throws a `TypeError` in strict-mode code). To empty part of the stack, use `clearFromMark()` or `pop(n)`. +::: + ### Stack Markers -The Action Stack provides methods to mark positions and clear actions above them: +The Action Stack provides methods to mark positions and clear actions above them. #### setMark() -Sets a marker at the current stack position. + +Sets a marker at the current stack position: `mark = stack.length`. There is only **one** marker; calling it again overwrites the previous position. #### clearFromMark() -Removes all actions added after the last marker. + +Removes all actions added after the last marker with `stack.splice(mark)`, then resets the marker to `null`. If no marker was ever set, it is a no-op — and note that it clears without executing. **Example:** + ```js // Mark current position actionStack.setMark(); // Add temporary actions actionStack.push({ - id: 'temp-action', - action() { - // Handle temporary state - } + id: 'temp-action', + action() { + // Handle temporary state + } }); // Clear all actions added since mark actionStack.clearFromMark(); ``` +The file browser uses exactly this pattern: `actionStack.setMark()` when it opens, then `clearFromMark()` plus `remove("filebrowser")` when it closes. + +### freeze() / unfreeze() + +`freeze()` sets a module-level flag that makes `pop()` a complete no-op; `unfreeze()` clears it. Nothing else observes the flag — `push()`, `remove()`, `get()`, `has()` and `setMark()` all keep working while frozen. + +Acode uses it for modal loaders (`src/dialogs/loader.js`): `actionStack.freeze()` when the loader appears and `actionStack.unfreeze()` 300 ms later, once the fade-out has removed the dialog. That is the pattern to copy for a blocking overlay of your own. + +::: danger An unbalanced `freeze()` breaks Back for the whole app +`freeze` is a module-level `let freeze = false` (`src/lib/actionStack.js:7`), set by `freeze()` and cleared by `unfreeze()` (`actionStack.js:141-146`) — **a boolean pair, not a counter.** `unfreeze()` is a plain assignment, so calling it when nothing is frozen is harmless and never throws; there is nothing to guard against. + +The danger is the other direction. If your plugin calls `freeze()` and then crashes, is disabled, is updated, or simply throws before the matching `unfreeze()`, the flag stays `true` for the rest of the process. `pop()` then returns on its very first line (`if (freeze) return;`, `actionStack.js:62`) — so Back is a **no-op app-wide**, and the user cannot even reach the exit flow until Acode restarts. + +So: **call `actionStack.unfreeze()` from your `acode.setPluginUnmount` handler, unconditionally**, and reset your own depth counter in the same place so the two can never disagree. That handler is the only safety net — `acode.unmountPlugin` (`src/lib/acode.js:784-800`) runs it on disable, uninstall and update, and nothing else will clear the flag for you. +::: + +::: warning `freeze()` is not re-entrant +It is a boolean, not a counter. Two overlapping loaders that both freeze will both call `unfreeze()`, and the first one to finish releases the stack while the second is still up. If you freeze yourself, track your own depth and only call `unfreeze()` when it returns to zero. +::: + +### onCloseApp + +A get/set accessor, not a method. Reading it returns the callback (or `undefined`); assigning replaces it globally — there is only one, and Acode overwrites it with `() => acode.exec("save-state")` at startup. + +Acode's own store-and-exit example: + +```js +actionStack.onCloseApp = () => acode.exec("save-state"); +``` + +A promise returned by your callback is awaited and `exitApp()` runs in its `finally`, so an async cleanup is respected. If it returns a non-promise, `exitApp()` runs immediately. + +::: danger Setting this replaces Acode's session save +Acode assigns `onCloseApp` once during startup. If you assign it, the user is relying on *your* callback to persist state — call `acode.exec("save-state")` yourself or you will lose the editor session on exit. Always read the previous value first if you must wrap it. +::: + +### windowCopy() + +Returns a shallow copy of the module with a wrapped `pop()` that logs `"Deprecated: \`window.actionStack\` is deprecated, import \`actionStack\` instead"` and then delegates. `src/main.js` uses it for `window.actionStack = actionStack.windowCopy()`. + +It copies the **current** value of the `length` getter and `onCloseApp` accessor as plain properties, so the copy's `length` is frozen at that moment. Do not use it in a plugin; use `acode.require('actionStack')`. + +## Ordering Semantics + +| Question | Answer | +| --- | --- | +| Is it LIFO? | Yes — `stack.pop()` takes the newest entry. | +| Are actions executed in registration order? | No, reverse. | +| Does a popped action re-add itself? | Only if your callback pushes again, which is how a page that stays open can survive back. Acode's own search bar uses `actionStack.get("search-bar")?.action()`. | +| Are async actions awaited? | No. `fun.action()` is invoked bare; its return value is discarded. | +| Is `id` required? | No, but `get`/`has`/`remove` are useless without one. | +| Is the id unique? | Not enforced. Duplicates are allowed and `remove()` takes the oldest. | +| Does a thrown action leave the stack intact? | No — the entry is already removed. | + +## How Pages, Dialogs and the Editor Push and Pop + +Every interactive surface in Acode follows the same three-line pattern: push on open, remove on close. + +**A plugin page — automatic.** `src/lib/loadPlugin.js` wires your plugin's `$page` for you: + +```js +const $page = Page("Plugin"); +$page.show = () => { + actionStack.push({ + id: pluginId, + action: $page.hide, + }); + + app.append($page); +}; + +$page.onhide = function () { + actionStack.remove(pluginId); +}; +``` + +That is the whole mechanism: **you do not need to push anything for your plugin page to participate in back navigation.** It is already done, keyed by your plugin id. Pushing your own entries on top of it is how you layer sub-screens, and the action-stack order guarantees yours are popped first. + +**Dialogs.** `src/dialogs/confirm.js`, `alert.js`, `select.js`, `prompt.js`, `multiPrompt.js`, `color.js` and `dialog.js` all push a unique id on open and `actionStack.remove(actionId)` in their `hide()` — so dismissing a dialog with back does not leave a stale entry behind. + +```js +actionStack.push({ + id: actionId, // e.g. "confirm-", unique per open dialog + action: cancel, // what back should do: the cancel path +}); +// ... +function hide() { + actionStack.remove(actionId); + hideAlert(); +} +``` + +**Nested pages.** `src/pages/*` (about, plugins, plugin, problems, sponsors, settings, changelog, font manager, …) each push a stable literal id such as `"about"`, `"plugins"`, `"plugin"` or `"problems"` on show and remove it on close. `handlers/quickTools.js` pushes `"search-bar"`, the file browser pushes one entry per opened directory **keyed by the directory url**, and its selection mode pushes `"fbSelection"`. + +**The editor itself.** The editor tabs are *not* on the action stack. Back from a clean editor goes to the empty-stack exit flow. + +**WebViews.** A fullscreen WebView runs in its own Android activity and therefore never touches this stack — see [WebView](./webview.md#relationship-with-the-action-stack). + +**Loaders.** `dialogs/loader.js` freezes the stack instead of pushing, so a modal loader swallows back entirely. + +## How a Plugin Uses It + +Add a layer on top of the automatic plugin-page entry: + +```js +const actionStack = acode.require('actionStack'); + +function openSubScreen($subScreen) { + // Back closes the sub-screen, not the plugin page. + actionStack.push({ + id: 'my-plugin-sub-screen', + action() { + closeSubScreen(); + }, + }); + + app.append($subScreen); +} + +function closeSubScreen() { + actionStack.remove('my-plugin-sub-screen'); + $subScreen.remove(); +} +``` + +Make it survive back when it should: + +```js +function openSubScreen($subScreen) { + if (actionStack.has('my-plugin-sub-screen')) return; + + actionStack.push({ + id: 'my-plugin-sub-screen', + action() { + // `pop()` has already removed this entry. Re-push so the next back + // press can close it again, or forward to the plugin page. + $subScreen.hide(); + actionStack.push({ + id: 'my-plugin-sub-screen', + action: () => $subScreen.remove(), + }); + }, + }); + + app.append($subScreen); +} +``` + +## Gotchas + +- `pop()` with an empty stack exits the app (after an optional confirm). Anything you forget to `remove()` keeps the user one press away from the exit dialog instead of leaving your screen. +- `pop()` is `async` but does not await your action. Do not rely on back-press ordering for async cleanup. +- A throwing action is not caught and the entry is already gone. +- `freeze()`/`unfreeze()` are a boolean pair, not a counter. A freeze you never release makes Back a **permanent no-op app-wide** until Acode restarts — always `unfreeze()` in `acode.setPluginUnmount`. +- `clearFromMark()` clears without executing, and a single mark is shared globally. +- `onCloseApp` is a single global slot that Acode already owns. +- `length` is a getter with no setter. +- `windowCopy()` is deprecated; `window.actionStack` logs an error on every `pop()`. +- `push()` accepts any object. If `action` is not a function, back throws `fun.action is not a function`. +- `remove(id)` removes the **oldest** duplicate, `pop()` executes the **newest**. + ## Example Here's a complete example showing how to use the Action Stack for managing a file preview feature: ```js +const actionStack = acode.require('actionStack'); + class FilePreviewPlugin { - async showPreview(file) { - // Create preview UI - const preview = document.createElement('div'); - preview.className = 'preview-container'; - preview.innerHTML = await this.renderPreview(file); - document.body.appendChild(preview); - - // Add to action stack - actionStack.push({ - id: `preview-${file.name}`, - action: () => { - // Clean up preview when back is pressed - preview.remove(); - editor.focus(); - } - }); - } - - async renderPreview(file) { - // Preview rendering logic - } + async showPreview(file) { + // Create preview UI + const preview = document.createElement('div'); + preview.className = 'preview-container'; + preview.innerHTML = await this.renderPreview(file); + document.body.appendChild(preview); + + // Only one preview at a time — the id is stable for this purpose. + if (actionStack.has(`preview-${file.name}`)) return; + + // Add to action stack + actionStack.push({ + id: `preview-${file.name}`, + action: () => { + // Clean up preview when back is pressed. + // `pop()` already removed the entry before calling us. + preview.remove(); + editor.focus(); + } + }); + } + + async renderPreview(file) { + // Preview rendering logic + } } ``` When the user clicks the back button, the preview will be automatically cleaned up and focus returned to editor. + +## Complete plugin lifecycle + +Everything above, wired together the way a real plugin should — push on show, remove on close, and never leave an entry behind on unmount. Plugins are loaded as **classic scripts**, so there are no `import` / `export` statements. + +```js +const PLUGIN_ID = 'com.example.plugin'; +const actionStack = acode.require('actionStack'); +const Page = acode.require('page'); + +const PREVIEW_ID = 'my-plugin-preview'; +const OVERLAY_ID = 'my-plugin-settings'; + +let $preview = null; +let $overlay = null; + +function showPreview(html) { + // Guard against a second push with the same id. + if (actionStack.has(PREVIEW_ID)) return; + + $preview = Page('Preview'); + $preview.innerHTML = html; + + actionStack.push({ + id: PREVIEW_ID, + action: () => { + // `pop()` removed this entry already, so no `remove()` here. + // Never let a throwing action escape `pop()`. + try { + $preview.remove(); + } catch (error) { + console.error('preview teardown failed', error); + } + $preview = null; + }, + }); + + app.append($preview); +} + +function closePreview() { + // Explicit close (a button, for example): remove without executing. + actionStack.remove(PREVIEW_ID); + $preview?.remove(); + $preview = null; +} + +function openSettings($settings) { + $overlay = $settings; + + actionStack.push({ + id: OVERLAY_ID, + action: closeSettings, + }); + + app.append($overlay); +} + +function closeSettings() { + actionStack.remove(OVERLAY_ID); + $overlay?.remove(); + $overlay = null; +} + +// Freeze the stack while a blocking overlay of our own is up. Track our own +// depth, because freeze()/unfreeze() are a boolean pair, not a counter. +let freezeDepth = 0; + +function showBlockingOverlay($node) { + if (freezeDepth++ === 0) actionStack.freeze(); + app.append($node); +} + +function hideBlockingOverlay($node) { + $node.remove(); + if (--freezeDepth === 0) actionStack.unfreeze(); +} + +// The safety net for any freeze() above that never reached its unfreeze(). +function releaseFreezes() { + freezeDepth = 0; + // unfreeze() is a plain assignment, so this is harmless when nothing is + // frozen — which is exactly why it can be unconditional. + actionStack.unfreeze(); +} + +// Plugin unload / disable / update. Every id we pushed is removed here so a +// reload never inherits a stale entry. +acode.setPluginUnmount(PLUGIN_ID, () => { + actionStack.remove(PREVIEW_ID); + actionStack.remove(OVERLAY_ID); + $preview = null; + $overlay = null; + + // SAFETY NET: the freeze flag is module-level in `src/lib/actionStack.js`, + // so a freeze left behind by a crash in the code above outlives this + // plugin. `pop()` then returns immediately (`actionStack.js:62`) and Back + // does nothing anywhere in the app. Always run this. + releaseFreezes(); +}); +``` \ No newline at end of file diff --git a/docs/advanced-apis/executor.md b/docs/advanced-apis/executor.md index c980ca2..61cf9cf 100644 --- a/docs/advanced-apis/executor.md +++ b/docs/advanced-apis/executor.md @@ -14,7 +14,7 @@ const Executor = globalThis.Executor; // Executor instance const background = Executor.BackgroundExecutor; // BackgroundExecutor instance ``` -Both instances share the same methods. +Both instances share the same **JavaScript** class — but they are backed by two different native plugins, so they do **not** support the same actions. See [The two instances are not identical](#the-two-instances-are-not-identical). > [!NOTE] > **Which executor should I use?** @@ -22,6 +22,89 @@ Both instances share the same methods. > - Use the **background executor** (`Executor.BackgroundExecutor`) for **short-running commands**. It starts processes directly with no foreground service and no notification, so it is lighter, but Android can kill the process once the app leaves the foreground. Use it for quick, self-contained commands that finish in seconds. > - Use the **foreground executor** (`Executor`) for **long-running commands**. Its processes run under a foreground service with a persistent notification, which keeps them alive while the app is in the background. This is the default mode; `moveToForeground()` / `moveToBackground()` switch it at runtime. +### `Executor` is **not** requirable + +This is the single most common mistake with this API. **`acode.require("Executor")` returns `undefined`.** + +| Access | Works? | +| --- | --- | +| `acode.require("Executor")` | ❌ returns `undefined` | +| `acode.require("executor")` | ❌ returns `undefined` | +| `window.Executor` | ✅ | +| `globalThis.Executor` | ✅ | +| Bare `Executor` | ✅ (same global, but shadow it with `const` and you lose it for nested code) | + +Evidence: + +- `Acode#define(name, module)` stores modules in a private `#modules` map keyed by `name.toLowerCase()`, and `require(module)` is a plain lookup in that map. **Nothing anywhere in `src/` calls `this.define("Executor", …)`** — the only terminal-adjacent registration is `this.define("terminal", terminalModule)`. +- `Executor` is instead a **Cordova clobber**. `src/plugins/terminal/plugin.xml` declares: + + ```xml + + + + ``` + + and `www/Executor.js` ends with `module.exports = executorInstance;`. +- Acode's own typings agree: `src/index.d.ts` declares `Executor` only as a global (`declare const Executor: Executor | undefined;` plus `declare global { var Executor: Executor | undefined; }`) — there is no `require(module: "Executor")` overload. + +::: danger Always null-check it +`src/index.d.ts` types the global as `Executor | undefined`, and Acode itself guards before use (`if (typeof Executor === "undefined")`). Do the same, because a plugin can load before the Cordova clobber is applied: + +```js +const executor = globalThis.Executor; +if (!executor) { + acode.toast('Terminal plugin is not ready'); + return; +} +``` +::: + +::: warning Do not shadow the global +`const Executor = globalThis.Executor;` works, but any inner scope that also declares `Executor` silently changes which executor a nested call uses. Prefer `const exec = globalThis.Executor;` and pass it around. +::: + +### The two instances are **not** identical + +`Executor.BackgroundExecutor` is a **second, independent native plugin class**, not a mode flag. `BackgroundExecutor.java` only implements these actions: + +`start`, `write`, `stop`, `exec`, `isRunning`, `listProcesses`, `listAllProcesses`, `killProcess`, `loadLibrary`, `setProotDebug` + +Everything else falls into its `default:` branch and **rejects** with `"Unknown action: …"`. So: + +| Method | `Executor` (foreground) | `Executor.BackgroundExecutor` | +| --- | --- | --- | +| `execute`, `start`, `write`, `stop`, `isRunning` | ✅ | ✅ | +| `listProcesses`, `listAllProcesses`, `killProcess` | ✅ | ✅ | +| `loadLibrary`, `setProotDebug` | action accepted, but `loadLibrary` always rejects | action accepted, but `loadLibrary` always rejects | +| `spawnStream` | ✅ (handled before service binding) | ❌ rejects, `"Unknown action: spawnStream"` | +| `moveToForeground`, `moveToBackground` | ✅ | ❌ rejects, `"Unknown action: moveToForeground"` | +| `stopService` | ✅ | ❌ rejects, `"Unknown action: stopService"` | + +"Both instances share the same methods" is true of the **JavaScript** class (`Executor.js` gives both the same prototype) and false of the **native** side. Always call `spawnStream` and the three service-control methods on `Executor` only. + +::: warning "Both instances share the same methods" is only half true +`Executor.js` builds both objects from the same class, so the methods exist in JS. But `Executor` and `BackgroundExecutor` are two different `CordovaPlugin` classes with different `switch` blocks. Calling a service-control method on the background instance **rejects**; it does not silently no-op. +::: + +### Where commands actually run + +Both executors delegate to the same `ProcessManager`, which does exactly this: + +```java +String xcmd = useAlpine ? "source $PREFIX/init-sandbox.sh " + cmd : cmd; +ProcessBuilder builder = new ProcessBuilder("sh", "-c", xcmd); +``` + +- **Everything runs through `sh -c`.** There is no direct `execve`; your `command` string is always shell text. +- `alpine = true` prefixes `source $PREFIX/init-sandbox.sh ` to your command — the AXS/proot sandbox. `alpine = false` (the default) runs on the plain Android shell under the app's own UID. +- The environment is always seeded with `PREFIX` (app files dir), `NATIVE_DIR` (native library dir), `ANDROID_TZ`, and `FDROID`. When proot debug is on, `PROOT_VERBOSE=2` is added. +- `spawnStream()` is the exception: it runs `new ProcessBuilder(cmd)` with **no** environment setup and `redirectErrorStream(true)`, and its signature has no `alpine` parameter at all. + +::: danger No plugin-declared permission gates any of this +`Executor` is part of the app's own Cordova build. Its `plugin.xml` declares `WAKE_LOCK`, `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_SPECIAL_USE` and `POST_NOTIFICATIONS` into the **app** manifest — there is no runtime check against the calling plugin, and no manifest field a plugin can use to request it. Any plugin can spawn a shell. See [System](./system.md) for the same situation. +::: + ## One-off execution ### `execute(command, alpine?)` @@ -29,8 +112,8 @@ Both instances share the same methods. - Purpose: Runs a single shell command and waits for it to finish. Output is returned after the process exits (no live streaming of output). - Parameters: - `command` (string): The command to run. - - `alpine` (boolean, optional): Run inside the Alpine sandbox when `true`; run in the Android environment when `false`. -- Returns: `Promise` that resolves with stdout on success, or rejects with an error/stderr on failure. + - `alpine` (boolean, optional, default `false`): Run inside the Alpine sandbox when `true`; run in the Android environment when `false`. +- Returns: `Promise`. Resolves with **trimmed stdout** when the exit code is `0`; **rejects** with trimmed stderr (or `"Command exited with code: N"` when stderr is empty) otherwise. ```js // Outputting hello on stdout @@ -49,6 +132,10 @@ console.log(output); > [!Warning] > Do not run things like an infinite loop or a shell because `execute()` waits for the process to exit and a shell never exits on its own, avoid running those commands with this function. +::: tip `execute()` is not filtered +Unlike [`terminal.write()`](./terminal.md#the-write-security-filter), there is **no command blocklist** on `Executor.execute()`. It runs whatever you pass. Validate any user- or file-derived arguments yourself before interpolating them. +::: + ## Long-running processes ### `start(command, onData, alpine?)` @@ -56,23 +143,25 @@ console.log(output); - Starts a shell process and enables real-time streaming of `stdout`, `stderr`, and `exit`. - Parameters: - `command` (string): The command to run (e.g. `"sh"`, `"ls -al"`). - - `onData` (function): `(type, data) => void`. `type` is `"stdout"`, `"stderr"`, or `"exit"` (the process exit code); `data` is the output line or exit code. - - `alpine` (boolean, optional): Run inside the Alpine sandbox when `true`. + - `onData` (function): `(type, data) => void`. `type` is `"stdout"`, `"stderr"`, or `"exit"` (the process exit code); `data` is the output **line** or exit code. A frame that does not parse as `^([^:]+):(.*)$` is delivered as `("unknown", message)`. + - `alpine` (boolean, optional, default `false`): Run inside the Alpine sandbox when `true`. - Returns: `Promise` resolving to a unique process UUID used by `write()`, `stop()`, and `isRunning()`. ```js const uuid = await Executor.start("sh", (type, data) => { console.log(`[${type}] ${data}`); }); -Executor.write(uuid, "echo Hello World\r"); -Executor.stop(uuid); +await Executor.write(uuid, "echo Hello World\r"); +await Executor.stop(uuid); ``` +The UUID is delivered as the **first** native callback, before any output, and the promise is resolved with it. `stdout`/`stderr` arrive **line by line** (`StreamHandler.streamOutput` reads with a `BufferedReader`), so partial lines are never delivered, but a single line has no size cap. There is no backpressure and no ring buffer: a chatty process will call `onData` thousands of times, and every call crosses the Cordova bridge. + ### `write(uuid, input)` Sends input to a running process's stdin. -- Returns: `Promise`. +- Returns: `Promise` resolving with `"Written to process"`. **Rejects** with `"Process not found or closed"` if the uuid is unknown (background executor), or `"Write error: …"` on an `IOException`. ```js await Executor.write(uuid, "ls /sdcard\r"); @@ -82,13 +171,13 @@ await Executor.write(uuid, "ls /sdcard\r"); Terminates a running process. -- Returns: `Promise`. +- Returns: `Promise` resolving with `"Process terminated"`. The background executor kills the whole **process group** (`kill -9 -`) and then `destroy()`s the `Process`, so children die too. Rejects with `"No such process"` for an unknown uuid. ### `isRunning(uuid)` Checks whether a process is still running. -- Returns: `Promise`. +- Returns: `Promise`. The native side answers `"running"`, `"exited"`/`"stopped"`, or `"not_found"`; the JS wrapper collapses all three non-`"running"` answers to **`false`**, so you cannot distinguish "finished" from "never existed". ```js if (await Executor.isRunning(uuid)) { @@ -104,6 +193,25 @@ Spawns a process and exposes it as a raw WebSocket stream. Once the process is r - `cmd` (string[]): Command and arguments (e.g. `["sh", "-c", "echo hi"]`). - `callback` (function): `(ws) => void`. - `onError` (function, optional): error handler. +- Returns: **`undefined`** — this method is callback-only and wraps no promise. It resolves an ephemeral local port internally and connects to `ws://127.0.0.1:`. + +```js +Executor.spawnStream( + ["sh", "-c", "while read l; do echo \"got: $l\"; done"], + (ws) => { + ws.send("hello\n"); // message frames are forwarded to stdin + }, + (err) => console.error(err), +); +``` + +::: warning `spawnStream()` is foreground-executor only and behaves differently +- Native `BackgroundExecutor` has no `spawn` case → it rejects with `"Unknown action: spawnStream"`. +- It uses `new ProcessBuilder(cmd)` directly, so **no** `sh -c`, **no** Acode environment (`PREFIX`, `NATIVE_DIR`, `FDROID`, … are absent) and **no** `alpine` option whatsoever. Put `-c` in `cmd` yourself if you need a shell. +- `redirectErrorStream(true)` means **stderr is merged into the same stream**; you cannot separate them. +- Frames are binary `ArrayBuffer` chunks (`ws.binaryType = "arraybuffer"`), truncated at the server's 8 KB buffer. Chunk boundaries are arbitrary and may split UTF-8 sequences. +- Closing the socket destroys the process (`onClose` → `process.destroy()`). +::: ## Managing processes @@ -111,33 +219,49 @@ Spawns a process and exposes it as a raw WebSocket stream. Once the process is r Lists the processes currently managed by this Executor. -- Returns: `Promise>`. `background` is `true` for a `BackgroundExecutor`. +- Returns: `Promise>`. `background` is added by the JS wrapper and is `true` for a `BackgroundExecutor`. `pid` is the real OS pid; `startedAt` is a `Date.now()` timestamp. +- Only **live** processes are listed — dead ones are skipped, so the list changes without any event. +- The foreground `Executor` returns an **empty array immediately** when its service is not yet bound, rather than starting the service. + +```js +for (const p of await Executor.listProcesses()) { + console.log(p.background ? 'bg' : 'fg', p.id, p.pid, p.command); +} +``` ### `listAllProcesses()` Lists all running OS processes under the app's user id. -- Returns: `Promise>`. +- Returns: `Promise>`, read straight from `/proc`. `memory` is `VmRSS` **in kB**, `ppid` is `-1` when unreadable, and `startedAt` is the `/proc/` directory mtime (not a real start time). Processes owned by other UIDs are filtered out; unreadable ones are skipped. ### `killProcess(pid)` Forcefully kills a process by its native PID. -- Returns: `Promise`. +- Returns: `Promise` resolving with `"Process terminated"`. +- Runs `kill -9 ` as a separate process and **rejects** with `Failed to kill process: …` if `kill` exits non-zero — so killing a pid you do not own is an error, not a silent no-op. ## Service control ### `moveToForeground()` / `moveToBackground()` -Moves the Executor service between foreground (shows the notification) and background. +Moves the `Executor` service between foreground (shows the notification) and background. -- Returns: `Promise`. +- Returns: `Promise` — `"Service moved to foreground mode"` / `"Service moved to background mode"`. +- **Foreground executor only.** `Executor.BackgroundExecutor` rejects with `"Unknown action: …"`. ### `stopService()` -Stops the Executor service completely. This does **not** guarantee that all running processes are killed - the service just stops being active. The processes will keep running until stopped. +Stops the `Executor` service completely. This does **not** guarantee that all running processes are killed - the service just stops being active. The processes will keep running until stopped. -- Returns: `Promise`. +- Returns: `Promise` resolving with `"Service stopped"`. +- Unbinds the service, calls `context.stopService(TerminalService)`, and clears the cached messenger. +- **Foreground executor only.** `Executor.BackgroundExecutor` rejects with `"Unknown action: stopService"`. + +::: warning `terminal.close()` calls this for you +`TerminalManager.closeTerminal()` calls `Executor.stopService()` whenever the last terminal goes away. If your plugin is holding a long-lived `Executor` process, closing the user's terminal will drop the service out from under it. +::: ## Advanced @@ -145,21 +269,45 @@ Stops the Executor service completely. This does **not** guarantee that all runn Loads a native library from the given path. -- Returns: `Promise`. - -```js -await Executor.loadLibrary('/path/to/library.so'); -``` +- Returns: a rejected `Promise` — always. > [!Warning] > `loadLibrary()` has been deprecated and is no longer supported on newer Acode versions. +::: danger It does not "do nothing" — it rejects +Both native classes short-circuit the action with an error and no `System.load` at all: + +``` +This feature is no longer supported. Loading native libraries directly from +JavaScript is no longer allowed due to security reasons. +``` + +Do not call it, and do not wrap it in `try/catch` expecting a fallback. +::: + ### `setProotDebug(enabled)` -Toggles proot debug output (used for the Alpine sandbox). +Toggles proot debug output (used for the Alpine sandbox). Sets the **static** `ProcessManager.prootDebug` flag, which is shared by both executors and controls `PROOT_VERBOSE=2` in every subsequently spawned process. + +- Returns: `Promise` — `"PRoot debug enabled"` / `"PRoot debug disabled"`. +- Acode manages this from `settings.terminalSettings.prootDebug`; it calls it on **both** instances whenever AXS is (re)started, so any value you set is overwritten the next time a server terminal is created. + +## Gotchas -- Returns: `Promise`. +- `acode.require("Executor")` is `undefined`. Use `globalThis.Executor` and null-check it. +- `Executor.BackgroundExecutor` is a different native plugin: no `spawnStream`, no `moveToForeground`/`moveToBackground`, no `stopService`. Those calls reject. +- Everything runs as `sh -c `; `alpine: true` prepends `source $PREFIX/init-sandbox.sh `. There is no argv-array API except `spawnStream`. +- There is **no** security filter here. `terminal.write()` blocks 23 dangerous line patterns; `execute()` does not. +- `execute()` trims stdout and rejects on **any** non-zero exit code — a `grep` that legitimately finds nothing will reject. +- `isRunning()` cannot distinguish "exited" from "not_found"; both are `false`. +- `start()` streams by line, uncapped and without backpressure. +- Foreground `listProcesses()` returns `[]` (not a pending promise) while the service is unbound. +- `loadLibrary()` always rejects. +- `setProotDebug()` is global and gets reset by Acode's terminal startup. +- `stopService()` can be triggered by Acode itself when the last terminal closes. +- `POST_NOTIFICATIONS` is requested by the native `Executor` plugin's own `initialize()` at app startup — not by your code, and not something you can suppress. ## Related APIs - Visual terminal sessions: [Terminal](./terminal.md) +- Low-level device bridge (`window.system`): [System](./system.md) \ No newline at end of file diff --git a/docs/advanced-apis/file-handlers.md b/docs/advanced-apis/file-handlers.md index 1c247f1..e9c34b4 100644 --- a/docs/advanced-apis/file-handlers.md +++ b/docs/advanced-apis/file-handlers.md @@ -1,6 +1,16 @@ # File Handlers API -Use this API to register custom open handlers for file extensions. +Use this API to register custom open handlers for file extensions. A handler intercepts `openFile()` so your plugin — not the text editor — decides what happens. + +## Where the API lives + +| Access | Works? | +| --- | --- | +| `acode.registerFileHandler(id, options)` | ✅ | +| `acode.unregisterFileHandler(id)` | ✅ | +| `acode.require('fileTypeHandler')` | ❌ returns `undefined` | + +`src/lib/acode.js` imports the registry as a private module and never calls `this.define(...)` for it, so the registry is **not** exposed through `acode.require()`. The two `acode.*` methods are the entire plugin-facing surface. `getFileHandler(filename)`, `getHandlers()` and the underlying `Map` are internal. ## `acode.registerFileHandler(id, options)` @@ -15,21 +25,207 @@ acode.registerFileHandler("com.example.svg-viewer", { }); ``` -`options` fields: +| Argument | Type | Description | +| --- | --- | --- | +| `id` | `string` | Unique handler id. Re-using a registered id **throws** | +| `options.extensions` | `string[]` | Required, must be non-empty | +| `options.handleFile` | `Function` | Required. Must be a function | + +These are the only two option keys that exist. Extra keys are silently ignored — there is no `icon`, `mimeType`, `priority`, `name` or `displayName` field, and the registry stores only `{extensions, handleFile}`. + +### Extension normalization + +```js +ext.toLowerCase().replace(/^\./, "") +``` + +Each entry is lowercased and has **one** leading dot removed. So `"svgx"`, `".svgx"` and `".SVGX"` all normalize to `"svgx"`. `"..svgx"` would normalize to `".svgx"` and never match. + +Matching (`getFileHandler(filename)`) takes the **last** dot segment only: + +```js +const ext = filename.split(".").pop().toLowerCase(); +``` + +| Filename | Matched extension | +| --- | --- | +| `app.SVGX` | `svgx` | +| `button.test.ts` | `ts` | +| `.env` | `env` (a leading dot yields an empty first segment, so the dotfile *does* match) | + +::: warning Last segment only — not longest match +`button.test.ts` matches a handler registered for `ts`, never one registered for `test.ts`. This is different from the icon-pack API, which does try the longest compound extension first. +::: + +### `"*"` catch-all + +A handler whose `extensions` contains `"*"` matches **every** filename: + +```js +acode.registerFileHandler("com.example.vault", { + extensions: ["*"], + handleFile: async (fileInfo) => { /* ... */ }, +}); +``` + +## `handleFile(fileInfo)` + +Called by `openFile()` with a single object: + +| Field | Type | Description | +| --- | --- | --- | +| `name` | `string` | File name from `fs.stat()`, falling back to the tab filename, then the URI | +| `uri` | `string` | Absolute file URL | +| `stats` | `object` | The result of `fsOperation(uri).stat()` | +| `readOnly` | `boolean` | `true` when `stats.canWrite === false` | +| `options.cursorPos` | `{row, column}` | Cursor from the original open request | +| `options.render` | `boolean` | Render hint from the original open request | +| `options.onsave` | `Function` | Save hook from the original open request | +| `options.encoding` | `string` | Encoding from the original open request | +| `options.mode` | `string` | Language mode from the original open request | +| `options.createEditor` | `(isUnsaved, text, detectedEncoding?) => void` | Escape hatch — build a normal editor tab from inside your handler | +| `options.signal` | `AbortSignal` | Aborted when the open request is superseded | + +**Return value: none.** It is awaited purely for its side effects. Once it resolves, `openFile()` returns immediately and the normal text-editor, image, video and audio paths are **all skipped**. + +::: tip Delegating to the editor +`options.createEditor(isUnsaved, text, detectedEncoding)` is the escape hatch, but **you must read the file yourself** — `text` is not supplied for you: + +```js +const fs = acode.require('fs'); +const encoding = acode.require('settings').value.defaultFileEncoding; +const text = await fs(uri).readFile(encoding); +options.createEditor(false, text, encoding); +``` + +Calling `options.createEditor(false)` without text creates an editor tab with no content. Delegating this way also skips `recents.addFile(uri)`. +::: + +### When is it called? + +`openFile()` runs the handler lookup right after `fs.stat()` and **before** any content-type branch: + +```js +const customHandler = fileTypeHandler.getFileHandler(name); +... +if (customHandler) { + try { + await customHandler.handleFile({ name, uri, stats: fileInfo, readOnly, options: {...} }); + return; + } catch (error) { + console.error(`File handler '${customHandler.id}' failed:`, error); + // non-external opens continue with the default handling + } +} +``` + +Two important consequences: + +- If the file is **already open**, `openFile()` returns early (promoting the existing tab) and your handler is **not** called. +- If `handleFile` **throws**, Acode logs `File handler '' failed:` and **falls through to the default editor** — so a buggy handler degrades to a text tab rather than losing the file. -- `extensions` (required): extension array. Dots are allowed and normalized. -- `handleFile` (required): async function receiving file info. +::: danger External intents +When `openFile()` is called with `{external: true}` (an Android file intent, e.g. tapping a `.pdf` in another app), a handler failure is **re-thrown** instead of falling back. For those extensions Acode also *requires* a handler: `.pdf`, `.docx`, `.dotx`, `.xlsx`, `.xls`, `.ods`, `.pptx`, `.ppsx` and `.potx` open with `DOCUMENT_HANDLER_UNAVAILABLE` when no handler matches. +::: ## `acode.unregisterFileHandler(id)` -Removes a handler. +Removes a handler. Unknown ids are ignored — the delete is unconditional, so it does not throw. ```js acode.unregisterFileHandler("com.example.svg-viewer"); ``` +::: tip Clean up on unmount +Nothing unregisters file handlers automatically. Call `acode.unregisterFileHandler(id)` from `acode.setPluginUnmount(id, ...)` or the handler leaks into the next load of your plugin. +::: + +## Priority and overrides + +This is provable from the registry, and it is **first-registration-wins**: + +- `getFileHandler()` iterates `this.#handlers` — a `Map`, so in insertion order — and returns the **first** handler whose `extensions` include the matched extension or `"*"`. +- Re-registering the same `id` throws `Handler with id '' is already registered`. There is no "replace" path. +- `unregister()` + `register()` therefore **moves you to the end** of the queue, which can hand priority to another plugin. + +So two plugins registering the same extension is a race won by whichever registered first; the loser's `handleFile` is simply never called. + ## Notes -- Handler ids must be unique. -- Extensions are matched case-insensitively. +- Handler ids must be unique — duplicate registration throws. +- Extensions are normalized to lowercase with a single leading dot removed, and matched case-insensitively. - `"*"` can be used to match any extension. +- Return nothing from `handleFile`. If you want a normal editor tab instead, read the file yourself and call `options.createEditor(false, text, encoding)`. + +## Complete example: a custom viewer + +```js +// main.js +if (window.acode) { + const id = 'com.example.markdown-preview'; + const fs = acode.require('fs'); + const page = acode.require('page'); + const actionStack = acode.require('actionStack'); + const encoding = acode.require('settings').value.defaultFileEncoding; + + // Never inject raw file content into innerHTML. + const escape = (s) => + s.replace(/&/g, '&').replace(//g, '>'); + + acode.registerFileHandler(id, { + // Dots are allowed and normalized away + extensions: ['mdx', '.md'], + handleFile: async ({ name, uri, readOnly }) => { + const source = await fs(uri).readFile(encoding); + + const preview = page(name.replace(/\.mdx?$/i, ''), { + lead: tag('span', { + className: 'icon arrow_back', + onclick: () => preview.hide(), + }), + }); + + preview.appendBody(tag('pre', { textContent: source })); + if (readOnly) preview.appendBody(tag('small', { textContent: 'Read only' })); + + preview.onhide = () => actionStack.remove(`${id}-preview`); + preview.on('show', () => preview.body.scrollTo(0, 0)); + + preview.show = () => { + if (preview.isConnected) return; // never push twice + actionStack.push({ id: `${id}-preview`, action: preview.hide }); + app.append(preview); + }; + + preview.show(); + }, + }); + + acode.setPluginUnmount(id, () => { + acode.unregisterFileHandler(id); + }); +} +``` + +::: tip Pass `"*"` for an envelope handler +If your plugin should claim files only when it recognises them, register `extensions: ['*']` and delegate when it does not: + +```js +const fs = acode.require('fs'); +const encoding = acode.require('settings').value.defaultFileEncoding; + +acode.registerFileHandler('com.example.picky', { + extensions: ['*'], + handleFile: async ({ uri, name, options }) => { + if (!name.endsWith('.todo')) { + const text = await fs(uri).readFile(encoding); + options.createEditor(false, text, encoding); + return; + } + // ... your viewer ... + }, +}); +``` + +Because resolution is first-registration-wins, an envelope handler registered early will shadow every later handler. Prefer narrow extensions. +::: \ No newline at end of file diff --git a/docs/advanced-apis/intent.md b/docs/advanced-apis/intent.md index 69fc7e8..00bb6dd 100644 --- a/docs/advanced-apis/intent.md +++ b/docs/advanced-apis/intent.md @@ -2,22 +2,34 @@ The Intent API provides functionality to handle intents from other apps and implement custom URI scheme handling in Acode plugins. +Verified against Acode **v1.13.5** (versionCode `1011`): `src/handlers/intent.js` in full, the module wrapper in `src/lib/acode.js` (line 394), the native intent JSON in `src/plugins/system/android/com/foxdebug/system/System.java` (`getIntentJson`), the manifest in `config.xml`, and the in-app consumer `src/pages/plugin/plugin.js`. + ## Overview The Intent API allows plugins to: -- Handle intents when Acode is opened from other apps -- Process custom URI schemes in the format `acode:////` -- Control intent behavior through event handlers -- Enable deep linking and file sharing between applications +- Handle `acode://` deep links that Acode dispatches as intents +- React to custom URI schemes in the format `acode:////` +- Suppress or short-circuit Acode's own handling with `preventDefault()` / `stopPropagation()` +- Enable deep linking between applications For example, you could: -- Open specific files directly from your file manager -- Accept shared text content from messaging apps -- Implement "Open in Acode" functionality in other apps -- Create custom URI schemes for your plugin features +- Deep-link into your plugin's own screens (`acode://myplugin/search/query`) +- React to Acode's built-in deep links (`acode://plugin/install/`) +- Skip Acode's default behaviour when you want to handle a link yourself + +::: warning Compatibility note +`intent` is only present when the running Acode version exports it. Check before use: + +```js +const intent = acode.require('intent'); +if (!intent?.addHandler) { + // Older Acode without the intent module. +} +``` +::: -::: warning Compatibility Note -The Intent API is not supported in older versions of Acode. Always check if it's available before using. +::: danger This API is **not** for shared files +A file opened from a file manager, or shared from another app, **never reaches your intent handler.** See [What actually gets dispatched](#what-actually-gets-dispatched). Only `acode://` URLs produce an `IntentEvent`. ::: ## Usage @@ -28,106 +40,452 @@ The Intent API is not supported in older versions of Acode. Always check if it's const intent = acode.require('intent'); ``` +The module has exactly two members, defined in `src/lib/acode.js`: + +```js +const intent = { + addHandler: addIntentHandler, + removeHandler: removeIntentHandler, +}; +``` + ### Methods #### addHandler -Adds an intent handler function that will be called when intents are received. + +Appends a handler to a single module-level array. ```ts -addHandler(handler: (event: IntentEvent) => void): void +addHandler(handler: (event: IntentEvent) => void | Promise): void ``` +- **Parameters:** `handler` — a function receiving the `IntentEvent`. +- **Returns:** `undefined`. Nothing is validated; a non-function value is pushed and will throw when the intent arrives. +- **Order:** handlers run in registration order, oldest first. +- **Removal:** you must keep a reference to the exact function you passed, because `removeHandler` compares by identity (`handlers.indexOf(handler)`). + #### removeHandler -Removes a previously added intent handler. + +Removes the **first** matching handler. ```ts removeHandler(handler: (event: IntentEvent) => void): void ``` +- **Returns:** `undefined`. Removing a handler that was never added is a silent no-op — it does not throw. +- Registering the same function twice and calling `removeHandler` once leaves one registration behind. + +```js +const handler = (event) => { /* ... */ }; +intent.addHandler(handler); +intent.removeHandler(handler); +``` + ### The IntentEvent Object -The handler receives an `IntentEvent` object with these properties: +A fresh `IntentEvent` class instance is created per intent and shared by every handler in that dispatch. -- `module` (string): The module name from the URI -- `action` (string): The action to perform -- `value` (string): Additional data value +| Member | Type | Description | +| --- | --- | --- | +| `module` | `string` | First path segment of the `acode://` URL. `undefined` when the URL had fewer segments. | +| `action` | `string` | Second path segment. `undefined` when absent. | +| `value` | `string` | Third path segment. `undefined` when absent. | +| `preventDefault()` | method | Marks the event default-prevented. Acode then skips **all** of its own built-in handling for this intent. | +| `stopPropagation()` | method | Stops the dispatch loop — handlers registered after yours never run for this intent. | +| `defaultPrevented` | getter | `boolean`, read-only. | +| `propagationStopped` | getter | `boolean`, read-only. | -And these methods: +::: warning `value` is the raw third segment only — and it is never decoded +Parsing is literally (`src/handlers/intent.js:32-33`): -- `preventDefault()`: Prevents default intent handling -- `stopPropagation()`: Stops other handlers from executing +```js +const path = url.replace("acode://", ""); +const [module, action, value] = path.split("/"); +``` + +Three consequences: + +1. **Everything after the third `/` is discarded.** Destructuring stops at the + third name, so `acode://myplugin/open/a/b/c` gives `value === "a"`. There is + no `join`, no remainder, nothing — a **raw multi-slash URI can never survive**. +2. **There is no URL-decoding, anywhere.** `%20` stays `%20`, and a `+` stays `+`. + Neither `src/handlers/intent.js` nor the native `getIntentJson` + (`System.java:2038` passes `intent.getDataString()` through verbatim) calls + `decodeURIComponent`. Decoding is **the plugin's job**. +3. **Query strings and fragments are part of `value`.** `acode://myplugin/search/hello%20world` + arrives as the literal string `hello%20world`. + +So the two cases behave like this: + +| Link as delivered | `event.value` | What you must do | +| --- | --- | --- | +| `acode://myplugin/open/file%3A%2F%2F%2Fa%2Fb.txt` | `file%3A%2F%2F%2Fa%2Fb.txt` | `decodeURIComponent(value)` → `file:///a/b.txt` — **the only form that works** | +| `acode://myplugin/open/file:///a/b.txt` | `file:` | Nothing — the path is already gone. This link cannot work. | + +`url.replace("acode://", "")` replaces only the first occurrence, so a nested `acode://` later in the string is preserved. +::: + +::: danger Always build the value as one encoded segment +There is no raw multi-slash form to fall back on. Construct the link as `` `acode://${module}/${action}/${encodeURIComponent(value)}` `` and decode with `decodeURIComponent(value)` in the handler. + +Guard the decode — `decodeURIComponent` **throws** `URIError` on a malformed escape such as a lone `%`, and neither the dispatch loop (`intent.js:41-45`) nor your own caller catches it. +::: + +::: danger `preventDefault()` is ignored inside an async handler +Handlers are invoked like this: + +```js +for (const handler of handlers) { + handler(event); + if (event.defaultPrevented) defaultPrevented = true; + if (event.propagationStopped) break; +} +``` + +`handler(event)` is called **without `await`**, and the loop inspects the flags immediately afterwards. If your handler is `async` and calls `event.preventDefault()` after an `await`, the flag is set too late: Acode has already decided not to suppress its default handling and has already run your later handlers. Call `preventDefault()` / `stopPropagation()` **synchronously**, before the first `await`. + +An `async` handler is otherwise fine — Acode never awaits it and never catches its rejection, so **you** must handle your own errors. +::: + +## What actually gets dispatched + +Acode's `HandleIntent(intent)` only runs for the Android actions `VIEW`, `EDIT`, `SEND` and `SEND_MULTIPLE` (matched as the last dot-segment of `intent.action`, so `android.intent.action.SEND_MULTIPLE` becomes `SEND_MULTIPLE`). + +### `acode://` URLs go to your handler + +The URL is taken from `intent.fileUri || intent.data || intent.extras["android.intent.extra.STREAM"]`. If it starts with `acode://`, an `IntentEvent` is built and every registered handler is invoked. + +| URI | `module` / `action` / `value` | Who handles it | +| --- | --- | --- | +| `acode://auth/callback/...` | — | **Never dispatched.** Reserved by the native layer (`System.isReservedAuthIntent`) and additionally short-circuited in `handlers/intent.js`. A code you can rely on being free. | +| `acode://plugin/install/` | `plugin` / `install` / `` | Acode opens its plugin details page. `value` must match `/^([a-z0-9.]+)$/` or nothing happens. Suppress it with `preventDefault()` to run your own install flow. | +| `acode://plugin/purchased/` | `plugin` / `purchased` / `` | Acode's plugin page already listens for this after a browser checkout (`src/pages/plugin/plugin.js`). | +| `acode://plugin/uninstall/` | `plugin` / `uninstall` / `` | Same — Acode's plugin page listens for this after an external refund flow. | +| `acode://pro/` | `pro` / `` / — | Acode refreshes `config.HAS_PRO` from the server and hides the banner. No other effect. | +| `acode://myplugin//` | `myplugin` / `` / `` | **Yours.** Any module name is yours; nothing in Acode claims it. Build the third segment with `encodeURIComponent` — a raw multi-slash URI is truncated to `"file:"`. | + +Acode's own built-in `plugin/install` and `pro` handling runs **only if no handler called `preventDefault()`** — the check is `if (defaultPrevented) return;` before both blocks. + +::: tip Claim a module name Acode does not use +The reserved names are just `auth`, `plugin` and `pro`. A plugin-specific module such as `acode://myplugin/...` collides with nothing. +::: + +### Everything else never reaches your handler + +For any non-`acode://` URL, Acode does **not** build an `IntentEvent`. It filters `intent.uris` (or the URL) down to `content://` and `file://` entries, queues them, and opens them itself once files are restored and plugins have loaded: + +```js +await openFile(uri, { + mode: "single", + render: true, + persistInSession: false, + external: true, +}); +``` + +This is the path used by: +- `android.intent.action.VIEW` / `EDIT` with `file://` or `content://` data (the file manager) +- `android.intent.action.SEND` / `SEND_MULTIPLE`, including `EXTRA_STREAM` and `ClipData` + +::: warning `event.module === 'file'` never happens +There is no `'file'` module and no `'open'` action in the intent API. A shared or opened file never produces an `IntentEvent` — it is opened by Acode before your code can see it, so you cannot intercept it here at all. To influence how a shared file opens, register a [file handler](./file-handlers.md) instead. +::: -### Examples +::: warning Files may need a document plugin +With `external: true`, opening a `.pdf`, `.docx`, `.dotx`, `.xlsx`, `.xls`, `.ods`, `.pptx`, `.ppsx` or `.potx` without a registered document handler throws: + +``` +Error: Document handler unavailable +code: "DOCUMENT_HANDLER_UNAVAILABLE" +``` + +Acode batches those failures and shows a dialog offering to open the Plugins page. This is the error path an earlier version of this page attributed to `openFile`; it lives in `src/lib/openFile.js` and is not something a plugin can suppress. +::: + +### When handlers are registered + +Intent delivery waits for both startup phases. `processPendingIntents()` returns early until `sessionStorage.isfilesRestored === "true"` **and** `isInitialPluginLoadComplete()`. Registered file intents are queued in `pendingIntents` and drained one batch at a time. + +The `acode://` dispatch itself is **not** queued: `HandleIntent` runs handlers immediately, including very early in startup, before your plugin may have registered anything. + +::: warning Register your handler as early as possible +`src/main.js` installs the native handler with `system.setIntentHandler(...)` and then calls `system.getCordovaIntent(...)` to replay the launch intent. If your `main.js` has not run `intent.addHandler(...)` by then, a cold-start deep link is simply lost. Register at the top level of `main.js`, not inside `acode.setPluginInit` or a `DOMContentLoaded` handler. +::: + +## Opening files from an intent handler + +::: danger `editorManager.openFile()` does not exist +Earlier versions of this page showed `editorManager.openFile(event.value)`. **There is no such method.** The exported `editorManager` object is defined at `src/lib/editorManager.js:3164` and exposes `getFile`, `switchFile`, `addFile`, `splitPane`, `revealRange`, `openPreviousEditorFromHistory` and so on — `openFile` is not among them. The `openFile` in that file is an internal import from `src/lib/openFile.js` and is **not** exposed to plugins: + +```js +// src/lib/editorManager.js:102 — internal only +import openFile from "lib/openFile"; +``` + +Calling it throws `TypeError: editorManager.openFile is not a function`. +::: + +`acode.exec("open-file", ...)` is **not** a substitute either — `src/lib/commands.js:435` implements it as *open the file browser picker*: + +```js +async "open-file"() { + editorManager.editor.contentDOM.blur(); + const FileBrowser = await loadFileBrowser(); + FileBrowser("file").then(FileBrowser.openFile).catch(FileBrowser.openFileError); +} +``` + +### Use `EditorFile` + +`acode.require("EditorFile")` is the supported way to open a path from a plugin. Constructing it registers the file with the editor; `load()` fills the session (reusing an in-flight load), and `render()` makes it active. + +```js +const EditorFile = acode.require('EditorFile'); + +async function openPath(uri) { + // Already open? Just focus the existing tab. + const existing = window.editorManager?.getFile(uri, 'uri'); + if (existing) { + existing.makeActive(); + return existing; + } + + // `uri` is already decoded, so decode nothing a second time — a basename + // containing a literal "%" would make decodeURIComponent throw URIError. + const name = uri.split('/').pop() || 'untitled.txt'; + const file = new EditorFile(name, { + uri, + render: true, + }); + + await file.load(); + return file; +} +``` + +Pass this a **decoded** URI. `event.value` arrives exactly as it sat in the deep link, so a link built the correct way — +`acode://myplugin/open/file%3A%2F%2F%2Fa%2Fb.txt` — needs `decodeURIComponent(event.value)` first. A raw +`acode://myplugin/open/file:///a/b.txt` cannot work at all: the parser stops at the third `/`, so `value` is just `"file:"`. + +`new EditorFile(filename, options)` accepts `uri`, `text`, `isUnsaved`, `mode`, `encoding`, `cursorPos`, `render`, `onsave`, `readOnly`, `paneId` and `persistInSession` in its options object. `acode.newEditorFile(filename, options)` is a convenience wrapper but **returns `undefined`**, so use `new EditorFile(...)` when you need the instance. + +::: tip `EditorFile` does not validate the URI +Unlike `lib/openFile.js`, constructing an `EditorFile` does not call `fs.stat()` first. A bad `uri` surfaces later, from `load()` (a rejected promise) or from `saveFile`. Null-check `uri` yourself, and use `acode.require("fs")` first if you need to confirm the path exists. +::: + +## Examples Basic intent handling: + ```js const intent = acode.require('intent'); const handler = (event) => { - const { module, action, value } = event; + const { module, action, value } = event; - // Optional: prevent default behavior - // event.preventDefault(); + // Optional: prevent default behavior + // event.preventDefault(); - // Optional: stop other handlers - // event.stopPropagation(); + // Optional: stop other handlers + // event.stopPropagation(); - console.log(`Intent received: ${module}/${action}/${value}`); + console.log(`Intent received: ${module}/${action}/${value}`); }; // Register handler -intent?.addHandler(handler); +intent.addHandler(handler); // Later: remove handler if needed -intent?.removeHandler(handler); +intent.removeHandler(handler); ``` -File opening handler: +Custom URI scheme handler: + ```js -const fileHandler = (event) => { - if (event.module === 'file' && event.action === 'open') { - // Open the file specified in event.value - editorManager.openFile(event.value); - event.preventDefault(); // Prevent default handling - } +const intent = acode.require('intent'); + +const pluginHandler = (event) => { + if (event.module !== 'myplugin') return; + + // Synchronous: `preventDefault()` after an `await` is ignored by Acode. + event.preventDefault(); + // Claim the intent so other plugins cannot also react to it. + event.stopPropagation(); + + // `value` is the RAW third path segment: it is NOT decoded, and anything + // after a further "/" was already dropped. The link must have been built as + // acode://myplugin/search/QUERY with encodeURIComponent(QUERY). The try/catch + // is needed because decodeURIComponent THROWS on a malformed escape. + let value = ''; + try { + value = decodeURIComponent(event.value || ''); + } catch { + return; // malformed percent-escape — nothing sensible to dispatch + } + + switch (event.action) { + case 'search': + // acode://myplugin/search/QUERY + performSearch(value); + break; + case 'create': + // acode://myplugin/create/NAME + createNewFile(value); + break; + } }; -intent?.addHandler(fileHandler); +intent.addHandler(pluginHandler); ``` -Custom URI scheme handler: +Intercepting Acode's own install deep link: + ```js -const pluginHandler = (event) => { - if (event.module === 'myplugin') { - switch(event.action) { - case 'search': - // Handle search URI: acode://myplugin/search/query - performSearch(event.value); - break; - case 'create': - // Handle file creation: acode://myplugin/create/filename - createNewFile(event.value); - break; - } - event.preventDefault(); - } -}; +const intent = acode.require('intent'); + +function onInstallIntent(event) { + if (event.module !== 'plugin' || event.action !== 'install') return; -intent?.addHandler(pluginHandler); + // Suppress Acode opening its plugin page, and run our own flow. + event.preventDefault(); + + const pluginId = event.value; + myPlugin.installWithOwnUi(pluginId); +} + +intent.addHandler(onInstallIntent); ``` -## Common Use Cases +## Complete runnable example -1. Opening specific files when launched from other apps: - - File managers can open files directly in Acode - - Handle shared files from other applications +A plugin that owns the `myplugin` module: handles three deep links, opens files safely, and unregisters cleanly on unload. Plugins are loaded as **classic scripts**, so there are no `import` / `export` statements. -2. Handling custom URI schemes for plugin functionality: - - Implement deep linking to specific plugin features - - Create custom commands accessible via URIs - - Enable cross-app integration workflows +```js +const PLUGIN_ID = 'com.example.plugin'; +const MODULE = 'myplugin'; + +const intent = acode.require('intent'); +const EditorFile = acode.require('EditorFile'); + +// Only these actions are meaningful in an acode://myplugin/... deep link. +const ALLOWED = new Set(['search', 'open', 'create']); + +// Acode hands you `value` exactly as it appeared in the URL, so decoding is +// your job — and decodeURIComponent THROWS on a malformed escape such as "%". +function decodeValue(value) { + try { + return decodeURIComponent(value || ''); + } catch (error) { + acode.toast('Malformed deep link'); + return null; + } +} + +async function openPath(uri) { + const existing = window.editorManager?.getFile(uri, 'uri'); + if (existing) { + existing.makeActive(); + return existing; + } + + // `uri` is already decoded, so split on the last "/" and decode nothing. + const name = uri.split('/').pop() || 'untitled.txt'; + const file = new EditorFile(name, { uri, render: true }); + await file.load(); + return file; +} + +// How to BUILD a link that will actually survive the parser: +// +// encodeURIComponent('file:///sdcard/Acode/notes.md') +// -> 'file%3A%2F%2F%2Fsdcard%2FAcode%2Fnotes.md' +// acode://myplugin/open/file%3A%2F%2F%2Fsdcard%2FAcode%2Fnotes.md +// +// Only then is `value` the full URI, and decodeURIComponent(value) rebuilds it. +function buildLink(action, rawValue) { + return `acode://${MODULE}/${action}/${encodeURIComponent(rawValue)}`; +} + +function handleIntent(event) { + // Ignore anything that is not ours — other plugins get their own chance. + if (event.module !== MODULE) return; + if (!ALLOWED.has(event.action)) return; + + // Both calls are synchronous. Do this BEFORE any await. + event.preventDefault(); + event.stopPropagation(); + + const value = decodeValue(event.value); + if (value === null) return; + + try { + switch (event.action) { + case 'search': + runSearch(value); + break; + + case 'open': + // acode://myplugin/open/ + // `value` is RAW, so decode it — and the link must have been built with + // the URI in ONE percent-encoded segment. A raw + // `acode://myplugin/open/file:///a/b.txt` arrives as just "file:". + openPath(value).catch((error) => acode.toast(error.message)); + break; + + case 'create': + createFile(value); + break; + } + } catch (error) { + // The dispatch loop calls `handler(event)` with no try/catch, so a throw + // here escapes into Acode and aborts the remaining handlers. + acode.toast(String(error && error.message)); + } +} + +function runSearch(query) { /* your search */ } +function createFile(name) { /* your file creation */ } + +// Register as early as possible: a cold-start deep link is dispatched before +// a deferred init would have run. +intent?.addHandler(handleIntent); + +// Clean up on plugin unload / disable / update. +acode.setPluginUnmount(PLUGIN_ID, () => { + intent?.removeHandler(handleIntent); +}); +``` + +## Common Use Cases -3. Intercepting intents to implement custom behaviors: - - Custom file type handlers - - Special handling for shared content - - Integration with external tools and services +1. **Deep-linking into your plugin**: + - Own a module namespace (`acode://myplugin/...`) so nothing collides + - Build every link as `` `acode://${module}/${action}/${encodeURIComponent(value)}` `` — the value must be **one** percent-encoded segment + - Decode with `decodeURIComponent` in the handler (guarded — it throws on a malformed escape) + +2. **Reacting to Acode's own deep links**: + - `acode://plugin/install/` — run your own install or upsell UI instead + - `acode://plugin/purchased/` and `acode://plugin/uninstall/` — refresh your own licence UI + +3. **Intercepting intents**: + - Call `preventDefault()` synchronously to suppress Acode's built-in behaviour + - Call `stopPropagation()` to stop other plugins from seeing the same link + +## Gotchas + +- **Only `acode://` URLs produce an `IntentEvent`.** Shared and opened files never do. +- **`preventDefault()` / `stopPropagation()` are ignored after an `await`.** The dispatch loop is synchronous. +- **Your handler's rejection is unhandled.** Acode does not `await` or `try/catch` it; wrap your own body. +- **`value` is capped at the third path segment and is not URL-decoded.** There is no raw multi-slash form: `acode://myplugin/open/file:///a/b.txt` arrives as `"file:"`. Encode the value with `encodeURIComponent` when you build the link. +- **`decodeURIComponent` throws `URIError`** on a malformed escape, and nothing in the intent pipeline catches it — wrap your own decode. +- **`removeHandler` removes only the first match** and compares by function identity — keep the reference. +- **Handlers registered after a cold-start deep link miss it.** Register at the top level of `main.js`. +- **`stopPropagation()` stops later handlers, not earlier ones.** An earlier handler may already have run. +- **`preventDefault()` suppresses all of Acode's built-in handling**, including `acode://plugin/install` and the `acode://pro` refresh — not just the part your plugin cares about. +- **`acode://auth/callback` never reaches you**, so it is safe to leave to the auth plugin. +- **There is no way to unregister all handlers.** `removeHandler` is the only removal API. + +## Related APIs + +- Shared / opened files: register a [File Handler](./file-handlers.md) — that is the hook for file intents, not this one +- Opening a path from a plugin: `EditorFile` + `load()` — see [Editor File](../editor-components/editor-file.md) \ No newline at end of file diff --git a/docs/advanced-apis/lsp.md b/docs/advanced-apis/lsp.md index 9f720aa..ef574fc 100644 --- a/docs/advanced-apis/lsp.md +++ b/docs/advanced-apis/lsp.md @@ -6,22 +6,45 @@ Use the LSP API to register language servers for Acode's CodeMirror LSP integrat const lsp = acode.require("lsp"); ``` +Verified against Acode **v1.13.5** (versionCode `1011`): `src/cm/lsp/api.ts`, `src/cm/lsp/providerUtils.ts`, `src/cm/lsp/serverRegistry.ts`, `src/cm/lsp/serverCatalog.ts`, `src/cm/lsp/runtimeProviders.ts`, `src/cm/lsp/types.ts`, `src/cm/lsp/workerTransport.ts`, plus `src/lib/acode.js` (lines 247–253), which is where the module is assembled. + ## API Overview -The public module exposes: - -- `defineServer(options)`: Creates a normalized server manifest for common local servers. -- `defineBundle(options)`: Groups multiple server manifests and optional install hooks. -- `register(entry, options?)`: Registers a server or bundle. -- `upsert(entry)`: Registers or replaces a server or bundle. -- `installers.*`: Helpers for structured install metadata. -- `servers.*`: Server registry inspection and updates. -- `bundles.*`: Bundle registry inspection. -- `runtimes.*`: Runtime provider registry helpers. -- `workers.createTransport(options)`: Web Worker LSP transport. -- `registerRuntimeProvider(provider)`: Alias for `runtimes.register(provider)`. -- `unregisterRuntimeProvider(id)`: Alias for `runtimes.unregister(id)`. -- `clientManager.*`: Limited client-manager access for advanced plugins. +`acode.require("lsp")` is **not** the raw `lspApi` default export. `src/lib/acode.js` builds it as a spread plus one extra namespace: + +```js +// src/lib/acode.js:247 +const lspModule = { + ...lspApi, + clientManager: { + setOptions: (options) => lspClientManager.setOptions(options), + getActiveClients: () => lspClientManager.getActiveClients(), + }, +}; +``` + +So the module has exactly these keys: + +| Key | Type | Signature | +| --- | --- | --- | +| `defineServer` | `function` | `defineServer(options: ManagedServerOptions): LspServerManifest` | +| `defineBundle` | `function` | `defineBundle(options: { id, label?, servers, hooks? }): LspServerBundle` | +| `register` | `function` | `register(entry, options?: { replace?: boolean }): LspServerDefinition \| LspServerBundle` | +| `upsert` | `function` | `upsert(entry): LspServerDefinition \| LspServerBundle` | +| `installers` | `object` | `apk` / `npm` / `pip` / `cargo` / `manual` / `shell` / `githubRelease` | +| `servers` | `object` | `get`, `list`, `listForLanguage`, `update`, `unregister`, `onChange` | +| `bundles` | `object` | `list`, `getForServer`, `unregister` | +| `runtimes` | `object` | `register`, `unregister`, `get`, `list`, `select` | +| `workers` | `object` | `createTransport(options): TransportHandle` | +| `registerRuntimeProvider` | `function` | Alias of `runtimes.register` | +| `unregisterRuntimeProvider` | `function` | Alias of `runtimes.unregister` | +| `clientManager` | `object` | `setOptions(options)`, `getActiveClients()` | + +::: warning `runtimes.select` is the only `async` member +`runtimes.select(server, context?)` returns a `Promise` (`selectRuntimeProvider` is `async`). Every other method on the module is synchronous — `register()`, `upsert()`, `servers.*`, `bundles.*`, `runtimes.register/unregister/get/list`, `workers.createTransport()` and both `clientManager` methods return their value directly, not a promise. +::: + +`clientManager` is intentionally tiny. Acode's own LSP plumbing (`diagnostics.ts`, `codeActions.ts`, `formatter.ts`, `definition.ts`, `rename.ts`, `references.ts`, `documentSymbols.ts`, `documentColors.ts`, `inlayHints.ts`, `logs.ts`) is exported from `src/cm/lsp/index.ts` for internal use only and is **not** reachable from `acode.require("lsp")`. There is no `lsp.formatDocument()`, no `lsp.getLspDiagnostics()` and no `lsp.addLspLog()` in the plugin surface. Most plugins should use `defineServer()` plus `upsert()`. Use a custom runtime when the server does not run through the built-in Alpine/AXS path (for example a plugin-bundled Web Worker). @@ -102,15 +125,21 @@ This is managed by Acode's built-in external WebSocket runtime. Install and upda Structured installers describe how Acode can install or update the executable for a local server. -Available helpers: +Available helpers (each returns a plain `LauncherInstallConfig` object with `kind` and a default `source`): -- `lsp.installers.apk(options)` -- `lsp.installers.npm(options)` -- `lsp.installers.pip(options)` -- `lsp.installers.cargo(options)` -- `lsp.installers.githubRelease(options)` -- `lsp.installers.manual(options)` -- `lsp.installers.shell(options)` +| Helper | Required options | Optional options | +| --- | --- | --- | +| `lsp.installers.apk(options)` | `packages`, `executable` | `label`, `source` (default `"apk"`) | +| `lsp.installers.npm(options)` | `packages`, `executable` | `label`, `source` (default `"npm"`), `global` | +| `lsp.installers.pip(options)` | `packages`, `executable` | `label`, `source` (default `"pip"`), `breakSystemPackages` | +| `lsp.installers.cargo(options)` | `packages`, `executable` | `label`, `source` (default `"cargo"`) | +| `lsp.installers.manual(options)` | `binaryPath` | `executable` (defaults to `binaryPath`), `label`, `source` (default `"manual"`) | +| `lsp.installers.shell(options)` | `command`, `executable` | `updateCommand`, `uninstallCommand`, `label`, `source` (default `"custom"`) | +| `lsp.installers.githubRelease(options)` | `repo`, `binaryPath`, `assetNames` | `executable` (defaults to `binaryPath`), `extractFile`, `archiveType` (`"zip"` \| `"binary"`), `label`, `source` (default `"github-release"`) | + +::: warning Every non-`shell` installer must name its executable +`sanitizeDefinition()` throws `LSP server managed installers must declare install.binaryPath or install.executable` for any `install.kind` other than `shell` when neither field is present. Passing `executable: ""` or `undefined` fails the same check — the value is `.trim()`-tested. +::: Example: @@ -133,10 +162,11 @@ lsp.upsert(pythonServer); Notes: -- Managed installers should declare the executable they provide. -- `githubRelease()` is intended for architecture-specific downloaded binaries. +- Managed installers should declare the executable they provide — it is mandatory, not advisory. +- `githubRelease()` is intended for architecture-specific downloaded binaries. `archiveType` is coerced: anything other than `"binary"` becomes `"zip"`. - `manual()` is useful when the binary already exists at a known path. -- `shell()` is the advanced fallback when no structured installer fits. +- `shell()` is the advanced fallback when no structured installer fits. It is the one kind exempt from the registry's "must declare `binaryPath` or `executable`" check, although `executable` is still a required constructor argument. +- `installers.*` helpers only build data. Acode's own installer runner lives in `src/cm/lsp/installerUtils.ts`, `installRuntime.ts` and `runtimeActions.ts` and is not exposed to plugins. ## Bundles @@ -211,11 +241,21 @@ const bundle = lsp.defineBundle({ lsp.upsert(bundle); ``` -Supported hooks: +Supported hooks (this is the complete `BundleHooks` interface, spread onto the bundle by `defineBundle()`): + +| Hook | Signature | +| --- | --- | +| `getExecutable` | `(serverId: string, manifest: LspServerManifest) => string \| null \| undefined` | +| `checkInstallation` | `(serverId, manifest) => Promise` | +| `installServer` | `(serverId, manifest, mode: "install" \| "update" \| "reinstall", options?: { promptConfirm?: boolean }) => Promise` | + +`InstallCheckResult` is `{ status: "present" \| "missing" \| "failed" \| "unknown", version?: string \| null, canInstall: boolean, canUpdate: boolean, message?: string }`. + +::: tip `uninstallServer` is a fourth hook on the type, but not on `defineBundle()` +`LspServerBundle` in `types.ts` declares `uninstallServer(serverId, manifest, options?)`, yet `BundleHooks` (the type of `defineBundle`'s `hooks` argument) omits it. Because `defineBundle()` does `return { id, label, getServers: () => servers, ...hooks }`, an `uninstallServer` key you pass through is still copied onto the bundle and is still found by the type — TypeScript just will not autocomplete it. +::: -- `getExecutable(serverId, manifest)` -- `checkInstallation(serverId, manifest)` -- `installServer(serverId, manifest, mode, options?)` +`lsp.bundles.getForServer(serverId)` resolves through this bundle's `getServers()`, so a bundle is also the unit of ownership: registering a second bundle that contains a server the first bundle owns requires `replace: true`. ## URI Translation @@ -276,12 +316,29 @@ A runtime provider decides where and how a server runs. Built-in Acode servers n Runtime providers are advanced API. If your plugin only registers a normal local server, use `defineServer()`. -Runtime selection has two gates: +Runtime selection has **three** gates, in this order (`src/cm/lsp/runtimeProviders.ts`, `selectRuntimeProvider`): + +1. **User setting override.** `settings.lsp.runtime.default`, `settings.lsp.runtime.servers[serverId]` and `settings.lsp.runtime.workspaces[pathPrefix]` are consulted first (longest matching workspace prefix wins). A configured id that is not `auto` is tried on its own; if its `canHandle()` returns falsy the app logs a warning and **falls through** to the automatic scan. +2. **`server.runtimes`,** when present, limits which provider ids are allowed for that server. Provider ids are compared lowercased. +3. **`provider.canHandle(server, context)`,** in descending `priority` order (ties broken by `id.localeCompare`). The first provider that returns a truthy value wins; a provider that throws is skipped with a console warning. + +Acode derives `context.workspaceKind` from the current URI/root via `inferWorkspaceKind()`. The declared `WorkspaceKind` type is `"app-private"`, `"builtin-alpine"`, `"termux-saf"`, `"saf"`, `"remote"`, `"proot-distro"`, `"virtual"` or `"unknown"` — but note that `inferWorkspaceKind()` in v1.13.5 can only ever produce `unknown`, `app-private`, `virtual`, `remote`, `builtin-alpine`, `termux-saf` and `saf`. `proot-distro` exists in the type only. + +### Built-in providers vs. bundled providers + +Acode registers exactly three runtime providers at module load, and it does so with `replace: true` (`src/cm/lsp/runtimes/registerBuiltins.ts`): -- `server.runtimes`, when present, limits which provider ids are allowed for that server. -- `provider.canHandle(server, context)` decides whether that provider can handle the current file, root, workspace kind, and server. +| Id | `label` | `priority` | What it actually does | +| --- | --- | --- | --- | +| `web-worker` | `Built-in Web Worker` | `100` | Serves the four bundled servers (`html`, `css`, `json`, `typescript`) from `build/*LspWorker.js`. `canHandle()` returns true only for those exact ids. `checkInstallation()` reports `{ status: "present", version: "bundled", canInstall: false, canUpdate: false }`. | +| `external-websocket` | `External WebSocket` | `-50` | Connects to `server.transport.url` when the transport kind is `websocket` **and** a `url` is present. `checkInstallation()` reports `{ status: "unknown", canInstall: false, canUpdate: false }`. | +| `builtin-alpine` | `Built-in Alpine` | `-100` | Starts the server through Acode's own terminal/AXS bridge (`ensureServerRunning`) and hands back a `TransportHandle`. `checkInstallation()` delegates to `checkServerInstallation(server)`, and `install()` refuses with a "Terminal not installed" prompt when the Terminal plugin is missing. | -Acode derives `context.workspaceKind` from the current URI/root. These values come from Acode's `WorkspaceKind` type: `"app-private"`, `"builtin-alpine"`, `"termux-saf"`, `"saf"`, `"remote"`, `"proot-distro"`, `"virtual"`, and `"unknown"`. +::: warning `registerRuntimeProvider` throws on a duplicate id +`runtimes.register(provider)` calls `registerRuntimeProvider(provider)` with **no** options object, so the internal `{ replace: true }` escape hatch is not reachable through `acode.require("lsp")`. Registering an id twice throws `LSP runtime provider is already registered`. Always `unregisterRuntimeProvider(id)` first when reloading during development. +::: + +The bundled web-worker servers are declared in `src/cm/lsp/servers/`. Their ids are `html`, `css`, `json`, `typescript`, `html-stdio`, `css-stdio`, `json-stdio`, `typescript-native`, `vtsls`, `eslint`, `ty`, `python`, `clangd`, `gopls`, `rust-analyzer`, `tailwindcss` and `luau`, grouped into the bundles `builtin-javascript`, `builtin-python`, `builtin-luau`, `builtin-web`, `builtin-systems` and `builtin-tailwindcss`. Register your own ids instead of replacing these. ### Register a Runtime Provider @@ -342,16 +399,16 @@ lsp.registerRuntimeProvider({ }); ``` -Required provider fields: +Required provider fields (each one is validated on registration): -- `id` -- `label` -- `canHandle(server, context)` -- `start(server, context)` +- `id` — trimmed and lowercased; a missing/empty id throws `LSP runtime provider requires a non-empty id`. +- `label` — must be a non-empty string, else `LSP runtime provider requires a label`. +- `canHandle(server, context)` — must be a function, may return a boolean **or** a promise. +- `start(server, context)` — must be a function, must return a `Promise`. Optional provider fields: -- `priority` +- `priority` — non-numeric values are coerced to `0`. - `resolveUris(server, context)` - `checkInstallation(server, context)` - `install(server, context, mode, options?)` @@ -360,6 +417,8 @@ Optional provider fields: - `getUninstallCommand(server, context)` - `stop(connection)` +Any of these being present but **not** a function also throws (`LSP runtime provider has invalid ()`), so do not set them to `null` or a non-function truthy value. + Higher `priority` values are tried first. Provider ids are normalized to lowercase. ### Register a Server for a Runtime @@ -387,9 +446,19 @@ lsp.upsert( If `runtimes` is omitted, any registered provider whose `canHandle()` returns `true` may be selected. Use `runtimes` when your plugin owns both the runtime and the server definition. -## Worker Transport +## Worker Transport -Creates a CodeMirror-compatible LSP transport backed by a Web Worker. Available from Acode **versionCode `1002`**. Set `"minVersionCode": 1002` in `plugin.json` when your plugin depends on it. +Creates a CodeMirror-compatible LSP transport backed by a Web Worker. + +::: warning There is no `versionCode` for this API +`CHANGELOG.md` records no `versionCode` for `lsp.workers.createTransport()` — the headings stop annotating build numbers after `v1.12.0 (970)`, and `config.xml` only declares the current build (`android-versionCode="1011"` for v1.13.5). Do **not** hardcode `"minVersionCode": 1002`; `CHANGELOG.md` lists *"feat(lsp): add web worker language servers as defaults"* under **v1.12.7**, but that is a PR, not a build number. Feature-detect instead: + +```js +if (lsp?.workers?.createTransport) { + // safe to use +} +``` +::: ```js const handle = lsp.workers.createTransport({ @@ -417,18 +486,20 @@ const handle = lsp.workers.createTransport({ | Option | Type | Description | | --- | --- | --- | -| `url` | `string` | Required. Absolute or app-relative URL of the worker script. | -| `name` | `string` | Optional worker name and default log identity. | -| `serverId` | `string` | Optional id for LSP logs and errors. Defaults to `name`. | -| `startupTimeout` | `number` | Milliseconds to wait for `{ kind: "ready" }`. Default `10000`. | -| `configure` | `object` | Optional message posted right after the worker starts. | -| `hostHandlers` | `Record unknown \| Promise>` | Handlers for worker host requests, keyed by method name. | +| `url` | `string` | Required. Absolute or app-relative URL of the worker script. Throws `createWorkerTransport requires a worker url` when missing. | +| `name` | `string` | Optional worker name (`WorkerOptions.name`) and default log identity. Defaults to `"acode-lsp-worker"`. | +| `serverId` | `string` | Optional id for LSP logs and error messages. Defaults to `name`. | +| `startupTimeout` | `number` | Milliseconds to wait for `{ kind: "ready" }`. Default `10000`; values that are not `> 0` fall back to the default. | +| `configure` | `object` | Optional message posted right after the worker is created (before `ready` is awaited). | +| `hostHandlers` | `Record unknown \| Promise>` | Handlers for worker host requests, keyed by method name. Defaults to `{}`. | + +The function throws `Web Workers are not available in this environment` when `typeof Worker === "undefined"`. Returns a `TransportHandle`: -- `transport`: `{ send, subscribe, unsubscribe }` +- `transport`: `{ send(message: string): void, subscribe(handler), unsubscribe(handler) }`. `send()` throws `The worker is closed` after disposal, and `subscribe()` silently no-ops after disposal. - `ready`: `Promise` -- `dispose()`: cleanup and `worker.terminate()` +- `dispose()`: cleanup and `worker.terminate()` — idempotent, returns `undefined` (not a promise). ### Worker protocol @@ -492,7 +563,7 @@ JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }) { kind: "host-response", id: 1, error: "message" } ``` -Nested `params` and flat fields are both normalized into the object passed to `hostHandlers[method]`. Thrown handler errors become `error` on the response. +Nested `params` and flat fields are both normalized into the object passed to `hostHandlers[method]` — every key except `kind`, `id`, `method` and `params` is copied. Thrown handler errors become `error` on the response, and a `host-request` whose `method` has no entry in `hostHandlers` is answered with `error: "Unsupported worker host method: "` rather than being ignored. Responses are never posted after disposal. **Optional logs** (worker → main): @@ -507,6 +578,10 @@ Nested `params` and flat fields are both normalized into the object passed to `h const lsp = acode.require("lsp"); const RUNTIME_ID = "my-css-web-worker"; const SERVER_ID = "my-css-worker"; +const PLUGIN_ID = "com.example.plugin"; + +// Your plugin's base URL, e.g. "https://localhost/plugins/com.example.plugin/" +const baseUrl = "https://localhost/plugins/com.example.plugin/"; function createRuntime(workerBaseUrl) { const workerUrl = new URL("language.worker.js", workerBaseUrl).href; @@ -567,6 +642,7 @@ function createRuntime(workerBaseUrl) { }; } +lsp.unregisterRuntimeProvider(RUNTIME_ID); lsp.runtimes.register(createRuntime(baseUrl)); lsp.upsert( lsp.defineServer({ @@ -582,12 +658,18 @@ lsp.upsert( ); // On unmount -lsp.servers.unregister(SERVER_ID); -lsp.runtimes.unregister(RUNTIME_ID); +acode.setPluginUnmount(PLUGIN_ID, () => { + lsp.servers.unregister(SERVER_ID); + lsp.runtimes.unregister(RUNTIME_ID); +}); ``` Use `transport: { kind: "external" }` for worker servers — the runtime returns the real transport handle. Register your own server id; do not replace built-in ids like `html`, `css`, `json`, or `typescript`. +::: warning `runtimes.register()` throws if the id is taken +Registering the provider on every plugin load without unregistering first throws `LSP runtime provider is already registered`. Always `unregisterRuntimeProvider(RUNTIME_ID)` before `registerRuntimeProvider(...)`, as above. +::: + ### Runtime URI Resolution A runtime provider can translate both document and root URIs after it has been selected. @@ -630,15 +712,7 @@ lsp.registerRuntimeProvider({ }); ``` -`resolveUris()` receives: - -- `originalDocumentUri` -- `originalRootUri` -- `normalizedDocumentUri` -- `normalizedRootUri` -- all `LspRuntimeContext` fields, including `file`, `view`, `languageId`, `documentUri`, `rootUri`, `serverId`, and `workspaceKind` - -It may return `scope: "workspace"` or `scope: "document"`. Document scope starts a separate client for each document. +`resolveUris()` receives `LspRuntimeUriResolutionContext` — `originalDocumentUri`, `originalRootUri`, `normalizedDocumentUri`, `normalizedRootUri` plus every `LspRuntimeContext` field (`file`, `view`, `languageId`, `documentUri`, `rootUri`, `serverId`, `workspaceKind`, …). It may return `{ documentUri?, rootUri?, scope? }` with `scope: "workspace"` (the default) or `"document"`; document scope starts a separate client for each document. It runs **after** this provider has been selected, so one runtime cannot rewrite another runtime's documents. See [`LspRuntimeUriResolutionContext`](#lspruntimeuriresolutioncontext). ## Definition Reference @@ -646,30 +720,38 @@ It may return `scope: "workspace"` or `scope: "document"`. Document scope starts Convenience helper for local bridge-backed servers (and worker-backed servers when combined with a custom runtime). -Supported fields: - -- `id`: Required server id. -- `label`: Required display label. -- `languages`: Required non-empty language id array. -- `enabled`: Defaults to `true`. -- `useWorkspaceFolders`: Use one client per server and workspace folders. Useful for TypeScript and Rust. -- `runtimes`: Optional preferred runtime provider ids for this server (for example `["web-worker"]` or a plugin runtime id). -- `command`: Executable used for the AXS bridge. -- `args`: Arguments passed to `command`. -- `transport`: Optional partial transport descriptor. Defaults to `{ kind: "websocket" }`. -- `bridge`: Optional AXS bridge details such as `port` or `session`. -- `installer`: Structured installer config from `lsp.installers.*`. -- `checkCommand` -- `versionCommand` -- `updateCommand` -- `uninstallCommand` -- `startupTimeout` -- `initializationOptions` -- `clientConfig` -- `resolveLanguageId` -- `rootUri` -- `documentUri` -- `capabilityOverrides` +Supported fields (this is the complete `ManagedServerOptions` interface): + +| Field | Type | Default | Notes | +| --- | --- | --- | --- | +| `id` | `string` | — | Required. Normalized to trimmed lowercase. | +| `label` | `string` | — | Display label; falls back to `id` after registration. | +| `languages` | `string[]` | — | Required, must be non-empty after normalization. | +| `enabled` | `boolean` | `true` | Only `enabled === false` disables. | +| `useWorkspaceFolders` | `boolean` | `false` | One client per server, folders added over time. Acode's own comment: *"Heavy LSP servers like TypeScript and rust-analyzer should use this."* | +| `runtimes` | `string[]` | unset | Restricts which provider ids may run this server. Lowercased. | +| `command` | `string` | unset | Turns into `launcher.bridge.command`. | +| `args` | `string[]` | unset | Turns into `launcher.bridge.args`. | +| `transport` | `Partial` | `{ kind: "websocket" }` | Spread **after** the default, so `kind` is overridable. | +| `bridge` | `Partial \| null` | unset | Merged with `command` / `args`; `port` and `session` pass straight through. `kind` must be `"axs"` — anything else throws. | +| `installer` | `LauncherInstallConfig` | unset | Becomes `launcher.install`. | +| `checkCommand` | `string` | unset | `launcher.checkCommand`. | +| `versionCommand` | `string` | unset | `launcher.versionCommand`. | +| `updateCommand` | `string` | unset | `launcher.updateCommand`. | +| `uninstallCommand` | `string` | unset | `launcher.uninstallCommand`. | +| `logOutput` | `"all" \| "warnings-and-errors"` | `"all"` | Anything other than `"warnings-and-errors"` becomes `"all"`. | +| `startupTimeout` | `number` | unset | Copied to `clientConfig.timeout` as the LSP connect timeout. | +| `initializationOptions` | `object` | unset | Deep-cloned via `JSON.parse(JSON.stringify(...))`; functions do not survive. Merged with `clientConfig.initializationOptions` (client wins). | +| `workspaceConfiguration` | `object` | unset | Same JSON clone. | +| `clientConfig` | `object` | unset | See [Formatters and Diagnostics](#formatters-and-diagnostics). | +| `resolveLanguageId` | `fn` | unset | `(context: { languageId, languageName?, uri?, file? }) => string \| null`. | +| `rootUri` | `fn` | unset | `(uri, context: RootUriContext) => string \| null \| Promise<…>`. | +| `documentUri` | `fn` | unset | `(uri, context: DocumentUriContext) => string \| null \| undefined \| Promise<…>`. | +| `capabilityOverrides` | `object` | unset | Stored but **never read** in v1.13.5 — see [Gotchas](#gotchas). | + +::: tip `defineServer()` is not a validator +It never throws and never fills in an id. Everything is validated later by `sanitizeDefinition()` when the manifest reaches `lsp.register()` / `lsp.upsert()`. Note it always emits a `launcher` object, even an empty one, and always emits a `transport` object. +::: ### Raw Server Manifest @@ -680,12 +762,14 @@ Common fields: - `id` - `label` - `enabled` +- `priority` — number, default `0`. Higher wins for single-provider features such as formatting. - `languages` - `transport` -- `launcher` +- `launcher` — `{ command, args, startCommand, checkCommand, versionCommand, updateCommand, uninstallCommand, logOutput, install, bridge }` - `runtimes` - `useWorkspaceFolders` - `initializationOptions` +- `workspaceConfiguration` - `clientConfig` - `startupTimeout` - `capabilityOverrides` @@ -693,7 +777,18 @@ Common fields: - `documentUri` - `resolveLanguageId` -For `transport.kind: "websocket"`, provide either `transport.url` or `launcher.bridge.command`. For `transport.kind: "stdio"`, provide `transport.command`. +For `transport.kind: "websocket"`, provide either `transport.url` or `launcher.bridge.command`. For `transport.kind: "stdio"`, provide `transport.command`. A raw manifest **defaults `transport.kind` to `"stdio"`**, which is why a raw manifest without `transport.command` is rejected. + +::: warning Two `transport` fields are silently dropped from every raw manifest +`sanitizeDefinition()` rebuilds the transport descriptor from a fixed key list and never copies `transport.protocols` or `transport.create`. Consequences: + +- `external-websocket` reads `server.transport.protocols` when building its connection, so it is always `undefined` after registration. +- `transport.create(server, context)` never survives, so `transport: { kind: "external" }` on a **raw** manifest always throws `LSP server declares an external transport without a create() factory` at connect time. `kind: "external"` only works when a **runtime provider** supplies the transport (return `{ kind: "transport", providerId, transport }` from `start()`), which is how the bundled web-worker servers do it. +::: + +::: tip Raw `"stdio"` is not an editor-to-process pipe +`createStdioTransport()` throws `STDIO transport for is missing a websocket bridge url` unless `transport.url` is set or `context.dynamicPort` was discovered by the launcher, and then it just delegates to `createWebSocketTransport()`. It also logs an info message when `transport.options.binary` is not set, because it falls back to text frames. +::: Raw local bridge example: @@ -725,17 +820,51 @@ lsp.upsert({ ## Registration -### `lsp.register(entry, options?)` +### `lsp.register(entry, options?)` + +Registers a server or bundle and returns the **normalized** definition (`LspServerDefinition` for a server, `LspServerBundle` for a bundle). `options.replace` defaults to `false`. + +::: warning It does **not** throw on a duplicate id +`registerServer()` does this when the id already exists: + +```ts +const exists = registry.has(normalized.id); +if (exists && !replace) { + const existing = registry.get(normalized.id); + if (existing) return existing; // ← returns the OLD definition +} +``` + +So a second `lsp.register(server)` for the same id is a **silent no-op that returns the previously registered definition** — your new fields are discarded with no warning. Bundles behave the same way for their own id. This is exactly why `upsert()` exists. +::: + +There is one case where registration *does* throw, and it is not about the entry's own id: when a **bundle** claims a server id that a *different* bundle already owns. -Registers a server or bundle. It throws if the id already exists unless `options.replace` is `true`. +```js +// LSP server eslint is already provided by builtin-javascript; +// my-web must replace explicitly +lsp.register(bundle); // throws +``` + +| Throw | Message | +| --- | --- | +| Bundle claims a server owned by another bundle | `LSP server is already provided by ; must replace explicitly` | +| Bundle entry without an id | `LSP server bundle returned a server without id` | +| Bundle without an id | `LSP server bundle requires a non-empty id` | +| Validation (see [Gotchas](#gotchas)) | `LSP server definition requires a non-empty id`, `… must declare supported languages`, `… (stdio) requires a command`, `… (websocket) requires a url or a launcher bridge`, `… managed installers must declare install.binaryPath or install.executable` | ```js +// Force replacement lsp.register(server, { replace: true }); ``` +::: tip A bundle's own servers are always registered with `replace: true` +Inside `registerServerBundle()` each member manifest is passed to `registry.registerServer(definition, { replace: true })`, so re-registering a bundle refreshes its servers even though the bundle's `replace` option defaults to `false`. +::: + ### `lsp.upsert(entry)` -Registers or replaces a server or bundle. +Registers or replaces a server or bundle — literally `register(entry, { replace: true })`. ```js lsp.upsert(server); @@ -759,16 +888,14 @@ const unsubscribe = lsp.servers.onChange((event, changedServer) => { }); ``` -Available methods: - -- `lsp.servers.get(id)` -- `lsp.servers.list()` -- `lsp.servers.listForLanguage(languageId, options?)` -- `lsp.servers.update(id, updater)` -- `lsp.servers.unregister(id)` -- `lsp.servers.onChange(listener)` - -`listForLanguage()` accepts `{ includeDisabled?: boolean }`. +| Method | Signature | Returns | +| --- | --- | --- | +| `lsp.servers.get(id)` | `(id: string) => LspServerDefinition \| null` | `null` when unknown. The id is trimmed + lowercased. | +| `lsp.servers.list()` | `() => LspServerDefinition[]` | Every registered server, including disabled ones. | +| `lsp.servers.listForLanguage(languageId, options?)` | `(languageId: string, options?: { includeDisabled?: boolean }) => LspServerDefinition[]` | Enabled servers whose `languages` contain the (lowercased) id, **sorted by `priority` descending**. Pass `{ includeDisabled: true }` to include disabled servers. | +| `lsp.servers.update(id, updater)` | `(id: string, updater: (current) => Partial \| null) => LspServerDefinition \| null` | `null` when the id is unknown. `updater` receives a **shallow copy**; returning `null` aborts the update and the current definition is returned unchanged. Otherwise the merged result is re-validated and re-normalized. | +| `lsp.servers.unregister(id)` | `(id: string) => boolean` | `true` when something was removed. | +| `lsp.servers.onChange(listener)` | `(listener: (event: "register" \| "unregister" \| "update", server: LspServerDefinition) => void) => () => void` | Unsubscribe function. Listener exceptions are caught and logged, so one bad listener cannot break the others. A non-function listener yields a no-op unsubscribe. | ### Bundles @@ -778,11 +905,11 @@ const bundle = lsp.bundles.getForServer("my-html"); lsp.bundles.unregister("my-web-tools"); ``` -Available methods: - -- `lsp.bundles.list()` -- `lsp.bundles.getForServer(serverId)` -- `lsp.bundles.unregister(id)` +| Method | Signature | Returns | +| --- | --- | --- | +| `lsp.bundles.list()` | `() => LspServerBundle[]` | Every registered bundle, including the six built-ins. | +| `lsp.bundles.getForServer(id)` | `(id: string) => LspServerBundle \| null` | The bundle that **owns the server** with that id, or `null` for a standalone server. | +| `lsp.bundles.unregister(id)` | `(id: string) => boolean` | `true` when removed. This also unregisters every server the bundle still owned. | ### Runtimes @@ -792,15 +919,17 @@ const runtime = lsp.runtimes.get("builtin-alpine"); lsp.runtimes.unregister("my-runtime"); ``` -Available methods: +| Method | Signature | Returns | +| --- | --- | --- | +| `lsp.runtimes.register(provider)` | `(provider: LspRuntimeProvider) => LspRuntimeProvider` | The normalized provider (id lowercased). **Throws** if the id is already registered. | +| `lsp.runtimes.unregister(id)` | `(id: string) => boolean` | `true` when removed. | +| `lsp.runtimes.get(id)` | `(id: string) => LspRuntimeProvider \| null` | `null` when unknown. | +| `lsp.runtimes.list()` | `() => LspRuntimeProvider[]` | All providers sorted by `priority` descending, ties broken by `id.localeCompare`. | +| `lsp.runtimes.select(server, context?)` | `(server: LspServerDefinition, context?: LspRuntimeContext) => Promise` | The winning provider, or `null`. Honors the settings override and `server.runtimes`. | -- `lsp.runtimes.register(provider)` -- `lsp.runtimes.unregister(id)` -- `lsp.runtimes.get(id)` -- `lsp.runtimes.list()` -- `lsp.runtimes.select(server, context?)` +`registerRuntimeProvider` and `unregisterRuntimeProvider` are the same functions under different names. -### Workers +### Workers ```js const handle = lsp.workers.createTransport({ @@ -830,12 +959,235 @@ Available methods: - `lsp.workers.createTransport(options)` +## Formatters and Diagnostics + +There is no formatter or diagnostics function on `acode.require("lsp")`. Both are wired through the server manifest's `clientConfig` instead, and Acode resolves them for you. + +### Formatting + +Acode registers **one** formatter named `lsp` (display name `Language Server`) at startup, via `registerLspFormatter(acode)` which `src/lib/acode.js` calls once while defining modules. When the user runs "Format": + +1. `supportsBuiltinFormatting(server)` filters candidates — it is `server.clientConfig?.builtinExtensions?.formatting !== false`, so a server opts out only with `builtinExtensions: { formatting: false }`. +2. Candidates come from `getServersForLanguage(languageId)` (highest `priority` first) and are filtered by the same check. +3. `lspClientManager.formatDocument(fullMetadata)` then walks the same candidates in `priority` order and uses the first one that resolves a runtime target **and** reports `serverCapabilities.documentFormattingProvider`. If a server returns no edits at all, formatting is treated as a success. Failures surface as the toasts `LSP formatter unavailable`, `Unknown language for LSP formatting`, `No LSP formatter available` or `LSP formatter failed`. + +There is no public "format now" call. The supported way to control formatting from a plugin is to opt in/out per server: + +```js +lsp.upsert( + lsp.defineServer({ + id: "my-language-server", + label: "My Language Server", + languages: ["mylang"], + command: "my-language-server", + args: ["--stdio"], + // Opt in to Acode's built-in hover / completion / signature / keymaps / + // diagnostics / formatting / document-color features for this server. + clientConfig: { + builtinExtensions: { + hover: true, + completion: true, + signature: true, + keymaps: true, + diagnostics: true, + formatting: true, + documentColors: true, + inlayHints: false, + }, + }, + }), +); +``` + +### Diagnostics + +`builtinExtensions.diagnostics` (default `true`) adds the `textDocument/publishDiagnostics` handler, the `workspace/diagnostic/refresh` handler and the CodeMirror lint integration. Setting it to `false` disables them for that server only. + +The UI half — the lint gutter, the `linter()` extension and the panel wiring — is **global**, not per server. It comes from `clientManager.options.diagnosticsUiExtension`, which Acode sets from the `lintGutter` setting: + +```js +appSettings.on("update:lintGutter", function (value) { + lspClientManager.setOptions({ + diagnosticsUiExtension: lspDiagnosticsUiExtension(value !== false), + }); + // ... +}); +``` + +To observe diagnostics from a plugin, register your own handler for the same method. Because the client merges `clientConfig.extensions` **after** the built-ins and drops `diagnosticsExtension` when one of your extensions declares `clientCapabilities.textDocument.publishDiagnostics`, doing this **replaces** Acode's handler for that server: + +```js +lsp.upsert( + lsp.defineServer({ + id: "my-language-server", + label: "My Language Server", + languages: ["mylang"], + command: "my-language-server", + args: ["--stdio"], + clientConfig: { + // Keep Acode's diagnostics UI. If you declare + // `clientCapabilities.textDocument.publishDiagnostics` on your own + // extension instead, Acode drops its diagnosticsExtension for this + // server and your handler becomes the only one. + extensions: [ + { + notificationHandlers: { + "textDocument/publishDiagnostics": (client, params) => { + // params: { uri, version?, diagnostics: [{ range, severity, message }] } + const { uri, diagnostics } = params; + const errors = diagnostics.filter((d) => d.severity === 1).length; + console.log(`[mylang] ${errors} error(s) in ${uri}`); + return true; // returning true marks the notification handled + }, + }, + }, + ], + }, + }), +); +``` + +::: warning Handlers are `(client, params) => boolean` and not awaited +The contract is LSP-shaped: return `true` when you consumed the notification. Returning `false` leaves it available to other handlers in the chain, which is how you can observe without claiming. +::: + +::: warning `clientConfig.notificationHandlers` for `window/*` and `$/progress` is not yours +Acode builds the final handler map by spreading your handlers first and then assigning its own for `"window/logMessage"`, `"window/showMessage"` and `"$/progress"`, so those three always win. Every other method name is preserved. +::: + +### Complete example: server + formatter + diagnostics + +```js +// main.js — Acode loads plugins as *classic* scripts, so no top-level await +// and no import/export statements. See core-file.md. +const PLUGIN_ID = "com.example.plugin"; +const lsp = acode.require("lsp"); +const SERVER_ID = "my-plugin-toml"; + +const server = lsp.defineServer({ + id: SERVER_ID, + label: "TOML (plugin)", + languages: ["toml"], + enabled: true, + // One client shared across the whole workspace rather than one per root. + useWorkspaceFolders: true, + command: "taplo", + args: ["lsp"], + checkCommand: "command -v taplo", + startupTimeout: 15_000, + installer: lsp.installers.cargo({ + executable: "taplo", + packages: ["taplo-cli"], + }), + initializationOptions: { + formatting: { enabled: true }, + }, + clientConfig: { + // Formatting is opt-out: "formatting !== false" enables it. + builtinExtensions: { + hover: true, + completion: true, + signature: true, + diagnostics: true, + formatting: true, + documentColors: false, + }, + notificationHandlers: { + // Acode's own window/logMessage, window/showMessage and $/progress + // handlers always win; any other method name is preserved. + "my-plugin/telemetry": (client, params) => { + console.log("taplo telemetry", params); + return true; + }, + }, + }, +}); + +// upsert() == register(entry, { replace: true }), so re-registering on a +// plugin reload replaces rather than silently returning the old definition. +lsp.upsert(server); + +// Read-only inspection. +(async () => { + const registered = lsp.servers.get(SERVER_ID); + console.log(registered.id, registered.transport.kind, registered.languages); + + // Which provider would run it for a given document? + const runtime = await lsp.runtimes.select(registered, { + uri: "file:///project/Cargo.toml", + }); + console.log("runtime:", runtime ? runtime.id : "none"); + + // Which live clients does the editor hold for it right now? + for (const state of lsp.clientManager.getActiveClients()) { + if (state.server.id !== SERVER_ID) continue; + console.log("root:", state.rootUri); + } +})(); + +// Cleanup on plugin unload. +acode.setPluginUnmount(PLUGIN_ID, () => { + lsp.servers.unregister(SERVER_ID); +}); +``` + ## Client Manager -The public client manager API is intentionally small. +The public client manager API is intentionally small — two methods. + +### `clientManager.setOptions(options)` + +```ts +setOptions(next: Partial): void +``` + +A **shallow merge** into the live options object of the editor's single `LspClientManager` singleton: + +```ts +this.options = { ...this.options, ...next }; +``` + +| Option | Type | Default | Notes | +| --- | --- | --- | --- | +| `diagnosticsUiExtension` | `Extension \| Extension[]` | unset | Appended to *every* server's merged extension list. Acode sets it from the `lintGutter` setting. | +| `clientExtensions` | `Extension \| Extension[]` | unset | Appended to every server's client, after the built-ins. | +| `resolveRoot` | `(context: RootUriContext) => Promise` | unset | Acode uses it to compute the workspace root. | +| `displayFile` | `(uri: string) => Promise` | unset | Focus an already-open editor for a URI. | +| `openFile` | `(uri: string) => Promise` | unset | Open a URI and return its editor view. | +| `resolveLanguageId` | `(uri: string) => string \| null` | unset | Per-URI language resolution for the workspace file list. | +| `clientIdleGracePeriodMs` | `number` | `DEFAULT_CLIENT_IDLE_GRACE_PERIOD_MS` = `15000` | Delay before an unreferenced client is reported idle. | +| `onClientIdle` | `(info: ClientIdleInfo) => void` | unset | `info` is `{ server, client, rootUri, dispose }`; `dispose()` tears down **only that** idle client. | +| `allowNonTerminalWorkspace` | `boolean` | `false` | Lets `builtin-alpine` serve a workspace it cannot reach natively, by falling back to the cache file. | + +::: danger Do not call this to "reset" options +`setOptions` merges, it never resets, and the editor itself keeps writing to the same object. Setting `diagnosticsUiExtension: []` — as older versions of this page suggested — does not disable anything for your plugin: it **overwrites the editor's lint gutter and diagnostics panel** for every server until the user toggles the `lintGutter` setting again. If you want your own CodeMirror extensions applied to every LSP client, use `clientExtensions` and do not touch `diagnosticsUiExtension`. + +There is also no read-back accessor: `setOptions` returns `undefined` and the manager exposes no `getOptions()`, so you cannot restore a previous value yourself. +::: + +### `clientManager.getActiveClients()` + +```ts +getActiveClients(): ClientState[] +``` + +Returns `Array.from(this.#clients.values())` — a snapshot array of every **currently initialized** client. It excludes clients still initializing (`#pendingClients` is a separate map) and is not live; call it again to refresh. + +Each `ClientState` is: + +| Field | Type | Description | +| --- | --- | --- | +| `server` | `LspServerDefinition` | The normalized server definition this client belongs to. | +| `client` | `LSPClient` | The `@codemirror/lsp-client` instance. | +| `transport` | `TransportHandle` | `{ transport, dispose, ready }` for the underlying transport. | +| `rootUri` | `string \| null` | Normalized workspace root, or `null` for document-scoped clients. | +| `attach` | `(uri: string, view: EditorView, aliases?: string[]) => void` | Bind this client to a document. | +| `detach` | `(uri: string, view?: EditorView) => void` | Unbind. | +| `dispose` | `() => Promise` | Tear down this client. | ```js lsp.clientManager.setOptions({ + // Not recommended — see the warning above. diagnosticsUiExtension: [], }); @@ -843,11 +1195,6 @@ const activeClients = lsp.clientManager.getActiveClients(); console.log(activeClients); ``` -Available methods: - -- `lsp.clientManager.setOptions(options)` -- `lsp.clientManager.getActiveClients()` - ## Important Types ### `LspMessageTransport` @@ -868,11 +1215,11 @@ Object returned by transport factories and by `lsp.workers.createTransport()`. - `dispose`: Function that cleans up the transport. - `ready`: `Promise` that resolves when the transport is ready. -### `LspWorkerTransportOptions` +### `LspWorkerTransportOptions` Options for `lsp.workers.createTransport(options)`: -- `url` +- `url` (required) - `name?` - `serverId?` - `startupTimeout?` @@ -902,21 +1249,30 @@ Runtime providers return one of these shapes from `start()`. } ``` +`protocols` and `dispose` are optional in both shapes. + ### `LspRuntimeContext` -Context passed to runtime providers. +Context passed to runtime providers. It extends `TransportContext`: -- `uri` -- `file` -- `view` -- `languageId` -- `rootUri` -- `originalRootUri` +- `uri`, `file`, `view`, `languageId`, `rootUri`, `originalRootUri`, `debugWebSocket`, `dynamicPort` — from `TransportContext` - `documentUri` - `originalDocumentUri` -- `serverId` -- `workspaceKind`: One of `"app-private"`, `"builtin-alpine"`, `"termux-saf"`, `"saf"`, `"remote"`, `"proot-distro"`, `"virtual"`, or `"unknown"`. +- `serverId` — defaults to `server.id` +- `workspaceKind` — derived by `inferWorkspaceKind()` when the caller does not set it. The declared type is `"app-private"`, `"builtin-alpine"`, `"termux-saf"`, `"saf"`, `"remote"`, `"proot-distro"`, `"virtual"` or `"unknown"`; see [Runtime Providers](#runtime-providers) for which of those Acode actually produces. - `allowNonTerminalWorkspace` +- `runtimeAction` — one of `"checkInstallation"`, `"install"`, `"uninstall"`, `"command"` when the provider is being asked for install metadata rather than a connection + +### `LspRuntimeUriResolutionContext` + +`resolveUris()` receives this instead. It is `LspRuntimeContext` plus: + +- `originalDocumentUri` (non-optional `string`) +- `originalRootUri` (`string | null`) +- `normalizedDocumentUri` (`string | null`) +- `normalizedRootUri` (`string | null`) + +and returns `{ documentUri?, rootUri?, scope? }` where `scope` is `"workspace"` (default) or `"document"`. Document scope starts a separate client per document. ## Best Practices @@ -926,5 +1282,24 @@ Context passed to runtime providers. - Use `useWorkspaceFolders: true` for heavy workspace-aware servers. - If the server cannot see Acode's file paths, define `documentUri` and usually `rootUri`. - Runtime plugins should register their own server definitions instead of taking over built-in Acode server ids. -- For Web Worker language services, use `lsp.workers.createTransport()` . -- Set `minVersionCode` to `1002` when your plugin requires the worker transport API. +- For Web Worker language services, use `lsp.workers.createTransport()`. +- Feature-detect `lsp.workers?.createTransport` rather than declaring a `minVersionCode`; see the [Worker Transport](#worker-transport) section. + +## Gotchas + +- **`register()` does not throw on a duplicate id.** `registerServer()` returns the *already registered* definition when `replace` is not set. Only the bundle-ownership case throws: claiming a server id that another bundle already owns without `replace: true` throws `LSP server is already provided by ; must replace explicitly`. +- **Validation happens before registration, and it throws.** A raw manifest must have a non-empty `id`, a non-empty `languages` array, and — for `transport.kind: "stdio"` — a `transport.command`. `websocket` needs a `transport.url` **or** a `launcher.bridge.command`. Any managed installer (`kind` other than `shell`) must declare `install.binaryPath` or `install.executable`. +- **Ids are normalized.** Server ids, runtime provider ids, `languages` entries and `runtimes` entries are all trimmed and lowercased. `label` defaults to the id, `priority` to `0`, `enabled` to `true` (only `enabled === false` disables), `useWorkspaceFolders` to `false` (only `=== true` enables it), and `rootUri` / `documentUri` / `resolveLanguageId` to `null` when they are not functions. +- **`defineServer()` sets `transport.kind` to `"websocket"`; a raw manifest defaults to `"stdio"`.** +- **`capabilityOverrides` is dead in v1.13.5.** It is declared in `types.ts`, accepted by `defineServer()` and stored by `sanitizeDefinition()`, but nothing in `src/` ever reads it. To change capabilities, use `clientConfig.extensions` / `clientConfig.clientCapabilities`. +- **`clientConfig.notificationHandlers` cannot override `window/logMessage`, `window/showMessage` or `$/progress`.** Acode spreads your handlers first and then assigns its own for those three keys, so yours are silently replaced. Any other method name you add is kept. +- **`server.startupTimeout` is copied into `clientConfig.timeout`** (unless you set one yourself), where it becomes the LSP client connect timeout — it does not bound the Web Worker startup, which is `lsp.workers.createTransport({ startupTimeout })`. +- **`inlayHints` is opt-in.** Every other `clientConfig.builtinExtensions` flag is opt-out (`!== false`); `inlayHints` must be `true`. +- **Adding a server after startup does not extend the LSP formatter.** `registerLspFormatter(this)` runs once during Acode's module definition and snapshots `serverRegistry.listServers()` at that moment to build its extension list. A plugin server registered later is not in that list unless the snapshot happened to be empty (`["*"]`), so "Format" may not appear for your language. Formatting still works through the client manager if the command is invoked. +- **Registering `textDocument/publishDiagnostics` yourself suppresses Acode's built-in diagnostics extension** for that server — `wantsCustomDiagnostics` makes the client drop `diagnosticsExtension` from the merged extension list. +- **`lsp.bundles.unregister(id)` also unregisters every server the bundle owned.** +- **`lsp.bundles.getForServer()` takes a *server* id**, not a bundle id. +- **`runtimes.select()` can return `null`** (no provider's `canHandle()` matched). Nothing throws in that case; the client manager just logs `Cannot resolve runtime or document URI for LSP server ` and skips that server. +- **Settings can override your runtime.** `settings.lsp.runtime.*` is consulted before `canHandle()`, so a user's choice can route your server to a provider you did not expect (or fail and fall through). +- **`resolveUris()` only runs for the selected provider**, after selection, so one runtime cannot rewrite another runtime's documents. +- **Bundled built-in servers may be handed to the web-worker runtime regardless of `server.runtimes`** — `web-worker.canHandle()` checks only the server id, so registering your own server under a bundled id takes it over. Do not reuse `html`, `css`, `json` or `typescript`. diff --git a/docs/advanced-apis/system.md b/docs/advanced-apis/system.md index 923542c..b23f70d 100644 --- a/docs/advanced-apis/system.md +++ b/docs/advanced-apis/system.md @@ -6,6 +6,19 @@ The `system` module wraps Acode's native Android bridge (`cordova-plugin-system` const system = window.system; ``` +::: warning `acode.require("system")` returns `undefined` +`system` is a **Cordova clobber**, not an Acode module. `src/lib/acode.js` never calls `this.define("system", …)` — the only modules it registers are listed in its constructor, and `system` is not among them (`require()` is a plain lookup in the private `#modules` map, so an unregistered name yields `undefined`). + +| Access | Works? | +| --- | --- | +| `acode.require("system")` | ❌ returns `undefined` | +| `window.system` | ✅ | +| `globalThis.system` | ✅ | +| Bare `system` | ✅ (same global, don't shadow it) | + +Note the case-insensitive lookup: `require()` lower-cases the module name, so neither `"system"` nor `"System"` resolves. +::: + Most methods are callback-based (`(success, error) => void`). Wrap them with `helpers.promisify` when you prefer promises: ```js @@ -13,11 +26,27 @@ const helpers = acode.require("helpers"); const filesDir = await helpers.promisify(system.getFilesDir); ``` +::: tip `promisify` appends the callbacks for you +`helpers.promisify(func, ...args)` calls `func(...args, resolve, reject)`, so leading arguments are passed positionally: + +```js +const parent = await helpers.promisify(system.getParentPath, "/a/b/c.txt"); +``` + +The `system` methods never read `this`, so destructuring them (`const { getFilesDir } = system`) is safe. +::: + +::: danger No plugin-declared permission gates any of this +The Android permissions these methods depend on are merged into the **app's** manifest at build time by the plugin's own `plugin.xml`, so they are already granted to Acode. A plugin *can* declare a `permissions` array in its manifest, but that array is read only by the native `Tee` plugin, which copies the strings into a **token-scoped** list that `ctx.grantedPermission()` / `ctx.listAllPermissions()` echo back — nothing in `src/` consults it before dispatching a `System` action, and there is no consent prompt. + +So any installed plugin can call `system.*` freely, including `launchApp`, `httpStream` and the storage-manager helpers. Treat the module as fully trusted, and never forward untrusted input straight into it. +::: + ## Files ### `getFilesDir(success, error)` -Resolves the app's internal files directory path. +Resolves the app's internal files directory path. This is the `$PREFIX` used by the terminal/Executor sandbox. ```js const filesDir = await helpers.promisify(system.getFilesDir); @@ -29,7 +58,7 @@ Resolves the parent directory of `path`. ### `listChildren(path, success, error)` -Lists the children of a directory path. +Lists the children of a directory path. Success receives an array of entries. ### `mkdirs(path, success, error)` @@ -37,7 +66,11 @@ Recursively creates directories. ### `fileExists(path, countSymlinks, success, error)` -Checks whether a file exists. `countSymlinks` is a boolean passed as a string. +Checks whether a file exists. `countSymlinks` is a boolean passed as a string (`String(countSymlinks)`). Success receives a **number**, not a boolean — compare with `result == 1`, which is exactly what Acode's own `Terminal.js` does. + +```js +const exists = (await helpers.promisify(system.fileExists, path, false)) == 1; +``` ### `copyToUri(srcUri, destUri, fileName, success, error)` @@ -61,11 +94,15 @@ Marks a file path as executable (`executable` is a boolean passed as a string). ### `extractAsset(assetName, destinationPath, success, error)` -Extracts an app asset to a destination path. +Extracts an app asset to a destination path. Used by the terminal installer to unpack `alpine.rootfs`. ### `getNativeLibraryPath(success, error)` -Resolves the directory where native libraries are stored. +Resolves the directory where native libraries are stored. This is `$NATIVE_DIR`. + +::: warning Defined twice +`getNativeLibraryPath` appears twice in `www/plugin.js` with an identical body; the second definition wins. Behaviour is unchanged, but do not be surprised by the duplicate. +::: ## Storage management @@ -93,7 +130,7 @@ Checks whether the app is currently an external storage manager. ### `hasPermission(permission, success, error)` -Checks whether a runtime permission is granted. +Checks whether a runtime permission is granted. `permission` is an Android manifest constant such as `android.permission.CAMERA`. Success receives a boolean. ### `requestPermission(permission, success, error)` @@ -107,11 +144,11 @@ Requests multiple runtime permissions at once. ### `getAppInfo(success, error)` -Resolves information about the Acode app. +Resolves information about the Acode app: `{ versionName, versionCode, label, firstInstallTime, lastUpdateTime }`. ### `getInstaller(success, error)` -Resolves the package that installed the app (used for `window.appInstallSource`). +Resolves the package that installed the app (used by `window.appInstallSource`). ### `getAndroidVersion(success, error)` @@ -119,11 +156,11 @@ Resolves the Android OS version. ### `getArch(success, error)` -Resolves the device architecture (e.g. `arm64-v8a`). +Resolves the device architecture (e.g. `arm64-v8a`). Acode's terminal installer uses this to pick the proot/axs/Alpine bundle, and supports only `arm64-v8a`, `armeabi-v7a` and `x86_64`. ### `getWebviewInfo(success, error)` -Resolves WebView information (used by the terminal's engine detection). +Resolves WebView information: `{ versionName, packageName, versionCode }`. Used by the About page and by `acode.exec("…")` device-info output to report the WebView build. ### `isPowerSaveMode(success, error)` @@ -131,7 +168,7 @@ Checks whether the device is in power-save mode. ### `getGlobalSetting(key, success, error)` -Reads a global Android setting by key. +Reads a global Android setting by key. Acode reads `animator_duration_scale` from here to match system animation timing. ### `clearCache(success, error)` @@ -141,27 +178,32 @@ Clears the app's cache. ### `fileAction(fileUri, filename, action, mimeType, error?)` -Launches an Android intent for a file. `action` is one of `VIEW`, `EDIT`, `SEND`, or `RUN` (the app prepends `android.intent.action.`). Arguments are flexible: `system.fileAction(uri, filename, action, mimeType, onFail)`. +Launches an Android intent for a file. `action` is one of `VIEW`, `EDIT`, `SEND`, or `RUN` (the app prepends `android.intent.action.`). Arguments are flexible: the wrapper inspects argument types and shifts them, so all of `fileAction(uri, filename, action, mimeType, onFail)`, `fileAction(uri, action, mimeType, onFail)` and `fileAction(uri, action, onFail)` work. When an optional callback slot is not a function it is replaced with a no-op. ```js system.fileAction(fileUri, filename, "VIEW", "text/plain"); ``` +::: warning `fileAction` never reports success +The success callback is hard-coded to an empty function inside the wrapper, so only `onFail` is ever invoked. If you need to know whether the viewer opened, check the resulting file state yourself. +::: + ### `shareText(text, success, error)` Shares a text string through the system share sheet. ### `openInBrowser(src)` -Opens a url in the system browser. +Opens a url in the system browser. Both callbacks are `null`, so failures are silent. ### `inAppBrowser(url, title, showButtons, disableCache)` -Opens a url in Acode's in-app browser. Returns an object with `onOpenExternalBrowser` and `onError` callbacks that can be assigned: +Opens a url in Acode's in-app browser. Returns an object with `onOpenExternalBrowser` and `onError` callbacks that can be assigned. Both start as `null`; if the native layer fires before you assign them, Acode logs `handler is not set` / `error callback not handled` and the event is lost. ```js const browser = system.inAppBrowser(url, title, true, false); browser.onOpenExternalBrowser = (url) => console.log("opened externally", url); +browser.onError = (err) => console.error(err); ``` ### `launchApp(app, className, extras?, success?, error?)` @@ -184,6 +226,10 @@ system.launchApp( Adds a home-screen shortcut. `shortcut` is `{ id, label, description, icon, action, data }`. +::: warning `addShortcut` assigns to an undeclared global +The wrapper reads `shortcut.action` into a bare `action` identifier that was never declared with `var` alongside the other fields. The correct value is still passed to the native layer, but a global `window.action` is created as a side effect in sloppy mode. There is no fix on the JavaScript side — use the value yourself and don't read `window.action`. +::: + ### `removeShortcut(id, success, error)` Removes a shortcut by id. @@ -194,21 +240,21 @@ Pins a shortcut. ### `pinFileShortcut(shortcut, success, error)` -Pins a file shortcut. +Pins a file shortcut. Unlike `addShortcut`, the object is forwarded to native **whole** — its shape is `{ id, label, description?, icon?, uri }` (`uri` is required), and it is not flattened into positional args. ## Intents ### `getCordovaIntent(success, error)` -Resolves the intent that launched the app (for handling external open requests). +Resolves the intent that launched the app (for handling external open requests): `{ uris?, action, data, type, package, extras }`. ### `setIntentHandler(handler, onerror)` -Registers a handler for intents received while the app is running. `handler` receives the intent data. +Registers a handler for intents received while the app is running. `handler` receives the intent data. Note the handler is passed as the **success** callback of a single-shot native call, so it fires once. ## Text comparison -Used by the editor's dirty-tracking and file-change detection. Both methods compare in a background thread. +Used by the editor's dirty-tracking and file-change detection. Both methods compare in a background thread. Unlike everything else in this module they are **promise-based already** — no `promisify` needed. Each resolves `true` when the values **differ**, `false` when they match. ### `compareFileText(fileUri, encoding, currentText): Promise` @@ -226,12 +272,92 @@ const changed = await system.compareFileText(file.uri, file.encoding, text); ### `setUiTheme(systemBarColor, theme, success?, error?)` -Sets the Android system bar colors to match a theme. `systemBarColor` is a hex color; `theme` is the theme id. A pure white color is mapped to `#fffffe` so status bar icons stay visible. +Sets the Android system bar colors to match a theme. `systemBarColor` is a hex color; `theme` is the theme id. A pure white color is mapped to `#fffffe` (both `#ffffff` and `#ffffffff` are rewritten) so status bar icons stay visible. On success the wrapper also calls `window.statusbar.setBackgroundColor(systemBarColor)` before invoking your `onSuccess`. ### `setInputType(type, success, error)` -Changes the soft-keyboard input type. +Changes the soft-keyboard input type. Acode uses this to switch between `NORMAL` and the app's configured keyboard mode when dialogs open and close. ### `setNativeContextMenuDisabled(disabled, success, error)` -Enables or disables the native context menu on the WebView. +Enables or disables the native context menu on the WebView. The flag is coerced with `String(!!disabled)`. Use it when you render your own long-press menu. + +## HTTP streaming + +### `httpStream(url, options?): Promise` + +Performs an HTTP request and streams the response body to JavaScript as a WHATWG `ReadableStream` of `Uint8Array` chunks. This is the one member of the module that returns a real `Response`, and the most useful one for plugins that consume SSE or large downloads. + +| Option | Type | Default | Notes | +| --- | --- | --- | --- | +| `method` | `string` | `"GET"` | | +| `headers` | `Record` | — | Normalised into a `Headers` object | +| `body` | `string` | — | Sent as UTF-8 unless `bodyIsBase64` | +| `bodyIsBase64` | `boolean` | `false` | Body is base64-decoded | +| `followRedirects` | `boolean` | `true` | | +| `connectTimeout` | `number` | `30000` | ms | +| `readTimeout` | `number` | `0` | ms, `0` = no timeout | +| `chunkSize` | `number` | `32768` | Requested native chunk size in bytes | +| `signal` | `AbortSignal` | — | Aborting cancels the native request | + +```js +const res = await system.httpStream("https://example.com/events", { + signal: controller.signal, +}); + +const reader = res.body.getReader(); +const decoder = new TextDecoder(); +let buffer = ""; + +while (true) { + const { value, done } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop(); + for (const line of lines) { + if (line.startsWith("data:")) onEvent(line.slice(5).trim()); + } +} +``` + +::: warning Chunk boundaries are arbitrary +The native layer does **no** parsing at all — no SSE, no provider-specific framing — it just forwards raw byte chunks, which may split multi-byte UTF-8 characters in half. Buffer and decode yourself, as above. A 4xx/5xx status is a normal response (the promise resolves); only transport failures reject. Cancelling the reader (or aborting `options.signal`) cancels the underlying native request; if headers had not arrived yet the promise rejects with an `AbortError`, otherwise the stream is errored. +::: + +## App icon + +### `setAppIcon(iconName, success, error)` + +Changes the app launcher icon at runtime. Pass `"default"` to restore the original. + +Valid ids: `default`, `pro`, `midnight_circuit`, `aurora_pulse`, `terminal_glow`, `solar_flare`, `blueprint`, `pixel_party`, `prism`, `porcelain`, `tangerine`, `tidal`, `lilac`, `volt`, `cobalt`, `glacier`. (`pro` additionally requires the Pro purchase.) + +::: warning Cosmetic only +This changes the launcher icon of the whole Acode app — it is not per-plugin and it affects every user of that install. Use it from an explicit user action in your plugin's settings, and restore `"default"` when disabled. +::: + +## Reward status + +### `getRewardStatus(success, error)` + +Resolves the state of Acode's "remove ads" reward pass as either a JSON **string** or a plain string — Acode's own wrapper `JSON.parse`s whichever it gets: + +```js +{ + isActive, canRedeem, redeemDisabledReason, + adFreeUntil, lastExpiredRewardUntil, remainingMs, + redemptionsToday, remainingRedemptions, maxRedemptionsPerDay, + maxActivePassMs, hasPendingExpiryNotice, expiryNoticePendingUntil, + grantedDurationMs?, appliedDurationMs?, offerId? +} +``` + +### `redeemReward(offerId, success, error)` + +Redeems an offer and resolves with the same shape. Both take callbacks, not promises. + +::: warning Ad-gating logic lives in Acode +Acode's own ads check is `!config.HAS_PRO && adRewards.canShowAds()`, so a redemptions UI belongs to Acode's settings, not to your plugin. A plugin that surfaces `getRewardStatus()` is exposing Acode's monetisation internals and will show numbers that disagree with what Acode actually enforces — check `config.HAS_PRO` and prefer not shipping this UI at all. +::: \ No newline at end of file diff --git a/docs/advanced-apis/terminal.md b/docs/advanced-apis/terminal.md index f99a8c9..7d4ab75 100644 --- a/docs/advanced-apis/terminal.md +++ b/docs/advanced-apis/terminal.md @@ -1,6 +1,6 @@ # Terminal -The `terminal` module exposes Acode’s xterm.js-based terminal. Require it with `acode.require('terminal')` to create terminals, manage sessions, and add themes. +The `terminal` module exposes Acode’s xterm.js-based terminal. Require it with `acode.require('terminal')` to create terminals, manage sessions, add themes, and register touch-selection "More" actions. ## Import @@ -14,16 +14,20 @@ The module exposes these methods: - `create(options)`: Creates a new terminal tab. Returns an instance object. - `createLocal(options)`: Creates a local-only terminal (no backend). -- `createServer(options)`: Creates a server-connected terminal when available(By default it will connect to Alpine). -- `get(id)`: Returns a terminal instance by id or null. -- `getAll()`: Returns a Map-like collection of all terminals. -- `write(id, data)`: Writes text to a terminal (ANSI supported). This only write input into the terminal, **it does not automatically submit/execute shell commands**. To execute a command, include a line ending (carriage return/newline) such as `\r` or `\r\n`. +- `createServer(options)`: Creates a server-connected terminal (by default it connects to the Alpine/AXS sandbox). +- `get(id)`: Returns a terminal instance by id, or `null`. +- `getAll()`: Returns the live `Map` of all terminals. +- `write(id, data)`: Writes text to a terminal (ANSI supported). This only writes input into the terminal, **it does not automatically submit/execute shell commands**. To execute a command, include a line ending (carriage return/newline) such as `\r` or `\r\n`. - `clear(id)`: Clears a terminal screen. -- `close(id)`: Closes and disposes a terminal. -- `themes.register(name, theme, pluginId)`: Adds a custom theme. -- `themes.unregister(name, pluginId)`: Removes a theme registered by your plugin. -- `themes.get(name)`: Returns a theme by name. -- `themes.getAll()`: Returns an object map of all themes. +- `close(id)`: Closes and disposes a terminal session. +- `moreOptions.add(option)`: Registers a touch-selection "More" menu entry. +- `moreOptions.remove(id)`: Removes a previously registered entry. +- `moreOptions.list()`: Returns an array of the registered entries. +- `touchSelection.moreOptions`: The **same object** as `moreOptions` (alias). +- `themes.register(name, theme, pluginId)`: Adds a custom theme. Returns a `boolean`. +- `themes.unregister(name, pluginId)`: Removes a theme registered by your plugin. Returns a `boolean`. +- `themes.get(name)`: Returns a theme object by name (falls back to `dark`). +- `themes.getAll()`: Returns a plain object map of all themes. - `themes.getNames()`: Returns an array of available theme names. - `themes.createVariant(baseName, overrides)`: Clones a theme with overrides. @@ -37,41 +41,150 @@ The native terminal environment is also exposed globally as `Terminal`. Methods // Generic create (chooses mode from options) const term = await terminal.create({ name: 'My Terminal', - theme: 'dark', // Any installed theme + theme: terminal.themes.get('dark'), // must be an OBJECT, not a name }); -// Local terminal (no backend), It is like a empty terminal instance where you can write stuff , it doesn't opens any shell +// Local terminal (no backend). An empty terminal instance where you can write +// output. It does not open any shell and accepts no input. const local = await terminal.createLocal({ name: 'Plugin Output' }); -// Server terminal (connects to backend if available) +// Server terminal (connects to the Alpine/AXS backend) const server = await terminal.createServer({ name: 'Server Shell' }); -// Or via command +// Or via command (always creates a SERVER terminal) acode.exec('new-terminal'); ``` +### `create` vs `createLocal` vs `createServer` + +All three funnel into the same `TerminalManager.createTerminal(options)`. They differ only in the `serverMode` flag they force: + +| Function | `serverMode` | Behaviour | +| --- | --- | --- | +| `create(options)` | `options.serverMode !== false`, so **default `true`** | Server mode unless you explicitly pass `serverMode: false` | +| `createLocal(options)` | forced `false` | No backend, no PTY, no shell. Acode writes `Local terminal mode - ready for output` once at creation | +| `createServer(options)` | forced `true` | Starts/uses the AXS sandbox backend, creates a PTY, connects a WebSocket | + +::: warning `create()` defaults to server mode +`terminal.create({ name: 'x' })` with no `serverMode` runs the **full install + AXS startup flow**, exactly like `createServer()`. If you want a pure output pane you must pass `serverMode: false` or call `createLocal()`. `serverMode` is also honoured as `false` only when it is strictly `false` (`serverMode !== false`), so `0` and `null` still mean server mode. +::: + +::: tip `createLocal` still respects `terminal.write()` +A local terminal has no PTY, so everything you `write()` is rendered by xterm as literal output. ANSI colour codes work. Nothing you write can ever be executed. +::: + ### TerminalOptions -Common options accepted by create functions: +Options are split in two: keys consumed by `TerminalManager.createTerminal()` itself, and everything else, which is forwarded verbatim into the xterm.js constructor. + +**Consumed by the manager** + +| Option | Type | Default | Meaning | +| --- | --- | --- | --- | +| `name` | `string` | `` `Terminal ${terminalNumber}` `` | Tab/file name. Trimmed. If it matches `^Terminal\s+(\d+)` that number becomes `terminalNumber` and the tab prefix | +| `serverMode` | `boolean` | `true` | `false` for local mode. Overridden by `createLocal` / `createServer` | +| `render` | `boolean` | `true` | `false` creates the tab without rendering (`render !== false`) | +| `pinned` | `boolean` | `false` | Passed to the `EditorFile`; a pinned tab is not closed by `file.remove()` | +| `reconnecting` | `boolean` | `false` | Suppresses the error alert dialog on failure (used by session restore) | +| `pid` | `string` | — | Attach to an existing AXS session instead of creating one. Only meaningful in server mode | + +**Forwarded to xterm.js** + +| Option | Type | Default | Notes | +| --- | --- | --- | --- | +| `rows` | `number` | `24` | Initial size hint | +| `cols` | `number` | `80` | Initial size hint | +| `port` | `number` | `8767` | AXS HTTP/WebSocket port on `127.0.0.1`. Used for session create, resize and terminate | +| `renderer` | `string` | `"auto"` | `"auto"` and `"webgl"` both load `@xterm/addon-webgl` and silently fall back to canvas if WebGL fails; `"canvas"` never loads it | +| `theme` | `object` | app setting `"dark"` resolved | **Must be a theme object**, see below | +| `fontSize` | `number` | `12` | App terminal setting | +| `fontFamily` | `string` | `"MesloLGS NF Regular"` | App terminal setting | +| `fontWeight` | `string` | `"normal"` | App terminal setting | +| `cursorBlink` | `boolean` | `true` | App terminal setting | +| `cursorStyle` | `string` | `"block"` | App terminal setting | +| `cursorInactiveStyle` | `string` | `"outline"` | App terminal setting | +| `scrollback` | `number` | `1000` | App terminal setting | +| `tabStopWidth` | `number` | `4` | App terminal setting | +| `convertEol` | `boolean` | `true` | App terminal setting | +| `letterSpacing` | `number` | `0` | App terminal setting | +| `allowProposedApi` | `boolean` | `true` | xterm option | +| `scrollOnUserInput` | `boolean` | `true` | xterm option | + +Any other xterm.js option (`overviewRuler`, `minimumContrastRatio`, `drawBoldTextInBrightColors`, …) is also passed straight through. + +::: danger `theme` must be an object, not a name +The component resolves the app's theme setting **before** merging your options: + +```js +this.options = { + // ... + theme: TerminalThemeManager.getTheme(terminalSettings.theme), + // ... + ...options, // your `theme` wins +}; +``` + +Because your options are spread **last**, `theme: 'dark'` replaces the resolved object with a plain string. Nothing re-resolves it afterwards — `mount()` then does `this.container.style.background = this.options.theme.background`, which is `undefined` for a string, and xterm receives a string where it expects a theme object. Always pass the object: + +```js +const out = await terminal.createLocal({ + name: 'Plugin Output', + theme: terminal.themes.get('nord'), +}); +``` + +`component.updateTheme(nameOrObject)` is the one place that **does** accept a name string, because it explicitly calls `TerminalThemeManager.getTheme()` for strings. +::: + +::: warning There is no PTY spawn options +Acode does **not** expose node-pty style spawn arguments. `args`, `cwd`, `env`, `term`, `type` and a caller-supplied `id` are **not** options. The PTY is created server-side by AXS with a fixed `POST /terminals` body of `{ cols, rows }` only. To run a different program, write it to a server terminal instead of trying to spawn it. +::: + +### What is a "server terminal"? -- `name`: Display name for the terminal tab. -- `serverMode`: Boolean to force server connection (default true) or local mode (false). -- `port`: Useful in server mode to connect to specific `axs` port. Backend HTTP/WS port (default 8767). -- `theme`: Theme name to apply (see Themes). -- `rows, cols`: Preferred initial size hints. -- `renderer`: Preferred xterm.js renderer. Accepts `'auto'` (default; prefers `webgl`), `'webgl'`, or `'canvas'`. Use `webgl` for best performance; `canvas` is a fallback and may be slower. -- `fontSize`: Initial font size. -- And other xtermjs terminal options such as: allowProposedApi,scrollOnUserInput,fontFamily,fontWeight,cursorBlink,cursorStyle,cursorInactiveStyle,scrollback,letterSpacing, etc +A server terminal is a real interactive shell. In server mode the manager: + +1. Checks `Terminal.isInstalled()` and `Terminal.isSupported()`, and if needed opens a **"Terminal Installation"** tab and streams the Alpine/AXS download + extraction log into it. Creation only continues if `Terminal.install()` resolves `true`. +2. Starts AXS if it is not already running (`Terminal.isAxsRunning()`), calls `Executor.setProotDebug(...)`, then polls `http://127.0.0.1:/status` up to **20 times, 500 ms apart**, waiting for `OK`. +3. `POST /terminals` with `{ cols, rows }` to get a **PID**, which becomes the terminal id. +4. Opens `ws://127.0.0.1:/terminals/`, attaches xterm's `AttachAddon`, and forwards keystrokes both ways. + +Because it is a live PTY, `terminal.write(id, 'ls -la\r')` really does execute `ls -la`. This is why the module-level `write()` is filtered (see below), and it is exactly why `Executor` exists as a separate, unfiltered path for background work. + +### Remote (SSH) terminals + +`createTerminal()` accepts an internal `remoteSsh` option, which switches the transport from AXS WebSocket to the `sftp` native plugin's interactive shell: + +```js +{ + remoteSsh: { + profileId: 'profile-xxxx', // must start with "profile-" + displayName: 'my-server', // shown as the tab subtitle + initialDirectory: '/srv/app', // optional; a `cd` is sent when it isn't "/" + } +} +``` + +When `remoteSsh` is set: `component.pid` becomes `` `ssh:` ``, session data flows over `sftp.writeShell()` instead of a WebSocket, and Acode **ignores `port`**. The manager exposes a dedicated helper, `createRemoteTerminal(storage, options)`, which builds this from an SFTP storage URL, but that helper is **not** part of the `acode.require('terminal')` module — it is reachable only from inside Acode. + +::: tip OSC 7777 is local-only +A remote SSH host can emit `\e]7777;open;file;/path\e\\`, but Acode ignores it when `remoteSsh` is set — a remote machine must not be able to ask for files on the Android device. +::: ### Return Value -The create methods resolve to an instance object: +All three create functions resolve to an instance object: + +| Field | Type | Description | +| --- | --- | --- | +| `id` | `string` | `component.pid` when the backend supplied one, otherwise a generated `terminal_1`, `terminal_2`, … | +| `name` | `string` | Tab/file name | +| `terminalNumber` | `number` | Ordinal used for the `Terminal N` tab prefix | +| `component` | `TerminalComponent` | The xterm.js wrapper — see [Terminal instance surface](#terminal-instance-surface) | +| `file` | `EditorFile` | EditorFile tab representing the terminal | +| `container` | `HTMLElement` | The `div.terminal-content` DOM element hosting the terminal | -- id: Unique id (PID when available; fallback to generated id). -- name: Terminal name. -- component: [TerminalComponent instance](https://github.com/Acode-Foundation/Acode/blob/a38f019444cac4c155aff5f18df52d8685fb171d/src/components/terminal/terminal.js#L24). -- file: EditorFile tab representing the terminal. -- container: The DOM element hosting the terminal. +The promise is rejected when creation fails (install failure, AXS not ready, unsupported architecture, WebSocket connect timeout of 5 s), after Acode has already disposed the component and force-removed the tab. ## Manage @@ -81,10 +194,10 @@ const t = terminal.get('terminal_1'); // Iterate all terminals for (const [id, inst] of terminal.getAll()) { - console.log(id, inst.name); + console.log(id, inst.name, inst.component.pid); } -// Write text (ANSI supported) +// Write text (ANSI supported). Needs \r to actually submit in server mode. terminal.write('terminal_1', 'Hello World!\r\n'); // Clear and close @@ -92,17 +205,209 @@ terminal.clear('terminal_1'); terminal.close('terminal_1'); ``` +### `get(id)` + +Returns the instance object for `id`, or **`null`** when there is no such terminal (it is not `undefined`). Silently no-ops on `write`/`clear`/`close` for an unknown id. + +### `getAll()` + +Returns the manager's **live internal `Map`** keyed by terminal id — not a copy. Mutating it corrupts Acode's registry. Iterate it, don't write to it. + +### `write(id, data)` + +```js +terminal.write(id, 'plain output\r\n'); +terminal.write(id, '\u001b[36mcyan\u001b[0m\r\n'); +terminal.write(id, 'ls -la\r'); // server mode: actually executes +``` + +| Parameter | Type | Notes | +| --- | --- | --- | +| `id` | `string` | Terminal id from `create*()` or `get(id).id` | +| `data` | `string` | **Must be a string.** Anything else is dropped with a console warning | + +- **Returns: `undefined`.** It is fire-and-forget. +- In **server mode** the data is sent through the PTY's WebSocket, so it is *input*, not output. +- In **local mode** (or when the socket is not `OPEN`) it is handed straight to `terminal.write(data)`, i.e. rendered as output. +- It routes through a private security filter. See below. + > [!Note] -> write uses a secured path internally to prevent unintended escape sequences from breaking state. +> `terminal.write()` is the only filtered path. `terminal.get(id).component.write(data)` calls `TerminalComponent.write()` **directly and bypasses every check** — the filter lives in the module wrapper, not in the component. + +### The `write()` security filter + +`acode.js` routes `terminal.write` through a private `#secureTerminalWrite(id, data)`. Every plugin-authored write passes these checks, in this order: + +| # | Check | On failure | +| --- | --- | --- | +| 1 | `typeof data !== "string"` | `console.warn("Terminal write data must be a string")`, write dropped, returns `undefined` | +| 2 | 24 dangerous patterns — 23 anchored command lines plus the null-byte check (table below) | `console.warn("Blocked potentially dangerous terminal command: …")` + toast **`"Potentially dangerous command blocked for security"`** (3 s) | +| 3 | Command substitution: if the data contains both `$(` and `)`, every `$( … )` group is re-tested against the same 24 patterns | `console.warn("Blocked command substitution with dangerous content: …")` + toast **`"Command substitution blocked for security"`** (3 s) | +| 4 | `data.length > 64 * 1024` | `console.warn("Terminal write data truncated - exceeded 65536 characters")`, then the payload is cut to 65536 chars and **`"\n[Data truncated for security]\n"`** is appended | + +Nothing is ever partially written: on a block the function returns before touching the terminal. + +#### Blocked patterns + +Every command pattern is anchored with `^ … $` **and the `m` flag**, so it must match a whole **line**. `rm -rf /` is blocked; `echo hi; rm -rf /` is **not** (the line does not start with `rm`), and `sudo rm -rf /data` is blocked by the `sudo rm -rf /` rule because `/data` still starts with `/`. + +| # | Regex (as written in source) | Blocked example | +| --- | --- | --- | +| 1 | `/^\s*rm\s+-rf?\s+\/[^\r\n]*[\r\n]?$/m` | `rm -rf /` | +| 2 | `/^\s*rm\s+-rf?\s+\*[^\r\n]*[\r\n]?$/m` | `rm -rf *` | +| 3 | `/^\s*rm\s+-rf?\s+~[^\r\n]*[\r\n]?$/m` | `rm -rf ~` | +| 4 | `/^\s*mkfs\.[^\r\n]*[\r\n]?$/m` | `mkfs.ext4 /dev/block/sda` | +| 5 | `/^\s*dd\s+if=\/[^\r\n]*[\r\n]?$/m` | `dd if=/dev/zero of=/dev/sda` | +| 6 | `/^\s*:(){ :\|:& };:[^\r\n]*[\r\n]?$/m` | `:(){ :\|:& };:` (fork bomb) | +| 7 | `/^\s*sudo\s+dd\s+if=\/[^\r\n]*[\r\n]?$/m` | `sudo dd if=/dev/zero of=/dev/sda` | +| 8 | `/^\s*sudo\s+rm\s+-rf?\s+\/[^\r\n]*[\r\n]?$/m` | `sudo rm -rf /` | +| 9 | `/^\s*curl\s+[^\r\n]*\|\s*sh[^\r\n]*[\r\n]?$/m` | `curl -fsSL https://get.example.com/i.sh \| sh` | +| 10 | `/^\s*wget\s+[^\r\n]*\|\s*sh[^\r\n]*[\r\n]?$/m` | `wget -qO- https://get.example.com/i.sh \| sh` | +| 11 | `/^\s*bash\s+<\s*\([^\r\n]*[\r\n]?$/m` | `bash <(curl -s https://get.example.com/i.sh)` | +| 12 | `/^\s*sh\s+<\s*\([^\r\n]*[\r\n]?$/m` | `sh <(curl -s https://get.example.com/i.sh)` | +| 13 | `/^\s*nc\s+-l\s+-p\s+\d+[^\r\n]*[\r\n]?$/m` | `nc -l -p 4444` | +| 14 | `/^\s*ncat\s+-l\s+-p\s+\d+[^\r\n]*[\r\n]?$/m` | `ncat -l -p 4444` | +| 15 | `/^\s*python\s+.*SimpleHTTPServer[^\r\n]*[\r\n]?$/m` | `python -m SimpleHTTPServer 8000` | +| 16 | `/^\s*python\s+.*http\.server[^\r\n]*[\r\n]?$/m` | `python3 -m http.server` | +| 17 | `/^\s*kill\s+-9\s+1\s*[\r\n]?$/m` | `kill -9 1` | +| 18 | `/^\s*killall\s+-9\s+\*[^\r\n]*[\r\n]?$/m` | `killall -9 *` | +| 19 | `/^\s*chmod\s+777\s+\/[^\r\n]*[\r\n]?$/m` | `chmod 777 /` | +| 20 | `/^\s*chown\s+[^\s]+\s+\/[^\r\n]*[\r\n]?$/m` | `chown root /` | +| 21 | `/^\s*cat\s+\/etc\/passwd[^\r\n]*[\r\n]?$/m` | `cat /etc/passwd` | +| 22 | `/^\s*cat\s+\/etc\/shadow[^\r\n]*[\r\n]?$/m` | `cat /etc/shadow` | +| 23 | `/^\s*cat\s+\/root\/[^\r\n]*[\r\n]?$/m` | `cat /root/.bashrc` | +| 24 | `/\x00/g` | any payload containing a NUL byte | + +::: tip The filter is a blocklist, not a sandbox +It is a **line-anchored blocklist**, so it is trivially bypassable. These all pass and still do the damage: + +| Bypasses | Why | +| --- | --- | +| `rm -rf ${HOME}` | No literal `/` after `-rf`, and no `*` or `~` | +| `rm -fr /` | `-rf?` only matches `-r` / `-rf`, never `-fr` | +| `sudo sh -c 'rm -rf /'` | The `sudo rm -rf /` rule requires `sudo` immediately followed by `rm` | +| `$(echo rm -rf /)` | The substitution scan re-tests the whole `$( … )` group, and the group still does not *start* with `rm` | +| `nc -l 4444` | The netcat rules require the full `-l -p ` form | +| `curl -s url -o s.sh && sh s.sh` | The pipe-to-shell rules need a literal `\| sh` on the same line | +| `echo cm0=… \| base64 -d \| sh` | No `$`, no `-p`, and the first line does not start with `curl`/`wget` | + +Do not treat `terminal.write()` as a security boundary for untrusted input. If you need to execute a command with attacker-controlled arguments, validate them yourself, or use a local (non-server) terminal, which has no PTY to attack at all. +::: + +::: warning Null bytes and truncation are silent from the caller's side +Both a block and a truncation return `undefined`. The only signals are the console warning and the toast. If a plugin needs to know, keep its own length check before calling. +::: + +### `clear(id)` + +Calls `component.clear()` → `xterm.clear()`. Clears the viewport and the scrollback buffer. Returns `undefined`. No-op for an unknown id. + +### `close(id)` + +```js +terminal.close(instance.id); // disposes the session +instance.file.remove(true); // ALSO removes the tab, if you want that +``` + +`terminal.close(id)` maps to `TerminalManager.closeTerminal(id)` where the second parameter `removeTab` **defaults to `false`**. + +| What happens | | +| --- | --- | +| Sets `component.intentionalClose = true` | so the resulting socket close is not treated as a crash | +| Removes the persisted session record | only for non-remote server terminals | +| Disconnects the `ResizeObserver` and focus handlers | | +| Calls `component.dispose()` | terminates the PTY (`POST /terminals//terminate`), disposes addons and xterm | +| Deletes the instance from the registry | | +| **Does not remove the editor tab** | you must call `instance.file.remove(true)` yourself | +| Calls `Executor.stopService()` when it was the last terminal | side effect worth knowing about | + +**Returns `Promise`.** To fully close a terminal from a plugin, call `close(id)` then `file.remove(true, { ignorePinned: true })`. + +::: warning Do not reuse Acode's event callbacks +`TerminalManager.setupTerminalHandlers()` already assigns `component.onConnect`, `onDisconnect`, `onError`, `onTitleChange`, `onProcessExit` and `onOscOpen`. They are **single-assignment callback properties**, not an EventEmitter — assigning your own handler **overwrites** Acode's exit/disconnect cleanup and can leave a zombie tab with a dead PTY. If you need exit notifications, chain instead: + +```js +const previous = instance.component.onProcessExit; +instance.component.onProcessExit = (data) => { + previous.call(instance.component, data); + // your logic here +}; +``` + +For raw streaming, subscribe to the xterm instance instead — that *is* EventEmitter-style and returns a disposable: + +```js +const sub = instance.component.terminal.onData((data) => { /* keystrokes */ }); +sub.dispose(); +``` + +The component's own `onConnect`/`onDisconnect`/`onError`/`onTitleChange`/`onBell`/`onProcessExit`/`onOscOpen` are declared as no-op prototype methods and are always overwritten by the manager right after `create*()` resolves. +::: + +## Touch selection "More" options + +`terminal.moreOptions` and `terminal.touchSelection.moreOptions` are **the same object literal** — pick either spelling. Options registered here appear in the sheet opened by the "More" row of Acode's terminal touch-selection context menu. + +```js +const id = terminal.moreOptions.add({ + id: 'com.example.plugin.copy-cwd', + label: 'Copy working directory', + icon: 'content_copy', + enabled: (ctx) => !!ctx.selection, + action: async (ctx) => { + // ctx.selection may be empty + }, +}); + +terminal.moreOptions.list(); // [{ id, label, icon, enabled, action }, …] +terminal.moreOptions.remove(id); +``` + +| Method | Signature | Returns | +| --- | --- | --- | +| `add` | `add(option) => string \| null` | The normalised id, or `null` if the option was rejected | +| `remove` | `remove(id) => boolean` | `true` if an option was deleted | +| `list` | `list() => Array` | Shallow copies of every registered option | + +### Option shape + +| Key | Type | Required | Notes | +| --- | --- | --- | --- | +| `label` | `string \| (ctx) => string` | **Yes** | Aliases: `text`, `title`. A function is resolved at menu-open time; if it returns `""`/`null` the entry is hidden | +| `action` | `(ctx) => void \| Promise` | **Yes** | Aliases: `onselect`, `onclick`. Awaited; a throw is caught, logged, and toasted as `Failed to execute action.` | +| `id` | `string` | No | Generated as `` `terminal_more_option_${n}` `` when omitted or empty. Re-using an id **replaces** the option | +| `icon` | `string` | No | Defaults to `null` | +| `enabled` | `boolean \| (ctx) => boolean` | No | Defaults to enabled. `false`/a function returning `false` greys the row out and blocks `action` | + +### `action` context + +| Field | Type | Description | +| --- | --- | --- | +| `terminal` | `Terminal` | The raw xterm instance | +| `touchSelection` | `TerminalTouchSelection` | The selection controller | +| `selection` | `string` | Current selection (falls back to `terminal.getSelection()`) | +| `clearSelection` | `() => void` | Force-clear the selection | +| `copySelection` | `() => void` | Copy selection to clipboard | +| `pasteFromClipboard` | `() => void` | Paste clipboard into the terminal | +| `selectAll` | `() => void` | Select all terminal text | + +::: tip A built-in entry always exists +Acode pre-registers `__acode_terminal_select_all__` ("Select all"). It is returned by `list()` and is guaranteed to be present; you can add your own entries alongside it but you should not remove it. +::: + +::: warning Nothing auto-cleans these +Touch-selection options are stored in a module-level `Map` with **no plugin scoping** — unlike themes, there is no `pluginId`. Remove your own options from `acode.setPluginUnmount(...)` or they will fire against the next version of your plugin. +::: ## Themes Register custom themes or derive variants from existing ones. You can also query available themes. ```js -// Register +// Register — returns true on success, false on conflict or invalid shape terminal.themes.register('myTheme', { - background: '#1a1a1a', foreground: '#ffffff', cursor: '#ffffff', cursorAccent: '#1a1a1a', selection: '#ffffff40', + background: '#1a1a1a', foreground: '#ffffff', cursor: '#ffffff', cursorAccent: '#1a1a1a', + selectionBackground: '#ffffff40', black: '#000000', red: '#ff5555', green: '#50fa7b', yellow: '#f1fa8c', blue: '#bd93f9', magenta: '#ff79c6', cyan: '#8be9fd', white: '#f8f8f2', brightBlack: '#44475a', brightRed: '#ff6e6e', brightGreen: '#69ff94', brightYellow: '#ffffa5', brightBlue: '#d6acff', brightMagenta: '#ff92df', brightCyan: '#a4ffff', brightWhite: '#ffffff', }, 'my-plugin-id'); @@ -123,23 +428,124 @@ terminal.themes.unregister('darkCustom', 'my-plugin-id'); ### Required Theme Keys -- background, foreground, cursor, cursorAccent, selection +`register()` validates the theme and **refuses** it (returns `false`, logs the offending colour) unless all **18** of these keys are present and are strings: + +- background, foreground, cursor - black, red, green, yellow, blue, magenta, cyan, white - brightBlack, brightRed, brightGreen, brightYellow, brightBlue, brightMagenta, brightCyan, brightWhite +::: tip `cursorAccent` and `selection` are optional +`cursorAccent`, `selectionBackground` / `selection`, `selectionForeground`, `selectionInactiveBackground`, `scrollbarSliderBackground`, `scrollbarSliderHoverBackground`, `scrollbarSliderActiveBackground` and `overviewRulerBorder` are **not** validated. For legacy themes written against older xterm.js, a `selection` key is automatically renamed to `selectionBackground` on registration and the original `selection` key is deleted. +::: + +### Method reference + +| Method | Signature | Behaviour | +| --- | --- | --- | +| `register` | `register(name, theme, pluginId) => boolean` | `false` if `name` collides with a **built-in** theme (logged as a conflict), `false` if validation fails. Otherwise stores `{ ...normalizedTheme, _pluginId: pluginId, _isPlugin: true }` and returns `true` | +| `unregister` | `unregister(name, pluginId) => boolean` | `false` if no such theme, `false` if `theme._pluginId !== pluginId`. This `pluginId` check is the **only** ownership scoping in the theme API | +| `get` | `get(name) => object` | Plugin themes first, then built-ins, then **falls back to the built-in `dark`** for unknown names — it never returns `undefined` | +| `getAll` | `getAll() => object` | Shallow merge of built-ins and plugin themes into a new object | +| `getNames` | `getNames() => string[]` | `Object.keys(getAll())` | +| `createVariant` | `createVariant(baseName, overrides) => object` | `{ ...getTheme(baseName), ...overrides }` | + +Built-in names: `dark`, `light`, `solarizedDark`, `solarizedLight`, `monokai`, `dracula`, `nord`, `gruvbox`, `oneDark`, `material`, `tokyoNight`, `catppuccin`, `synthwave`, `cyberpunk`, `forest`, `sunset`, `ocean`, `glass`, `glassDark`. + +::: warning `pluginId` is ownership metadata only +It is stamped onto the theme as `_pluginId` and checked by `unregister()`. It does **not** namespace the theme, does not prevent another plugin from reading it via `get()`, and does not auto-clean anything — `unregisterPluginThemes(pluginId)` exists on the manager but is never called from anywhere in Acode and is not exposed on `terminal.themes`. Call `terminal.themes.unregister(name, pluginId)` yourself from `acode.setPluginUnmount(id, ...)`. +::: + +::: warning `createVariant` has no override allowlist +`createVariant` merges **any** key you pass. If the base is a **plugin** theme, the variant inherits its `_pluginId` and `_isPlugin` markers, so registering the variant under a new name carries the original plugin's ownership over to it — and `unregister(variantName, yourId)` will then fail. Variant a **built-in** theme, or strip the metadata, if you intend to register the result. +::: + +::: tip Registering the same plugin name twice overwrites +The built-in collision check only looks at built-ins, so `register('myTheme', v1, 'p')` followed by `register('myTheme', v2, 'p')` silently replaces `v1`. Changing a plugin's `pluginId` between those calls will make the theme permanently un-unregisterable. +::: + +## Terminal instance surface + +`instance.component` is a `TerminalComponent`. These are the members you can rely on. + +**Methods** + +| Member | Signature | Notes | +| --- | --- | --- | +| `write` | `write(data)` | Remote SSH → `sftp.writeShell`; connected server → `websocket.send`; otherwise `terminal.write(data)`. **Unfiltered** | +| `writeln` | `writeln(data)` | Always `terminal.writeln(data)` — never goes to the PTY | +| `clear` | `clear()` | `terminal.clear()` | +| `focus` / `blur` | `focus()` / `blur()` | | +| `fit` | `fit()` | `FitAddon.fit()` | +| `fitAndResizeTerminal` | `fitAndResizeTerminal(forceServerSync = false)` | Fit, then sync dims to the PTY if they changed or if forced | +| `resizeTerminal` | `resizeTerminal(cols, rows, force = false)` | Skips duplicate `colsxrows` requests; server mode only | +| `search` | `search(term, skip, backward) => boolean` | Uses `SearchAddon.findNext` / `findPrevious` with the app's regex/whole-word/case settings. Returns `false` for an empty term. The `skip` argument is accepted but **never read** in the source — it always starts from the first match | +| `updateTheme` | `updateTheme(theme: object \| string)` | **Accepts a theme name string** and resolves it via the theme manager | +| `updateOptions` | `updateOptions(options)` | Writes keys onto both `terminal.options` and `this.options`; routes `theme` through `updateTheme` | +| `updateFontSize` | `updateFontSize(fontSize)` | Persists to `settings.terminalSettings.fontSize`, refreshes and re-fits. No-op if unchanged | +| `increaseFontSize` / `decreaseFontSize` | `()` | Clamped to 8–24 | +| `updateImageSupport` | `updateImageSupport(enabled)` | Loads/disposes `ImageAddon` | +| `updateFontLigatures` | `updateFontLigatures(enabled)` | Loads/disposes the ligatures addon | +| `updateScrollbarVisibility` | `updateScrollbarVisibility(visible)` | Toggles the scrollbar gutter via `overviewRuler.width` | +| `updateBackgroundColor` | `updateBackgroundColor()` | Syncs container + xterm background to the theme | +| `copySelection` / `pasteFromClipboard` | `()` | Clipboard helpers; silently no-op without `cordova.plugins.clipboard` | +| `mount` | `mount(container)` | Opens xterm, loads addons, first fit + focus | +| `createContainer` | `createContainer()` | Builds the `div.terminal-container` | +| `createSession` | `createSession() => Promise` | Throws in local mode. Returns the new PTY pid | +| `connectToSession` | `connectToSession(pid?)` | Throws in local mode. Creates a session if `pid` is omitted. Rejects after a 5 s connect timeout | +| `terminate` | `terminate() => Promise` | Closes the SSH shell or WebSocket, then `POST /terminals//terminate`. Sets `intentionalClose` | +| `dispose` | `dispose()` | `terminate()` + dispose addons, xterm, listeners and remove the container | +| `handleOscOpen` | `handleOscOpen(type, path)` | Invokes `onOscOpen`; Acode's handler is a no-op for remote SSH | +| `loadTerminalFont` | `loadTerminalFont() => Promise` | Injects + awaits the configured font | + +**Properties** + +| Property | Type | Notes | +| --- | --- | --- | +| `terminal` | `Terminal` (xterm) | Full xterm.js API. `onData` / `onResize` / `onTitleChange` / `onBell` return `{ dispose() }` | +| `pid` | `string \| null` | AXS pid, or `` `ssh:` `` for remote terminals | +| `isConnected` | `boolean` | | +| `serverMode` | `boolean` | | +| `remoteSsh` | `object \| null` | | +| `options` | `object` | The merged option set actually handed to xterm | +| `websocket` | `WebSocket \| null` | | +| `fitAddon`, `attachAddon`, `searchAddon`, `unicode11Addon`, `webLinksAddon`, `webglAddon`, `imageAddon`, `ligaturesAddon` | `object \| null` | Loaded conditionally | +| `touchSelection`, `touchScrolling` | `object \| null` | Mobile only, created after the first animation frame | +| `intentionalClose`, `processExited` | `boolean` | Lifecycle flags; read them before acting on `onDisconnect` | +| `container` | `HTMLElement` | | + +**Event callbacks** (assignable properties, already claimed by Acode): `onConnect()`, `onDisconnect(info)`, `onError(error)`, `onTitleChange(title)`, `onBell()`, `onProcessExit(exitData)`, `onOscOpen(type, path)`. + +- `onDisconnect` receives `{ intentional, processExited, code, reason }`. +- `onProcessExit` receives the AXS exit JSON (`{ exit_code, signal? }`) or `{ exit_code }` for SSH. Acode turns it into a toast such as `Process exited successfully (code 0)`. + ## Behavior & Lifecycle -- Installation flow: When serverMode is true (default), terminal checks if the backend is installed and supported. If missing, an installation terminal opens and streams progress. Creation proceeds only if install succeeds. -- IDs: If the backend provides a PID, it becomes the terminal id; otherwise a generated id like `terminal_1` is used. -- Tab: Each terminal is an EditorFile tab with an icon and custom title (PID or generated id). On process exit, the tab closes and a toast shows the exit status. +- Installation flow: When serverMode is true (default) and it is not a remote SSH terminal, the terminal checks `Terminal.isInstalled()` / `Terminal.isSupported()`. If missing, an installation terminal opens and streams progress. Creation proceeds only if `Terminal.install()` resolves `true`. +- IDs: If the backend provides a PID, it becomes the terminal id; otherwise a generated id like `terminal_1` is used. The counter is shared with installation terminals, which consume ids in the form `install_terminal_N`. +- Tab: Each terminal is an `EditorFile` tab with a `icon square-terminal` tab icon and a custom title (PID, SSH display name, or the terminal id). On process exit the tab closes and a toast shows the exit status. +- Session persistence: non-remote server terminals are stored in `localStorage` under `acodeTerminalSessions` as `{ pid, name, pinned }` and restored on next launch — but only while `Terminal.isAxsRunning()` reports the backend alive. +- Close confirmation: closing a tab asks for confirmation unless `settings.terminalSettings.confirmTabClose === false`. The forced removal inside `closeTerminal(id, true)` sets `_skipTerminalCloseConfirm`, but because `terminal.close(id)` passes `removeTab = false` it never reaches that path — a tab the user closes by hand after a plugin called `terminal.close(id)` still prompts. +- End-of-session convergence: process exit, unexpected disconnect and socket errors all funnel into one idempotent `finishTerminalSession()`, so a missed exit message cannot leave a zombie tab. ## Native Terminal Environment -Use the global `Terminal` object when you need to inspect or manage the underlying terminal runtime directly. +Use the global `Terminal` object when you need to inspect or manage the underlying terminal runtime directly. It is clobbered onto `window.Terminal` by the terminal plugin. -### `Terminal.isInstalled()` +| Method | Signature | Returns | +| --- | --- | --- | +| `isInstalled` | `isInstalled() => Promise` | `true` when `alpine/`, `.downloaded`, `.extracted` and `.configured` all exist under the app files dir | +| `isSupported` | `isSupported() => Promise` | `true` for `arm64-v8a`, `armeabi-v7a`, `x86_64` | +| `isAxsRunning` | `isAxsRunning() => Promise` | Checks the pid file and `kill -0` | +| `install` | `install(logger?, err_logger?) => Promise` | Runs the download/extract/configure pipeline; `false` on failure | +| `startAxs` | `startAxs(installing?, logger?, err_logger?, failsafe?) => Promise` | | +| `stopAxs` | `stopAxs() => Promise` | `kill -KILL` on the pid file | +| `backup` | `backup() => Promise` | Resolves the URI of `aterm_backup.tar` | +| `restore` | `restore() => Promise` | Resolves `"ok"` | +| `uninstall` | `uninstall() => Promise` | Resolves `"ok"`. Does **not** clean `$PREFIX` | +| `migrateLegacyHome` | `migrateLegacyHome() => Promise` | One-shot migration of old `alpine/home` + `alpine/root` into `public/MIGRATE` | +| `formatError` | `formatError(error) => string` | Normalises cordova/Error/string payloads for display | -Returns a `Promise` that resolves to `true` when the Alpine terminal environment has already been downloaded and extracted. +`lastInstallError` holds the most recent install failure message. ```js if (globalThis.Terminal) { @@ -153,23 +559,161 @@ if (globalThis.Terminal) { This is useful when a plugin needs to decide whether it can use terminal-backed features before opening a server terminal. For normal terminal creation, prefer `terminal.create()` or `terminal.createServer()`, because they already run the install flow when needed. +::: danger Do not call `install()`, `uninstall()` or `restore()` from a plugin +These mutate the user's sandbox, wipe their files, and `install()` is called automatically by `createServer()`. Use them only in a dedicated settings screen, and never on plugin load. +::: + ## Background Execution (No Terminal) Use the globally available `Executor` to run shell commands without opening a visual terminal session - one-off commands, long-running processes with streaming output, and background-mode execution. See [Executor](./executor.md). +## Gotchas + +- **`create()` defaults to server mode.** Forgetting `serverMode: false` triggers the whole Alpine install/AXS startup flow. Use `createLocal()` for output panes. +- **`theme` must be an object**, not a name string. See [the note above](#create). +- **`write()` goes into the PTY in server mode.** Anything you write is *input*. Include `\r` to submit, or you are just typing into the prompt. +- **`write()` is filtered; `component.write()` is not.** The security filter is only on the module wrapper. +- **`terminal.close(id)` does not close the tab.** Follow it with `file.remove(true, { ignorePinned: true })`. +- **Do not overwrite `component.onProcessExit` / `onDisconnect` / `onError`.** You will break Acode's session teardown. Chain instead. +- **`getAll()` returns the live internal `Map`.** Read-only by convention. +- **`get(unknownId)` is `null`, and `write`/`clear`/`close` silently no-op** rather than throwing. +- **All theme and touch-selection registrations are global and unscoped except by `_pluginId`.** Nothing is cleaned up when your plugin unmounts. +- **Closing the last terminal calls `Executor.stopService()`**, which unbinds and stops `TerminalService`. +- **Font, cursor, scrollback and theme defaults come from `settings.terminalSettings`**, not from your options, unless you pass them explicitly. `updateFontSize()` also *persists* to app settings. +- **Touch selection and touch scrolling only initialise on mobile** (`window.cordova` present), after the first animation frame. Never assume `component.touchSelection` exists. +- **Server terminals are recoverable across app restarts.** A plugin-created server terminal can reappear after a reload. Untrack your ids accordingly. + ## Example: Themed Output Terminal ```js const terminal = acode.require('terminal'); +const PLUGIN_ID = 'com.example.plugin'; // Ensure your theme exists (or use a built-in one) -terminal.themes.register('cyberpunk', { /* colors */ }, 'my-plugin'); +terminal.themes.register( + 'cyberpunkCustom', + { /* all 18 required colour keys */ }, + PLUGIN_ID, +); // Create a local output terminal and log -const out = await terminal.createLocal({ name: 'Plugin Output', theme: 'cyberpunk' }); -// Or -const out = await terminal.create({ name: 'Plugin Output', serverMode: false, theme: 'cyberpunk' }); +const out = await terminal.createLocal({ + name: 'Plugin Output', + theme: terminal.themes.get('cyberpunkCustom'), +}); terminal.write(out.id, '\u001b[36mPlugin initialized\u001b[0m\r\n'); ``` + +## Complete plugin example + +A runnable plugin that creates a local output terminal, sends a command to a server terminal, streams the result back, and cleans up on unmount. + +```js +// main.js +if (window.acode) { + const PLUGIN_ID = 'com.example.terminal-demo'; + const terminal = acode.require('terminal'); + const THEME_NAME = 'demoOutput'; + + const THEME = { + background: '#1a1a1a', + foreground: '#f8f8f2', + cursor: '#ff5555', + cursorAccent: '#1a1a1a', + selectionBackground: '#ffffff40', + black: '#000000', + red: '#ff5555', + green: '#50fa7b', + yellow: '#f1fa8c', + blue: '#bd93f9', + magenta: '#ff79c6', + cyan: '#8be9fd', + white: '#f8f8f2', + brightBlack: '#44475a', + brightRed: '#ff6e6e', + brightGreen: '#69ff94', + brightYellow: '#ffffa5', + brightBlue: '#d6acff', + brightMagenta: '#ff92df', + brightCyan: '#a4ffff', + brightWhite: '#ffffff', + }; + + // Theme must be registered BEFORE create*, because the options object is + // resolved once at construction time. + terminal.themes.register(THEME_NAME, THEME, PLUGIN_ID); + + let outputTerminal = null; + let serverTerminal = null; + + const write = (text, ansi = '') => { + if (!outputTerminal) return; + terminal.write(outputTerminal.id, `${ansi}${text}\u001b[0m\r\n`); + }; + + async function run() { + // 1. A local pane to render our own log lines into. + outputTerminal = await terminal.createLocal({ + name: 'Demo Output', + theme: terminal.themes.get(THEME_NAME), + }); + write('Local output terminal ready.', '\u001b[32m'); + + // 2. A server terminal: a real Alpine shell behind a PTY. + serverTerminal = await terminal.createServer({ + name: 'Demo Shell', + theme: terminal.themes.get(THEME_NAME), + }); + write(`Server terminal id: ${serverTerminal.id}`, '\u001b[36m'); + + // 3. Subscribe to raw xterm events. This is EventEmitter-style and + // disposable, and does not clobber Acode's own callbacks. + const subscription = serverTerminal.component.terminal.onData((data) => { + if (data === '\r') write('[shell] user pressed Enter'); + }); + + // 4. Chain Acode's exit hook instead of replacing it. + const previousExit = serverTerminal.component.onProcessExit; + serverTerminal.component.onProcessExit = function (data) { + previousExit.call(this, data); + write(`Shell exited: ${JSON.stringify(data)}`, '\u001b[31m'); + subscription.dispose(); + }; + + // 5. Execute a command in the shell. \r submits it. + terminal.write(serverTerminal.id, 'echo hello from a plugin\r'); + write('Command sent.', '\u001b[32m'); + } + + run().catch((error) => { + write(`Failed: ${error?.message || error}`, '\u001b[31m'); + }); + + // Touch selection: a "More" entry scoped to this plugin, removed on unmount. + const moreOptionId = terminal.moreOptions.add({ + id: `${PLUGIN_ID}.report`, + label: 'Report this terminal id', + action: (ctx) => { + console.log('Demo terminal:', serverTerminal?.id, 'selection:', ctx.selection); + }, + }); + + acode.setPluginUnmount(PLUGIN_ID, () => { + terminal.moreOptions.remove(moreOptionId); + terminal.themes.unregister(THEME_NAME, PLUGIN_ID); + + for (const instance of [serverTerminal, outputTerminal]) { + if (!instance) continue; + // close() disposes the session; file.remove() actually closes the tab. + terminal.close(instance.id); + instance.file.remove(true, { ignorePinned: true }); + } + }); +} +``` + +::: tip Use `Executor` instead when you do not need a tab +`terminal.write()` is filtered and drives a visible PTY. For a one-off command or a background process, [Executor](./executor.md) has no filter and no UI cost. +::: \ No newline at end of file diff --git a/docs/advanced-apis/webview.md b/docs/advanced-apis/webview.md index feba94c..08167a4 100644 --- a/docs/advanced-apis/webview.md +++ b/docs/advanced-apis/webview.md @@ -2,6 +2,8 @@ The `webview` module exposes Acode's native WebView API. Require it with `acode.require('webview')` to display web content in a fullscreen view or run a headless (hidden) WebView in the background, and communicate with the loaded page over a two-way messaging bridge. +Verified against Acode **v1.13.5** (versionCode `1011`): `src/lib/webview.js` (the module), `src/plugins/webview/www/webview.js` (the Cordova bridge) and `src/plugins/webview/src/android/com/foxdebug/webview/{WebViewPlugin,WebViewInstance,WebViewActivity}.java` (the native side). Registered in `src/lib/acode.js:440` as `this.define("webview", webview)`. + ## Import ```js @@ -10,30 +12,39 @@ const webview = acode.require('webview'); ## API Overview -The module exposes a single method: +The module is a plain object with exactly **one** member: + +- `create(options)`: Creates a new WebView instance. Returns `Promise`. -- `create(options)`: Creates a new WebView instance. Resolves to a WebView instance object. +There are no module-level singletons, no `close()`, no `get()` and no event emitter on the module itself. All state lives on the returned instance. -Each instance exposes these methods: +Each instance exposes these methods (all of them `async` except `onMessage`, `offMessage`, `on` and `off`): -- `loadURL(url)`: Loads a URL. Only `http://` and `https://` URLs are allowed; input without a scheme (e.g. `example.com`) is loaded over `https`. -- `loadHTML(html)`: Loads an HTML string directly. -- `evaluate(js)`: Evaluates JavaScript in the page and resolves with the result. -- `postMessage(message)`: Sends a message to the page. Non-string values are JSON-stringified. -- `onMessage(callback)`: Registers a callback for messages sent from the page. -- `offMessage(callback)`: Removes a previously registered message callback. -- `on(event, callback)`: Registers a listener for lifecycle events (see Events). -- `off(event, callback)`: Removes a previously registered event listener. -- `show()`: Shows a fullscreen WebView (or brings it back to the front after `hide()`). -- `hide()`: Moves a fullscreen WebView to the background, keeping its page state intact. -- `reload()`: Reloads the current page. -- `destroy()`: Destroys the instance and releases its resources. **You must call this when the WebView is no longer needed.** +| Member | Signature | Returns | +| --- | --- | --- | +| `id` | `string` | Native instance id, `wv_` + the first 12 hex characters of a UUID (`wv_1a2b3c4d5e6f`). | +| `options` | `object` | The options object you passed to `create()`, verbatim. | +| `loadURL(url)` | `(url: string) => Promise` | — | +| `loadHTML(html)` | `(html: string) => Promise` | — | +| `evaluate(js)` | `(js: string) => Promise` | The value of the expression, JSON-decoded. | +| `postMessage(message)` | `(message: unknown) => Promise` | — | +| `onMessage(callback)` | `(callback: (message: unknown) => void) => void` | — | +| `offMessage(callback)` | `(callback: (message: unknown) => void) => void` | — | +| `on(event, callback)` | `(event: string, callback: (event: string, data: object \| undefined) => void) => void` | — | +| `off(event, callback)` | `(event: string, callback: Function) => void` | — | +| `show()` | `() => Promise` | — | +| `hide()` | `() => Promise` | — | +| `reload()` | `() => Promise` | — | +| `destroy()` | `() => Promise` | — | -> [!Warning] -> Always call `destroy()` once the WebView is no longer required. Every instance holds a native WebView, so leaving instances alive leaks memory and keeps pages (and their scripts/timers/network activity) running in the background. +::: danger Always call `destroy()` once the WebView is no longer required +Every instance holds a native WebView, so leaving instances alive leaks memory and keeps pages (and their scripts/timers/network activity) running in the background. Instances are **not** tied to your plugin's lifecycle — Acode only force-destroys them when the whole Cordova plugin is torn down (`WebViewPlugin.onDestroy()`), which is process teardown, not plugin unload. Destroy them in `acode.setPluginUnmount(id, ...)`. + +Calling `destroy()` **twice** throws `WebView has been destroyed` (there is no idempotence guard on the JS side). +::: > [!Note] -> After an instance is destroyed (via `destroy()` or by the user closing a fullscreen WebView), calling any method on it throws `WebView has been destroyed`. +> After an instance is destroyed — by `destroy()` or by the user closing a fullscreen WebView — calling `loadURL`, `loadHTML`, `evaluate`, `postMessage`, `show`, `hide`, `reload`, `destroy`, `onMessage` or `on` throws `WebView has been destroyed`. `offMessage()` and `off()` are the exceptions: they do not check the destroyed flag and simply filter their (already emptied) lists. ## Create @@ -56,22 +67,25 @@ await deferred.loadURL('https://example.com'); await deferred.show(); ``` +`create()` is synchronous until it reaches the native bridge, so it **throws synchronously** (not a rejected promise) on a bad mode: + +``` +Unsupported WebView mode: "". Use "fullscreen" or "hidden". +``` + ### WebViewOptions -Options accepted by `create()`: +Only these five options are read. Anything else you pass is ignored by the native side (it is still readable on `instance.options`): -- `mode`: `'fullscreen'` or `'hidden'` (default `'hidden'`). Fullscreen opens the WebView in its own activity; hidden creates a headless WebView that is never displayed. -- `title`: Title shown in the fullscreen activity. -- `allowNavigation`: Boolean, whether the page is allowed to navigate (default `true`). When `false`, all in-page navigation is blocked. -- `allowDownloads`: Boolean, enables downloads (default `false`). Downloads are confirmed with a dialog and saved via the system DownloadManager into the public Downloads directory. -- `visible`: Boolean, whether a fullscreen WebView is shown immediately (default `true`). When `false`, the launch is deferred until `show()` is called. +- `mode`: `'fullscreen'` or `'hidden'` (default `'hidden'`). Fullscreen hosts the WebView in its own Android activity; hidden creates a headless WebView that is created immediately and never displayed. +- `title`: Set as the hosting activity's title. Only applied when non-empty. +- `allowNavigation`: Boolean, default `true`. When `false`, every **page-initiated** navigation is blocked. It does **not** affect `loadURL()` / `loadHTML()`, which call `WebView.loadUrl()` / `loadDataWithBaseURL()` directly and therefore bypass `shouldOverrideUrlLoading()` entirely. +- `allowDownloads`: Boolean, default `false`. When `true`, a `DownloadListener` is installed. Each download shows a confirm dialog and is then saved by the system `DownloadManager` into the public Downloads directory, with the WebView's cookies forwarded. Non-`http(s)` download URLs are refused with a toast. +- `visible`: Boolean, default `true`. Only meaningful for `mode: 'fullscreen'`: when `false`, the hosting activity launch is deferred until you call `show()`. ### Return Value -`create()` resolves to an instance object with: - -- `id`: Unique id (e.g. `wv_1a2b3c4d5e6f`). -- Plus the instance methods listed in API Overview. +`create()` resolves to a `WebView` instance whose only own data properties are `id`, `options`, `_messageCallbacks`, `_eventCallbacks`, `_destroyed` and `_destroyPromise`. Treat everything except `id` as internal. ## Manage @@ -88,6 +102,11 @@ await view.reload(); await view.destroy(); ``` +`evaluate()` results go through `JSONTokener`, so a string expression comes back as a JS string (quotes stripped, escapes intact) and a missing value comes back as `null`. + +> [!Note] +> `show()` on a `mode: 'hidden'` instance **rejects** with `Hidden WebViews cannot be shown; use mode "fullscreen" to display content`. `hide()` on a hidden instance resolves as a no-op, because it is already hidden. + ## Messaging The plugin and the page can exchange messages over a two-way bridge. A `window.webview` object is injected into every loaded page: @@ -108,12 +127,18 @@ view.onMessage((msg) => { await view.postMessage({ fromPlugin: true }); ``` -- Messages can be strings or any JSON-serializable value. JSON payloads are parsed automatically on both sides; anything else arrives as a raw string. -- The bridge is re-injected after every navigation, so `window.webview` is always available to the current page. -- Use `offMessage(callback)` on either side to unsubscribe. +- Messages can be strings or any JSON-serializable value. `postMessage()` on the plugin side stringifies non-strings before crossing the bridge; on the page side `postMessage()` stringifies and then calls the native interface with `String(data)`. The receiving end tries `JSON.parse` first and falls back to the raw string, so a plain string arrives as a string **unless it happens to look like JSON**. +- The bridge is injected twice per navigation — best-effort on `onPageStarted` (before page scripts in most cases) and guaranteed on `onPageFinished`. Injection is guarded by `window.webview.__acodeBridge`, so callbacks registered between the two injections survive. +- The page-side object has exactly `onMessage(cb)`, `offMessage(cb)`, `postMessage(msg)` (plus the internal `__acodeBridge` flag and `_dispatch(msg)` helper). +- Use `offMessage(callback)` on either side to unsubscribe. On the plugin side it filters by reference identity. +- `onMessage(cb)` and `on(event, cb)` silently ignore a non-function argument. + +::: warning A numeric-looking string arrives as a number +Because both ends try `JSON.parse` first, `view.postMessage('123')` reaches the page as the **number** `123`, and a page calling `window.webview.postMessage('true')` reaches your plugin as the **boolean** `true`. Wrap non-JSON payloads in an object (`{ type: 'text', value: '123' }`) when the type matters. +::: > [!Note] -> Fullscreen instances create the native WebView lazily, so `postMessage()`, `evaluate()` and `reload()` reject with `WebView is not ready` until a page exists. `loadURL()` and `loadHTML()` called early are queued and applied once the WebView is created. +> Fullscreen instances create the native WebView lazily, inside the hosting activity. Until it exists, `postMessage()`, `evaluate()` and `reload()` reject with `WebView is not ready`, while `loadURL()` and `loadHTML()` are queued in `pendingUrl` / `pendingHtml` and applied the moment the WebView is created. Hidden instances create their WebView immediately, so they never hit this. ## Events @@ -133,19 +158,159 @@ view.on('closed', () => { }); ``` -- `pageFinished`: A page finished loading. Data: `{ url, title }`. -- `titleChanged`: The page title changed. Data: `{ title }`. -- `closed`: A fullscreen WebView was closed by the user (back button/task removal). The instance is marked destroyed and further calls on it fail fast. +| Event | `data` | Raised when | +| --- | --- | --- | +| `pageFinished` | `{ url, title }` (empty strings when the native getters return `null`) | The page finished loading. | +| `titleChanged` | `{ title }` | `WebChromeClient.onReceivedTitle` fired. | +| `closed` | `undefined` | The hosting activity was destroyed — the user pressed back with no history left, or removed the task. | -Use `off(event, callback)` to remove a listener. +- Only these three event names are ever emitted. `on()` accepts any string but nothing else will fire. +- On `closed`, the instance is marked destroyed and dropped from the JS instance map, and **all** message and event callbacks are cleared. +- `WebViewActivity.onDestroy()` fires `closed` **after** it has already destroyed the instance natively — so by the time your callback runs, the WebView is gone. +- Use `off(event, callback)` to remove a listener (matched on both event name and callback identity). ## Behavior & Lifecycle - Modes: `fullscreen` hosts the WebView in its own activity; `hidden` is headless and never displayed, useful for background automation or scraping. -- Back button: In fullscreen mode it navigates back through page history first; when nothing is left, the WebView closes and the `closed` event fires. -- Hide/Show: `hide()` backgrounds the fullscreen activity without destroying it, so `show()` restores it with the page state intact. -- Cleanup: Instances are not tied to your plugin's lifecycle. Destroy every instance you create — ideally in your plugin's `destroy()` function — so hidden WebViews don't outlive the plugin. -- Security: Hosted content is isolated. File and content scheme access is disabled, only `http(s)` URLs can load, and non-http(s) navigation (`file:`, `intent:`, `javascript:`, `tel:`, ...) is always blocked. When `allowNavigation` is `false`, all navigation is blocked. +- Back button: in fullscreen mode `WebViewActivity.onBackPressed()` calls `webView.goBack()` while there is history; when there is none, the activity finishes, which destroys the instance and fires `closed`. +- Hide/Show: `hide()` calls `moveTaskToBack(true)` on the hosting activity, so nothing is destroyed and `show()` re-launches it with `FLAG_ACTIVITY_REORDER_TO_FRONT`. `show()` is therefore also the way to bring an instance that was backgrounded by the user back to the front. +- Show on an already-visible fullscreen instance is harmless: it re-launches the same activity with `REORDER_TO_FRONT` rather than creating a second WebView, and `createWebView()` is idempotent. +- Activity recreation (rotation, locale change) reuses the existing native WebView rather than leaking a new one. +- Cleanup: instances are not tied to your plugin's lifecycle. Destroy every instance you create — ideally in `acode.setPluginUnmount()` — so hidden WebViews don't outlive the plugin. + +### Relationship with the Action Stack + +A fullscreen WebView lives in a **separate Android activity**, so it does not participate in Acode's JavaScript action stack at all. `acode.require("actionStack")` entries are untouched by showing a WebView, and the Android back button inside the WebView activity is handled by `WebViewActivity.onBackPressed()`, never by `actionStack.pop()`. `hide()` moving the task to the back is likewise invisible to the action stack. + +::: tip Pair them yourself when you need back navigation +Push your own action-stack entry so that, after `hide()`, Acode's back button returns to your page instead of exiting the app: + +```js +const actionStack = acode.require('actionStack'); + +actionStack.push({ + id: 'my-plugin-browser', + action() { + view.hide(); // task goes to the back, page state preserved + }, +}); +``` + +The WebView is not registered for you, so `actionStack.remove('my-plugin-browser')` is also yours to do when you `destroy()` it. +::: + +### Relationship with the browser plugin + +`webview` has nothing to do with `cordova-plugin-browser` or the Custom Tabs plugin. Those are separate Cordova plugins that Acode uses internally (`src/lib/customTab.ts`, `src/plugins/browser/**`, `src/plugins/custom-tabs/**`); none of them is registered through `Acode#define`, so `acode.require("browser")` and `acode.require("customTab")` are both `undefined`. If you need an in-app browser inside a plugin page, use `acode.require("webview")` with `mode: "fullscreen"`. + +### Security + +Everything below is enforced natively in `WebViewInstance.java`, not in JavaScript, so it applies no matter what the page does. + +| Control | Implementation | +| --- | --- | +| Only `http(s)` may load | `sanitizeUrl()` rejects any URL whose `scheme://` prefix is not `http` or `https` with `Blocked URL: only http:// and https:// URLs are allowed`. Input without a `scheme://` prefix is treated as a host and loaded as `https://`. | +| Only `http(s)` may navigate | `shouldOverrideUrlLoading()` blocks everything when `allowNavigation` is `false`, and otherwise blocks any target whose scheme is not `http`/`https` — so `file:`, `content:`, `intent:`, `javascript:`, `tel:` and scheme-less targets are refused. This hook only sees page-initiated navigations. | +| No file or content access | `setAllowFileAccess(false)`, `setAllowContentAccess(false)`, `setAllowFileAccessFromFileURLs(false)`, `setAllowUniversalAccessFromFileURLs(false)`. | +| JavaScript and DOM storage | `setJavaScriptEnabled(true)` and `setDomStorageEnabled(true)` — the page is fully scriptable by design, since `evaluate()` and the message bridge depend on it. | +| Plugin-to-page messages are injection-safe | `postMessage()` wraps the payload in `JSONObject.quote()` before evaluating it, so a hostile string cannot break out of the JS string literal. | +| Downloads | Opt-in via `allowDownloads`, http(s) only, user-confirmed per file. | + +::: warning `window.AcodeWebViewNative` is always present on the page +The native JavaScript interface is added unconditionally (`addJavascriptInterface(new JsBridge(), "AcodeWebViewNative")`) and exposes a single `postMessage(String)`. `window.webview` is only a convenience wrapper around it, and any script in the page — including one you did not write — can call the raw interface. Treat everything you send through the bridge as visible to the page, and do not put secrets in it. The reverse direction is contained by the native side: the page cannot invoke plugin methods, only post a message. +::: + +::: warning `loadHTML()` gives your page an opaque origin +`loadHTML()` uses `loadDataWithBaseURL(null, html, "text/html", "UTF-8", null)`, so the document origin is `null`. Relative URLs resolve against nothing, `localStorage`/`indexedDB` are unavailable or partitioned per-load, and `fetch()` to a same-origin API will fail the same-origin check. If you need storage or a real origin, serve the page over `http(s)` and use `loadURL()`. +::: + +## Example: two-way bridge + +```js +const webview = acode.require('webview'); + +const view = await webview.create({ + mode: 'fullscreen', + title: 'My Plugin Console', + allowNavigation: false, +}); + +view.onMessage((msg) => { + if (!msg || typeof msg !== 'object') return; + + switch (msg.type) { + case 'ready': + // The page booted and the bridge is installed. + view.postMessage({ type: 'config', theme: 'dark', features: ['logs'] }); + break; + + case 'log': + console.log('[page]', msg.level, msg.message); + break; + + case 'run': + // Never trust the page: `code` arrives from remote content. + handleRemoteCode(String(msg.code ?? '')); + break; + + default: + console.warn('unknown message', msg); + } +}); + +view.on('pageFinished', (_event, data) => { + console.log('loaded', data.url, data.title); +}); + +view.on('closed', () => { + console.log('closed by the user'); +}); + +await view.loadHTML(` + + + + +`); + +async function handleRemoteCode(code) { + // Never eval() remote content. Open it as an unsaved tab instead. + const EditorFile = acode.require('EditorFile'); + new EditorFile('from-webview.js', { + text: code, + isUnsaved: true, + render: true, + }); +} + +// Always clean up. +acode.setPluginUnmount('com.example.plugin', async () => { + await view.destroy(); +}); +``` ## Example: Headless Title Fetcher diff --git a/docs/editor-components/editor-file.md b/docs/editor-components/editor-file.md index d69eb9c..7f5c0e4 100644 --- a/docs/editor-components/editor-file.md +++ b/docs/editor-components/editor-file.md @@ -12,23 +12,34 @@ This API is defined in the [Acode source code (src/lib/editorFile.js)](https://g const EditorFile = acode.require('editorFile'); ``` +`acode.require` lower-cases the module name, so `acode.require('EditorFile')` and `acode.require('editorFile')` return the same class. + +::: warning +The manager is a global, not a module: `window.editorManager`. `acode.require('editorManager')` returns `undefined`. See [EditorManager](../global-apis/editor-manager.md). +::: + ## Constructor ```js new EditorFile(filename, options) ``` +Both arguments are optional. `new EditorFile()` creates the app's default empty tab. + ::: info -You can also use [`acode.newEditorFile(filename, options)`](../global-apis/acode.md#neweditorfilefilename-string-options-fileoptions-editorfile) as an alternative. -Both methods are equivalent and accept & return the same parameters. +You can also use [`acode.newEditorFile(filename, options)`](../global-apis/acode.md#neweditorfile-filename-string-options-fileoptions-void) as an alternative — but note that it **returns `undefined`**; it only constructs the file. Use `new EditorFile(...)` when you need the instance back. ::: ### Parameters | Parameter | Type | Description | Default | |-----------|------|-------------|---------| -| filename | `string` | Name of the file | - | -| options | [`FileOptions`](#fileoptions) | File creation options | - | +| filename | `string` | Name of the file | `"untitled.txt"` | +| options | [`FileOptions`](#fileoptions) | File creation options | `undefined` | + +::: warning +If a file with the same `id` (or `uri`) is already open, the constructor **does not create a second tab** — it activates the existing one and returns early. Look the file up first with `editorManager.getFile(uri, 'uri')` if that matters. +::: ### FileOptions @@ -36,28 +47,39 @@ Both methods are equivalent and accept & return the same parameters. |----------|------|-------------|---------| | isUnsaved | `boolean` | Whether file needs to be saved | `false` | | render | `boolean` | Make file active | `true` | -| id | `string` | ID for the file | - | +| id | `string` | ID for the file | `uri.hashCode()` or a fresh UUID | | uri | `string` | URI of the file | - | -| text | `string` | Session text | - | -| editable | `boolean` | Enable file editing | `true` | +| text | `string` | Session text. Also marks the file as already loaded | - | +| editable | `boolean` | Enable file editing | `!readOnly` | +| readOnly | `boolean` | Open the file as read-only (inverse of `editable`) | `false` | | deletedFile | `boolean` | File does not exist at source | `false` | | SAFMode | `'single' \| 'tree'` | Storage access framework mode | - | | encoding | `string` | Text encoding | `appSettings.value.defaultFileEncoding` | -| cursorPos | `object` | Cursor position | - | -| scrollLeft | `number` | Scroll left position | - | -| scrollTop | `number` | Scroll top position | - | +| cursorPos | `{ ranges: [{ from, to }], mainIndex: number }` | Restored selection | - | +| scrollLeft | `number` | Scroll left position | `0` | +| scrollTop | `number` | Scroll top position | `0` | | folds | `Array<{ fromLine: number, fromCol: number, toLine: number, toCol: number }>` | Code folds | - | -| type | `string` | Type of content (e.g., 'editor') | `'editor'` | +| type | `string` | Type of content (e.g. `'editor'`, `'custom'`, `'terminal'`, `'image'`, `'video'`, `'audio'`) | `'editor'` | | tabIcon | `string` | Icon class for the file tab | `'file file_type_default'` | -| content | string \| [HTMLElement](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement) | Custom content element or HTML string. Strings are sanitized using DOMPurify | - | -| stylesheets | `string\|string[]` | Custom stylesheets for tab. Can be URL, or CSS string | - | -| highlightStyles | `boolean` | Adopt the static CodeMirror highlight stylesheet into this custom tab's shadow root. Use only when the tab will render `codeHighlight` HTML. Available from **versionCode `1008`** | `false` | +| content | `string` \| `HTMLElement` | Custom content element or HTML string. For non-editor types (other than `terminal`) the content is mounted inside a Shadow DOM and strings are sanitized using DOMPurify | - | +| stylesheets | `string` \| `string[]` | Custom stylesheets for tab. Entries starting with `http` or `/` become ``, anything else becomes an inline `); +document.body.setAttribute('theme-type', registered.type); +$style.textContent = registered.css; +if (!$style.isConnected) document.head.append($style); + +// Snapshot it — this is the shape ThemeBuilder.fromJSON() accepts +const saved = theme.toJSON(); // { name, type, version, PrimaryColor, ... } +const restored = ThemeBuilder.fromJSON(saved); +console.log(restored.css === theme.css); // true + +// Pick readable text automatically +theme.primaryTextColor = Color(theme.primaryColor).isDark ? '#FFFFFF' : '#121212'; +``` + ### Best Practices - Choose a consistent color palette - Ensure sufficient contrast between text and background @@ -96,55 +275,95 @@ myCustomTheme.borderColor = "#333333"; ### Supported CSS Custom Properties -The ThemeBuilder generates the following CSS custom properties: -- `--primary-color` -- `--secondary-color` -- `--text-color` -- `--background-color` +The ThemeBuilder generates these 25 custom properties, all under `:root`: + +- `--popup-border-radius` - `--active-color` -- `--button-background-color` +- `--active-text-color` +- `--active-icon-color` - `--border-color` -- And many more... +- `--box-shadow-color` +- `--button-active-color` +- `--button-background-color` +- `--button-text-color` +- `--error-text-color` +- `--success-text-color` +- `--primary-color` +- `--primary-text-color` +- `--secondary-color` +- `--secondary-text-color` +- `--link-text-color` +- `--scrollbar-color` +- `--popup-border-color` +- `--popup-icon-color` +- `--popup-background-color` +- `--popup-text-color` +- `--popup-active-color` +- `--danger-color` +- `--danger-text-color` +- `--file-tab-width` + +::: info Two of those are not colours +`--popup-border-radius` (`4px`) and `--file-tab-width` (`120px`) are lengths. There is no `--text-color` and no `--background-color`. +::: ### Notes - Always import the ThemeBuilder from the `acode` library - Theme customization is flexible and supports both light and dark modes - You can override default styles for specific UI components - for theme management check the `themes`documentation -======= -**Theme Builder** - -**Introduction** - -To create a new theme for your application, you'll need to utilize the `ThemeBuilder` class provided by the `acode` library. This class offers a straightforward way to customize various aspects of your theme, from primary and secondary colors to font styles and more. - -**Basic Usage** - -1. **Import the `ThemeBuilder` class:** - ```javascript - const ThemeBuilder = acode.require('themeBuilder'); - ``` -2. **Create a new theme instance:** - ```javascript - const myTheme = new ThemeBuilder("MyDarkTheme", "dark"); - ``` - * **Theme Name:** The first argument, `"MyDarkTheme"`, is the name of your theme. It should be a descriptive name that reflects the theme's style. - * **Theme Mode:** The second argument, `"dark"`, specifies the base mode of the theme (either "light" or "dark"). - -3. **Customize theme properties:** - ```javascript - myTheme.primaryColor = "#333"; - myTheme.secondaryColor = "#666"; - // ... other theme property customizations - ``` - You can customize various theme properties, such as: - * `primaryColor` - * `secondaryColor` - * `textColor` - * `backgroundColor` - * `fontFamily` - * `fontSize` - * `fontWeight` - * // ... and many more +### Gotchas + +::: danger `ThemeBuilder.fromCSS()` always throws +Two separate failure paths: + +```javascript +static fromCSS(name, css) { + const themeBuilder = new ThemeBuilder(name); + const rules = css.match(/:root\s*{([^}]*)}/); + if (!rules) throw new Error("Invalid CSS string"); // (a) + const variables = rules[1].match(/--[\w-]+:\s*[^;]+/g); + if (!variables) throw new Error("Invalid CSS string"); // (b) + variables.forEach((variable) => { + const [key, value] = variable.split(":"); + themeBuilder(ThemeBuilder.#toPascal(key.trim()), value.trim()); // (c) ← not callable + }); + return themeBuilder; +} +``` + +If the CSS has no `:root { … }` block you get `Error("Invalid CSS string")`. If it has one but no custom properties you get the same error. If it parses, line (c) calls the **instance** as a function, which throws `TypeError: themeBuilder is not a function`. There is no input for which `fromCSS()` returns a builder. +::: + +::: danger `toJSON("rgba")` throws +The `"rgba"` branch calls `Color(value).rgba.toString()`, but [`Color`](./color.md) has no `rgba` getter — it only defines `rgb`, `hex` and `hsl`. The call throws `TypeError: Cannot read properties of undefined (reading 'toString')`. Use the default `toJSON()` or `toJSON("hex")`. +::: + +::: danger `toJSON("hex")` corrupts the non-colour values +`--popup-border-radius: 4px` and `--file-tab-width: 120px` are not colours. Running them through `Color()` hands an unparseable string to the canvas, which keeps its previous fill style and yields a plausible-looking colour instead of the length. After `toJSON("hex")` those two keys hold garbage. The app itself only ever calls `toJSON("hex")` to feed `system.setUiTheme()`, so this never shows up in the UI. +::: + +::: warning `darkenedPrimaryColor` is not a real accessor +There is no `get darkenedPrimaryColor()` / `set darkenedPrimaryColor()` on the prototype. Assigning to it creates an ordinary own property that is **not** a CSS variable and is **not** part of `css` or `toJSON()`. That is deliberate: it is the value Acode hands to the native `system.setUiTheme()` to darken the status and navigation bars while a modal mask is up. Pre-installed themes set it by hand (for example `oled.darkenedPrimaryColor = "rgb(0, 0, 0)"`), and `darkenPrimaryColor()` recomputes it on demand. +::: + +::: warning `autoDarkened` does not darken `--primary-color` +Setting `theme.primaryColor = '#2196F3'` stores `#2196F3` in `--primary-color` verbatim. The only side effect is that `darkenedPrimaryColor` is recomputed as `Color('#2196F3').darken(0.4)`. The built-in themes turn `autoDarkened` off because they set `darkenedPrimaryColor` explicitly. +::: + +::: warning `--danger-text-color` has no accessor +It is present in the default variable map and therefore appears in `css` and `toJSON()` as `DangerTextColor`, but the class declares no getter/setter for it. Assigning `theme.dangerTextColor = …` silently creates an inert own property, and `fromJSON()` skips it because there is no descriptor on the prototype. It stays at its default, `rgb(255, 255, 255)`. +::: + +::: warning `ThemeBuilder` is not a global +`acode.require('themeBuilder')` is the only way in. Register the result with [`themes.add()`](./themes.md) — the module stores `instanceof ThemeBuilder` objects only. +::: + +### See also +- [Themes](./themes.md) — the registry that consumes `ThemeBuilder` instances. +- [Fonts](./fonts.md) — what `preferredFont` resolves against. +- [Color API](./color.md) — the helper behind `darkenPrimaryColor()` and `toJSON("hex")`. +- [Editor Themes](../utilities/editor-themes.md) — the separate CodeMirror theme registry, for `preferredEditorTheme`. +- [`acode.require()`](../global-apis/acode.md) — how modules are resolved. diff --git a/docs/helpers/themes.md b/docs/helpers/themes.md index 75930c7..1c55294 100644 --- a/docs/helpers/themes.md +++ b/docs/helpers/themes.md @@ -2,43 +2,231 @@ Acode provides a flexible and intuitive module for managing themes, enabling developers to seamlessly add, retrieve, update, and list themes within their project. +:::info Verified against Acode v1.13.5 +Every signature below is taken from `src/theme/list.js` and the plugin-facing wrapper in `src/lib/acode.js`. +::: + ## API Overview ```javascript const themes = acode.require('themes'); ``` +::: warning `themes` is not a global +It is only reachable through [`acode.require()`](../global-apis/acode.md). +::: + +## App themes vs editor themes + +This module manages **app / UI themes**: a `:root { --custom-property: value; … }` block of CSS custom properties that restyles Acode's chrome, dialogs, popups and scrollbars. + +It is a **completely different registry** from the CodeMirror syntax themes you register with `acode.require('editorThemes')`. + +| | `acode.require('themes')` | `acode.require('editorThemes')` | +| --- | --- | --- | +| Styles | Acode's own UI (pages, dialogs, quick tools, native system bars) | The code editor's syntax colouring only | +| Object you pass | A `ThemeBuilder` **instance** | A plain spec object `{ id, caption?, dark?, getExtension \| extensions }` | +| Registry method | `add(builder)` | `register(spec)` | +| Keys | `name.toLowerCase()` | The `id` you pass | +| Apply | **Deprecated no-op** — see below | `editorThemes.apply(id)` | + +See [Editor Themes](../utilities/editor-themes.md) for the editor-side API. A theme plugin normally registers with **both**. + ## Methods +The module object handed to plugins has exactly five members. + +| Method | Signature | Returns | +| --- | --- | --- | +| `add` | `add(theme: ThemeBuilder)` | `undefined` | +| `get` | `get(name: string)` | `ThemeBuilder \| undefined` | +| `list` | `list()` | `Array` | +| `update` | `update(theme: ThemeBuilder)` | `undefined` | +| `apply` | `apply(id: string, init?: boolean)` | `undefined` — **does nothing** | + ### `add(theme: ThemeBuilder)` Adds a new theme to the theme collection. **Parameters:** -- `theme` (required): An instance of ThemeBuilder defining the theme's properties +| Name | Type | Required | Default | Description | +| --- | --- | --- | --- | --- | +| `theme` | `ThemeBuilder` | Yes | — | An **instance** of [`ThemeBuilder`](./theme-builder.md). Anything else is silently ignored | + +Returns: `undefined`. + +There is no success/failure signal. The call bails out silently — again with `undefined` — when: + +1. `theme` is not `instanceof ThemeBuilder`. A plain object literal, however perfect, is dropped. +2. A theme with that `id` is already registered. The registry key is `theme.id`, which is `theme.name.toLowerCase()`. + +Otherwise the theme is stored, and if its `id` matches the currently selected `settings.value.appTheme`, the app immediately applies it. That last step works even though `themes.apply` is a no-op for plugins, because `add()` calls the internal applier directly. **Example:** ```javascript +const ThemeBuilder = acode.require('themeBuilder'); +const themes = acode.require('themes'); + const theme = new ThemeBuilder('Modern Dark', 'dark'); +theme.primaryColor = 'rgb(33, 150, 243)'; themes.add(theme); ``` +::: warning Theme ids are the lower-cased name +`new ThemeBuilder('Modern Dark')` registers under `modern dark`. `get('modern dark')` and `get('Modern Dark')` both work because `get()` lower-cases its argument, but two themes whose names differ only in case collide. +::: + +::: warning `version` gates whether the theme is usable +A theme with `version: 'paid'` is replaced by `default` on a build without Pro, and the selection is silently rewritten to `default`. `new ThemeBuilder(name, type)` defaults `version` to `'free'`, which is what you want for a plugin theme. +::: + ### `get(name: string)` Retrieves a specific theme by its name. **Parameters:** -- `name` (required): The unique name of the theme to retrieve +| Name | Type | Required | Default | Description | +| --- | --- | --- | --- | --- | +| `name` | `string` | Yes | — | The theme name; lower-cased before the lookup | -**Returns:** -- ThemeBuilder instance representing the requested theme +**Returns:** the live `ThemeBuilder` instance, or `undefined` when no theme has that id. + +Because it returns the **registered instance**, mutating it mutates the theme in place: -**Example:** ```javascript const theme = themes.get('Modern Dark'); +if (theme) { + theme.primaryColor = 'rgb(0, 122, 255)'; + theme.activeColor = 'rgb(0, 122, 255)'; +} +``` + +### `list()` + +Returns a summary of every registered theme. + +**Returns:** `Array` of plain objects: + +| Field | Type | Description | +| --- | --- | --- | +| `id` | `string` | `name.toLowerCase()` | +| `name` | `string` | The theme name, **title-cased** by `String.prototype.capitalize()` — `"modern dark"` comes back as `"Modern Dark"` | +| `type` | `string` | `"light"` or `"dark"` | +| `version` | `string` | `"free"` or `"paid"` | +| `primaryColor` | `string` | The theme's `--primary-color` value | + +Note this is a **summary, not the theme**: the returned objects have no `css`, no `toJSON()` and no accessors. Use `get(theme.id)` when you need the builder. + +```javascript +themes.list().forEach(({ id, name, type }) => { + console.log(`${id} — ${name} (${type})`); +}); ``` ### `update(theme: ThemeBuilder)` Updates an existing theme in the theme collection. +**Parameters:** +| Name | Type | Required | Default | Description | +| --- | --- | --- | --- | --- | +| `theme` | `ThemeBuilder` | Yes | — | A `ThemeBuilder` **instance**. Non-instances are ignored | + +**Returns:** `undefined`. + +Two distinct behaviours: + +- **Not registered yet** → it delegates to `add(theme)`, so an unknown id is created rather than updated. +- **Already registered** → every key of `theme.toJSON()` is copied onto the existing builder, which means it goes through the accessors and updates the underlying CSS variables in place. `name`, `type` and `version` are copied as plain fields. + +Because `toJSON()` only returns the theme's own colour variables plus `name`/`type`/`version`, non-colour extras you attach to a builder (`preferredFont`, `darkenedPrimaryColor`, …) are **not** copied by `update()`. + +### `apply(id, init?)` + +::: danger `apply` is a no-op +The plugin-facing module replaces it with an empty arrow function: + +```javascript +const themesModule = { + add: themes.add, + get: themes.get, + list: themes.list, + update: themes.update, + // Deprecated, not supported anymore + apply: () => {}, +}; +``` + +Calling `themes.apply('modern dark')` returns `undefined` and changes nothing — no CSS, no settings, no repaint. Use [`Settings`](../editor-components/settings.md) to persist a selection and inject the CSS yourself. +::: + +**Parameters:** +| Name | Type | Required | Default | Description | +| --- | --- | --- | --- | --- | +| `id` | `string` | Yes | — | Ignored | +| `init` | `boolean` | No | `undefined` | Ignored | + +## Complete example + +Register a theme, read it back, and make it live: + +```javascript +const themes = acode.require('themes'); +const ThemeBuilder = acode.require('themeBuilder'); +const settings = acode.require('settings'); + +const theme = new ThemeBuilder('Acme', 'dark'); +theme.primaryColor = 'rgb(33, 150, 243)'; +theme.secondaryColor = 'rgb(24, 28, 34)'; +theme.popupBackgroundColor = 'rgb(18, 20, 24)'; +theme.popupTextColor = 'rgb(236, 240, 245)'; +// Assigning primaryColor already computed this, because autoDarkened defaults to true +theme.darkenedPrimaryColor = 'rgb(8, 91, 157)'; + +// 1. Register it. Silently ignored if the id already exists. +themes.add(theme); + +// 2. Confirm it is in the registry. +console.log(themes.list().map((t) => t.id)); // [..., 'acme'] +const registered = themes.get('Acme'); // the very same instance you added +console.log(registered === theme); // true + +// 3. Persist the selection so it survives a restart. +settings.update({ appTheme: registered.id }, false); + +// 4. themes.apply() is a no-op, so do what the app's internal applier does. +const $style = document.head.get('style#app-theme') ?? (); +document.body.setAttribute('theme-type', registered.type); +$style.textContent = registered.css; +if (!$style.isConnected) document.head.append($style); +``` + +## Gotchas + +::: warning `settings.update({ appTheme })` does **not** repaint +The only `update:appTheme` listener is the system colour-scheme watcher. Writing `appTheme` persists the choice; it does not regenerate the `