Skip to content

fix(mobile): remote-first mobile shell with sign-in, restore, renewal and cutoff - #549

Open
Ziinc wants to merge 2 commits into
mainfrom
claude/prd-gaps-mobile-remote-shell
Open

Ziinc wants to merge 2 commits into
mainfrom
claude/prd-gaps-mobile-remote-shell

Conversation

@Ziinc

@Ziinc Ziinc commented Sep 27, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked PR. It is based on #547 (claude/prd-gaps-managed-ssh-login) and targets that branch. Merge #547 first, then retarget this PR to main.

Summary

This PR closes gaps against the mobile PRD acceptance criteria 1, 2, 4, 7 and 8:

  • AC1 (build gating): tauri-plugin-cli and the cli module now build only on desktop. The desktop capability file is limited to desktop platforms, so it no longer asks for cli:default on Android or iOS.
  • Shell selection: the app picks the mobile shell from the build target (TAURI_ENV_PLATFORM) instead of the window width. A narrow desktop window keeps the desktop shell. Desktop dev builds can preview the mobile shell with ?shell=mobile.
  • AC2 (remote first): the mobile flow is now sign in, then connect, then choose a repository and workspace. The local review views only appear in the desktop dev preview.
  • Sign-in: the mobile header has sign-in and sign-out controls backed by useAuthStore. The treq://auth/callback deep link is registered for mobile, and its token is exchanged through the deep-link plugin (getCurrent + onOpenUrl).
  • Repository selection: mobile lists the repositories already opened on the connected endpoint (the on-device saved registry) and the user-managed SSH hosts saved on this device. A free-text path is still available as a fallback.
  • AC7 (restore and resume): the app saves the endpoint choice, repository path and screen. It never saves certificates, hosts or keys. On launch, or when the app returns from the background or the network comes back, it re-queries instance status and issues a new certificate if the old one is due, the instance changed, or a previous connect failed. After a reconnect, a trip to the background of 30 s or more, or the network coming back, it remounts the repository view, which reloads workspaces, agent status and PTY sessions from the VM.
  • AC8 (renewal and cutoff): the managed connection uses the shared state machine in managed-ssh-connection.ts with the keystore device key and starts certificate renewal. The mobile shell listens for remote://cutoff events. A cutoff shows RemoteStatusBanner in its cutoff state and hides every remote screen until reauthentication succeeds. The cutoff is cleared only after a new certificate is issued.
  • AC4 (mutations): an idempotency key now belongs to one user action and is reused when that action is retried, instead of being built from Date.now(). This applies to create workspace, rebase, commit, push, resolve, agent start and agent input. Agent Stop now needs a confirm tap (MutationButton), goes through dispatchMutationOverSsh, and shows an ambiguous result.

Changes

  • src-tauri/Cargo.toml, src-tauri/src/lib.rs: tauri-plugin-cli is now a desktop-only target dependency. mod cli, the plugin init, the CliExt block and is_cli_process are #[cfg(desktop)].
  • src-tauri/capabilities/default.json: platforms: [linux, macOS, windows].
  • src-tauri/tauri.conf.json: plugins.deep-link.mobile registers the custom scheme treq, host auth, path /callback, with appLink: false.
  • New src/lib/mobile-platform.ts, and vite.config.ts now uses envPrefix: ["VITE_", "TAURI_ENV_"].
  • New src/hooks/useMobileRemoteConnection.ts (managed and user-managed connection, renewal, reauthentication, resume check) and src/hooks/useAppResume.ts (visibilitychange + online).
  • New src/lib/mobile-session.ts (persists identifiers only, with a whitelist parser), src/lib/remote-idempotency.ts and src/lib/auth-deep-link.ts.
  • RemoteConnectPanel rewritten as the mobile remote flow. The pickers live in the new mobile/MobileRemotePickers.tsx.
  • RemoteRepoScreen exports RemoteRepoScreenState and takes initialScreen and onScreenChange.
  • sshEndpointFromUserManaged moved from Dashboard.tsx to lib/remote-endpoints.ts. There is no behaviour change on desktop.
  • Removed stale "Phase N" comments in the files this PR touches.
    UI changes: the mobile shell layout changed: it has an account control in the header, an endpoint picker, a repository picker, a status banner and a cutoff block. Agent Stop now takes two taps. I did not run the screenshot harness.

