= ({
const content = (
{
};
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);
diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts
index d3539c29..f2e3f3c4 100644
--- a/packages/ui/src/lib/api/types.ts
+++ b/packages/ui/src/lib/api/types.ts
@@ -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 = {
diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts
index f5dcb6d5..18375b2b 100644
--- a/packages/ui/src/lib/i18n/messages/en.ts
+++ b/packages/ui/src/lib/i18n/messages/en.ts
@@ -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}',
diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts
index 7d4bba4f..d1a93005 100644
--- a/packages/ui/src/lib/i18n/messages/es.ts
+++ b/packages/ui/src/lib/i18n/messages/es.ts
@@ -31,6 +31,44 @@ export const dict: Record = {
"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 = {
"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}",
diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts
index 335d431f..c4eb7f12 100644
--- a/packages/ui/src/lib/i18n/messages/fr.ts
+++ b/packages/ui/src/lib/i18n/messages/fr.ts
@@ -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}',
diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts
index 97e58f37..306ae0fe 100644
--- a/packages/ui/src/lib/i18n/messages/ja.ts
+++ b/packages/ui/src/lib/i18n/messages/ja.ts
@@ -33,6 +33,45 @@ export const dict: Record = {
'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': 'セッションメタデータを開く',
diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts
index 0e590934..d329885d 100644
--- a/packages/ui/src/lib/i18n/messages/ko.ts
+++ b/packages/ui/src/lib/i18n/messages/ko.ts
@@ -31,6 +31,44 @@ export const dict: Record = {
'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 = {
'mobile.menu.files': '파일',
'mobile.menu.changes': '변경사항',
'mobile.menu.mcp': 'MCP',
+ 'mobile.menu.instances': '인스턴스',
'mobile.menu.update': '업데이트',
'mobile.menu.settings': '설정',
'mobile.sessions.newChatCta': '{project}에서 새 채팅',
diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts
index f4c2f728..30cd07a1 100644
--- a/packages/ui/src/lib/i18n/messages/pl.ts
+++ b/packages/ui/src/lib/i18n/messages/pl.ts
@@ -32,6 +32,44 @@ export const dict: Record = {
'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 = {
'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}',
diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts
index 5063bb75..bcbf1aed 100644
--- a/packages/ui/src/lib/i18n/messages/pt-BR.ts
+++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts
@@ -31,6 +31,44 @@ export const dict: Record = {
"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 = {
"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}",
diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts
index 5fca9424..38414a0a 100644
--- a/packages/ui/src/lib/i18n/messages/uk.ts
+++ b/packages/ui/src/lib/i18n/messages/uk.ts
@@ -31,6 +31,44 @@ export const dict: Record = {
"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 = {
"mobile.menu.files": "Файли",
"mobile.menu.changes": "Зміни",
"mobile.menu.mcp": "MCP",
+ "mobile.menu.instances": "Інстанси",
"mobile.menu.update": "Оновити",
"mobile.menu.settings": "Налаштування",
"mobile.sessions.newChatCta": "Новий чат у {project}",
diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts
index 82b4dced..09ad5833 100644
--- a/packages/ui/src/lib/i18n/messages/zh-CN.ts
+++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts
@@ -31,6 +31,44 @@ export const dict: Record = {
'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 = {
'mobile.menu.files': '文件',
'mobile.menu.changes': '更改',
'mobile.menu.mcp': 'MCP',
+ 'mobile.menu.instances': '实例',
'mobile.menu.update': '更新',
'mobile.menu.settings': '设置',
'mobile.sessions.newChatCta': '在 {project} 中新建会话',
diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts
index 5b1d666c..34cdf134 100644
--- a/packages/ui/src/lib/i18n/messages/zh-TW.ts
+++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts
@@ -31,6 +31,44 @@ export const dict: Record = {
'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 = {
'mobile.menu.files': '檔案',
'mobile.menu.changes': '變更',
'mobile.menu.mcp': 'MCP',
+ 'mobile.menu.instances': '執行個體',
'mobile.menu.update': '更新',
'mobile.menu.settings': '設定',
'mobile.sessions.newChatCta': '在 {project} 中新增聊天',
diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts
index 216ef690..040c85d1 100644
--- a/packages/ui/src/lib/opencode/client.ts
+++ b/packages/ui/src/lib/opencode/client.ts
@@ -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;
diff --git a/packages/ui/src/lib/platform.ts b/packages/ui/src/lib/platform.ts
new file mode 100644
index 00000000..73615a4d
--- /dev/null
+++ b/packages/ui/src/lib/platform.ts
@@ -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';
+};
diff --git a/packages/ui/src/styles/mobile.css b/packages/ui/src/styles/mobile.css
index c00ea86a..11724eff 100644
--- a/packages/ui/src/styles/mobile.css
+++ b/packages/ui/src/styles/mobile.css
@@ -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 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;
+}
diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx
index 9387eaf7..da34f3a4 100644
--- a/packages/ui/src/sync/sync-context.tsx
+++ b/packages/ui/src/sync/sync-context.tsx
@@ -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(null)
if (!childStoresRef.current) childStoresRef.current = new ChildStoreManager()
const childStores = childStoresRef.current
@@ -2053,7 +2059,7 @@ export function useDirectorySync(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(() => {
diff --git a/packages/web/bin/lib/commands-connect-url.js b/packages/web/bin/lib/commands-connect-url.js
index b9ae0f50..c2b94705 100644
--- a/packages/web/bin/lib/commands-connect-url.js
+++ b/packages/web/bin/lib/commands-connect-url.js
@@ -35,6 +35,16 @@ async function resolveConnectUrlServerUrl(options) {
}
const bindHost = resolveConfiguredBindHost(hostOverride);
+
+ // A host that's already a full http(s) URL is a public/server URL, not a bind
+ // address (e.g. `--host https://devchamber.example.com` for a remote deploy
+ // behind a reverse proxy). Use it directly instead of feeding it to
+ // buildLocalUrl, which would produce `http://https://...:port`.
+ const hostAsServerUrl = normalizeServerUrlForConnection(bindHost);
+ if (hostAsServerUrl) {
+ return { serverUrl: hostAsServerUrl, source: 'configured-host' };
+ }
+
if (!isWildcardBindHost(bindHost)) {
return {
serverUrl: buildLocalUrl(options.port, '/', hostOverride).replace(/\/+$/, ''),
diff --git a/packages/web/mobile.html b/packages/web/mobile.html
index 18a298ac..907e8c41 100644
--- a/packages/web/mobile.html
+++ b/packages/web/mobile.html
@@ -4,6 +4,65 @@
OpenChamber Mobile
+
+
+
diff --git a/packages/web/server/index.js b/packages/web/server/index.js
index 21238b5e..28f6c2dd 100644
--- a/packages/web/server/index.js
+++ b/packages/web/server/index.js
@@ -9,6 +9,7 @@ import net from 'net';
import { fileURLToPath } from 'url';
import os from 'os';
import crypto from 'crypto';
+import http2 from 'node:http2';
import { createUiAuth } from './lib/ui-auth/ui-auth.js';
import { createTunnelAuth } from './lib/opencode/tunnel-auth.js';
import { createManagedTunnelConfigRuntime } from './lib/tunnels/managed-config.js';
@@ -79,6 +80,7 @@ import { registerNotificationRoutes } from './lib/notifications/routes.js';
import { createNotificationEmitterRuntime } from './lib/notifications/emitter-runtime.js';
import { createNotificationTriggerRuntime } from './lib/notifications/runtime.js';
import { createPushRuntime } from './lib/notifications/push-runtime.js';
+import { createApnsRuntime } from './lib/notifications/apns-runtime.js';
import { createNotificationTemplateRuntime } from './lib/notifications/template-runtime.js';
import { createGracefulShutdownRuntime } from './lib/opencode/shutdown-runtime.js';
import { createProjectConfigRuntime } from './lib/projects/project-config.js';
@@ -275,6 +277,7 @@ const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR
: path.join(os.homedir(), '.config', 'openchamber');
const SETTINGS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'settings.json');
const PUSH_SUBSCRIPTIONS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'push-subscriptions.json');
+const APNS_TOKENS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'apns-tokens.json');
const REMOTE_CLIENTS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'remote-clients.json');
const CLOUDFLARE_MANAGED_REMOTE_TUNNELS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'cloudflare-managed-remote-tunnels.json');
const CLOUDFLARE_LEGACY_NAMED_TUNNELS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'cloudflare-named-tunnels.json');
@@ -377,12 +380,34 @@ const getOrCreateVapidKeys = (...args) => pushRuntime.getOrCreateVapidKeys(...ar
const addOrUpdatePushSubscription = (...args) => pushRuntime.addOrUpdatePushSubscription(...args);
const removePushSubscription = (...args) => pushRuntime.removePushSubscription(...args);
const sendPushToAllUiSessions = (...args) => pushRuntime.sendPushToAllUiSessions(...args);
-const updateUiVisibility = (...args) => pushRuntime.updateUiVisibility(...args);
+// Set once the notification trigger runtime exists (declared later). When a UI
+// client reports it became visible, reset the native push badge set — the same
+// moment the device zeroes its icon badge on becomeActive, keeping them in sync.
+let clearPendingPushBadge = () => {};
+const updateUiVisibility = (token, visible, platform) => {
+ if (visible === true) clearPendingPushBadge();
+ return pushRuntime.updateUiVisibility(token, visible, platform);
+};
const isAnyUiVisible = (...args) => pushRuntime.isAnyUiVisible(...args);
+const isAnyInteractiveClientVisible = (...args) => pushRuntime.isAnyInteractiveClientVisible(...args);
const isUiVisible = (...args) => pushRuntime.isUiVisible(...args);
const ensurePushInitialized = (...args) => pushRuntime.ensurePushInitialized(...args);
const setPushInitialized = (...args) => pushRuntime.setPushInitialized(...args);
+const apnsRuntime = createApnsRuntime({
+ fsPromises,
+ path,
+ crypto,
+ http2,
+ APNS_TOKENS_FILE_PATH,
+ readSettingsFromDiskMigrated,
+ writeSettingsToDisk,
+});
+
+const addOrUpdateApnsToken = (...args) => apnsRuntime.addOrUpdateApnsToken(...args);
+const removeApnsToken = (...args) => apnsRuntime.removeApnsToken(...args);
+const sendApnsToAllUiSessions = (...args) => apnsRuntime.sendApnsToAllUiSessions(...args);
+
const TERMINAL_INPUT_WS_MAX_REBINDS_PER_WINDOW = 128;
const TERMINAL_INPUT_WS_REBIND_WINDOW_MS = 60 * 1000;
const TERMINAL_INPUT_WS_HEARTBEAT_INTERVAL_MS = 15 * 1000;
@@ -676,12 +701,15 @@ const notificationTriggerRuntime = createNotificationTriggerRuntime({
emitDesktopNotification,
broadcastUiNotification,
sendPushToAllUiSessions,
+ sendApnsToAllUiSessions,
+ isAnyInteractiveClientVisible,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
});
const maybeSendPushForTrigger = (...args) => notificationTriggerRuntime.maybeSendPushForTrigger(...args);
const setAutoAcceptSession = (...args) => notificationTriggerRuntime.setAutoAcceptSession(...args);
+clearPendingPushBadge = () => notificationTriggerRuntime.clearPendingPushBadge();
const globalMessageStreamHub = createGlobalMessageStreamHub({
buildOpenCodeUrl,
@@ -1103,7 +1131,13 @@ async function main(options = {}) {
const app = express();
const serverStartedAt = new Date().toISOString();
- const packagedClientOrigins = new Set(['openchamber-ui://app']);
+ const packagedClientOrigins = new Set([
+ 'openchamber-ui://app',
+ 'capacitor://localhost',
+ 'http://localhost',
+ 'https://localhost',
+ ]);
+ const isLocalDevClientOrigin = (origin) => /^https?:\/\/(localhost|127\.0\.0\.1):\d+$/.test(origin);
app.set('trust proxy', true);
// Keep self-hosted instances out of search engines. The app shell is served
// publicly (it loads before prompting for the UI password), so without this
@@ -1118,7 +1152,7 @@ async function main(options = {}) {
});
app.use((req, res, next) => {
const origin = typeof req.headers.origin === 'string' ? req.headers.origin : '';
- if (packagedClientOrigins.has(origin)) {
+ if (packagedClientOrigins.has(origin) || isLocalDevClientOrigin(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
@@ -1193,7 +1227,10 @@ async function main(options = {}) {
writeSettingsToDisk,
addOrUpdatePushSubscription,
removePushSubscription,
+ addOrUpdateApnsToken,
+ removeApnsToken,
updateUiVisibility,
+ clearPendingPushBadge: () => clearPendingPushBadge(),
isUiVisible,
getUiNotificationClients: () => uiNotificationClients,
writeSseEvent,
diff --git a/packages/web/server/lib/notifications/APNS.md b/packages/web/server/lib/notifications/APNS.md
new file mode 100644
index 00000000..61821a3e
--- /dev/null
+++ b/packages/web/server/lib/notifications/APNS.md
@@ -0,0 +1,131 @@
+# APNs remote push — signed relay mode
+
+Native iOS background push (notifications even when the app is **suspended or killed**) is
+delivered via APNs through a **central relay**, so no user configures an Apple key. Each server
+signs its relay requests with an auto-generated keypair, and tokens are bound to the server that
+registered them — so a leaked device token alone can't be used to push.
+
+## How it works
+
+1. The app registers its APNs device token with **its own server** (`POST /api/push/apns-token`,
+ `useNativePushRegistration`). PWA/desktop never register — only the native Capacitor app.
+2. The server **binds the token on the relay**: it POSTs `{ token, publicKeyJwk, ts, sig }` to
+ `POST /v1/push/register-token`, signed with its auto-generated ECDSA P-256 key
+ (`getOrCreateRelayKeypair`, persisted in settings like the VAPID keys). The relay records
+ `token → serverId` where `serverId = SHA-256(publicKey)`.
+3. On a trigger (ready/error/question/permission), the server composes **generic, content-free**
+ text — a fixed scenario title ("Agent response is ready" / "Agent needs your input" / "Agent
+ needs permission" / "Agent hit an error") + the **session name** as the body, no model/project/
+ message content — plus a **`badge`** count (see below) — and POSTs `{ tokens, title, body,
+ badge, env, data:{sessionId}, publicKeyJwk, ts, sig }` to `POST /v1/push/send`
+ (`apns-runtime.js` → `sendViaRelay`). It does **not** gate on UI visibility (see below).
+4. The **relay** (`openchamber-website/apps/api`, Cloudflare Worker) verifies the signature +
+ `ts` freshness, derives `serverId`, and only delivers to tokens bound to that server. It holds
+ the single project APNs `.p8` key, signs an ES256 JWT with `crypto.subtle`, and sends each
+ token to APNs over HTTP/2, returning per-token results; the server drops tokens flagged `drop`
+ (410 / BadDeviceToken). The relay stores no secret — only `token → serverId` hashes.
+5. Tapping a push deep-links to its session via the forwarded `sessionId`.
+
+## Foreground suppression
+
+APNs is **not** gated on UI visibility. A backgrounded WKWebView can't reliably report "hidden"
+before iOS suspends it, so a server-side visibility gate dropped background push for short
+responses. Instead the server always sends, and **iOS** suppresses the foreground banner
+(`PushNotifications.presentationOptions: []` in `capacitor.config`) — so there is no notification
+while the app is active, with no race. APNs is the native app's **only** channel; local
+notifications were removed (a WKWebView can't tell foreground from background — `document.hasFocus()`
+is unreliable — so they leaked while the app was open). Cloudflare is touched only when a native
+app with notifications on has a registered token and a trigger fires.
+
+## App-icon badge
+
+Each push carries an **absolute** `aps.badge` = the number of **distinct collapse-ids (`tag`)
+pushed since the app was last foregrounded**. It mirrors the lock-screen banner stack.
+
+The count is a `Set` (`pendingPushTags`) in the trigger runtime (`runtime.js`):
+`toApnsGenericPayload` adds the push `tag` and returns the set size as the badge. We key by **`tag`,
+not sessionId**, because the tag *is* the banner identity — iOS uses it as `apns-collapse-id`, so
+same-tag pushes replace one banner while different tags are distinct banners. One session can raise
+several banners (`ready-`, `question-`, `permission-` are different tags), so
+counting sessionIds both over- and under-counts the stack; counting tags matches it.
+
+It is deliberately **not** derived from the live attention snapshot (`needsAttention`/`isViewed`):
+that machinery drives in-app indicators on *connected* clients, where a backgrounded client stays
+"viewing" and `needsAttention` is set by a separate `session.status` event that races the push
+trigger. The set self-clears via `clearPendingPushBadge` on any signal that the user is engaging
+with the app: the visibility beacon (`updateUiVisibility` wrapper, `visible:true`), **plus** opening
+a session (`POST /api/sessions/:id/view`) and sending a message (`POST /api/sessions/:id/
+message-sent`). The latter two need no auth and fire reliably on the native app when it foregrounds,
+so they are the dependable reset — the visibility beacon alone proved unreliable in WKWebView. This
+mirrors the device zeroing its icon badge on `sceneDidBecomeActive` (`AppDelegate.swift`), keeping
+server and device in sync.
+
+The value flows `runtime.js` (`toApnsGenericPayload`) → `apns-runtime.js` (`sendViaRelay` body /
+direct-mode `aps.badge`) → relay (`pushSendSchema.badge` → `aps.badge`). It is **not** signed (like
+`body`/`data`); the relay still only delivers to bound tokens. The set is server-global, so every
+device token of a server sees the same badge.
+
+## Modes
+
+- **Relay (default):** server has no Apple key; `OPENCHAMBER_PUSH_RELAY_URL` defaults to
+ `https://api.openchamber.dev/v1/push/send` (register URL is derived as `…/register-token`).
+- **Direct (fallback):** set `OPENCHAMBER_PUSH_RELAY_DISABLED=true` + `OPENCHAMBER_APNS_KEY_ID/
+ TEAM_ID/P8` to sign+send from the server itself (HTTP/2 + ES256 JWT); no relay binding needed.
+
+## Config
+
+Server (`apns-runtime.js`):
+- `OPENCHAMBER_PUSH_RELAY_URL` (default the public relay), `OPENCHAMBER_APNS_ENVIRONMENT`
+ (`sandbox` default / `production`). The signing keypair is auto-generated — nothing to set.
+- Direct fallback: `OPENCHAMBER_APNS_KEY_ID`, `OPENCHAMBER_APNS_TEAM_ID`, `OPENCHAMBER_APNS_P8`
+ (or `_P8_PATH`), `OPENCHAMBER_APNS_BUNDLE_ID`, `OPENCHAMBER_PUSH_RELAY_DISABLED=true`.
+
+Relay (Cloudflare Worker secrets via `wrangler secret put` / GitHub Actions): `APNS_P8`,
+`APNS_KEY_ID`, `APNS_TEAM_ID`, optional `APNS_BUNDLE_ID` / `APNS_DEFAULT_ENV`. The `push_tokens`
+binding table is created by `migrations/0002_push_tokens.sql` (applied on deploy).
+
+## Apple setup (one-time)
+
+1. Apple **Keys** (not Certificates) → create an **APNs Auth Key** (`.p8`) → Key ID + Team ID;
+ enable **Push Notifications** on App ID `com.openchamber.app`.
+2. In the **openchamber-website** repo → Actions secrets: `APNS_P8` (PEM), `APNS_KEY_ID`,
+ `APNS_TEAM_ID`. Push to `main` → relay deploys, secrets sync, D1 migrations apply.
+3. Xcode: confirm the Push Notifications capability; Clean Build Folder; run on device.
+
+## Security posture
+
+- The device token is a per-install secret, but no longer the *only* defence: every relay request
+ is signed by the server's private key, and the relay only delivers to a token from its bound
+ `serverId`. A leaked token alone is useless — an attacker has neither the private key nor a
+ matching binding.
+- `serverId` self-certifies (`SHA-256(publicKey)`), so the relay holds no secret; a D1 leak
+ exposes only `token → serverId` hashes. The signed `ts` (±5 min window) blocks replay.
+- Residual: trust-on-first-bind (whoever registers a token first owns it) — acceptable, since
+ registering already requires possessing the token. Cloudflare rate limiting is defence-in-depth.
+
+## Data confidentiality (what the relay / Apple can see)
+
+The push payload is **not** application-encrypted, so there is no decryption step. The text is
+sent in plaintext, protected only by **TLS in transit** (HTTPS to the relay, TLS from the relay
+to APNs). The request **signature is authentication, not encryption** — the relay *verifies* it
+(valid / invalid), it does not hide anything.
+
+Who can read the alert text:
+
+- **Network hops:** nothing (TLS).
+- **The relay (Cloudflare):** the generic title + body (session name), the device token, and
+ `sessionId`. It stores only `token → serverId` hashes (no text, no payload).
+- **Apple APNs:** the alert text too — APNs always reads the alert payload of an `alert` push.
+- **The device:** displays it.
+
+This is acceptable **because the text is deliberately content-free**: a fixed scenario title +
+the session name only — no model, project, or message content (`runtime.js` →
+`toApnsGenericPayload`). The session name is the single semi-personal field that crosses the
+relay/Apple. To hide even that from Apple would require an end-to-end **encrypted payload**
+(`mutable-content` + a Notification Service Extension that decrypts on-device with a key never
+sent to the relay) — not implemented, and unnecessary for generic text.
+
+## Android (FCM) note
+
+The Android equivalent is **FCM** (not implemented): the same relay would forward to FCM with a
+server key, and the client would register an FCM token (same store/routes + signing).
diff --git a/packages/web/server/lib/notifications/DOCUMENTATION.md b/packages/web/server/lib/notifications/DOCUMENTATION.md
index 01ff1f6f..bf736a1e 100644
--- a/packages/web/server/lib/notifications/DOCUMENTATION.md
+++ b/packages/web/server/lib/notifications/DOCUMENTATION.md
@@ -7,6 +7,7 @@ This module provides notification message preparation utilities for the web serv
- `packages/web/server/lib/notifications/index.js`: public entrypoint imported by `packages/web/server/index.js`.
- `packages/web/server/lib/notifications/routes.js`: route registration for push, visibility, and session status/attention endpoints.
- `packages/web/server/lib/notifications/push-runtime.js`: push subscription persistence, VAPID initialization, and UI visibility runtime.
+- `packages/web/server/lib/notifications/apns-runtime.js`: native iOS APNs device-token persistence + delivery. Two modes: **relay** (default — sign + POST tokens + generic text to the central Cloudflare relay `https://api.openchamber.dev/v1/push/send`, which holds the single project APNs key) and **direct** (fallback — sign ES256 JWT with Node crypto + HTTP/2, when `OPENCHAMBER_PUSH_RELAY_DISABLED=true`). Each server has an auto-generated ECDSA P-256 keypair (`getOrCreateRelayKeypair`, persisted in settings); it binds tokens on the relay (`/v1/push/register-token`) and signs every relay request, so the relay only delivers to tokens bound to that server. APNs is the native app's sole notification channel (no local notifications) and is NOT gated on UI visibility — iOS suppresses the foreground banner instead. Mobile push carries only generic text (scenario title + session name) — see `APNS.md`.
- `packages/web/server/lib/notifications/emitter-runtime.js`: desktop/stdout + UI SSE notification emission runtime.
- `packages/web/server/lib/notifications/runtime.js`: trigger runtime for OpenCode event-driven notification fanout.
- `packages/web/server/lib/notifications/template-runtime.js`: notification template variables and session text/title enrichment runtime. Zen-model helpers are retained as compatibility stubs only.
@@ -24,6 +25,8 @@ This module provides notification message preparation utilities for the web serv
- `GET /api/push/vapid-public-key`
- `POST /api/push/subscribe`
- `DELETE /api/push/subscribe`
+ - `POST /api/push/apns-token` (native iOS APNs device-token registration)
+ - `DELETE /api/push/apns-token`
- `POST /api/push/visibility`
- `GET /api/push/visibility`
- `GET /api/notifications/stream`
@@ -61,6 +64,16 @@ This module provides notification message preparation utilities for the web serv
- `isAnyUiVisible()`
- `isUiVisible(token)`
+### APNs runtime API (apns-runtime.js)
+- `createApnsRuntime(dependencies)`: creates runtime for native iOS APNs push and device-token state. Dependencies: `fsPromises`, `path`, `crypto`, `http2`, `APNS_TOKENS_FILE_PATH`, `readSettingsFromDiskMigrated`, `writeSettingsToDisk` (persists the auto-generated relay signing keypair).
+- Returned API:
+ - `addOrUpdateApnsToken(uiSessionToken, deviceToken, userAgent)` — also binds a newly-seen token on the relay (signed `/v1/push/register-token`).
+ - `removeApnsToken(uiSessionToken, deviceToken)`
+ - `removeApnsTokenFromAllSessions(deviceToken)`
+ - `sendApnsToAllUiSessions(payload)` — signs + sends to all registered tokens (no UI-visibility gate; iOS suppresses the foreground banner). No-ops with a single warning when APNs is unconfigured. Drops tokens on `410` / `BadDeviceToken` / `Unregistered`.
+ - `resolveApnsConfig()`
+- Configuration (env first, then `settings.apnsConfig`): `OPENCHAMBER_APNS_KEY_ID`, `OPENCHAMBER_APNS_TEAM_ID`, `OPENCHAMBER_APNS_P8` (PEM contents; literal `\n` accepted) or `OPENCHAMBER_APNS_P8_PATH`, `OPENCHAMBER_APNS_BUNDLE_ID` (default `com.openchamber.app`), `OPENCHAMBER_APNS_ENVIRONMENT` (`sandbox` default, or `production`).
+
### Emitter runtime API (emitter-runtime.js)
- `createNotificationEmitterRuntime(dependencies)`: creates runtime for unified notification emission channels.
- Returned API:
diff --git a/packages/web/server/lib/notifications/apns-runtime.js b/packages/web/server/lib/notifications/apns-runtime.js
new file mode 100644
index 00000000..4f3040e9
--- /dev/null
+++ b/packages/web/server/lib/notifications/apns-runtime.js
@@ -0,0 +1,512 @@
+// APNs (Apple Push Notification service) runtime for the native iOS mobile app.
+//
+// Device tokens are persisted per UI session (mirrors push-runtime.js). Delivery has two
+// modes, chosen at send time:
+// - Relay (default): POST tokens + generic text to the central Cloudflare relay, which
+// holds the single project APNs key and signs+sends — so users configure nothing.
+// - Direct (fallback): sign an ES256 JWT with Node crypto and send over HTTP/2 ourselves,
+// for self-hosters who set OPENCHAMBER_APNS_* and OPENCHAMBER_PUSH_RELAY_DISABLED=true.
+// Wired into the same trigger fanout as web push (see runtime.js); the relay carries only
+// generic, model-based text (no session content) — see APNS.md.
+
+const APNS_TOKENS_VERSION = 1;
+const APNS_HOST_PRODUCTION = 'https://api.push.apple.com';
+const APNS_HOST_SANDBOX = 'https://api.sandbox.push.apple.com';
+// APNs rejects auth tokens older than 1h; refresh well inside that window.
+const JWT_TTL_MS = 50 * 60 * 1000;
+const DEFAULT_BUNDLE_ID = 'com.openchamber.app';
+const DEFAULT_RELAY_URL = 'https://api.openchamber.dev/v1/push/send';
+const MAX_TOKENS_PER_SESSION = 10;
+// APNs reasons that mean the token is permanently invalid → drop it.
+const DEAD_TOKEN_REASONS = new Set(['BadDeviceToken', 'Unregistered', 'DeviceTokenNotForTopic']);
+
+const trimmedEnv = (name) => {
+ const value = process.env[name];
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
+};
+
+// Env vars commonly store the .p8 with literal "\n" sequences; restore real newlines.
+const normalizePem = (value) => (typeof value === 'string' ? value.replace(/\\n/g, '\n').trim() : '');
+
+export const createApnsRuntime = (deps) => {
+ const {
+ fsPromises,
+ path,
+ crypto,
+ http2,
+ APNS_TOKENS_FILE_PATH,
+ readSettingsFromDiskMigrated,
+ writeSettingsToDisk,
+ } = deps;
+
+ let persistLock = Promise.resolve();
+ let cachedJwt = null; // { token, issuedAtMs, keyId }
+ let cachedRelayKey = null; // { privateKey, publicJwk }
+ let warnedUnconfigured = false;
+
+ // ---------------------------------------------------------------------------
+ // Per-server relay signing identity (ECDSA P-256). Auto-generated + persisted in settings
+ // (mirrors getOrCreateVapidKeys). The relay derives serverId = SHA-256(publicKey), verifies
+ // each request's signature, and only delivers to tokens this server registered — so a leaked
+ // device token alone can't be used to push. Zero-config: the keypair generates on first use.
+ // ---------------------------------------------------------------------------
+
+ const getOrCreateRelayKeypair = async () => {
+ if (cachedRelayKey) return cachedRelayKey;
+ const settings = await readSettingsFromDiskMigrated();
+ const existing = settings?.relaySigningKey;
+ if (existing && existing.privateJwk && existing.publicJwk) {
+ cachedRelayKey = {
+ privateKey: crypto.createPrivateKey({ key: existing.privateJwk, format: 'jwk' }),
+ publicJwk: existing.publicJwk,
+ };
+ return cachedRelayKey;
+ }
+ const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
+ const privateJwk = privateKey.export({ format: 'jwk' });
+ const publicJwk = publicKey.export({ format: 'jwk' });
+ await writeSettingsToDisk({ ...settings, relaySigningKey: { privateJwk, publicJwk } });
+ cachedRelayKey = { privateKey, publicJwk };
+ return cachedRelayKey;
+ };
+
+ const signRelayMessage = (privateKey, message) =>
+ crypto.sign('SHA256', Buffer.from(message), { key: privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64url');
+
+ // Trim to the 4 fields the relay's schema accepts (and that feed the serverId hash).
+ const relayPublicJwk = (publicJwk) => ({
+ kty: publicJwk.kty,
+ crv: publicJwk.crv,
+ x: publicJwk.x,
+ y: publicJwk.y,
+ });
+
+ const registerTokenWithRelay = async (token, platform = 'ios') => {
+ const relay = resolveRelayConfig();
+ if (!relay) return; // direct mode — no relay binding needed
+ try {
+ const { privateKey, publicJwk } = await getOrCreateRelayKeypair();
+ const ts = Date.now();
+ // platform is part of the signed message so it can't be tampered en route.
+ const sig = signRelayMessage(privateKey, `${ts}.${token}.${platform}`);
+ const res = await fetch(relay.registerUrl, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ token, platform, publicKeyJwk: relayPublicJwk(publicJwk), ts, sig }),
+ });
+ if (!res.ok) console.warn(`[Push relay] register-token failed status=${res.status}`);
+ } catch (error) {
+ console.warn('[Push relay] register-token request failed:', error?.message ?? error);
+ }
+ };
+
+ // ---------------------------------------------------------------------------
+ // Token persistence (same shape + write-lock pattern as push-runtime.js)
+ // ---------------------------------------------------------------------------
+
+ const emptyStore = () => ({ version: APNS_TOKENS_VERSION, tokensBySession: {} });
+
+ const readTokensFromDisk = async () => {
+ try {
+ const raw = await fsPromises.readFile(APNS_TOKENS_FILE_PATH, 'utf8');
+ const parsed = JSON.parse(raw);
+ if (!parsed || typeof parsed !== 'object' || parsed.version !== APNS_TOKENS_VERSION) {
+ return emptyStore();
+ }
+ const tokensBySession =
+ parsed.tokensBySession && typeof parsed.tokensBySession === 'object' ? parsed.tokensBySession : {};
+ return { version: APNS_TOKENS_VERSION, tokensBySession };
+ } catch (error) {
+ if (error && typeof error === 'object' && error.code === 'ENOENT') {
+ return emptyStore();
+ }
+ console.warn('Failed to read APNs tokens file:', error);
+ return emptyStore();
+ }
+ };
+
+ const writeTokensToDisk = async (data) => {
+ await fsPromises.mkdir(path.dirname(APNS_TOKENS_FILE_PATH), { recursive: true });
+ await fsPromises.writeFile(APNS_TOKENS_FILE_PATH, JSON.stringify(data, null, 2), 'utf8');
+ };
+
+ const persistTokenUpdate = async (mutate) => {
+ persistLock = persistLock.then(async () => {
+ const current = await readTokensFromDisk();
+ const next = mutate({ version: APNS_TOKENS_VERSION, tokensBySession: current.tokensBySession || {} });
+ await writeTokensToDisk(next);
+ return next;
+ });
+ return persistLock;
+ };
+
+ const normalizeTokens = (record) => {
+ if (!Array.isArray(record)) return [];
+ return record
+ .map((entry) => {
+ if (!entry || typeof entry !== 'object') return null;
+ const deviceToken = entry.deviceToken;
+ if (typeof deviceToken !== 'string' || deviceToken.trim().length === 0) return null;
+ return {
+ deviceToken: deviceToken.trim(),
+ createdAt: typeof entry.createdAt === 'number' ? entry.createdAt : null,
+ lastSeenAt: typeof entry.lastSeenAt === 'number' ? entry.lastSeenAt : null,
+ userAgent: typeof entry.userAgent === 'string' ? entry.userAgent : undefined,
+ // 'ios' (APNs) or 'android' (FCM). Older entries without one are APNs by default.
+ platform: entry.platform === 'android' ? 'android' : 'ios',
+ };
+ })
+ .filter(Boolean);
+ };
+
+ // Normalize an incoming platform hint to the two we support; default to APNs/iOS since that
+ // was the only registrant before Android/FCM existed.
+ const normalizePlatform = (platform) => (platform === 'android' ? 'android' : 'ios');
+
+ const addOrUpdateApnsToken = async (uiSessionToken, deviceToken, userAgent, platform) => {
+ if (!uiSessionToken || typeof deviceToken !== 'string' || deviceToken.trim().length === 0) return;
+ const token = deviceToken.trim();
+ const tokenPlatform = normalizePlatform(platform);
+ const now = Date.now();
+
+ await persistTokenUpdate((current) => {
+ const tokensBySession = { ...(current.tokensBySession || {}) };
+ const existing = normalizeTokens(tokensBySession[uiSessionToken]);
+ const filtered = existing.filter((entry) => entry.deviceToken !== token);
+ filtered.unshift({
+ deviceToken: token,
+ createdAt: now,
+ lastSeenAt: now,
+ userAgent: typeof userAgent === 'string' && userAgent.length > 0 ? userAgent : undefined,
+ platform: tokenPlatform,
+ });
+ tokensBySession[uiSessionToken] = filtered.slice(0, MAX_TOKENS_PER_SESSION);
+ return { version: APNS_TOKENS_VERSION, tokensBySession };
+ });
+
+ // (Re)bind this token to our server on the relay so only we can push to it. The device
+ // re-sends its token on each launch; this is an idempotent upsert relay-side, and binding
+ // every time (not just for new tokens) keeps existing tokens bound after a relay/server
+ // upgrade rather than silently going unbound. Platform is bound too so the relay routes
+ // it to APNs vs FCM.
+ await registerTokenWithRelay(token, tokenPlatform);
+ };
+
+ const removeApnsToken = async (uiSessionToken, deviceToken) => {
+ if (!uiSessionToken || !deviceToken) return;
+ await persistTokenUpdate((current) => {
+ const tokensBySession = { ...(current.tokensBySession || {}) };
+ const filtered = normalizeTokens(tokensBySession[uiSessionToken]).filter(
+ (entry) => entry.deviceToken !== deviceToken,
+ );
+ if (filtered.length === 0) delete tokensBySession[uiSessionToken];
+ else tokensBySession[uiSessionToken] = filtered;
+ return { version: APNS_TOKENS_VERSION, tokensBySession };
+ });
+ };
+
+ const removeApnsTokenFromAllSessions = async (deviceToken) => {
+ if (!deviceToken) return;
+ await persistTokenUpdate((current) => {
+ const tokensBySession = { ...(current.tokensBySession || {}) };
+ for (const [session, entries] of Object.entries(tokensBySession)) {
+ const filtered = normalizeTokens(entries).filter((entry) => entry.deviceToken !== deviceToken);
+ if (filtered.length === 0) delete tokensBySession[session];
+ else tokensBySession[session] = filtered;
+ }
+ return { version: APNS_TOKENS_VERSION, tokensBySession };
+ });
+ };
+
+ // ---------------------------------------------------------------------------
+ // Config (env first, then settings.apnsConfig) — mirrors resolveVapidSubject
+ // ---------------------------------------------------------------------------
+
+ const resolveApnsConfig = async () => {
+ let keyId = trimmedEnv('OPENCHAMBER_APNS_KEY_ID');
+ let teamId = trimmedEnv('OPENCHAMBER_APNS_TEAM_ID');
+ let bundleId = trimmedEnv('OPENCHAMBER_APNS_BUNDLE_ID');
+ let environment = (trimmedEnv('OPENCHAMBER_APNS_ENVIRONMENT') || '').toLowerCase();
+ let p8 = normalizePem(process.env.OPENCHAMBER_APNS_P8 || '');
+
+ const p8Path = trimmedEnv('OPENCHAMBER_APNS_P8_PATH');
+ if (!p8 && p8Path) {
+ try {
+ p8 = (await fsPromises.readFile(p8Path, 'utf8')).trim();
+ } catch (error) {
+ console.warn('[APNs] Failed to read OPENCHAMBER_APNS_P8_PATH:', error?.message ?? error);
+ }
+ }
+
+ if (!keyId || !teamId || !p8) {
+ try {
+ const settings = await readSettingsFromDiskMigrated();
+ const stored = settings?.apnsConfig;
+ if (stored && typeof stored === 'object') {
+ keyId = keyId || (typeof stored.keyId === 'string' ? stored.keyId.trim() : null);
+ teamId = teamId || (typeof stored.teamId === 'string' ? stored.teamId.trim() : null);
+ bundleId = bundleId || (typeof stored.bundleId === 'string' ? stored.bundleId.trim() : null);
+ environment = environment || (typeof stored.environment === 'string' ? stored.environment.toLowerCase() : '');
+ if (!p8 && typeof stored.p8 === 'string') p8 = normalizePem(stored.p8);
+ }
+ } catch {
+ // settings unavailable — fall through to the unconfigured result
+ }
+ }
+
+ if (!keyId || !teamId || !p8) return null;
+
+ return {
+ keyId,
+ teamId,
+ p8,
+ bundleId: bundleId || DEFAULT_BUNDLE_ID,
+ environment: environment === 'production' ? 'production' : 'sandbox',
+ };
+ };
+
+ // ---------------------------------------------------------------------------
+ // JWT (ES256, JOSE/raw signature) + HTTP/2 send
+ // ---------------------------------------------------------------------------
+
+ const signApnsJwt = (config) => {
+ const header = Buffer.from(JSON.stringify({ alg: 'ES256', kid: config.keyId })).toString('base64url');
+ const claims = Buffer.from(
+ JSON.stringify({ iss: config.teamId, iat: Math.floor(Date.now() / 1000) }),
+ ).toString('base64url');
+ const signingInput = `${header}.${claims}`;
+ const signature = crypto
+ .sign('sha256', Buffer.from(signingInput), { key: config.p8, dsaEncoding: 'ieee-p1363' })
+ .toString('base64url');
+ return `${signingInput}.${signature}`;
+ };
+
+ const getJwt = (config) => {
+ const now = Date.now();
+ if (cachedJwt && cachedJwt.keyId === config.keyId && now - cachedJwt.issuedAtMs < JWT_TTL_MS) {
+ return cachedJwt.token;
+ }
+ const token = signApnsJwt(config);
+ cachedJwt = { token, issuedAtMs: now, keyId: config.keyId };
+ return token;
+ };
+
+ const buildBody = (payload) => {
+ const data = payload && typeof payload.data === 'object' && payload.data ? payload.data : {};
+ return JSON.stringify({
+ aps: {
+ alert: {
+ title: typeof payload?.title === 'string' ? payload.title : undefined,
+ body: typeof payload?.body === 'string' ? payload.body : undefined,
+ },
+ badge: Number.isFinite(payload?.badge) && payload.badge >= 0 ? Math.trunc(payload.badge) : undefined,
+ sound: 'default',
+ 'thread-id': typeof payload?.tag === 'string' ? payload.tag : undefined,
+ // Wakes the Notification Service Extension so it can refresh the home/lock-screen
+ // widgets (attention count + unread dot) from the push, even when the app is closed.
+ // No extra network call — just an extra key on the push we already send.
+ 'mutable-content': 1,
+ },
+ ...data,
+ });
+ };
+
+ const sendOne = (client, deviceToken, body, jwt, config) =>
+ new Promise((resolve) => {
+ const headers = {
+ ':method': 'POST',
+ ':path': `/3/device/${deviceToken}`,
+ authorization: `bearer ${jwt}`,
+ 'apns-topic': config.bundleId,
+ 'apns-push-type': 'alert',
+ 'apns-priority': '10',
+ };
+ // collapse-id dedups like web-push tags; APNs caps it at 64 bytes.
+ const collapseId = typeof config.tag === 'string' ? config.tag.slice(0, 64) : undefined;
+ if (collapseId) headers['apns-collapse-id'] = collapseId;
+
+ let req;
+ try {
+ req = client.request(headers);
+ } catch (error) {
+ console.warn('[APNs] request open failed:', error?.message ?? error);
+ resolve();
+ return;
+ }
+
+ let status = 0;
+ let responseBody = '';
+ req.on('response', (resHeaders) => {
+ status = Number(resHeaders[':status']) || 0;
+ });
+ req.setEncoding('utf8');
+ req.on('data', (chunk) => {
+ responseBody += chunk;
+ });
+ req.on('end', async () => {
+ if (status === 200) {
+ resolve();
+ return;
+ }
+ let reason = '';
+ try {
+ reason = JSON.parse(responseBody)?.reason || '';
+ } catch {
+ // non-JSON error body
+ }
+ if (status === 410 || DEAD_TOKEN_REASONS.has(reason)) {
+ await removeApnsTokenFromAllSessions(deviceToken);
+ } else {
+ console.warn(`[APNs] push failed status=${status} reason=${reason || 'unknown'}`);
+ }
+ resolve();
+ });
+ req.on('error', (error) => {
+ console.warn('[APNs] request error:', error?.message ?? error);
+ resolve();
+ });
+ req.end(body);
+ });
+
+ // Relay mode (default): the single APNs key lives in the central Cloudflare relay, not on
+ // each user's server — so users configure nothing. The server just POSTs device tokens +
+ // generic text; the relay signs + sends and reports which tokens to drop. Direct mode (below)
+ // is the fallback for self-hosters who set OPENCHAMBER_APNS_* and disable the relay.
+ const resolveRelayConfig = () => {
+ if (trimmedEnv('OPENCHAMBER_PUSH_RELAY_DISABLED') === 'true') return null;
+ const url = trimmedEnv('OPENCHAMBER_PUSH_RELAY_URL') || DEFAULT_RELAY_URL;
+ return {
+ url,
+ registerUrl: url.replace(/\/send$/, '/register-token'),
+ environment:
+ (trimmedEnv('OPENCHAMBER_APNS_ENVIRONMENT') || 'sandbox').toLowerCase() === 'production'
+ ? 'production'
+ : 'sandbox',
+ };
+ };
+
+ const sendViaRelay = async (deviceTokens, payload, relay) => {
+ const tokens = deviceTokens.slice(0, 100);
+ const title = typeof payload?.title === 'string' && payload.title.length > 0 ? payload.title : 'OpenChamber';
+ const { privateKey, publicJwk } = await getOrCreateRelayKeypair();
+ const ts = Date.now();
+ // Sign over the same canonical form the relay verifies: ts.sortedTokens.title.
+ const sig = signRelayMessage(privateKey, `${ts}.${[...tokens].sort().join(',')}.${title}`);
+ const requestBody = JSON.stringify({
+ tokens,
+ title,
+ body: typeof payload?.body === 'string' ? payload.body : '',
+ badge: Number.isFinite(payload?.badge) && payload.badge >= 0 ? Math.trunc(payload.badge) : undefined,
+ collapseId: typeof payload?.tag === 'string' ? payload.tag.slice(0, 64) : undefined,
+ env: relay.environment,
+ data: payload?.data && typeof payload.data === 'object' ? payload.data : undefined,
+ publicKeyJwk: relayPublicJwk(publicJwk),
+ ts,
+ sig,
+ });
+ try {
+ const res = await fetch(relay.url, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: requestBody,
+ });
+ if (!res.ok) {
+ console.warn(`[APNs relay] send failed status=${res.status}`);
+ return;
+ }
+ const data = await res.json().catch(() => null);
+ const results = Array.isArray(data?.results) ? data.results : [];
+ for (const result of results) {
+ if (result && result.drop === true && typeof result.token === 'string') {
+ await removeApnsTokenFromAllSessions(result.token);
+ }
+ }
+ } catch (error) {
+ console.warn('[APNs relay] request failed:', error?.message ?? error);
+ }
+ };
+
+ const sendViaDirectApns = async (deviceTokens, payload) => {
+ const config = await resolveApnsConfig();
+ if (!config) {
+ if (!warnedUnconfigured) {
+ warnedUnconfigured = true;
+ console.warn(
+ '[APNs] Relay disabled and no direct config; set OPENCHAMBER_APNS_KEY_ID / OPENCHAMBER_APNS_TEAM_ID / OPENCHAMBER_APNS_P8 for direct send.',
+ );
+ }
+ return;
+ }
+
+ const host = config.environment === 'production' ? APNS_HOST_PRODUCTION : APNS_HOST_SANDBOX;
+ const jwt = getJwt(config);
+ const body = buildBody(payload);
+ const sendConfig = { ...config, tag: typeof payload?.tag === 'string' ? payload.tag : undefined };
+
+ let client;
+ try {
+ client = http2.connect(host);
+ } catch (error) {
+ console.warn('[APNs] connect failed:', error?.message ?? error);
+ return;
+ }
+
+ await new Promise((resolve) => {
+ let settled = false;
+ const finish = () => {
+ if (settled) return;
+ settled = true;
+ try {
+ client.close();
+ } catch {
+ // ignore close errors
+ }
+ resolve();
+ };
+ client.on('error', (error) => {
+ console.warn('[APNs] session error:', error?.message ?? error);
+ finish();
+ });
+ Promise.all(
+ deviceTokens.map((token) => sendOne(client, token, body, jwt, sendConfig)),
+ ).finally(finish);
+ });
+ };
+
+ // NOT gated on UI visibility (unlike web push). A backgrounded WKWebView can't reliably
+ // report "hidden" before iOS suspends it, so a visibility gate wrongly suppressed
+ // background push for short responses. Instead we always send, and rely on iOS to NOT
+ // display the alert while the app is foreground (presentationOptions: [] in
+ // capacitor.config) — so there is no notification when the app is active, with no race.
+ const sendApnsToAllUiSessions = async (payload, _options = {}) => {
+ const store = await readTokensFromDisk();
+ const deviceTokens = [];
+ const seen = new Set();
+ for (const record of Object.values(store.tokensBySession || {})) {
+ for (const entry of normalizeTokens(record)) {
+ if (!seen.has(entry.deviceToken)) {
+ seen.add(entry.deviceToken);
+ deviceTokens.push(entry.deviceToken);
+ }
+ }
+ }
+ if (deviceTokens.length === 0) return;
+
+ const relay = resolveRelayConfig();
+ if (relay) {
+ await sendViaRelay(deviceTokens, payload, relay);
+ return;
+ }
+ await sendViaDirectApns(deviceTokens, payload);
+ };
+
+ return {
+ addOrUpdateApnsToken,
+ removeApnsToken,
+ removeApnsTokenFromAllSessions,
+ sendApnsToAllUiSessions,
+ resolveApnsConfig,
+ // exposed for tests
+ signApnsJwt,
+ };
+};
diff --git a/packages/web/server/lib/notifications/apns-runtime.test.js b/packages/web/server/lib/notifications/apns-runtime.test.js
new file mode 100644
index 00000000..5605ddc7
--- /dev/null
+++ b/packages/web/server/lib/notifications/apns-runtime.test.js
@@ -0,0 +1,196 @@
+import crypto from 'node:crypto';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { createApnsRuntime } from './apns-runtime.js';
+
+// A real P-256 key so the ES256 signing path (direct mode) runs for real.
+const { privateKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
+const P8 = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
+const APNS_CONFIG = { keyId: 'KEY123', teamId: 'TEAM123', p8: P8, bundleId: 'com.openchamber.app', environment: 'sandbox' };
+
+// In-memory fs so add-then-read reflects within a test.
+const createMemoryFs = () => {
+ let content = null;
+ return {
+ mkdir: vi.fn(async () => {}),
+ readFile: vi.fn(async () => {
+ if (content == null) {
+ const err = new Error('ENOENT');
+ err.code = 'ENOENT';
+ throw err;
+ }
+ return content;
+ }),
+ writeFile: vi.fn(async (_path, data) => {
+ content = data;
+ }),
+ };
+};
+
+const makeDeps = (overrides = {}) => {
+ // Stateful settings so the auto-generated relay signing keypair persists + reads back.
+ let settings = {};
+ return {
+ fsPromises: createMemoryFs(),
+ path: { dirname: () => '/tmp' },
+ crypto,
+ http2: { connect: vi.fn(() => { throw new Error('http2 must not be used in relay mode'); }) },
+ APNS_TOKENS_FILE_PATH: '/tmp/apns-tokens.json',
+ readSettingsFromDiskMigrated: vi.fn(async () => settings),
+ writeSettingsToDisk: vi.fn(async (next) => { settings = next; }),
+ ...overrides,
+ };
+};
+
+const jsonResponse = (data, status = 200) =>
+ new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json' } });
+
+// Mirror of the relay's verifier (crypto.subtle), to prove the server's signatures are valid.
+const verifyRelaySignature = async (publicKeyJwk, message, sigB64Url) => {
+ const key = await crypto.subtle.importKey(
+ 'jwk',
+ { kty: publicKeyJwk.kty, crv: publicKeyJwk.crv, x: publicKeyJwk.x, y: publicKeyJwk.y },
+ { name: 'ECDSA', namedCurve: 'P-256' },
+ false,
+ ['verify'],
+ );
+ return crypto.subtle.verify(
+ { name: 'ECDSA', hash: 'SHA-256' },
+ key,
+ new Uint8Array(Buffer.from(sigB64Url, 'base64url')),
+ new TextEncoder().encode(message),
+ );
+};
+
+const isRegister = ([url]) => String(url).endsWith('/register-token');
+const isSend = ([url]) => String(url) === 'https://relay.test/v1/push/send';
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ delete process.env.OPENCHAMBER_PUSH_RELAY_URL;
+ delete process.env.OPENCHAMBER_PUSH_RELAY_DISABLED;
+});
+
+describe('apns runtime relay mode (default)', () => {
+ it('registers tokens (signed) and posts signed generic text, dropping dead tokens', async () => {
+ const fetchMock = vi.fn(async (url) =>
+ isRegister([url])
+ ? jsonResponse({ ok: true })
+ : jsonResponse({
+ results: [
+ { token: 'tokenA', ok: true, drop: false },
+ { token: 'tokenDead', ok: false, drop: true },
+ ],
+ }),
+ );
+ vi.stubGlobal('fetch', fetchMock);
+ process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
+
+ const runtime = createApnsRuntime(makeDeps());
+ await runtime.addOrUpdateApnsToken('s1', 'tokenA');
+ await runtime.addOrUpdateApnsToken('s2', 'tokenDead');
+
+ // Each new token is bound on the relay with a signed register-token call.
+ const registerCalls = fetchMock.mock.calls.filter(isRegister);
+ expect(registerCalls).toHaveLength(2);
+ for (const [url, init] of registerCalls) {
+ expect(url).toBe('https://relay.test/v1/push/register-token');
+ const body = JSON.parse(init.body);
+ expect(body.publicKeyJwk).toMatchObject({ kty: 'EC', crv: 'P-256' });
+ expect(typeof body.ts).toBe('number');
+ expect(body.platform).toBe('ios');
+ expect(await verifyRelaySignature(body.publicKeyJwk, `${body.ts}.${body.token}.${body.platform}`, body.sig)).toBe(true);
+ }
+
+ fetchMock.mockClear();
+ await runtime.sendApnsToAllUiSessions(
+ { title: 'Agent response is ready', body: 'My session', badge: 3, tag: 'ready-x', data: { sessionId: 'sess1' } },
+ {},
+ );
+
+ const sendCall = fetchMock.mock.calls.find(isSend);
+ expect(sendCall).toBeTruthy();
+ const sent = JSON.parse(sendCall[1].body);
+ expect(sendCall[1].headers.authorization).toBeUndefined();
+ expect(new Set(sent.tokens)).toEqual(new Set(['tokenA', 'tokenDead']));
+ expect(sent.title).toBe('Agent response is ready');
+ expect(sent.body).toBe('My session');
+ expect(sent.badge).toBe(3);
+ expect(sent.data).toEqual({ sessionId: 'sess1' });
+ expect(sent.publicKeyJwk).toMatchObject({ kty: 'EC', crv: 'P-256' });
+ const sendMessage = `${sent.ts}.${[...sent.tokens].sort().join(',')}.${sent.title}`;
+ expect(await verifyRelaySignature(sent.publicKeyJwk, sendMessage, sent.sig)).toBe(true);
+
+ // tokenDead should have been dropped → next send targets only tokenA.
+ fetchMock.mockClear();
+ await runtime.sendApnsToAllUiSessions({ title: 'x', body: 'y', tag: 't' }, {});
+ expect(JSON.parse(fetchMock.mock.calls.find(isSend)[1].body).tokens).toEqual(['tokenA']);
+ });
+
+ it('reuses one persisted keypair (same serverId) across register + send', async () => {
+ const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
+ vi.stubGlobal('fetch', fetchMock);
+ process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
+
+ const deps = makeDeps();
+ const runtime = createApnsRuntime(deps);
+ await runtime.addOrUpdateApnsToken('s1', 'tokenA');
+ await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b', tag: 'x' }, {});
+
+ const keys = fetchMock.mock.calls.map(([, init]) => JSON.parse(init.body).publicKeyJwk);
+ expect(keys.length).toBeGreaterThanOrEqual(2);
+ expect(keys.every((k) => k.x === keys[0].x && k.y === keys[0].y)).toBe(true);
+ // Keypair was generated + persisted exactly once.
+ expect(deps.writeSettingsToDisk).toHaveBeenCalledTimes(1);
+ });
+
+ it('no-ops (no relay call) when no tokens are registered', async () => {
+ const fetchMock = vi.fn();
+ vi.stubGlobal('fetch', fetchMock);
+ const runtime = createApnsRuntime(makeDeps());
+ await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b' });
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+});
+
+describe('apns runtime direct fallback (relay disabled)', () => {
+ it('signs an ES256 JWT and sends over http2 when relay is disabled', async () => {
+ process.env.OPENCHAMBER_PUSH_RELAY_DISABLED = 'true';
+ const targeted = [];
+ const http2 = {
+ connect: () => ({
+ on: () => {},
+ close: () => {},
+ request: (headers) => {
+ targeted.push(String(headers[':path']).replace('/3/device/', ''));
+ const listeners = {};
+ const req = {
+ on: (event, cb) => { listeners[event] = cb; return req; },
+ setEncoding: () => req,
+ end: () => {
+ queueMicrotask(() => {
+ listeners.response?.({ ':status': '200' });
+ listeners.end?.();
+ });
+ },
+ };
+ return req;
+ },
+ }),
+ };
+ const runtime = createApnsRuntime(
+ makeDeps({ http2, readSettingsFromDiskMigrated: vi.fn(async () => ({ apnsConfig: APNS_CONFIG })) }),
+ );
+ await runtime.addOrUpdateApnsToken('s', 'tokenDirect');
+ await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b', tag: 'ready-x' });
+ expect(targeted).toEqual(['tokenDirect']);
+ });
+
+ it('signApnsJwt produces a 3-part ES256 token with the expected header/claims', () => {
+ const runtime = createApnsRuntime(makeDeps());
+ const parts = runtime.signApnsJwt(APNS_CONFIG).split('.');
+ expect(parts).toHaveLength(3);
+ expect(JSON.parse(Buffer.from(parts[0], 'base64url').toString())).toEqual({ alg: 'ES256', kid: 'KEY123' });
+ expect(JSON.parse(Buffer.from(parts[1], 'base64url').toString()).iss).toBe('TEAM123');
+ });
+});
diff --git a/packages/web/server/lib/notifications/push-runtime.js b/packages/web/server/lib/notifications/push-runtime.js
index ab776a8d..01abcb08 100644
--- a/packages/web/server/lib/notifications/push-runtime.js
+++ b/packages/web/server/lib/notifications/push-runtime.js
@@ -115,12 +115,13 @@ export const createPushRuntime = (deps) => {
p256dh,
auth,
createdAt: typeof entry.createdAt === 'number' ? entry.createdAt : null,
+ platform: typeof entry.platform === 'string' ? entry.platform : undefined,
};
})
.filter(Boolean);
};
- const addOrUpdatePushSubscription = async (uiSessionToken, subscription, userAgent) => {
+ const addOrUpdatePushSubscription = async (uiSessionToken, subscription, userAgent, platform) => {
if (!uiSessionToken) {
return;
}
@@ -135,6 +136,7 @@ export const createPushRuntime = (deps) => {
const filtered = existing.filter((entry) => entry && typeof entry.endpoint === 'string' && entry.endpoint !== subscription.endpoint);
+ const previous = existing.find((entry) => entry && entry.endpoint === subscription.endpoint);
filtered.unshift({
endpoint: subscription.endpoint,
p256dh: subscription.p256dh,
@@ -142,6 +144,13 @@ export const createPushRuntime = (deps) => {
createdAt: now,
lastSeenAt: now,
userAgent: typeof userAgent === 'string' && userAgent.length > 0 ? userAgent : undefined,
+ // Platform lets the sender route mobile PWA push through the same presence gate as APNs.
+ platform:
+ typeof platform === 'string' && platform
+ ? platform
+ : typeof previous?.platform === 'string'
+ ? previous.platform
+ : undefined,
});
subsBySession[uiSessionToken] = filtered.slice(0, 10);
@@ -230,18 +239,32 @@ export const createPushRuntime = (deps) => {
}
await Promise.all(Array.from(subscriptionsByEndpoint.values()).map(async (sub) => {
- if (requireNoSse && isAnyUiVisible()) {
- return;
+ if (requireNoSse) {
+ // Mobile PWA subscriptions follow the same presence model as native push: suppress only
+ // when an interactive (desktop/web) client is visible. The phone PWA's own foreground is
+ // handled in the service worker (focused-client check), so it won't double-notify.
+ // Non-mobile (desktop/web) subscriptions keep the existing any-visible gate.
+ const suppressed = isMobilePlatform(sub.platform) ? isAnyInteractiveClientVisible() : isAnyUiVisible();
+ if (suppressed) return;
}
await sendPushToSubscription(sub, payload);
}));
};
- const updateUiVisibility = (token, visible) => {
+ // A client is "mobile" if it reports a native mobile platform. Anything else (web, desktop,
+ // vscode, or an older client that doesn't report a platform) is treated as interactive — i.e.
+ // a surface where the user would actually see the in-app notification.
+ const MOBILE_PLATFORMS = new Set(['ios', 'android']);
+ const isMobilePlatform = (platform) => typeof platform === 'string' && MOBILE_PLATFORMS.has(platform);
+
+ const updateUiVisibility = (token, visible, platform) => {
if (!token) return;
const now = Date.now();
const nextVisible = Boolean(visible);
- uiVisibilityByToken.set(token, { visible: nextVisible, updatedAt: now });
+ const existing = uiVisibilityByToken.get(token);
+ // Keep the last known platform if this beacon didn't carry one (e.g. a heartbeat).
+ const nextPlatform = typeof platform === 'string' && platform ? platform : existing?.platform;
+ uiVisibilityByToken.set(token, { visible: nextVisible, updatedAt: now, platform: nextPlatform });
};
const isAnyUiVisible = () => {
@@ -255,6 +278,25 @@ export const createPushRuntime = (deps) => {
return false;
};
+ // True when at least one NON-mobile client (desktop/web/vscode) is currently visible. Used to
+ // suppress native push to the phone: an active desktop already shows the notification, so the
+ // phone doesn't need it. Deliberately based on the desktop's visibility (reliable), never the
+ // phone's own (a backgrounded WKWebView can't report "hidden" before iOS suspends it).
+ const isAnyInteractiveClientVisible = () => {
+ const now = Date.now();
+ pruneUiVisibility(now);
+ for (const state of uiVisibilityByToken.values()) {
+ if (
+ state.visible === true &&
+ now - state.updatedAt <= UI_VISIBILITY_TTL_MS &&
+ !isMobilePlatform(state.platform)
+ ) {
+ return true;
+ }
+ }
+ return false;
+ };
+
const isUiVisible = (token) => {
const now = Date.now();
pruneUiVisibility(now);
@@ -317,6 +359,7 @@ export const createPushRuntime = (deps) => {
sendPushToAllUiSessions,
updateUiVisibility,
isAnyUiVisible,
+ isAnyInteractiveClientVisible,
isUiVisible,
ensurePushInitialized,
setPushInitialized,
diff --git a/packages/web/server/lib/notifications/push-runtime.test.js b/packages/web/server/lib/notifications/push-runtime.test.js
index cfcb756e..20de23a8 100644
--- a/packages/web/server/lib/notifications/push-runtime.test.js
+++ b/packages/web/server/lib/notifications/push-runtime.test.js
@@ -42,4 +42,38 @@ describe('push runtime visibility tracking', () => {
expect(runtime.isAnyUiVisible()).toBe(false);
expect(runtime.isUiVisible('visible-client')).toBe(false);
});
+
+ it('treats only mobile platforms as non-interactive for isAnyInteractiveClientVisible', () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
+
+ const runtime = createRuntime();
+
+ // Only the phone (foreground) is connected → no interactive client to absorb the notification.
+ runtime.updateUiVisibility('phone', true, 'ios');
+ expect(runtime.isAnyUiVisible()).toBe(true);
+ expect(runtime.isAnyInteractiveClientVisible()).toBe(false);
+
+ // A visible desktop counts as interactive → suppress mobile push.
+ runtime.updateUiVisibility('desktop', true, 'desktop');
+ expect(runtime.isAnyInteractiveClientVisible()).toBe(true);
+
+ // Desktop hidden again → back to mobile-only, push should flow to the phone.
+ runtime.updateUiVisibility('desktop', false, 'desktop');
+ expect(runtime.isAnyInteractiveClientVisible()).toBe(false);
+
+ // A client that never reported a platform is treated as interactive (conservative).
+ runtime.updateUiVisibility('legacy', true);
+ expect(runtime.isAnyInteractiveClientVisible()).toBe(true);
+ });
+
+ it('remembers the last platform when a heartbeat omits it', () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
+
+ const runtime = createRuntime();
+ runtime.updateUiVisibility('phone', true, 'android');
+ runtime.updateUiVisibility('phone', true); // heartbeat without platform
+ expect(runtime.isAnyInteractiveClientVisible()).toBe(false);
+ });
});
diff --git a/packages/web/server/lib/notifications/routes.js b/packages/web/server/lib/notifications/routes.js
index 32ea30e4..4f291a28 100644
--- a/packages/web/server/lib/notifications/routes.js
+++ b/packages/web/server/lib/notifications/routes.js
@@ -35,7 +35,10 @@ export const registerNotificationRoutes = (app, dependencies) => {
writeSettingsToDisk,
addOrUpdatePushSubscription,
removePushSubscription,
+ addOrUpdateApnsToken,
+ removeApnsToken,
updateUiVisibility,
+ clearPendingPushBadge,
isUiVisible,
getUiNotificationClients,
writeSseEvent,
@@ -106,6 +109,7 @@ export const registerNotificationRoutes = (app, dependencies) => {
}
}
+ const platform = typeof req.body?.platform === 'string' ? req.body.platform : undefined;
await addOrUpdatePushSubscription(
uiToken,
{
@@ -113,7 +117,8 @@ export const registerNotificationRoutes = (app, dependencies) => {
p256dh: keys.p256dh,
auth: keys.auth,
},
- req.headers['user-agent']
+ req.headers['user-agent'],
+ platform
);
return res.json({ ok: true });
@@ -138,6 +143,50 @@ export const registerNotificationRoutes = (app, dependencies) => {
return res.json({ ok: true });
});
+ // Native iOS APNs device token registration (mirrors /api/push/subscribe). The token
+ // is a hex APNs device token from @capacitor/push-notifications, scoped to the UI
+ // session like web-push subscriptions.
+ app.post('/api/push/apns-token', async (req, res) => {
+ await ensureSessionWatcher();
+
+ const uiToken = uiAuthController?.ensureSessionToken
+ ? await uiAuthController.ensureSessionToken(req, res)
+ : getUiSessionTokenFromRequest(req);
+ if (!uiToken) {
+ return res.status(401).json({ error: 'UI session missing' });
+ }
+
+ const deviceToken = typeof req.body?.token === 'string' ? req.body.token.trim() : '';
+ if (!deviceToken) {
+ return res.status(400).json({ error: 'Invalid body' });
+ }
+
+ const platform = req.body?.platform === 'android' ? 'android' : 'ios';
+ if (typeof addOrUpdateApnsToken === 'function') {
+ await addOrUpdateApnsToken(uiToken, deviceToken, req.headers['user-agent'], platform);
+ }
+ return res.json({ ok: true });
+ });
+
+ app.delete('/api/push/apns-token', async (req, res) => {
+ const uiToken = uiAuthController?.ensureSessionToken
+ ? await uiAuthController.ensureSessionToken(req, res)
+ : getUiSessionTokenFromRequest(req);
+ if (!uiToken) {
+ return res.status(401).json({ error: 'UI session missing' });
+ }
+
+ const deviceToken = typeof req.body?.token === 'string' ? req.body.token.trim() : '';
+ if (!deviceToken) {
+ return res.status(400).json({ error: 'Invalid body' });
+ }
+
+ if (typeof removeApnsToken === 'function') {
+ await removeApnsToken(uiToken, deviceToken);
+ }
+ return res.json({ ok: true });
+ });
+
app.post('/api/push/visibility', async (req, res) => {
const uiToken = uiAuthController?.ensureSessionToken
? await uiAuthController.ensureSessionToken(req, res)
@@ -146,8 +195,9 @@ export const registerNotificationRoutes = (app, dependencies) => {
return res.status(401).json({ error: 'UI session missing' });
}
- const visible = req.body && typeof req.body === 'object' ? req.body.visible : null;
- updateUiVisibility(uiToken, visible === true);
+ const body = req.body && typeof req.body === 'object' ? req.body : {};
+ const platform = typeof body.platform === 'string' ? body.platform : undefined;
+ updateUiVisibility(uiToken, body.visible === true, platform);
return res.json({ ok: true });
});
@@ -301,6 +351,10 @@ export const registerNotificationRoutes = (app, dependencies) => {
const clientId = req.headers['x-client-id'] || req.ip || 'anonymous';
markSessionViewed(sessionId, clientId);
+ // The user is engaging with the app, so the native push badge no longer
+ // applies — reset it here too (not only on the visibility beacon), since
+ // opening the app reliably marks the opened session viewed.
+ if (typeof clearPendingPushBadge === 'function') clearPendingPushBadge();
return res.json({
success: true,
@@ -326,6 +380,9 @@ export const registerNotificationRoutes = (app, dependencies) => {
const sessionId = req.params.id;
markUserMessageSent(sessionId);
+ // Sending a message means the user is active in the app; reset the native
+ // push badge so it counts only notifications since this engagement.
+ if (typeof clearPendingPushBadge === 'function') clearPendingPushBadge();
return res.json({
success: true,
diff --git a/packages/web/server/lib/notifications/runtime.js b/packages/web/server/lib/notifications/runtime.js
index 5a2d8259..01e8a245 100644
--- a/packages/web/server/lib/notifications/runtime.js
+++ b/packages/web/server/lib/notifications/runtime.js
@@ -10,10 +10,84 @@ export const createNotificationTriggerRuntime = (deps) => {
emitDesktopNotification,
broadcastUiNotification,
sendPushToAllUiSessions,
+ sendApnsToAllUiSessions,
+ isAnyInteractiveClientVisible,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
} = deps;
+ // App-icon badge for native push: the set of DISTINCT collapse-ids (the push
+ // `tag`, e.g. `ready-` / `permission-`) we've sent since
+ // the app was last foregrounded. The badge is the absolute APNs `aps.badge`.
+ //
+ // We key by `tag`, not sessionId, because the tag IS the banner identity: iOS
+ // uses it as `apns-collapse-id`, so same-tag pushes REPLACE one banner while
+ // different tags are distinct banners. One session can raise several banners
+ // (ready + question + permission are different tags), so counting sessionIds
+ // both over- and under-counts the lock-screen stack; counting tags mirrors it.
+ //
+ // We deliberately do NOT derive this from the live attention snapshot
+ // (needsAttention/isViewed): that machinery is for in-app indicators on
+ // connected clients — a backgrounded client stays "viewing", and needsAttention
+ // is set by a separate session.status event that races the push trigger. The
+ // set is cleared when a UI client reports visible (`clearPendingPushBadge`),
+ // the same moment the device zeroes its icon badge on becomeActive.
+ const pendingPushTags = new Set();
+ const clearPendingPushBadge = () => {
+ pendingPushTags.clear();
+ };
+ const trackPushAndCountBadge = (tag) => {
+ if (typeof tag === 'string' && tag.length > 0) {
+ pendingPushTags.add(tag);
+ }
+ return pendingPushTags.size;
+ };
+
+ // Generic notification for native push (per the mobile design): a fixed, scenario-based
+ // title + the session name as the body. No model/project/message content crosses the relay.
+ const APNS_TITLE_BY_TYPE = {
+ ready: 'Agent response is ready',
+ error: 'Agent hit an error',
+ question: 'Agent needs your input',
+ permission: 'Agent needs permission',
+ };
+
+ const toApnsGenericPayload = (payload) => {
+ const data = payload?.data && typeof payload.data === 'object' ? payload.data : {};
+ const sessionName = typeof data.sessionName === 'string' && data.sessionName.trim().length > 0
+ ? data.sessionName.trim()
+ : 'Session';
+ return {
+ title: APNS_TITLE_BY_TYPE[data.type] || 'Agent update',
+ body: sessionName,
+ badge: trackPushAndCountBadge(typeof payload?.tag === 'string' ? payload.tag : undefined),
+ tag: payload?.tag,
+ // sessionId is forwarded so a tapped push can deep-link; it is an opaque id, not content.
+ data: typeof data.sessionId === 'string' ? { sessionId: data.sessionId } : undefined,
+ };
+ };
+
+ // Fan a notification out to every delivery channel: browser web-push (full templated
+ // payload) and native iOS APNs (generic model-based text). Both share the dedup tag and
+ // `requireNoSse` focus gate; a failure in one channel must not block the other.
+ const fanoutPush = (payload, options) => {
+ // Presence-aware routing: if any interactive (non-mobile) client — desktop/web/vscode — is
+ // currently visible, it already shows the in-app notification, so skip the native push to the
+ // phone. Gated on the desktop's visibility (reliable), never the phone's own. When we skip we
+ // also skip toApnsGenericPayload, so the badge isn't incremented for an undelivered push.
+ const interactiveVisible = isAnyInteractiveClientVisible?.() === true;
+ return Promise.all([
+ Promise.resolve(sendPushToAllUiSessions?.(payload, options)).catch((error) => {
+ console.warn('[Push] web-push fanout failed:', error?.message ?? error);
+ }),
+ interactiveVisible
+ ? Promise.resolve()
+ : Promise.resolve(sendApnsToAllUiSessions?.(toApnsGenericPayload(payload), options)).catch((error) => {
+ console.warn('[APNs] fanout failed:', error?.message ?? error);
+ }),
+ ]);
+ };
+
let getIsWindowFocused = typeof deps.getIsWindowFocused === 'function'
? deps.getIsWindowFocused
: null;
@@ -240,6 +314,7 @@ export const createNotificationTriggerRuntime = (deps) => {
let title = `${formatMode(info?.mode)} agent is ready`;
let body = `${formatModelId(info?.modelID)} completed the task`;
+ let sessionName = '';
try {
const templates = settings.notificationTemplates || {};
@@ -249,6 +324,7 @@ export const createNotificationTriggerRuntime = (deps) => {
: (templates.completion || { title: '{agent_name} is ready', message: '{model_name} completed the task' });
const variables = await buildTemplateVariables(payload, sessionId);
+ sessionName = typeof variables.session_name === 'string' ? variables.session_name : sessionName;
const messageId = info?.id;
let lastMessage = extractLastMessageText(payload);
@@ -283,7 +359,7 @@ export const createNotificationTriggerRuntime = (deps) => {
broadcastUiNotification(notificationPayload, { desktopNotificationDelivered });
}
- await sendPushToAllUiSessions(
+ await fanoutPush(
{
title,
body,
@@ -291,6 +367,7 @@ export const createNotificationTriggerRuntime = (deps) => {
data: {
url: buildSessionDeepLinkUrl(sessionId),
sessionId,
+ sessionName,
type: 'ready',
},
},
@@ -308,9 +385,11 @@ export const createNotificationTriggerRuntime = (deps) => {
let title = 'Tool error';
let body = 'An error occurred';
+ let sessionName = '';
try {
const variables = await buildTemplateVariables(payload, sessionId);
+ sessionName = typeof variables.session_name === 'string' ? variables.session_name : sessionName;
const errorMessageId = info?.id;
let lastMessage = extractLastMessageText(payload);
if (!lastMessage) {
@@ -345,7 +424,7 @@ export const createNotificationTriggerRuntime = (deps) => {
broadcastUiNotification(notificationPayload, { desktopNotificationDelivered });
}
- await sendPushToAllUiSessions(
+ await fanoutPush(
{
title,
body,
@@ -353,6 +432,7 @@ export const createNotificationTriggerRuntime = (deps) => {
data: {
url: buildSessionDeepLinkUrl(sessionId),
sessionId,
+ sessionName,
type: 'error',
},
},
@@ -391,9 +471,11 @@ export const createNotificationTriggerRuntime = (deps) => {
? 'Switch to build mode'
: header || 'Input needed';
let body = questionText || 'Agent is waiting for your response';
+ let sessionName = '';
try {
const variables = await buildTemplateVariables(payload, sessionId);
+ sessionName = typeof variables.session_name === 'string' ? variables.session_name : sessionName;
variables.last_message = questionText || header || '';
const templates = settings.notificationTemplates || {};
@@ -421,7 +503,7 @@ export const createNotificationTriggerRuntime = (deps) => {
broadcastUiNotification(notificationPayload, { desktopNotificationDelivered });
}
- void sendPushToAllUiSessions(
+ void fanoutPush(
{
title,
body,
@@ -429,6 +511,7 @@ export const createNotificationTriggerRuntime = (deps) => {
data: {
url: buildSessionDeepLinkUrl(sessionId),
sessionId,
+ sessionName,
type: 'question',
},
},
@@ -505,9 +588,11 @@ export const createNotificationTriggerRuntime = (deps) => {
let title = 'Permission required';
let body = fallbackMessage;
+ let sessionName = '';
try {
const variables = await buildTemplateVariables(payload, sessionId);
+ sessionName = typeof variables.session_name === 'string' ? variables.session_name : sessionName;
variables.last_message = fallbackMessage;
const templates = settings.notificationTemplates || {};
@@ -539,7 +624,7 @@ export const createNotificationTriggerRuntime = (deps) => {
notifiedPermissionRequests.add(requestKey);
}
- void sendPushToAllUiSessions(
+ void fanoutPush(
{
title,
body,
@@ -547,6 +632,7 @@ export const createNotificationTriggerRuntime = (deps) => {
data: {
url: buildSessionDeepLinkUrl(sessionId),
sessionId,
+ sessionName,
type: 'permission',
},
},
@@ -562,5 +648,6 @@ export const createNotificationTriggerRuntime = (deps) => {
maybeSendPushForTrigger,
setAutoAcceptSession,
setGetIsWindowFocused,
+ clearPendingPushBadge,
};
};
diff --git a/packages/web/server/lib/opencode/bootstrap-runtime.js b/packages/web/server/lib/opencode/bootstrap-runtime.js
index 7b41d17c..49d43e98 100644
--- a/packages/web/server/lib/opencode/bootstrap-runtime.js
+++ b/packages/web/server/lib/opencode/bootstrap-runtime.js
@@ -32,7 +32,10 @@ export const createBootstrapRuntime = (dependencies) => {
writeSettingsToDisk,
addOrUpdatePushSubscription,
removePushSubscription,
+ addOrUpdateApnsToken,
+ removeApnsToken,
updateUiVisibility,
+ clearPendingPushBadge,
isUiVisible,
getUiNotificationClients,
writeSseEvent,
@@ -95,7 +98,10 @@ export const createBootstrapRuntime = (dependencies) => {
writeSettingsToDisk,
addOrUpdatePushSubscription,
removePushSubscription,
+ addOrUpdateApnsToken,
+ removeApnsToken,
updateUiVisibility,
+ clearPendingPushBadge,
isUiVisible,
getUiNotificationClients,
writeSseEvent,
diff --git a/packages/web/server/lib/security/request-security.js b/packages/web/server/lib/security/request-security.js
index 183c3847..5fb85cde 100644
--- a/packages/web/server/lib/security/request-security.js
+++ b/packages/web/server/lib/security/request-security.js
@@ -1,6 +1,6 @@
export const createRequestSecurityRuntime = (deps) => {
const { readSettingsFromDiskMigrated } = deps;
- const packagedClientOrigins = new Set(['openchamber-ui://app']);
+ const packagedClientOrigins = new Set(['openchamber-ui://app', 'capacitor://localhost']);
const getUiSessionTokenFromRequest = (req) => {
const cookieHeader = req?.headers?.cookie;
diff --git a/packages/web/server/lib/security/request-security.test.js b/packages/web/server/lib/security/request-security.test.js
index a37cb057..031e8bef 100644
--- a/packages/web/server/lib/security/request-security.test.js
+++ b/packages/web/server/lib/security/request-security.test.js
@@ -6,7 +6,7 @@ const createRuntime = () => createRequestSecurityRuntime({
});
describe('request security runtime', () => {
- test('allows packaged client origin for remote client transports', async () => {
+ test('allows packaged client origins for remote client transports', async () => {
const runtime = createRuntime();
await expect(runtime.isRequestOriginAllowed({
@@ -16,5 +16,13 @@ describe('request security runtime', () => {
},
socket: {},
})).resolves.toBe(true);
+
+ await expect(runtime.isRequestOriginAllowed({
+ headers: {
+ origin: 'capacitor://localhost',
+ host: '192.168.1.130:1202',
+ },
+ socket: {},
+ })).resolves.toBe(true);
});
});
diff --git a/packages/web/src/api/push.ts b/packages/web/src/api/push.ts
index 525e4f1f..d93047c2 100644
--- a/packages/web/src/api/push.ts
+++ b/packages/web/src/api/push.ts
@@ -1,4 +1,4 @@
-import type { PushAPI, PushSubscribePayload, PushUnsubscribePayload } from '@openchamber/ui/lib/api/types';
+import type { ApnsTokenPayload, PushAPI, PushSubscribePayload, PushUnsubscribePayload } from '@openchamber/ui/lib/api/types';
import { runtimeFetch } from '@openchamber/ui/lib/runtime-fetch';
const fetchJson = async (input: string | URL | Request, init?: RequestInit): Promise => {
@@ -47,7 +47,7 @@ export const createWebPushAPI = (): PushAPI => ({
});
},
- async setVisibility(payload: { visible: boolean }) {
+ async setVisibility(payload: { visible: boolean; platform?: string }) {
return fetchJson<{ ok: true }>('/api/push/visibility', {
method: 'POST',
headers: {
@@ -57,4 +57,24 @@ export const createWebPushAPI = (): PushAPI => ({
keepalive: true,
});
},
+
+ async registerApnsToken(payload: ApnsTokenPayload) {
+ return fetchJson<{ ok: true }>('/api/push/apns-token', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(payload),
+ });
+ },
+
+ async unregisterApnsToken(payload: ApnsTokenPayload) {
+ return fetchJson<{ ok: true }>('/api/push/apns-token', {
+ method: 'DELETE',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(payload),
+ });
+ },
});