diff --git a/README.md b/README.md index 89d1254b..1912dbd7 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ own computer, and lets you follow them, approve what they do and review their changes from your terminal, browser or phone.** -[![Latest release: v2.7.0](https://img.shields.io/github/v/release/JeremiahM37/lectern?label=release&color=8b5cf6)](https://github.com/JeremiahM37/lectern/releases/latest) +[![Latest release: v2.8.0](https://img.shields.io/github/v/release/JeremiahM37/lectern?label=release&color=8b5cf6)](https://github.com/JeremiahM37/lectern/releases/latest) ![license](https://img.shields.io/badge/license-MIT-blue) ![go](https://img.shields.io/badge/single%20binary-Go-00add8) @@ -72,7 +72,9 @@ phone, then review and commit the change. comments for the agent, stage what you want and commit. On your main branch it offers a new branch first. - **Use your phone.** The phone layout is installable as an app, with - notifications when an agent needs you. + notifications when an agent needs you. On Android there is also a + [native app](docs/android.md) (the APK is on each release) that approves + from the notification and updates itself. A pending approval in the phone layout diff --git a/cmd/lectern/plugin.go b/cmd/lectern/plugin.go index ec2d2a7e..6768431c 100644 --- a/cmd/lectern/plugin.go +++ b/cmd/lectern/plugin.go @@ -27,7 +27,7 @@ const pluginUsage = `usage: lectern plugin remove ID lectern plugin secret ID NAME (reads the value from stdin; empty removes it) lectern plugin source add NAME GIT_URL [--ref REF] | source list | source remove NAME - lectern plugin new ID [DIR] + lectern plugin new ID [DIR] [--mod] (--mod: a plugin with one mod, docs/mods.md) lectern plugin validate [DIR]` // pluginOffline handles the subcommands that need no server: scaffolding a @@ -38,14 +38,23 @@ func pluginOffline(args []string, out io.Writer) (ok bool, err error) { } switch args[0] { case "new": - if len(args) < 2 || len(args) > 3 { - return true, fmt.Errorf("usage: lectern plugin new ID [DIR]") + scaffold := pluginpkg.Scaffold + var rest []string + for _, a := range args[1:] { + if a == "--mod" { + scaffold = pluginpkg.ScaffoldMod + continue + } + rest = append(rest, a) + } + if len(rest) < 1 || len(rest) > 2 || strings.HasPrefix(rest[0], "-") { + return true, fmt.Errorf("usage: lectern plugin new ID [DIR] [--mod]") } - dir := args[1] - if len(args) == 3 { - dir = args[2] + dir := rest[0] + if len(rest) == 2 { + dir = rest[1] } - files, err := pluginpkg.Scaffold(args[1]) + files, err := scaffold(rest[0]) if err != nil { return true, err } diff --git a/cmd/lectern/plugin_test.go b/cmd/lectern/plugin_test.go index af019b46..be912186 100644 --- a/cmd/lectern/plugin_test.go +++ b/cmd/lectern/plugin_test.go @@ -40,6 +40,29 @@ func TestPluginNewThenValidate(t *testing.T) { } } +func TestPluginNewModThenValidate(t *testing.T) { + dir := filepath.Join(t.TempDir(), "hello") + var out bytes.Buffer + if ok, err := pluginOffline([]string{"new", "acme.hello", "--mod", dir}, &out); !ok || err != nil { + t.Fatalf("new --mod: %v %v", ok, err) + } + for _, f := range []string{"mods/hello.js", "mods/hello.test.mjs", "README.md"} { + if _, err := os.Stat(filepath.Join(dir, f)); err != nil { + t.Fatalf("scaffold is missing %s: %v", f, err) + } + } + out.Reset() + if ok, err := pluginOffline([]string{"validate", dir}, &out); !ok || err != nil { + t.Fatalf("validate: %v %v\n%s", ok, err, out.String()) + } + if !strings.Contains(out.String(), "acme.hello 0.1.0 — valid") { + t.Fatalf("validate output:\n%s", out.String()) + } + if _, err := pluginOffline([]string{"new", "--mod"}, &out); err == nil { + t.Fatal("new --mod without an id was accepted") + } +} + func TestPluginInstallAsksAndConsentsToExactlyWhatWasShown(t *testing.T) { var installs []map[string]any srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/docs/a2a.md b/docs/a2a.md index 2ab83b3f..3073e134 100644 --- a/docs/a2a.md +++ b/docs/a2a.md @@ -35,7 +35,7 @@ curl -s "$LECTERN_BASE_URL/.well-known/agent-card.json" | jq { "name": "Lectern", "description": "Control plane for AI coding agents. Dispatch a coding task ...", - "version": "2.7.0", + "version": "2.8.0", "supportedInterfaces": [ { "url": "https://lectern.example.com/a2a/v1", diff --git a/docs/android.md b/docs/android.md index 224e17c5..d8fc3429 100644 --- a/docs/android.md +++ b/docs/android.md @@ -1,9 +1,11 @@ # Android app -A native Android app for Lectern. It is a prototype. The signed 0.2.1 APK is on the -[v2.6.0 release](https://github.com/JeremiahM37/lectern/releases/download/v2.6.0/lectern-android-0.2.1.apk) (SHA-256 `11f2ee2fa4ea56a38f753c748c1abf0cf82a353c67758eb7fc241ec12a3b7925`). -It is signed with the same key as 0.1.0, so an installed 0.1.0 upgrades in place and keeps its pairing. -0.1.0 opened every in-app terminal as a second copy of the app; 0.2.1 fixes that, and the back key now closes overlays first. +A native Android app for Lectern. From 2.8.0 the app carries Lectern's own +version, and every release has its signed APK attached: +[lectern-android-.apk on the latest release](https://github.com/JeremiahM37/lectern/releases/latest). +It is signed with the same key as every earlier build (0.1.0, 0.2.1), so it +installs over them and keeps their pairing. After that, the app updates itself +(see [Updates](#updates)). If notifications don't arrive, open ntfy once and turn off battery optimisation for it. See "Tested" and "Limits" below before using it. @@ -149,6 +151,41 @@ only for its own Lectern's pages. Android 12+ shows the lectern mark on the app's dark background while it starts, and Android 13 themed icons get a monochrome variant. +## Updates + +When a newer Lectern release has an app, the app says so in a bar at the top +of the page (**Update** / **Later**), and **Settings → Phone & devices → App +updates** shows the installed version with **Check for updates**. It checks +when it opens, at most every six hours, and needs no Google service. + +- Every release carries `lectern-android.json`: + `{"version", "versionCode", "apk", "sha256", "size"}`. The app reads it from + `https://github.com/JeremiahM37/lectern/releases/latest/download/lectern-android.json`, + which always names the newest release, so there is no API call and no rate + limit. +- **Update** downloads the APK, which must come from this repository's + release downloads, checks it against the manifest's SHA-256, and hands it + to Android's installer. Android shows **Do you want to update this app?** + and refuses any APK not signed with the installed app's key, so a forged + manifest can at most name an update that will not install. +- The first time, Android asks to **Allow from this source** for Lectern; + the update carries on when you come back from that setting. +- Pairings, keys, tokens and push registrations are kept; Android restarts + the app as the new version. + +Releasing: after `release.yml` has published a tag, check the tag out and run +`LECTERN_ANDROID_SIGNING=… tools/publish-android.sh vX.Y.Z`. It runs the unit +tests, builds and signs the APK, checks the signing certificate, uploads the +APK, its `.sha256` and `lectern-android.json`, and reads back what installed +apps will see. The app's version comes from `internal/version/version.go` +(`versionCode` = major×10000 + minor×100 + patch). + +Tested on an Android 15 emulator: a signed 2.8.0 offered a locally served +stand-in 2.8.1 (built with `LECTERN_ANDROID_VERSION=2.8.1`, +`LECTERN_UPDATE_MANIFEST` and `LECTERN_UPDATE_APK_PREFIX` pointing at it), +went through Allow from this source and Android's confirmation, and came +back as 2.8.1, still paired. + ## Push notifications (UnifiedPush and ntfy) The app receives push through [UnifiedPush](https://unifiedpush.org), so no @@ -204,6 +241,10 @@ approvals, and a revoked device gets a 401. mode the page's origin is your Lectern's address, so API calls, SSE and terminals are ordinary same-origin requests, but every app file is still answered from the APK. The service worker is not installed in the app. +- **The screen's edges.** Android 15 draws apps under the status bar, the + camera cutout and the gesture bar. The WebView sits in a frame padded by + those insets (and the keyboard's), whose colour follows the page's theme + (`LecternNative.barColors`), so nothing the page draws is under them. - **`window.LecternNative`** (`frontend/src/native/bridge.ts`) is how the page reaches the native side. It refuses calls from any origin but the connected one, and the WebView opens every other link in the phone's browser. @@ -287,8 +328,8 @@ host, physical devices, and Android versions other than 14. ## Limits - **Web app updates arrive with the APK.** The app runs the web app it was - built with. A host much newer or older than the app can differ in API; build - the APK from the same commit as the host. + built with. A host much newer or older than the app can differ in API; keep + the app updated (it offers each release) along with the host. - **Media over the relay.** Images, video and downloads that the web app loads by URL rather than `fetch` (the browser's service worker handles those) do not load in relay mode yet. diff --git a/docs/mods.md b/docs/mods.md new file mode 100644 index 00000000..6f703cfd --- /dev/null +++ b/docs/mods.md @@ -0,0 +1,166 @@ +# Mods + +A mod is a small JavaScript module, shipped inside a [plugin](plugins.md), that +changes how Lectern behaves and looks: it watches what happens, rewrites or +blocks it, draws its own buttons, badges and panes, and adds commands. The same +module runs in the web app and in the terminal console (`lectern console`). + +```js +// mods/blast-radius.js +export function register(on) { + on("prompt.submit", async ($, e, next) => { + if (/rm\s+-rf\s+\//.test(e.text)) return { deny: "That would delete the whole disk." }; + return next(e); + }); + on("ui.render", "session.card", async ($, e, next) => { + const out = await next(e); + const { Badge } = $.ui.resolve(e); + if (e.props.session.agent === "codex") out.append.push(Badge({ text: "codex", tone: "accent" })); + return out; + }); +} +``` + +Code: `internal/pluginpkg` (manifest, validation), `internal/plugins/mods.go` +(serving), `frontend/src/mods/` (web runtime), `internal/console/mods/` +(console runtime). + +## The package + +```yaml +id: acme.blast-radius +name: Blast radius +version: 0.1.0 +capabilities: + mods: [web, cli] # where its code runs: your browser, your terminal console + api: read # optional: $.api may read Lectern's API ("write" may also change things) +contributes: + mods: + - id: blast-radius + path: mods/blast-radius.js + surfaces: [web, cli] # default: both +``` + +`lectern plugin validate` refuses a mod whose surfaces the `mods` capability +does not list, a file that is missing, larger than 256 KB, not `.js`/`.mjs`, or +that does not parse. A mod is one file: it may not `import` anything (bundle +with esbuild first if you need a library). It defines `register`, either as +`export function register(on, options)`, `export default function (on, options)`, +or a plain `function register(on, options)`. + +Install, consent, updates and the content hash work exactly as for every other +plugin: the code that runs is the code a person looked at and allowed, and a +changed file stops the mod until someone trusts it again. + +## Hooks + +`on(event, [matcher], handler)` adds a handler. Handlers of every mod form one +chain per event, in install order; `next(e)` passes the event on, and the end +of the chain is Lectern itself. A handler can: + +- **observe**: `const r = await next(e); …; return r;` +- **rewrite**: `return next({ ...e, text: e.text.trim() });` +- **answer**: return without calling `next`, e.g. `return { deny: "reason" }`. + +`matcher` narrows an event: the component name for `ui.render`, the command id +for `command.run`, the stream type for `server.event`. It is a string or a +RegExp. + +| Event | `e` | What Lectern does at the end of the chain | A handler may | +| --- | --- | --- | --- | +| `app.start` | `{surface, version}` | nothing | observe | +| `server.event` | `{type, data}` — every live update (`session`, `task`, `approval`, …) | nothing | observe | +| `prompt.submit` | `{session_id, text}` — a message a person sends to a session | sends `text` | rewrite `text`, or `{deny}` | +| `approval.decide` | `{approval, decision}` — `decision` is `allow` or `deny` | records the decision | `{deny}` to stop it (a person decides again) | +| `command.run` | `{id, args}` — a command from the palette | runs it | rewrite, or answer | +| `ui.render` | `{component, props, surface, viewport}` | returns `{hidden:false, append:[]}` | hide the component, append elements | + +`{deny}` is a safety net for the person at the keyboard, not a permission +system: agents never go through these hooks. Use permission rules for a hard +block. In the web app `prompt.submit` and `approval.decide` are hooked in the +API client every screen uses; the approval strip inside a terminal tab and +keys typed straight into a terminal do not pass through them. + +### Components + +| `component` | Where | `props` | +| --- | --- | --- | +| `session.card` | a session in Sessions (web) and the console list (cli) | `{session}` | +| `status` | the top bar (web) and the console footer (cli) | `{}` | +| `pane` | a pane opened with `$.ui.open` | `{id}` | + +For `pane` the result's `append` is the pane's content. + +## `$` + +The module runs in a sandbox of its own: in the browser, an opaque-origin +iframe with no access to Lectern's page, cookies or network; in the console, +an embedded JavaScript engine with no file system or network. Everything goes +through `$`. + +| | | +| --- | --- | +| `$.surface` | `"web"` or `"cli"` | +| `$.mod` | `{plugin, id}` | +| `$.ui.resolve(e)` | element constructors: `Box`, `Text`, `Badge`, `Button`, `Link` | +| `$.ui.toast(text)` | a notification | +| `$.ui.status(text)` | this mod's segment of the status component (`null` clears it) | +| `$.ui.open({id, title})` / `$.ui.close(id)` | a pane, drawn by `ui.render` with `component: "pane"` | +| `$.ui.render()` | draw again (`$.state.set` does this for you) | +| `$.state.get(key)` / `$.state.set(key, value)` | JSON values kept for this mod on this device | +| `$.command.register({id, title, run})` | a palette command; `run($, args)` | +| `$.navigate(hash)` | open a Lectern view (`#sessions`, `#session/4`); the console ignores it | +| `$.api.get(path)` | needs `api: read`; `path` starts with `/api/` | +| `$.api.post/put/delete(path, body)` | needs `api: write` | +| `$.sleep(ms)` | wait | +| `$.log(...)` | shown under the mod in Settings → Plugins | + +The API is called as the person using the app. Plugin management, approval +decisions, sign-in and secrets are never reachable from a mod. + +### Elements + +Elements are plain objects, the same on both surfaces; the console draws what +a terminal can and ignores the rest. + +| | props | +| --- | --- | +| `Box` | `direction` (`row`/`column`), `gap`, `children` | +| `Text` | `text`, `tone` (`dim`, `accent`, `warn`, `danger`, `ok`), `bold`, `mono` | +| `Badge` | `text`, `tone` | +| `Button` | `label`, `onPress`, `hotkey` (console) | +| `Link` | `label`, `href` (`#view` or `https://`) | + +## In the console + +`lectern console` runs the same modules on an embedded JavaScript engine, one +per mod, with no file system or network. It differs from the web app where a +terminal has to: + +- It polls rather than streams, so a newly enabled mod starts within about 15 + seconds, and `server.event` carries only `session` (a status changed) and + `approval` (a new one is waiting). +- A session row draws its mod elements after the title on one line. Its + buttons are in the palette rather than on hotkeys, which would clash with + the dashboard's own; inside a pane, `hotkey` works and Esc closes it. +- `Link` is drawn as its label, and `Text`'s `mono` is ignored. +- `lectern console --plain` does not load mods. + +State lives beside the console's saved view, in your config directory. + +## Limits + +- A handler has 2 seconds; one that throws or runs over is skipped (the event + continues as if it had called `next(e)`) and the error is recorded. A mod + that fails 5 times in a minute is paused until the page or console restarts. +- `ui.render` handlers are synchronous in practice: a render waits at most + 100 ms for them and draws without the late ones. +- State is at most 256 KB per mod. + +## Writing one + +```sh +lectern plugin new my-mod --mod # a plugin with one mod and a test +lectern plugin validate ./my-mod +lectern plugin install ./my-mod # preview, then Allow +``` diff --git a/docs/plugins.md b/docs/plugins.md index b6057814..5491d6a3 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -48,9 +48,10 @@ contributes: - {id: docs, title: "Release notes: open docs", href: "https://example.com/release-notes"} ``` -Everything is declarative. Code runs only as a declared hook, a declared MCP -server, or a command an agent or person runs from a skill. A plugin has no -in-process code and no UI code of its own. +Everything else is declarative. Code runs only as a declared hook, a declared +MCP server, a command an agent or person runs from a skill, or a +[mod](mods.md): a JavaScript module that hooks the web app and the terminal +console from a sandbox of its own. ### Contributions @@ -65,6 +66,7 @@ in-process code and no UI code of its own. | `quick_commands` | commands in the terminal key row and Snippets sheet | — | | `themes` | presets in Settings → Appearance | — | | `palette_commands` | entries in the command palette that open a Lectern view or a link | — | +| `mods` | JavaScript that hooks events and draws in the web app and `lectern console` ([mods.md](mods.md)) | `mods`, and `api` to call Lectern's API | `lectern plugin validate` refuses a manifest whose contributions need a capability it does not declare, so the capability list is never a summary @@ -192,7 +194,7 @@ files stops the provider. A plugin is enabled everywhere or for chosen projects. Project scope applies to the contributions that belong to a project — MCP servers, skills, workflows, -hooks and quick commands. Agents, themes and palette commands are global. +hooks and quick commands. Agents, themes, palette commands and mods are global. Turning a plugin off stops its MCP servers, hooks, quick commands, themes and palette commands at once, and turns its skills and workflows off in every diff --git a/e2e/session_sheet.py b/e2e/session_sheet.py index e4e74288..b302cc3f 100644 --- a/e2e/session_sheet.py +++ b/e2e/session_sheet.py @@ -30,12 +30,9 @@ def nav_to(page, name): if tab.count() and tab.is_visible(): tab.click() return - # More: a menu on a narrow screen, a group that opens in place in the - # desktop sidebar. + # More, on a narrow screen; a desktop sidebar lists every page. if page.locator("#nav-overflow").count(): page.locator("#nav-overflow > summary").click() - elif not page.locator("#nav-more-group").evaluate("el => el.open"): - page.locator("#nav-more-group > summary").click() page.locator(f'#tabbar [data-nav-target="{name}"]:visible').click() diff --git a/e2e/test_desktop_navigation.py b/e2e/test_desktop_navigation.py index 07d1b485..dcb48103 100644 --- a/e2e/test_desktop_navigation.py +++ b/e2e/test_desktop_navigation.py @@ -1,5 +1,5 @@ -"""The desktop sidebar is the phone's three items plus an in-place More -group; neither it nor the compact More can overlay the terminal.""" +"""The desktop sidebar lists every page, with no More; neither it nor the +compact More can overlay the terminal.""" from playwright.sync_api import expect from test_terminal_workspace import real_terminal from test_terminal_tabs import attach, frame, ready @@ -10,15 +10,9 @@ def test_desktop_sidebar_stays_outside_a_connected_terminal(page, real_terminal) page.set_viewport_size({"width": 1440, "height": 900}) page.goto(t["url"] + "/#sessions") expect(page.locator("#nav-overflow")).to_have_count(0) - expect(page.locator("#tabbar > .tab")).to_have_count(3) - for destination in ["sessions", "approvals", "settings"]: + expect(page.locator("#nav-more-group")).to_have_count(0) + for destination in ["sessions", "approvals", "tasks", "terminals", "overview", "issues", "media", "evals", "settings"]: expect(page.locator(f'#tabbar > .tab[data-nav-target="{destination}"]')).to_be_visible() - # Everything else is one click away, in place in the sidebar. - group = page.locator("#nav-more-group") - expect(group.locator('[data-nav-target="overview"]')).to_be_hidden() - group.locator("summary").click() - for destination in ["tasks", "terminals", "overview", "issues", "media", "evals"]: - expect(group.locator(f'[data-nav-target="{destination}"]')).to_be_visible() # Machines and Plugins are Settings tabs; the sidebar does not repeat them. for duplicate in ["machines", "plugins"]: expect(page.locator(f'#tabbar [data-nav-target="{duplicate}"]')).to_have_count(0) @@ -43,7 +37,6 @@ def test_open_mobile_more_is_removed_when_resizing_to_desktop(page, server): expect(page.locator('#nav-overflow [data-nav-target="tasks"]')).to_be_visible() page.set_viewport_size({"width": 1440, "height": 900}) expect(page.locator("#nav-overflow")).to_have_count(0) - page.locator("#nav-more-group > summary").click() page.locator('.tab[data-tab="tasks"]').click() expect(page.locator("#board")).to_be_visible() page.set_viewport_size({"width": 390, "height": 844}) diff --git a/e2e/test_light_mode_sweep.py b/e2e/test_light_mode_sweep.py index 0f4be010..8a897a80 100644 --- a/e2e/test_light_mode_sweep.py +++ b/e2e/test_light_mode_sweep.py @@ -57,12 +57,9 @@ def nav(page, name): if button.is_visible(): button.click() else: - # More: a menu on a narrow screen, a group that opens in place in the - # desktop sidebar. + # More, on a narrow screen; a desktop sidebar lists every page. if page.locator("#nav-overflow").count(): page.locator("#nav-overflow > summary").click() - elif not page.locator("#nav-more-group").evaluate("el => el.open"): - page.locator("#nav-more-group > summary").click() page.locator(f'#tabbar [data-nav-target="{name}"]:visible').click() if legacy == "targets": # The old Settings page opened on Machines. @@ -158,7 +155,6 @@ def test_light_mode_board_settings_and_dialogs(page, server, theme): sweep.check("more-menu") page.locator("#nav-overflow > summary").click() else: - page.locator("#nav-more-group > summary").click() sweep.check("desktop-navigation") page.goto(server + "/#evals") page.wait_for_timeout(600) diff --git a/e2e/test_mobile_now_strip_and_voice.py b/e2e/test_mobile_now_strip_and_voice.py index edf00361..0043be62 100644 --- a/e2e/test_mobile_now_strip_and_voice.py +++ b/e2e/test_mobile_now_strip_and_voice.py @@ -39,22 +39,27 @@ def test_now_strip_shows_a_waiting_session_and_the_approval_count(page, server): approvals_chip = page.locator("#now-approvals") expect(approvals_chip).to_contain_text("1 to approve", timeout=25000) + # On a phone the strip carries only what wants a person; an idle session + # is already a card just below it. waiting_chip = strip.locator('.now-chip[data-state="idle"]', has_text="Waiting agent") - expect(waiting_chip).to_contain_text("Idle") + expect(waiting_chip).to_be_hidden() # The strip sits above the fold and never causes horizontal overflow at # phone width, same guarantee every other mobile surface here keeps. assert strip.bounding_box()["y"] < page.locator("#sesslist").bounding_box()["y"] assert page.evaluate("document.documentElement.scrollWidth<=innerWidth") - # One tap into the session it names. - waiting_chip.click() - expect(page.locator(".scard", has_text="Waiting agent")).to_be_visible() - # One tap into Needs-you for the approval count. approvals_chip.click() expect(page.locator("#needs-you")).to_be_in_viewport() + # Wider, every session has its chip, and one tap opens the session it names. + page.set_viewport_size({"width": 1024, "height": 800}) + page.evaluate("scrollTo(0, 0)") + expect(waiting_chip).to_contain_text("Idle") + waiting_chip.click() + expect(page.locator(".scard", has_text="Waiting agent")).to_be_visible() + def test_now_strip_stays_out_of_the_way_with_nothing_to_show(page, server): page.set_viewport_size(PHONE) diff --git a/e2e/test_mods.py b/e2e/test_mods.py new file mode 100644 index 00000000..82917f95 --- /dev/null +++ b/e2e/test_mods.py @@ -0,0 +1,124 @@ +"""Mods (docs/mods.md) in the real browser: a plugin's mod installed through +consent draws a status line, a badge on a session card and a pane whose +state survives a reload, adds a palette command, and rewrites or refuses a +message sent to a session before it leaves the page.""" +import re +from pathlib import Path + +import pytest +from playwright.sync_api import expect + +from test_terminal_workspace import real_terminal # noqa: F401 (fixture) + +EXAMPLE = str(Path(__file__).resolve().parents[1] / "examples" / "plugins" / "mod-demo") +ALLOW = {"env": {"LECTERN_PLUGINS_ALLOW_UNAUTHENTICATED": "1"}} + + +def install(page, url): + preview = page.request.post(f"{url}/api/plugins/preview", data={"kind": "path", "path": EXAMPLE}) + assert preview.ok, preview.text() + pv = preview.json() + accept = sorted(c["key"] if not c.get("detail") else f'{c["key"]}: {d}' + for c in pv["capabilities"] for d in (c.get("detail") or [None])) + assert any(a.startswith("mods: demo (mods/demo.js)") for a in accept), accept + done = page.request.post(f"{url}/api/plugins/install", data={"hash": pv["hash"], "accept": accept}) + assert done.ok, done.text() + + +def palette(page, text): + page.keyboard.press("Control+k") + page.get_by_role("combobox", name="Search sessions, tasks, and actions").fill(text) + page.get_by_role("dialog", name="Search Lectern").get_by_role("option", name=re.compile(text)).click() + + +@pytest.mark.parametrize("real_terminal", [ALLOW], indirect=True) +def test_a_mod_draws_hooks_and_keeps_state(page, real_terminal): + t = real_terminal + errors = [] + page.on("pageerror", lambda error: errors.append(str(error))) + page.set_viewport_size({"width": 1440, "height": 900}) + install(page, t["url"]) + page.goto(t["url"] + "/#sessions") + + # Status line and a badge on the session's card. + expect(page.locator("[data-mod-status]")).to_contain_text("demo mod on") + card = page.locator(f'.scard[data-session-id="{t["id"]}"]') + expect(card.locator(".scard-mods .mod-badge")).to_have_text(re.compile(rf"#{t['id']} ")) + + # A palette command opens its pane; the counter's state survives a reload. + palette(page, "Mod demo: open the counter") + pane = page.locator('[data-mod-pane="example.mod-demo/demo/counter"]') + expect(pane).to_contain_text("Pressed 0 times") + pane.get_by_role("button", name="Press").click() + expect(pane).to_contain_text("Pressed 1 times") + pane.get_by_role("button", name="Press").click() + expect(pane).to_contain_text("Pressed 2 times") + page.reload() + expect(page.locator("[data-mod-status]")).to_contain_text("demo mod on") + palette(page, "Mod demo: open the counter") + expect(pane).to_contain_text("Pressed 2 times") + pane.get_by_role("button", name="Close").click() + expect(pane).to_have_count(0) + + # prompt.submit: "shout:" is rewritten before it is sent; a wipe is refused. + sent = [] + page.on("request", lambda r: sent.append(r.post_data_json) if r.method == "POST" and r.url.endswith(f"/sessions/{t['id']}/send") else None) + card.get_by_text("Chat").click() + box = page.locator("#conversation-input") + box.fill("shout: echo modded") + page.get_by_text("Send", exact=True).click() + page.wait_for_timeout(500) + assert {"text": "ECHO MODDED"}.items() <= (sent[-1] or {}).items(), sent + count = len(sent) + box.fill("rm -rf /") + page.get_by_text("Send", exact=True).click() + expect(page.get_by_text("that would delete the whole disk").first).to_be_visible() + assert len(sent) == count, sent + + # Settings → Plugins shows the mod running in this browser. + page.get_by_role("button", name="Close conversation").click() + page.goto(t["url"] + "/#settings/plugins") + plugin = page.locator('[data-plugin="example.mod-demo"]') + plugin.get_by_role("button", name="Details").click() + expect(plugin.locator('[data-mods-here] [data-mod-state="running"]')).to_contain_text("demo") + + # Turned off, it is gone: no status, no badge. + plugin.get_by_role("button", name="Turn off").click() + page.goto(t["url"] + "/#sessions") + expect(card).to_be_visible() + expect(page.locator("[data-mod-status]")).to_have_count(0) + expect(card.locator(".scard-mods")).to_have_count(0) + assert not errors, errors + + +@pytest.mark.parametrize("real_terminal", [ALLOW], indirect=True) +def test_a_mod_cannot_reach_the_network_or_the_page(page, real_terminal, tmp_path): + """The sandbox: no fetch, no parent page, no cookies — only $.""" + t = real_terminal + plugin = tmp_path / "probe" + (plugin / "mods").mkdir(parents=True) + (plugin / "lectern-plugin.yaml").write_text( + "id: example.probe\nname: Probe\nversion: 0.1.0\ncapabilities: {mods: [web]}\n" + "contributes: {mods: [{id: probe, path: mods/probe.js, surfaces: [web]}]}\n") + (plugin / "mods" / "probe.js").write_text(""" +export function register(on) { + on("app.start", async ($, e, next) => { + const tries = []; + try { await fetch("/api/sessions"); tries.push("fetch ok"); } catch { tries.push("fetch blocked"); } + try { parent.document.title; tries.push("page ok"); } catch { tries.push("page blocked"); } + try { document.cookie; tries.push("cookie " + (document.cookie ? "read" : "empty")); } catch { tries.push("cookie blocked"); } + try { await $.api.get("/api/sessions"); tries.push("api ok"); } catch (err) { tries.push("api refused"); } + $.ui.status(tries.join(", ")); + return next(e); + }); +} +""") + preview = page.request.post(f"{t['url']}/api/plugins/preview", data={"kind": "path", "path": str(plugin)}).json() + accept = sorted(c["key"] if not c.get("detail") else f'{c["key"]}: {d}' for c in preview["capabilities"] for d in (c.get("detail") or [None])) + assert page.request.post(f"{t['url']}/api/plugins/install", data={"hash": preview["hash"], "accept": accept}).ok + page.goto(t["url"] + "/#sessions") + status = page.locator("[data-mod-status]") + expect(status).to_contain_text("fetch blocked, page blocked") + expect(status).not_to_contain_text("cookie read") + # Without the api capability, $.api is refused. + expect(status).to_contain_text("api refused") diff --git a/e2e/test_recent_sessions.py b/e2e/test_recent_sessions.py index b7c63d2d..5f9e7877 100644 --- a/e2e/test_recent_sessions.py +++ b/e2e/test_recent_sessions.py @@ -10,13 +10,14 @@ from test_native_resume import stopped, argv from test_session_restore import request from test_terminal_workspace import real_terminal +from session_sheet import session_tool def open_recent(page, t, width=1440): page.set_viewport_size({"width": width, "height": 844}) page.goto(t["url"] + "/#sessions") - expect(page.locator("#sess-recent")).to_be_visible(timeout=15000) - page.locator("#sess-recent").click() + expect(page.locator("#sess-new")).to_be_visible(timeout=15000) + session_tool(page, "#sess-recent").click() expect(page.locator(".recent-closed")).to_be_visible(timeout=10000) expect(page.locator(".recent-closed")).not_to_contain_text("Loading", timeout=10000) diff --git a/e2e/test_relay.py b/e2e/test_relay.py index 17abb617..0047fef5 100644 --- a/e2e/test_relay.py +++ b/e2e/test_relay.py @@ -93,12 +93,9 @@ def _tab(page, name): page.get_by_role("button", name="Show navigation", exact=True).click() button = page.locator(f'.tab[data-tab="{name}"]') if not button.is_visible(): - # More: a menu on a narrow screen, a group that opens in place in the - # desktop sidebar. + # More, on a narrow screen; a desktop sidebar lists every page. if page.locator("#nav-overflow").count(): page.locator("#nav-overflow > summary").click() - elif not page.locator("#nav-more-group").evaluate("el => el.open"): - page.locator("#nav-more-group > summary").click() page.locator(f'#tabbar [data-nav-target="{name}"]:visible').click() else: button.click() diff --git a/e2e/test_simple_walkthroughs.py b/e2e/test_simple_walkthroughs.py index 2d61eae5..fe003c2f 100644 --- a/e2e/test_simple_walkthroughs.py +++ b/e2e/test_simple_walkthroughs.py @@ -180,7 +180,8 @@ def test_d_end_offers_undo_and_restore_finds_it(page, server, size): assert not dialogs, dialogs expect(page.locator(".scard", has_text=name)).to_have_count(0, timeout=20000) assert click.n == 2, click.n - click(page.locator("#sess-recent")) + # On a phone Restore waits under the header's ⋯. + session_tool(page, "#sess-recent").click() expect(page.locator(".recent-closed")).to_contain_text(name, timeout=20000) @@ -442,21 +443,21 @@ def reader(route): @pytest.mark.parametrize("size", WIDTHS, ids=IDS) def test_navigation_is_short_and_more_stays_in_its_place(page, real_terminal, size): """N9: on a phone, Sessions, Approvals, Settings (+ Tasks once used) and - More, and an open terminal never takes a slot. A desktop sidebar has the - same three, Terminals while one is open, and a More group that opens in - place, never over the terminal.""" + More, and an open terminal never takes a slot. A desktop sidebar lists + every page, with no More, and never sits over the terminal.""" t = real_terminal page.set_viewport_size(size) page.goto(t["url"] + "/#sessions") tabs = page.locator("#tabbar > .tab") - expect(tabs).to_have_count(3) + count = 3 if size is PHONE else 9 + expect(tabs).to_have_count(count) page.locator(".scard", has_text="Real terminal").get_by_role("button", name="⌨ Terminal", exact=True).click() expect(page.locator("#terminal-workspace")).to_be_visible() if size is PHONE: show = page.get_by_role("button", name="Show navigation", exact=True) if show.is_visible(): show.click() - expect(tabs).to_have_count(3 if size is PHONE else 4) + expect(tabs).to_have_count(count) if size is PHONE: expect(page.locator("#more-terminal-badge")).to_have_text("1") page.locator("#nav-overflow > summary").click() diff --git a/e2e/test_ui.py b/e2e/test_ui.py index b2c76d45..b1953ead 100644 --- a/e2e/test_ui.py +++ b/e2e/test_ui.py @@ -16,12 +16,9 @@ def _tab(page, name): page.get_by_role('button',name='Show navigation',exact=True).click() button=page.locator(f'.tab[data-tab="{name}"]') if not button.is_visible(): - # More: a menu on a narrow screen, a group that opens in place in the - # desktop sidebar. + # More, on a narrow screen; a desktop sidebar lists every page. if page.locator("#nav-overflow").count(): page.locator("#nav-overflow > summary").click() - elif not page.locator("#nav-more-group").evaluate("el => el.open"): - page.locator("#nav-more-group > summary").click() page.locator(f'#tabbar [data-nav-target="{name}"]:visible').click() else: button.click() if legacy == "targets": diff --git a/examples/plugins/mod-demo/lectern-plugin.yaml b/examples/plugins/mod-demo/lectern-plugin.yaml new file mode 100644 index 00000000..d789d941 --- /dev/null +++ b/examples/plugins/mod-demo/lectern-plugin.yaml @@ -0,0 +1,12 @@ +id: example.mod-demo +name: Mod demo +version: 0.1.0 +description: A status line, a badge on every session, a guard on what you send, and a pane with a counter. +license: MIT +min_lectern: 2.8.0 +capabilities: + mods: [web, cli] +contributes: + mods: + - id: demo + path: mods/demo.js diff --git a/examples/plugins/mod-demo/mods/demo.js b/examples/plugins/mod-demo/mods/demo.js new file mode 100644 index 00000000..40e4f0f2 --- /dev/null +++ b/examples/plugins/mod-demo/mods/demo.js @@ -0,0 +1,42 @@ +// An example mod (docs/mods.md). The same file runs in the web app and in +// `lectern console`. +export function register(on) { + on("app.start", async ($, e, next) => { + $.ui.status("demo mod on"); + $.command.register({ + id: "counter", + title: "Mod demo: open the counter", + run: ($) => $.ui.open({ id: "counter", title: "Counter" }), + }); + return next(e); + }); + + // A badge on every session: its number and agent. + on("ui.render", "session.card", async ($, e, next) => { + const out = await next(e); + const { Badge } = $.ui.resolve(e); + const s = e.props.session; + out.append.push(Badge({ text: `#${s.id} ${s.agent}`, tone: "accent" })); + return out; + }); + + // The pane the command opens. $.state survives reloads. + on("ui.render", "pane", async ($, e, next) => { + const out = await next(e); + if (e.props.id !== "counter") return out; + const { Box, Text, Button } = $.ui.resolve(e); + const n = $.state.get("count") ?? 0; + out.append.push(Box({ direction: "column", gap: 2, children: [ + Text({ text: `Pressed ${n} times`, bold: true }), + Button({ label: "Press", hotkey: "p", onPress: () => $.state.set("count", n + 1) }), + ] })); + return out; + }); + + // A guard on what you send: refuse wiping the disk, and "shout:" upper-cases. + on("prompt.submit", async ($, e, next) => { + if (/\brm\s+-rf\s+\/(\s|$)/.test(e.text)) return { deny: "Mod demo: that would delete the whole disk." }; + if (e.text.startsWith("shout:")) return next({ ...e, text: e.text.slice(6).trim().toUpperCase() }); + return next(e); + }); +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5917c344..25afce5e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -33,6 +33,8 @@ import { t, useLocale } from "./i18n"; import { SECTIONS, settingsIndex } from "./settings/search-index"; import { loadPluginContributions, safeHref, usePluginContributions } from "./plugins/contributions"; import { Palette, type Command } from "./shell/Palette"; +import { modHost } from "./mods/host"; +import { ModPanes, ModStatus, useModsVersion } from "./mods/react"; import { canonicalHash, HOME, isView, moreEntries, primaryViews, VIEWS, type View } from "./shell/routes"; import { Deck, Approvals } from "./shell/LiveViews"; import { Icon } from "./shell/Icon"; @@ -49,6 +51,7 @@ import { applyBadge, computeBadgeCount } from "./badge"; import { sessionState, stateText } from "./sessions/status"; import type { NoticeAction } from "./types"; import { offlineCache } from "./api/offline"; +import { UpdateBanner } from "./mobile/UpdateBanner"; import { OfflineBanner } from "./mobile/OfflineBanner"; import { PullToRefresh } from "./mobile/PullToRefresh"; import { noteView, setViewNavigator, useBackClose } from "./mobile/back"; @@ -146,7 +149,6 @@ const NAV_SHORTCUT: Record = { evals: "nav.evals", settings: "nav.targets", }; -const NAV_MORE_KEY = "lectern.nav.moreOpen"; // #media/ narrows the feed to one session's posts. const mediaSessionOf = (hash: string) => { const match = /^#?media\/([1-9]\d*)$/.exec(hash); @@ -161,16 +163,9 @@ export default function App() { update(); return () => media.removeEventListener("change", update); }, []); - // The desktop sidebar's More group starts collapsed and remembers being - // opened, on this device only. - const [moreOpen, setMoreOpen] = useState(() => { - try { return localStorage.getItem(NAV_MORE_KEY) === "1"; } catch { return false; } - }); - const rememberMoreOpen = useCallback((open: boolean) => { - setMoreOpen(open); - try { localStorage.setItem(NAV_MORE_KEY, open ? "1" : "0"); } catch { /* private window */ } - }, []); useLocale(); + // Mod commands join the palette as mods start and stop. + useModsVersion(); const [view, setView] = useState(HOME), [showEvals, setShowEvals] = useState(false), [projects, setProjects] = useState([]), @@ -631,9 +626,16 @@ export default function App() { // What enabled plugins add to the browser (themes, quick commands, // palette commands); the stream says when a plugin changed. void loadPluginContributions().catch(() => {}); + // Mods (docs/mods.md): code from enabled plugins, each in its own sandbox. + modHost.notify = notice; + const uninstallMods = modHost.install(); + void modHost.refresh().catch(() => {}); const stream = new EventSource(withToken("/api/stream")); stream.addEventListener("ui_prefs", () => void loadPrefs().catch(() => {})); - stream.addEventListener("plugins", () => void loadPluginContributions().catch(() => {})); + stream.addEventListener("plugins", () => { + void loadPluginContributions().catch(() => {}); + void modHost.refresh().catch(() => {}); + }); let opened = false; stream.onopen = () => { setConnected(true); @@ -655,7 +657,12 @@ export default function App() { "ci", "target_reach", ]) - stream.addEventListener(event, update); + stream.addEventListener(event, (message) => { + update(); + let data: unknown = null; + try { data = JSON.parse((message as MessageEvent).data); } catch { /* not JSON */ } + modHost.serverEvent(event, data); + }); stream.addEventListener("session_handoff", (event) => { try { const row = JSON.parse((event as MessageEvent).data) as { @@ -694,6 +701,7 @@ export default function App() { return () => { alive = false; refreshGeneration.current++; + uninstallMods(); stream.close(); clearInterval(interval); document.removeEventListener("visibilitychange", visibility); @@ -1015,6 +1023,15 @@ export default function App() { }, }]; }), + // Commands mods registered with $.command.register. + ...modHost.commands().map((row) => ({ + id: "mod-" + row.mod + "/" + row.id, + title: row.title, + category: t("app.commands.plugins"), + detail: row.name, + keywords: "mod plugin " + row.mod, + run: () => void modHost.runCommand(row.mod, row.id), + })), // Individual settings, so "accent" or "push" lands on the control itself. ...settingsIndex().filter((entry) => ["appearance", "workspace", "shortcuts"].includes(entry.section)).map((entry) => ({ id: "setting-" + entry.id, @@ -1089,6 +1106,7 @@ export default function App() { {chordsFor("palette.open")[0] && {displayChord(chordsFor("palette.open")[0]!)}}
+
+ {view === "tasks" && ( {t("board.newTask")}
} +
{toasts.map((toast) => (
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 5c7b8d2f..1b2b8907 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -19,6 +19,23 @@ export interface ClientOptions { run(path: string, load: () => Promise): Promise; clear(): void; }; + /** Skip request interceptors (a mod's own $.api calls). */ + bypassInterceptors?: boolean; +} + +/** + * Sees every request a client sends and may change or refuse it before it + * leaves the page. Mods use it to hook messages sent to a session and + * approval decisions in one place, whichever screen made them. + */ +export type RequestInterceptor = ( + path: string, + options: RequestOptions, + send: (path: string, options: RequestOptions) => Promise, +) => Promise; +let interceptor: RequestInterceptor | undefined; +export function setRequestInterceptor(next: RequestInterceptor | undefined) { + interceptor = next; } export interface RequestOptions extends Omit { @@ -39,6 +56,11 @@ export function createClient(options: ClientOptions = {}) { const getToken = options.token ?? authToken; const offline = options.offline; async function api(path: string, optionsIn: RequestOptions = {}): Promise { + if (interceptor && !options.bypassInterceptors) + return interceptor(path, optionsIn, (p, o) => direct(p, o)) as Promise; + return direct(path, optionsIn); + } + async function direct(path: string, optionsIn: RequestOptions): Promise { if (offline?.cacheable(path, optionsIn.method) && optionsIn.body === undefined) return offline.run(path, () => send(path, optionsIn)); return send(path, optionsIn); diff --git a/frontend/src/i18n/en/core.ts b/frontend/src/i18n/en/core.ts index 6496e733..10b8fd11 100644 --- a/frontend/src/i18n/en/core.ts +++ b/frontend/src/i18n/en/core.ts @@ -358,6 +358,20 @@ const en: Record = { "appHosts.open": "open now", "appHosts.switch": "Switch", "appHosts.manage": "Add or manage Lecterns…", + "appUpdate.title": "App updates", + "appUpdate.version": "This app is Lectern {version}.", + "appUpdate.check": "Check for updates", + "appUpdate.checking": "Checking for a newer app…", + "appUpdate.current": "You have the newest app.", + "appUpdate.available": "Lectern {version} is available.", + "appUpdate.install": "Update to {version}", + "appUpdate.downloading": "Downloading the update… {progress}%", + "appUpdate.installing": "Android is installing the update; confirm it if it asks.", + "appUpdate.failed": "Could not update: {error}", + "appUpdate.banner": "Lectern {version} is out for this app.", + "appUpdate.update": "Update", + "appUpdate.later": "Later", + "appUpdate.retry": "Try again", "pairLink.copy": "Copy link", "pairLink.copied": "Pairing link copied. It works once, for a few minutes.", "pairLink.share": "Share…", diff --git a/frontend/src/i18n/en/plugins.ts b/frontend/src/i18n/en/plugins.ts index df5a7d67..4986f3be 100644 --- a/frontend/src/i18n/en/plugins.ts +++ b/frontend/src/i18n/en/plugins.ts @@ -80,6 +80,14 @@ const catalog: Record = { "plugins.hook.host": "on the server", "plugins.hook.target": "on the machine", "plugins.hookRuns": "Recent hook runs", + "plugins.kind.mods": "Mods", + "plugins.cap.mods": "Runs its own code in your browser or terminal console", + "plugins.cap.api": "Can use Lectern's API as you", + "plugins.modsHere": "Mods in this browser", + "plugins.mod.loading": "starting", + "plugins.mod.running": "running", + "plugins.mod.paused": "paused", + "plugins.mod.failed": "failed", "plugins.hookRun.ok": "ok", "plugins.hookRun.failed": "failed", "plugins.scope": "Where it applies", diff --git a/frontend/src/i18n/es/core.ts b/frontend/src/i18n/es/core.ts index bd9232ce..67edae9a 100644 --- a/frontend/src/i18n/es/core.ts +++ b/frontend/src/i18n/es/core.ts @@ -352,6 +352,20 @@ const catalog: Record = { "appHosts.open": "abierto ahora", "appHosts.switch": "Cambiar", "appHosts.manage": "Añadir o gestionar Lecterns…", + "appUpdate.title": "Actualizaciones de la app", + "appUpdate.version": "Esta app es Lectern {version}.", + "appUpdate.check": "Buscar actualizaciones", + "appUpdate.checking": "Buscando una versión más reciente…", + "appUpdate.current": "Tienes la app más reciente.", + "appUpdate.available": "Lectern {version} está disponible.", + "appUpdate.install": "Actualizar a {version}", + "appUpdate.downloading": "Descargando la actualización… {progress}%", + "appUpdate.installing": "Android está instalando la actualización; confírmala si lo pide.", + "appUpdate.failed": "No se pudo actualizar: {error}", + "appUpdate.banner": "Ya está Lectern {version} para esta app.", + "appUpdate.update": "Actualizar", + "appUpdate.later": "Más tarde", + "appUpdate.retry": "Reintentar", "pairLink.copy": "Copiar enlace", "pairLink.copied": "Enlace de vinculación copiado. Funciona una vez, durante unos minutos.", "pairLink.share": "Compartir…", diff --git a/frontend/src/i18n/es/plugins.ts b/frontend/src/i18n/es/plugins.ts index a041f1a1..50fe83a3 100644 --- a/frontend/src/i18n/es/plugins.ts +++ b/frontend/src/i18n/es/plugins.ts @@ -78,6 +78,14 @@ const catalog: Record = { "plugins.hook.host": "en el servidor", "plugins.hook.target": "en la máquina", "plugins.hookRuns": "Ejecuciones recientes de hooks", + "plugins.kind.mods": "Mods", + "plugins.cap.mods": "Ejecuta su propio código en tu navegador o en la consola de terminal", + "plugins.cap.api": "Puede usar la API de Lectern en tu nombre", + "plugins.modsHere": "Mods en este navegador", + "plugins.mod.loading": "iniciando", + "plugins.mod.running": "en ejecución", + "plugins.mod.paused": "en pausa", + "plugins.mod.failed": "falló", "plugins.hookRun.ok": "ok", "plugins.hookRun.failed": "falló", "plugins.scope": "Dónde se aplica", diff --git a/frontend/src/i18n/fr/core.ts b/frontend/src/i18n/fr/core.ts index 76e6bcb6..b84eff3a 100644 --- a/frontend/src/i18n/fr/core.ts +++ b/frontend/src/i18n/fr/core.ts @@ -337,6 +337,20 @@ const catalog: Record = { "appHosts.open": "ouvert", "appHosts.switch": "Changer", "appHosts.manage": "Ajouter ou gérer des Lecterns…", + "appUpdate.title": "Mises à jour de l'app", + "appUpdate.version": "Cette app est Lectern {version}.", + "appUpdate.check": "Rechercher une mise à jour", + "appUpdate.checking": "Recherche d'une version plus récente…", + "appUpdate.current": "Vous avez la version la plus récente.", + "appUpdate.available": "Lectern {version} est disponible.", + "appUpdate.install": "Mettre à jour vers {version}", + "appUpdate.downloading": "Téléchargement de la mise à jour… {progress} %", + "appUpdate.installing": "Android installe la mise à jour ; confirmez si demandé.", + "appUpdate.failed": "Mise à jour impossible : {error}", + "appUpdate.banner": "Lectern {version} est disponible pour cette app.", + "appUpdate.update": "Mettre à jour", + "appUpdate.later": "Plus tard", + "appUpdate.retry": "Réessayer", "pairLink.copy": "Copier le lien", "pairLink.copied": "Lien d’association copié. Il fonctionne une fois, pendant quelques minutes.", "pairLink.share": "Partager…", diff --git a/frontend/src/i18n/fr/plugins.ts b/frontend/src/i18n/fr/plugins.ts index 490415a9..0834312a 100644 --- a/frontend/src/i18n/fr/plugins.ts +++ b/frontend/src/i18n/fr/plugins.ts @@ -78,6 +78,14 @@ const catalog: Record = { "plugins.hook.host": "sur le serveur", "plugins.hook.target": "sur la machine", "plugins.hookRuns": "Exécutions récentes des hooks", + "plugins.kind.mods": "Mods", + "plugins.cap.mods": "Exécute son propre code dans votre navigateur ou la console du terminal", + "plugins.cap.api": "Peut utiliser l'API de Lectern en votre nom", + "plugins.modsHere": "Mods dans ce navigateur", + "plugins.mod.loading": "démarrage", + "plugins.mod.running": "actif", + "plugins.mod.paused": "en pause", + "plugins.mod.failed": "échec", "plugins.hookRun.ok": "ok", "plugins.hookRun.failed": "échec", "plugins.scope": "Où il s’applique", diff --git a/frontend/src/i18n/ja/core.ts b/frontend/src/i18n/ja/core.ts index f2e8d235..0d0e07f9 100644 --- a/frontend/src/i18n/ja/core.ts +++ b/frontend/src/i18n/ja/core.ts @@ -344,6 +344,20 @@ const catalog: Record = { "appHosts.open": "表示中", "appHosts.switch": "切り替え", "appHosts.manage": "Lectern を追加・管理…", + "appUpdate.title": "アプリの更新", + "appUpdate.version": "このアプリは Lectern {version} です。", + "appUpdate.check": "更新を確認", + "appUpdate.checking": "新しいアプリを確認中…", + "appUpdate.current": "最新のアプリです。", + "appUpdate.available": "Lectern {version} が利用できます。", + "appUpdate.install": "{version} に更新", + "appUpdate.downloading": "更新をダウンロード中… {progress}%", + "appUpdate.installing": "Android が更新をインストールしています。確認を求められたら承認してください。", + "appUpdate.failed": "更新できませんでした: {error}", + "appUpdate.banner": "このアプリの Lectern {version} が公開されました。", + "appUpdate.update": "更新", + "appUpdate.later": "後で", + "appUpdate.retry": "再試行", "pairLink.copy": "リンクをコピー", "pairLink.copied": "ペアリングリンクをコピーしました。数分間、1 回だけ使えます。", "pairLink.share": "共有…", diff --git a/frontend/src/i18n/ja/plugins.ts b/frontend/src/i18n/ja/plugins.ts index 77586916..73d46269 100644 --- a/frontend/src/i18n/ja/plugins.ts +++ b/frontend/src/i18n/ja/plugins.ts @@ -78,6 +78,14 @@ const catalog: Record = { "plugins.hook.host": "サーバー上", "plugins.hook.target": "マシン上", "plugins.hookRuns": "最近のフック実行", + "plugins.kind.mods": "Mod", + "plugins.cap.mods": "ブラウザーまたはターミナルコンソールで独自のコードを実行する", + "plugins.cap.api": "あなたとして Lectern の API を使える", + "plugins.modsHere": "このブラウザーの Mod", + "plugins.mod.loading": "起動中", + "plugins.mod.running": "実行中", + "plugins.mod.paused": "一時停止", + "plugins.mod.failed": "失敗", "plugins.hookRun.ok": "成功", "plugins.hookRun.failed": "失敗", "plugins.scope": "適用範囲", diff --git a/frontend/src/i18n/ko/core.ts b/frontend/src/i18n/ko/core.ts index 679b0952..ecdfe6ab 100644 --- a/frontend/src/i18n/ko/core.ts +++ b/frontend/src/i18n/ko/core.ts @@ -344,6 +344,20 @@ const catalog: Record = { "appHosts.open": "현재 열림", "appHosts.switch": "전환", "appHosts.manage": "Lectern 추가 또는 관리…", + "appUpdate.title": "앱 업데이트", + "appUpdate.version": "이 앱은 Lectern {version}입니다.", + "appUpdate.check": "업데이트 확인", + "appUpdate.checking": "새 앱을 확인하는 중…", + "appUpdate.current": "최신 앱입니다.", + "appUpdate.available": "Lectern {version}을(를) 사용할 수 있습니다.", + "appUpdate.install": "{version}(으)로 업데이트", + "appUpdate.downloading": "업데이트 다운로드 중… {progress}%", + "appUpdate.installing": "Android가 업데이트를 설치하고 있습니다. 확인을 요청하면 승인하세요.", + "appUpdate.failed": "업데이트하지 못했습니다: {error}", + "appUpdate.banner": "이 앱용 Lectern {version}이(가) 나왔습니다.", + "appUpdate.update": "업데이트", + "appUpdate.later": "나중에", + "appUpdate.retry": "다시 시도", "pairLink.copy": "링크 복사", "pairLink.copied": "페어링 링크를 복사했습니다. 몇 분 동안 한 번만 사용할 수 있습니다.", "pairLink.share": "공유…", diff --git a/frontend/src/i18n/ko/plugins.ts b/frontend/src/i18n/ko/plugins.ts index 8af05700..b9d5f22a 100644 --- a/frontend/src/i18n/ko/plugins.ts +++ b/frontend/src/i18n/ko/plugins.ts @@ -78,6 +78,14 @@ const catalog: Record = { "plugins.hook.host": "서버에서", "plugins.hook.target": "머신에서", "plugins.hookRuns": "최근 훅 실행", + "plugins.kind.mods": "모드", + "plugins.cap.mods": "브라우저나 터미널 콘솔에서 자체 코드를 실행함", + "plugins.cap.api": "사용자로서 Lectern API를 사용할 수 있음", + "plugins.modsHere": "이 브라우저의 모드", + "plugins.mod.loading": "시작 중", + "plugins.mod.running": "실행 중", + "plugins.mod.paused": "일시 중지됨", + "plugins.mod.failed": "실패", "plugins.hookRun.ok": "성공", "plugins.hookRun.failed": "실패", "plugins.scope": "적용 범위", diff --git a/frontend/src/i18n/zh/core.ts b/frontend/src/i18n/zh/core.ts index 55819b53..7ecd10dc 100644 --- a/frontend/src/i18n/zh/core.ts +++ b/frontend/src/i18n/zh/core.ts @@ -337,6 +337,20 @@ const catalog: Record = { "appHosts.open": "当前打开", "appHosts.switch": "切换", "appHosts.manage": "添加或管理 Lectern…", + "appUpdate.title": "应用更新", + "appUpdate.version": "此应用为 Lectern {version}。", + "appUpdate.check": "检查更新", + "appUpdate.checking": "正在检查新版应用…", + "appUpdate.current": "已是最新版应用。", + "appUpdate.available": "Lectern {version} 可用。", + "appUpdate.install": "更新到 {version}", + "appUpdate.downloading": "正在下载更新… {progress}%", + "appUpdate.installing": "Android 正在安装更新;如有提示请确认。", + "appUpdate.failed": "无法更新:{error}", + "appUpdate.banner": "此应用的 Lectern {version} 已发布。", + "appUpdate.update": "更新", + "appUpdate.later": "稍后", + "appUpdate.retry": "重试", "pairLink.copy": "复制链接", "pairLink.copied": "配对链接已复制。它只能使用一次,有效期几分钟。", "pairLink.share": "分享…", diff --git a/frontend/src/i18n/zh/plugins.ts b/frontend/src/i18n/zh/plugins.ts index 8946138b..eae47719 100644 --- a/frontend/src/i18n/zh/plugins.ts +++ b/frontend/src/i18n/zh/plugins.ts @@ -78,6 +78,14 @@ const catalog: Record = { "plugins.hook.host": "在服务器上", "plugins.hook.target": "在机器上", "plugins.hookRuns": "最近的钩子运行", + "plugins.kind.mods": "模组", + "plugins.cap.mods": "在你的浏览器或终端控制台中运行自己的代码", + "plugins.cap.api": "可以以你的身份使用 Lectern 的 API", + "plugins.modsHere": "此浏览器中的模组", + "plugins.mod.loading": "启动中", + "plugins.mod.running": "运行中", + "plugins.mod.paused": "已暂停", + "plugins.mod.failed": "失败", "plugins.hookRun.ok": "成功", "plugins.hookRun.failed": "失败", "plugins.scope": "适用范围", diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index d7bfca8f..7ffbb8ad 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -26,6 +26,7 @@ import "./remote/remote.css"; import "./pairing/pair.css"; import "./review/review.css"; import "./shell/simple.css"; +import "./mods/mods.css"; // Last, so a phone's density overrides every view's desktop sizing. import "./shell/mobile.css"; import "./theme/theme.css"; diff --git a/frontend/src/mobile/UpdateBanner.tsx b/frontend/src/mobile/UpdateBanner.tsx new file mode 100644 index 00000000..eb1de350 --- /dev/null +++ b/frontend/src/mobile/UpdateBanner.tsx @@ -0,0 +1,39 @@ +// "Lectern 2.9.0 is out · Update", in the Android app, when a newer release +// has an app (native/update.ts). Checked quietly when the app opens, at most +// every few hours; dismissing hides that version until the next one. +import { useEffect } from "react"; +import { t, useLocale } from "../i18n"; +import { checkForUpdateNow, dismissUpdate, installUpdate, updateDismissed, useUpdateStatus } from "../native/update"; + +export function UpdateBanner() { + useLocale(); + const status = useUpdateStatus(); + useEffect(() => { + checkForUpdateNow(); + const visible = () => !document.hidden && checkForUpdateNow(); + document.addEventListener("visibilitychange", visible); + return () => document.removeEventListener("visibilitychange", visible); + }, []); + const working = status.state === "downloading" || status.state === "installing"; + const failed = status.state === "error" && !!status.latest; + if (!working && !failed && (status.state !== "available" || updateDismissed(status.latest))) return null; + return ( +
+ + {status.state === "downloading" + ? t("appUpdate.downloading", { progress: status.progress ?? 0 }) + : status.state === "installing" + ? t("appUpdate.installing") + : failed + ? t("appUpdate.failed", { error: status.error ?? "" }) + : t("appUpdate.banner", { version: status.latest ?? "" })} + + {!working && ( + <> + + + + )} +
+ ); +} diff --git a/frontend/src/mobile/phone.css b/frontend/src/mobile/phone.css index ff0b5f58..0bf0eb69 100644 --- a/frontend/src/mobile/phone.css +++ b/frontend/src/mobile/phone.css @@ -15,6 +15,17 @@ #offline-banner button { min-height: 32px; } body:has(#offline-banner) #view .scard { opacity: .92; } +/* A newer Android app is out (mobile/UpdateBanner.tsx). */ +#update-banner { + display: flex; align-items: center; gap: 8px; flex-wrap: wrap; + margin: 0 10px 10px; padding: 8px 12px; + background: color-mix(in srgb, var(--accent) 12%, var(--panel)); + border: 1px solid color-mix(in srgb, var(--accent) 40%, var(--line)); + border-radius: var(--r); color: var(--ink); font-size: 13px; +} +#update-banner > span { flex: 1 1 160px; min-width: 0; } +#update-banner button { min-height: 32px; } + #pull-refresh { position: fixed; top: calc(env(safe-area-inset-top, 0px) + 8px); left: 50%; z-index: 90; width: 36px; height: 36px; display: grid; place-items: center; diff --git a/frontend/src/mobile/usePhone.ts b/frontend/src/mobile/usePhone.ts new file mode 100644 index 00000000..54614f8f --- /dev/null +++ b/frontend/src/mobile/usePhone.ts @@ -0,0 +1,16 @@ +import { useEffect, useState } from "react"; + +const PHONE = "(max-width: 600px)"; + +/** Whether the view is phone-width (the same breakpoint as shell/mobile.css). */ +export function usePhone(): boolean { + const [phone, setPhone] = useState(() => typeof matchMedia === "function" && matchMedia(PHONE).matches); + useEffect(() => { + if (typeof matchMedia !== "function") return; + const media = matchMedia(PHONE); + const update = () => setPhone(media.matches); + media.addEventListener("change", update); + return () => media.removeEventListener("change", update); + }, []); + return phone; +} diff --git a/frontend/src/mods/host.ts b/frontend/src/mods/host.ts new file mode 100644 index 00000000..38793a85 --- /dev/null +++ b/frontend/src/mods/host.ts @@ -0,0 +1,409 @@ +// The web side of mods (docs/mods.md): loads each enabled mod into its own +// sandboxed iframe, runs the handler chains, and does what a mod asks of `$`. +// Mods never touch the page; everything they draw is data that React renders. + +import { ApiError, createClient, setRequestInterceptor, type RequestOptions } from "../api/client"; +import { sandboxDocument } from "./sandbox"; + +export type ModInfo = { plugin: string; id: string; name: string; api?: string; hash: string; script?: string; error?: string }; +export type ModElement = { type: string; props: Record }; +export type RenderResult = { hidden: boolean; append: ModElement[] }; +type Matcher = { eq: string } | { re: string; flags: string } | null; +type Handler = { hid: number; event: string; matcher: Matcher }; +type FnRef = { __fn: number; __mod?: string }; + +export type ModStatus = { + key: string; + plugin: string; + id: string; + name: string; + state: "loading" | "running" | "paused" | "failed"; + error?: string; + log: string[]; +}; + +const HANDLER_MS = 2000; +const RENDER_MS = 100; +const FAILS = 5; +const STATE_BYTES = 256 * 1024; +export const MATCHED_EVENT: Record) => string> = { + "ui.render": (e) => String(e.component ?? ""), + "command.run": (e) => String(e.id ?? ""), + "server.event": (e) => String(e.type ?? ""), +}; + +// Paths a mod may never reach, whatever its api capability: installing or +// trusting plugins, deciding approvals, sign-in, pairing and secrets. +const FORBIDDEN = [ + /^\/api\/(plugins|plugin-sources|auth|pairing|devices|secrets|tokens?|whoami)\b/, + /^\/api\/approvals\/[^/]+\/decision\b/, + /secret|token|password|credential/i, +]; +export function apiAllowed(api: string | undefined, method: string, path: string): string | null { + if (!api) return "this mod has no api capability"; + if (!path.startsWith("/api/") || path.includes("..")) return "paths start with /api/"; + if (FORBIDDEN.some((re) => re.test(path.split("?")[0]!))) return `${path} is not available to mods`; + if (method !== "GET" && api !== "write") return "this mod may only read (api: read)"; + return null; +} + +class Mod { + frame?: HTMLIFrameElement; + port?: MessagePort; + handlers: Handler[] = []; + state: ModStatus["state"] = "loading"; + error?: string; + log: string[] = []; + fails: number[] = []; + status: string | null = null; + commands: { id: string; title: string }[] = []; + data: Record; + waits = new Map) => void>(); + /** Button references this mod has drawn; another mod cannot forge one. */ + drawn = new Set(); + constructor(readonly info: ModInfo) { + this.data = readState(this.key); + } + get key() { return `${this.info.plugin}/${this.info.id}`; } + note(line: string) { + this.log.push(new Date().toLocaleTimeString() + " " + line); + if (this.log.length > 50) this.log.shift(); + } +} + +const stateKey = (key: string) => "lec-mod-state:" + key; +function readState(key: string): Record { + try { return JSON.parse(localStorage.getItem(stateKey(key)) || "{}"); } catch { return {}; } +} + +let seq = 0; + +export class ModHost { + private mods: Mod[] = []; + private listeners = new Set<() => void>(); + private version = 0; + panes: { key: string; id: string; title: string }[] = []; + notify: (text: string, error?: boolean) => void = () => {}; + private request = createClient({ bypassInterceptors: true }); + + constructor(private readonly root: () => HTMLElement = () => document.body) {} + + subscribe = (fn: () => void) => { this.listeners.add(fn); return () => { this.listeners.delete(fn); }; }; + snapshot = () => this.version; + private changed() { this.version++; for (const fn of this.listeners) fn(); } + + statuses(): ModStatus[] { + return this.mods.map((m) => ({ key: m.key, plugin: m.info.plugin, id: m.info.id, name: m.info.name, state: m.state, error: m.error, log: [...m.log] })); + } + statusTexts(): { key: string; text: string }[] { + return this.mods.filter((m) => m.status && m.state === "running").map((m) => ({ key: m.key, text: m.status! })); + } + commands(): { mod: string; id: string; title: string; name: string }[] { + return this.mods.filter((m) => m.state === "running").flatMap((m) => m.commands.map((c) => ({ mod: m.key, id: c.id, title: c.title, name: m.info.name }))); + } + has(event: string) { return this.mods.some((m) => m.state === "running" && m.handlers.some((h) => h.event === event)); } + + /** Starts, keeps or replaces mods so the running set matches list. */ + load(list: ModInfo[]) { + const want = new Map(list.map((m) => [`${m.plugin}/${m.id}`, m])); + for (const m of this.mods) { + const next = want.get(m.key); + if (!next || next.hash !== m.info.hash || next.script !== m.info.script) this.stop(m); + } + const kept = new Map(this.mods.filter((m) => m.frame || m.state === "failed").map((m) => [m.key, m])); + this.mods = list.map((info) => { + const key = `${info.plugin}/${info.id}`; + const old = kept.get(key); + if (old && old.info.hash === info.hash && old.info.script === info.script) return old; + const mod = new Mod(info); + if (info.error || !info.script) { + mod.state = "failed"; + mod.error = info.error || "no code"; + } else this.start(mod); + return mod; + }); + this.panes = this.panes.filter((p) => this.mods.some((m) => m.key === p.key)); + this.changed(); + } + + async refresh() { + const list = await this.request("/plugins/mods?surface=web"); + this.load(list); + } + + private stop(m: Mod) { + m.port?.close(); + m.frame?.remove(); + m.frame = m.port = undefined; + m.state = "paused"; + } + + private start(m: Mod) { + const frame = document.createElement("iframe"); + frame.setAttribute("sandbox", "allow-scripts"); + frame.setAttribute("aria-hidden", "true"); + frame.dataset.mod = m.key; + frame.style.display = "none"; + frame.srcdoc = sandboxDocument(); + frame.addEventListener("load", () => { + const channel = new MessageChannel(); + m.port = channel.port1; + m.port.onmessage = (event) => void this.receive(m, event.data); + frame.contentWindow?.postMessage({ t: "init" }, "*", [channel.port2]); + m.port.postMessage({ t: "load", script: m.info.script, mod: { plugin: m.info.plugin, id: m.info.id }, state: m.data }); + }, { once: true }); + m.frame = frame; + this.root().appendChild(frame); + } + + private async receive(m: Mod, msg: Record) { + switch (msg.t) { + case "ready": + m.state = "running"; + this.changed(); + void this.dispatch("app.start", { surface: "web", version: document.documentElement.dataset.version ?? "" }, async () => null, [m]); + return; + case "failed": + m.state = "failed"; + m.error = msg.error; + m.note("failed to start: " + msg.error); + this.changed(); + return; + case "on": + m.handlers.push({ hid: msg.hid, event: msg.event, matcher: msg.matcher }); + return; + case "result": + m.waits.get(msg.rid)?.(msg); + return; + case "$": + return this.dollar(m, msg); + } + } + + private async dollar(m: Mod, msg: Record) { + const [a, b, c] = msg.args ?? []; + const reply = (value: unknown, error?: string) => msg.rid && m.port?.postMessage({ t: "reply", rid: msg.rid, value, error }); + switch (msg.op) { + case "toast": this.notify(`${m.info.name}: ${a}`); return; + case "status": m.status = a; this.changed(); return; + case "open": + if (!this.panes.some((p) => p.key === m.key && p.id === a.id)) this.panes = [...this.panes, { key: m.key, id: a.id, title: a.title }]; + this.changed(); + return; + case "close": this.panes = this.panes.filter((p) => !(p.key === m.key && p.id === a)); this.changed(); return; + case "render": this.changed(); return; + case "state": { + const next = { ...m.data, [a]: b }; + const text = JSON.stringify(next); + if (text.length > STATE_BYTES) { m.note(`state.set(${a}) refused: more than 256 KB`); return; } + m.data = next; + try { localStorage.setItem(stateKey(m.key), text); } catch { /* private window: kept for this page */ } + this.changed(); + return; + } + case "command": + m.commands = [...m.commands.filter((x) => x.id !== a.id), a]; + this.changed(); + return; + case "navigate": if (typeof a === "string" && a.startsWith("#")) location.hash = a; return; + case "log": m.note(String(a)); this.changed(); return; + case "api": { + const refused = apiAllowed(m.info.api, a, b); + if (refused) return reply(undefined, refused); + try { + reply(await this.request(b.replace(/^\/api/, ""), { method: a, body: c ?? undefined })); + } catch (e) { + reply(undefined, e instanceof Error ? e.message : String(e)); + } + return; + } + case "next": { + const cont = this.continuations.get(a); + if (!cont) return reply(undefined, "next() was called after this handler finished"); + try { reply(await cont(b)); } catch (e) { reply(undefined, e instanceof Error ? e.message : String(e)); } + return; + } + } + } + + private continuations = new Map Promise>(); + + private fail(m: Mod, what: string) { + m.note(what); + const now = Date.now(); + m.fails = [...m.fails.filter((t) => now - t < 60_000), now]; + if (m.fails.length >= FAILS && m.state === "running") { + m.state = "paused"; + m.error = `paused after ${FAILS} failures in a minute: ${what}`; + this.notify(`${m.info.name}: ${m.error}`, true); + } + this.changed(); + } + + private chain(event: string, e: Record, only?: Mod[]) { + const subject = MATCHED_EVENT[event]?.(e); + const out: { m: Mod; h: Handler }[] = []; + for (const m of only ?? this.mods) { + if (m.state !== "running") continue; + for (const h of m.handlers) { + if (h.event !== event) continue; + if (h.matcher && "eq" in h.matcher && h.matcher.eq !== subject) continue; + if (h.matcher && "re" in h.matcher) { + try { if (!new RegExp(h.matcher.re, h.matcher.flags).test(subject ?? "")) continue; } catch { continue; } + } + out.push({ m, h }); + } + } + return out; + } + + /** + * Runs event through every matching handler, in mod order, ending in def. + * A handler that throws or runs over its time is skipped: the chain goes + * on as if it had called next(e). If it had already called next, that + * call's result stands, so Lectern's own action never runs twice. + */ + async dispatch(event: string, e: Record, def: (e: Record) => Promise, only?: Mod[]): Promise { + const links = this.chain(event, e, only); + const run = async (i: number, ev: Record): Promise => { + if (i >= links.length) return def(ev); + const { m, h } = links[i]!; + const rid = ++seq; + let downstream: Promise | undefined; + this.continuations.set(rid, (e2) => { + if (downstream) return downstream; + downstream = run(i + 1, (e2 && typeof e2 === "object" ? e2 : ev) as Record); + return downstream; + }); + try { + const msg = await new Promise>((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`${event} handler took longer than ${HANDLER_MS / 1000}s`)), HANDLER_MS); + m.waits.set(rid, (reply) => { clearTimeout(timer); resolve(reply); }); + m.port?.postMessage({ t: "invoke", rid, hid: h.hid, e: ev }); + }).finally(() => m.waits.delete(rid)); + if (msg.error !== undefined) throw new Error(msg.error); + return tag(msg.value, m, this.mods); + } catch (err) { + this.fail(m, `${event}: ${err instanceof Error ? err.message : String(err)}`); + return downstream ?? run(i + 1, ev); + } finally { + // Late next() calls get downstream if it exists; otherwise refused. + if (!downstream) this.continuations.delete(rid); + else setTimeout(() => this.continuations.delete(rid), HANDLER_MS); + } + }; + return run(0, e) as Promise; + } + + /** What mods add to (or whether they hide) a component. */ + async render(component: string, props: Record, owner?: string): Promise { + const empty: RenderResult = { hidden: false, append: [] }; + if (!this.has("ui.render")) return empty; + // A pane is drawn by the mod that opened it. + const only = owner ? this.mods.filter((m) => m.key === owner) : undefined; + const e = { component, props, surface: "web", viewport: { width: innerWidth, height: innerHeight } }; + const result = await Promise.race([ + this.dispatch("ui.render", e, async () => ({ hidden: false, append: [] }), only), + new Promise((r) => setTimeout(() => r(null), RENDER_MS)), + ]); + if (!result || typeof result !== "object") return empty; + const r = result as Partial; + return { hidden: !!r.hidden, append: Array.isArray(r.append) ? r.append.filter(isElement) : [] }; + } + + closePane(key: string, id: string) { + this.panes = this.panes.filter((p) => !(p.key === key && p.id === id)); + this.changed(); + } + + /** A Button's onPress, run in the mod that drew it. */ + press(ref: unknown, ...args: unknown[]) { + const fn = ref as FnRef; + const m = this.mods.find((x) => x.key === fn?.__mod); + if (m?.state === "running" && m.drawn.has(fn.__fn)) m.port?.postMessage({ t: "fn", id: fn.__fn, args }); + } + + /** Runs a mod's palette command through the command.run chain. */ + runCommand(mod: string, id: string, args: Record = {}) { + const m = this.mods.find((x) => x.key === mod); + return this.dispatch("command.run", { id, mod, args }, async (e) => { + if (!m || m.state !== "running") return null; + const rid = ++seq; + const msg = await new Promise>((resolve) => { + m.waits.set(rid, resolve); + m.port?.postMessage({ t: "command", rid, id: e.id, args: e.args }); + }).finally(() => m.waits.delete(rid)); + if (msg.error !== undefined) this.fail(m, `command ${id}: ${msg.error}`); + return msg.value ?? null; + }); + } + + /** + * prompt.submit and approval.decide, hooked where every screen sends them: + * the API client. A deny becomes the request's error, which each screen + * already shows. + */ + intercept = async (path: string, options: RequestOptions, send: (p: string, o: RequestOptions) => Promise) => { + const body = options.body as Record | undefined; + const sendTo = /^\/sessions\/(\d+)\/send$/.exec(path); + if (sendTo && options.method === "POST" && body && typeof body.text === "string" && this.has("prompt.submit")) { + const result = await this.dispatch("prompt.submit", { session_id: Number(sendTo[1]), text: body.text }, async (e) => + send(path, { ...options, body: { ...body, text: String(e.text ?? "") } })); + return refuse(result); + } + const decide = /^\/approvals\/(\d+)\/decision$/.exec(path); + if (decide && options.method === "POST" && body && this.has("approval.decide")) { + const decision = body.decision === "denied" ? "deny" : "allow"; + const result = await this.dispatch("approval.decide", { approval: { id: Number(decide[1]) }, decision }, async () => send(path, options)); + return refuse(result); + } + return send(path, options); + }; + + serverEvent(type: string, data: unknown) { + if (this.has("server.event")) void this.dispatch("server.event", { type, data }, async () => null); + } + + install() { + setRequestInterceptor(this.intercept); + return () => setRequestInterceptor(undefined); + } +} + +function refuse(result: unknown) { + if (result && typeof result === "object" && "deny" in result) { + const reason = String((result as { deny: unknown }).deny || "A mod stopped this."); + throw new ApiError(409, reason, { detail: reason, mod_denied: true }); + } + return result; +} + +function isElement(v: unknown): v is ModElement { + return !!v && typeof v === "object" && typeof (v as ModElement).type === "string"; +} + +// Marks every function reference a mod returned with the mod it belongs to. +// A reference passed along from a mod further down the chain keeps its +// owner, but only one that owner really drew. +function tag(v: unknown, m: Mod, mods: Mod[], depth = 0): unknown { + if (depth > 40) return null; + if (Array.isArray(v)) return v.map((x) => tag(x, m, mods, depth + 1)); + if (v && typeof v === "object") { + const o = v as Record; + if (typeof o.__fn === "number") { + if (typeof o.__mod !== "string" || o.__mod === m.key) { + m.drawn.add(o.__fn); + if (m.drawn.size > 5000) m.drawn.delete(m.drawn.values().next().value as number); + return { __fn: o.__fn, __mod: m.key }; + } + const owner = mods.find((x) => x.key === o.__mod); + return owner?.drawn.has(o.__fn) ? { __fn: o.__fn, __mod: owner.key } : null; + } + const out: Record = {}; + for (const k of Object.keys(o)) out[k] = tag(o[k], m, mods, depth + 1); + return out; + } + return v; +} + +export const modHost = new ModHost(); diff --git a/frontend/src/mods/mods.css b/frontend/src/mods/mods.css new file mode 100644 index 00000000..7142d9d3 --- /dev/null +++ b/frontend/src/mods/mods.css @@ -0,0 +1,31 @@ +/* Mods (docs/mods.md): what mods draw, in the app's own tokens. */ +.mod-slot, .mod-box { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; min-width: 0; } +.mod-col { flex-direction: column; align-items: flex-start; } +.mod-text { color: var(--ink); font-size: 12.5px; overflow-wrap: anywhere; } +.mod-bold { font-weight: 600; } +.mod-mono { font-family: var(--mono); } +.mod-badge { + display: inline-block; padding: 1px 7px; border: 1px solid var(--line-2); border-radius: 999px; + color: var(--ink-dim); font-size: 10.5px; font-weight: 600; line-height: 16px; white-space: nowrap; +} +.mod-dim { color: var(--ink-faint); } +.mod-accent { color: var(--accent-soft); border-color: color-mix(in srgb, var(--accent-soft) 45%, var(--bg)); } +.mod-warn { color: var(--amber); border-color: var(--amber); } +.mod-danger { color: var(--red); border-color: color-mix(in srgb, var(--red) 55%, var(--bg)); } +.mod-ok { color: var(--green); border-color: color-mix(in srgb, var(--green) 55%, var(--bg)); } +.mod-button { min-height: 28px; padding: 2px 10px; font-size: 12px; } +.mod-link { color: var(--accent-soft); font-size: 12.5px; } +.mod-status { display: inline-flex; align-items: center; gap: 10px; margin-right: 10px; min-width: 0; max-width: 40vw; overflow: hidden; } +.mod-panes { + position: fixed; right: 12px; bottom: calc(var(--tabbar-h, 0px) + 12px); z-index: 40; + display: flex; flex-direction: column; gap: 10px; width: min(380px, calc(100vw - 24px)); max-height: 70dvh; +} +.mod-pane { + display: flex; flex-direction: column; min-height: 0; border: 1px solid var(--line-2); border-radius: 12px; + background: var(--panel); box-shadow: var(--shadow); +} +.mod-pane > header { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--line); } +.mod-close { min-height: 28px; padding: 0 9px; } +.mod-pane-body { padding: 12px; overflow: auto; } +.mod-pane-body > .mod-slot { align-items: stretch; } +@media (min-width: 1024px) { .mod-panes { bottom: 16px; right: 20px; } } diff --git a/frontend/src/mods/react.tsx b/frontend/src/mods/react.tsx new file mode 100644 index 00000000..176bee45 --- /dev/null +++ b/frontend/src/mods/react.tsx @@ -0,0 +1,100 @@ +// React's side of mods: where their elements are drawn (docs/mods.md). +import { useEffect, useState, useSyncExternalStore, type ReactNode } from "react"; +import { modHost, type ModElement, type RenderResult } from "./host"; +import { safeHref } from "../plugins/contributions"; + +export function useModsVersion() { + return useSyncExternalStore(modHost.subscribe, modHost.snapshot); +} + +/** + * What mods hide or add to one component. Drawn without them first; their + * part arrives a moment later, and again whenever a mod asks to redraw. + */ +export function useModRender(component: string, props: Record, owner?: string): RenderResult | null { + const version = useModsVersion(); + const [result, setResult] = useState(null); + const key = JSON.stringify(props); + useEffect(() => { + if (!modHost.has("ui.render")) { + setResult(null); + return; + } + let alive = true; + void modHost.render(component, JSON.parse(key), owner).then((r) => alive && setResult(r.hidden || r.append.length ? r : null)); + return () => { alive = false; }; + }, [component, key, owner, version]); + return result; +} + +const TONES = new Set(["dim", "accent", "warn", "danger", "ok"]); +const tone = (p: Record) => (typeof p.tone === "string" && TONES.has(p.tone) ? ` mod-${p.tone}` : ""); +const str = (v: unknown) => (v == null ? "" : String(v)); + +function ModElementView({ el }: { el: ModElement }): ReactNode { + const p = el.props ?? {}; + const children = Array.isArray(p.children) ? (p.children as ModElement[]) : []; + switch (el.type) { + case "Box": + return ( + + {children.map((c, i) => )} + + ); + case "Text": + return {str(p.text ?? p.children)}; + case "Badge": + return {str(p.text ?? p.children)}; + case "Button": + return ( + + ); + case "Link": { + const href = safeHref(str(p.href)); + if (!href) return {str(p.label)}; + return event.stopPropagation()}>{str(p.label ?? href)}; + } + } + return null; +} + +export function ModElements({ elements, className = "" }: { elements: ModElement[]; className?: string }) { + if (!elements.length) return null; + return {elements.map((el, i) => )}; +} + +/** The status component: each mod's $.ui.status text and its render. */ +export function ModStatus() { + useModsVersion(); + const rendered = useModRender("status", {}); + const texts = modHost.statusTexts(); + if (!texts.length && !rendered?.append.length) return null; + return ( + + {texts.map((s) => {s.text})} + {rendered && } + + ); +} + +function Pane({ pane }: { pane: { key: string; id: string; title: string } }) { + const rendered = useModRender("pane", { id: pane.id }, pane.key); + return ( +
+
+ {pane.title} + +
+
{rendered ? : null}
+
+ ); +} + +/** Panes mods opened with $.ui.open, docked on the right. */ +export function ModPanes() { + useModsVersion(); + if (!modHost.panes.length) return null; + return
{modHost.panes.map((p) => )}
; +} diff --git a/frontend/src/mods/sandbox.ts b/frontend/src/mods/sandbox.ts new file mode 100644 index 00000000..35c95907 --- /dev/null +++ b/frontend/src/mods/sandbox.ts @@ -0,0 +1,151 @@ +// The code that runs inside a mod's iframe (docs/mods.md). The iframe is +// sandboxed without allow-same-origin and carries its own CSP with no +// network, so a mod reaches Lectern only through the messages below. Keep +// everything inside sandboxMain: it is sent as source text, so nothing +// outside the function exists in the iframe. + +/* eslint-disable @typescript-eslint/no-explicit-any */ +function sandboxMain() { + type Msg = Record; + let port: MessagePort | undefined; + const handlers: Array<(...args: any[]) => any> = []; + const commands = new Map any>(); + const fns = new Map any>(); + const pending = new Map void; rej: (e: any) => void }>(); + let fnSeq = 0; + let callSeq = 0; + let state: Record = {}; + let mod = { plugin: "", id: "" }; + + const post = (msg: Msg) => port?.postMessage(msg); + const call = (op: string, args: unknown[]) => + new Promise((res, rej) => { + const rid = ++callSeq; + pending.set(rid, { res, rej }); + post({ t: "$", rid, op, args }); + }); + const fire = (op: string, args: unknown[]) => post({ t: "$", op, args }); + const clone = (v: unknown) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v))); + // Functions cannot cross postMessage: a Button's onPress becomes a + // reference the host calls back through. + const ser = (v: any, depth = 0): any => { + if (depth > 40) return null; + if (typeof v === "function") { + const id = ++fnSeq; + fns.set(id, v); + if (fns.size > 2000) fns.delete(fns.keys().next().value as number); + return { __fn: id }; + } + if (Array.isArray(v)) return v.map((x) => ser(x, depth + 1)); + if (v && typeof v === "object") { + if (v instanceof RegExp) return String(v); + const out: Record = {}; + for (const k of Object.keys(v)) out[k] = ser(v[k], depth + 1); + return out; + } + return v; + }; + const el = (type: string) => (props: Record = {}) => ({ type, props }); + const elements = { Box: el("Box"), Text: el("Text"), Badge: el("Badge"), Button: el("Button"), Link: el("Link") }; + const $ = { + surface: "web", + get mod() { return { ...mod }; }, + ui: { + resolve: () => elements, + toast: (text: unknown) => fire("toast", [String(text)]), + status: (text: unknown) => fire("status", [text == null ? null : String(text)]), + open: (o: { id: string; title?: string; focus?: boolean }) => fire("open", [{ id: String(o?.id), title: String(o?.title ?? o?.id) }]), + close: (id: string) => fire("close", [String(id)]), + render: () => fire("render", []), + }, + state: { + get: (key: string) => clone(state[key]), + set: (key: string, value: unknown) => { + state[key] = clone(value); + fire("state", [key, state[key] ?? null]); + }, + }, + command: { + register: (c: { id: string; title: string; run: (...a: any[]) => any }) => { + commands.set(String(c.id), c.run); + fire("command", [{ id: String(c.id), title: String(c.title ?? c.id) }]); + }, + }, + navigate: (hash: string) => fire("navigate", [String(hash)]), + api: { + get: (path: string) => call("api", ["GET", path]), + post: (path: string, body?: unknown) => call("api", ["POST", path, clone(body)]), + put: (path: string, body?: unknown) => call("api", ["PUT", path, clone(body)]), + delete: (path: string) => call("api", ["DELETE", path]), + }, + sleep: (ms: number) => new Promise((r) => setTimeout(r, Math.max(0, Number(ms) || 0))), + log: (...args: unknown[]) => fire("log", [args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" ")]), + }; + const on = (event: string, a: any, b?: any) => { + const fn = typeof b === "function" ? b : a; + const m = typeof b === "function" ? a : undefined; + if (typeof fn !== "function") throw new TypeError("on(event, [matcher], handler): handler must be a function"); + const hid = handlers.push(fn) - 1; + const matcher = m instanceof RegExp ? { re: m.source, flags: m.flags } : m == null ? null : { eq: String(m) }; + post({ t: "on", hid, event: String(event), matcher }); + }; + const errorText = (e: any) => (e && e.stack ? String(e.stack).split("\n").slice(0, 3).join("\n") : String(e)); + + async function receive(msg: Msg) { + switch (msg.t) { + case "load": { + mod = msg.mod; state = msg.state || {}; + try { + const register = new Function(msg.script + "\n;return typeof register === 'function' ? register : null;")(); + if (!register) throw new Error("the mod does not define register"); + await register(on, {}); + post({ t: "ready" }); + } catch (e) { + post({ t: "failed", error: errorText(e) }); + } + return; + } + case "invoke": { + const next = (e2?: unknown) => call("next", [msg.rid, e2 === undefined ? msg.e : clone(e2)]); + try { + const value = await handlers[msg.hid]!($, msg.e, next); + post({ t: "result", rid: msg.rid, value: ser(value) }); + } catch (e) { + post({ t: "result", rid: msg.rid, error: errorText(e) }); + } + return; + } + case "reply": { + const p = pending.get(msg.rid); + pending.delete(msg.rid); + if (msg.error !== undefined) p?.rej(new Error(msg.error)); + else p?.res(msg.value); + return; + } + case "fn": { + try { await fns.get(msg.id)?.(...(msg.args || [])); } catch (e) { fire("log", ["error: " + errorText(e)]); } + return; + } + case "command": { + try { + const value = await commands.get(msg.id)?.($, msg.args || {}); + post({ t: "result", rid: msg.rid, value: ser(value) }); + } catch (e) { + post({ t: "result", rid: msg.rid, error: errorText(e) }); + } + return; + } + } + } + window.addEventListener("message", (event) => { + if (port || event.data?.t !== "init" || !event.ports[0]) return; + port = event.ports[0]; + port.onmessage = (m) => void receive(m.data); + }); +} + +/** The iframe document a mod runs in. */ +export const sandboxDocument = () => + "" + + `` + + ""; diff --git a/frontend/src/native/bridge.ts b/frontend/src/native/bridge.ts index ddd125c7..2c6d51f5 100644 --- a/frontend/src/native/bridge.ts +++ b/frontend/src/native/bridge.ts @@ -41,6 +41,13 @@ export interface NativeBridge { openHosts?(): void; /** Opens an http(s) address in the phone's browser. */ openUrl?(url: string): void; + // ---- added in app 2.8.0 ---- + /** Looks for a newer app; answered with a "lectern-native-update" event (native/update.ts). */ + checkUpdate?(): void; + /** Downloads, verifies and installs the newest app; Android asks to confirm. */ + installUpdate?(): void; + /** The page's background, for the phone's status and navigation bars. */ + barColors?(background: string, light: boolean): void; } export interface AppHost { diff --git a/frontend/src/native/update.test.ts b/frontend/src/native/update.test.ts new file mode 100644 index 00000000..c35bd224 --- /dev/null +++ b/frontend/src/native/update.test.ts @@ -0,0 +1,37 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// A browser-shaped window with an app bridge that records what it is asked. +const calls: string[] = []; +const store = new Map(); +const target = new EventTarget(); +Object.assign(globalThis, { + window: Object.assign(target, { + LecternNative: { checkUpdate: () => calls.push("check"), installUpdate: () => calls.push("install") }, + }), + localStorage: { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => void store.set(k, v), + }, + CustomEvent: class extends Event { detail: T; constructor(type: string, init: { detail: T }) { super(type); this.detail = init.detail; } }, +}); +const update = await import("./update"); + +test("the app checks for an update at most every few hours", () => { + calls.length = 0; + update.checkForUpdateNow(Date.now()); + update.checkForUpdateNow(Date.now() + 60_000); + assert.deepEqual(calls, ["check"]); + update.checkForUpdateNow(Date.now() + 7 * 60 * 60 * 1000); + assert.deepEqual(calls, ["check", "check"]); +}); + +test("a dismissed version stays hidden until the next one, and Update asks the app", () => { + update._setUpdateStatus({ state: "available", current: "2.8.0", latest: "2.9.0" }); + assert.equal(update.updateDismissed("2.9.0"), false); + update.dismissUpdate("2.9.0"); + assert.equal(update.updateDismissed("2.9.0"), true); + assert.equal(update.updateDismissed("2.10.0"), false); + update.installUpdate(); + assert.equal(calls.at(-1), "install"); +}); diff --git a/frontend/src/native/update.ts b/frontend/src/native/update.ts new file mode 100644 index 00000000..d8973a68 --- /dev/null +++ b/frontend/src/native/update.ts @@ -0,0 +1,105 @@ +// Updating the Android app from inside it (docs/android.md, "Updates"). The +// native side (Updates.kt) reads the newest release's lectern-android.json, +// downloads and verifies the APK and hands it to Android's installer; this +// keeps what it last said, for the banner and Settings → Phone & devices. +import { useSyncExternalStore } from "react"; +import { nativeBridge } from "./bridge"; + +export interface UpdateStatus { + state: "idle" | "checking" | "current" | "available" | "downloading" | "installing" | "error"; + current?: string; + latest?: string; + progress?: number; + error?: string; +} + +const CHECKED_KEY = "lec-app-update-checked"; +const DISMISSED_KEY = "lec-app-update-dismissed"; +const EVERY_MS = 6 * 60 * 60 * 1000; + +let status: UpdateStatus = { state: "idle" }; +const listeners = new Set<() => void>(); +const set = (next: UpdateStatus) => { + status = next; + for (const fn of listeners) fn(); +}; +let listening = false; +function listen() { + if (listening || typeof window === "undefined") return; + listening = true; + window.addEventListener("lectern-native-update", (event) => { + const detail = (event as CustomEvent).detail; + // An error from the installer does not repeat which version it was. + if (detail && typeof detail.state === "string") set({ latest: status.latest, ...detail }); + }); +} + +/** Whether this app can update itself (app 2.8.0 and later). */ +export function canUpdate(): boolean { + return typeof nativeBridge()?.checkUpdate === "function"; +} + +export function checkForUpdate() { + const bridge = nativeBridge(); + if (!bridge?.checkUpdate) return; + listen(); + set({ ...status, state: "checking", error: undefined }); + try { + localStorage.setItem(CHECKED_KEY, String(Date.now())); + } catch { + // Private storage: the check still runs, only more often. + } + bridge.checkUpdate(); +} + +/** At most every few hours, quietly, when the app opens. */ +export function checkForUpdateNow(now = Date.now()) { + if (!canUpdate()) return; + let last = 0; + try { + last = Number(localStorage.getItem(CHECKED_KEY)) || 0; + } catch { + last = 0; + } + if (now - last >= EVERY_MS) checkForUpdate(); +} + +export function installUpdate() { + const bridge = nativeBridge(); + if (!bridge?.installUpdate) return; + listen(); + set({ ...status, state: "downloading", progress: 0, error: undefined }); + bridge.installUpdate(); +} + +export function dismissUpdate(version: string) { + try { + localStorage.setItem(DISMISSED_KEY, version); + } catch { + // Not remembered; the banner comes back next time. + } + set({ ...status }); +} + +export function updateDismissed(version: string | undefined): boolean { + try { + return !!version && localStorage.getItem(DISMISSED_KEY) === version; + } catch { + return false; + } +} + +const subscribe = (fn: () => void) => { + listen(); + listeners.add(fn); + return () => listeners.delete(fn); +}; + +export function useUpdateStatus(): UpdateStatus { + return useSyncExternalStore(subscribe, () => status, () => status); +} + +/** Test seam: feed a status as the native side would. */ +export function _setUpdateStatus(next: UpdateStatus) { + set(next); +} diff --git a/frontend/src/sessions/SessionCard.tsx b/frontend/src/sessions/SessionCard.tsx index 50e1f07e..6953241f 100644 --- a/frontend/src/sessions/SessionCard.tsx +++ b/frontend/src/sessions/SessionCard.tsx @@ -1,4 +1,5 @@ import { OpenInEditor } from "../remote/OpenInEditor"; +import { ModElements, useModRender } from "../mods/react"; import { SessionLineage } from "../continuity/SessionLineage"; import { SessionMemory } from "./SessionMemory"; import { useState } from "react"; @@ -63,6 +64,12 @@ interface Props { // and tests that predate the feature keep compiling. approval?: Approval; } +// The part of a session a mod sees: what a card shows, not its output. +const modSession = (s: SessionView) => ({ + id: s.id, name: s.name, agent: s.agent, model: s.model ?? "", status: s.status, + project_id: s.project_id ?? null, project_name: s.project_name ?? "", idle_seconds: s.idle_seconds, + ended: !!s.ended_at, +}); export function SessionCard({ session: s, approval, @@ -88,6 +95,8 @@ export function SessionCard({ onRevive, onClosed, }: Props) { + // What mods hide or add to this card (docs/mods.md). + const modded = useModRender("session.card", { session: modSession(s) }); useLocale(); const [progress, setProgress] = useState(""), [progressBusy, setProgressBusy] = useState(false); @@ -248,6 +257,7 @@ export function SessionCard({ : s.setup_error ? t("sessions.card.setupFailedPreview", { error: s.setup_error }) : s.pane_tail || ""; + if (modded?.hidden) return null; return (
+
{scratch ? scratchDefaultName(s) : s.project_name || t("sessions.card.unassigned")}
@@ -291,6 +301,7 @@ export function SessionCard({ : t("sessions.card.quiet", { duration: duration(s.idle_seconds) })}
+ {modded && } {activeApproval && (
diff --git a/frontend/src/sessions/Sessions.tsx b/frontend/src/sessions/Sessions.tsx index 1c0a0c27..54674b09 100644 --- a/frontend/src/sessions/Sessions.tsx +++ b/frontend/src/sessions/Sessions.tsx @@ -1,4 +1,5 @@ import { SwipeRow } from "../mobile/SwipeRow"; +import { usePhone } from "../mobile/usePhone"; import { sessionSwipes } from "../mobile/sessionSwipes"; import { ScratchReview } from "./ScratchReview"; import { Fragment, useEffect, useMemo, useRef, useState } from "react"; @@ -106,6 +107,7 @@ export function Sessions({ const [rows, setRows] = useState([]), [scope, setScope] = useState<"active" | "all" | "archived">("active"), [query, setQuery] = useState(""), + phone = usePhone(), [group, setGroup] = useState(savedGrouping), [scratchShown, setScratchShown] = useState(false), [collapsed, setCollapsed] = useState(savedCollapsed), @@ -554,7 +556,16 @@ export function Sessions({ const firstRun = scope === "active" && !query && rows.length === 0; // With only a couple of sessions there is nothing to search, group or // filter (re-audit N8): those tools wait under ⋯ until the list grows. - const few = scope === "active" && !query && group === "none" && rows.filter((row) => !row.ended_at && row.status !== "dead").length <= 2; + const activeCount = rows.filter((row) => !row.ended_at && row.status !== "dead").length; + const few = scope === "active" && !query && group === "none" && activeCount <= 2; + // A phone keeps one row of header: Start an agent and ⋯. Everything else + // the desk shows there (saved search, discovery, restore, grouping) waits + // under ⋯, and the search field appears only once the list is long enough + // to need it. + const compact = few || phone; + // Typing in ⋯'s search brings the field out under the header, still + // focused, and closes the menu, so the results it filters are not under it. + const showSearch = phone ? activeCount > 4 || !!query : !few; const headerExtras = ( <> ); return (
@@ -636,16 +672,8 @@ export function Sessions({

- {!few && headerExtras} - + {!compact && headerExtras} + {!phone && restoreButton} {openTerminals && ( - {few && ( + {compact && (
⋯
{headerExtras} - {listTools} + {phone && restoreButton} + {!showSearch && searchField} + {listFilters} + ) : ( + + )} +
+
+ ); +} + +function appVersion(): string { + try { + return (JSON.parse(nativeBridge()?.version() ?? "{}") as { app?: string }).app ?? ""; + } catch { + return ""; + } +} diff --git a/frontend/src/settings/Plugins.tsx b/frontend/src/settings/Plugins.tsx index a67e88e6..a5e340e0 100644 --- a/frontend/src/settings/Plugins.tsx +++ b/frontend/src/settings/Plugins.tsx @@ -4,6 +4,8 @@ // preview of exactly what the plugin may do. The server refuses every change // that does not come from a signed-in person; this page only shows the // decision clearly enough to make it. +import { modHost } from "../mods/host"; +import { useModsVersion } from "../mods/react"; import { useEffect, useState } from "react"; import type { Project } from "../types"; import type { SettingsApi } from "./Settings"; @@ -58,6 +60,7 @@ interface PluginDetail extends PluginRow { quick_commands?: { id: string; label: string; text: string }[]; themes?: { id: string; name: string }[]; palette_commands?: { id: string; title: string }[]; + mods?: { id: string; path: string; surfaces?: string[] }[]; }; agents_more?: number; hook_runs?: HookRun[]; @@ -108,7 +111,7 @@ interface Attachment { // The bundled plugin that fills Settings → Agents → Add from catalog. const CATALOG_PLUGIN = "lectern.agent-catalog"; -const KINDS = ["agents", "mcp_servers", "skills", "workflows", "hooks", "sandbox_providers", "quick_commands", "themes", "palette_commands"]; +const KINDS = ["agents", "mcp_servers", "skills", "workflows", "hooks", "sandbox_providers", "quick_commands", "themes", "palette_commands", "mods"]; const short = (sha?: string) => (sha ? sha.slice(0, 12) : ""); const errorText = (error: unknown) => (error instanceof Error ? error.message : String(error)); @@ -126,13 +129,35 @@ function Contributions({ counts }: { counts?: Record }) { ); } +// How this plugin's mods are doing in this browser: running, paused after +// failures, or failed to start, with what they logged. +function ModsHere({ plugin }: { plugin: string }) { + useModsVersion(); + const rows = modHost.statuses().filter((m) => m.plugin === plugin); + if (!rows.length) return null; + return ( + <> +

{t("plugins.modsHere")}

+
    + {rows.map((m) => ( +
  • + {m.id} {t("plugins.mod." + m.state)} + {m.error &&
    {m.error}
    } + {m.log.slice(-5).map((line, i) =>
    {line}
    )} +
  • + ))} +
+ + ); +} + export function CapabilityList({ capabilities, grown = [] }: { capabilities: Capability[]; grown?: string[] }) { if (!capabilities.length) return

{t("plugins.cap.none")}

; const isNew = (key: string, detail?: string) => grown.includes(detail ? `${key}: ${detail}` : key); return (