Testing

  • npx tsc --noEmit: clean.
  • npx eslint / npx oxlint / npx biome format on the changed files: no new warnings. The remaining eslint warnings are in existing Dashboard.tsx code.
  • npx vitest run --config vitest.unit.config.ts on the remote and mobile suites (src/components/remote, src/lib/remote-*, src/components/mobile, MobileShell, Remote*, src/lib/mobile-*, auth-deep-link, managed-ssh, src/stores): 26 files, 147 tests, all passed. The new tests cover:
    • shell routing by platform
    • session persistence (no secrets) and restore on launch from fresh remote state
    • resume re-checks: a fresh certificate is kept, a due one is re-issued
    • cutoff blocking and reauthentication
    • user-managed endpoints and the known-repository list
    • idempotency-key stability on retry
    • the Agent Stop confirmation and its ambiguous result
    • deep-link token handling
  • cargo check --locked --all-targets --features tauri-test: OK.
  • cargo clippy --locked --all-targets --all-features -- -D warnings: OK.
  • cargo fmt --check: OK.
  • cargo test --lib identifies_cli_process: OK.
  • I did not run cargo check --target aarch64-linux-android. The Android target and the NDK are not installed here, and C dependencies (ring, bundled rusqlite) need the NDK's clang, so the mobile compile is still unverified.

Not in this PR

  • Android/iOS compile check (see Testing). portable-pty (local PTY manager) and notify (file watcher) stay unconditional. Both are libc/inotify/kqueue crates that list Android and iOS as supported targets. If a mobile build shows they fail, they should be gated along with the commands::* surface that uses them, which is a bigger refactor. There is no updater plugin in this app.
  • Native project config: the deep-link plugin's build script writes the Android intent filter and the iOS CFBundleURLTypes into src-tauri/gen/ from tauri.conf.json. That only happens once the gitignored native projects exist (tauri android init / tauri ios init). HTTPS app links (App Links / Universal Links) would also need assetlinks.json / apple-app-site-association on the web host. They are not configured here.
  • Sign-in source: the web sign-in page is still opened with source=desktop, since its callback is the same treq://auth/callback. A mobile-specific source value would be a change to the web page.
  • Managed repository list from the control plane: the control plane has no repository list for a managed VM today. Mobile uses the on-device saved registry plus free text.
  • Provisioning from mobile: this is a PRD non-goal. An account with no instance is told to set one up on desktop.
  • Ambiguous-result dialog: mobile mutation screens show ambiguous results inline, which is the existing describeMutationOutcome pattern. The global RemoteAmbiguousMutationDialog is not mounted on mobile.
  • Desktop auth deep links: on desktop, the Rust on_open_url handler only routes treq://agent/... links to the frontend. Whether auth callbacks reach AppStoreEffects on desktop looks worth checking separately. This PR does not change it.
    🤖 Generated with Claude Code
    https://claude.ai/code/session_01MbYSPHBRmDYwPJpHG33Y4t
    Generated by Claude Code

@Ziinc Ziinc left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Inline notes on why the main changes were made: build gating, platform detection, the connection hook, restore and cutoff, idempotency keys, and deep links.


Generated by Claude Code

Comment thread src-tauri/src/lib.rs

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

  • tauri-plugin-cli has no Android/iOS support. The treq CLI entry point only makes sense on desktop, so the plugin init, CliExt block, mod cli and is_cli_process are now #[cfg(desktop)], and mobile hard-codes cli_process = false.
  • The matching Cargo change moves the crate into a cfg(not(any(android, ios))) target table. Cargo.lock does not change.

Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

  • A capability with no platforms applies everywhere. Without this limit it would ask for cli:default on mobile, where the plugin is no longer linked, and the mobile build would fail at tauri-build. Mobile permissions stay in mobile.json.

Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

  • The shell is a build property. A narrow desktop window still has local repos and PTYs, so viewport width was the wrong signal.
  • TAURI_ENV_PLATFORM is set by the Tauri CLI for the target being built and exposed through envPrefix in vite.config.ts. This follows Tauri's recommended setup and avoids a round trip to the plugin-os crate or a Rust command at startup.

Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

  • It reuses connectExistingReadyInstance / wakeManagedInstance / reauthenticateManagedInstance, so mobile gets the same register -> issue -> activate -> renew sequence as the Dashboard instead of the old one-shot flow with no renewal.
  • Key registration is keyed on the device key fingerprint. The shared keystore:device reference is the same string on every phone, so keying on it would make a second device replay the first device's registration.
  • refreshAfterResume exists because timers stop while the OS suspends the app. On resume it re-reads instance status and reconnects if the lease is due, or if the instance or generation changed. A cut-off endpoint is never reconnected automatically.

Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

  • On launch or resume it remounts RemoteRepoScreen (via key={epoch}) instead of trusting mounted state. Every screen then re-reads workspaces, agent status and PTY sessions from the VM (AC7). Short trips to the background (under 30 s) keep an open terminal as it was.
  • restoringRef stops the save effect from overwriting the remembered repo with null in the gap between "connected" and the probe finishing.
  • A cutoff replaces the whole repository view with the reauthentication block, so no remote screen or terminal can be used until reauthenticate succeeds (AC8).

Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

  • The old Date.now() keys changed on every tap, so retrying after an ambiguous result looked like a new command to the VM and could apply twice.
  • A key belongs to one set of inputs until a confirmed outcome (applied / already_applied). Pending keys live in a map, so doing something else in between does not lose the retry key.
  • I also applied it to the rebase/commit/push/resolve screens in RemoteWorkspaceMutationScreens.tsx, which had the same bug.

