feat: native iOS & Android mobile apps (Capacitor) (#1954)

* feat(mobile): add Capacitor native shell

* docs: add serve-sim workflow guidance

* docs(mobile): add implementation handoff

* chore(mobile): clean up generated defaults

* feat(mobile): add connection onboarding

* feat(mobile): manage saved instances

* feat(mobile): refine connection management UI

* chore(mobile): upgrade Capacitor 8

* fix(mobile): reliable saved-instance auth with secure token storage

- store client tokens in the OS secure store (iOS Keychain / Android Keystore)
  per instance URL via direct native plugin calls; keep only token-less metadata
  in localStorage. Bound every secure call so a stalled bridge can't hang unlock.
- bypass the secure-storage JS wrapper's lazy platform load (which stalled in the
  webview) by calling internalSetItem/internalGetItem/internalRemoveItem directly.
- harden the shared connect/unlock controller (health + session + progressive
  password) and drop the heavy pre-connect hydration that stalled no-token hosts.
- await token persistence before switching runtime endpoints (no fire-and-forget).
- sync native iOS/Android projects + Keyboard/StatusBar config for Capacitor 8.

* fix(mobile): keep UI stable across connection churn (no transport hardcoding)

The "reload every ~10s" was a UX bug, not a transport one:
- MobileSurfaceShell received a fresh inline onClose each parent render, so any
  re-render (e.g. an SSE/WS event) re-ran the focus effect and refocused the first
  element — stealing focus from the active input and collapsing the keyboard
  mid-edit. onClose now lives in a ref so the focus/keydown effect depends only on
  `open`. Fixes all sheets (Instances/Files/Changes/Settings).
- Gate the mobile shell on connectionPhase, not the live isConnected flag, so a
  transient reconnect keeps MobileShell mounted instead of flashing the loader.
- Instances form: populate fields imperatively on edit/cancel/save instead of via
  an effect keyed on the derived connection, so list churn can't wipe input.

Transport stays on `auto` (WS-first with SSE fallback) — no hardcoded override, so
WS-only Quick Tunnels and SSE-capable proxies both keep working.

* feat(mobile): add native QR pairing-code scanner

Wire the connection onboarding + Instances scan buttons to a real native
scanner via @capacitor-mlkit/barcode-scanning, which registers as the
BarcodeScanner plugin the existing mobileQrScan helper already resolves at
runtime. Add NSCameraUsageDescription and bump the iOS deployment target to
15.5 (GoogleMLKit 8 requirement).

* fix(cli): repair connect-url host resolution

Define the missing isWildcardBindHost helper that connect-url called but was
never declared, which crashed any link generation that reached host
resolution. Also treat a full http(s) --host value as a public server URL so
'--host https://example.com' produces a correct link instead of
'http://https://example.com:port'.

* fix(mobile): make input follow the keyboard across all surfaces

Switch the native Capacitor Keyboard plugin to resize: 'none' and drive the
layout from an --oc-keyboard-inset CSS variable set on keyboardWillShow, which
fires at the start of the iOS keyboard animation. A transition tuned to the
native keyboard curve/duration (0.25s, cubic-bezier(0.38, 0.7, 0.125, 1)) makes
the layout rise together with the keyboard instead of snapping into place after
the built-in 'native' resize finished (~1.5s lag).

The inset is consumed by every surface that can hold a focused input:
- chat shell shrinks its height;
- portal sheets/overlays raise their bottom edge;
- the full-screen connect/login view caps its height so it actually scrolls
  (and is now generally scrollable for long saved-connection lists).

* feat(mobile): rounder chat composer + native bottom safe area

Round the mobile chat composer corners a touch more (1rem), and reserve a small
app-level bottom safe area for the native shell via the --oc-app-bottom-safe
token so controls clear the phone's rounded hardware corners. The reservation
folds into the keyboard inset (no gap above the keyboard), and the composer's
own bottom padding tightens while the keyboard is open.

* fix(mobile): remove iOS 26 dark status-bar band; polish composer

The dark band behind the status bar in system Dark Mode was iOS 26's automatic
scroll edge effect (Liquid Glass) dimming the WebView's top edge beneath the
status bar — appearance-coloured, so it tracked the system theme regardless of
the in-app theme. Hide it via UIScrollView.topEdgeEffect/bottomEdgeEffect on the
WebView's scroll view (iOS 26+), and make the WebView non-opaque so the themed
web background shows under the overlaid status bar.

Also: re-assert the status-bar overlay on resume, paint the document canvas with
the theme background in the native shell, round the composer corners to 1.5rem,
and enlarge the app-level bottom safe area so controls clear the rounded corners.

* feat(mobile): logo splash until first paint is final (no FOUT / layout shift)

Cold start flashed the fallback font and then reflowed once the real font and
persisted appearance prefs landed, and text jumped a frame after mount because the
mobile typography classes were applied from a hook effect. Fix it on three fronts:

- apply device classes (device-mobile / mobile-pointer) synchronously in
  renderMobileApp before the first React paint, so mobile --text-* sizes are in
  effect from the start;
- hold a logo splash (useFontsReady) until the UI web font has loaded;
- gate that splash on appBootReady too, resolved once async appearance/typography
  preferences are applied, plus a double rAF so styles commit before reveal.

All under a 2.5s safety timeout so a slow/offline CDN can't block startup.

* feat(mobile): native local notifications; APNs implemented but frozen

The native app now delivers agent ready/error/question/permission events as iOS
(and Android) Local Notifications: a native notifications API backed by
@capacitor/local-notifications replaces the Web Notifications API (which doesn't
display in a WKWebView), driven by the notification SSE stream now subscribed in
the mobile app. Tapping a notification opens its session. Also fix the settings
toggle, which treated the Capacitor app as a browser and gated 'Enable
Notifications' on the absent Web Notification permission, leaving it un-toggleable.

Remote APNs push is implemented end-to-end (dependency-free HTTP/2 + ES256 JWT
server runtime, token routes, client registration, iOS native config) but kept
dormant: config-gated so it never fires, client registration not wired, and the
aps-environment entitlement / background mode removed so the app builds with no
Apple push setup. It will be reused once OpenChamber ships its own encrypted
relay so users don't each configure APNs. See notifications/APNS.md.

WKWebView can't use web push (unlike an installed PWA), so true
background-when-suspended delivery on native requires APNs via that relay.

* feat(mobile): APNs relay-mode background push

Deliver native iOS background push through the central relay: the server posts
device tokens + generic, model-based text to api.openchamber.dev/v1/push/send
(default), which holds the single APNs key and signs+sends; dead tokens (410)
are dropped from the per-session store. Direct APNs (HTTP/2 + ES256 JWT) stays
as a fallback when OPENCHAMBER_PUSH_RELAY_DISABLED=true. The mobile push payload
is generic only (model + scenario) so no session content crosses the relay.

Re-enable the client token registration (useNativePushRegistration) and the
aps-environment entitlement (alert pushes need no background mode). Wired into
the same fanout as web push; focus-suppressed and only when tokens exist.

* fix(mobile): APNs-only native notifications, generic templates, no foreground

Make APNs the single notification channel for the native app and fix delivery:

- Remove local notifications entirely (the @capacitor/local-notifications plugin
  and the SSE-driven path). A WKWebView can't tell foreground from background
  (document.hasFocus() is unreliable), so local notifications leaked while the app
  was open; the in-app dispatch is no-op'd on native.
- Stop gating APNs on UI visibility — a backgrounded WebView can't report 'hidden'
  before iOS suspends it, which dropped background push. Instead always send and let
  iOS suppress the foreground banner (PushNotifications presentationOptions: []).
- Fix a ReferenceError (out-of-scope 'variables') that crashed maybeSendPushForTrigger
  before any push was sent.
