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
+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';
};