Skip to content

feat(desktop): advertise and continue Handoff activities with OpenCode iOS - #56694

Draft
kitlangton wants to merge 1 commit into
devfrom
feat/desktop-handoff
Draft

kitlangton wants to merge 1 commit into
devfrom
feat/desktop-handoff

Conversation

@kitlangton

@kitlangton kitlangton commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Why

Moving between a Mac running OpenCode Desktop and an iPhone or iPad running OpenCode iOS requires manually finding the same server, workspace directory, and session on the second device. Even when both devices are signed in to the same iCloud account and connected over local network or Tailscale, neither app advertises or accepts an NSUserActivity for the active session.

What Changes

OpenCode Desktop on macOS and the OpenCode web UI server now participate in bidirectional Apple Handoff (ai.opencode.view-session) with OpenCode iOS:

Surface Direction Behavior
Desktop (darwin) Mac → iPhone / iPad Viewing a session calls app.setUserActivity("ai.opencode.view-session", userInfo, webpageURL) with the session ID, workspace directory, and candidate server origins; leaving a session tab calls app.invalidateCurrentActivity().
Desktop (darwin) iPhone / iPad → Mac Clicking the OpenCode Handoff tile in the macOS Dock handles will-continue-activity and continue-activity, converts the activity into an opencode://session/<id>?dir=<dir>&origin=<origin> deep link, focuses the window, and switches to or opens that session tab on the matching server.
Desktop (Info.plist) Build & dev Declares NSUserActivityTypes: ["ai.opencode.view-session"] in electron-builder.config.ts and scripts/dev-electron.ts so useractivityd routes incoming Handoff continuations to the app.
CLI Web UI (/.well-known/apple-app-site-association) Browser → iPhone / iPad Serves applinks and activitycontinuation for 5NZ4Q7NXJ4.ai.opencode.mobilecode, allowing Safari and Universal Links on reachable HTTPS/Tailscale servers to hand off /<base64url-dir>/session/<id> URLs to iOS.
sequenceDiagram
    participant Renderer as Desktop Renderer
    participant Main as Electron Main (macOS)
    participant Handoff as macOS useractivityd / BLE
    participant iOS as OpenCode iOS

    Renderer->>Main: WindowSetUserActivity({ sessionID, directory, serverOrigin, serverOrigins })
    Main->>Handoff: app.setUserActivity("ai.opencode.view-session", userInfo, webpageURL)
    Handoff-->>iOS: Lock Screen / App Switcher Handoff banner
    iOS->>Handoff: Advertise active iOS session ("ai.opencode.view-session")
    Handoff-->>Main: app.on("continue-activity", type, userInfo, details)
    Main->>Renderer: opencode://session/<id>?dir=<dir>&origin=<origin>
    Renderer->>Renderer: Select matching server & open session tab
Loading

Activity Payload and Deep-Link Routing

The ai.opencode.view-session userInfo dictionary carries only routing metadata (v: 1, sessionID, directory, serverOrigin, serverOrigins, machineHost, and webpageURL)—never Basic Auth passwords, bearer tokens, or message text, because useractivityd syncs the payload through iCloud across the user's devices.

When continue-activity fires on macOS, handoffActivityToDeepLink validates either the native userInfo dictionary or a Universal Link webpageURL (/<base64url-dir>/session/<id>) and emits an opencode://session/... URL. DeepLinkBridge in @opencode/app consumes queued startup deep links (window.__OPENCODE__.deepLinks) and live "opencode:deep-link" events, matches any advertised origin against configured servers (preferring an already-matching active server, or switching servers when another configured connection matches), and opens the session in the workspace layout.

Scope

This PR owns the macOS Electron Handoff lifecycle, renderer deep-link navigation for opencode:// session URLs, NSUserActivityTypes packaging configuration, and the CLI web server's /.well-known/apple-app-site-association endpoint. The companion iOS implementation is tracked in OC-410.

Verification