- Mobile push text is generic: a scenario title ('Agent response is ready' / 'needs
  your input' / 'needs permission' / 'hit an error') + the session name, no model or
  message content.
- Hide the focus toggle, templates, and test button in mobile notification settings.

* feat(push): sign relay requests + bind tokens per server

Each OpenChamber server now auto-generates an ECDSA P-256 keypair (persisted in settings,
like the VAPID keys) and uses it to:
- bind every newly-seen device token to the server on the relay
  (POST /v1/push/register-token, signed), and
- sign every push send (publicKeyJwk + ts + signature over ts.sortedTokens.title).

The relay derives serverId = SHA-256(publicKey), verifies the signature + timestamp, and
only delivers to tokens bound to that server. Result: a leaked device token alone can no
longer be used to push to a device — the sender also needs the server's private key. Stays
zero-config (the keypair generates on first use). Drops the soft PUSH_RELAY_TOKEN bearer.

* docs(push): describe relay data-confidentiality model

Document that the push payload is not application-encrypted (TLS-in-transit only), what the
relay and Apple can see (generic scenario title + session name, plus token/sessionId), that
the signature is authentication rather than encryption, and what an end-to-end encrypted
payload would require.

* fix: invalid skill description

* feat(push): app-icon badge for native notifications

Send an absolute aps.badge with each native push = the count of distinct
collapse-ids (tag) pushed since the app was last foregrounded, mirroring the
lock-screen banner stack. Cleared server-side on user engagement (session view,
message-sent, visibility beacon) and on-device via sceneDidBecomeActive.

* feat(mobile): auto-connect last instance on launch + notification deep-links

Cold launch silently reconnects to the most-recent saved instance (when reachable
and a token is saved), holding the splash instead of flashing the connect screen;
falls back to the connect screen when there's no saved instance, it's unreachable,
or it needs a re-login. Notification-tap deep-links are now captured unconditionally
(even before connect / on cold launch) and applied once the app is ready, so a tap
opens the target session instead of being lost on the login screen.

* fix(mobile): resolve theme background before first paint on cold launch

The mobile shell entry (mobile.html) had no pre-paint theme step, so a cold
launch flashed the WebView's default light canvas, then the baked
design-system default (.dark { --background: #151313 }) via body.bg-background,
before React's theme system injected the real theme vars. Add a blocking script
that resolves dark/light from the persisted theme + system preference and sets
--background (plus color-scheme and the element background) inline on the root,
so the very first paint matches the resolved theme. Falls back to the default
flexoki backgrounds when no theme has been persisted yet.

* feat(mobile): openchamber:// deep-link foundation + arm64 simulator build

Add a typed deep-link vocabulary (deepLinks.ts: parse/build + DeepLinkIntent)
and a single native navigation layer (deepLinkNavigation.ts) that handles both
the openchamber:// URL scheme (App.appUrlOpen — widgets, Live Activities,
external links) and notification taps, normalising each into an intent. Session
and new-session resolve against the store; shell surfaces (sessions/settings/
views/changes) register handlers. Cold-launch intents stash until the app is
ready. Replaces the push-only useNativePushDeepLink and keeps backwards
compatibility with bare sessionId payloads.

Register the openchamber:// scheme in Info.plist.

Dev tooling: with-mobile-env now honours xcode-select (-p) instead of hardcoding
Xcode.app, so an Xcode beta is used. build:ios:simulator runs a new
ios-sim-build script that temporarily drops the MLKit barcode-scanning pod
(no arm64-simulator slice) so the app builds an arm64 binary installable on
Apple Silicon simulators, then restores the Podfile + Pods for device builds.
QR scanning already degrades cleanly when the native plugin is absent.

* feat(mobile): iOS home/lock/Control Center widgets + push-driven refresh

Add a Widget Extension (OpenChamberWidget) and a Notification Service Extension
(OpenChamberNotificationService), wired into the Xcode project, sharing an App
Group with the app.

Widgets:
- Overview (medium): recent sessions with read/unread dots + four quick actions
  (new, status, instances, settings).
- Sessions (large): session list with per-session project label, attention count
  and a new-session button in the header.
- Quick Actions (small): New chat pill + status/instances.
- Lock Screen (accessoryCircular x2): brand logo to new session, attention counter.
- Control Center control: brand logo (custom SF Symbol) to new session.

Data: the app writes a session-overview snapshot (attention count + recent
sessions with project labels) to the App Group on scene activate/resign; the NSE
refreshes it from each push (aps.badge + sessionId) so widgets update even when
the app is closed (needs aps mutable-content, added to the server + relay).

Deep links: add openchamber://status (session status panel) and reuse
view/instances; all widget taps route through the existing deep-link channel.

* feat(mobile): large Sessions widget lists 6 sessions with project labels

* feat(mobile): edge-swipe to switch sessions with directional slide+fade

* fix(mobile): keep widgets in sync via reload-on-change + periodic refresh

Widgets sharing the app's WidgetKit reload budget refreshed unevenly, leaving the
large Sessions widget stale (no unread dot / attention count) while medium updated.
Drop the per-call updatedAt from the snapshot, only write + reloadAllTimelines when
the session overview actually changed (so we don't burn the budget on every scene
activate/resign), and give each widget a periodic timeline refresh so a missed
reload self-corrects.

* feat(mobile): Android support — chrome fixes, SSE lock, icon, QR scan

Cosmetics:
- Status bar: on Android inset the WebView below the bar (overlay:false) and
  paint it with the resolved theme background + correct content Style, since
  Android doesn't feed env(safe-area-inset-top) to CSS.
- Keyboard: skip the manual --oc-keyboard-inset on Android (the window resizes
  natively, so applying it double-counted and floated the composer); declare
  windowSoftInputMode=adjustResize and disable the shell height transition on
  Android so the header no longer bounces on keyboard open.

Transport: lock Capacitor apps to SSE — native WebSocket streaming is unreliable
on Android (events only arrive once a run finishes). Forced in sync-context and
the other options are disabled in the Chat settings UI.

Push: gate APNs registration to iOS only; on Android @capacitor/push-notifications
register() needs Firebase/FCM (not configured) and crashes at launch.

QR pairing: declare CAMERA permission + the ML Kit barcode_ui dependency, and
install/await the Google barcode scanner module (with a post-install retry) before
scanning so the first scan works without a manual retry.

Icon: Android adaptive launcher icon generated from the cube logo (full-bleed
white background, no edge artifact on One UI). Source assets under mobile/assets.

Tooling: adb-based android-device.mjs + android:* scripts for device deploy.

* feat(notifications): presence-aware push routing (don't spam the phone)

Only push to a device when the notification would otherwise be missed there. A
notification is suppressed on devices where the user is already present.

- Tag every client's visibility beacon and web-push subscription with a platform
  ('ios' | 'android' | 'vscode' | 'desktop' | 'web') via getClientPlatform().
- Server tracks visibility per client (keyed by oc_ui_session) with the platform,
  and exposes isAnyInteractiveClientVisible() = any visible non-mobile client.
- Native push (APNs) and mobile PWA web-push are now suppressed when an
  interactive (desktop/web/vscode) client is visible — it already shows the
  in-app notification. Gated on the desktop's visibility (reliable), never the
  phone's own (a backgrounded WKWebView can't report "hidden").
- Desktop/web web-push keeps the any-visible gate (a visible client absorbs it).
- Skipping APNs also skips the badge increment so it doesn't drift.

Fixes the case where every session on a shared instance pushed to the phone even
while the user was actively working on desktop.

* feat(mobile): Android FCM push notifications

Enable native background push on Android via Firebase Cloud Messaging, in parallel
with the existing iOS APNs path.

- Add google-services.json + declare POST_NOTIFICATIONS (Android 13+). The Google
  Services Gradle plugin is applied when the file is present, so register() returns
  an FCM token instead of crashing.
- Un-gate native push registration to iOS OR Android, and tag the registered token
  with its platform ('ios' | 'android') so the relay routes it to APNs vs FCM.
- Server stores the platform per device token and binds it to the relay (platform
  included in the signed register message).
- Notification small icon: monochrome cube silhouette with a mark on the top face,
  set as the FCM default_notification_icon so the status-bar icon reads as the logo.

Relay-side FCM sending ships in openchamber-website.

* docs(mobile): refresh HANDOFF with current state, dev/deploy process, and CI gap

* chore(mobile): iOS store-review prerequisites (privacy manifest, encryption flag)

- Add the app's PrivacyInfo.xcprivacy (no tracking; required-reason UserDefaults for the App
  Group snapshot shared with the widget + notification service extension) and wire it into the
  App target's resources — Apple requires an app-level privacy manifest.
- Set ITSAppUsesNonExemptEncryption=false to skip the per-build export-compliance prompt.
- HANDOFF: add a store-review-readiness checklist (in-repo vs release-time console/infra items).

Verified: plist lint, xcodebuild parse, and an iOS simulator build with PrivacyInfo.xcprivacy
bundled into App.app.

* refactor(mobile): dedupe capacitor detection + make beacon guard explicit

Addresses non-blocking PR review notes:
- Consolidate the repeated Capacitor-native check (mobileConnections, deepLinkNavigation,
  usePushVisibilityBeacon each redefined it) onto the single isCapacitorApp() in lib/platform.
- usePushVisibilityBeacon now guards on isWebRuntime() OR isCapacitorApp() instead of relying on
  isWebRuntime() being true for Capacitor, so the beacon can't silently stop if that changes.
This commit is contained in:
Bohdan Triapitsyn
2026-07-01 09:55:41 +03:00
committed by GitHub
parent 4e0dded547
commit 61a4a23add
162 changed files with 8842 additions and 98 deletions
+2 -21
View File
@@ -34,7 +34,6 @@ import { markSessionViewed } from '@/sync/notification-store';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { opencodeClient } from '@/lib/opencode/client';
import { disposeTerminalInputTransport } from '@/lib/terminalApi';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { getRuntimeKey, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
@@ -57,8 +56,8 @@ import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
import { useI18n } from '@/lib/i18n';
import { applyMobileKeyboardMode } from '@/lib/mobileKeyboardMode';
import { SyncAppEffects } from '@/apps/AppEffects';
import { resetAppForRuntimeEndpointChange } from '@/apps/runtimeEndpointReset';
import { useAppFontEffects } from '@/apps/useAppFontEffects';
import { resetStreamingState } from '@/sync/streaming';
import { OpenCodeUpdateToast } from '@/components/update/OpenCodeUpdateToast';
import { markStartupTrace, startupTraceEnabled } from '@/lib/startupTrace';
@@ -268,25 +267,7 @@ function App({ apis }: AppProps) {
React.useEffect(() => {
return subscribeRuntimeEndpointChanged((detail) => {
useSessionUIStore.getState().prepareForRuntimeSwitch(detail.previousRuntimeKey);
useUIStore.getState().prepareForRuntimeSwitch(detail.previousRuntimeKey);
if (detail.previousRuntimeKey) {
useAutoReviewStore.getState().stopRunningRunsForRuntime(detail.previousRuntimeKey);
}
disposeTerminalInputTransport();
opencodeClient.reconnectToRuntimeBaseUrl();
useConfigStore.setState({
providers: [],
agents: [],
isConnected: false,
isInitialized: false,
connectionPhase: 'connecting',
lastDisconnectReason: null,
});
useProjectsStore.getState().resetForRuntimeSwitch();
useSessionUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey);
useUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey);
resetStreamingState();
resetAppForRuntimeEndpointChange(detail);
setRuntimeEndpointEpoch((epoch) => epoch + 1);
setInitRetryExhausted(false);
setInitRetryEpoch((epoch) => epoch + 1);
File diff suppressed because it is too large Load Diff
+12 -3
View File
@@ -67,6 +67,15 @@ export const MobileSurfaceShell: React.FC<MobileSurfaceShellProps> = ({
const isDraggingRef = React.useRef(false);
const surfaceRef = React.useRef<HTMLElement | null>(null);
const previousFocusRef = React.useRef<HTMLElement | null>(null);
// Keep onClose in a ref so the focus/keydown effect below depends only on `open`.
// The parent passes a fresh inline onClose on every render; if the effect depended
// on it, each parent re-render (e.g. an SSE store update) would re-run it and
// refocus the first element — stealing focus from whatever input the user is in
// and collapsing the keyboard mid-edit.
const onCloseRef = React.useRef(onClose);
React.useEffect(() => {
onCloseRef.current = onClose;
}, [onClose]);
if (typeof document !== 'undefined' && !rootRef.current) {
rootRef.current = ensureSurfaceRoot();
@@ -112,7 +121,7 @@ export const MobileSurfaceShell: React.FC<MobileSurfaceShellProps> = ({
const focusTimer = window.setTimeout(focusFirstElement, ENTER_DELAY_MS);
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
onCloseRef.current();
return;
}
if (event.key !== 'Tab') return;
@@ -145,7 +154,7 @@ export const MobileSurfaceShell: React.FC<MobileSurfaceShellProps> = ({
previousFocusRef.current?.focus?.({ preventScroll: true });
previousFocusRef.current = null;
};
}, [onClose, open]);
}, [open]);
const handleDragStart = (event: React.TouchEvent<HTMLDivElement>) => {
if (disableSwipeDismiss) return;
@@ -208,7 +217,7 @@ export const MobileSurfaceShell: React.FC<MobileSurfaceShellProps> = ({
return createPortal(
<div
className={cn(
'fixed inset-0 z-50 flex flex-col bg-[rgb(0_0_0_/_0.45)]',
'oc-keyboard-inset-surface fixed inset-0 z-50 flex flex-col bg-[rgb(0_0_0_/_0.45)]',
// The opacity transition keeps the scrim on its own compositing layer,
// which iOS Safari clips to the viewport — without it, a static scrim
// bleeds the dim into the bottom toolbar overscroll zone. Quick fade so
+17
View File
@@ -0,0 +1,17 @@
// Resolves once the one-time app boot work that affects layout has been applied —
// notably persisted appearance/typography preferences (font size, spacing), which are
// loaded asynchronously and would otherwise reflow the UI a frame after first paint.
// The mobile splash gate (useFontsReady) awaits this so the first UI shown is final.
let resolveBoot: (() => void) | null = null;
let resolved = false;
export const appBootReadyPromise = new Promise<void>((resolve) => {
resolveBoot = resolve;
});
export function markAppBootReady(): void {
if (resolved) return;
resolved = true;
resolveBoot?.();
}
+198
View File
@@ -0,0 +1,198 @@
import React from 'react';
import { isCapacitorApp } from '@/lib/platform';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useUIStore } from '@/stores/useUIStore';
import { buildDeepLink, parseDeepLink, type DeepLinkIntent, type SessionsFilter, type ViewTarget } from './deepLinks';
/**
* Navigation layer for {@link DeepLinkIntent}s — the only place that knows how to *apply* a
* deep link. Producers (notification taps, widget `widgetURL`, Live Activities) feed intents
* in via {@link useDeepLinkSource}; the surfaces that can satisfy them register imperative
* handlers via {@link useDeepLinkHandlers}. Session/new-session navigation goes straight to
* the session store (always available), so those resolve even before the shell has mounted.
*
* Intents that arrive before the app is ready (cold launch from a tap/widget) or before their
* handler is registered are stashed in a module-level holder that survives the connect flow
* and SyncProvider remount, then applied as soon as the app becomes ready / the handler
* appears. Only the most recent intent is kept (newest wins) — a burst of taps shouldn't queue.
*/
export interface DeepLinkHandlers {
/** Open the sessions sheet, optionally pre-filtered (filter support is best-effort for now). */
openSessions?: (filter?: SessionsFilter) => void;
/** Open a non-session surface (files / mcp / instances / update). */
openView?: (target: ViewTarget) => void;
/** Open the Changes surface, optionally jumping straight to a file diff. */
openChanges?: (options?: { path?: string; staged?: boolean }) => void;
/** Open Settings, optionally at a specific section. */
openSettings?: (section?: string) => void;
}
let handlers: DeepLinkHandlers = {};
let ready = false;
let pending: DeepLinkIntent | null = null;
const execute = (intent: DeepLinkIntent): boolean => {
switch (intent.type) {
case 'session':
void useSessionUIStore.getState().setCurrentSession(intent.sessionId, intent.directory ?? null);
return true;
case 'new-session': {
const store = useSessionUIStore.getState();
store.openNewSessionDraft();
if (intent.directory || intent.projectId) {
store.setNewSessionDraftTarget({
directoryOverride: intent.directory ?? null,
projectId: intent.projectId ?? null,
selectedProjectId: intent.projectId ?? null,
});
}
return true;
}
case 'sessions':
if (!handlers.openSessions) return false;
handlers.openSessions(intent.filter);
return true;
case 'status':
// The session status panel is store-backed (useUIStore.mobileSessionPanelOpen),
// so it opens without a shell handler — like session/new-session.
useUIStore.getState().setMobileSessionPanelOpen(true);
return true;
case 'view':
if (!handlers.openView) return false;
handlers.openView(intent.target);
return true;
case 'changes':
if (!handlers.openChanges) return false;
handlers.openChanges({ path: intent.path, staged: intent.staged });
return true;
case 'settings':
if (!handlers.openSettings) return false;
handlers.openSettings(intent.section);
return true;
}
};
const flush = (): void => {
if (!ready || !pending) return;
const intent = pending;
// Drop the stash before executing; if the handler isn't registered yet, execute() returns
// false and we re-stash so a later registerDeepLinkHandlers() flush can retry it.
pending = null;
if (!execute(intent)) {
pending = intent;
}
};
/** Apply an intent now if possible, otherwise stash it until the app is ready / a handler appears. */
export const applyDeepLinkIntent = (intent: DeepLinkIntent): void => {
pending = intent;
flush();
};
/** Convenience: parse a raw `openchamber://…` URL and apply it. No-op for unrecognised URLs. */
export const applyDeepLinkUrl = (raw: string | null | undefined): void => {
const intent = parseDeepLink(raw);
if (intent) {
applyDeepLinkIntent(intent);
}
};
const setReady = (value: boolean): void => {
ready = value;
flush();
};
/**
* Register the surfaces that can satisfy shell-scoped intents (sessions/settings/views/changes).
* Call from the component that owns those panels; the handlers are torn down on unmount.
* Registering also flushes any pending intent that was waiting for these handlers.
*/
export const useDeepLinkHandlers = (next: DeepLinkHandlers): void => {
React.useEffect(() => {
handlers = next;
flush();
return () => {
if (handlers === next) {
handlers = {};
}
};
}, [next]);
};
/**
* Single native entry point for deep links. Subscribes to both the custom URL scheme
* (`App.appUrlOpen` — widgets, Live Activities, external links) and notification taps
* (`pushNotificationActionPerformed`), normalising each into a {@link DeepLinkIntent}.
* Both listeners are registered UNCONDITIONALLY so a cold-launch tap/open isn't lost while
* the app is still connecting; intents stash until `ready` (connected + initialized).
*/
export const useDeepLinkSource = (options: { ready: boolean }): void => {
const { ready: isReady } = options;
React.useEffect(() => {
setReady(isReady);
}, [isReady]);
React.useEffect(() => {
if (!isCapacitorApp()) return;
let disposed = false;
const cleanup: Array<() => void> = [];
void import('@capacitor/app')
.then(async ({ App }) => {
if (disposed) return;
const handle = await App.addListener('appUrlOpen', (event) => {
applyDeepLinkUrl(event?.url);
});
if (disposed) {
void handle.remove();
return;
}
cleanup.push(() => void handle.remove());
})
.catch(() => undefined);
void import('@capacitor/push-notifications')
.then(async ({ PushNotifications }) => {
if (disposed) return;
const handle = await PushNotifications.addListener('pushNotificationActionPerformed', (action) => {
const data = action?.notification?.data as Record<string, unknown> | undefined;
// Prefer an explicit deep link in the payload (richest); fall back to a bare
// sessionId for backwards compatibility with existing push senders.
const url = typeof data?.url === 'string' ? data.url : typeof data?.deeplink === 'string' ? data.deeplink : undefined;
if (url) {
applyDeepLinkUrl(url);
return;
}
const sessionId = typeof data?.sessionId === 'string' ? data.sessionId : undefined;
if (sessionId) {
applyDeepLinkIntent({ type: 'session', sessionId });
}
});
if (disposed) {
void handle.remove();
return;
}
cleanup.push(() => void handle.remove());
})
.catch(() => undefined);
return () => {
disposed = true;
cleanup.forEach((remove) => remove());
};
}, []);
};
// Re-export so producers (notifications, future widgets) have one import for the whole vocabulary.
export { buildDeepLink, parseDeepLink };
export type { DeepLinkIntent, SessionsFilter, ViewTarget };
+169
View File
@@ -0,0 +1,169 @@
/**
* OpenChamber deep-link vocabulary — the single source of truth for the `openchamber://`
* URL scheme used across every native entry point: notification taps, home-screen / lock-
* screen widgets, and (later) Live Activities. Anything that wants to drive navigation
* builds a URL with {@link buildDeepLink} and anything that receives one parses it with
* {@link parseDeepLink} into a typed {@link DeepLinkIntent}; the navigation layer
* (deepLinkNavigation) is the only place that knows how to *apply* an intent.
*
* Keep this file pure (no React, no stores, no Capacitor) so it can be imported from any
* context — including, eventually, a tiny encoder shared with the native widget/extension.
*/
export const DEEP_LINK_SCHEME = 'openchamber';
export type SessionsFilter = 'all' | 'attention' | 'recent';
export type ViewTarget = 'files' | 'mcp' | 'instances' | 'update';
/**
* Every navigable destination the app exposes to the outside world. New widget/notification
* ideas should add a variant here first, then teach deepLinkNavigation how to apply it —
* that keeps the "blocks" composable without leaking ad-hoc URL parsing into features.
*/
export type DeepLinkIntent =
| { type: 'session'; sessionId: string; directory?: string }
| { type: 'new-session'; directory?: string; projectId?: string; agent?: string; model?: string }
| { type: 'sessions'; filter?: SessionsFilter }
| { type: 'status' }
| { type: 'settings'; section?: string }
| { type: 'changes'; path?: string; staged?: boolean }
| { type: 'view'; target: ViewTarget };
const trimSlashes = (value: string): string => value.replace(/^\/+|\/+$/g, '');
const segmentsOf = (url: URL): string[] => {
// Custom-scheme URLs put the first route token in `host` (openchamber://session/<id>),
// but be tolerant of authority-less forms (openchamber:/session/<id>) where it lands in
// the pathname instead.
const pathSegments = trimSlashes(url.pathname).split('/').filter(Boolean);
if (url.host) {
return [url.host, ...pathSegments];
}
return pathSegments;
};
/**
* Parse a raw `openchamber://…` string into a typed intent, or `null` if it isn't a
* recognised OpenChamber deep link. Tolerant by design: unknown routes return `null`
* rather than throwing, so callers can fall back without a try/catch.
*/
export function parseDeepLink(raw: string | null | undefined): DeepLinkIntent | null {
if (typeof raw !== 'string' || raw.length === 0) {
return null;
}
let url: URL;
try {
url = new URL(raw);
} catch {
return null;
}
if (url.protocol !== `${DEEP_LINK_SCHEME}:`) {
return null;
}
const segments = segmentsOf(url);
const route = (segments[0] ?? '').toLowerCase();
const rest = segments.slice(1);
const query = url.searchParams;
switch (route) {
case 'session': {
const sessionId = rest[0] || query.get('id') || '';
if (!sessionId) {
return null;
}
return { type: 'session', sessionId, directory: query.get('dir') ?? undefined };
}
case 'new':
case 'new-session':
return {
type: 'new-session',
directory: query.get('dir') ?? undefined,
projectId: query.get('project') ?? undefined,
agent: query.get('agent') ?? undefined,
model: query.get('model') ?? undefined,
};
case 'sessions': {
const filter = query.get('filter');
return {
type: 'sessions',
filter: filter === 'attention' || filter === 'recent' || filter === 'all' ? filter : undefined,
};
}
case 'status':
return { type: 'status' };
case 'settings':
return { type: 'settings', section: rest[0] || query.get('section') || undefined };
case 'changes':
return {
type: 'changes',
path: rest.join('/') || query.get('path') || undefined,
staged: query.get('staged') === 'true',
};
case 'view': {
const target = (rest[0] || '').toLowerCase();
// `changes` has its own richer intent (diff path); route the bare view token to it.
if (target === 'changes') {
return { type: 'changes' };
}
if (target === 'files' || target === 'mcp' || target === 'instances' || target === 'update') {
return { type: 'view', target };
}
return null;
}
default:
return null;
}
}
/**
* Build a canonical `openchamber://…` URL for an intent. Used by anything that needs to hand
* a deep link to iOS — notification payloads, `widgetURL(...)`, Live Activity tap targets —
* so every producer emits the exact shape {@link parseDeepLink} understands.
*/
export function buildDeepLink(intent: DeepLinkIntent): string {
const base = `${DEEP_LINK_SCHEME}://`;
const withQuery = (path: string, params: Record<string, string | undefined>): string => {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (typeof value === 'string' && value.length > 0) {
search.set(key, value);
}
}
const query = search.toString();
return query ? `${base}${path}?${query}` : `${base}${path}`;
};
switch (intent.type) {
case 'session':
return withQuery(`session/${encodeURIComponent(intent.sessionId)}`, { dir: intent.directory });
case 'new-session':
return withQuery('new', {
dir: intent.directory,
project: intent.projectId,
agent: intent.agent,
model: intent.model,
});
case 'sessions':
return withQuery('sessions', { filter: intent.filter });
case 'status':
return `${base}status`;
case 'settings':
return intent.section ? `${base}settings/${encodeURIComponent(intent.section)}` : `${base}settings`;
case 'changes':
return withQuery(intent.path ? `changes/${intent.path}` : 'changes', {
staged: intent.staged ? 'true' : undefined,
});
case 'view':
return `${base}view/${intent.target}`;
}
}
+680
View File
@@ -0,0 +1,680 @@
// Saved-connection storage + the shared connect/unlock flow for the dedicated
// mobile app. Both the onboarding welcome screen and the Instances sheet drive
// connections through `useMobileConnection` so the health-check + progressive
// password unlock + client-token issuance + runtime switch all behave identically.
//
// Persistence model (deliberately simple so it is correct-by-inspection):
// - Instance *metadata* (id/label/url/lastUsedAt + a `hasToken` flag) lives in
// localStorage. On native it NEVER contains the client token.
// - The client token lives in the OS secure store (iOS Keychain / Android
// Keystore) via @aparajita/capacitor-secure-storage, keyed per instance URL.
// - On web (browser-hosted mobile.html) there is no secure store, so the token
// stays inline in localStorage — that surface is not the native security target.
//
// Token writes are AWAITED before we switch the runtime endpoint, so a successful
// unlock guarantees the token is actually persisted (no fire-and-forget).
import { SecureStorage } from '@aparajita/capacitor-secure-storage';
import React from 'react';
import { useI18n } from '@/lib/i18n';
import { isCapacitorApp } from '@/lib/platform';
import { switchRuntimeEndpoint } from '@/lib/runtime-switch';
const MOBILE_CONNECTIONS_STORAGE_KEY = 'openchamber.mobile.connections.v1';
const MOBILE_SECURE_STORAGE_PREFIX = 'openchamber.mobile.';
const MOBILE_CONNECTIONS_LIMIT = 12;
const MOBILE_CONNECT_TIMEOUT_MS = 8000;
const MOBILE_NATIVE_HTTP_TIMEOUT_MS = 2500;
const MOBILE_SECURE_TIMEOUT_MS = 3000;
export type MobileSavedConnection = {
id: string;
label: string;
url: string;
lastUsedAt: number;
// Native: indicates a token exists in the secure store. Web: unused.
hasToken?: boolean;
// Web only: the token stored inline. On native this stays undefined in the list.
clientToken?: string;
};
export type MobilePendingConnection = {
label: string;
url: string;
};
export type MobileConnectInput = {
url: string;
clientToken?: string;
label?: string;
};
type MobileFetchResponse = {
ok: boolean;
status: number;
source: 'native-http' | 'browser-fetch';
json: () => Promise<unknown>;
};
type MobileSessionStatus = {
authenticated?: boolean;
disabled?: boolean;
scope?: string;
};
// ---------------------------------------------------------------------------
// URL helpers
// ---------------------------------------------------------------------------
export const normalizeConnectionUrl = (value: string): string => {
const trimmed = value.trim();
if (!trimmed) return '';
const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`;
const url = new URL(withScheme);
url.hash = '';
url.search = '';
url.pathname = url.pathname.replace(/\/+$/, '');
return url.toString().replace(/\/+$/, '');
};
export const getConnectionLabel = (url: string): string => {
try {
return new URL(url).host;
} catch {
return url;
}
};
const getConnectionStorageKey = (url: string): string => {
try {
return normalizeConnectionUrl(url);
} catch {
return url.trim().replace(/\/+$/g, '');
}
};
export const isSameConnectionUrl = (left: string, right: string): boolean =>
getConnectionStorageKey(left) === getConnectionStorageKey(right);
// ---------------------------------------------------------------------------
// Request helpers (native CapacitorHttp first — needed to reach plain-http LAN
// servers the secure webview cannot fetch — then a browser-fetch fallback).
// ---------------------------------------------------------------------------
const logConnect = (step: string, detail: Record<string, unknown> = {}): void => {
console.info('[mobile-connect]', step, detail);
};
const logStorage = (step: string, detail: Record<string, unknown> = {}): void => {
console.info('[mobile-storage]', step, detail);
};
const parseMaybeJson = (value: unknown): unknown => {
if (typeof value !== 'string') return value;
try {
return JSON.parse(value) as unknown;
} catch {
return value;
}
};
const getJsonRequestData = (body: BodyInit | null | undefined): unknown => {
if (typeof body !== 'string') return body ?? undefined;
try {
return JSON.parse(body) as unknown;
} catch {
return body;
}
};
const nativeHttpRequest = async (url: string, init?: RequestInit): Promise<MobileFetchResponse | null> => {
if (!isCapacitorApp()) return null;
try {
const { CapacitorHttp } = await import('@capacitor/core');
const headers = Object.fromEntries(new Headers(init?.headers).entries());
const response = await CapacitorHttp.request({
url,
method: init?.method || 'GET',
headers,
data: getJsonRequestData(init?.body),
});
return {
ok: response.status >= 200 && response.status < 300,
status: response.status,
source: 'native-http',
json: async () => parseMaybeJson(response.data),
};
} catch (error) {
console.warn('[mobile-connect] native-http failed', { url, error });
return null;
}
};
const browserFetchRequest = async (url: string, init?: RequestInit): Promise<MobileFetchResponse | null> => {
const response = await fetch(url, init).catch((error) => {
console.warn('[mobile-connect] browser-fetch failed', { url, error });
return null;
});
if (!response) return null;
return { ok: response.ok, status: response.status, source: 'browser-fetch', json: () => response.json() };
};
const raceWithTimeout = async <T,>(timeoutMs: number, operation: Promise<T | null>, onTimeout?: () => void): Promise<T | null> => {
let timeoutId: number | undefined;
const timeout = new Promise<null>((resolve) => {
timeoutId = window.setTimeout(() => {
onTimeout?.();
resolve(null);
}, timeoutMs);
});
try {
return await Promise.race([operation, timeout]);
} catch {
return null;
} finally {
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
}
};
const requestWithTimeout = async (url: string, init?: RequestInit): Promise<MobileFetchResponse | null> => {
const startedAt = Date.now();
const native = await raceWithTimeout(
Math.min(MOBILE_NATIVE_HTTP_TIMEOUT_MS, MOBILE_CONNECT_TIMEOUT_MS),
nativeHttpRequest(url, init),
);
if (native) return native;
const controller = new AbortController();
const remainingMs = Math.max(1000, MOBILE_CONNECT_TIMEOUT_MS - (Date.now() - startedAt));
return raceWithTimeout(
remainingMs,
browserFetchRequest(url, { ...init, signal: controller.signal }),
() => controller.abort(),
);
};
const readSessionStatus = async (response: MobileFetchResponse | null): Promise<MobileSessionStatus | null> => {
if (!response) return null;
const payload = await response.json().catch(() => null);
if (!payload || typeof payload !== 'object') return null;
const record = payload as Record<string, unknown>;
return {
authenticated: typeof record.authenticated === 'boolean' ? record.authenticated : undefined,
disabled: typeof record.disabled === 'boolean' ? record.disabled : undefined,
scope: typeof record.scope === 'string' ? record.scope : undefined,
};
};
// ---------------------------------------------------------------------------
// Metadata storage (localStorage) — never holds the token on native.
// ---------------------------------------------------------------------------
const readConnections = (): MobileSavedConnection[] => {
if (typeof window === 'undefined') return [];
let parsed: unknown;
try {
parsed = JSON.parse(window.localStorage.getItem(MOBILE_CONNECTIONS_STORAGE_KEY) || '[]');
} catch {
return [];
}
if (!Array.isArray(parsed)) return [];
const native = isCapacitorApp();
return parsed
.flatMap((item): MobileSavedConnection[] => {
if (!item || typeof item !== 'object') return [];
const c = item as Partial<MobileSavedConnection>;
if (typeof c.id !== 'string' || typeof c.url !== 'string') return [];
const inlineToken = typeof c.clientToken === 'string' && c.clientToken.trim() ? c.clientToken : undefined;
const base: MobileSavedConnection = {
id: c.id,
label: typeof c.label === 'string' && c.label.trim() ? c.label : getConnectionLabel(c.url),
url: c.url,
lastUsedAt: typeof c.lastUsedAt === 'number' ? c.lastUsedAt : 0,
};
if (native) return [{ ...base, hasToken: Boolean(c.hasToken) || Boolean(inlineToken) }];
return [{ ...base, clientToken: inlineToken, hasToken: Boolean(inlineToken) }];
})
.sort((a, b) => b.lastUsedAt - a.lastUsedAt);
};
const writeConnections = (connections: MobileSavedConnection[]): void => {
if (typeof window === 'undefined') return;
const native = isCapacitorApp();
const serialized = connections.slice(0, MOBILE_CONNECTIONS_LIMIT).map((c) => (
native
? { id: c.id, label: c.label, url: c.url, lastUsedAt: c.lastUsedAt, hasToken: Boolean(c.hasToken || c.clientToken) }
: { id: c.id, label: c.label, url: c.url, lastUsedAt: c.lastUsedAt, clientToken: c.clientToken }
));
try {
window.localStorage.setItem(MOBILE_CONNECTIONS_STORAGE_KEY, JSON.stringify(serialized));
} catch (error) {
console.warn('[mobile-storage] failed to persist connection metadata', error);
}
};
const upsertConnectionInList = (
connections: MobileSavedConnection[],
draft: { label: string; url: string; clientToken?: string; hasToken?: boolean },
): MobileSavedConnection[] => {
const key = getConnectionStorageKey(draft.url);
const existing = connections.find((item) => getConnectionStorageKey(item.url) === key);
const native = isCapacitorApp();
const next: MobileSavedConnection = {
id: existing?.id || crypto.randomUUID(),
label: draft.label,
url: draft.url,
lastUsedAt: Date.now(),
...(native
? { hasToken: draft.hasToken ?? (Boolean(draft.clientToken) || existing?.hasToken || false) }
: { clientToken: draft.clientToken ?? existing?.clientToken, hasToken: Boolean(draft.clientToken ?? existing?.clientToken) }),
};
return [
next,
...connections.filter((item) => item.id !== next.id && getConnectionStorageKey(item.url) !== key),
].slice(0, MOBILE_CONNECTIONS_LIMIT);
};
// ---------------------------------------------------------------------------
// Secure token storage (native only), per-instance URL. Every call is bounded
// so a hung/unavailable Keychain can never block the connect flow.
// ---------------------------------------------------------------------------
// We call the plugin's NATIVE methods (`internalSetItem`/`internalGetItem`/
// `internalRemoveItem`) directly. Capacitor routes native methods straight to the
// iOS/Android plugin via the bridge — unlike the high-level `setItem`/`setKeyPrefix`
// JS methods, which make the `registerPlugin` proxy lazy-load its platform JS module
// (the step that stalls in this webview). We also build the prefixed key ourselves
// so we never touch the JS-only `setKeyPrefix`.
type NativeSecureStorage = {
internalSetItem: (options: { prefixedKey: string; data: string; sync: boolean; access: number }) => Promise<void>;
internalGetItem: (options: { prefixedKey: string; sync: boolean }) => Promise<{ data: string | null }>;
internalRemoveItem: (options: { prefixedKey: string; sync: boolean }) => Promise<{ success: boolean }>;
};
const nativeSecure = SecureStorage as unknown as NativeSecureStorage;
const KEYCHAIN_ACCESS_WHEN_UNLOCKED = 0; // KeychainAccess.whenUnlocked
const prefixedTokenKey = (url: string): string =>
`${MOBILE_SECURE_STORAGE_PREFIX}token.${encodeURIComponent(getConnectionStorageKey(url))}`;
const withTimeout = async <T,>(operation: Promise<T>, fallback: T): Promise<T> => {
let timeoutId: number | undefined;
const timeout = new Promise<T>((resolve) => {
timeoutId = window.setTimeout(() => resolve(fallback), MOBILE_SECURE_TIMEOUT_MS);
});
try {
return await Promise.race([operation.catch(() => fallback), timeout]);
} finally {
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
}
};
// Bound a native Keychain call so a stalled/failed bridge can never hang the flow.
const boundedSecure = async <T,>(label: string, run: () => Promise<T>, fallback: T): Promise<T> => {
if (!isCapacitorApp()) return fallback;
return withTimeout(
run().catch((error) => {
console.warn(`[mobile-storage] ${label} failed`, error);
return fallback;
}),
fallback,
);
};
const readSecureToken = async (url: string): Promise<string | undefined> => {
logStorage('secure:read-start', { url });
const value = await boundedSecure(
'secure:read',
async () => (await nativeSecure.internalGetItem({ prefixedKey: prefixedTokenKey(url), sync: false })).data,
null,
);
const token = typeof value === 'string' && value.trim() ? value : undefined;
logStorage('secure:read', { url, hasToken: Boolean(token) });
return token;
};
const writeSecureToken = async (url: string, token: string): Promise<boolean> => {
logStorage('secure:write-start', { url });
const ok = await boundedSecure('secure:write', async () => {
await nativeSecure.internalSetItem({
prefixedKey: prefixedTokenKey(url),
data: token,
sync: false,
access: KEYCHAIN_ACCESS_WHEN_UNLOCKED,
});
return true;
}, false);
logStorage('secure:write', { url, ok });
return ok;
};
const deleteSecureToken = async (url: string): Promise<void> => {
await boundedSecure('secure:delete', async () => {
await nativeSecure.internalRemoveItem({ prefixedKey: prefixedTokenKey(url), sync: false });
return true;
}, false);
};
// ---------------------------------------------------------------------------
// Public storage API
// ---------------------------------------------------------------------------
// One-time migration: a legacy localStorage record on native might still carry an
// inline `clientToken`. Move it into the secure store and strip the metadata.
const migrateLegacyInlineTokens = async (): Promise<void> => {
if (typeof window === 'undefined' || !isCapacitorApp()) return;
let parsed: unknown;
try {
parsed = JSON.parse(window.localStorage.getItem(MOBILE_CONNECTIONS_STORAGE_KEY) || '[]');
} catch {
return;
}
if (!Array.isArray(parsed)) return;
const legacy = parsed.filter((item): item is { url: string; clientToken: string } =>
Boolean(item) && typeof item === 'object'
&& typeof (item as { url?: unknown }).url === 'string'
&& typeof (item as { clientToken?: unknown }).clientToken === 'string'
&& Boolean((item as { clientToken: string }).clientToken.trim()));
if (legacy.length === 0) return;
logStorage('secure:migrate-start', { count: legacy.length });
for (const { url, clientToken } of legacy) {
await writeSecureToken(url, clientToken);
}
writeConnections(readConnections());
logStorage('secure:migrate-done', { count: legacy.length });
};
export const loadMobileConnections = async (): Promise<MobileSavedConnection[]> => {
await migrateLegacyInlineTokens();
return readConnections();
};
export const upsertMobileConnection = async (
connection: { label: string; url: string; clientToken?: string },
): Promise<MobileSavedConnection[]> => {
const next = upsertConnectionInList(readConnections(), connection);
writeConnections(next);
if (isCapacitorApp() && connection.clientToken) {
await writeSecureToken(connection.url, connection.clientToken);
}
return next;
};
export const deleteMobileConnection = async (id: string): Promise<MobileSavedConnection[]> => {
const connections = readConnections();
const removed = connections.find((connection) => connection.id === id) ?? null;
const next = connections.filter((connection) => connection.id !== id);
writeConnections(next);
if (removed && isCapacitorApp()) await deleteSecureToken(removed.url);
return next;
};
// Cold-launch auto-connect: silently reconnect to the most-recently-used saved
// instance so a returning user (and notification deep-links) land straight in the
// app instead of the connect screen. Returns true and switches the runtime endpoint
// when the instance is reachable AND we already have a usable bearer token; returns
// false — caller shows the connect screen — when there is no saved instance, it's
// unreachable, or it needs a (re)login. Mirrors the success path of
// `useMobileConnection.connect`, with no prompts or UI state.
export const autoConnectLastInstance = async (): Promise<boolean> => {
await migrateLegacyInlineTokens();
const candidate = readConnections()[0]; // sorted most-recent-first
if (!candidate) return false;
const url = normalizeConnectionUrl(candidate.url);
if (!url) return false;
// The native runtime transport needs a bearer token; only auto-connect when one is
// already saved. A missing/expired token must go through the login UI, not silently.
let token: string | undefined;
if (isCapacitorApp()) {
if (!candidate.hasToken) return false;
token = await readSecureToken(url);
if (!token) return false;
} else {
token = candidate.clientToken;
}
const headers = token ? { Authorization: `Bearer ${token}` } : undefined;
const health = await requestWithTimeout(`${url}/health`, { method: 'GET', headers });
if (!health?.ok) return false;
const session = await requestWithTimeout(`${url}/auth/session`, { method: 'GET', credentials: 'include', headers });
// Token rejected / session invalid → fall back to the login screen.
if (!session || (!session.ok && session.status !== 404)) return false;
const status = await readSessionStatus(session);
if (status && status.disabled !== true && status.authenticated === false) return false;
await upsertMobileConnection({ label: candidate.label, url }); // bump lastUsedAt (keeps hasToken)
switchRuntimeEndpoint({ apiBaseUrl: url, clientToken: token ?? null });
return true;
};
// ---------------------------------------------------------------------------
// Shared connection controller
// ---------------------------------------------------------------------------
export type UseMobileConnection = {
connections: MobileSavedConnection[];
isBusy: boolean;
isPasswordBusy: boolean;
error: string | null;
pendingConnection: MobilePendingConnection | null;
connect: (input: MobileConnectInput) => Promise<void>;
submitPassword: (password: string) => Promise<void>;
cancelPassword: () => void;
saveConnection: (input: MobileConnectInput) => Promise<MobileSavedConnection | null>;
removeConnection: (id: string) => Promise<MobileSavedConnection | null>;
setError: (message: string | null) => void;
};
// `onConnected` fires once the runtime endpoint is switched (the caller navigates
// away / closes its surface from there).
export const useMobileConnection = (onConnected: () => void): UseMobileConnection => {
const { t } = useI18n();
const [connections, setConnections] = React.useState<MobileSavedConnection[]>(() => readConnections());
const [busyOperation, setBusyOperation] = React.useState<'connect' | 'password' | null>(null);
const [error, setError] = React.useState<string | null>(null);
const [pendingConnection, setPendingConnection] = React.useState<MobilePendingConnection | null>(null);
const connectionsRef = React.useRef(connections);
const busyRef = React.useRef<'connect' | 'password' | null>(null);
const applyConnections = React.useCallback((next: MobileSavedConnection[]) => {
connectionsRef.current = next;
setConnections(next);
}, []);
const beginBusy = React.useCallback((operation: 'connect' | 'password') => {
busyRef.current = operation;
setBusyOperation(operation);
}, []);
const endBusy = React.useCallback((operation: 'connect' | 'password') => {
if (busyRef.current !== operation) return;
busyRef.current = null;
setBusyOperation(null);
}, []);
// Refresh from storage on mount (runs the legacy-token migration too).
React.useEffect(() => {
let disposed = false;
void loadMobileConnections().then((loaded) => {
if (!disposed) applyConnections(loaded);
});
return () => { disposed = true; };
}, [applyConnections]);
// Persist metadata for a connection and reflect it in state immediately.
const persistMetadata = React.useCallback((draft: { label: string; url: string; clientToken?: string }) => {
const next = upsertConnectionInList(connectionsRef.current, draft);
applyConnections(next);
writeConnections(next);
return next;
}, [applyConnections]);
const connect = React.useCallback(async (input: MobileConnectInput) => {
setError(null);
beginBusy('connect');
try {
const url = normalizeConnectionUrl(input.url);
if (!url) {
setError(t('mobile.connect.error.urlRequired'));
return;
}
const label = input.label?.trim()
|| connectionsRef.current.find((c) => isSameConnectionUrl(c.url, url))?.label
|| getConnectionLabel(url);
// Resolve a token: explicit input wins, otherwise read the saved one from
// the secure store (single bounded read — never blocks the flow).
let token = input.clientToken?.trim() || undefined;
const tokenIsNew = Boolean(token);
if (!token && isCapacitorApp()) {
const saved = connectionsRef.current.find((c) => isSameConnectionUrl(c.url, url));
if (saved?.hasToken) token = await readSecureToken(url);
}
const headers = token ? { Authorization: `Bearer ${token}` } : undefined;
logConnect('health:start', { url });
const health = await requestWithTimeout(`${url}/health`, { method: 'GET', headers });
logConnect('health:done', { ok: health?.ok === true, source: health?.source ?? null, status: health?.status ?? null });
if (!health?.ok) {
setError(t('mobile.connect.error.unreachable'));
return;
}
logConnect('session:start', { url, hasToken: Boolean(token) });
const session = await requestWithTimeout(`${url}/auth/session`, { method: 'GET', credentials: 'include', headers });
const status = await readSessionStatus(session);
logConnect('session:done', { ok: session?.ok === true, status: session?.status ?? null, scope: status?.scope ?? null, disabled: status?.disabled === true });
// A cookie-only native session (authenticated, but not a `client` bearer
// scope and not auth-disabled) is not enough — the runtime transport needs a
// bearer token, so fall through to the password flow to mint one.
const cookieOnlyNeedsToken = isCapacitorApp()
&& session?.ok === true
&& !token
&& status?.authenticated === true
&& status.disabled !== true
&& status.scope !== 'client';
if (!token && (session?.status === 401 || cookieOnlyNeedsToken)) {
persistMetadata({ label, url });
setPendingConnection({ label, url });
return;
}
if (!session || (!session.ok && session.status !== 404)) {
setError(t('mobile.connect.error.authRequired'));
return;
}
// Connected. If the token came from the user (not the secure store), persist
// it first so a cold restart won't re-prompt.
if (token && tokenIsNew && isCapacitorApp()) {
await writeSecureToken(url, token);
}
persistMetadata({ label, url, clientToken: token });
switchRuntimeEndpoint({ apiBaseUrl: url, clientToken: token ?? null });
onConnected();
} catch (error) {
console.warn('[mobile-connect] connect threw', error);
setError(t('mobile.connect.error.invalidUrl'));
} finally {
endBusy('connect');
}
}, [beginBusy, endBusy, onConnected, persistMetadata, t]);
const submitPassword = React.useCallback(async (password: string) => {
if (!pendingConnection || !password.trim() || busyRef.current === 'password') return;
setError(null);
beginBusy('password');
const { url, label } = pendingConnection;
try {
logConnect('password:start', { url });
const response = await requestWithTimeout(`${url}/auth/session`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ password, trustDevice: true, issueClientToken: true, clientLabel: 'OpenChamber Mobile' }),
});
logConnect('password:done', { ok: response?.ok === true, status: response?.status ?? null });
if (!response?.ok) {
setError(t('mobile.connect.error.passwordFailed'));
return;
}
const payload = await response.json().catch(() => null) as { clientToken?: unknown } | null;
const issuedToken = typeof payload?.clientToken === 'string' ? payload.clientToken.trim() : '';
logConnect('password:token', { issued: Boolean(issuedToken) });
// Native runtime transport needs a bearer token; a cookie-only success is
// not acceptable for a saved protected instance.
if (isCapacitorApp() && !issuedToken) {
setError(t('mobile.connect.error.authRequired'));
return;
}
// Guarantee the token is persisted BEFORE switching (no fire-and-forget).
if (isCapacitorApp() && issuedToken) {
await writeSecureToken(url, issuedToken);
}
persistMetadata({ label, url, clientToken: issuedToken || undefined });
setPendingConnection(null);
switchRuntimeEndpoint({ apiBaseUrl: url, clientToken: issuedToken || null });
onConnected();
} catch (error) {
console.warn('[mobile-connect] password threw', error);
setError(t('mobile.connect.error.passwordFailed'));
} finally {
endBusy('password');
}
}, [beginBusy, endBusy, onConnected, pendingConnection, persistMetadata, t]);
const cancelPassword = React.useCallback(() => {
setPendingConnection(null);
setError(null);
}, []);
const saveConnection = React.useCallback(async (input: MobileConnectInput): Promise<MobileSavedConnection | null> => {
setError(null);
const url = normalizeConnectionUrl(input.url);
if (!url) {
setError(t('mobile.connect.error.urlRequired'));
return null;
}
const clientToken = input.clientToken?.trim() || undefined;
const label = input.label?.trim() || getConnectionLabel(url);
// Awaited token write so "Save" truly persisted the secret before returning.
if (isCapacitorApp() && clientToken) {
await writeSecureToken(url, clientToken);
}
const next = persistMetadata({ label, url, clientToken });
return next.find((connection) => isSameConnectionUrl(connection.url, url)) ?? null;
}, [persistMetadata, t]);
const removeConnection = React.useCallback(async (id: string): Promise<MobileSavedConnection | null> => {
const removed = connectionsRef.current.find((connection) => connection.id === id) ?? null;
const next = await deleteMobileConnection(id);
applyConnections(next);
return removed;
}, [applyConnections]);
return {
connections,
isBusy: busyOperation !== null,
isPasswordBusy: busyOperation === 'password',
error,
pendingConnection,
connect,
submitPassword,
cancelPassword,
saveConnection,
removeConnection,
setError,
};
};
+176
View File
@@ -0,0 +1,176 @@
// Connection payload parsing + native QR scanning for the dedicated mobile app.
//
// The pairing link format is produced by `openchamber connect-url --qr`:
// openchamber://connect?v=1&server=<url>&token=<token>&label=<label>
// We also accept a bare http(s) URL so a QR encoding only the server address works.
//
// QR scanning is delegated to a Capacitor barcode-scanner plugin if the native
// shell registered one (`window.Capacitor.Plugins.BarcodeScanner`). We resolve it
// at runtime instead of importing the package so the web build stays dependency-free
// and the browser-hosted mobile UI degrades to `unsupported` cleanly.
export type MobileConnectionPayload = {
url: string;
clientToken?: string;
label?: string;
};
export type QrScanResult =
| ({ status: 'ok' } & MobileConnectionPayload)
| { status: 'cancelled' }
| { status: 'unsupported' }
| { status: 'permission-denied' }
| { status: 'invalid' }
| { status: 'failed' };
type ScannedBarcode = { rawValue?: string; displayValue?: string };
type ModuleInstallProgress = { state?: number };
type ListenerHandle = { remove: () => void };
type BarcodeScannerPlugin = {
requestPermissions?: () => Promise<{ camera?: string } | undefined>;
scan?: (options?: { formats?: string[] }) => Promise<{ barcodes?: ScannedBarcode[] } | undefined>;
// Android-only: the Google code scanner used by scan() needs the ML Kit barcode module,
// which Play Services must download once before the first scan. Absent on iOS.
isGoogleBarcodeScannerModuleAvailable?: () => Promise<{ available?: boolean } | undefined>;
installGoogleBarcodeScannerModule?: () => Promise<void>;
addListener?: (
event: 'googleBarcodeScannerModuleInstallProgress',
cb: (info: ModuleInstallProgress) => void,
) => Promise<ListenerHandle>;
};
// Google's ModuleInstallProgress states: 4 = COMPLETED, 3 = CANCELED, 5 = FAILED.
const MODULE_STATE_COMPLETED = 4;
const MODULE_STATE_CANCELED = 3;
const MODULE_STATE_FAILED = 5;
const MODULE_INSTALL_TIMEOUT_MS = 90_000;
// Ensure the Android Google barcode module is downloaded before scanning. No-op on platforms
// where these methods don't exist (iOS) or when it's already available. Resolves once the module
// is usable; rejects if the install is canceled, fails, or times out.
const ensureScannerModule = async (plugin: BarcodeScannerPlugin): Promise<void> => {
if (!plugin.isGoogleBarcodeScannerModuleAvailable || !plugin.installGoogleBarcodeScannerModule) {
return;
}
const status = await plugin.isGoogleBarcodeScannerModuleAvailable().catch(() => undefined);
if (status?.available) return;
await new Promise<void>((resolve, reject) => {
let handle: ListenerHandle | undefined;
const finish = (fn: () => void) => {
window.clearTimeout(timer);
handle?.remove();
fn();
};
const timer = window.setTimeout(
() => finish(() => reject(new Error('module install timed out'))),
MODULE_INSTALL_TIMEOUT_MS,
);
// addListener may return a handle synchronously OR a Promise<handle> depending on the
// Capacitor proxy — normalize with Promise.resolve so a non-thenable handle doesn't throw
// and abort the install call below.
Promise.resolve(
plugin.addListener?.('googleBarcodeScannerModuleInstallProgress', (info) => {
if (info?.state === MODULE_STATE_COMPLETED) finish(resolve);
else if (info?.state === MODULE_STATE_CANCELED || info?.state === MODULE_STATE_FAILED) {
finish(() => reject(new Error('module install failed')));
}
}),
)
.then((h) => {
handle = h as ListenerHandle | undefined;
})
.catch(() => undefined);
Promise.resolve(plugin.installGoogleBarcodeScannerModule?.()).catch((error) =>
finish(() => reject(error instanceof Error ? error : new Error('module install failed'))),
);
});
};
const getScannerPlugin = (): BarcodeScannerPlugin | null => {
if (typeof window === 'undefined') return null;
const capacitor = (window as typeof window & {
Capacitor?: { Plugins?: Record<string, unknown> };
}).Capacitor;
const plugin = capacitor?.Plugins?.BarcodeScanner as BarcodeScannerPlugin | undefined;
return plugin && typeof plugin.scan === 'function' ? plugin : null;
};
export const parseConnectionPayload = (raw: string): MobileConnectionPayload | null => {
const trimmed = raw.trim();
if (!trimmed) return null;
if (/^openchamber:\/\//i.test(trimmed)) {
try {
const parsed = new URL(trimmed);
const server = parsed.searchParams.get('server')?.trim();
if (!server) return null;
const clientToken = parsed.searchParams.get('token')?.trim();
const label = parsed.searchParams.get('label')?.trim();
return {
url: server,
clientToken: clientToken || undefined,
label: label || undefined,
};
} catch {
return null;
}
}
if (/^https?:\/\//i.test(trimmed)) return { url: trimmed };
return null;
};
// The Google code scanner can briefly still throw "module not available" in the moments right
// after its install completes. Detect that specific error so we can re-ensure + retry rather
// than surfacing a failure the user would have to manually tap through.
const isModuleUnavailableError = (error: unknown): boolean => {
const message =
typeof error === 'object' && error && 'message' in error
? String((error as { message?: unknown }).message ?? '')
: String(error ?? '');
return /module/i.test(message) && /not\s*available|unavailable/i.test(message);
};
export const isQrScanSupported = (): boolean => getScannerPlugin() !== null;
export const scanConnectionQr = async (): Promise<QrScanResult> => {
const plugin = getScannerPlugin();
if (!plugin?.scan) return { status: 'unsupported' };
try {
if (plugin.requestPermissions) {
const permission = await plugin.requestPermissions();
const camera = permission?.camera;
if (camera && camera !== 'granted' && camera !== 'limited') {
return { status: 'permission-denied' };
}
}
// First scan on Android downloads the Google barcode module (the button stays in its
// scanning state for the whole wait). The module can still report "not available" for a
// moment right after install, so re-ensure + retry within this same call instead of erroring
// out — the user shouldn't have to guess to tap again.
for (let attempt = 0; attempt < 3; attempt++) {
try {
await ensureScannerModule(plugin);
const result = await plugin.scan({ formats: ['QR_CODE'] });
const barcode = result?.barcodes?.[0];
const raw = (barcode?.rawValue ?? barcode?.displayValue ?? '').trim();
if (!raw) return { status: 'cancelled' };
const payload = parseConnectionPayload(raw);
if (!payload) return { status: 'invalid' };
return { status: 'ok', ...payload };
} catch (error) {
if (!isModuleUnavailableError(error) || attempt === 2) return { status: 'failed' };
await new Promise((resolve) => window.setTimeout(resolve, 600));
}
}
return { status: 'failed' };
} catch {
return { status: 'failed' };
}
};
@@ -0,0 +1,122 @@
import type { Session } from '@opencode-ai/sdk/v2';
import type { ProjectEntry } from '@/lib/api/types';
import { useUIStore } from '@/stores/useUIStore';
import { resolveGlobalSessionDirectory, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useNotificationStore } from '@/sync/notification-store';
/**
* Builds the lightweight session overview the native iOS widgets render (home medium,
* lock-screen, Control Center). The widget process can't see the WebView, so the native
* shell pulls this snapshot via `window.__OPENCHAMBER_WIDGET_SNAPSHOT__()` on
* background/activate, writes it to the shared App Group, and reloads the widget timelines
* (see SceneDelegate.writeWidgetSnapshot). Mirrors the sidebar's attention logic so the
* widget's "needs attention" mark matches the in-app unread dot exactly:
* needsAttention = unseenCount > 0 && (!isSubtask || notifyOnSubtasks)
*/
export interface MobileWidgetSession {
id: string;
title: string;
/** True when the session needs attention (unread + honouring the subtask setting). */
unread: boolean;
/** Project label for the session's directory (matched project name, else folder name). */
project: string;
}
export interface MobileWidgetSnapshot {
/** Count of sessions needing attention — same signal that drives the app-icon badge. */
attentionCount: number;
/** Most-recently-updated top-level sessions, newest first (capped for the medium widget). */
recentSessions: MobileWidgetSession[];
}
const RECENT_LIMIT = 6;
const parentIdOf = (session: Session): string | null =>
(session as Session & { parentID?: string | null }).parentID ?? null;
const basename = (path: string): string => {
const trimmed = path.replace(/\/+$/, '');
return trimmed.slice(trimmed.lastIndexOf('/') + 1) || trimmed;
};
const normalizeProjectPath = (path: string): string =>
path.replace(/\\/g, '/').replace(/\/+$/, '');
/** Project label for a session directory: longest matching project's name, else the folder name. */
const projectLabelForDirectory = (directory: string | null, projects: ProjectEntry[]): string => {
if (!directory) return '';
let best: ProjectEntry | null = null;
let bestLen = -1;
for (const project of projects) {
const projectPath = normalizeProjectPath(project.path);
if (directory === projectPath || directory.startsWith(`${projectPath}/`)) {
if (projectPath.length > bestLen) {
best = project;
bestLen = projectPath.length;
}
}
}
if (best) {
return best.label?.trim() || basename(best.path);
}
return basename(directory);
};
export const buildMobileWidgetSnapshot = (): MobileWidgetSnapshot => {
const sessions = useGlobalSessionsStore.getState().activeSessions;
const unseenBySession = useNotificationStore.getState().index.session.unseenCount;
const notifyOnSubtasks = useUIStore.getState().notifyOnSubtasks;
const projects = useProjectsStore.getState().projects;
let attentionCount = 0;
const topLevel: Array<{ id: string; title: string; updated: number; unread: boolean; project: string }> = [];
for (const session of sessions) {
const isSubtask = parentIdOf(session) !== null;
const unseenCount = unseenBySession[session.id] ?? 0;
const needsAttention = unseenCount > 0 && (!isSubtask || notifyOnSubtasks);
if (needsAttention) {
attentionCount += 1;
}
if (!isSubtask) {
topLevel.push({
id: session.id,
title: session.title ?? '',
updated: session.time?.updated ?? session.time?.created ?? 0,
unread: needsAttention,
project: projectLabelForDirectory(resolveGlobalSessionDirectory(session), projects),
});
}
}
topLevel.sort((a, b) => b.updated - a.updated);
const recentSessions = topLevel
.slice(0, RECENT_LIMIT)
.map(({ id, title, unread, project }) => ({ id, title, unread, project }));
return { attentionCount, recentSessions };
};
const SNAPSHOT_GLOBAL_KEY = '__OPENCHAMBER_WIDGET_SNAPSHOT__';
/**
* Exposes the snapshot builder on `window` so the native shell can read it synchronously via
* `evaluateJavaScript`. Returns a JSON string (the bridge wants a primitive result) or `null`
* if building fails, so the native side can skip writing on error rather than clobber a good
* snapshot. Safe to call in any runtime; only the native iOS shell ever invokes it.
*/
export const installMobileWidgetSnapshotBridge = (): void => {
if (typeof window === 'undefined') {
return;
}
(window as typeof window & { [SNAPSHOT_GLOBAL_KEY]?: () => string | null })[SNAPSHOT_GLOBAL_KEY] = () => {
try {
return JSON.stringify(buildMobileWidgetSnapshot());
} catch {
return null;
}
};
};
+29 -4
View File
@@ -3,12 +3,14 @@ import { createRoot } from 'react-dom/client';
import '@/styles/fonts';
import '@/index.css';
import '@/lib/debug';
import { SessionAuthGate } from '@/components/auth/SessionAuthGate';
import { DiffWorkerProvider } from '@/contexts/DiffWorkerProvider';
import { ThemeProvider } from '@/components/providers/ThemeProvider';
import { ThemeSystemProvider } from '@/contexts/ThemeSystemContext';
import type { RuntimeAPIs } from '@/lib/api/types';
import { startAppearanceAutoSave } from '@/lib/appearanceAutoSave';
import { getDeviceInfo } from '@/lib/device';
import { markAppBootReady } from './appBootReady';
import { installMobileWidgetSnapshotBridge } from './mobileWidgetSnapshot';
import { applyPersistedDirectoryPreferences } from '@/lib/directoryPersistence';
import { initializeLocale, I18nProvider } from '@/lib/i18n';
import { initializeAppearancePreferences, syncDesktopSettings } from '@/lib/persistence';
@@ -32,26 +34,49 @@ const initializeSharedPreferences = () => {
startTypographyWatcher();
}).catch((err) => {
console.error('[mobile-main] appearance init failed:', err);
}).finally(() => {
// Persisted typography/appearance is now applied — release the splash gate so the
// first UI paint is already at its final sizes.
markAppBootReady();
});
};
export function renderMobileApp(apis: RuntimeAPIs) {
initializeSharedPreferences();
// Expose the widget snapshot builder so the native shell can read the session overview
// (attention count + recent sessions) and feed the home/lock-screen/Control Center widgets.
installMobileWidgetSnapshotBridge();
// Apply the device classes (`device-mobile`, `mobile-pointer`) to <html> BEFORE the
// first React paint. They gate the mobile typography rules in mobile.css (larger
// --text-* sizes); applied late from a hook effect, they bumped text size a frame
// after mount and shifted the layout (connect / scan / saved-connection labels).
getDeviceInfo();
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error('Root element not found');
}
// The native Capacitor app delivers notifications via APNs only (background, server-side
// focus-gated). Disable the in-app notification dispatch on native with a no-op
// notifications API: scheduling local notifications can't tell foreground from background
// in a WKWebView and leaked while the app was open. (The Web Notifications API the web
// runtime uses also doesn't display inside a WKWebView.)
const capacitor = (window as typeof window & { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor;
const isNativeShell = capacitor?.isNativePlatform?.() === true || window.location.protocol === 'capacitor:';
const resolvedApis = isNativeShell
? { ...apis, notifications: { notifyAgentCompletion: async () => false, canNotify: () => false } }
: apis;
createRoot(rootElement).render(
<StrictMode>
<I18nProvider>
<ThemeSystemProvider>
<ThemeProvider>
<DiffWorkerProvider>
<SessionAuthGate>
<MobileApp apis={apis} />
</SessionAuthGate>
<MobileApp apis={resolvedApis} />
</DiffWorkerProvider>
</ThemeProvider>
</ThemeSystemProvider>
@@ -0,0 +1,31 @@
import { opencodeClient } from '@/lib/opencode/client';
import type { RuntimeEndpointChangedDetail } from '@/lib/runtime-switch';
import { disposeTerminalInputTransport } from '@/lib/terminalApi';
import { useConfigStore } from '@/stores/useConfigStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
import { useUIStore } from '@/stores/useUIStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { resetStreamingState } from '@/sync/streaming';
export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedDetail): void => {
useSessionUIStore.getState().prepareForRuntimeSwitch(detail.previousRuntimeKey);
useUIStore.getState().prepareForRuntimeSwitch(detail.previousRuntimeKey);
if (detail.previousRuntimeKey) {
useAutoReviewStore.getState().stopRunningRunsForRuntime(detail.previousRuntimeKey);
}
disposeTerminalInputTransport();
opencodeClient.reconnectToRuntimeBaseUrl();
useConfigStore.setState({
providers: [],
agents: [],
isConnected: false,
isInitialized: false,
connectionPhase: 'connecting',
lastDisconnectReason: null,
});
useProjectsStore.getState().resetForRuntimeSwitch();
useSessionUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey);
useUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey);
resetStreamingState();
};
@@ -0,0 +1,125 @@
import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import { resolveGlobalSessionDirectory, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
/**
* Native-feeling edge swipe to switch sessions in the mobile chat: start a horizontal swipe
* from the very left/right edge and drag toward the centre to step through sessions.
*
* - Left edge → centre = previous session (the more-recent one in the list)
* - Right edge → centre = next session (the older one)
*
* Navigation walks the same ranked list the rest of the mobile UI uses: top-level sessions
* (no subtasks) across all projects, newest-first by `time.updated`. The order is computed at
* gesture time from the store (not subscribed) so it's always fresh and never re-attaches.
*
* Only `touchstart`/`touchend` are observed (both passive), so this never interferes with
* vertical chat scrolling or the horizontal scroll inside code blocks — it just reads where the
* gesture began and ended. The edge zone keeps it clear of in-content horizontal scroll, which
* lives away from the screen edges.
*/
const EDGE_ZONE = 32; // px from a side where the swipe must begin
const MIN_DISTANCE = 64; // px of horizontal travel required to commit a switch
const MAX_OFF_AXIS_RATIO = 0.7; // |dy| must stay below |dx| * this (keep it horizontal)
const parentIdOf = (session: Session): string | null =>
(session as Session & { parentID?: string | null }).parentID ?? null;
const updatedAt = (session: Session): number => session.time?.updated ?? session.time?.created ?? 0;
/** Top-level sessions across all projects, newest-first — the list the swipe walks. */
const orderedTopLevelSessions = (): Session[] =>
useGlobalSessionsStore
.getState()
.activeSessions.filter((session) => parentIdOf(session) === null)
.slice()
.sort((a, b) => updatedAt(b) - updatedAt(a));
/**
* Switch to the session `step` positions away from the current one (clamped — no wrap).
* Returns true if a switch actually happened.
*/
const switchByStep = (step: number): boolean => {
const ordered = orderedTopLevelSessions();
if (ordered.length < 2) return false;
const currentId = useSessionUIStore.getState().currentSessionId;
const index = ordered.findIndex((session) => session.id === currentId);
if (index < 0) return false;
const targetIndex = index + step;
if (targetIndex < 0 || targetIndex >= ordered.length) return false;
const target = ordered[targetIndex];
useSessionUIStore.getState().setCurrentSession(target.id, resolveGlobalSessionDirectory(target));
return true;
};
export interface EdgeSwipeSessionSwitchOptions {
/** Called after a successful switch, with the travel direction, so the caller can animate. */
onSwitch?: (direction: 'prev' | 'next') => void;
}
export const useEdgeSwipeSessionSwitch = (
ref: React.RefObject<HTMLElement | null>,
options?: EdgeSwipeSessionSwitchOptions,
): void => {
// Keep onSwitch in a ref so a changing callback identity doesn't re-attach the listeners.
const onSwitchRef = React.useRef(options?.onSwitch);
onSwitchRef.current = options?.onSwitch;
React.useEffect(() => {
const element = ref.current;
if (!element) return;
let tracking = false;
let fromLeftEdge = false;
let startX = 0;
let startY = 0;
const onTouchStart = (event: TouchEvent) => {
if (event.touches.length !== 1) {
tracking = false;
return;
}
const touch = event.touches[0];
const width = element.clientWidth;
const nearLeft = touch.clientX <= EDGE_ZONE;
const nearRight = touch.clientX >= width - EDGE_ZONE;
tracking = nearLeft || nearRight;
fromLeftEdge = nearLeft;
startX = touch.clientX;
startY = touch.clientY;
};
const onTouchEnd = (event: TouchEvent) => {
if (!tracking) return;
tracking = false;
const touch = event.changedTouches[0];
if (!touch) return;
const dx = touch.clientX - startX;
const dy = touch.clientY - startY;
if (Math.abs(dx) < MIN_DISTANCE) return;
if (Math.abs(dy) > Math.abs(dx) * MAX_OFF_AXIS_RATIO) return;
// Must travel toward the centre: left edge → rightward, right edge → leftward.
if (fromLeftEdge && dx <= 0) return;
if (!fromLeftEdge && dx >= 0) return;
const step = fromLeftEdge ? -1 : 1;
if (switchByStep(step)) {
onSwitchRef.current?.(step < 0 ? 'prev' : 'next');
}
};
element.addEventListener('touchstart', onTouchStart, { passive: true });
element.addEventListener('touchend', onTouchEnd, { passive: true });
return () => {
element.removeEventListener('touchstart', onTouchStart);
element.removeEventListener('touchend', onTouchEnd);
};
}, [ref]);
};
+51
View File
@@ -0,0 +1,51 @@
import React from 'react';
import { useFontPreferences } from '@/hooks/useFontPreferences';
import { loadUiFont } from '@/lib/fontLoader';
import { appBootReadyPromise } from './appBootReady';
/**
* Resolves to `true` once the first UI paint can be final — i.e. the selected UI web
* font has loaded AND one-time appearance/typography boot work has been applied (or a
* safety timeout elapses, so a slow/offline CDN can never block the app forever).
*
* Without this, the app paints immediately in the fallback font / default typography and
* then reflows once the real font and persisted appearance prefs arrive — a visible flash
* and micro layout shift. Hold a logo splash until this is `true` so the first UI the user
* sees is already at its final font and sizes.
*/
export function useFontsReady(timeoutMs = 2500): boolean {
const { uiFont } = useFontPreferences();
const [ready, setReady] = React.useState(false);
React.useEffect(() => {
let cancelled = false;
const markReady = () => {
if (!cancelled) setReady(true);
};
// Wait one paint after everything settles so the applied styles are committed before
// we reveal the UI (avoids revealing on the same frame a size/font change lands).
const settleThenReady = () => {
requestAnimationFrame(() => requestAnimationFrame(markReady));
};
const ready = Promise.all([
loadUiFont(uiFont).catch(() => undefined),
document.fonts?.ready?.then(() => undefined).catch(() => undefined) ?? Promise.resolve(),
appBootReadyPromise.catch(() => undefined),
]).then(() => undefined);
const timeout = new Promise<void>((resolve) => {
window.setTimeout(resolve, timeoutMs);
});
void Promise.race([ready, timeout]).then(settleThenReady);
return () => {
cancelled = true;
};
}, [uiFont, timeoutMs]);
return ready;
}
@@ -0,0 +1,101 @@
import React from 'react';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { getClientPlatform } from '@/lib/platform';
import { useUIStore } from '@/stores/useUIStore';
/**
* Registers the native iOS APNs device token with the connected server so the app can
* receive remote push even when suspended/closed. Delivery goes through the central relay
* (server posts generic text → relay signs+sends) — see
* `packages/web/server/lib/notifications/APNS.md`.
*
* Lazy-imports `@capacitor/push-notifications` (only present in the Capacitor shell),
* mirroring the other `@capacitor/*` integrations in MobileApp. On `registration` the
* device token is sent to the server via `apis.push.registerApnsToken`; tapping a push
* deep-links to its session. Pass `enabled = isNativeMobileApp && isConnected`; the hook
* additionally gates on the `nativeNotificationsEnabled` setting and re-registers when
* the connection (and thus the active server endpoint) changes.
*/
// Native push: iOS uses APNs, Android uses FCM. Both are set up natively (google-services.json +
// the Google Services Gradle plugin on Android), so @capacitor/push-notifications' register()
// returns the right token per platform. The token is sent to the server tagged with its platform
// so the relay routes it to APNs vs FCM.
const isNativePushPlatform = (): boolean => {
if (typeof window === 'undefined') return false;
const capacitor = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor;
const platform = capacitor?.getPlatform?.();
return platform === 'ios' || platform === 'android';
};
export const useNativePushRegistration = (options: { enabled: boolean }): void => {
const { enabled } = options;
const nativeNotificationsEnabled = useUIStore((state) => state.nativeNotificationsEnabled);
const lastTokenRef = React.useRef<string | null>(null);
React.useEffect(() => {
if (!enabled || !nativeNotificationsEnabled || !isNativePushPlatform()) {
return;
}
let disposed = false;
const cleanup: Array<() => void> = [];
void import('@capacitor/push-notifications')
.then(async ({ PushNotifications }) => {
if (disposed) return;
let permission = await PushNotifications.checkPermissions().catch(() => null);
if (permission?.receive !== 'granted') {
permission = await PushNotifications.requestPermissions().catch(() => null);
}
if (permission?.receive !== 'granted') {
return;
}
const registrationHandle = await PushNotifications.addListener('registration', (token) => {
lastTokenRef.current = token.value;
const apis = getRegisteredRuntimeAPIs();
void apis?.push?.registerApnsToken?.({ token: token.value, platform: getClientPlatform() });
});
const registrationErrorHandle = await PushNotifications.addListener('registrationError', (error) => {
console.warn('[Push] APNs registration error:', error);
});
// Note: notification-tap handling lives in the deep-link layer (`useDeepLinkSource`
// in deepLinkNavigation), registered unconditionally so cold-launch taps aren't lost
// while disconnected.
await PushNotifications.register().catch(() => undefined);
if (disposed) {
void registrationHandle.remove();
void registrationErrorHandle.remove();
return;
}
cleanup.push(
() => void registrationHandle.remove(),
() => void registrationErrorHandle.remove(),
);
})
.catch(() => undefined);
return () => {
disposed = true;
cleanup.forEach((remove) => remove());
};
}, [enabled, nativeNotificationsEnabled]);
// When notifications are turned off, drop the token from the server so it stops
// pushing to this device. (Separate from the register effect so a transient
// disconnect doesn't unregister.)
React.useEffect(() => {
if (nativeNotificationsEnabled) return;
const token = lastTokenRef.current;
if (!token) return;
lastTokenRef.current = null;
const apis = getRegisteredRuntimeAPIs();
void apis?.push?.unregisterApnsToken?.({ token });
}, [nativeNotificationsEnabled]);
};
@@ -1146,7 +1146,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}, [currentSessionId, currentDirectory, t]);
const isDesktopExpanded = isExpandedInput && !isMobile;
const chatInputRadius = 'var(--radius-xl)';
// Rounder composer on mobile (touch UI reads better with a softer corner).
const chatInputRadius = isMobile ? '1.5rem' : 'var(--radius-xl)';
const useCompactChatPlaceholder = isMobile || isNarrowComposer;
React.useEffect(() => {
@@ -4016,7 +4017,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
className={cn(
"relative w-full pt-0 pb-4",
isDesktopExpanded && 'flex h-full min-h-0 flex-col pt-4',
isMobile && 'bottom-safe-area'
isMobile && 'bottom-safe-area oc-mobile-composer'
)}
style={isMobile && inputBarOffset > 0 ? { marginBottom: `${inputBarOffset}px` } : undefined}
>
@@ -7,6 +7,7 @@ import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { getClientPlatform } from '@/lib/platform';
import { useI18n } from '@/lib/i18n';
const DEFAULT_NOTIFICATION_TEMPLATES = {
@@ -39,7 +40,15 @@ export const NotificationSettings: React.FC = () => {
const { t } = useI18n();
const isDesktop = React.useMemo(() => isDesktopShell(), []);
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
const isBrowser = !isDesktop && !isVSCode;
// The native Capacitor app runs in a WKWebView with no Web Notification API; it has its
// own native (Local Notifications) permission. Treat it as a native runtime, not a
// browser, so the toggle isn't gated on Notification.permission (which is stuck there).
const isNativeApp = React.useMemo(() => {
if (typeof window === 'undefined') return false;
const capacitor = (window as typeof window & { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor;
return capacitor?.isNativePlatform?.() === true || window.location.protocol === 'capacitor:';
}, []);
const isBrowser = !isDesktop && !isVSCode && !isNativeApp;
const nativeNotificationsEnabled = useUIStore(state => state.nativeNotificationsEnabled);
const setNativeNotificationsEnabled = useUIStore(state => state.setNativeNotificationsEnabled);
const notificationMode = useUIStore(state => state.notificationMode);
@@ -131,7 +140,7 @@ export const NotificationSettings: React.FC = () => {
}
};
const canShowNotifications = isDesktop || isVSCode || (isBrowser && typeof Notification !== 'undefined' && Notification.permission === 'granted');
const canShowNotifications = isDesktop || isVSCode || isNativeApp || (isBrowser && typeof Notification !== 'undefined' && Notification.permission === 'granted');
const updateTemplate = (
event: 'completion' | 'error' | 'question' | 'subtask',
@@ -383,6 +392,7 @@ export const NotificationSettings: React.FC = () => {
auth: keys.auth,
},
origin: typeof window !== 'undefined' ? window.location.origin : undefined,
platform: getClientPlatform(),
}),
15000,
'Push subscribe request timed out'
@@ -474,7 +484,10 @@ export const NotificationSettings: React.FC = () => {
<span className="typography-ui-label text-foreground">{t('settings.notifications.page.delivery.enableLabel')}</span>
</div>
{nativeNotificationsEnabled && canShowNotifications && (
{/* The native Capacitor app never notifies while focused (hard rule) and uses
generic, non-customizable text, so the "notify while focused" toggle and the
test button are hidden there. */}
{nativeNotificationsEnabled && canShowNotifications && !isNativeApp && (
<>
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
@@ -618,7 +631,8 @@ export const NotificationSettings: React.FC = () => {
</section>
</div>
{/* --- Template Customization --- */}
{/* --- Template Customization (not on the native app — it uses generic text) --- */}
{!isNativeApp && (
<div className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
@@ -666,6 +680,7 @@ export const NotificationSettings: React.FC = () => {
))}
</div>
</div>
)}
</>
)}
@@ -7,6 +7,7 @@ import type { ThemeMode } from '@/types/theme';
import { useUIStore } from '@/stores/useUIStore';
import { useMessageQueueStore, type FollowUpBehavior } from '@/stores/messageQueueStore';
import { cn } from '@/lib/utils';
import { isCapacitorApp } from '@/lib/platform';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { NumberInput } from '@/components/ui/number-input';
@@ -321,6 +322,10 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const setShowSplitAssistantMessageActions = useUIStore(state => state.setShowSplitAssistantMessageActions);
const messageStreamTransport = useConfigStore((state) => state.settingsMessageStreamTransport);
const setMessageStreamTransport = useConfigStore((state) => state.setSettingsMessageStreamTransport);
// Capacitor apps are locked to SSE (native WebSocket streaming is unreliable on mobile);
// sync-context forces it too. Show SSE selected and disable the other options here.
const isCapacitorAppRuntime = React.useMemo(() => isCapacitorApp(), []);
const effectiveMessageStreamTransport = isCapacitorAppRuntime ? 'sse' : messageStreamTransport;
const settingsDefaultFileViewerPreview = useConfigStore((state) => state.settingsDefaultFileViewerPreview);
const setSettingsDefaultFileViewerPreview = useConfigStore((state) => state.setSettingsDefaultFileViewerPreview);
const isSettingsDialogOpen = useUIStore(state => state.isSettingsDialogOpen);
@@ -1525,7 +1530,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
key={option.id}
variant="chip"
size="xs"
aria-pressed={messageStreamTransport === option.id}
aria-pressed={effectiveMessageStreamTransport === option.id}
disabled={isCapacitorAppRuntime && option.id !== 'sse'}
className="!font-normal"
onClick={() => handleMessageStreamTransportChange(option.id)}
>
@@ -1535,7 +1541,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</div>
<span className="typography-meta text-muted-foreground">
{(() => {
const option = MESSAGE_STREAM_TRANSPORT_OPTIONS.find((item) => item.id === messageStreamTransport);
const option = MESSAGE_STREAM_TRANSPORT_OPTIONS.find((item) => item.id === effectiveMessageStreamTransport);
return option?.descriptionKey ? tUnsafe(option.descriptionKey) : '';
})()}
</span>
@@ -85,7 +85,7 @@ export const MobileOverlayPanel: React.FC<MobileOverlayPanelProps> = ({
const content = (
<div
className={cn(
'fixed inset-0 z-[60] flex flex-col bg-[rgb(0_0_0_/_0.45)] transition-opacity duration-200 ease-out',
'oc-keyboard-inset-surface fixed inset-0 z-[60] flex flex-col bg-[rgb(0_0_0_/_0.45)] transition-opacity duration-200 ease-out',
entered ? 'opacity-100' : 'opacity-0',
)}
role="dialog"
@@ -1,5 +1,6 @@
import React from 'react';
import { isWebRuntime } from '@/lib/desktop';
import { getClientPlatform, isCapacitorApp } from '@/lib/platform';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
const HEARTBEAT_MS = 20000;
@@ -10,7 +11,7 @@ const resolveVisibilityState = (): 'visible' | 'hidden' => {
};
const sendVisibility = (visible: boolean) => {
if (!isWebRuntime()) {
if (!isWebRuntime() && !isCapacitorApp()) {
return;
}
@@ -19,13 +20,62 @@ const sendVisibility = (visible: boolean) => {
return;
}
void apis.push.setVisibility({ visible });
// platform lets the server distinguish mobile (push recipients) from interactive surfaces
// (desktop/web/vscode) so it can suppress phone push only while an interactive client is visible.
void apis.push.setVisibility({ visible, platform: getClientPlatform() });
};
export const usePushVisibilityBeacon = (options?: { enabled?: boolean }) => {
const enabled = options?.enabled ?? true;
React.useEffect(() => {
if (!enabled || !isWebRuntime() || typeof document === 'undefined') {
if (!enabled || (!isWebRuntime() && !isCapacitorApp()) || typeof window === 'undefined') {
return;
}
// Native (Capacitor): drive visibility AUTHORITATIVELY from App.appStateChange. The
// web signals (document.visibilityState / hasFocus) are unreliable in a WKWebView —
// hasFocus() often returns false while the app is active — which made the app report
// "hidden" while foregrounded and leaked push notifications. The server's focus gate
// suppresses push whenever a UI client is visible, so getting this right is what
// guarantees "no push while the app is active".
if (isCapacitorApp()) {
let active = true;
let disposed = false;
let removeListener: (() => void) | null = null;
const reportActive = () => sendVisibility(active);
void import('@capacitor/app')
.then(async ({ App }) => {
if (disposed) return;
const state = await App.getState().catch(() => null);
if (state) active = state.isActive === true;
reportActive();
const handle = await App.addListener('appStateChange', ({ isActive }) => {
active = isActive === true;
reportActive();
});
if (disposed) {
void handle.remove();
return;
}
removeListener = () => void handle.remove();
})
.catch(() => undefined);
// Heartbeat so the server's visibility TTL never expires while the app is active.
const interval = window.setInterval(() => {
if (active) sendVisibility(true);
}, HEARTBEAT_MS);
return () => {
disposed = true;
window.clearInterval(interval);
removeListener?.();
};
}
// Web / desktop: document-based visibility.
if (typeof document === 'undefined') {
return;
}
@@ -45,7 +95,6 @@ export const usePushVisibilityBeacon = (options?: { enabled?: boolean }) => {
report();
// Heartbeat while visible so server TTL (30s) never expires.
const interval = window.setInterval(reportVisibleOnly, HEARTBEAT_MS);
document.addEventListener('visibilitychange', report);
+12 -1
View File
@@ -759,17 +759,28 @@ export interface PushSubscribePayload {
auth: string;
};
origin?: string;
/** Runtime surface ('ios' | 'android' | 'vscode' | 'desktop' | 'web') for presence-aware routing. */
platform?: string;
}
export interface PushUnsubscribePayload {
endpoint: string;
}
export interface ApnsTokenPayload {
token: string;
/** 'ios' (APNs) or 'android' (FCM) — lets the relay route the token to the right service. */
platform?: string;
}
export interface PushAPI {
getVapidPublicKey(): Promise<{ publicKey: string } | null>;
subscribe(payload: PushSubscribePayload): Promise<{ ok: true } | null>;
unsubscribe(payload: PushUnsubscribePayload): Promise<{ ok: true } | null>;
setVisibility(payload: { visible: boolean }): Promise<{ ok: true } | null>;
setVisibility(payload: { visible: boolean; platform?: string }): Promise<{ ok: true } | null>;
/** Register a native iOS APNs device token (Capacitor mobile app only). */
registerApnsToken(payload: ApnsTokenPayload): Promise<{ ok: true } | null>;
unregisterApnsToken(payload: ApnsTokenPayload): Promise<{ ok: true } | null>;
}
export type GitHubUserSummary = {
+39
View File
@@ -30,6 +30,44 @@ export const dict = {
'layout.mainTab.terminal': 'Terminal',
'layout.mainTab.context': 'Context',
'mobile.nav.aria': 'Mobile navigation',
'mobile.connect.welcome.title': 'Connect to OpenChamber',
'mobile.connect.welcome.description': 'Add a server URL or scan a pairing QR code to start using the mobile app.',
'mobile.connect.url.label': 'Server URL',
'mobile.connect.url.placeholder': 'http://192.168.1.74:2606',
'mobile.connect.token.label': 'Client token',
'mobile.connect.token.placeholder': 'Paste access token',
'mobile.connect.token.hint': 'Only needed if your server requires a token instead of a password.',
'mobile.connect.password.label': 'Password',
'mobile.connect.password.placeholder': 'OpenChamber password',
'mobile.connect.connectButton': 'Connect',
'mobile.connect.unlockButton': 'Unlock and connect',
'mobile.connect.cancelPassword': 'Use another server',
'mobile.connect.connecting': 'Connecting...',
'mobile.connect.scanQr': 'Scan QR code',
'mobile.connect.advanced': 'Advanced',
'mobile.connect.scan.permissionDenied': 'Camera access is off. Enable it in Settings to scan a QR code.',
'mobile.connect.scan.failed': 'Could not scan that QR code. Try again or enter the URL manually.',
'mobile.connect.scan.invalid': 'That QR code is not an OpenChamber connection code.',
'mobile.connect.scan.unsupported': 'QR scanning is only available in the installed mobile app.',
'mobile.connect.saved.title': 'Saved connections',
'mobile.connect.saved.empty': 'No saved connections yet.',
'mobile.connect.error.urlRequired': 'Enter a server URL.',
'mobile.connect.error.invalidUrl': 'That server URL is not valid.',
'mobile.connect.error.unreachable': 'Could not reach that OpenChamber server.',
'mobile.connect.error.authRequired': 'This server needs a password or client token.',
'mobile.connect.error.passwordFailed': 'Could not unlock that server. Check the password.',
'mobile.instances.addTitle': 'Add instance',
'mobile.instances.editTitle': 'Edit instance',
'mobile.instances.edit': 'Edit',
'mobile.instances.delete': 'Delete',
'mobile.instances.deleteAria': 'Delete {label}',
'mobile.instances.confirmDeleteAria': 'Confirm deleting {label}',
'mobile.instances.cancelDeleteAria': 'Keep {label}',
'mobile.instances.cancelEdit': 'Cancel',
'mobile.instances.label.label': 'Name',
'mobile.instances.label.placeholder': 'Optional display name',
'mobile.instances.saveNew': 'Save instance',
'mobile.instances.saveEdit': 'Save changes',
'mobile.nav.changes': 'Changes',
'mobile.nav.settings': 'Settings',
'mobile.surface.closeAria': 'Close',
@@ -42,6 +80,7 @@ export const dict = {
'mobile.menu.files': 'Files',
'mobile.menu.changes': 'Changes',
'mobile.menu.mcp': 'MCP',
'mobile.menu.instances': 'Instances',
'mobile.menu.update': 'Update',
'mobile.menu.settings': 'Settings',
'mobile.sessions.newChatCta': 'New chat in {project}',
+39
View File
@@ -31,6 +31,44 @@ export const dict: Record<I18nKey, string> = {
"layout.mainTab.terminal": "Terminal",
"layout.mainTab.context": "Contexto",
"mobile.nav.aria": "Navegación móvil",
"mobile.connect.welcome.title": "Conéctate a OpenChamber",
"mobile.connect.welcome.description": "Agrega una URL de servidor o escanea un código QR de emparejamiento para empezar a usar la app móvil.",
"mobile.connect.url.label": "URL del servidor",
"mobile.connect.url.placeholder": "http://192.168.1.74:2606",
"mobile.connect.token.label": "Token de cliente",
"mobile.connect.token.placeholder": "Pega el token de acceso",
"mobile.connect.token.hint": "Solo es necesario si tu servidor requiere un token en lugar de una contraseña.",
"mobile.connect.password.label": "Contraseña",
"mobile.connect.password.placeholder": "Contraseña de OpenChamber",
"mobile.connect.connectButton": "Conectar",
"mobile.connect.unlockButton": "Desbloquear y conectar",
"mobile.connect.cancelPassword": "Usar otro servidor",
"mobile.connect.connecting": "Conectando...",
"mobile.connect.scanQr": "Escanear código QR",
"mobile.connect.advanced": "Avanzado",
"mobile.connect.scan.permissionDenied": "El acceso a la cámara está desactivado. Actívalo en Ajustes para escanear un código QR.",
"mobile.connect.scan.failed": "No se pudo escanear ese código QR. Inténtalo de nuevo o introduce la URL manualmente.",
"mobile.connect.scan.invalid": "Ese código QR no es un código de conexión de OpenChamber.",
"mobile.connect.scan.unsupported": "El escaneo de QR solo está disponible en la app móvil instalada.",
"mobile.connect.saved.title": "Conexiones guardadas",
"mobile.connect.saved.empty": "Aún no hay conexiones guardadas.",
"mobile.connect.error.urlRequired": "Introduce una URL de servidor.",
"mobile.connect.error.invalidUrl": "Esa URL de servidor no es válida.",
"mobile.connect.error.unreachable": "No se pudo conectar con ese servidor de OpenChamber.",
"mobile.connect.error.authRequired": "Este servidor requiere una contraseña o un token de cliente.",
"mobile.connect.error.passwordFailed": "No se pudo desbloquear ese servidor. Revisa la contraseña.",
"mobile.instances.addTitle": "Agregar instancia",
"mobile.instances.editTitle": "Editar instancia",
"mobile.instances.edit": "Editar",
"mobile.instances.delete": "Eliminar",
"mobile.instances.deleteAria": "Eliminar {label}",
"mobile.instances.confirmDeleteAria": "Confirmar la eliminación de {label}",
"mobile.instances.cancelDeleteAria": "Conservar {label}",
"mobile.instances.cancelEdit": "Cancelar",
"mobile.instances.label.label": "Nombre",
"mobile.instances.label.placeholder": "Nombre para mostrar (opcional)",
"mobile.instances.saveNew": "Guardar instancia",
"mobile.instances.saveEdit": "Guardar cambios",
"mobile.nav.changes": "Cambios",
"mobile.nav.settings": "Ajustes",
"mobile.surface.closeAria": "Cerrar",
@@ -43,6 +81,7 @@ export const dict: Record<I18nKey, string> = {
"mobile.menu.files": "Archivos",
"mobile.menu.changes": "Cambios",
"mobile.menu.mcp": "MCP",
"mobile.menu.instances": "Instancias",
"mobile.menu.update": "Actualizar",
"mobile.menu.settings": "Ajustes",
"mobile.sessions.newChatCta": "Nuevo chat en {project}",
+39
View File
@@ -2448,6 +2448,44 @@ export const dict = {
'quota.window.premiumInteractions': 'Interactions premium',
'layout.mainTab.diagram': 'Diagramme',
'mobile.nav.aria': 'Navigation mobile',
'mobile.connect.welcome.title': 'Se connecter à OpenChamber',
'mobile.connect.welcome.description': 'Ajoutez une URL de serveur ou scannez un code QR d\'appairage pour commencer à utiliser l\'app mobile.',
'mobile.connect.url.label': 'URL du serveur',
'mobile.connect.url.placeholder': 'http://192.168.1.74:2606',
'mobile.connect.token.label': 'Jeton client',
'mobile.connect.token.placeholder': 'Collez le jeton d\'accès',
'mobile.connect.token.hint': 'Nécessaire uniquement si votre serveur exige un jeton au lieu d\'un mot de passe.',
'mobile.connect.password.label': 'Mot de passe',
'mobile.connect.password.placeholder': 'Mot de passe OpenChamber',
'mobile.connect.connectButton': 'Se connecter',
'mobile.connect.unlockButton': 'Déverrouiller et se connecter',
'mobile.connect.cancelPassword': 'Utiliser un autre serveur',
'mobile.connect.connecting': 'Connexion...',
'mobile.connect.scanQr': 'Scanner le code QR',
'mobile.connect.advanced': 'Avancé',
'mobile.connect.scan.permissionDenied': 'L\'accès à la caméra est désactivé. Activez-le dans les Réglages pour scanner un code QR.',
'mobile.connect.scan.failed': 'Impossible de scanner ce code QR. Réessayez ou saisissez l\'URL manuellement.',
'mobile.connect.scan.invalid': 'Ce code QR n\'est pas un code de connexion OpenChamber.',
'mobile.connect.scan.unsupported': 'Le scan QR est disponible uniquement dans l\'app mobile installée.',
'mobile.connect.saved.title': 'Connexions enregistrées',
'mobile.connect.saved.empty': 'Aucune connexion enregistrée pour le moment.',
'mobile.connect.error.urlRequired': 'Saisissez une URL de serveur.',
'mobile.connect.error.invalidUrl': 'Cette URL de serveur n\'est pas valide.',
'mobile.connect.error.unreachable': 'Impossible de joindre ce serveur OpenChamber.',
'mobile.connect.error.authRequired': 'Ce serveur nécessite un mot de passe ou un jeton client.',
'mobile.connect.error.passwordFailed': 'Impossible de déverrouiller ce serveur. Vérifiez le mot de passe.',
'mobile.instances.addTitle': 'Ajouter une instance',
'mobile.instances.editTitle': 'Modifier l\'instance',
'mobile.instances.edit': 'Modifier',
'mobile.instances.delete': 'Supprimer',
'mobile.instances.deleteAria': 'Supprimer {label}',
'mobile.instances.confirmDeleteAria': 'Confirmer la suppression de {label}',
'mobile.instances.cancelDeleteAria': 'Conserver {label}',
'mobile.instances.cancelEdit': 'Annuler',
'mobile.instances.label.label': 'Nom',
'mobile.instances.label.placeholder': 'Nom d\'affichage facultatif',
'mobile.instances.saveNew': 'Enregistrer l\'instance',
'mobile.instances.saveEdit': 'Enregistrer les modifications',
'mobile.nav.changes': 'Modifications',
'mobile.nav.settings': 'Paramètres',
'mobile.surface.closeAria': 'Fermer',
@@ -2460,6 +2498,7 @@ export const dict = {
'mobile.menu.files': 'Fichiers',
'mobile.menu.changes': 'Modifications',
'mobile.menu.mcp': 'MCP',
'mobile.menu.instances': 'Instances',
'mobile.menu.update': 'Mettre à jour',
'mobile.menu.settings': 'Paramètres',
'mobile.sessions.newChatCta': 'Nouveau chat dans {project}',
+39
View File
@@ -33,6 +33,45 @@ export const dict: Record<I18nKey, string> = {
'mobile.nav.aria': 'モバイルナビゲーション',
'mobile.nav.changes': '変更',
'mobile.nav.settings': '設定',
'mobile.menu.instances': 'インスタンス',
'mobile.connect.welcome.title': 'OpenChamber に接続',
'mobile.connect.welcome.description': 'サーバー URL を追加するか、ペアリング QR コードをスキャンしてモバイルアプリを使い始めましょう。',
'mobile.connect.url.label': 'サーバー URL',
'mobile.connect.url.placeholder': 'http://192.168.1.74:2606',
'mobile.connect.scanQr': 'QR コードをスキャン',
'mobile.connect.advanced': '詳細設定',
'mobile.connect.token.label': 'クライアントトークン',
'mobile.connect.token.placeholder': 'アクセストークンを貼り付け',
'mobile.connect.token.hint': 'サーバーがパスワードの代わりにトークンを必要とする場合のみ必要です。',
'mobile.connect.connectButton': '接続',
'mobile.connect.connecting': '接続中...',
'mobile.connect.password.label': 'パスワード',
'mobile.connect.password.placeholder': 'OpenChamber のパスワード',
'mobile.connect.unlockButton': 'ロックを解除して接続',
'mobile.connect.cancelPassword': '別のサーバーを使用',
'mobile.connect.saved.title': '保存された接続',
'mobile.connect.saved.empty': '保存された接続はまだありません。',
'mobile.connect.error.urlRequired': 'サーバー URL を入力してください。',
'mobile.connect.error.invalidUrl': 'そのサーバー URL は無効です。',
'mobile.connect.error.unreachable': 'その OpenChamber サーバーに接続できませんでした。',
'mobile.connect.error.authRequired': 'このサーバーにはパスワードまたはクライアントトークンが必要です。',
'mobile.connect.error.passwordFailed': 'サーバーのロックを解除できませんでした。パスワードを確認してください。',
'mobile.connect.scan.unsupported': 'QR スキャンはインストール済みのモバイルアプリでのみ利用できます。',
'mobile.connect.scan.permissionDenied': 'カメラへのアクセスがオフになっています。QR コードを読み取るには設定で有効にしてください。',
'mobile.connect.scan.invalid': 'その QR コードは OpenChamber の接続コードではありません。',
'mobile.connect.scan.failed': 'その QR コードを読み取れませんでした。もう一度試すか、URL を手動で入力してください。',
'mobile.instances.addTitle': 'インスタンスを追加',
'mobile.instances.editTitle': 'インスタンスを編集',
'mobile.instances.label.label': '名前',
'mobile.instances.label.placeholder': '表示名(任意)',
'mobile.instances.saveNew': 'インスタンスを保存',
'mobile.instances.saveEdit': '変更を保存',
'mobile.instances.cancelEdit': 'キャンセル',
'mobile.instances.edit': '編集',
'mobile.instances.delete': '削除',
'mobile.instances.deleteAria': '{label} を削除',
'mobile.instances.confirmDeleteAria': '{label} の削除を確定',
'mobile.instances.cancelDeleteAria': '{label} を残す',
'mobile.surface.closeAria': '閉じる',
'mobile.header.openMenuAria': 'メニューを開く',
'mobile.header.openMetadataAria': 'セッションメタデータを開く',
+39
View File
@@ -31,6 +31,44 @@ export const dict: Record<I18nKey, string> = {
'layout.mainTab.terminal': '터미널',
'layout.mainTab.context': '컨텍스트',
'mobile.nav.aria': '모바일 내비게이션',
'mobile.connect.welcome.title': 'OpenChamber에 연결',
'mobile.connect.welcome.description': '서버 URL을 추가하거나 페어링 QR 코드를 스캔하여 모바일 앱을 시작하세요.',
'mobile.connect.url.label': '서버 URL',
'mobile.connect.url.placeholder': 'http://192.168.1.74:2606',
'mobile.connect.token.label': '클라이언트 토큰',
'mobile.connect.token.placeholder': '액세스 토큰 붙여넣기',
'mobile.connect.token.hint': '서버가 비밀번호 대신 토큰을 요구하는 경우에만 필요합니다.',
'mobile.connect.password.label': '비밀번호',
'mobile.connect.password.placeholder': 'OpenChamber 비밀번호',
'mobile.connect.connectButton': '연결',
'mobile.connect.unlockButton': '잠금 해제 후 연결',
'mobile.connect.cancelPassword': '다른 서버 사용',
'mobile.connect.connecting': '연결 중...',
'mobile.connect.scanQr': 'QR 코드 스캔',
'mobile.connect.advanced': '고급',
'mobile.connect.scan.permissionDenied': '카메라 접근이 꺼져 있습니다. QR 코드를 스캔하려면 설정에서 사용 설정하세요.',
'mobile.connect.scan.failed': 'QR 코드를 스캔하지 못했습니다. 다시 시도하거나 URL을 직접 입력하세요.',
'mobile.connect.scan.invalid': '이 QR 코드는 OpenChamber 연결 코드가 아닙니다.',
'mobile.connect.scan.unsupported': 'QR 스캔은 설치된 모바일 앱에서만 사용할 수 있습니다.',
'mobile.connect.saved.title': '저장된 연결',
'mobile.connect.saved.empty': '아직 저장된 연결이 없습니다.',
'mobile.connect.error.urlRequired': '서버 URL을 입력하세요.',
'mobile.connect.error.invalidUrl': '유효하지 않은 서버 URL입니다.',
'mobile.connect.error.unreachable': '해당 OpenChamber 서버에 연결할 수 없습니다.',
'mobile.connect.error.authRequired': '이 서버에는 비밀번호 또는 클라이언트 토큰이 필요합니다.',
'mobile.connect.error.passwordFailed': '서버 잠금을 해제할 수 없습니다. 비밀번호를 확인하세요.',
'mobile.instances.addTitle': '인스턴스 추가',
'mobile.instances.editTitle': '인스턴스 편집',
'mobile.instances.edit': '편집',
'mobile.instances.delete': '삭제',
'mobile.instances.deleteAria': '{label} 삭제',
'mobile.instances.confirmDeleteAria': '{label} 삭제 확인',
'mobile.instances.cancelDeleteAria': '{label} 유지',
'mobile.instances.cancelEdit': '취소',
'mobile.instances.label.label': '이름',
'mobile.instances.label.placeholder': '표시 이름 (선택 사항)',
'mobile.instances.saveNew': '인스턴스 저장',
'mobile.instances.saveEdit': '변경 사항 저장',
'mobile.nav.changes': '변경사항',
'mobile.nav.settings': '설정',
'mobile.surface.closeAria': '닫기',
@@ -43,6 +81,7 @@ export const dict: Record<I18nKey, string> = {
'mobile.menu.files': '파일',
'mobile.menu.changes': '변경사항',
'mobile.menu.mcp': 'MCP',
'mobile.menu.instances': '인스턴스',
'mobile.menu.update': '업데이트',
'mobile.menu.settings': '설정',
'mobile.sessions.newChatCta': '{project}에서 새 채팅',
+39
View File
@@ -32,6 +32,44 @@ export const dict: Record<I18nKey, string> = {
'layout.mainTab.terminal': 'Terminal',
'layout.mainTab.context': 'Kontekst',
'mobile.nav.aria': 'Nawigacja mobilna',
'mobile.connect.welcome.title': 'Połącz z OpenChamber',
'mobile.connect.welcome.description': 'Dodaj adres URL serwera lub zeskanuj kod QR parowania, aby zacząć korzystać z aplikacji mobilnej.',
'mobile.connect.url.label': 'Adres URL serwera',
'mobile.connect.url.placeholder': 'http://192.168.1.74:2606',
'mobile.connect.token.label': 'Token klienta',
'mobile.connect.token.placeholder': 'Wklej token dostępu',
'mobile.connect.token.hint': 'Potrzebny tylko, gdy serwer wymaga tokenu zamiast hasła.',
'mobile.connect.password.label': 'Hasło',
'mobile.connect.password.placeholder': 'Hasło OpenChamber',
'mobile.connect.connectButton': 'Połącz',
'mobile.connect.unlockButton': 'Odblokuj i połącz',
'mobile.connect.cancelPassword': 'Użyj innego serwera',
'mobile.connect.connecting': 'Łączenie...',
'mobile.connect.scanQr': 'Skanuj kod QR',
'mobile.connect.advanced': 'Zaawansowane',
'mobile.connect.scan.permissionDenied': 'Dostęp do aparatu jest wyłączony. Włącz go w Ustawieniach, aby zeskanować kod QR.',
'mobile.connect.scan.failed': 'Nie udało się zeskanować tego kodu QR. Spróbuj ponownie lub wpisz adres URL ręcznie.',
'mobile.connect.scan.invalid': 'Ten kod QR nie jest kodem połączenia OpenChamber.',
'mobile.connect.scan.unsupported': 'Skanowanie QR jest dostępne tylko w zainstalowanej aplikacji mobilnej.',
'mobile.connect.saved.title': 'Zapisane połączenia',
'mobile.connect.saved.empty': 'Brak zapisanych połączeń.',
'mobile.connect.error.urlRequired': 'Podaj adres URL serwera.',
'mobile.connect.error.invalidUrl': 'Ten adres URL serwera jest nieprawidłowy.',
'mobile.connect.error.unreachable': 'Nie udało się połączyć z tym serwerem OpenChamber.',
'mobile.connect.error.authRequired': 'Ten serwer wymaga hasła lub tokenu klienta.',
'mobile.connect.error.passwordFailed': 'Nie udało się odblokować tego serwera. Sprawdź hasło.',
'mobile.instances.addTitle': 'Dodaj instancję',
'mobile.instances.editTitle': 'Edytuj instancję',
'mobile.instances.edit': 'Edytuj',
'mobile.instances.delete': 'Usuń',
'mobile.instances.deleteAria': 'Usuń {label}',
'mobile.instances.confirmDeleteAria': 'Potwierdź usunięcie {label}',
'mobile.instances.cancelDeleteAria': 'Zachowaj {label}',
'mobile.instances.cancelEdit': 'Anuluj',
'mobile.instances.label.label': 'Nazwa',
'mobile.instances.label.placeholder': 'Opcjonalna nazwa wyświetlana',
'mobile.instances.saveNew': 'Zapisz instancję',
'mobile.instances.saveEdit': 'Zapisz zmiany',
'mobile.nav.changes': 'Zmiany',
'mobile.nav.settings': 'Ustawienia',
'mobile.surface.closeAria': 'Zamknij',
@@ -44,6 +82,7 @@ export const dict: Record<I18nKey, string> = {
'mobile.menu.files': 'Pliki',
'mobile.menu.changes': 'Zmiany',
'mobile.menu.mcp': 'MCP',
'mobile.menu.instances': 'Instancje',
'mobile.menu.update': 'Aktualizuj',
'mobile.menu.settings': 'Ustawienia',
'mobile.sessions.newChatCta': 'Nowy czat w {project}',
@@ -31,6 +31,44 @@ export const dict: Record<I18nKey, string> = {
"layout.mainTab.terminal": "Terminal",
"layout.mainTab.context": "Contexto",
"mobile.nav.aria": "Navegação móvel",
"mobile.connect.welcome.title": "Conectar ao OpenChamber",
"mobile.connect.welcome.description": "Adicione a URL de um servidor ou leia um código QR de pareamento para começar a usar o app móvel.",
"mobile.connect.url.label": "URL do servidor",
"mobile.connect.url.placeholder": "http://192.168.1.74:2606",
"mobile.connect.token.label": "Token do cliente",
"mobile.connect.token.placeholder": "Cole o token de acesso",
"mobile.connect.token.hint": "Só é necessário se o seu servidor exigir um token em vez de senha.",
"mobile.connect.password.label": "Senha",
"mobile.connect.password.placeholder": "Senha do OpenChamber",
"mobile.connect.connectButton": "Conectar",
"mobile.connect.unlockButton": "Desbloquear e conectar",
"mobile.connect.cancelPassword": "Usar outro servidor",
"mobile.connect.connecting": "Conectando...",
"mobile.connect.scanQr": "Ler código QR",
"mobile.connect.advanced": "Avançado",
"mobile.connect.scan.permissionDenied": "O acesso à câmera está desativado. Ative-o nos Ajustes para ler um código QR.",
"mobile.connect.scan.failed": "Não foi possível ler esse código QR. Tente novamente ou digite a URL manualmente.",
"mobile.connect.scan.invalid": "Esse código QR não é um código de conexão do OpenChamber.",
"mobile.connect.scan.unsupported": "A leitura de QR só está disponível no app móvel instalado.",
"mobile.connect.saved.title": "Conexões salvas",
"mobile.connect.saved.empty": "Nenhuma conexão salva ainda.",
"mobile.connect.error.urlRequired": "Informe a URL de um servidor.",
"mobile.connect.error.invalidUrl": "Essa URL de servidor não é válida.",
"mobile.connect.error.unreachable": "Não foi possível acessar esse servidor OpenChamber.",
"mobile.connect.error.authRequired": "Este servidor requer uma senha ou token do cliente.",
"mobile.connect.error.passwordFailed": "Não foi possível desbloquear esse servidor. Verifique a senha.",
"mobile.instances.addTitle": "Adicionar instância",
"mobile.instances.editTitle": "Editar instância",
"mobile.instances.edit": "Editar",
"mobile.instances.delete": "Excluir",
"mobile.instances.deleteAria": "Excluir {label}",
"mobile.instances.confirmDeleteAria": "Confirmar exclusão de {label}",
"mobile.instances.cancelDeleteAria": "Manter {label}",
"mobile.instances.cancelEdit": "Cancelar",
"mobile.instances.label.label": "Nome",
"mobile.instances.label.placeholder": "Nome de exibição opcional",
"mobile.instances.saveNew": "Salvar instância",
"mobile.instances.saveEdit": "Salvar alterações",
"mobile.nav.changes": "Alterações",
"mobile.nav.settings": "Configurações",
"mobile.surface.closeAria": "Fechar",
@@ -43,6 +81,7 @@ export const dict: Record<I18nKey, string> = {
"mobile.menu.files": "Arquivos",
"mobile.menu.changes": "Alterações",
"mobile.menu.mcp": "MCP",
"mobile.menu.instances": "Instâncias",
"mobile.menu.update": "Atualizar",
"mobile.menu.settings": "Configurações",
"mobile.sessions.newChatCta": "Novo chat em {project}",
+39
View File
@@ -31,6 +31,44 @@ export const dict: Record<I18nKey, string> = {
"layout.mainTab.terminal": "Термінал",
"layout.mainTab.context": "Контекст",
"mobile.nav.aria": "Мобільна навігація",
"mobile.connect.welcome.title": "Підключись до OpenChamber",
"mobile.connect.welcome.description": "Додай адресу сервера або відскануй QR-код pairing, щоб почати користуватись мобільною апкою.",
"mobile.connect.url.label": "Адреса сервера",
"mobile.connect.url.placeholder": "http://192.168.1.74:2606",
"mobile.connect.token.label": "Токен клієнта",
"mobile.connect.token.placeholder": "Встав токен доступу",
"mobile.connect.token.hint": "Потрібен, лише якщо сервер вимагає токен замість пароля.",
"mobile.connect.password.label": "Пароль",
"mobile.connect.password.placeholder": "Пароль OpenChamber",
"mobile.connect.connectButton": "Підключити",
"mobile.connect.unlockButton": "Розблокувати і підключити",
"mobile.connect.cancelPassword": "Інший сервер",
"mobile.connect.connecting": "Підключення...",
"mobile.connect.scanQr": "Сканувати QR-код",
"mobile.connect.advanced": "Додатково",
"mobile.connect.scan.permissionDenied": "Доступ до камери вимкнено. Увімкни його в Налаштуваннях, щоб сканувати QR-код.",
"mobile.connect.scan.failed": "Не вдалося відсканувати QR-код. Спробуй ще раз або введи адресу вручну.",
"mobile.connect.scan.invalid": "Це не QR-код підключення OpenChamber.",
"mobile.connect.scan.unsupported": "Сканування QR доступне лише у встановленій мобільній апці.",
"mobile.connect.saved.title": "Збережені підключення",
"mobile.connect.saved.empty": "Збережених підключень ще немає.",
"mobile.connect.error.urlRequired": "Введи адресу сервера.",
"mobile.connect.error.invalidUrl": "Ця адреса сервера некоректна.",
"mobile.connect.error.unreachable": "Не вдалося достукатись до цього OpenChamber сервера.",
"mobile.connect.error.authRequired": "Цьому серверу потрібен пароль або client token.",
"mobile.connect.error.passwordFailed": "Не вдалося розблокувати сервер. Перевір пароль.",
"mobile.instances.addTitle": "Додати інстанс",
"mobile.instances.editTitle": "Редагувати інстанс",
"mobile.instances.edit": "Редагувати",
"mobile.instances.delete": "Видалити",
"mobile.instances.deleteAria": "Видалити {label}",
"mobile.instances.confirmDeleteAria": "Підтвердити видалення {label}",
"mobile.instances.cancelDeleteAria": "Залишити {label}",
"mobile.instances.cancelEdit": "Скасувати",
"mobile.instances.label.label": "Назва",
"mobile.instances.label.placeholder": "Необовʼязкова назва",
"mobile.instances.saveNew": "Зберегти інстанс",
"mobile.instances.saveEdit": "Зберегти зміни",
"mobile.nav.changes": "Зміни",
"mobile.nav.settings": "Налаштування",
"mobile.surface.closeAria": "Закрити",
@@ -43,6 +81,7 @@ export const dict: Record<I18nKey, string> = {
"mobile.menu.files": "Файли",
"mobile.menu.changes": "Зміни",
"mobile.menu.mcp": "MCP",
"mobile.menu.instances": "Інстанси",
"mobile.menu.update": "Оновити",
"mobile.menu.settings": "Налаштування",
"mobile.sessions.newChatCta": "Новий чат у {project}",
@@ -31,6 +31,44 @@ export const dict: Record<I18nKey, string> = {
'layout.mainTab.terminal': '终端',
'layout.mainTab.context': '上下文',
'mobile.nav.aria': '移动导航',
'mobile.connect.welcome.title': '连接到 OpenChamber',
'mobile.connect.welcome.description': '添加服务器 URL 或扫描配对二维码即可开始使用移动应用。',
'mobile.connect.url.label': '服务器 URL',
'mobile.connect.url.placeholder': 'http://192.168.1.74:2606',
'mobile.connect.token.label': '客户端令牌',
'mobile.connect.token.placeholder': '粘贴访问令牌',
'mobile.connect.token.hint': '仅当服务器需要令牌而非密码时才需要填写。',
'mobile.connect.password.label': '密码',
'mobile.connect.password.placeholder': 'OpenChamber 密码',
'mobile.connect.connectButton': '连接',
'mobile.connect.unlockButton': '解锁并连接',
'mobile.connect.cancelPassword': '使用其他服务器',
'mobile.connect.connecting': '连接中...',
'mobile.connect.scanQr': '扫描二维码',
'mobile.connect.advanced': '高级',
'mobile.connect.scan.permissionDenied': '相机访问已关闭。请在“设置”中开启以扫描二维码。',
'mobile.connect.scan.failed': '无法扫描该二维码。请重试或手动输入网址。',
'mobile.connect.scan.invalid': '该二维码不是 OpenChamber 连接码。',
'mobile.connect.scan.unsupported': '二维码扫描仅在已安装的移动应用中可用。',
'mobile.connect.saved.title': '已保存的连接',
'mobile.connect.saved.empty': '暂无已保存的连接。',
'mobile.connect.error.urlRequired': '请输入服务器 URL。',
'mobile.connect.error.invalidUrl': '该服务器 URL 无效。',
'mobile.connect.error.unreachable': '无法连接到该 OpenChamber 服务器。',
'mobile.connect.error.authRequired': '该服务器需要密码或客户端令牌。',
'mobile.connect.error.passwordFailed': '无法解锁该服务器。请检查密码。',
'mobile.instances.addTitle': '添加实例',
'mobile.instances.editTitle': '编辑实例',
'mobile.instances.edit': '编辑',
'mobile.instances.delete': '删除',
'mobile.instances.deleteAria': '删除 {label}',
'mobile.instances.confirmDeleteAria': '确认删除 {label}',
'mobile.instances.cancelDeleteAria': '保留 {label}',
'mobile.instances.cancelEdit': '取消',
'mobile.instances.label.label': '名称',
'mobile.instances.label.placeholder': '可选显示名称',
'mobile.instances.saveNew': '保存实例',
'mobile.instances.saveEdit': '保存更改',
'mobile.nav.changes': '更改',
'mobile.nav.settings': '设置',
'mobile.surface.closeAria': '关闭',
@@ -43,6 +81,7 @@ export const dict: Record<I18nKey, string> = {
'mobile.menu.files': '文件',
'mobile.menu.changes': '更改',
'mobile.menu.mcp': 'MCP',
'mobile.menu.instances': '实例',
'mobile.menu.update': '更新',
'mobile.menu.settings': '设置',
'mobile.sessions.newChatCta': '在 {project} 中新建会话',
@@ -31,6 +31,44 @@ export const dict: Record<I18nKey, string> = {
'layout.mainTab.terminal': '終端機',
'layout.mainTab.context': '上下文',
'mobile.nav.aria': '行動導覽',
'mobile.connect.welcome.title': '連線至 OpenChamber',
'mobile.connect.welcome.description': '新增伺服器網址或掃描配對 QR 碼,即可開始使用行動應用程式。',
'mobile.connect.url.label': '伺服器網址',
'mobile.connect.url.placeholder': 'http://192.168.1.74:2606',
'mobile.connect.token.label': '用戶端權杖',
'mobile.connect.token.placeholder': '貼上存取權杖',
'mobile.connect.token.hint': '僅當伺服器需要權杖而非密碼時才需要填寫。',
'mobile.connect.password.label': '密碼',
'mobile.connect.password.placeholder': 'OpenChamber 密碼',
'mobile.connect.connectButton': '連線',
'mobile.connect.unlockButton': '解鎖並連線',
'mobile.connect.cancelPassword': '使用其他伺服器',
'mobile.connect.connecting': '連線中...',
'mobile.connect.scanQr': '掃描 QR code',
'mobile.connect.advanced': '進階',
'mobile.connect.scan.permissionDenied': '相機存取已關閉。請在「設定」中開啟以掃描 QR code。',
'mobile.connect.scan.failed': '無法掃描該 QR code。請重試或手動輸入網址。',
'mobile.connect.scan.invalid': '此 QR code 不是 OpenChamber 連線代碼。',
'mobile.connect.scan.unsupported': 'QR code 掃描僅在已安裝的行動應用程式中可用。',
'mobile.connect.saved.title': '已儲存的連線',
'mobile.connect.saved.empty': '尚未儲存任何連線。',
'mobile.connect.error.urlRequired': '請輸入伺服器網址。',
'mobile.connect.error.invalidUrl': '該伺服器網址無效。',
'mobile.connect.error.unreachable': '無法連線至該 OpenChamber 伺服器。',
'mobile.connect.error.authRequired': '此伺服器需要密碼或用戶端權杖。',
'mobile.connect.error.passwordFailed': '無法解鎖該伺服器。請檢查密碼。',
'mobile.instances.addTitle': '新增執行個體',
'mobile.instances.editTitle': '編輯執行個體',
'mobile.instances.edit': '編輯',
'mobile.instances.delete': '刪除',
'mobile.instances.deleteAria': '刪除 {label}',
'mobile.instances.confirmDeleteAria': '確認刪除 {label}',
'mobile.instances.cancelDeleteAria': '保留 {label}',
'mobile.instances.cancelEdit': '取消',
'mobile.instances.label.label': '名稱',
'mobile.instances.label.placeholder': '選填顯示名稱',
'mobile.instances.saveNew': '儲存執行個體',
'mobile.instances.saveEdit': '儲存變更',
'mobile.nav.changes': '變更',
'mobile.nav.settings': '設定',
'mobile.surface.closeAria': '關閉',
@@ -43,6 +81,7 @@ export const dict: Record<I18nKey, string> = {
'mobile.menu.files': '檔案',
'mobile.menu.changes': '變更',
'mobile.menu.mcp': 'MCP',
'mobile.menu.instances': '執行個體',
'mobile.menu.update': '更新',
'mobile.menu.settings': '設定',
'mobile.sessions.newChatCta': '在 {project} 中新增聊天',
+23 -1
View File
@@ -30,6 +30,7 @@ import {
// Can be overridden with VITE_OPENCODE_URL for absolute URLs in special deployments
const DEFAULT_BASE_URL = import.meta.env.VITE_OPENCODE_URL || "/api";
const CONFIG_CACHE_TTL_MS = 10_000;
const OPENCODE_HEALTH_TIMEOUT_MS = 4_000;
/**
* Render an SDK error payload into a short string for Error messages.
@@ -157,6 +158,26 @@ const resolveRuntimeBaseUrl = (): string | null => {
}
};
type AbortSignalConstructorWithTimeout = typeof AbortSignal & {
timeout?: (milliseconds: number) => AbortSignal;
};
const createTimeoutSignal = (timeoutMs: number): { signal: AbortSignal; cleanup: () => void } => {
const abortSignal = typeof AbortSignal !== 'undefined'
? AbortSignal as AbortSignalConstructorWithTimeout
: undefined;
if (typeof abortSignal?.timeout === 'function') {
return { signal: abortSignal.timeout(timeoutMs), cleanup: () => undefined };
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
return {
signal: controller.signal,
cleanup: () => clearTimeout(timeoutId),
};
};
const createRuntimeOpencodeClient = (config: { baseUrl: string; directory?: string }): OpencodeClient => {
return createOpencodeClient({
...config,
@@ -1543,7 +1564,8 @@ class OpencodeService {
? '/api/opencode/health'
: `${normalizedBase}/opencode/health`;
markStartupTrace('opencodeClient.checkHealth:url', { baseUrl: this.baseUrl, healthUrl });
const response = await runtimeFetch(healthUrl);
const timeout = createTimeoutSignal(OPENCODE_HEALTH_TIMEOUT_MS);
const response = await runtimeFetch(healthUrl, { signal: timeout.signal }).finally(timeout.cleanup);
markStartupTrace('opencodeClient.checkHealth:response', { status: response.status });
if (!response.ok) {
return false;
+26
View File
@@ -0,0 +1,26 @@
import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
/** True when running inside the native Capacitor shell (iOS/Android app), not the web/PWA. */
export const isCapacitorApp = (): boolean => {
if (typeof window === 'undefined') return false;
const capacitor = (window as typeof window & { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor;
return capacitor?.isNativePlatform?.() === true || window.location.protocol === 'capacitor:';
};
export type ClientPlatform = 'ios' | 'android' | 'vscode' | 'desktop' | 'web';
/**
* The runtime surface this client is. Used by the push presence model: only 'ios'/'android'
* count as mobile (push recipients); everything else is an interactive surface that suppresses
* mobile push while visible.
*/
export const getClientPlatform = (): ClientPlatform => {
if (typeof window !== 'undefined') {
const capacitor = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor;
const native = capacitor?.getPlatform?.();
if (native === 'ios' || native === 'android') return native;
}
if (isVSCodeRuntime()) return 'vscode';
if (isDesktopShell()) return 'desktop';
return 'web';
};
+85
View File
@@ -508,3 +508,88 @@
}
}
}
/* Small app-wide bottom safe area for the native shell. The phone's rounded hardware
corners clip controls flush against the bottom edge, and the PWA's own safe-area
padding is gated behind display-mode: standalone which the Capacitor WebView does
not match so nothing reserves bottom room in the native app. Expose it as a token
so any native surface can consume it; the chat shell does so below. */
:root.oc-capacitor-app {
--oc-app-bottom-safe: max(16px, calc(env(safe-area-inset-bottom, 0px) * 0.5));
}
/* Paint the document canvas with the theme background in the native app the same
thing .desktop-runtime does for body/#root, which the Capacitor shell never got.
The status bar is overlaid (transparent), and in dark mode `color-scheme: dark`
makes the bare UA canvas dark, so any sliver not covered by content (notably the
area behind the status bar) bled through as a dark band at the top. It only showed
in dark mode, which is why it tracked the system theme. */
:root.oc-capacitor-app,
:root.oc-capacitor-app body,
:root.oc-capacitor-app #root {
background: var(--background) !important;
background-color: var(--background) !important;
}
/* Native (Capacitor) keyboard handling.
The Keyboard plugin runs in `resize: 'none'` mode so the WebView keeps its full
height; instead we shrink the app shell by the keyboard frame height, exposed as
--oc-keyboard-inset and set once from `keyboardWillShow` (see useNativeMobileChrome).
Scoped to .oc-capacitor-app so the browser PWA keeps its dvh / interactive-widget
behaviour untouched.
`keyboardWillShow` fires at the start of the iOS keyboard animation, so the inset
is set once and the transition carries the rise. The duration/curve are tuned to
mimic the native iOS keyboard (0.25s, cubic-bezier(0.38, 0.7, 0.125, 1)) so our
layout and the keyboard move together. (visualViewport live-tracking would be exact
but doesn't report under WKWebView's `resize: 'none'`, so this is the best signal.) */
:root.oc-capacitor-app .oc-mobile-app-shell {
height: calc(100dvh - var(--oc-keyboard-inset, 0px));
/* Reserve the bottom safe area only while the keyboard is down when it's up the
inset cancels it out (the home indicator is hidden and the composer should sit
flush above the keyboard). The shell keeps its own bg behind this padding. */
padding-bottom: max(0px, calc(var(--oc-app-bottom-safe, 0px) - var(--oc-keyboard-inset, 0px)));
transition: height 0.25s cubic-bezier(0.38, 0.7, 0.125, 1),
padding-bottom 0.25s cubic-bezier(0.38, 0.7, 0.125, 1);
}
/* Android resizes the window for the keyboard natively (no manual --oc-keyboard-inset),
so 100dvh changes instantly. Animating height against that instant resize makes the
header/content bounce on keyboard open disable the transition on Android. */
:root.oc-capacitor-app.oc-platform-android .oc-mobile-app-shell {
transition: none;
}
/* Portal surfaces (bottom sheets, overlay panels) render at <body> level, outside
the app shell, so they don't inherit the shell's keyboard inset. They're full-
height `fixed inset-0` scrims with a bottom-anchored (`mt-auto`) sheet, so raising
their bottom edge by the keyboard height shrinks scrim + sheet together and lifts
any input above the keyboard instead of hiding it underneath. The opacity term
preserves the scrim's enter fade (Tailwind's `transition-opacity` would otherwise
be overridden by this rule's `transition` shorthand). */
:root.oc-capacitor-app .oc-keyboard-inset-surface {
bottom: var(--oc-keyboard-inset, 0px);
transition: bottom 0.25s cubic-bezier(0.38, 0.7, 0.125, 1), opacity 0.2s ease-out;
}
/* Full-screen scroll views (e.g. the connect/login screen) live outside the app
shell, so shrink them by the keyboard height the same way the shell does. Capping
the height (instead of min-height: 100dvh) is what makes overflow-y-auto actually
scroll, so a field near the bottom lifts above the keyboard rather than staying
hidden behind it. min-height: 0 neutralises the Tailwind min-h-dvh baseline. */
:root.oc-capacitor-app .oc-keyboard-fill-screen {
height: calc(100dvh - var(--oc-keyboard-inset, 0px));
min-height: 0;
transition: height 0.25s cubic-bezier(0.38, 0.7, 0.125, 1);
}
/* The composer keeps its 1rem bottom padding while the keyboard is down (breathing
room above the home indicator), but that gap looks artificial sitting above the
keyboard's accessory bar so tighten it while the keyboard is open. Animated to
match the keyboard motion. */
:root.oc-capacitor-app .oc-mobile-composer {
transition: padding-bottom 0.25s cubic-bezier(0.38, 0.7, 0.125, 1);
}
:root.oc-capacitor-app.oc-keyboard-open .oc-mobile-composer {
padding-bottom: 6px;
}
+8 -2
View File
@@ -8,6 +8,7 @@ import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
import { createEventPipeline } from "./event-pipeline"
import { isVSCodeRuntime } from "@/lib/desktop"
import { isMobileSurfaceRuntime } from "@/lib/runtimeSurface"
import { isCapacitorApp } from "@/lib/platform"
import { reduceGlobalEvent, applyGlobalProject, applyDirectoryEvent } from "./event-reducer"
import { useGlobalSyncStore } from "./global-sync-store"
import { ChildStoreManager, type DirectoryStore } from "./child-store"
@@ -1584,7 +1585,12 @@ export function SyncProvider(props: {
directory: string
children: React.ReactNode
}) {
const messageStreamTransport = useConfigStore((state) => state.settingsMessageStreamTransport)
const storedMessageStreamTransport = useConfigStore((state) => state.settingsMessageStreamTransport)
// Capacitor apps are locked to SSE: native WebSocket streaming is unreliable there (on
// Android events only arrive once the run finishes), while SSE streams correctly. The Chat
// settings UI disables the other options on mobile, but force it here too so the effective
// transport can't drift. Remove this override (and the UI lock) to re-enable WS on mobile.
const messageStreamTransport: 'auto' | 'ws' | 'sse' = isCapacitorApp() ? 'sse' : storedMessageStreamTransport
const childStoresRef = useRef<ChildStoreManager | null>(null)
if (!childStoresRef.current) childStoresRef.current = new ChildStoreManager()
const childStores = childStoresRef.current
@@ -2053,7 +2059,7 @@ export function useDirectorySync<T>(selector: (state: State) => T, directory?: s
return useStore(store, selector)
}
/** Get session messages for a specific session */
/** Get session messages for a specific session */
export function useSessionMessages(sessionID: string, directory?: string) {
const store = useDirectoryStore(directory)
const getSnapshot = useCallback(() => {