-
Notifications
You must be signed in to change notification settings - Fork 14
feat(remote-access): mobile companion — Telegram first (foundation) #1339
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
43 commits
Select commit
Hold shift + click to select a range
399aed8
feat(remote-bridge): Telegram platform adapter + pairing capture
Astro-Han f103ea4
feat(desktop): in-process mobile-companion bridge + Telegram connect UI
Astro-Han 0828c15
docs(remote-bridge): platform adapter contract, transport table, roadmap
Astro-Han 30ed81f
docs(remote-bridge): WeChat-personal is official (Tencent iLink); Wha…
Astro-Han 34b6eda
fix(remote-bridge): drain the full Telegram pairing backlog before ca…
Astro-Han aab4e4e
fix(desktop): bundle remote-bridge into Electron main instead of exte…
Astro-Han ed822e1
fix(desktop): preserve paired userName when loading saved remote cred…
Astro-Han 0d83ec0
fix(desktop): keep the pairing token main-owned; confirm without rese…
Astro-Han 5871fc6
fix(desktop): make remote status switch exhaustive for lint
Astro-Han f37e60d
test(desktop): match vite config assertion to the remote-bridge exclude
Astro-Han 069224b
fix(desktop): inject safeStorage into the credential store for a dete…
Astro-Han ec60d50
fix(app): use {{name}} placeholder syntax in remote i18n strings
Astro-Han f1bb52c
fix(remote-bridge): retry the telegram pairing ack so it is not replayed
Astro-Han 120b814
fix(desktop): drop a pairing captured after cancel instead of resurre…
Astro-Han 2045fde
fix(remote-bridge): keep the newline at a telegram split boundary
Astro-Han e365bd6
fix(desktop): re-assert 0o600 on credential rewrite
Astro-Han 812b345
fix(app): disable remote connect when the bridge api is unavailable
Astro-Han 013287c
fix(app): ignore a stale pairing resolution after cancel
Astro-Han 22e052c
feat(app): wire remote access into the settings surface
Astro-Han 56d55ce
feat(app): flesh out the remote access page
Astro-Han c9d3f9d
refactor(app): scope the Telegram pairing note to its block
Astro-Han 2d6ca26
refactor(app): move the pairing note into the connect dialog
Astro-Han 8803855
fix(remote-bridge): drop the backlog on telegram (re)start
Astro-Han fd3c6e4
fix(desktop): land the remote bridge on degraded when the build fails
Astro-Han 781b5a7
docs(desktop): correct the bot-token IPC comments
Astro-Han d24b68b
fix(app): update settings e2e for the live remote access tab
Astro-Han 92b6643
refactor(remote-bridge): extract a NaN-safe nextOffset helper
Astro-Han a030f1b
fix(desktop): abort the remote bridge synchronously on stop
Astro-Han 67676a0
test(app): cover connected/degraded/waiting/disconnect in remote snap
Astro-Han 7ed5be5
docs(remote-bridge): record the deferred native-prompt-UI optimization
Astro-Han 34b9bce
feat(remote-bridge): localize chat copy (en/zh) with clearer prompts
Astro-Han eb7f8b1
feat(desktop): drive remote-bridge locale from the app UI language
Astro-Han 4eebe63
fix(remote-bridge): preflight secure storage before pairing
Astro-Han fd5d6d5
fix(remote-bridge): drain backlog before getMe so pairing keeps the f…
Astro-Han c3c04f1
fix(remote-bridge): defer "connected" until the platform is actually …
Astro-Han 4fcc8c3
fix(remote-bridge): gate Telegram readiness on first live poll, bound…
Astro-Han 5afa140
refactor(app): split disconnect dialog into its own lazy module
Astro-Han 93b4211
fix(app): defer remote connect toast until status is actually connected
Astro-Han 08ac21c
fix(app): mask the bot token input
Astro-Han 5265ba3
fix(remote-bridge): retry Telegram sends per-chunk, never resend deli…
Astro-Han 9d7710b
refactor(app): inline the disconnect confirm into remote.tsx
Astro-Han d357ad3
fix(remote-bridge): count each platform's onReady at most once
Astro-Han 34383aa
docs(remote-bridge): scope platform adapter README to shipped code
Astro-Han File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| import { test } from "../fixtures" | ||
| import { openSettings } from "../actions" | ||
| import { composeGrid, snapOutputPath, type Shot } from "./_compose" | ||
|
|
||
| test.use({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2 }) | ||
|
|
||
| // Remote access settings page + connect flow. snap runs in web Chromium, which has | ||
| // no Electron preload, so window.api.remote is injected here as a stub. The stub | ||
| // holds a mutable status the test drives via window.__remote.set(...), and parks | ||
| // startPairing() until window.__remote.capture(...) — so one run can snapshot every | ||
| // state the user sees: the page (disconnected / connected / degraded) and the | ||
| // connect-flow dialog (token / waiting / confirm / disconnect). The IPC behaviour | ||
| // behind that API is covered by unit tests in desktop-electron and remote-bridge. | ||
| test("settings-remote", async ({ page, project }) => { | ||
| test.setTimeout(180_000) | ||
|
|
||
| await page.addInitScript(() => { | ||
| let status = { state: "disconnected", platform: null, identity: null, error: null } as Record<string, unknown> | ||
| const listeners = new Set<(s: unknown) => void>() | ||
| let resolvePairing: ((v: unknown) => void) | undefined | ||
| ;(window as any).api = { | ||
| ...(window as any).api, | ||
| remote: { | ||
| getStatus: () => Promise.resolve(status), | ||
| // Stays pending so the dialog parks on the "waiting" step until the test | ||
| // releases it via __remote.capture(). | ||
| startPairing: () => new Promise((resolve) => (resolvePairing = resolve)), | ||
| cancelPairing: () => Promise.resolve(), | ||
| confirmPairing: () => Promise.resolve(), | ||
| disconnect: () => Promise.resolve(), | ||
| onStatus: (cb: (s: unknown) => void) => { | ||
| listeners.add(cb) | ||
| return () => listeners.delete(cb) | ||
| }, | ||
| }, | ||
| } | ||
| ;(window as any).__remote = { | ||
| set: (s: Record<string, unknown>) => { | ||
| status = s | ||
| listeners.forEach((cb) => cb(status)) | ||
| }, | ||
| capture: (sender: unknown) => resolvePairing?.(sender), | ||
| } | ||
| }) | ||
|
|
||
| await project.open() | ||
| const settings = await openSettings(page) | ||
| await settings.getByRole("tab", { name: "Remote access" }).click() | ||
|
|
||
| const shots: Shot[] = [] | ||
|
|
||
| // 1) Disconnected page. | ||
| const connect = settings.getByRole("button", { name: "Connect" }).first() | ||
| await connect.waitFor({ state: "visible", timeout: 30_000 }) | ||
| shots.push({ name: "disconnected", buf: await settings.screenshot() }) | ||
|
|
||
| // 2) Connect dialog — paste the bot token. | ||
| await connect.click() | ||
| const dialog = page.getByRole("dialog") | ||
| await dialog.waitFor({ state: "visible", timeout: 30_000 }) | ||
| shots.push({ name: "token", buf: await dialog.screenshot() }) | ||
|
|
||
| // 3) Waiting — token accepted, awaiting the first message (startPairing pending). | ||
| await dialog.getByRole("textbox").first().fill("8403172:AAExampleBotTokenForPreview") | ||
| await dialog.getByRole("button", { name: "Continue" }).click() | ||
| await dialog.getByText("Send your bot a message").waitFor({ state: "visible", timeout: 30_000 }) | ||
| shots.push({ name: "waiting", buf: await dialog.screenshot() }) | ||
|
|
||
| // 4) Confirm — the captured sender awaits approval. | ||
| await page.evaluate(() => | ||
| (window as any).__remote.capture({ userId: "8403172", userName: "yuhan", botUsername: "my_pawwork_bot" }), | ||
| ) | ||
| await dialog.getByText("Allow this account?").waitFor({ state: "visible", timeout: 30_000 }) | ||
| shots.push({ name: "confirm", buf: await dialog.screenshot() }) | ||
|
|
||
| // Close the connect dialog via its Close (X) control. The confirm step's Cancel | ||
| // only steps back to the token step (it doesn't close), and the pairing flow | ||
| // keeps Escape from tearing the dialog down — so drive the explicit X. | ||
| await dialog.getByRole("button", { name: "Close" }).click() | ||
| await dialog.waitFor({ state: "hidden", timeout: 30_000 }) | ||
|
|
||
| // 5) Connected page — green status rule + paired identity. | ||
| await page.evaluate(() => | ||
| (window as any).__remote.set({ | ||
| state: "connected", | ||
| platform: "telegram", | ||
| identity: { userId: "8403172", userName: "yuhan" }, | ||
| error: null, | ||
| }), | ||
| ) | ||
| await settings.getByText("Connected", { exact: true }).waitFor({ state: "visible", timeout: 30_000 }) | ||
| shots.push({ name: "connected", buf: await settings.screenshot() }) | ||
|
|
||
| // 6) Disconnect confirm dialog — only reachable while connected. | ||
| await settings.getByRole("button", { name: "Disconnect" }).click() | ||
| const disconnectDialog = page.getByRole("dialog") | ||
| await disconnectDialog.waitFor({ state: "visible", timeout: 30_000 }) | ||
| shots.push({ name: "disconnect", buf: await disconnectDialog.screenshot() }) | ||
| await disconnectDialog.getByRole("button", { name: "Cancel" }).click() | ||
| await disconnectDialog.waitFor({ state: "hidden", timeout: 30_000 }) | ||
|
|
||
| // 7) Degraded page — red status rule + error detail. | ||
| await page.evaluate(() => | ||
| (window as any).__remote.set({ | ||
| state: "degraded", | ||
| platform: "telegram", | ||
| identity: { userId: "8403172", userName: "yuhan" }, | ||
| error: "Lost connection to Telegram", | ||
| }), | ||
| ) | ||
| await settings.getByText("Lost connection to Telegram").first().waitFor({ state: "visible", timeout: 30_000 }) | ||
| shots.push({ name: "degraded", buf: await settings.screenshot() }) | ||
|
|
||
| const out = snapOutputPath("settings-remote") | ||
| await composeGrid(shots, out) | ||
| process.stdout.write(`\n[snap] settings-remote grid -> ${out}\n\n`) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,157 @@ | ||
| import { Button } from "@opencode-ai/ui/button" | ||
| import { useDialog } from "@opencode-ai/ui/context/dialog" | ||
| import { Dialog } from "@opencode-ai/ui/dialog" | ||
| import { Icon } from "@opencode-ai/ui/icon" | ||
| import { Spinner } from "@opencode-ai/ui/spinner" | ||
| import { TextField } from "@opencode-ai/ui/text-field" | ||
| import { Match, onCleanup, Switch } from "solid-js" | ||
| import { createStore } from "solid-js/store" | ||
| import type { RemotePairingResult } from "@/desktop-api-contract" | ||
| import { useLanguage } from "@/context/language" | ||
|
|
||
| // Connect flow for the mobile companion. Reuses the connect-provider dialog | ||
| // SHAPE — a small state machine inside a Dialog — but its own backend (the | ||
| // main-process bridge over window.api.remote), since this connects a chat bot, | ||
| // not an LLM provider. Pairing is paste-token → message-the-bot → approve. | ||
| export function DialogConnectRemote(props: { onApproved?: () => void }) { | ||
| const language = useLanguage() | ||
| const dialog = useDialog() | ||
|
|
||
| const [store, setStore] = createStore({ | ||
| step: "token" as "token" | "waiting" | "confirm", | ||
| token: "", | ||
| captured: undefined as RemotePairingResult | undefined, | ||
| error: undefined as string | undefined, | ||
| busy: false, | ||
| }) | ||
|
|
||
| // Guard against late async resolutions after the dialog is gone, and stop the | ||
| // bot's capture poll when the dialog closes mid-pairing. | ||
| const alive = { value: true } | ||
| // Monotonic pairing-attempt id: cancelling (or restarting) pairing bumps it, so | ||
| // a startPairing() that resolves just after the user cancels is ignored instead | ||
| // of flipping the dialog back to the confirm step. | ||
| let attempt = 0 | ||
| onCleanup(() => { | ||
| alive.value = false | ||
| void window.api?.remote?.cancelPairing() | ||
| }) | ||
|
|
||
| const remote = () => window.api?.remote | ||
|
|
||
| async function startPairing(event?: Event) { | ||
| event?.preventDefault() | ||
| const api = remote() | ||
| const token = store.token.trim() | ||
| if (!api || token === "" || store.busy) return | ||
| const mine = ++attempt | ||
| setStore({ step: "waiting", error: undefined }) | ||
| try { | ||
| const captured = await api.startPairing(token) | ||
| if (!alive.value || mine !== attempt) return | ||
| // null = cancelled before a sender arrived; fall back to the token step. | ||
| if (!captured) return setStore({ step: "token" }) | ||
| setStore({ step: "confirm", captured }) | ||
| } catch (err) { | ||
| if (!alive.value || mine !== attempt) return | ||
| setStore({ step: "token", error: errorMessage(err) }) | ||
| } | ||
| } | ||
|
|
||
| async function allow() { | ||
| const api = remote() | ||
| const captured = store.captured | ||
| if (!api || !captured || store.busy) return | ||
| setStore("busy", true) | ||
| try { | ||
| // The main process holds the token + captured identity from startPairing; | ||
| // confirm just approves it — we never resend the secret. | ||
| await api.confirmPairing() | ||
| // The bridge is starting but not yet serving. Hand the success signal to the | ||
| // page, which fires the toast only when status actually reaches "connected" | ||
| // (and never when a 409 ends in "degraded") — so we don't claim "connected" | ||
| // here, a step before it is true. Page-side, so it survives this close(). | ||
| props.onApproved?.() | ||
| if (!alive.value) return | ||
| dialog.close() | ||
| } catch (err) { | ||
| if (!alive.value) return | ||
| setStore({ busy: false, step: "token", error: errorMessage(err) }) | ||
| } | ||
| } | ||
|
|
||
| function backToToken() { | ||
| attempt++ // invalidate any in-flight startPairing so its late resolve is dropped | ||
| void remote()?.cancelPairing() | ||
| setStore({ step: "token", captured: undefined }) | ||
| } | ||
|
|
||
| return ( | ||
| <Dialog title={language.t("settings.remote.connect.title")} fit class="w-full max-w-[460px] mx-auto"> | ||
| <div class="px-6 pt-2 pb-6 flex flex-col gap-5"> | ||
| <Switch> | ||
| <Match when={store.step === "token"}> | ||
| <form onSubmit={startPairing} class="flex flex-col gap-4"> | ||
| <TextField | ||
| autofocus | ||
| type="password" | ||
| label={language.t("settings.remote.connect.token.label")} | ||
| placeholder={language.t("settings.remote.connect.token.placeholder")} | ||
| name="token" | ||
| value={store.token} | ||
| onChange={(value) => setStore("token", value)} | ||
| validationState={store.error ? "invalid" : undefined} | ||
| error={store.error} | ||
| /> | ||
| <p class="text-small text-fg-weak">{language.t("settings.remote.connect.token.help")}</p> | ||
| <div class="flex justify-end"> | ||
| <Button type="submit" variant="primary" disabled={store.token.trim() === ""}> | ||
| {language.t("common.continue")} | ||
| </Button> | ||
| </div> | ||
| </form> | ||
| </Match> | ||
|
|
||
| <Match when={store.step === "waiting"}> | ||
| <div class="flex flex-col gap-3"> | ||
| <div class="flex items-center gap-2 text-body text-fg-strong"> | ||
| <Spinner /> | ||
| <span>{language.t("settings.remote.connect.waiting.title")}</span> | ||
| </div> | ||
| <p class="text-body text-fg-weak">{language.t("settings.remote.connect.waiting.body")}</p> | ||
| <div class="flex justify-end"> | ||
| <Button variant="secondary" onClick={backToToken}> | ||
| {language.t("common.cancel")} | ||
| </Button> | ||
| </div> | ||
| </div> | ||
| </Match> | ||
|
|
||
| <Match when={store.step === "confirm"}> | ||
| <div class="flex flex-col gap-4"> | ||
| <div class="flex items-center gap-2 text-body text-fg-strong"> | ||
| <Icon name="circle-check" class="text-icon-success-base" /> | ||
| <span>{language.t("settings.remote.connect.confirm.title")}</span> | ||
| </div> | ||
| <p class="text-body text-fg-weak"> | ||
| {language.t("settings.remote.connect.confirm.body", { name: store.captured?.userName ?? "" })} | ||
| </p> | ||
| <div class="flex justify-end gap-2"> | ||
| <Button variant="secondary" onClick={backToToken} disabled={store.busy}> | ||
| {language.t("common.cancel")} | ||
| </Button> | ||
| <Button variant="primary" onClick={allow} disabled={store.busy}> | ||
| {language.t("settings.remote.connect.action.allow")} | ||
| </Button> | ||
| </div> | ||
| </div> | ||
| </Match> | ||
| </Switch> | ||
| </div> | ||
| </Dialog> | ||
| ) | ||
| } | ||
|
|
||
| function errorMessage(err: unknown): string { | ||
| return err instanceof Error ? err.message : String(err) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.