Generated by Claude Code

Comment thread src/lib/auth-deep-link.ts

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

  • The Rust on_open_url handler is desktop-only, so mobile reads links from the deep-link plugin in JS. getCurrent covers the cold-start link and onOpenUrl covers links that arrive while the app runs.
  • handledTokens is module-level because getCurrent keeps returning the launch link. Sign-in tokens are single-use, so exchanging one twice would show a spurious error.

Generated by Claude Code

Comment thread src-tauri/tauri.conf.json

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

  • The web sign-in page ends at treq://auth/callback?token=..., so mobile registers that custom scheme (appLink: false, so no hosted verification files are needed).
  • The deep-link plugin's build script turns this entry into the Android intent filter and the iOS CFBundleURLTypes in the generated native projects.

Generated by Claude Code

Ziinc pushed a commit that referenced this pull request Sep 27, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012PGXpwptLRetsjFcHkL8x5
…tificate

The control plane returns the issued certificate plus an endpoint whose
authentication names a server-side key id. The native transport treated
that id as a file under ~/.ssh and the certificate was never used, so
managed-VM SSH login could not succeed on desktop or mobile.

- SshAuthentication::Certificate carries the certificate text inline
  (optional, falls back to <key>-cert.pub) and a local key reference:
  a key or .pub path on desktop, or the reserved keystore:device on
  mobile, resolved through a device-key provider on the pool.
- The client composes the endpoint it activates from the server
  endpoint, the local key reference and the certificate; renewal swaps
  in the new certificate for future connections without stopping the
  renewal loop or dropping open channels.
- Dashboard Connect/Provision now run the real connect flows and report
  errors in the setup panel. Wake uses a per-attempt idempotency key.
- Mobile RemoteConnectPanel dispatches with the device key and the
  issued certificate.
- The Sprites adapter wakes a paused Sprite with a no-op exec instead of
  a metadata GET, which the Sprites docs do not count as activity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MbYSPHBRmDYwPJpHG33Y4t
@Ziinc
Ziinc force-pushed the claude/prd-gaps-managed-ssh-login branch from 65102d0 to ddb7558 Compare September 27, 2026 16:58
… and cutoff

- Pick the shell from the build target (TAURI_ENV_PLATFORM), not the
  window width; `?shell=mobile` previews it in desktop dev builds.
- Mobile flow is sign in -> connect -> choose repository/workspace. The
  managed instance uses the shared managed-ssh-connection state machine
  with the keystore device key, starts certificate renewal, and blocks
  remote screens on a credential cutoff until reauthentication.
- Known repositories and user-managed SSH endpoints saved on the device
  are offered, with a free-text path as fallback.
- Persist endpoint, repository path and screen (identifiers only) and on
  launch or resume re-query instance status, re-issue the certificate
  when due, and reload remote screens from the VM.
- Idempotency keys belong to the user action and are reused on retry;
  Agent Stop is confirmed and reports ambiguous results.
- Mobile sign-in through the auth store plus a `treq` deep-link scheme
  for Android/iOS.
- Gate tauri-plugin-cli and the `cli` module to desktop; limit the
  desktop capability to desktop platforms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MbYSPHBRmDYwPJpHG33Y4t
@Ziinc
Ziinc force-pushed the claude/prd-gaps-mobile-remote-shell branch from c19e63e to ba85f4d Compare September 27, 2026 17:00
Base automatically changed from claude/prd-gaps-managed-ssh-login to main September 27, 2026 17:25

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants