diff --git a/CHANGELOG.md b/CHANGELOG.md index adda5c5..0a0d928 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.16] - 2026-07-26 + +### Added + +- Native iPhone notification registration now belongs to the signed-in 1Helm + profile. Background channel and resident-agent updates use a + durable, retryable, idempotent delivery queue, honor global sound and + per-channel mute choices, skip the author, encrypt device tokens in the push + relay, and open the relevant channel or thread when tapped. +- Settings → Notifications now lets a phone owner explicitly request system + notification permission, see whether the current phone is registered, and + turn registration off without changing another device. + +### Changed + +- Phone channel chrome now uses two calm rows: hamburger and channel name on + top, then a right-aligned row for Favorite, Router, resident status, + Terminal, Notes, and Skipper. Desktop remains one compact row, and every + phone action retains a 44-point target without horizontal overflow. +- The native iOS status surface matches the current light or dark page header + behind the Dynamic Island while WebView controls remain physically below the + system indicators. + +### Fixed + +- Choosing **Take Photo or Video** from a message attachment no longer causes + iOS to terminate 1Helm. Camera, photo-library, and video-microphone access now + have narrow user-facing privacy declarations tied to explicit attachment + actions. +- The Notes top bar shares the mobile surface treatment and keeps **New note** + and **Close** visible on a 390-point phone viewport. + ## [0.0.15] - 2026-07-26 ### Fixed @@ -406,7 +438,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 notarization, stapled tickets, Gatekeeper verification, persistent Application Support, and isolated Apple container machines. -[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.15...HEAD +[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.16...HEAD +[0.0.16]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.16 [0.0.15]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.15 [0.0.14]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.14 [0.0.13]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.13 diff --git a/README.md b/README.md index d5424f6..f6222a8 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,10 @@ Android Keystore. server, and do not retain host data or provider credentials beyond the selected server address and secure session token. Use **Disconnect** in the profile menu to erase both from the device. +- iPhone notifications are opt-in under **Settings → Notifications**. Device + registration belongs to the signed-in account, per-channel mute still wins, + and tapping an update opens its channel or thread. iOS camera and photo + access is requested only after an explicit attachment action. ## Ready on day one. Specialized by day one hundred. @@ -282,7 +286,7 @@ A fresh data directory opens first-run setup. The source runtime defaults to | `PORT` | `8123` | HTTP/WebSocket control-plane port. | | `CTRL_DATA_DIR` | `./data` | Databases, routing state, uploads, and narrow workspace mirrors. | | `HELM_CHANNEL_COMPUTER_BACKEND` | `apple` on macOS, `lxc` on Linux, `wsl` on Windows | Host isolation backend; `native` and `mock` are explicit development/test overrides. | -| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.15` | Versioned channel-machine image contract. | +| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.16` | Versioned channel-machine image contract. | ### Agent-first JSON CLI diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index 8062541..07d5617 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -13,6 +13,7 @@ dependencies { implementation project(':capacitor-app') implementation project(':capacitor-browser') implementation project(':capacitor-keyboard') + implementation project(':capacitor-push-notifications') implementation project(':capacitor-splash-screen') implementation project(':capacitor-status-bar') diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 470cdd3..89972f3 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -49,4 +49,6 @@ + + diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle index fdbf680..1349870 100644 --- a/android/capacitor.settings.gradle +++ b/android/capacitor.settings.gradle @@ -14,6 +14,9 @@ project(':capacitor-browser').projectDir = new File('../node_modules/@capacitor/ include ':capacitor-keyboard' project(':capacitor-keyboard').projectDir = new File('../node_modules/@capacitor/keyboard/android') +include ':capacitor-push-notifications' +project(':capacitor-push-notifications').projectDir = new File('../node_modules/@capacitor/push-notifications/android') + include ':capacitor-splash-screen' project(':capacitor-splash-screen').projectDir = new File('../node_modules/@capacitor/splash-screen/android') diff --git a/capacitor.config.json b/capacitor.config.json index 3d425d7..2d662b9 100644 --- a/capacitor.config.json +++ b/capacitor.config.json @@ -3,7 +3,7 @@ "appName": "1Helm", "webDir": "public", "loggingBehavior": "none", - "backgroundColor": "#08090c", + "backgroundColor": "#111318", "zoomEnabled": false, "appendUserAgent": " 1HelmMobile", "server": { @@ -48,7 +48,10 @@ "StatusBar": { "overlaysWebView": false, "style": "DEFAULT", - "backgroundColor": "#08090c" + "backgroundColor": "#111318" + }, + "PushNotifications": { + "presentationOptions": [] } } } diff --git a/cloudflare/migrations/0002_mobile_push.sql b/cloudflare/migrations/0002_mobile_push.sql new file mode 100644 index 0000000..8b10226 --- /dev/null +++ b/cloudflare/migrations/0002_mobile_push.sql @@ -0,0 +1,26 @@ +CREATE TABLE IF NOT EXISTS push_installations ( + installation_id TEXT PRIMARY KEY, + management_secret_hash TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS push_devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + installation_id TEXT NOT NULL REFERENCES push_installations(installation_id) ON DELETE CASCADE, + recipient_id TEXT NOT NULL, + platform TEXT NOT NULL, + token_hash TEXT NOT NULL, + token_cipher TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(installation_id,platform,token_hash) +); +CREATE INDEX IF NOT EXISTS idx_push_devices_recipient ON push_devices(installation_id,recipient_id); +CREATE TABLE IF NOT EXISTS push_deliveries ( + installation_id TEXT NOT NULL REFERENCES push_installations(installation_id) ON DELETE CASCADE, + idempotency_key TEXT NOT NULL, + recipient_id TEXT NOT NULL, + delivered_count INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + PRIMARY KEY (installation_id,idempotency_key) +); diff --git a/cloudflare/schema.sql b/cloudflare/schema.sql index 4480b45..a689941 100644 --- a/cloudflare/schema.sql +++ b/cloudflare/schema.sql @@ -36,3 +36,30 @@ CREATE TABLE IF NOT EXISTS feedback_attachments ( data TEXT NOT NULL, created_at INTEGER NOT NULL ); + +CREATE TABLE IF NOT EXISTS push_installations ( + installation_id TEXT PRIMARY KEY, + management_secret_hash TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS push_devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + installation_id TEXT NOT NULL REFERENCES push_installations(installation_id) ON DELETE CASCADE, + recipient_id TEXT NOT NULL, + platform TEXT NOT NULL, + token_hash TEXT NOT NULL, + token_cipher TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(installation_id,platform,token_hash) +); +CREATE INDEX IF NOT EXISTS idx_push_devices_recipient ON push_devices(installation_id,recipient_id); +CREATE TABLE IF NOT EXISTS push_deliveries ( + installation_id TEXT NOT NULL REFERENCES push_installations(installation_id) ON DELETE CASCADE, + idempotency_key TEXT NOT NULL, + recipient_id TEXT NOT NULL, + delivered_count INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + PRIMARY KEY (installation_id,idempotency_key) +); diff --git a/cloudflare/src/worker.ts b/cloudflare/src/worker.ts index 3c281df..8f45294 100644 --- a/cloudflare/src/worker.ts +++ b/cloudflare/src/worker.ts @@ -5,7 +5,12 @@ interface Env { CLOUDFLARE_RUNTIME_TOKEN: string; PROVISION_LIMIT?: RateLimit; FEEDBACK_LIMIT?: RateLimit; + PUSH_LIMIT?: RateLimit; FEEDBACK_ADMIN_TOKEN?: string; + PUSH_DEVICE_ENCRYPTION_KEY?: string; + APNS_TEAM_ID?: string; + APNS_KEY_ID?: string; + APNS_PRIVATE_KEY?: string; } type WorkspaceRow = { @@ -20,6 +25,8 @@ type WorkspaceRow = { enabled: number; error: string; }; +type PushInstallationRow = { installation_id: string }; +type PushDeviceRow = { id: number; installation_id: string; recipient_id: string; platform: string; token_cipher: string; token_hash: string }; const BETA_WORKSPACE_LIMIT = 1000; const RESERVED = new Set([ @@ -33,7 +40,7 @@ const json = (body: unknown, status = 200): Response => new Response(JSON.string "cache-control": "no-store", "access-control-allow-origin": "*", "access-control-allow-headers": "authorization, content-type", - "access-control-allow-methods": "GET, POST, OPTIONS", + "access-control-allow-methods": "GET, POST, DELETE, OPTIONS", }, }); const slugValid = (slug: string): boolean => /^(?=.{3,48}$)[a-z0-9](?:[a-z0-9-]*[a-z0-9])$/.test(slug) && !RESERVED.has(slug); @@ -64,6 +71,146 @@ const unseal = async (secret: string, value: string): Promise => { const decrypted = await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromBase64(ivText) }, key, fromBase64(encryptedText)); return new TextDecoder().decode(decrypted); }; +const base64url = (value: Uint8Array | string): string => { + const bytesValue = typeof value === "string" ? new TextEncoder().encode(value) : value; + return base64(bytesValue).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +}; +const pemBytes = (value: string): Uint8Array => { + const body = value.replace(/\\n/g, "\n").replace(/-----BEGIN [^-]+-----|-----END [^-]+-----|\s/g, ""); + const decoded = atob(body); + const result = new Uint8Array(new ArrayBuffer(decoded.length)); + for (let index = 0; index < decoded.length; index++) result[index] = decoded.charCodeAt(index); + return result; +}; + +let apnsAuthorization: { value: string; created: number } | null = null; +async function apnsToken(env: Env): Promise { + if (!env.APNS_TEAM_ID || !env.APNS_KEY_ID || !env.APNS_PRIVATE_KEY) throw new Error("APNs delivery is not configured."); + const timestamp = Math.floor(Date.now() / 1000); + if (apnsAuthorization && timestamp - apnsAuthorization.created < 45 * 60) return apnsAuthorization.value; + const header = base64url(JSON.stringify({ alg: "ES256", kid: env.APNS_KEY_ID })); + const claims = base64url(JSON.stringify({ iss: env.APNS_TEAM_ID, iat: timestamp })); + const key = await crypto.subtle.importKey("pkcs8", pemBytes(env.APNS_PRIVATE_KEY), { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"]); + const signature = new Uint8Array(await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, key, new TextEncoder().encode(`${header}.${claims}`))); + apnsAuthorization = { value: `${header}.${claims}.${base64url(signature)}`, created: timestamp }; + return apnsAuthorization.value; +} + +async function authenticatedPushInstallation(request: Request, env: Env, installationId: string): Promise { + const secret = request.headers.get("authorization")?.replace(/^Bearer\s+/i, "") || ""; + if (!secret || !/^[a-f0-9]{16}$/.test(installationId)) return null; + const row = await env.REGISTRY.prepare("SELECT * FROM push_installations WHERE installation_id=?").bind(installationId).first(); + return row && await sha256(secret) === row.management_secret_hash ? row : null; +} + +async function registerPushInstallation(request: Request, env: Env): Promise { + const clientAddress = request.headers.get("cf-connecting-ip") || "unknown"; + if (env.PUSH_LIMIT && !(await env.PUSH_LIMIT.limit({ key: `install:${clientAddress}` })).success) return json({ error: "Too many phone-notification registration attempts. Try again shortly." }, 429); + const body = await request.json().catch(() => ({})) as Record; + const installationId = String(body.installation_id || ""); + const secret = String(body.management_secret || ""); + if (!/^[a-f0-9]{16}$/.test(installationId) || secret.length < 32) return json({ error: "This 1Helm installation could not be verified." }, 400); + const hashed = await sha256(secret); + const existing = await env.REGISTRY.prepare("SELECT * FROM push_installations WHERE installation_id=?").bind(installationId).first(); + if (existing && (existing as PushInstallationRow & { management_secret_hash?: string }).management_secret_hash !== hashed) return json({ error: "This installation is owned by different push credentials." }, 409); + if (!existing) await env.REGISTRY.prepare("INSERT INTO push_installations (installation_id,management_secret_hash,created_at,updated_at) VALUES (?,?,?,?)").bind(installationId, hashed, Date.now(), Date.now()).run(); + else await env.REGISTRY.prepare("UPDATE push_installations SET updated_at=? WHERE installation_id=?").bind(Date.now(), installationId).run(); + return json({ ok: true }); +} + +async function pushDevice(request: Request, env: Env): Promise { + const body = await request.json().catch(() => ({})) as Record; + const installationId = String(body.installation_id || ""); + const clientAddress = request.headers.get("cf-connecting-ip") || "unknown"; + if (env.PUSH_LIMIT && !(await env.PUSH_LIMIT.limit({ key: `device:${installationId}:${clientAddress}` })).success) return json({ error: "Too many phone-notification device requests. Try again shortly." }, 429); + if (!await authenticatedPushInstallation(request, env, installationId)) return json({ error: "Push authorization failed." }, 401); + if (!env.PUSH_DEVICE_ENCRYPTION_KEY) return json({ error: "Push device storage is not configured." }, 503); + const recipientId = String(body.recipient_id || ""); + const platform = String(body.platform || ""); + const token = String(body.token || "").trim(); + if (!/^[a-f0-9]{32}$/.test(recipientId) || !["ios", "android"].includes(platform) || !/^[A-Za-z0-9:_-]{32,4096}$/.test(token)) return json({ error: "Invalid push device." }, 400); + const tokenHash = await sha256(`${platform}:${token}`); + if (request.method === "DELETE") { + await env.REGISTRY.prepare("DELETE FROM push_devices WHERE installation_id=? AND recipient_id=? AND platform=? AND token_hash=?").bind(installationId, recipientId, platform, tokenHash).run(); + return json({ ok: true }); + } + const cipher = await seal(env.PUSH_DEVICE_ENCRYPTION_KEY, token); + const timestamp = Date.now(); + await env.REGISTRY.prepare(`INSERT INTO push_devices (installation_id,recipient_id,platform,token_hash,token_cipher,created_at,updated_at) + VALUES (?,?,?,?,?,?,?) ON CONFLICT(installation_id,platform,token_hash) DO UPDATE SET recipient_id=excluded.recipient_id,token_cipher=excluded.token_cipher,updated_at=excluded.updated_at`).bind( + installationId, recipientId, platform, tokenHash, cipher, timestamp, timestamp, + ).run(); + return json({ ok: true }); +} + +async function sendApns(env: Env, device: PushDeviceRow, notification: Record): Promise<{ delivered: boolean; permanent: boolean; reason: string }> { + if (!env.PUSH_DEVICE_ENCRYPTION_KEY) throw new Error("Push device storage is not configured."); + const token = await unseal(env.PUSH_DEVICE_ENCRYPTION_KEY, device.token_cipher); + const authorization = await apnsToken(env); + const alert = { title: String(notification.title || "1Helm").slice(0, 178), body: String(notification.body || "New activity").slice(0, 512) }; + const aps: Record = { alert, "thread-id": `channel-${Number(notification.channelId || 0)}`, "interruption-level": "active" }; + if (notification.sound !== false) aps.sound = "default"; + const payload = JSON.stringify({ + aps, + channelId: Number(notification.channelId || 0), + messageId: Number(notification.messageId || 0), + rootMessageId: Number(notification.rootMessageId || 0) || null, + }); + const send = async (host: string): Promise => fetch(`https://${host}/3/device/${encodeURIComponent(token)}`, { + method: "POST", + headers: { + authorization: `bearer ${authorization}`, + "apns-topic": "com.gitcommit90.onehelm.mobile", + "apns-push-type": "alert", + "apns-priority": "10", + "apns-collapse-id": String(notification.idempotency_key || `message-${notification.messageId || "new"}`).slice(0, 64), + }, + body: payload, + }); + let response = await send("api.push.apple.com"); + let result = await response.json().catch(() => ({})) as { reason?: string }; + if (!response.ok && result.reason === "BadDeviceToken") { + response = await send("api.sandbox.push.apple.com"); + result = await response.json().catch(() => ({})) as { reason?: string }; + } + const reason = String(result.reason || (response.ok ? "" : `HTTP ${response.status}`)); + return { delivered: response.ok, permanent: response.status === 410 || ["BadDeviceToken", "DeviceTokenNotForTopic", "Unregistered"].includes(reason), reason }; +} + +async function pushDelivery(request: Request, env: Env): Promise { + const body = await request.json().catch(() => ({})) as Record; + const installationId = String(body.installation_id || ""); + if (env.PUSH_LIMIT && !(await env.PUSH_LIMIT.limit({ key: `delivery:${installationId}` })).success) return json({ error: "Phone-notification delivery is temporarily rate limited." }, 429); + if (!await authenticatedPushInstallation(request, env, installationId)) return json({ error: "Push authorization failed." }, 401); + const recipientId = String(body.recipient_id || ""); + const idempotencyKey = String(body.idempotency_key || "").slice(0, 200); + if (!/^[a-f0-9]{32}$/.test(recipientId) || !idempotencyKey) return json({ error: "Invalid push delivery." }, 400); + const claimed = await env.REGISTRY.prepare(`INSERT OR IGNORE INTO push_deliveries + (installation_id,idempotency_key,recipient_id,delivered_count,created_at) VALUES (?,?,?,-1,?)`).bind(installationId, idempotencyKey, recipientId, Date.now()).run(); + if (!Number(claimed.meta.changes || 0)) { + const previous = await env.REGISTRY.prepare("SELECT delivered_count FROM push_deliveries WHERE installation_id=? AND idempotency_key=?").bind(installationId, idempotencyKey).first<{ delivered_count: number }>(); + return json({ delivered: Math.max(0, Number(previous?.delivered_count || 0)), existing: true }); + } + const results = await env.REGISTRY.prepare("SELECT * FROM push_devices WHERE installation_id=? AND recipient_id=? ORDER BY id").bind(installationId, recipientId).all(); + const devices = results.results || []; + let delivered = 0; + const errors: string[] = []; + for (const device of devices) { + if (device.platform !== "ios") { errors.push("Android delivery is not configured."); continue; } + try { + const outcome = await sendApns(env, device, { ...body, idempotency_key: idempotencyKey }); + if (outcome.delivered) delivered += 1; + else if (outcome.reason) errors.push(outcome.reason); + if (outcome.permanent) await env.REGISTRY.prepare("DELETE FROM push_devices WHERE id=?").bind(device.id).run(); + } catch (error) { errors.push(error instanceof Error ? error.message : String(error)); } + } + if (devices.length && delivered === 0 && errors.length) { + await env.REGISTRY.prepare("DELETE FROM push_deliveries WHERE installation_id=? AND idempotency_key=? AND delivered_count=-1").bind(installationId, idempotencyKey).run(); + return json({ error: errors.join("; ").slice(0, 500) }, 502); + } + await env.REGISTRY.prepare("UPDATE push_deliveries SET delivered_count=? WHERE installation_id=? AND idempotency_key=?").bind(delivered, installationId, idempotencyKey).run(); + return json({ delivered, devices: devices.length }); +} async function cf(env: Env, path: string, init: RequestInit = {}): Promise { const response = await fetch(`https://api.cloudflare.com/client/v4${path}`, { @@ -223,6 +370,9 @@ export default { if (url.pathname === "/health") return json({ ok: true }); if (url.pathname === "/v1/feedback" && request.method === "POST") return feedbackIntake(request, env); if (url.pathname === "/v1/feedback" && request.method === "GET") return feedbackInbox(request, env); + if (url.pathname === "/v1/push/installations" && request.method === "POST") return registerPushInstallation(request, env); + if (url.pathname === "/v1/push/devices" && ["POST", "DELETE"].includes(request.method)) return pushDevice(request, env); + if (url.pathname === "/v1/push/deliveries" && request.method === "POST") return pushDelivery(request, env); const availability = url.pathname.match(/^\/v1\/slugs\/([a-z0-9-]+)$/); if (availability && request.method === "GET") { const slug = availability[1].toLowerCase(); diff --git a/cloudflare/wrangler.jsonc b/cloudflare/wrangler.jsonc index 07bedc6..82232c9 100644 --- a/cloudflare/wrangler.jsonc +++ b/cloudflare/wrangler.jsonc @@ -16,6 +16,11 @@ "name": "FEEDBACK_LIMIT", "namespace_id": "1002", "simple": { "limit": 30, "period": 60 } + }, + { + "name": "PUSH_LIMIT", + "namespace_id": "1003", + "simple": { "limit": 120, "period": 60 } } ], "d1_databases": [ diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 063e45e..9155699 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -298,16 +298,18 @@ isa = XCBuildConfiguration; baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */; buildSettings = { + APNS_ENVIRONMENT = development; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 15; + CURRENT_PROJECT_VERSION = 16; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.0.15; + MARKETING_VERSION = 0.0.16; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = com.gitcommit90.onehelm.mobile; PRODUCT_NAME = 1Helm; @@ -320,16 +322,18 @@ 504EC3181FED79650016851F /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + APNS_ENVIRONMENT = production; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 15; + CURRENT_PROJECT_VERSION = 16; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.0.15; + MARKETING_VERSION = 0.0.16; PRODUCT_BUNDLE_IDENTIFIER = com.gitcommit90.onehelm.mobile; PRODUCT_NAME = 1Helm; SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; diff --git a/ios/App/App/App.entitlements b/ios/App/App/App.entitlements new file mode 100644 index 0000000..c8c412e --- /dev/null +++ b/ios/App/App/App.entitlements @@ -0,0 +1,8 @@ + + + + + aps-environment + $(APNS_ENVIRONMENT) + + diff --git a/ios/App/App/AppDelegate.swift b/ios/App/App/AppDelegate.swift index c3cd83b..01adc5c 100644 --- a/ios/App/App/AppDelegate.swift +++ b/ios/App/App/AppDelegate.swift @@ -11,6 +11,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate { return true } + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + NotificationCenter.default.post(name: .capacitorDidRegisterForRemoteNotifications, object: deviceToken) + } + + func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error) + } + func applicationWillResignActive(_ application: UIApplication) { // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. diff --git a/ios/App/App/Info.plist b/ios/App/App/Info.plist index 8d25952..1d0d5a1 100644 --- a/ios/App/App/Info.plist +++ b/ios/App/App/Info.plist @@ -39,8 +39,14 @@ ITSAppUsesNonExemptEncryption + NSCameraUsageDescription + 1Helm uses the camera only when you choose Take Photo or Video for a message attachment. NSMicrophoneUsageDescription - 1Helm uses the microphone only when you choose dictation in a message composer. + 1Helm uses the microphone only when you choose dictation or record a video attachment. + NSPhotoLibraryUsageDescription + 1Helm accesses photos and videos only when you choose them as message attachments. + NSPhotoLibraryAddUsageDescription + 1Helm saves media to your photo library only when you explicitly choose to do so. NSSpeechRecognitionUsageDescription 1Helm converts speech to text only when you choose dictation in a message composer. UILaunchStoryboardName diff --git a/ios/App/CapApp-SPM/Package.swift b/ios/App/CapApp-SPM/Package.swift index d6337be..dc8ed3c 100644 --- a/ios/App/CapApp-SPM/Package.swift +++ b/ios/App/CapApp-SPM/Package.swift @@ -16,6 +16,7 @@ let package = Package( .package(name: "CapacitorApp", path: "../../../node_modules/@capacitor/app"), .package(name: "CapacitorBrowser", path: "../../../node_modules/@capacitor/browser"), .package(name: "CapacitorKeyboard", path: "../../../node_modules/@capacitor/keyboard"), + .package(name: "CapacitorPushNotifications", path: "../../../node_modules/@capacitor/push-notifications"), .package(name: "CapacitorSplashScreen", path: "../../../node_modules/@capacitor/splash-screen"), .package(name: "CapacitorStatusBar", path: "../../../node_modules/@capacitor/status-bar") ], @@ -29,6 +30,7 @@ let package = Package( .product(name: "CapacitorApp", package: "CapacitorApp"), .product(name: "CapacitorBrowser", package: "CapacitorBrowser"), .product(name: "CapacitorKeyboard", package: "CapacitorKeyboard"), + .product(name: "CapacitorPushNotifications", package: "CapacitorPushNotifications"), .product(name: "CapacitorSplashScreen", package: "CapacitorSplashScreen"), .product(name: "CapacitorStatusBar", package: "CapacitorStatusBar") ] diff --git a/package-lock.json b/package-lock.json index d1b207b..b58d8e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "1helm", - "version": "0.0.15", + "version": "0.0.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "1helm", - "version": "0.0.15", + "version": "0.0.16", "hasInstallScript": true, "license": "AGPL-3.0-only", "dependencies": { @@ -17,6 +17,7 @@ "@capacitor/core": "^8.4.2", "@capacitor/ios": "^8.4.2", "@capacitor/keyboard": "^8.0.1", + "@capacitor/push-notifications": "^8.0.1", "@capacitor/splash-screen": "^8.0.1", "@capacitor/status-bar": "^8.0.1", "@gitcommit90/rerouted": "https://github.com/gitcommit90/rerouted/releases/download/v0.5.7/ReRouted-0.5.7-linux-node.tgz", @@ -232,6 +233,15 @@ "@capacitor/core": ">=8.0.0" } }, + "node_modules/@capacitor/push-notifications": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@capacitor/push-notifications/-/push-notifications-8.0.1.tgz", + "integrity": "sha512-CDY6syMxdG81Epn+V4fCYRR4L9tVS72F1kVrTY2ka6umWy4BmwXOXPsxGxlE5PmHLOhmBwKpy3uOiIJtSpMhOw==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, "node_modules/@capacitor/splash-screen": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/@capacitor/splash-screen/-/splash-screen-8.0.1.tgz", diff --git a/package.json b/package.json index 4f14500..44e4e70 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "1helm", "productName": "1Helm", - "version": "0.0.15", + "version": "0.0.16", "private": true, "type": "module", "license": "AGPL-3.0-only", @@ -62,6 +62,7 @@ "@capacitor/core": "^8.4.2", "@capacitor/ios": "^8.4.2", "@capacitor/keyboard": "^8.0.1", + "@capacitor/push-notifications": "^8.0.1", "@capacitor/splash-screen": "^8.0.1", "@capacitor/status-bar": "^8.0.1", "@gitcommit90/rerouted": "https://github.com/gitcommit90/rerouted/releases/download/v0.5.7/ReRouted-0.5.7-linux-node.tgz", diff --git a/public/index.html b/public/index.html index b7d5a91..4373f30 100644 --- a/public/index.html +++ b/public/index.html @@ -29,11 +29,11 @@ document.querySelectorAll('meta[name="theme-color"]').forEach(function (m) { m.setAttribute("content", color); }); })(); - +
- + diff --git a/scripts/run-test-suite.mjs b/scripts/run-test-suite.mjs index f6219b1..7648a33 100644 --- a/scripts/run-test-suite.mjs +++ b/scripts/run-test-suite.mjs @@ -47,7 +47,7 @@ const suites = [ "test/cloudflare-worker.mjs", "test/connectors.mjs", "test/chatgpt-image.mjs", "test/autonomy-platform.mjs", "test/feedback.mjs", "test/feedback-browser.mjs", "test/gmail.mjs", "test/photon.mjs", "test/site.mjs", "test/release-license.mjs", "test/release-governance.mjs", "test/channel-surfaces.mjs", "test/workspace-interactions.mjs", "test/sweep-fleet-telemetry.mjs", "test/sweep-server-integration.mjs", "test/thread-followup-chat.mjs", - "test/notifications.mjs", "test/terminal-reconnect-contract.mjs", "test/terminal-reconnect-browser.mjs", "test/mobile.mjs", "test/web-research.mjs", "test/workflows.mjs"], + "test/notifications.mjs", "test/mobile-push.mjs", "test/terminal-reconnect-contract.mjs", "test/terminal-reconnect-browser.mjs", "test/mobile.mjs", "test/web-research.mjs", "test/workflows.mjs"], ]; let status = 0; diff --git a/src/client/app.ts b/src/client/app.ts index f9c765d..ae035b0 100644 --- a/src/client/app.ts +++ b/src/client/app.ts @@ -1,7 +1,7 @@ import { api, downloadAuthenticatedFile, initializeApiTransport, openAuthenticatedFile, uploadFile, connectEvents, getToken, setToken, clearToken, workspacePhotoSrc, type User, type Channel, type Message, type Bot, type Computer, type Provider, type Workspace, type ModelPolicy, type AgentProgress, type AgentQuestions, type ThreadFollowup, type ThreadUsage, type RoutingModel } from "./api.ts"; import { h, clear, add, md, color, initials, timeLabel, dayLabel, sameDay, icon, helmMark, type ChannelLink } from "./dom.ts"; import { openSettings, finishOpenRouterOAuth, refreshOpenSkillsSettings } from "./settings.ts"; -import { hydrateNotificationPreferences, playNotification } from "./notifications.ts"; +import { disableNativeNotifications, hydrateNotificationPreferences, playNotification, restoreNativeNotifications, setNativeNotificationNavigation } from "./notifications.ts"; import { openRoutingPopover, pushRoutingActivity } from "./routing.ts"; import { openOnboarding } from "./onboarding.ts"; import { defaultTerminalComputer, openTerminals, refitChannelTerminals, getTerminalChrome } from "./term.ts"; @@ -217,6 +217,8 @@ async function enterWorkspace(preferredChannelId?: number): Promise { if (!S.channelId && main) S.channelId = main.id; if (S.channelId) await openChannel(S.channelId, route.view, route.threadRootId, true); else renderApp(); + setNativeNotificationNavigation((channelId, rootMessageId) => { void openChannel(channelId, "chat", rootMessageId, true); }); + void restoreNativeNotifications(); if (!S.me.tour_complete && sessionStorage.getItem("1helm.justOnboarded") === "1") { sessionStorage.removeItem("1helm.justOnboarded"); void openWelcomeTour(); @@ -1167,6 +1169,7 @@ function openProfile(anchor: HTMLElement): void { if (isNativeMobile()) pop.append(h("section", { class: "flex items-center justify-between gap-3 border-t border-line pt-3" }, h("div", { class: "min-w-0" }, h("p", { class: "text-xs font-semibold text-fg" }, "Connected server"), h("p", { class: "truncate text-[11px] text-muted" }, getServerOrigin())), h("button", { class: "btn-subtle min-h-9 shrink-0 px-3 text-xs", onclick: async () => { + await disableNativeNotifications().catch(() => undefined); await api("/api/auth/logout", { method: "POST" }).catch(() => undefined); await forgetMobileServer(); await clearToken(); @@ -1539,7 +1542,7 @@ function renderHeader(): void { const channel = S.channels.find((item) => item.id === S.channelId); const agent = channel?.agent; clear(el); - el.className = "app-topbar flex min-h-12 items-center justify-between gap-2 border-b border-line bg-surface px-2 py-1.5 sm:gap-3 sm:px-4 sm:py-2.5"; + el.className = "app-topbar flex min-h-12 flex-col items-stretch justify-between gap-0 border-b border-line bg-surface px-2 py-1.5 sm:flex-row sm:items-center sm:gap-3 sm:px-4 sm:py-2.5"; const statusTone = agent?.status === "working" ? "bg-amber-400 animate-pulse" : agent?.status === "waiting" ? "bg-blue-400" : agent?.status === "archived" || agent?.status === "paused" ? "bg-faint" : "bg-ok"; const callSkipper = (): void => { S.view = "chat"; renderApp(); @@ -1566,12 +1569,12 @@ function renderHeader(): void { } }; add(el, - h("div", { class: "flex min-w-0 flex-1 items-center gap-2 overflow-hidden" }, + h("div", { class: "flex min-w-0 flex-1 items-center gap-2 overflow-hidden", dataset: { mobileHeaderTitle: "" } }, mobileMenuButton(), - h("div", { class: "flex min-w-0 max-w-[46%] shrink items-center gap-1 text-[15px] font-semibold leading-5 tracking-[-0.01em] text-fg sm:max-w-none sm:text-[16px]" }, channel?.kind === "dm" ? null : h("span", { class: "shrink-0 font-normal text-faint" }, "#"), h("span", { class: "min-w-0 truncate", title: channel?.name || "" }, channel?.name || "")), + h("div", { class: "flex min-w-0 flex-1 items-center gap-1 text-[15px] font-semibold leading-5 tracking-[-0.01em] text-fg sm:flex-initial sm:text-[16px]" }, channel?.kind === "dm" ? null : h("span", { class: "shrink-0 font-normal text-faint" }, "#"), h("span", { class: "min-w-0 truncate", title: channel?.name || "" }, channel?.name || "")), channel?.purpose ? h("span", { class: "hidden min-w-0 max-w-[min(38vw,32rem)] truncate border-l border-line pl-2.5 text-[12px] leading-4 text-muted 2xl:block", title: channel.purpose }, channel.purpose) : null, channel?.status === "archived" ? h("span", { class: "chip shrink-0" }, "Paused") : null), - h("div", { class: "flex max-w-[52%] shrink-0 items-center justify-end gap-1.5 sm:max-w-none sm:gap-2" }, + h("div", { class: "flex min-w-0 shrink-0 items-center justify-end gap-1 sm:max-w-none sm:gap-2", dataset: { mobileHeaderActions: "" } }, channel ? h("button", { class: `grid h-11 w-11 place-items-center rounded-md border border-transparent transition hover:border-line hover:bg-hover sm:h-9 sm:w-9 ${channel.favorite ? "text-accent" : "text-muted hover:text-fg"}`, title: channel.favorite ? "Remove from Favorites" : "Add to Favorites", "aria-label": channel.favorite ? "Remove current channel from Favorites" : "Favorite current channel", @@ -1581,8 +1584,8 @@ function renderHeader(): void { class: "grid h-11 w-11 place-items-center rounded-md border border-transparent text-muted transition hover:border-line hover:bg-hover hover:text-fg sm:h-9 sm:w-9", title: "Open live 1Helm Router activity", "aria-label": "Open 1Helm Router", dataset: { routingHeader: "" }, onclick: (event: MouseEvent) => { void openRoutingPopover(event).catch((error) => appAlert((error as Error).message)); }, }, routerSymbolIcon()), - agent ? h("button", { class: "flex min-h-11 max-w-full items-center gap-1.5 rounded-md border border-transparent px-1.5 py-1 font-mono text-[10px] text-muted transition hover:border-line hover:bg-hover hover:text-fg sm:min-h-0 sm:max-w-[14rem] sm:gap-2 sm:px-2 sm:text-[11px]", title: `${agent.display_name || agent.name} · ${agent.status} · ${agent.provider_kind === "routing" ? "Model fabric" : agent.provider_name || "no provider"} · ${agent.model || "no model"}`, onclick: channel?.can_manage ? () => navigateChannelView("settings") : undefined }, - h("span", { class: `h-1.5 w-1.5 shrink-0 rounded-full ${statusTone}` }), h("span", { class: "min-w-0 truncate" }, "@" + agent.name), + agent ? h("button", { class: "flex h-11 w-11 shrink-0 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-1 font-mono text-[10px] text-muted transition hover:border-line hover:bg-hover hover:text-fg sm:h-auto sm:min-h-0 sm:w-auto sm:max-w-[14rem] sm:gap-2 sm:px-2 sm:text-[11px]", title: `${agent.display_name || agent.name} · ${agent.status} · ${agent.provider_kind === "routing" ? "Model fabric" : agent.provider_name || "no provider"} · ${agent.model || "no model"}`, "aria-label": `Open ${agent.display_name || agent.name} settings · ${agent.status}`, onclick: channel?.can_manage ? () => navigateChannelView("settings") : undefined }, + h("span", { class: `h-1.5 w-1.5 shrink-0 rounded-full ${statusTone}` }), h("span", { class: "hidden min-w-0 truncate sm:inline" }, "@" + agent.name), h("span", { class: "hidden max-w-28 truncate text-faint 2xl:inline" }, agent.model || "no model")) : null, terminalsEnabled ? h("button", { class: `grid h-11 w-11 place-items-center rounded-md border border-transparent text-muted transition hover:border-line hover:bg-hover hover:text-fg sm:h-9 sm:w-9 ${S.terminalOpen || S.view === "terminal" ? "border-line bg-hover text-fg" : ""}`, @@ -1592,7 +1595,7 @@ function renderHeader(): void { class: `grid h-11 w-11 place-items-center rounded-md border border-transparent text-muted transition hover:border-line hover:bg-hover hover:text-fg sm:h-9 sm:w-9 ${S.notesOpen || S.view === "notes" ? "border-line bg-hover text-fg" : ""}`, title: "Notes", "aria-label": "Open channel notes", dataset: { notesHeader: "" }, onclick: openNotesFromHeader, }, icon("file", 18)) : null, - channel?.kind === "channel" ? h("button", { class: "btn-subtle min-h-11 px-2.5 text-xs sm:min-h-0 sm:px-3", title: "Call Skipper", onclick: callSkipper }, helmMark(14), h("span", { class: "hidden xl:inline" }, "Call Skipper")) : null)); + channel?.kind === "channel" ? h("button", { class: "btn-subtle min-h-11 w-11 px-0 text-xs sm:min-h-0 sm:w-auto sm:px-3", title: "Call Skipper", "aria-label": "Call Skipper", onclick: callSkipper }, helmMark(14), h("span", { class: "hidden xl:inline" }, "Call Skipper")) : null)); } function starIcon(filled: boolean): SVGElement { diff --git a/src/client/channel.ts b/src/client/channel.ts index 03f4efe..e6b01fc 100644 --- a/src/client/channel.ts +++ b/src/client/channel.ts @@ -629,7 +629,7 @@ export function renderNotes(container: HTMLElement, channelId: number, onClose?: h("button", { class: "note-format-button", type: "button", title: "Link", onclick: () => insertFormatting("[", "](https://)", "link text") }, "Link")); clear(container); const node = h("section", { class: "flex h-full min-h-[32rem] flex-col bg-surface", dataset: { notesSurface: String(channelId) } }, - h("div", { class: "flex items-start gap-3 border-b border-line px-4 py-3" }, + h("div", { class: "mobile-surface-topbar flex items-start gap-3 border-b border-line bg-surface px-4 py-3" }, h("div", { class: "min-w-0 flex-1" }, h("h2", { class: "font-display text-xl text-fg" }, "Notes"), h("p", { class: "mt-0.5 text-xs text-muted" }, "Fast Markdown notes shared with this channel's /workspace/notes.")), newButton, closeButton), h("div", { class: "grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[17rem_minmax(0,1fr)]" }, diff --git a/src/client/mobile.ts b/src/client/mobile.ts index a829aa7..1aa6610 100644 --- a/src/client/mobile.ts +++ b/src/client/mobile.ts @@ -17,6 +17,17 @@ const native = Capacitor.isNativePlatform(); let serverOrigin = ""; let launchFinishQueued = false; +/** Keep the reserved native status surface visually joined to the app header. */ +export async function syncNativeStatusSurface(): Promise { + if (!native) return; + const surface = getComputedStyle(document.documentElement).getPropertyValue("--c-surface").trim() || "#111318"; + const statusStyle = document.documentElement.classList.contains("light") ? Style.Light : Style.Dark; + await Promise.allSettled([ + StatusBar.setBackgroundColor({ color: surface }), + StatusBar.setStyle({ style: statusStyle }), + ]); +} + export const isNativeMobile = (): boolean => native; export const mobilePlatform = (): string => native ? Capacitor.getPlatform() : "web"; export const getServerOrigin = (): string => serverOrigin; @@ -96,10 +107,11 @@ export async function initializeMobileRuntime(): Promise { } await Promise.allSettled([ - StatusBar.setStyle({ style: Style.Default }), StatusBar.setOverlaysWebView({ overlay: platform !== "ios" }), Keyboard.setResizeMode({ mode: KeyboardResize.Native }), ]); + await syncNativeStatusSurface(); + window.addEventListener("themechange", () => { void syncNativeStatusSurface(); }); await App.addListener("appUrlOpen", ({ url }) => { const code = nativeCallbackCode(url); diff --git a/src/client/notifications.ts b/src/client/notifications.ts index aa76500..451191f 100644 --- a/src/client/notifications.ts +++ b/src/client/notifications.ts @@ -1,5 +1,7 @@ import { api } from "./api.ts"; import { beep, type NotificationSound } from "./dom.ts"; +import { getServerOrigin, isNativeMobile, mobilePlatform } from "./mobile.ts"; +import { PushNotifications, type PermissionStatus } from "@capacitor/push-notifications"; export const NOTIFICATION_SOUNDS: ReadonlyArray<{ value: NotificationSound; label: string }> = [ { value: "helm", label: "Helm chirp" }, @@ -16,6 +18,16 @@ type NotificationPreferences = { const sounds = new Set(NOTIFICATION_SOUNDS.map((item) => item.value)); let preferences: NotificationPreferences = { globalMuted: false, channels: {} }; +let nativePermission: PermissionStatus["receive"] | "unavailable" = isNativeMobile() ? "prompt" : "unavailable"; +let nativeRegistrationError = ""; +let nativeListenersReady = false; +let nativeNavigationHandler: ((channelId: number, rootMessageId: number | null) => void) | null = null; +let nativeRegistrationAttempt: { resolve: () => void; reject: (error: Error) => void } | null = null; +let nativeRegistrationPromise: Promise | null = null; +let nativeDeviceToken = ""; + +const nativePreferenceKey = (): string => `1helm.mobile.push.enabled:${getServerOrigin()}`; +const nativeNotificationsEnabled = (): boolean => localStorage.getItem(nativePreferenceKey()) === "1"; function soundValue(value: unknown): NotificationSound { return sounds.has(value as NotificationSound) ? value as NotificationSound : "helm"; @@ -78,3 +90,120 @@ export function playNotification(channelId: number, kind: "msg" | "mention" = "m export function previewNotification(sound: NotificationSound): void { beep("msg", soundValue(sound)); } + +function pushTarget(data: unknown): { channelId: number; rootMessageId: number | null } | null { + if (!data || typeof data !== "object") return null; + const row = data as Record; + const channelId = Number(row.channelId || row.channel_id || 0); + const rootMessageId = Number(row.rootMessageId || row.root_message_id || 0) || null; + return Number.isSafeInteger(channelId) && channelId > 0 ? { channelId, rootMessageId } : null; +} + +export function setNativeNotificationNavigation(handler: (channelId: number, rootMessageId: number | null) => void): void { + nativeNavigationHandler = handler; +} + +async function installNativeListeners(): Promise { + if (!isNativeMobile() || nativeListenersReady) return; + nativeListenersReady = true; + await PushNotifications.addListener("registration", ({ value }) => { + nativeDeviceToken = value; + nativeRegistrationError = ""; + void api("/api/mobile/push", { body: { platform: mobilePlatform(), token: value } }).then(() => { + nativeRegistrationAttempt?.resolve(); + }).catch((error) => { + const failure = error instanceof Error ? error : new Error(String(error)); + nativeRegistrationError = failure.message; + nativeRegistrationAttempt?.reject(failure); + }); + }); + await PushNotifications.addListener("registrationError", ({ error }) => { + nativeRegistrationError = String(error || "Registration failed."); + nativeRegistrationAttempt?.reject(new Error(nativeRegistrationError)); + }); + await PushNotifications.addListener("pushNotificationActionPerformed", ({ notification }) => { + const target = pushTarget(notification.data); + if (target) nativeNavigationHandler?.(target.channelId, target.rootMessageId); + }); +} + +async function registerNativeDevice(): Promise { + if (nativeRegistrationPromise) return nativeRegistrationPromise; + nativeRegistrationPromise = (async () => { + let timer: ReturnType | null = null; + const completion = new Promise((resolve, reject) => { + nativeRegistrationAttempt = { resolve, reject }; + timer = setTimeout(() => reject(new Error("Notification registration timed out. Please try again.")), 20_000); + }); + try { + await PushNotifications.register(); + await completion; + } finally { + if (timer) clearTimeout(timer); + nativeRegistrationAttempt = null; + } + })(); + try { await nativeRegistrationPromise; } + finally { nativeRegistrationPromise = null; } +} + +export type NativeNotificationState = { + available: boolean; + permission: PermissionStatus["receive"] | "unavailable"; + registered: boolean; + platforms: string[]; + error: string; +}; + +export async function nativeNotificationState(): Promise { + if (!isNativeMobile() || mobilePlatform() !== "ios") return { available: false, permission: "unavailable", registered: false, platforms: [], error: "" }; + await installNativeListeners(); + nativePermission = (await PushNotifications.checkPermissions()).receive; + if (!nativeNotificationsEnabled() || nativePermission !== "granted") return { available: true, permission: nativePermission, registered: false, platforms: [], error: nativeRegistrationError }; + if (!nativeDeviceToken) { + try { await registerNativeDevice(); } + catch (error) { nativeRegistrationError = error instanceof Error ? error.message : String(error); } + } + if (!nativeDeviceToken) return { available: true, permission: nativePermission, registered: false, platforms: [], error: nativeRegistrationError }; + const server = await api<{ registered: boolean; platforms: string[] }>("/api/mobile/push/status", { + body: { platform: mobilePlatform(), token: nativeDeviceToken }, + }).catch(() => ({ registered: false, platforms: [] })); + return { available: true, permission: nativePermission, registered: server.registered, platforms: server.platforms || [], error: nativeRegistrationError }; +} + +/** Request OS permission only from an explicit user action, then bind this device to the signed-in 1Helm profile. */ +export async function enableNativeNotifications(): Promise { + if (!isNativeMobile() || mobilePlatform() !== "ios") return nativeNotificationState(); + await installNativeListeners(); + let permission = await PushNotifications.checkPermissions(); + if (permission.receive === "prompt" || permission.receive === "prompt-with-rationale") permission = await PushNotifications.requestPermissions(); + nativePermission = permission.receive; + if (permission.receive !== "granted") return nativeNotificationState(); + nativeRegistrationError = ""; + try { await registerNativeDevice(); } + catch (error) { nativeRegistrationError = error instanceof Error ? error.message : String(error); } + if (nativeDeviceToken && !nativeRegistrationError) localStorage.setItem(nativePreferenceKey(), "1"); + return nativeNotificationState(); +} + +export async function disableNativeNotifications(): Promise { + if (!isNativeMobile() || mobilePlatform() !== "ios") return nativeNotificationState(); + if (!nativeDeviceToken && nativeNotificationsEnabled() && nativePermission === "granted") await registerNativeDevice().catch(() => undefined); + if (nativeDeviceToken) await api("/api/mobile/push", { method: "DELETE", body: { platform: mobilePlatform(), token: nativeDeviceToken } }).catch(() => undefined); + await PushNotifications.unregister().catch(() => undefined); + nativeDeviceToken = ""; + localStorage.removeItem(nativePreferenceKey()); + return nativeNotificationState(); +} + +/** Re-register an already-authorized app after sign-in without prompting. */ +export async function restoreNativeNotifications(): Promise { + if (!isNativeMobile() || mobilePlatform() !== "ios" || !nativeNotificationsEnabled()) return; + await installNativeListeners(); + const permission = await PushNotifications.checkPermissions(); + nativePermission = permission.receive; + if (permission.receive === "granted") { + try { await registerNativeDevice(); } + catch (error) { nativeRegistrationError = error instanceof Error ? error.message : String(error); } + } +} diff --git a/src/client/settings.ts b/src/client/settings.ts index 6bfc91e..b8b6041 100644 --- a/src/client/settings.ts +++ b/src/client/settings.ts @@ -2,7 +2,7 @@ import { api, getToken, openAuthenticatedFile, workspacePhotoSrc, type AccessReq import { h, clear, add, icon } from "./dom.ts"; import { S, avatar, reloadProviders, renderApp, appAlert, appConfirm, appPrompt } from "./app.ts"; import { connectRoutingOauth, routingPanel } from "./routing.ts"; -import { globalNotificationsMuted, setGlobalNotificationsMuted } from "./notifications.ts"; +import { disableNativeNotifications, enableNativeNotifications, globalNotificationsMuted, nativeNotificationState, setGlobalNotificationsMuted } from "./notifications.ts"; import { apiUrl, isNativeMobile, openExternalUrl } from "./mobile.ts"; // ============================================================ OpenRouter OAuth (PKCE) @@ -140,7 +140,28 @@ function notificationsPanel(): HTMLElement { status.textContent = (error as Error).message; } finally { unreadFirst.disabled = false; } }; + const nativeCard = h("section", { class: "card space-y-3 p-4", dataset: { nativeNotifications: "" } }, + h("div", {}, h("h3", { class: "font-semibold text-fg" }, "Phone notifications"), h("p", { class: "mt-1 text-sm leading-6 text-muted" }, "Receive channel and resident-agent updates when 1Helm is closed or in the background.")), + h("p", { class: "text-sm text-muted" }, "Checking this device…")); + const drawNative = async (): Promise => { + const state = await nativeNotificationState(); + if (!state.available) { nativeCard.remove(); return; } + const enabled = state.permission === "granted" && state.registered; + const action = h("button", { class: enabled ? "btn-subtle text-sm" : "btn-primary text-sm", type: "button" }, enabled ? "Turn off on this phone" : state.permission === "denied" ? "Check again" : "Turn on notifications") as HTMLButtonElement; + const detail = h("p", { class: `text-sm ${state.error ? "text-danger" : "text-muted"}` }, state.error || (enabled ? "This phone is registered for 1Helm notifications." : state.permission === "denied" ? "Notifications are blocked in iOS Settings. Open Settings → Notifications → 1Helm to allow them." : "1Helm will ask for permission once, after you choose Turn on.")); + action.onclick = async () => { + action.disabled = true; + if (enabled) await disableNativeNotifications(); + else if (state.permission !== "denied") await enableNativeNotifications(); + await drawNative(); + }; + nativeCard.replaceChildren( + h("div", {}, h("h3", { class: "font-semibold text-fg" }, "Phone notifications"), h("p", { class: "mt-1 text-sm leading-6 text-muted" }, "Receive channel and resident-agent updates when 1Helm is closed or in the background.")), + h("div", { class: "flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between" }, detail, action)); + }; + void drawNative(); return h("div", { class: "space-y-4" }, + nativeCard, h("section", { class: "card space-y-3 p-4" }, h("div", {}, h("h3", { class: "font-semibold text-fg" }, "Global sound"), h("p", { class: "mt-1 text-sm leading-6 text-muted" }, "This preference belongs only to your 1Helm account and follows you across signed-in devices.")), h("label", { class: "flex items-start gap-3 rounded-lg border border-line bg-panel p-3" }, muted, h("span", {}, h("span", { class: "block text-sm font-semibold text-fg" }, "Mute all notification sounds"), h("span", { class: "mt-1 block text-xs leading-5 text-muted" }, "Visual unread badges and agent activity remain available."))), diff --git a/src/client/styles.css b/src/client/styles.css index 50981af..2cebf8f 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -892,11 +892,21 @@ html, body { height: 100%; width: 100%; overflow: hidden; overscroll-behavior: n } @media (max-width: 767px) { - .app-topbar, .thread-topbar { + .app-topbar, .thread-topbar, .mobile-surface-topbar { padding-top: max(.375rem, env(safe-area-inset-top)); padding-left: max(.5rem, env(safe-area-inset-left)); padding-right: max(.5rem, env(safe-area-inset-right)); } + [data-mobile-header-actions] { + width: 100%; + min-height: 44px; + overflow: hidden; + } + [data-mobile-header-actions] > button { flex-shrink: 0; } + [data-mobile-header-actions] > button:nth-child(3) { + min-width: 0; + flex: 0 1 auto; + } .composer-wrap { padding-right: max(.75rem, env(safe-area-inset-right)); padding-bottom: max(.75rem, env(safe-area-inset-bottom)); diff --git a/src/server/channel-computers.ts b/src/server/channel-computers.ts index 5a05810..605f9fa 100644 --- a/src/server/channel-computers.ts +++ b/src/server/channel-computers.ts @@ -67,7 +67,7 @@ const APPLE_RUNTIME_VERSION = "1.1.0"; export const APPLE_RUNTIME_PACKAGE = `container-${APPLE_RUNTIME_VERSION}-installer-signed.pkg`; export const APPLE_RUNTIME_URL = `https://github.com/apple/container/releases/download/${APPLE_RUNTIME_VERSION}/${APPLE_RUNTIME_PACKAGE}`; export const APPLE_RUNTIME_SHA256 = "0ca1c42a2269c2557efb1d82b1b38ac553e6a3a3da1b1179c439bcee1e7d6714"; -export const DEFAULT_CHANNEL_IMAGE = process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.15"; +export const DEFAULT_CHANNEL_IMAGE = process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.16"; const CONTAINER_CANDIDATES = [process.env.HELM_CONTAINER_CLI, "/usr/local/bin/container", "/opt/homebrew/bin/container", "container"].filter(Boolean) as string[]; const LXC_RUNTIME_VERSION = "1helm-lxc-runtime-v1"; const LXC_HELPER_CANDIDATES = [ diff --git a/src/server/collaboration.ts b/src/server/collaboration.ts index 309cc7d..d0dec34 100644 --- a/src/server/collaboration.ts +++ b/src/server/collaboration.ts @@ -9,7 +9,7 @@ const DIR = join(DATA_DIR, "collaboration"); const SECRET_PATH = join(DIR, "management-secret"); const CONNECTOR_ID = "workspace"; -function managementSecret(): string { +export function installationManagementSecret(): string { mkdirSync(DIR, { recursive: true }); if (existsSync(SECRET_PATH)) return readFileSync(SECRET_PATH, "utf8").trim(); const secret = randomBytes(32).toString("hex"); @@ -61,7 +61,7 @@ export async function claimWorkspace(slug: string, workspaceName: string, port: slug: slug.trim().toLowerCase(), installation_id: String(workspace.installation_id), workspace_name: workspaceName, - management_secret: managementSecret(), + management_secret: installationManagementSecret(), }), }); if (result.connector) saveTunnelConnector(CONNECTOR_ID, result.connector, [result.workspace.hostname], port); @@ -83,7 +83,7 @@ export async function setCollaborationEnabled(enabled: boolean): Promise { } export function broadcastToChannel(channelId: number, payload: unknown): void { + queueMobilePush(channelId, payload); + void drainMobilePush(); const aud = audience(channelId); const data = JSON.stringify(payload); for (const c of clients) { diff --git a/src/server/index.ts b/src/server/index.ts index 617ef54..ce10d13 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -10,6 +10,7 @@ import { createMessage, deleteMessage, serializeMessage, setModelPref, setModelP import { computerRowView, fetchModels } from "./computer.ts"; import { cancelChannelTurns, resumeQueuedAgentTurns, runBot, stopThreadTurn } from "./bots.ts"; import { register, unregister, broadcastToChannel, broadcastAll, broadcastAdmins, sendToUsers } from "./events.ts"; +import { mobilePushStatus, registerMobilePush, startMobilePushLoop, unregisterMobilePush } from "./mobile-push.ts"; import { openChannelSession, openSession, attachClient, listSessions, closeChannelSessions, closeSession } from "./terms.ts"; import { startAgent } from "./agent.ts"; import { @@ -821,6 +822,23 @@ const server = createServer(async (req, res) => { } return json(res, 200, { state }); } + if (p === "/api/mobile/push" && m === "GET") return json(res, 200, mobilePushStatus(Number(user.id))); + if (p === "/api/mobile/push/status" && m === "POST") { + const b = await jbody(req); + return json(res, 200, mobilePushStatus(Number(user.id), b.platform, b.token)); + } + if (p === "/api/mobile/push" && m === "POST") { + const b = await jbody(req); + try { return json(res, 200, { registration: await registerMobilePush(Number(user.id), b.platform, b.token) }); } + catch (error) { return json(res, 400, { error: (error as Error).message }); } + } + if (p === "/api/mobile/push" && m === "DELETE") { + const b = await jbody(req); + try { + await unregisterMobilePush(Number(user.id), b.platform, b.token); + return json(res, 200, { ok: true }); + } catch (error) { return json(res, 400, { error: (error as Error).message }); } + } if (p === "/api/me/ui-state" && (m === "PUT" || m === "PATCH")) { const b = await jbody(req); const entries: { key: string; value: unknown }[] = []; @@ -2057,6 +2075,7 @@ server.on("upgrade", (req, socket: Socket, head) => { // ---- embedded local computer (open-terminal compatible) ---- async function bootstrap(): Promise { + startMobilePushLoop(); registerPhotonDispatcher((bot, channelId, triggerId, threadRootId) => runBot(bot, channelId, triggerId, threadRootId, true)); registerWorkflowDispatcher((bot, channelId, triggerId, threadRootId) => runBot(bot, channelId, triggerId, threadRootId, true)); reactivateComputersAfterPreparedRemoval(); diff --git a/src/server/mobile-push.ts b/src/server/mobile-push.ts new file mode 100644 index 0000000..20aa08b --- /dev/null +++ b/src/server/mobile-push.ts @@ -0,0 +1,194 @@ +import { createHash } from "node:crypto"; +import { q, q1, run, now, type Row } from "./db.ts"; +import { installationManagementSecret } from "./collaboration.ts"; +import { installedAppVersion } from "./updates.ts"; + +const RELAY = String(process.env.HELM_PUSH_RELAY_URL || "https://provision.1helm.com/v1/push").replace(/\/+$/, ""); +const APP_ROOT = String(process.env.HELM_APP_ROOT || process.cwd()); +let drainTimer: NodeJS.Timeout | null = null; +let draining = false; + +type Platform = "ios" | "android"; +type PushPayload = { + title: string; + body: string; + channelId: number; + messageId: number; + rootMessageId: number | null; + sound: boolean; +}; + +const workspaceIdentity = (): { installationId: string; secret: string } => { + const installationId = String(q1("SELECT installation_id FROM workspace WHERE id=1")?.installation_id || ""); + if (!/^[a-f0-9]{16}$/.test(installationId)) throw new Error("The 1Helm installation identity is unavailable."); + return { installationId, secret: installationManagementSecret() }; +}; + +const relayRecipientId = (secret: string, userId: number): string => createHash("sha256").update(`mobile-push:${secret}:${userId}`).digest("hex").slice(0, 32); + +async function relay(path: string, init: RequestInit): Promise> { + const response = await fetch(`${RELAY}${path}`, { ...init, signal: AbortSignal.timeout(20_000) }); + const payload = await response.json().catch(() => ({})) as Record; + if (!response.ok) throw new Error(String(payload.error || `Push relay returned HTTP ${response.status}.`)); + return payload; +} + +async function ensureRelayInstallation(): Promise<{ installationId: string; secret: string }> { + const identity = workspaceIdentity(); + await relay("/installations", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ installation_id: identity.installationId, management_secret: identity.secret }), + }); + return identity; +} + +export async function registerMobilePush(userId: number, platformInput: unknown, tokenInput: unknown): Promise { + const platform = String(platformInput || "") as Platform; + const token = String(tokenInput || "").trim(); + if (!(["ios", "android"] as string[]).includes(platform)) throw new Error("Unsupported mobile notification platform."); + if (!/^[A-Za-z0-9:_-]{32,4096}$/.test(token)) throw new Error("The mobile notification token is invalid."); + const identity = await ensureRelayInstallation(); + await relay("/devices", { + method: "POST", + headers: { authorization: `Bearer ${identity.secret}`, "content-type": "application/json" }, + body: JSON.stringify({ installation_id: identity.installationId, recipient_id: relayRecipientId(identity.secret, userId), platform, token }), + }); + const timestamp = now(); + run(`INSERT INTO mobile_push_registrations (user_id,platform,token,created,updated) VALUES (?,?,?,?,?) + ON CONFLICT(platform,token) DO UPDATE SET user_id=excluded.user_id,updated=excluded.updated`, userId, platform, token, timestamp, timestamp); + return q1("SELECT platform,created,updated FROM mobile_push_registrations WHERE platform=? AND token=?", platform, token)!; +} + +export async function unregisterMobilePush(userId: number, platformInput?: unknown, tokenInput?: unknown): Promise { + const platform = String(platformInput || ""); + const token = String(tokenInput || "").trim(); + if (platform && !(["ios", "android"] as string[]).includes(platform)) throw new Error("Unsupported mobile notification platform."); + if (token && !/^[A-Za-z0-9:_-]{32,4096}$/.test(token)) throw new Error("The mobile notification token is invalid."); + const rows = platform && token + ? q("SELECT platform,token FROM mobile_push_registrations WHERE user_id=? AND platform=? AND token=?", userId, platform, token) + : platform + ? q("SELECT platform,token FROM mobile_push_registrations WHERE user_id=? AND platform=?", userId, platform) + : q("SELECT platform,token FROM mobile_push_registrations WHERE user_id=?", userId); + if (!rows.length) return; + const identity = await ensureRelayInstallation().catch(() => null); + if (identity) await Promise.allSettled(rows.map((row) => relay("/devices", { + method: "DELETE", + headers: { authorization: `Bearer ${identity.secret}`, "content-type": "application/json" }, + body: JSON.stringify({ installation_id: identity.installationId, recipient_id: relayRecipientId(identity.secret, userId), platform: row.platform, token: row.token }), + }))); + if (platform && token) run("DELETE FROM mobile_push_registrations WHERE user_id=? AND platform=? AND token=?", userId, platform, token); + else if (platform) run("DELETE FROM mobile_push_registrations WHERE user_id=? AND platform=?", userId, platform); + else run("DELETE FROM mobile_push_registrations WHERE user_id=?", userId); +} + +export function mobilePushStatus(userId: number, platformInput?: unknown, tokenInput?: unknown): { registered: boolean; platforms: string[] } { + const platform = String(platformInput || ""); + const token = String(tokenInput || "").trim(); + if (platform && !(["ios", "android"] as string[]).includes(platform)) return { registered: false, platforms: [] }; + if (token && !/^[A-Za-z0-9:_-]{32,4096}$/.test(token)) return { registered: false, platforms: [] }; + const rows = platform && token + ? q("SELECT platform FROM mobile_push_registrations WHERE user_id=? AND platform=? AND token=?", userId, platform, token) + : q("SELECT DISTINCT platform FROM mobile_push_registrations WHERE user_id=? ORDER BY platform", userId); + const platforms = [...new Set(rows.map((row) => String(row.platform)))]; + return { registered: platforms.length > 0, platforms }; +} + +function messageSettled(message: Row): boolean { + const author = message.author && typeof message.author === "object" ? message.author as Row : {}; + if (author.kind === "user" || author.kind === "system") return true; + const body = String(message.body || "").trim(); + if (!body || body === "_Working…_") return false; + return !(Array.isArray(message.progress) && message.progress.some((item: Row) => item.status === "running")); +} + +function notificationPreferences(userId: number, channelId: number): { muted: boolean; sound: boolean } { + const row = q1("SELECT value FROM user_ui_state WHERE user_id=? AND key='notification_preferences'", userId); + if (!row) return { muted: false, sound: true }; + try { + const preferences = JSON.parse(String(row.value || "{}")) as { globalMuted?: unknown; channels?: Record }; + return { + muted: preferences.channels?.[String(channelId)]?.muted === true, + sound: preferences.globalMuted !== true, + }; + } catch { return { muted: false, sound: true }; } +} + +function notificationBody(message: Row): string { + const text = String(message.body || "").replace(/\s+/g, " ").trim(); + if (text) return text.length > 220 ? `${text.slice(0, 217)}…` : text; + const attachments = Array.isArray(message.attachments) ? message.attachments.length : 0; + return attachments ? `Shared ${attachments === 1 ? "an attachment" : `${attachments} attachments`}.` : "New activity"; +} + +/** Queue exactly one durable notification per recipient and settled message. */ +export function queueMobilePush(channelId: number, event: unknown): void { + const payload = event && typeof event === "object" ? event as { type?: unknown; message?: Row } : {}; + if (!["message", "message_update"].includes(String(payload.type || "")) || !payload.message || !messageSettled(payload.message)) return; + const message = payload.message; + const author = message.author && typeof message.author === "object" ? message.author as Row : {}; + const messageId = Number(message.id || 0); + if (!messageId) return; + const authorUserId = author.kind === "user" ? Number(author.id || 0) : 0; + const channel = q1("SELECT name,kind FROM channels WHERE id=?", channelId); + if (!channel) return; + const title = channel.kind === "dm" ? String(author.name || "1Helm") : `#${String(channel.name || "channel")} · ${String(author.name || "1Helm")}`; + const timestamp = now(); + for (const member of q(`SELECT DISTINCT m.user_id FROM members m + JOIN mobile_push_registrations r ON r.user_id=m.user_id WHERE m.channel_id=?`, channelId)) { + const userId = Number(member.user_id); + if (!userId || userId === authorUserId) continue; + const preference = notificationPreferences(userId, channelId); + if (preference.muted) continue; + const item: PushPayload = { + title, + body: notificationBody(message), + channelId, + messageId, + rootMessageId: message.parent_id == null ? null : Number(message.parent_id), + sound: preference.sound, + }; + run(`INSERT OR IGNORE INTO mobile_push_outbox + (user_id,channel_id,message_id,payload,state,next_attempt,created,updated) VALUES (?,?,?,?,'pending',0,?,?)`, + userId, channelId, messageId, JSON.stringify(item), timestamp, timestamp); + } +} + +export async function drainMobilePush(): Promise { + if (draining) return; + draining = true; + try { + const identity = q1("SELECT 1 FROM mobile_push_registrations LIMIT 1") ? await ensureRelayInstallation() : null; + if (!identity) return; + for (const row of q("SELECT * FROM mobile_push_outbox WHERE state IN ('pending','failed') AND next_attempt<=? AND attempt_count<20 ORDER BY id LIMIT 50", now())) { + const claimed = run("UPDATE mobile_push_outbox SET state='sending',attempt_count=attempt_count+1,updated=? WHERE id=? AND state IN ('pending','failed')", now(), row.id); + if (!claimed.changes) continue; + try { + await relay("/deliveries", { + method: "POST", + headers: { authorization: `Bearer ${identity.secret}`, "content-type": "application/json" }, + body: JSON.stringify({ + installation_id: identity.installationId, + recipient_id: relayRecipientId(identity.secret, Number(row.user_id)), + idempotency_key: `${identity.installationId}:${Number(row.user_id)}:${Number(row.message_id)}`, + app_version: installedAppVersion(APP_ROOT), + ...JSON.parse(String(row.payload || "{}")), + }), + }); + run("UPDATE mobile_push_outbox SET state='delivered',last_error='',updated=? WHERE id=?", now(), row.id); + } catch (error) { + const attempts = Number(row.attempt_count || 0) + 1; + const delay = Math.min(60 * 60_000, Math.max(15_000, 2 ** Math.min(attempts, 8) * 1000)); + run("UPDATE mobile_push_outbox SET state='failed',next_attempt=?,last_error=?,updated=? WHERE id=?", now() + delay, String((error as Error).message).slice(0, 500), now(), row.id); + } + } + } catch { /* relay setup remains retryable without interrupting chat */ } + finally { draining = false; } +} + +export function startMobilePushLoop(): void { + if (drainTimer) return; + void drainMobilePush(); + drainTimer = setInterval(() => { void drainMobilePush(); }, 30_000); + drainTimer.unref(); +} diff --git a/test/brief-regressions-browser.mjs b/test/brief-regressions-browser.mjs index f739b6b..ba88d70 100644 --- a/test/brief-regressions-browser.mjs +++ b/test/brief-regressions-browser.mjs @@ -154,6 +154,41 @@ try { ok(compactHeader.headerHeight < 72 && (compactHeader.purposeWidth === 0 || (compactHeader.purposeHeight <= compactHeader.lineHeight + 1 && compactHeader.overflow === "hidden" && compactHeader.whitespace === "nowrap" && compactHeader.textOverflow === "ellipsis")), `narrow desktop headers keep long channel descriptions to one truncated line instead of one letter per row (${JSON.stringify(compactHeader)})`); + await page.setViewport({ width: 390, height: 844, deviceScaleFactor: 3, isMobile: true, hasTouch: true }); + await page.goto(`${base}/c/${channel.slug}/chat`, { waitUntil: "networkidle0" }); + const phoneHeader = await page.evaluate(() => { + const header = document.getElementById("hdr"); + const title = document.querySelector("[data-mobile-header-title]"); + const actions = document.querySelector("[data-mobile-header-actions]"); + const headerRect = header?.getBoundingClientRect(); + const titleRect = title?.getBoundingClientRect(); + const actionRect = actions?.getBoundingClientRect(); + const controls = [...(actions?.querySelectorAll("button") || [])].map((button) => button.getBoundingClientRect()); + return { + twoRows: Boolean(titleRect && actionRect && titleRect.bottom <= actionRect.top + 1), + rightAligned: Boolean(headerRect && actionRect && Math.abs(actionRect.right - headerRect.right) <= 10), + controlsFit: Boolean(actionRect && controls.every((rect) => rect.left >= actionRect.left - 1 && rect.right <= actionRect.right + 1 && rect.width >= 43 && rect.height >= 43)), + pageFits: document.documentElement.scrollWidth <= document.documentElement.clientWidth, + }; + }); + ok(phoneHeader.twoRows && phoneHeader.rightAligned && phoneHeader.controlsFit && phoneHeader.pageFits, + `phone channel chrome uses a safe title row plus right-aligned 44px actions without horizontal overflow (${JSON.stringify(phoneHeader)})`); + await page.evaluate(() => document.querySelector('[data-notes-header]')?.click()); + await page.waitForFunction(() => [...document.querySelectorAll('[data-notes-surface]')].some((element) => element.getBoundingClientRect().width > 0)); + const phoneNotes = await page.evaluate(() => { + const surface = [...document.querySelectorAll("[data-notes-surface]")].find((element) => element.getBoundingClientRect().width > 0); + const buttons = [...(surface?.querySelectorAll(":scope > div:first-child button") || [])]; + return { + visible: buttons.length >= 2 && buttons.every((button) => { + const rect = button.getBoundingClientRect(); + return rect.top >= 0 && rect.right <= innerWidth && rect.bottom <= innerHeight; + }), + pageFits: document.documentElement.scrollWidth <= document.documentElement.clientWidth, + }; + }); + ok(phoneNotes.visible && phoneNotes.pageFits, "phone Notes keeps New note and Close visible inside the matching surface top bar"); + await page.setViewport({ width: 1280, height: 760, deviceScaleFactor: 1, isMobile: false, hasTouch: false }); + await page.goto(`${base}/c/main/texts`, { waitUntil: "networkidle0" }); await page.waitForSelector(`[data-texts-messages="${textConversation}"]`); ok(await page.$eval("body", (body) => body.innerText.includes("Phone hello") && body.innerText.includes("Phone") && body.innerText.includes("Texts")), "configured Photon exposes a private #main Texts inbox with phone-originated history"); diff --git a/test/channel-computers.mjs b/test/channel-computers.mjs index 40cb457..9b2398d 100644 --- a/test/channel-computers.mjs +++ b/test/channel-computers.mjs @@ -168,7 +168,7 @@ test("Apple channel-computer contract preserves isolation, files, wakes, archive test("runtime digest and packaged image recipe stay pinned", async () => { assert.equal(computers.APPLE_RUNTIME_SHA256, "0ca1c42a2269c2557efb1d82b1b38ac553e6a3a3da1b1179c439bcee1e7d6714"); assert.match(computers.APPLE_RUNTIME_URL, /\/1\.1\.0\/container-1\.1\.0-installer-signed\.pkg$/); - assert.equal(computers.DEFAULT_CHANNEL_IMAGE, "local/1helm-channel-machine:0.0.15"); + assert.equal(computers.DEFAULT_CHANNEL_IMAGE, "local/1helm-channel-machine:0.0.16"); const packaging = await readFile(join(root, "scripts", "package-mac-dmg.cjs"), "utf8"); assert.match(packaging, /container\(\?:\$\|\\\/\)/, "release packaging includes container/ image assets"); const image = await readFile(join(root, "container", "Containerfile"), "utf8"); diff --git a/test/cloudflare-worker.mjs b/test/cloudflare-worker.mjs index 800626c..3a23449 100644 --- a/test/cloudflare-worker.mjs +++ b/test/cloudflare-worker.mjs @@ -53,7 +53,9 @@ const env = (registry) => ({ CLOUDFLARE_RUNTIME_TOKEN: "runtime-test", PROVISION_LIMIT: { limit: async () => ({ success: true }) }, FEEDBACK_LIMIT: { limit: async () => ({ success: true }) }, + PUSH_LIMIT: { limit: async () => ({ success: true }) }, FEEDBACK_ADMIN_TOKEN: "feedback-admin-test", + PUSH_DEVICE_ENCRYPTION_KEY: "push-device-encryption-test", }); const request = (path, init) => new Request(`https://provision.1helm.com${path}`, init); const body = async (response) => ({ status: response.status, json: await response.json() }); @@ -269,3 +271,100 @@ test("feedback collector validates, deduplicates, bounds attachments, and keeps }), env(registry)); assert.equal(dishonestSize.status, 413, "the collector bounds decoded bytes rather than trusting caller-supplied sizes"); }); + +test("push relay authenticates installations, encrypts device tokens, signs APNs JWTs, and makes delivery idempotent", async (t) => { + class PushRegistry extends Registry { + installations = new Map(); + devices = []; + deliveries = new Map(); + prepare(sql) { + if (/push_(?:installations|devices|deliveries)/i.test(sql)) { + const registry = this; + return { + values: [], + bind(...values) { this.values = values; return this; }, + async first() { + if (/FROM push_installations/i.test(sql)) return registry.installations.get(this.values[0]) || null; + if (/FROM push_deliveries/i.test(sql)) return registry.deliveries.get(`${this.values[0]}:${this.values[1]}`) || null; + return null; + }, + async run() { + if (/INSERT INTO push_installations/i.test(sql)) { + const [installationId, hash, created, updated] = this.values; + registry.installations.set(installationId, { installation_id: installationId, management_secret_hash: hash, created_at: created, updated_at: updated }); + } else if (/UPDATE push_installations/i.test(sql)) { + const [updated, installationId] = this.values; + registry.installations.get(installationId).updated_at = updated; + } else if (/INSERT INTO push_devices/i.test(sql)) { + const [installationId, recipientId, platform, tokenHash, tokenCipher, created, updated] = this.values; + const existing = registry.devices.find((item) => item.installation_id === installationId && item.platform === platform && item.token_hash === tokenHash); + if (existing) Object.assign(existing, { recipient_id: recipientId, token_cipher: tokenCipher, updated_at: updated }); + else registry.devices.push({ id: registry.devices.length + 1, installation_id: installationId, recipient_id: recipientId, platform, token_hash: tokenHash, token_cipher: tokenCipher, created_at: created, updated_at: updated }); + } else if (/INSERT OR IGNORE INTO push_deliveries/i.test(sql)) { + const [installationId, idempotencyKey, recipientId, created] = this.values; + const key = `${installationId}:${idempotencyKey}`; + if (registry.deliveries.has(key)) return { success: true, meta: { changes: 0 } }; + registry.deliveries.set(key, { recipient_id: recipientId, delivered_count: -1, created_at: created }); + } else if (/UPDATE push_deliveries SET delivered_count/i.test(sql)) { + const [deliveredCount, installationId, idempotencyKey] = this.values; + registry.deliveries.get(`${installationId}:${idempotencyKey}`).delivered_count = deliveredCount; + } else if (/DELETE FROM push_deliveries/i.test(sql)) { + const [installationId, idempotencyKey] = this.values; + registry.deliveries.delete(`${installationId}:${idempotencyKey}`); + } else if (/DELETE FROM push_devices/i.test(sql) && /token_hash/i.test(sql)) { + const [installationId, recipientId, platform, tokenHash] = this.values; + registry.devices = registry.devices.filter((item) => !(item.installation_id === installationId && item.recipient_id === recipientId && item.platform === platform && item.token_hash === tokenHash)); + } + return { success: true, meta: { changes: 1 } }; + }, + async all() { + if (/FROM push_devices/i.test(sql)) return { results: registry.devices.filter((item) => item.installation_id === this.values[0] && item.recipient_id === this.values[1]) }; + return { results: [] }; + }, + }; + } + return super.prepare(sql); + } + } + const registry = new PushRegistry(); + const secret = "p".repeat(64); + const installationId = "0123456789abcdef"; + const recipientId = "7".repeat(32); + const pushEnv = env(registry); + const installation = await body(await worker.fetch(request("/v1/push/installations", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ installation_id: installationId, management_secret: secret }) }), pushEnv)); + assert.equal(installation.status, 200); + const registration = await body(await worker.fetch(request("/v1/push/devices", { method: "POST", headers: { authorization: `Bearer ${secret}`, "content-type": "application/json" }, body: JSON.stringify({ installation_id: installationId, recipient_id: recipientId, platform: "ios", token: "a".repeat(64) }) }), pushEnv)); + assert.equal(registration.status, 200); + assert.equal(registry.devices.length, 1); + assert.notEqual(registry.devices[0].token_cipher, "a".repeat(64), "raw APNs tokens are encrypted at rest"); + const unauthenticated = await worker.fetch(request("/v1/push/devices", { method: "POST", headers: { authorization: "Bearer wrong", "content-type": "application/json" }, body: JSON.stringify({ installation_id: installationId, recipient_id: recipientId, platform: "ios", token: "b".repeat(64) }) }), pushEnv); + assert.equal(unauthenticated.status, 401); + const signingPair = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]); + const pkcs8 = Buffer.from(await crypto.subtle.exportKey("pkcs8", signingPair.privateKey)).toString("base64").match(/.{1,64}/g).join("\n"); + Object.assign(pushEnv, { APNS_TEAM_ID: "98V3FJEFGR", APNS_KEY_ID: "TESTKEY001", APNS_PRIVATE_KEY: `-----BEGIN PRIVATE KEY-----\n${pkcs8}\n-----END PRIVATE KEY-----` }); + const originalFetch = globalThis.fetch; + const apnsCalls = []; + globalThis.fetch = async (url, init = {}) => { + apnsCalls.push({ url: String(url), init }); + return new Response(null, { status: 200 }); + }; + t.after(() => { globalThis.fetch = originalFetch; }); + const apnsDelivery = await body(await worker.fetch(request("/v1/push/deliveries", { + method: "POST", headers: { authorization: `Bearer ${secret}`, "content-type": "application/json" }, + body: JSON.stringify({ installation_id: installationId, recipient_id: recipientId, idempotency_key: "signed-message", title: "Ready", body: "Done", channelId: 2, messageId: 3 }), + }), pushEnv)); + assert.equal(apnsDelivery.status, 200); + assert.equal(apnsDelivery.json.delivered, 1); + assert.match(apnsCalls[0].url, /^https:\/\/api\.push\.apple\.com\/3\/device\//); + const jwt = String(apnsCalls[0].init.headers.authorization || "").replace(/^bearer /, ""); + assert.equal(jwt.split(".").length, 3); + assert.equal(Buffer.from(jwt.split(".")[2].replace(/-/g, "+").replace(/_/g, "/"), "base64url").length, 64, "ES256 uses the raw 64-byte JWT signature APNs requires"); + assert.equal(apnsCalls[0].init.headers["apns-topic"], "com.gitcommit90.onehelm.mobile"); + registry.devices = []; + const deliveryInit = { method: "POST", headers: { authorization: `Bearer ${secret}`, "content-type": "application/json" }, body: JSON.stringify({ installation_id: installationId, recipient_id: recipientId, idempotency_key: "one-message", title: "Ready", body: "Done", channelId: 2, messageId: 3 }) }; + const delivered = await body(await worker.fetch(request("/v1/push/deliveries", deliveryInit), pushEnv)); + assert.equal(delivered.status, 200); + assert.equal(delivered.json.delivered, 0); + const repeated = await body(await worker.fetch(request("/v1/push/deliveries", deliveryInit), pushEnv)); + assert.equal(repeated.json.existing, true); +}); diff --git a/test/mobile-push.mjs b/test/mobile-push.mjs new file mode 100644 index 0000000..f3b18ae --- /dev/null +++ b/test/mobile-push.mjs @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +test("mobile push registration, preferences, durable fan-out, and idempotency stay recipient-scoped", async () => { + const calls = []; + const relay = createServer(async (req, res) => { + let raw = ""; + for await (const chunk of req) raw += chunk; + calls.push({ path: req.url, method: req.method, authorization: req.headers.authorization || "", body: raw ? JSON.parse(raw) : {} }); + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: true, delivered: 1 })); + }); + await new Promise((resolve) => relay.listen(0, "127.0.0.1", resolve)); + const address = relay.address(); + const dataDir = await mkdtemp(join(tmpdir(), "1helm-mobile-push-")); + process.env.CTRL_DATA_DIR = dataDir; + process.env.HELM_PUSH_RELAY_URL = `http://127.0.0.1:${address.port}/v1/push`; + const db = await import("../src/server/db.ts"); + const push = await import("../src/server/mobile-push.ts"); + try { + db.seed(); + const userA = db.run("INSERT INTO users (username,pass,display,is_admin,created) VALUES ('captain','x','Captain',1,?)", db.now()).lastInsertRowid; + const userB = db.run("INSERT INTO users (username,pass,display,is_admin,created) VALUES ('crew','x','Crew',0,?)", db.now()).lastInsertRowid; + const userMuted = db.run("INSERT INTO users (username,pass,display,is_admin,created) VALUES ('quiet','x','Quiet',0,?)", db.now()).lastInsertRowid; + const channelId = Number(db.q1("SELECT id FROM channels WHERE kind='channel' LIMIT 1").id); + for (const userId of [userA, userB, userMuted]) db.run("INSERT OR IGNORE INTO members (channel_id,user_id) VALUES (?,?)", channelId, userId); + await push.registerMobilePush(userB, "ios", "a".repeat(64)); + await push.registerMobilePush(userB, "ios", "c".repeat(64)); + await push.registerMobilePush(userMuted, "ios", "b".repeat(64)); + db.run("INSERT INTO user_ui_state (user_id,key,value,updated) VALUES (?,'notification_preferences',?,?)", userMuted, JSON.stringify({ channels: { [channelId]: { muted: true } } }), db.now()); + const messageId = db.run("INSERT INTO messages (channel_id,user_id,body,created) VALUES (?,?,?,?)", channelId, userA, "Ready for review", db.now()).lastInsertRowid; + const event = { type: "message", message: { id: messageId, channel_id: channelId, parent_id: null, body: "Ready for review", author: { kind: "user", id: userA, name: "Captain" }, attachments: [], progress: [] } }; + push.queueMobilePush(channelId, event); + push.queueMobilePush(channelId, event); + await push.drainMobilePush(); + const outbox = db.q("SELECT * FROM mobile_push_outbox ORDER BY id"); + assert.equal(outbox.length, 1, "the sender and muted member receive no push and duplicate events collapse"); + assert.equal(Number(outbox[0].user_id), userB); + assert.equal(outbox[0].state, "delivered"); + const delivery = calls.find((call) => call.path === "/v1/push/deliveries"); + assert.match(delivery.body.recipient_id, /^[a-f0-9]{32}$/, "the relay receives only an opaque recipient identifier"); + assert.equal(delivery.body.channelId, channelId); + assert.equal(delivery.body.messageId, messageId); + assert.match(delivery.body.idempotency_key, new RegExp(`:${userB}:${messageId}$`)); + assert.ok(calls.filter((call) => call.path === "/v1/push/installations").length >= 1); + assert.ok(calls.every((call) => !JSON.stringify(call).includes("Captain')") && !Object.hasOwn(call.body, "user_id")), "SQL, local user IDs, and server internals are never relayed"); + assert.equal(push.mobilePushStatus(userB, "ios", "a".repeat(64)).registered, true, "status is scoped to this physical device token"); + await push.unregisterMobilePush(userB, "ios", "a".repeat(64)); + assert.equal(push.mobilePushStatus(userB, "ios", "a".repeat(64)).registered, false); + assert.equal(push.mobilePushStatus(userB, "ios", "c".repeat(64)).registered, true, "turning off one iPhone leaves the account's other iPhone registered"); + } finally { + relay.close(); + await rm(dataDir, { recursive: true, force: true }); + } +}); diff --git a/test/mobile.mjs b/test/mobile.mjs index ab1fb42..0757f26 100644 --- a/test/mobile.mjs +++ b/test/mobile.mjs @@ -87,8 +87,9 @@ test("mobile compatibility is explicit and CORS is confined to packaged Capacito }); test("Capacitor shells keep sessions native, connections HTTPS-only, and release identities stable", async () => { - const [config, mobile, api, app, androidManifest, androidBuild, androidRules, androidPackage, androidStyles, androidLaunch, iosInfo, iosProject, iosLaunch, privacy, packageJson, iosPackage] = await Promise.all([ + const [config, mobile, api, app, notifications, androidManifest, androidBuild, androidRules, androidPackage, androidStyles, androidLaunch, iosInfo, iosProject, iosLaunch, privacy, packageJson, iosPackage] = await Promise.all([ read("capacitor.config.json"), read("src/client/mobile.ts"), read("src/client/api.ts"), read("src/client/app.ts"), + read("src/client/notifications.ts"), read("android/app/src/main/AndroidManifest.xml"), read("android/app/build.gradle"), read("android/app/src/main/res/xml/data_extraction_rules.xml"), read("scripts/package-android-apk.mjs"), read("android/app/src/main/res/values/styles.xml"), read("android/app/src/main/res/layout/launch_screen.xml"), read("ios/App/App/Info.plist"), read("ios/App/App.xcodeproj/project.pbxproj"), read("ios/App/App/Base.lproj/LaunchScreen.storyboard"), read("ios/App/App/PrivacyInfo.xcprivacy"), read("package.json"), read("scripts/package-ios-ipa.mjs"), @@ -104,6 +105,8 @@ test("Capacitor shells keep sessions native, connections HTTPS-only, and release assert.equal(parsed.ios.preferredContentMode, "mobile"); assert.equal(parsed.ios.webContentsDebuggingEnabled, false); assert.equal(parsed.plugins.StatusBar.overlaysWebView, false, "iOS reserves the system status area before the packaged UI paints"); + assert.equal(parsed.plugins.StatusBar.backgroundColor, "#111318", "the native status area matches the dark header surface before first paint"); + assert.deepEqual(parsed.plugins.PushNotifications.presentationOptions, [], "foreground live events remain the one notification surface while background pushes use the system UI"); assert.equal(parsed.plugins.SplashScreen.launchAutoHide, false, "native launch art remains only until the first real screen paints"); assert.ok(parsed.plugins.SplashScreen.launchShowDuration <= 500, "launch has no artificial logo hold"); assert.equal(parsed.plugins.SplashScreen.launchFadeOutDuration, 180); @@ -118,6 +121,8 @@ test("Capacitor shells keep sessions native, connections HTTPS-only, and release assert.match(mobile, /SplashScreen\.hide\(\{ fadeOutDuration: 180 \}\)/); assert.match(mobile, /requestAnimationFrame\(\(\) => requestAnimationFrame/, "launch fades only after the gateway or workspace paints"); assert.match(mobile, /StatusBar\.setOverlaysWebView\(\{ overlay: platform !== "ios" \}\)/, "iOS keeps every full-screen header below the Dynamic Island while Android retains edge-to-edge rendering"); + assert.match(mobile, /StatusBar\.setBackgroundColor\(\{ color: surface \}\)/, "native status chrome follows the computed header surface"); + assert.match(mobile, /contains\("light"\) \? Style\.Light : Style\.Dark/, "status indicators stay legible when the user switches theme"); assert.match(mobile, /App\.getLaunchUrl/, "a cold-start OAuth callback is retained"); assert.doesNotMatch(mobile, /destination\.origin === serverOrigin/, "server links cannot replace the audited packaged WebView"); assert.doesNotMatch(api, /let token = localStorage\.getItem/, "the session is not eagerly copied out of native secure storage"); @@ -146,10 +151,15 @@ test("Capacitor shells keep sessions native, connections HTTPS-only, and release assert.match(iosInfo, /com\.gitcommit90\.onehelm\.mobile/); assert.match(iosInfo, /onehelm<\/string>/); assert.match(iosInfo, /NSMicrophoneUsageDescription/); + assert.match(iosInfo, /NSCameraUsageDescription/); + assert.match(iosInfo, /NSPhotoLibraryUsageDescription/); + assert.match(iosInfo, /Take Photo or Video/, "the protected camera path explains the exact user-triggered attachment action"); assert.match(iosInfo, /NSSpeechRecognitionUsageDescription/); assert.match(iosInfo, /ITSAppUsesNonExemptEncryption/); assert.match(iosProject, /PRODUCT_BUNDLE_IDENTIFIER = com\.gitcommit90\.onehelm\.mobile/); assert.match(iosProject, /PrivacyInfo\.xcprivacy in Resources/); + assert.match(iosProject, /CODE_SIGN_ENTITLEMENTS = App\/App\.entitlements/); + assert.match(notifications, /mobilePlatform\(\) !== "ios"/, "the current release offers push only on the platform with a complete APNs delivery path"); assert.match(iosLaunch, /contentMode="scaleAspectFit"/); assert.match(iosLaunch, /firstAttribute="width" constant="88"/); assert.match(iosLaunch, /firstAttribute="height" constant="88"/);