bun --cwd packages/desktop test src/main/lifecycle/handoff.test.ts electron-builder.config.test.ts
bun run --cwd packages/app test:unit src/shell/routes/deep-links.test.ts
bun --cwd packages/cli test test/web-ui.test.ts
bun --cwd packages/desktop test
bun run --cwd packages/app test:unit
bun --cwd packages/cli typecheck
bun --cwd packages/app typecheck
bun --cwd packages/desktop typecheck
  • packages/desktop: 133 passed across 16 test files (including handoff.test.ts covering payload normalization, webpage URL construction, deep-link extraction from native userInfo and Universal Links, and createHandoffController coalescing/invalidation, plus electron-builder.config.test.ts verifying NSUserActivityTypes).
  • packages/app: 763 passed across 108 test files (including deep-links.test.ts covering opencode://session/..., opencode://new-session, opencode://last-session, and server origin matching).
  • packages/cli: 3 passed in test/web-ui.test.ts (including GET /.well-known/apple-app-site-association returning applinks and activitycontinuation for 5NZ4Q7NXJ4.ai.opencode.mobilecode).
  • Full workspace turbo typecheck: 34/34 packages passed.

Added by the opencode bot (AI-generated).

Fixes #44167

Related: #55106, #53529

Public API: adds GET /.well-known/apple-app-site-association to the web UI server and handles opencode://session, new-session and last-session deep links in Desktop (backwards compatible)

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I couldn't verify the Handoff flow itself, because it needs macOS and an iOS device. On Linux the new unit tests, test:unit in app, and typecheck in desktop, app and cli all pass. I also ran opencode serve and confirmed /.well-known/apple-app-site-association returns the expected JSON (GET 200, POST 405).

Public API: all additive and backwards compatible.

  • New unauthenticated GET/HEAD /.well-known/apple-app-site-association on the web UI server.
  • New desktop deep links: opencode://session/<id>?dir=&project=&server=&host=&url=&web=, opencode://new-session?dir=&project=&server= and opencode://last-session?server=. Before this, the renderer ignored every opencode:// link except console auth.
  • The desktop IPC WindowSetUserActivity is internal.

Concerns (details inline):

  • Safari fallback to an unreachable address: iPhones without OpenCode iOS get a Safari Handoff to a LAN address the loopback sidecar doesn't serve.
  • Unknown servers: a Handoff from a server this Mac doesn't know opens the session on the built-in server, where it doesn't exist.
  • Replay on reload: links queued in the main process are replayed when the window reloads or a new window opens.
  • Description vs. code: the description doesn't match what the iOS app has to work with.
    • The description lists serverOrigin/serverOrigins; the code sends serverURLs, serverKey and projectID.
    • The description uses origin=; the code uses repeated url=.
    • The description describes links as /<base64url-dir>/session/<id>; the code uses /server/<base64(origin)>/session/<id> and /session/<id>.
    • Please align one with the other before the iOS side relies on it.
  • v1 parameter names: new-session reads dir, while v1 links (see #55106) use directory=. Those links will quietly fall back to the last project.

Fixes #44167
Related: #55106, #53529

}
} catch {}
}
if (!fallbackOrigin) return undefined

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When webpageURL is missing, this builds http://<LAN-IP>:<port>/server/... from the first rewritten origin, and the Mac advertises it on every session view. NSUserActivity falls back to opening webpageURL in Safari when the receiving device has no app for the activity type. That covers every iPhone without OpenCode iOS today.

The desktop sidecar normally listens only on 127.0.0.1 (0.0.0.0 only in the isolated background-service mode), so the LAN address won't connect. It also needs the server password, which isn't carried. I'd only set webpageURL when the origin is a configured non-loopback server known to be reachable, and otherwise leave it unset.

}
}

const fallback = servers.find(ServerConnection.builtin) ?? servers.find(ServerConnection.local) ?? servers[0]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Say the link carries serverURLs (e.g. a Handoff from iOS on a remote or Tailscale server) and none of them matches a configured server. This still falls back to the built-in/local server and opens a tab for a session ID that doesn't exist there. When the link names origins and none match, it would be safer to return undefined, or to show a notice, than to guess.

focusWindow(emitDeepLinks([url]) ?? null)
}

const stopHandoff = registerHandoffListeners(app, (url) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

emitDeepLinks pushes every URL onto pendingDeepLinks and also sends it live to the window. Only consumeInitialDeepLinks drains the queue, and that runs when a renderer starts.

Before this PR the renderer ignored these links, so the leftovers did no harm. Now DeepLinkBridge acts on them, so after a Handoff (or any opencode://session link) while a window is open, the next reload or new window navigates to that old session again. Consider only queueing when no window received the event, or clearing the queue once it is delivered live.

@kitlangton
kitlangton marked this pull request as draft October 18, 2026 05:53

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a deep link to open a specific Desktop session

1 participant