diff --git a/.agents/skills/locale-ui-patterns/SKILL.md b/.agents/skills/locale-ui-patterns/SKILL.md index ff57255b..93153562 100644 --- a/.agents/skills/locale-ui-patterns/SKILL.md +++ b/.agents/skills/locale-ui-patterns/SKILL.md @@ -11,10 +11,16 @@ User-facing UI text must go through `@/lib/i18n`; do not hardcode English string Use this skill for any React UI change that adds or edits visible text, accessible labels, placeholders, tooltips, toasts, dialogs, settings labels, navigation labels, or empty/error states. +## Translate everything immediately (no English placeholders) + +Every key you add to a non-English dictionary MUST contain a real translation in that language — never the English source string as a stand-in. There is NO "leave it in English for now" convention in this project; if an agent told you there was, it was wrong. Copying the English value into `es.ts`/`fr.ts`/`ko.ts`/`pl.ts`/`pt-BR.ts`/`uk.ts`/`zh-CN.ts`/`zh-TW.ts` is a defect, not a deferral. The app ships every locale at once, so an untranslated key is a visible bug for those users. + +If you genuinely cannot translate a language, say so explicitly to the user instead of silently pasting English. Do not invent a fallback policy. + ## Required Flow 1. Add or reuse a key in `packages/ui/src/lib/i18n/messages/en.ts`. -2. Add the same key to every non-English dictionary in `packages/ui/src/lib/i18n/messages/`. +2. Add the same key — fully translated, not the English text — to every non-English dictionary in `packages/ui/src/lib/i18n/messages/`. 3. In components, call `const { t } = useI18n()` from `@/lib/i18n` and render `t('key')`. 4. For locale names or language picker labels, use `label(locale)` from `useI18n()`. 5. Keep locale state in `packages/ui/src/lib/i18n/*`; do not add locale fields to broad stores like `useUIStore`. diff --git a/.agents/skills/serve-sim/SKILL.md b/.agents/skills/serve-sim/SKILL.md new file mode 100644 index 00000000..cd18d986 --- /dev/null +++ b/.agents/skills/serve-sim/SKILL.md @@ -0,0 +1,69 @@ +--- +name: serve-sim +description: Use when working with the OpenChamber iOS Simulator app without opening Xcode - boot/install/launch the Capacitor iOS app, start a browser stream, tap/type/gesture/rotate, inspect accessibility, or hand a simulator URL to the user. +--- + +# serve-sim + +Use `serve-sim` to stream and control a booted Apple Simulator from the terminal. It captures the simulator framebuffer, serves a browser preview, and exposes CLI controls for taps, typing, gestures, hardware buttons, rotation, memory warnings, permissions, camera injection, and accessibility inspection. + +## OpenChamber Defaults + +- Mobile package: `packages/mobile` +- iOS bundle id: `com.openchamber.app` +- Headless env wrapper: `packages/mobile/scripts/with-mobile-env.mjs` +- iOS simulator helper: `packages/mobile/scripts/ios-sim.mjs` +- Preferred scripts: + - `bun run mobile:build:ios:simulator` + - `bun run mobile:sim:run` + - `bun run mobile:sim:serve` + - `bun run mobile:sim:list` + - `bun run mobile:sim:kill` + +## Workflow + +1. Build the simulator app without opening Xcode: + ```sh + bun run mobile:build:ios:simulator + ``` + +2. Boot a simulator if needed, install, and launch the app: + ```sh + bun run mobile:sim:run + ``` + +3. Start the browser stream in detached JSON mode: + ```sh + bun run mobile:sim:serve + ``` + Surface the returned `url` to the user. It normally starts at `http://localhost:3200`. + +4. Stop helpers when finished unless the user asks to keep them running: + ```sh + bun run mobile:sim:kill + ``` + +## Direct CLI Controls + +- Tap normalized coordinates: `bunx serve-sim tap 0.5 0.5` +- Type focused text: `bunx serve-sim type "hello"` +- Hardware home: `bunx serve-sim button home` +- Rotate: `bunx serve-sim rotate portrait` +- List streams: `bunx serve-sim --list -q` +- Accessibility tree: `curl http://localhost:3100/ax` + +Coordinates are normalized `0..1`, not pixels. Prefer `tap` for simple taps; do not emulate taps using separate `gesture` begin/end commands because that can register as long press. + +## Preconditions + +- macOS host. +- Xcode installed; use `DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer` if `xcode-select` points at CommandLineTools. +- Node 18+. +- At least one simulator can be booted with `xcrun simctl`. + +## Anti-Patterns + +- Do not open Xcode just to build/install/launch during agent work; use the scripts above. +- Do not parse human output from `serve-sim`; use `-q` for JSON. +- Do not leave helper streams running unintentionally. +- Do not guess coordinates after accessibility lookup fails; report the missing target instead. diff --git a/AGENTS.md b/AGENTS.md index 263dd21d..9d21944a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -340,6 +340,7 @@ Project skills live under `.agents/skills/*/SKILL.md`. Before editing, agents ** | User-facing UI text: labels, buttons, placeholders, aria labels, empty/error/loading states, toasts, dialogs, settings copy, or navigation labels | `skill({ name: "locale-ui-patterns" })` | | Settings pages, settings dialogs, configuration UI, or visual/layout changes inside Settings | `skill({ name: "settings-ui-patterns" })` | | Drag-to-reorder, sortable lists/chips/grids, or `@dnd-kit` behavior including touch/mobile and wrapping variable-width items | `skill({ name: "drag-to-reorder" })` | +| iOS Simulator preview/control for the mobile app, `serve-sim`, simulator taps/typing/gestures/rotation, or headless install/launch workflows outside Xcode | `skill({ name: "serve-sim" })` | Skill docs are the source of truth for detailed patterns. Do not duplicate their full guidance here; load the skill and follow it before making matching changes. diff --git a/bun.lock b/bun.lock index 20ce1bac..72a8a905 100644 --- a/bun.lock +++ b/bun.lock @@ -112,11 +112,38 @@ "electron-builder": "^26.0.0", }, }, + "packages/mobile": { + "name": "@openchamber/mobile", + "version": "1.13.2", + "dependencies": { + "@aparajita/capacitor-secure-storage": "^8.0.0", + "@capacitor-mlkit/barcode-scanning": "^8.1.0", + "@capacitor/app": "^8.0.0", + "@capacitor/core": "^8.4.1", + "@capacitor/keyboard": "^8.0.0", + "@capacitor/push-notifications": "^8.1.1", + "@capacitor/status-bar": "^8.0.0", + }, + "devDependencies": { + "@capacitor/android": "^8.4.1", + "@capacitor/cli": "^8.4.1", + "@capacitor/ios": "^8.4.1", + "@types/node": "^24.3.1", + "serve-sim": "^0.1.34", + "typescript": "~5.9.0", + }, + }, "packages/ui": { "name": "@openchamber/ui", "version": "1.13.4", "dependencies": { + "@aparajita/capacitor-secure-storage": "^8.0.0", "@base-ui/react": "^1.4.0", + "@capacitor/app": "^8.0.0", + "@capacitor/core": "^8.4.1", + "@capacitor/keyboard": "^8.0.0", + "@capacitor/push-notifications": "^8.1.1", + "@capacitor/status-bar": "^8.0.0", "@codemirror/autocomplete": "^6.20.0", "@codemirror/commands": "^6.10.1", "@codemirror/lang-cpp": "^6.0.3", @@ -335,6 +362,8 @@ "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], + "@aparajita/capacitor-secure-storage": ["@aparajita/capacitor-secure-storage@8.0.0", "", { "dependencies": { "@capacitor/android": "^8.0.2", "@capacitor/app": "^8.0.0", "@capacitor/core": "^8.0.2", "@capacitor/ios": "^8.0.2", "@capacitor/keyboard": "^8.0.0" } }, "sha512-oYnwSjdIh23aRNgz8982+TmFvQH/2yZkEdw1iIg+H2ziFJoOVELPTc7u6Ez2HwOuDIW5AGqBX75GvrzQ+D70Qg=="], + "@apideck/better-ajv-errors": ["@apideck/better-ajv-errors@0.3.6", "", { "dependencies": { "json-schema": "^0.4.0", "jsonpointer": "^5.0.0", "leven": "^3.1.0" }, "peerDependencies": { "ajv": ">=8" } }, "sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA=="], "@azu/format-text": ["@azu/format-text@1.0.2", "", {}, "sha512-Swi4N7Edy1Eqq82GxgEECXSSLyn6GOb5htRFPzBDdUkECGXtlf12ynO5oJSpWKPwCaUssOu7NfhDcCWpIC6Ywg=="], @@ -551,6 +580,24 @@ "@base-ui/utils": ["@base-ui/utils@0.2.7", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@floating-ui/utils": "^0.2.11", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-nXYKhiL/0JafyJE8PfcflipGftOftlIwKd72rU15iZ1M5yqgg5J9P8NHU71GReDuXco5MJA/eVQqUT5WRqX9sA=="], + "@capacitor-mlkit/barcode-scanning": ["@capacitor-mlkit/barcode-scanning@8.1.0", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-lhOYHZINLOCT0i5YSbSMkouik3zh0BncJouumNTgXCT0/533Z4733jAX9zv+nEd++bE8QHeUl2g0NrewreumnQ=="], + + "@capacitor/android": ["@capacitor/android@8.4.1", "", { "peerDependencies": { "@capacitor/core": "^8.4.0" } }, "sha512-igtDCJ7QQn0P2qHFD9p4KXaa6V1b2PRNt+MxjVwtjTm/BJvqmiazOJq6rPjwFSZnfHm6iFoZk8TfzHd44pyBGw=="], + + "@capacitor/app": ["@capacitor/app@8.1.0", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-MlmttTOWHDedr/G4SrhNRxsXMqY+R75S4MM4eIgzsgCzOYhb/MpCkA5Q3nuOCfL1oHm26xjUzqZ5aupbOwdfYg=="], + + "@capacitor/cli": ["@capacitor/cli@8.4.1", "", { "dependencies": { "@ionic/cli-framework-output": "^2.2.8", "@ionic/utils-subprocess": "^3.0.1", "@ionic/utils-terminal": "^2.3.5", "commander": "^12.1.0", "debug": "^4.4.0", "env-paths": "^2.2.0", "fs-extra": "^11.2.0", "kleur": "^4.1.5", "native-run": "^2.0.3", "open": "^8.4.0", "plist": "^3.1.0", "prompts": "^2.4.2", "rimraf": "^6.0.1", "semver": "^7.6.3", "tar": "^7.5.3", "tslib": "^2.8.1", "xml2js": "^0.6.2" }, "bin": { "cap": "bin/capacitor", "capacitor": "bin/capacitor" } }, "sha512-t7F2s7fFHCq113xgrggrmK6ctV0/8E5YfLNVLfPHp4GCTDO+tly9fZvWPf2/sOI8lMm18dLT43qbXLRTz/OZgw=="], + + "@capacitor/core": ["@capacitor/core@8.4.1", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-xqhOGLbTAYeOWK+IDUNSjQJAPapQjRHrIcgk9PYp52or9zFTaaMko31uNi16N6W+CRJ8VrRram6fOYILkBG2Hg=="], + + "@capacitor/ios": ["@capacitor/ios@8.4.1", "", { "peerDependencies": { "@capacitor/core": "^8.4.0" } }, "sha512-EgcAk7NYheHMTyP3CUrA65qKs4D2UEAKgw44HI6Uk3dTw6KjLQkdLOQWvbeRncHaZ2gklfOojUoc5DlSw2lhYg=="], + + "@capacitor/keyboard": ["@capacitor/keyboard@8.0.5", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-oFXygC4eKYA5l2MdpTR06L2M/4x6e2SLD5yS1T9+UBDKTkzyvhWKEhbYLUaTIBPpLKqlfGudJw1X73S1H9eUzQ=="], + + "@capacitor/push-notifications": ["@capacitor/push-notifications@8.1.1", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-WqzjPKIbYbARMN+GC0XMAJcxJpUUzqgzS/Ny8RODLrro38pQhm3GXYwX2Mwd+LZlLY39rGImkCkrKyQSNfuikA=="], + + "@capacitor/status-bar": ["@capacitor/status-bar@8.0.2", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-WXs8YB8B9eEaPZz+bcdY6t2nForF1FLoj/JU0Dl9RRgQnddnS98FEEyDooQhaY7wivr000j4+SC1FyeJkrFO7A=="], + "@clack/core": ["@clack/core@1.1.0", "", { "dependencies": { "sisteransi": "^1.0.5" } }, "sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA=="], "@clack/prompts": ["@clack/prompts@1.1.0", "", { "dependencies": { "@clack/core": "1.1.0", "sisteransi": "^1.0.5" } }, "sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g=="], @@ -835,6 +882,22 @@ "@internationalized/string": ["@internationalized/string@3.2.7", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-D4OHBjrinH+PFZPvfCXvG28n2LSykWcJ7GIioQL+ok0LON15SdfoUssoHzzOUmVZLbRoREsQXVzA6r8JKsbP6A=="], + "@ionic/cli-framework-output": ["@ionic/cli-framework-output@2.2.8", "", { "dependencies": { "@ionic/utils-terminal": "2.3.5", "debug": "^4.0.0", "tslib": "^2.0.1" } }, "sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g=="], + + "@ionic/utils-array": ["@ionic/utils-array@2.1.6", "", { "dependencies": { "debug": "^4.0.0", "tslib": "^2.0.1" } }, "sha512-0JZ1Zkp3wURnv8oq6Qt7fMPo5MpjbLoUoa9Bu2Q4PJuSDWM8H8gwF3dQO7VTeUj3/0o1IB1wGkFWZZYgUXZMUg=="], + + "@ionic/utils-fs": ["@ionic/utils-fs@3.1.7", "", { "dependencies": { "@types/fs-extra": "^8.0.0", "debug": "^4.0.0", "fs-extra": "^9.0.0", "tslib": "^2.0.1" } }, "sha512-2EknRvMVfhnyhL1VhFkSLa5gOcycK91VnjfrTB0kbqkTFCOXyXgVLI5whzq7SLrgD9t1aqos3lMMQyVzaQ5gVA=="], + + "@ionic/utils-object": ["@ionic/utils-object@2.1.6", "", { "dependencies": { "debug": "^4.0.0", "tslib": "^2.0.1" } }, "sha512-vCl7sl6JjBHFw99CuAqHljYJpcE88YaH2ZW4ELiC/Zwxl5tiwn4kbdP/gxi2OT3MQb1vOtgAmSNRtusvgxI8ww=="], + + "@ionic/utils-process": ["@ionic/utils-process@2.1.12", "", { "dependencies": { "@ionic/utils-object": "2.1.6", "@ionic/utils-terminal": "2.3.5", "debug": "^4.0.0", "signal-exit": "^3.0.3", "tree-kill": "^1.2.2", "tslib": "^2.0.1" } }, "sha512-Jqkgyq7zBs/v/J3YvKtQQiIcxfJyplPgECMWgdO0E1fKrrH8EF0QGHNJ9mJCn6PYe2UtHNS8JJf5G21e09DfYg=="], + + "@ionic/utils-stream": ["@ionic/utils-stream@3.1.7", "", { "dependencies": { "debug": "^4.0.0", "tslib": "^2.0.1" } }, "sha512-eSELBE7NWNFIHTbTC2jiMvh1ABKGIpGdUIvARsNPMNQhxJB3wpwdiVnoBoTYp+5a6UUIww4Kpg7v6S7iTctH1w=="], + + "@ionic/utils-subprocess": ["@ionic/utils-subprocess@3.0.1", "", { "dependencies": { "@ionic/utils-array": "2.1.6", "@ionic/utils-fs": "3.1.7", "@ionic/utils-process": "2.1.12", "@ionic/utils-stream": "3.1.7", "@ionic/utils-terminal": "2.3.5", "cross-spawn": "^7.0.3", "debug": "^4.0.0", "tslib": "^2.0.1" } }, "sha512-cT4te3AQQPeIM9WCwIg8ohroJ8TjsYaMb2G4ZEgv9YzeDqHZ4JpeIKqG2SoaA3GmVQ3sOfhPM6Ox9sxphV/d1A=="], + + "@ionic/utils-terminal": ["@ionic/utils-terminal@2.3.5", "", { "dependencies": { "@types/slice-ansi": "^4.0.0", "debug": "^4.0.0", "signal-exit": "^3.0.3", "slice-ansi": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0", "tslib": "^2.0.1", "untildify": "^4.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-3cKScz9Jx2/Pr9ijj1OzGlBDfcmx7OMVBt4+P1uRR0SSW4cm1/y3Mo4OY3lfkuaYifMNBW8Wz6lQHbs1bihr7A=="], + "@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="], "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], @@ -937,6 +1000,8 @@ "@openchamber/electron": ["@openchamber/electron@workspace:packages/electron"], + "@openchamber/mobile": ["@openchamber/mobile@workspace:packages/mobile"], + "@openchamber/ui": ["@openchamber/ui@workspace:packages/ui"], "@openchamber/web": ["@openchamber/web@workspace:packages/web"], @@ -1329,6 +1394,8 @@ "@types/sarif": ["@types/sarif@2.1.7", "", {}, "sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ=="], + "@types/slice-ansi": ["@types/slice-ansi@4.0.0", "", {}, "sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ=="], + "@types/superagent": ["@types/superagent@8.1.9", "", { "dependencies": { "@types/cookiejar": "^2.1.5", "@types/methods": "^1.1.4", "@types/node": "*", "form-data": "^4.0.0" } }, "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ=="], "@types/supertest": ["@types/supertest@7.2.0", "", { "dependencies": { "@types/methods": "^1.1.4", "@types/superagent": "^8.1.0" } }, "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw=="], @@ -1519,6 +1586,8 @@ "better-sqlite3": ["better-sqlite3@12.10.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ=="], + "big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="], + "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], "binaryextensions": ["binaryextensions@6.11.0", "", { "dependencies": { "editions": "^6.21.0" } }, "sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw=="], @@ -1537,6 +1606,8 @@ "boundary": ["boundary@2.0.0", "", {}, "sha512-rJKn5ooC9u8q13IMCrW0RSp31pxBCHE3y9V/tp3TdWSLf8Em3p6Di4NBpfzbJge9YjjFEsD0RtFEjtvHL5VyEA=="], + "bplist-parser": ["bplist-parser@0.3.2", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ=="], + "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], @@ -1643,7 +1714,7 @@ "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], - "commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], "common-tags": ["common-tags@1.8.2", "", {}, "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA=="], @@ -1731,7 +1802,7 @@ "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], - "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], + "define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="], "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], @@ -1809,6 +1880,8 @@ "electron-winstaller": ["electron-winstaller@5.4.0", "", { "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", "fs-extra": "^7.0.1", "lodash": "^4.17.21", "temp": "^0.9.0" }, "optionalDependencies": { "@electron/windows-sign": "^1.1.2" } }, "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg=="], + "elementtree": ["elementtree@0.1.7", "", { "dependencies": { "sax": "1.1.4" } }, "sha512-wkgGT6kugeQk/P6VZ/f4T+4HB41BVgNBq5CDIZVbQ02nvTVqAiVTbskxxu3eA/X96lMlfYOwnLQpN2v5E1zDEg=="], + "elkjs": ["elkjs@0.11.0", "", {}, "sha512-u4J8h9mwEDaYMqo0RYJpqNMFDoMK7f+pu4GjcV+N8jIC7TRdORgzkfSjTJemhqONFfH6fBI3wpysgWbhgVWIXw=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -2135,10 +2208,12 @@ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + "ini": ["ini@4.1.3", "", {}, "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg=="], "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], + "inspect-webkit": ["inspect-webkit@0.0.5", "", { "peerDependencies": { "typescript": "^5" }, "bin": { "inspect-webkit": "dist/cli.js" } }, "sha512-584wP/2nJO1LX74nqHP2j0tQzlK9ZTi+D0Z9qeLQjtUR/LCMXQHGX8M0vrsqBwTeGakF7q5GMFpg81nxUYlCvw=="], + "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], "intl-messageformat": ["intl-messageformat@10.7.18", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/fast-memoize": "2.2.7", "@formatjs/icu-messageformat-parser": "2.11.4", "tslib": "^2.8.0" } }, "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g=="], @@ -2297,6 +2372,8 @@ "klaw-sync": ["klaw-sync@6.0.0", "", { "dependencies": { "graceful-fs": "^4.1.11" } }, "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ=="], + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + "lazy-val": ["lazy-val@1.0.5", "", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="], "leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="], @@ -2543,6 +2620,8 @@ "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], + "native-run": ["native-run@2.0.3", "", { "dependencies": { "@ionic/utils-fs": "^3.1.7", "@ionic/utils-terminal": "^2.3.4", "bplist-parser": "^0.3.2", "debug": "^4.3.4", "elementtree": "^0.1.7", "ini": "^4.1.1", "plist": "^3.1.0", "split2": "^4.2.0", "through2": "^4.0.2", "tslib": "^2.6.2", "yauzl": "^2.10.0" }, "bin": { "native-run": "bin/native-run" } }, "sha512-U1PllBuzW5d1gfan+88L+Hky2eZx+9gv3Pf6rNBxKbORxi7boHzqiA6QFGSnqMem4j0A9tZ08NMIs5+0m/VS1Q=="], + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], "negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="], @@ -2707,6 +2786,8 @@ "promise-retry": ["promise-retry@2.0.1", "", { "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" } }, "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g=="], + "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], + "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], @@ -2843,7 +2924,7 @@ "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], - "rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], + "rimraf": ["rimraf@6.1.3", "", { "dependencies": { "glob": "^13.0.3", "package-json-from-dist": "^1.0.1" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA=="], "roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="], @@ -2885,6 +2966,8 @@ "serialize-javascript": ["serialize-javascript@6.0.2", "", { "dependencies": { "randombytes": "^2.1.0" } }, "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g=="], + "serve-sim": ["serve-sim@0.1.43", "", { "dependencies": { "inspect-webkit": "^0.0.5", "ws": "^8.21.0" }, "bin": { "serve-sim": "dist/serve-sim.js" } }, "sha512-kLcWWucVZxPD2+73EAhku6iThATYXUTYlt8M4+sw1ZHZYkfFNhqCuhy8g+Z5JHCNUTf93t2qwCHPOPqvbYKRrw=="], + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], @@ -2967,6 +3050,8 @@ "spdx-license-ids": ["spdx-license-ids@3.0.23", "", {}, "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw=="], + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], "ssri": ["ssri@9.0.1", "", { "dependencies": { "minipass": "^3.1.1" } }, "sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q=="], @@ -3065,6 +3150,8 @@ "textextensions": ["textextensions@6.11.0", "", { "dependencies": { "editions": "^6.21.0" } }, "sha512-tXJwSr9355kFJI3lbCkPpUH5cP8/M0GGy2xLO34aZCjMXBaK3SoPnZwr/oWmo1FdCnELcs4npdCIOFtq9W3ruQ=="], + "through2": ["through2@4.0.2", "", { "dependencies": { "readable-stream": "3" } }, "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw=="], + "tiny-async-pool": ["tiny-async-pool@1.3.0", "", { "dependencies": { "semver": "^5.5.0" } }, "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA=="], "tiny-typed-emitter": ["tiny-typed-emitter@2.1.0", "", {}, "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA=="], @@ -3181,6 +3268,8 @@ "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "untildify": ["untildify@4.0.0", "", {}, "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw=="], + "unused-filename": ["unused-filename@4.0.1", "", { "dependencies": { "escape-string-regexp": "^5.0.0", "path-exists": "^5.0.0" } }, "sha512-ZX6U1J04K1FoSUeoX1OicAhw4d0aro2qo+L8RhJkiGTNtBNkd/Fi1Wxoc9HzcVu6HfOzm0si/N15JjxFmD1z6A=="], "upath": ["upath@1.2.0", "", {}, "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg=="], @@ -3301,9 +3390,9 @@ "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], - "xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], + "xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="], - "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + "xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], @@ -3345,6 +3434,14 @@ "@babel/preset-env/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@capacitor/cli/fs-extra": ["fs-extra@11.3.3", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg=="], + + "@capacitor/cli/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], + + "@capacitor/cli/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "@capacitor/cli/tar": ["tar@7.5.13", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng=="], + "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], "@electron/asar/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], @@ -3375,6 +3472,12 @@ "@heroui/theme/tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="], + "@ionic/utils-fs/@types/fs-extra": ["@types/fs-extra@8.1.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ=="], + + "@ionic/utils-fs/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], + + "@ionic/utils-terminal/slice-ansi": ["slice-ansi@4.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ=="], + "@isaacs/fs-minipass/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], "@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], @@ -3383,6 +3486,8 @@ "@npmcli/agent/socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], + "@npmcli/move-file/rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], + "@openchamber/web/cron-parser": ["cron-parser@4.9.0", "", { "dependencies": { "luxon": "^3.2.1" } }, "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q=="], "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], @@ -3437,15 +3542,13 @@ "@textlint/linter-formatter/pluralize": ["pluralize@2.0.0", "", {}, "sha512-TqNZzQCD4S42De9IfnnBvILN7HAW7riLqsCyp8lgjXeysyPlX5HhqKAcJHHHb9XskE4/a+7VGC9zzx8Ls0jOAw=="], - "@types/plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], - "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - "@vscode/vsce/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + "@vscode/vsce/xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], "@xenova/transformers/sharp": ["sharp@0.32.6", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.2", "node-addon-api": "^6.1.0", "prebuild-install": "^7.1.1", "semver": "^7.5.4", "simple-get": "^4.0.1", "tar-fs": "^3.0.4", "tunnel-agent": "^0.6.0" } }, "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w=="], @@ -3475,6 +3578,8 @@ "cacache/p-map": ["p-map@4.0.0", "", { "dependencies": { "aggregate-error": "^3.0.0" } }, "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ=="], + "cacache/rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], + "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -3499,6 +3604,8 @@ "electron-winstaller/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], + "elementtree/sax": ["sax@1.1.4", "", {}, "sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg=="], + "encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "encoding-sniffer/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], @@ -3539,6 +3646,8 @@ "is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + "keytar/node-addon-api": ["node-addon-api@4.3.0", "", {}, "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ=="], "make-fetch-happen/http-proxy-agent": ["http-proxy-agent@5.0.0", "", { "dependencies": { "@tootallnate/once": "2", "agent-base": "6", "debug": "4" } }, "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w=="], @@ -3601,14 +3710,16 @@ "path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], - "plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], - "postject/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], "prebuild-install/tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], + "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + "qrcode/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], + "rc/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + "rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], "react-syntax-highlighter/@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], @@ -3623,10 +3734,12 @@ "rehype-katex/katex": ["katex@0.16.45", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA=="], - "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "rimraf/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], + "serve-sim/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + "sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], @@ -3685,10 +3798,22 @@ "wsl-utils/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], + "xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + "@apideck/better-ajv-errors/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "@azure/identity/open/define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], + "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "@capacitor/cli/tar/chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + + "@capacitor/cli/tar/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "@capacitor/cli/tar/minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], + + "@capacitor/cli/tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + "@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], "@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], @@ -3697,6 +3822,8 @@ "@electron/universal/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "@npmcli/move-file/rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "@rollup/plugin-node-resolve/@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "@secretlint/config-loader/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], @@ -3705,6 +3832,8 @@ "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], + "@vscode/vsce/xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + "@xenova/transformers/sharp/node-addon-api": ["node-addon-api@6.1.0", "", {}, "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA=="], "app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], @@ -3727,6 +3856,8 @@ "cacache/glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], + "cacache/rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "cli-truncate/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "cli-truncate/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], @@ -3767,6 +3898,8 @@ "mdast-util-mdx-jsx/parse-entities/is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + "micromark-extension-math/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + "node-gyp/make-fetch-happen/cacache": ["cacache@19.0.1", "", { "dependencies": { "@npmcli/fs": "^4.0.0", "fs-minipass": "^3.0.0", "glob": "^10.2.2", "lru-cache": "^10.0.1", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", "ssri": "^12.0.0", "tar": "^7.4.3", "unique-filename": "^4.0.0" } }, "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ=="], "node-gyp/make-fetch-happen/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], @@ -3809,6 +3942,12 @@ "qrcode/yargs/yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="], + "rehype-katex/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + + "rimraf/glob/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], + + "rimraf/glob/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + "source-map/whatwg-url/tr46": ["tr46@1.0.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA=="], "source-map/whatwg-url/webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="], @@ -3961,6 +4100,8 @@ "qrcode/yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + "rimraf/glob/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], + "node-gyp/make-fetch-happen/cacache/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], "node-gyp/make-fetch-happen/cacache/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], @@ -3971,6 +4112,8 @@ "qrcode/yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + "rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "node-gyp/make-fetch-happen/cacache/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], "node-gyp/make-fetch-happen/cacache/glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], diff --git a/package.json b/package.json index 5da57494..3d6f9a0c 100644 --- a/package.json +++ b/package.json @@ -26,14 +26,17 @@ "build:web": "bun run --cwd packages/web build", "build:ui": "bun run --cwd packages/ui build", "build:electron": "bun run --cwd packages/electron build", + "build:mobile": "bun run --cwd packages/mobile build", "type-check": "bun run --filter '*' type-check", "type-check:web": "bun run --cwd packages/web type-check", "type-check:ui": "bun run --cwd packages/ui type-check", "type-check:electron": "bun run --cwd packages/electron type-check", + "type-check:mobile": "bun run --cwd packages/mobile type-check", "lint": "bun run --filter '*' lint", "lint:web": "bun run --cwd packages/web lint", "lint:ui": "bun run --cwd packages/ui lint", "lint:electron": "bun run --cwd packages/electron lint", + "lint:mobile": "bun run --cwd packages/mobile lint", "clean": "bun run --filter '*' clean", "changelog-card": "node scripts/changelog-card/generate.mjs", "postinstall": "node ./fix-deprecation.js && patch-package", @@ -46,6 +49,21 @@ "electron:dev": "node ./packages/electron/scripts/electron-dev.mjs", "electron:dev:bundled": "OPENCHAMBER_ELECTRON_USE_BUNDLED_UI=1 node ./packages/electron/scripts/electron-dev.mjs", "electron:build": "bun run --cwd packages/electron package", + "mobile:build": "bun run --cwd packages/mobile build", + "mobile:sync": "bun run --cwd packages/mobile sync", + "mobile:add:ios": "bun run --cwd packages/mobile add:ios", + "mobile:add:android": "bun run --cwd packages/mobile add:android", + "mobile:build:android:debug": "bun run --cwd packages/mobile build:android:debug", + "mobile:build:ios:simulator": "bun run --cwd packages/mobile build:ios:simulator", + "mobile:sim:boot": "bun run --cwd packages/mobile sim:boot", + "mobile:sim:install": "bun run --cwd packages/mobile sim:install", + "mobile:sim:launch": "bun run --cwd packages/mobile sim:launch", + "mobile:sim:run": "bun run --cwd packages/mobile sim:run", + "mobile:sim:serve": "bun run --cwd packages/mobile sim:serve", + "mobile:sim:list": "bun run --cwd packages/mobile sim:list", + "mobile:sim:kill": "bun run --cwd packages/mobile sim:kill", + "mobile:open:ios": "bun run --cwd packages/mobile open:ios", + "mobile:open:android": "bun run --cwd packages/mobile open:android", "vscode:dev": "node ./scripts/dev-vscode.mjs", "vscode:build": "bun run --cwd packages/vscode build", "vscode:package": "bun run --cwd packages/vscode package", diff --git a/packages/mobile/HANDOFF.md b/packages/mobile/HANDOFF.md new file mode 100644 index 00000000..92fa01e7 --- /dev/null +++ b/packages/mobile/HANDOFF.md @@ -0,0 +1,218 @@ +# OpenChamber Mobile Handoff + +Status and process reference for the native iOS/Android apps. Written so work can continue after +merge — either by finishing CI/release automation, or by adding features as follow-up fixes. The +apps are feature-complete for a first public/TestFlight-style test; CI/signing is the main gap. + +## What this package is + +`packages/mobile` is a Capacitor workspace that wraps the **hosted mobile web UI** (the `MobileApp` +renderer), not the desktop shell. The native app is a WKWebView (iOS) / Android WebView loading a +bundled copy of the web build; native capabilities are added via Capacitor plugins and two iOS app +extensions. + +- App id / package: `com.openchamber.app`; app name `OpenChamber`. +- Capacitor config: `capacitor.config.ts` (Keyboard `resize: 'none'`, StatusBar overlay, Push + `presentationOptions: []`). +- Renderer: the web build's `mobile.html` entry (`MobileApp`), copied into `dist/` and served by + Capacitor. Mobile-only surfaces (connection onboarding, `Instances`, QR pairing, widgets) exist + only in the Capacitor shell — hosted `mobile.html` in a plain browser does not expose them. + +## Build pipeline (how a native build is produced) + +``` +bun run --cwd packages/web build # web/dist + → scripts/prepare-web-assets.mjs # copy web/dist → mobile/dist, mobile.html → index.html + → cap sync # copy dist → native, sync plugins/config + → xcodebuild / gradle assembleDebug # native binary +``` + +`sync` (in `packages/mobile/package.json`) runs `bun run build && cap sync` inside the mobile env +wrapper. Everything native-facing goes through `scripts/with-mobile-env.mjs`. + +### `with-mobile-env.mjs` (toolchain wrapper — read this before debugging build env issues) + +Every build/deploy script runs through it. It sets, with env overrides honored first: + +- `DEVELOPER_DIR` — `$DEVELOPER_DIR` → `xcode-select -p` → `/Applications/Xcode.app/...`. It + intentionally honors `xcode-select` so an Xcode beta / non-default install is used (hardcoding + the path previously forced builds onto the wrong Xcode / Command Line Tools). +- `JAVA_HOME` — `$JAVA_HOME` → `/opt/homebrew/opt/openjdk@21`. +- `ANDROID_HOME` / `ANDROID_SDK_ROOT` — `$ANDROID_HOME` → `/opt/homebrew/share/android-commandlinetools`. +- `PATH` — prepends `$JAVA_HOME/bin` and `$ANDROID_HOME/platform-tools` (so `adb` resolves). + +On another machine, override these env vars rather than editing the script. `xcode-select` may +point at Command Line Tools; the wrapper's `DEVELOPER_DIR` handling covers that for mobile commands. + +## Commands + +Root aliases (from repo root): + +```sh +bun run mobile:build # web build + prepare-web-assets +bun run mobile:sync # build + cap sync +bun run mobile:build:android:debug # sync + gradle assembleDebug +bun run mobile:build:ios:simulator # simulator build (strips MLKit pod, see quirks) +bun run mobile:open:ios # open in Xcode +bun run mobile:open:android # open in Android Studio +bun run type-check:mobile +bun run lint:mobile +``` + +Android physical-device deploy (adb-based, in `scripts/android-device.mjs`) — **not aliased at +root**, run from the package: + +```sh +bun run --cwd packages/mobile android:devices # list adb devices (want `device`, not `unauthorized`) +bun run --cwd packages/mobile android:install # adb install -r the debug APK +bun run --cwd packages/mobile android:launch # am start MainActivity +bun run --cwd packages/mobile android:run # install + launch +bun run --cwd packages/mobile android:logcat # app logs +``` + +Typical device iteration: `bun run --cwd packages/mobile build:android:debug` then +`android:run`. APK path: `android/app/build/outputs/apk/debug/app-debug.apk`. + +iOS Simulator helpers: `mobile:sim:{boot,install,launch,run,serve,list,kill}` (see +`scripts/ios-sim.mjs`; `serve-sim` for a browser preview of the simulator). + +## Native capabilities implemented + +- **Connection onboarding** — server URL entry, password unlock for locked servers, client-token + issuance, saved connections, `Instances` management sheet, auto-connect to the last instance on + launch. Deleting the active instance resets the runtime to the connect screen. +- **QR pairing** — `@capacitor-mlkit/barcode-scanning`. Android's Google code scanner module is + downloaded on first scan (needs Play Services + network); `mobileQrScan.ts` installs/awaits it + and retries. CAMERA permission + `NSCameraUsageDescription` declared. +- **Secure storage** — `@aparajita/capacitor-secure-storage` for connection tokens. +- **Deep links** — `openchamber://` URL scheme; a reusable intent vocabulary (`apps/deepLinks.ts`) + used by notification taps, widgets, and Control Center. Cold-launch intents are stashed. +- **Push notifications** — iOS APNs + Android FCM (see below). Presence-aware routing suppresses a + device's push when an interactive (desktop/web) client is visible. +- **iOS widgets + Control Center + Notification Service Extension** — WidgetKit extension + (`OpenChamberWidget`), a Control Center control, and an NSE (`OpenChamberNotificationService`) + that refreshes widgets from push. All share the App Group `group.com.openchamber.app`. +- **Native chrome** — status bar (iOS overlay + safe-area; Android inset + themed background), + keyboard handling (iOS CSS inset; Android native `adjustResize`), edge-swipe session switch, + back-button handling, app-icon badge. +- **App icons** — iOS `AppIcon`; Android adaptive launcher icon; notification small icon + (`ic_stat_notify`). + +## Push / notifications architecture + +- Registration: on launch the app registers a device token — **iOS → APNs, Android → FCM** — and + sends it to the connected server tagged with `platform` (`ios`/`android`). +- The server forwards notification-worthy events to a signed **relay**; the relay routes each token + to APNs or FCM by its bound platform. The app itself only needs to obtain and register the token. +- **Presence-aware suppression**: each client reports foreground visibility + its platform; a + mobile push is skipped while an interactive (desktop/web/vscode) client is visible (it already + shows the in-app notification). Gated on the desktop's visibility, never the phone's own. +- Foreground behavior: iOS suppresses the banner via `presentationOptions: []`; the web/PWA service + worker suppresses when a window is focused. + +## Platform config specifics + +### iOS (`ios/App`) + +- Extensions: `OpenChamberWidget` (WidgetKit, deployment 17.0) and `OpenChamberNotificationService` + (NSE, 15.5), both hand-wired into `App.xcodeproj/project.pbxproj` and embedded via a copy phase. +- App Group `group.com.openchamber.app` in all three targets' entitlements (app + widget + NSE). +- `Info.plist`: `CFBundleURLTypes` scheme `openchamber`, `NSCameraUsageDescription`. +- Push entitlement (aps-environment) required. +- APNs `mutable-content: 1` (set server/relay side) wakes the NSE to refresh widgets. + +### Android (`android/app`) + +- `google-services.json` (committed; Firebase project `openchamber-8bf7e`). The Google Services + Gradle plugin is applied conditionally when the file exists; `@capacitor/push-notifications` + brings `firebase-messaging`. +- Manifest: permissions `INTERNET`, `CAMERA` (+ optional camera feature), `POST_NOTIFICATIONS` + (Android 13+; older versions allow notifications by default). `windowSoftInputMode=adjustResize`. + ML Kit `com.google.mlkit.vision.DEPENDENCIES=barcode_ui` meta (preloads the code scanner). FCM + `default_notification_icon=@drawable/ic_stat_notify`. +- Adaptive launcher icon: full-bleed color background + `ic_launcher_foreground` (sources under + `packages/mobile/assets/`, regenerable with `@capacitor/assets`). + +## Quirks / gotchas + +- **iOS Simulator + MLKit**: `GoogleMLKit` barcode has no arm64-simulator slice, so + `scripts/ios-sim-build.mjs` temporarily strips the `CapacitorMlkitBarcodeScanning` pod, builds, + then restores it. Device builds include it normally. +- **Android WebView version**: the UI uses `color-mix()` (Tailwind v4 + theme) which needs + Chromium **111+**. An outdated Android System WebView renders translucency/selection wrong — tell + testers to keep Android System WebView updated (or use a device with a current one). +- **Capacitor stream transport is locked to SSE** on the native apps (native WebSocket streaming is + unreliable on Android). The Chat transport setting shows SSE selected and disables the others in + the Capacitor shell. +- **Android push needs the app rebuilt with `google-services.json`**; without it `register()` used + to crash ("Default FirebaseApp is not initialized"). Registration is gated to iOS/Android natives. + +## Validation + +```sh +bun run type-check:mobile +bun run lint:mobile +bun run mobile:build:android:debug +bun run mobile:build:ios:simulator +``` + +Web-inherited build warnings (KaTeX font URLs, `onnxruntime-web` eval, chunk-size) are expected and +non-fatal. + +## The gap: CI / release automation (next work) + +The apps build and deploy locally; there is no CI/signing/publishing yet. To take them to +TestFlight / Play internal testing: + +### iOS + +- Apple Developer account; App IDs for the app **and** both extensions + (`com.openchamber.app`, `.OpenChamberWidget`, `.OpenChamberNotificationService`), each enabled for + the **App Group** and (app) **Push**. +- Signing certificate + provisioning profiles for all three targets (extensions need their own). +- App Store Connect API key for non-interactive TestFlight upload (`xcodebuild archive` + + `notarytool`/`altool`, or fastlane `gym`+`pilot`). +- Runner: macOS with the same Xcode as `DEVELOPER_DIR`. + +### Android + +- Release keystore (kept as a CI secret); build a signed **AAB** (`bundleRelease`) — the debug + scripts here produce an unsigned debug APK. +- Play Console app + internal testing track; a Play service account for automated upload (fastlane + `supply` or the Play Developer API). +- `google-services.json` is committed, so FCM builds in CI without extra setup. +- Runner: Linux with the Android SDK + `openjdk@21`. + +### Notes for CI + +- Reuse `with-mobile-env.mjs`'s env contract (`DEVELOPER_DIR`, `JAVA_HOME`, `ANDROID_HOME`) — set + them in the workflow instead of relying on local Homebrew paths. +- Relay/push secrets (APNs key, FCM service account) live in the relay infrastructure, not app CI. +- Version/build-number bumping is not automated yet. + +## Store review readiness + +Xcode build warnings do not block review; the concrete items are store requirements, not code +quality. Done in-repo vs. to-do at release time: + +**Done in-repo (this branch):** + +- iOS app **Privacy Manifest** (`ios/App/App/PrivacyInfo.xcprivacy`) — declares no tracking and the + required-reason UserDefaults API (App Group snapshot). Bundled SDKs ship their own manifests. +- iOS **`ITSAppUsesNonExemptEncryption = false`** in `Info.plist` (skips the per-build export- + compliance prompt). +- iOS camera + local-network usage strings; Android SDK levels (`target/compile 35`, `min 24`) meet + Play's current requirements. + +**To-do at release (console / infra, not code):** + +- **Privacy policy URL** — required by both stores because the app uses camera + notifications. +- iOS **App Privacy nutrition label** (App Store Connect) and Android **Data Safety** form — declare + what's collected (device push token; the app otherwise talks only to the user's own server). +- **Production APNs** for App Store / TestFlight builds: the app's `aps-environment` must be + `production` in the release build, and the relay must send to production APNs (not sandbox). +- **Demo instance + credentials** for reviewers — the app connects to a user's server, so review + needs a reachable test instance (App Store 2.1 / Play). +- **Guideline 4.2 (minimum functionality)** — WebView-wrapper apps can be scrutinized; cite the + native features (push, widgets, Control Center, QR pairing) in the review notes. +- Signing/upload as covered in the CI section above (all three iOS targets; signed Android AAB). diff --git a/packages/mobile/README.md b/packages/mobile/README.md new file mode 100644 index 00000000..6f21cb5f --- /dev/null +++ b/packages/mobile/README.md @@ -0,0 +1,71 @@ +# OpenChamber Mobile + +Capacitor shell for the dedicated OpenChamber mobile web surface. + +The mobile package reuses the web build, then rewrites `mobile.html` to `index.html` in `packages/mobile/dist` so native iOS/Android always launch `MobileApp` instead of the hosted surface selector. + +## Runtime Model + +- The native app bundles the mobile UI only; it does not embed the OpenChamber web server or OpenCode server. +- On first launch in Capacitor, the app shows a connection screen for an existing OpenChamber server. +- Connections are saved locally in the app and can be managed from the mobile overflow menu under `Instances`. +- The connection screen and `Instances` menu item are Capacitor-only. Hosted `mobile.html` in a normal browser keeps the regular web behavior. +- Password-protected OpenChamber servers can be unlocked from the mobile app. The app stores the issued client token with the saved connection. + +## Commands + +Run these from `packages/mobile`, or use the root `mobile:*` aliases. + +- `bun run build`: builds `packages/web` and prepares mobile web assets. +- `bun run sync`: prepares assets and runs `cap sync`. +- `bun run add:ios`: creates the native iOS project. +- `bun run add:android`: creates the native Android project. +- `bun run build:android:debug`: builds a debug Android APK without launching an emulator. +- `bun run build:ios:simulator`: builds an iOS Simulator app without launching Xcode or Simulator. +- `bun run sim:run`: boots a simulator if needed, installs the built iOS app, and launches it. +- `bun run sim:serve`: starts `serve-sim` in detached JSON mode and prints the browser preview URL. +- `bun run sim:list`: lists running `serve-sim` streams. +- `bun run sim:kill`: stops running `serve-sim` streams. +- `bun run open:ios`: opens the iOS project. +- `bun run open:android`: opens the Android project. + +## Headless Quickstart + +```sh +bun run build +bun run sync +bun run build:ios:simulator +bun run build:android:debug +``` + +These commands build and sync the native projects without launching Xcode, Android Studio, Simulator, or an emulator. + +## Local Tooling + +The default scripts assume the local Homebrew/Xcode paths prepared for this workspace: + +- Xcode: `/Applications/Xcode.app/Contents/Developer` +- JDK 21: `/opt/homebrew/opt/openjdk@21` +- Android SDK: `/opt/homebrew/share/android-commandlinetools` + +Override `DEVELOPER_DIR`, `JAVA_HOME`, `ANDROID_HOME`, or `ANDROID_SDK_ROOT` when using a different local setup. + +Required local tools: + +- Xcode with iOS Simulator support. +- CocoaPods for iOS dependency installation. +- JDK 21 for Android Gradle builds. +- Android SDK command-line tools with platform/build-tools 35. + +## Troubleshooting + +- If `xcodebuild` reports that the active developer directory is Command Line Tools, keep using the provided scripts or set `DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer`. +- If Android builds fail with `Unable to locate a Java Runtime` or `source release: 21`, install/use JDK 21 and set `JAVA_HOME` accordingly. +- If Android SDK packages are missing, install `platform-tools`, `platforms;android-35`, and `build-tools;35.0.0`, then accept SDK licenses. +- If CocoaPods cannot find Capacitor pods after reinstalling dependencies, run `bun install` from the workspace root, then rerun `bun run sync`. +- If connecting to a remote OpenChamber server fails from the app while `/health` works in curl, check that the server build includes the packaged-client CORS allowlist for `capacitor://localhost` and local dev origins. +- If `serve-sim` preview says the stream is not producing frames, check the raw MJPEG stream before assuming the simulator stopped. In prior testing the raw stream worked while the browser preview UI stayed stale. + +## Generated Assets + +The native projects currently use Capacitor-generated launcher and splash assets. Replace them before release branding work. diff --git a/packages/mobile/android/.gitignore b/packages/mobile/android/.gitignore new file mode 100644 index 00000000..48354a3d --- /dev/null +++ b/packages/mobile/android/.gitignore @@ -0,0 +1,101 @@ +# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore + +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +# Uncomment the following line in case you need and you don't have the release build type files in your app +# release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/assetWizardSettings.xml +.idea/dictionaries +.idea/libraries +# Android Studio 3 in .gitignore file. +.idea/caches +.idea/modules.xml +# Comment next line if keeping position of elements in Navigation Editor is relevant for you +.idea/navEditor.xml + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +# lint/reports/ + +# Android Profiling +*.hprof + +# Cordova plugins for Capacitor +capacitor-cordova-android-plugins + +# Copied web assets +app/src/main/assets/public + +# Generated Config files +app/src/main/assets/capacitor.config.json +app/src/main/assets/capacitor.plugins.json +app/src/main/res/xml/config.xml diff --git a/packages/mobile/android/app/.gitignore b/packages/mobile/android/app/.gitignore new file mode 100644 index 00000000..043df802 --- /dev/null +++ b/packages/mobile/android/app/.gitignore @@ -0,0 +1,2 @@ +/build/* +!/build/.npmkeep diff --git a/packages/mobile/android/app/build.gradle b/packages/mobile/android/app/build.gradle new file mode 100644 index 00000000..d86e8dd7 --- /dev/null +++ b/packages/mobile/android/app/build.gradle @@ -0,0 +1,50 @@ +apply plugin: 'com.android.application' + +android { + namespace "com.openchamber.app" + compileSdk rootProject.ext.compileSdkVersion + defaultConfig { + applicationId "com.openchamber.app" + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode 1 + versionName "1.0" + aaptOptions { + // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. + // Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61 + ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' + } + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } +} + +repositories { + flatDir{ + dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs' + } +} + +dependencies { + implementation fileTree(include: ['*.jar'], dir: 'libs') + implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" + implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" + implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" + implementation project(':capacitor-android') + implementation project(':capacitor-cordova-android-plugins') +} + +apply from: 'capacitor.build.gradle' + +try { + def servicesJSON = file('google-services.json') + if (servicesJSON.text) { + apply plugin: 'com.google.gms.google-services' + } +} catch(Exception e) { + logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work") +} diff --git a/packages/mobile/android/app/capacitor.build.gradle b/packages/mobile/android/app/capacitor.build.gradle new file mode 100644 index 00000000..32b2e26b --- /dev/null +++ b/packages/mobile/android/app/capacitor.build.gradle @@ -0,0 +1,24 @@ +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN + +android { + compileOptions { + sourceCompatibility JavaVersion.VERSION_21 + targetCompatibility JavaVersion.VERSION_21 + } +} + +apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" +dependencies { + implementation project(':aparajita-capacitor-secure-storage') + implementation project(':capacitor-mlkit-barcode-scanning') + implementation project(':capacitor-app') + implementation project(':capacitor-keyboard') + implementation project(':capacitor-push-notifications') + implementation project(':capacitor-status-bar') + +} + + +if (hasProperty('postBuildExtras')) { + postBuildExtras() +} diff --git a/packages/mobile/android/app/google-services.json b/packages/mobile/android/app/google-services.json new file mode 100644 index 00000000..3d271911 --- /dev/null +++ b/packages/mobile/android/app/google-services.json @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "519320768353", + "project_id": "openchamber-8bf7e", + "storage_bucket": "openchamber-8bf7e.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:519320768353:android:e70fd113d740a86c233f20", + "android_client_info": { + "package_name": "com.openchamber.app" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyADEQ6yRHXBMlwbaG6y8Vb1elC2q7mx6-A" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/packages/mobile/android/app/proguard-rules.pro b/packages/mobile/android/app/proguard-rules.pro new file mode 100644 index 00000000..f1b42451 --- /dev/null +++ b/packages/mobile/android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/packages/mobile/android/app/src/main/AndroidManifest.xml b/packages/mobile/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..0229e443 --- /dev/null +++ b/packages/mobile/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mobile/android/app/src/main/java/com/openchamber/app/MainActivity.java b/packages/mobile/android/app/src/main/java/com/openchamber/app/MainActivity.java new file mode 100644 index 00000000..0d8e2a72 --- /dev/null +++ b/packages/mobile/android/app/src/main/java/com/openchamber/app/MainActivity.java @@ -0,0 +1,5 @@ +package com.openchamber.app; + +import com.getcapacitor.BridgeActivity; + +public class MainActivity extends BridgeActivity {} diff --git a/packages/mobile/android/app/src/main/res/drawable-land-hdpi/splash.png b/packages/mobile/android/app/src/main/res/drawable-land-hdpi/splash.png new file mode 100644 index 00000000..e31573b4 Binary files /dev/null and b/packages/mobile/android/app/src/main/res/drawable-land-hdpi/splash.png differ diff --git a/packages/mobile/android/app/src/main/res/drawable-land-mdpi/splash.png b/packages/mobile/android/app/src/main/res/drawable-land-mdpi/splash.png new file mode 100644 index 00000000..f7a64923 Binary files /dev/null and b/packages/mobile/android/app/src/main/res/drawable-land-mdpi/splash.png differ diff --git a/packages/mobile/android/app/src/main/res/drawable-land-xhdpi/splash.png b/packages/mobile/android/app/src/main/res/drawable-land-xhdpi/splash.png new file mode 100644 index 00000000..80772550 Binary files /dev/null and b/packages/mobile/android/app/src/main/res/drawable-land-xhdpi/splash.png differ diff --git a/packages/mobile/android/app/src/main/res/drawable-land-xxhdpi/splash.png b/packages/mobile/android/app/src/main/res/drawable-land-xxhdpi/splash.png new file mode 100644 index 00000000..14c6c8fe Binary files /dev/null and b/packages/mobile/android/app/src/main/res/drawable-land-xxhdpi/splash.png differ diff --git a/packages/mobile/android/app/src/main/res/drawable-land-xxxhdpi/splash.png b/packages/mobile/android/app/src/main/res/drawable-land-xxxhdpi/splash.png new file mode 100644 index 00000000..244ca250 Binary files /dev/null and b/packages/mobile/android/app/src/main/res/drawable-land-xxxhdpi/splash.png differ diff --git a/packages/mobile/android/app/src/main/res/drawable-port-hdpi/splash.png b/packages/mobile/android/app/src/main/res/drawable-port-hdpi/splash.png new file mode 100644 index 00000000..74faaa58 Binary files /dev/null and b/packages/mobile/android/app/src/main/res/drawable-port-hdpi/splash.png differ diff --git a/packages/mobile/android/app/src/main/res/drawable-port-mdpi/splash.png b/packages/mobile/android/app/src/main/res/drawable-port-mdpi/splash.png new file mode 100644 index 00000000..e944f4ad Binary files /dev/null and b/packages/mobile/android/app/src/main/res/drawable-port-mdpi/splash.png differ diff --git a/packages/mobile/android/app/src/main/res/drawable-port-xhdpi/splash.png b/packages/mobile/android/app/src/main/res/drawable-port-xhdpi/splash.png new file mode 100644 index 00000000..564a82ff Binary files /dev/null and b/packages/mobile/android/app/src/main/res/drawable-port-xhdpi/splash.png differ diff --git a/packages/mobile/android/app/src/main/res/drawable-port-xxhdpi/splash.png b/packages/mobile/android/app/src/main/res/drawable-port-xxhdpi/splash.png new file mode 100644 index 00000000..bfabe687 Binary files /dev/null and b/packages/mobile/android/app/src/main/res/drawable-port-xxhdpi/splash.png differ diff --git a/packages/mobile/android/app/src/main/res/drawable-port-xxxhdpi/splash.png b/packages/mobile/android/app/src/main/res/drawable-port-xxxhdpi/splash.png new file mode 100644 index 00000000..69290712 Binary files /dev/null and b/packages/mobile/android/app/src/main/res/drawable-port-xxxhdpi/splash.png differ diff --git a/packages/mobile/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/packages/mobile/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 00000000..c7bd21db --- /dev/null +++ b/packages/mobile/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/packages/mobile/android/app/src/main/res/drawable/ic_launcher_background.xml b/packages/mobile/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..d5fccc53 --- /dev/null +++ b/packages/mobile/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mobile/android/app/src/main/res/drawable/ic_stat_notify.xml b/packages/mobile/android/app/src/main/res/drawable/ic_stat_notify.xml new file mode 100644 index 00000000..e984b381 --- /dev/null +++ b/packages/mobile/android/app/src/main/res/drawable/ic_stat_notify.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/packages/mobile/android/app/src/main/res/drawable/splash.png b/packages/mobile/android/app/src/main/res/drawable/splash.png new file mode 100644 index 00000000..f7a64923 Binary files /dev/null and b/packages/mobile/android/app/src/main/res/drawable/splash.png differ diff --git a/packages/mobile/android/app/src/main/res/layout/activity_main.xml b/packages/mobile/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 00000000..b5ad1387 --- /dev/null +++ b/packages/mobile/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/packages/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/packages/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..24335ca3 --- /dev/null +++ b/packages/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/packages/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/packages/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..24335ca3 --- /dev/null +++ b/packages/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/packages/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/packages/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..0d837827 Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png b/packages/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png new file mode 100644 index 00000000..91a97489 Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/packages/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..48f80b57 Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/packages/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 00000000..8366f56a Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-ldpi/ic_launcher.png b/packages/mobile/android/app/src/main/res/mipmap-ldpi/ic_launcher.png new file mode 100644 index 00000000..dd12e409 Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-ldpi/ic_launcher.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-ldpi/ic_launcher_background.png b/packages/mobile/android/app/src/main/res/mipmap-ldpi/ic_launcher_background.png new file mode 100644 index 00000000..df35134c Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-ldpi/ic_launcher_background.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-ldpi/ic_launcher_foreground.png b/packages/mobile/android/app/src/main/res/mipmap-ldpi/ic_launcher_foreground.png new file mode 100644 index 00000000..79528e93 Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-ldpi/ic_launcher_foreground.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-ldpi/ic_launcher_round.png b/packages/mobile/android/app/src/main/res/mipmap-ldpi/ic_launcher_round.png new file mode 100644 index 00000000..9f6912e5 Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-ldpi/ic_launcher_round.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/packages/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..5c1290e0 Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png b/packages/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png new file mode 100644 index 00000000..e91ef4c1 Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/packages/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..9549e887 Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/packages/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 00000000..1b4e0768 Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/packages/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..b7a7ad7d Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png b/packages/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png new file mode 100644 index 00000000..10c3ebc6 Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/packages/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..39fa8bfe Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/packages/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 00000000..72cf0b1a Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/packages/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..f6bb54d6 Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png b/packages/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png new file mode 100644 index 00000000..44c226b1 Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/packages/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..c01ebf99 Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/packages/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..12cb54d0 Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/packages/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..9238f654 Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png b/packages/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png new file mode 100644 index 00000000..8a72324c Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/packages/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..9d7e5486 Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/packages/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/packages/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..33991992 Binary files /dev/null and b/packages/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/packages/mobile/android/app/src/main/res/values/ic_launcher_background.xml b/packages/mobile/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 00000000..c5d5899f --- /dev/null +++ b/packages/mobile/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/packages/mobile/android/app/src/main/res/values/strings.xml b/packages/mobile/android/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..75f28bfd --- /dev/null +++ b/packages/mobile/android/app/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + OpenChamber + OpenChamber + com.openchamber.app + com.openchamber.app + diff --git a/packages/mobile/android/app/src/main/res/values/styles.xml b/packages/mobile/android/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..be874e54 --- /dev/null +++ b/packages/mobile/android/app/src/main/res/values/styles.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/packages/mobile/android/app/src/main/res/xml/file_paths.xml b/packages/mobile/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 00000000..bd0c4d80 --- /dev/null +++ b/packages/mobile/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/packages/mobile/android/build.gradle b/packages/mobile/android/build.gradle new file mode 100644 index 00000000..9183d42f --- /dev/null +++ b/packages/mobile/android/build.gradle @@ -0,0 +1,36 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.7.2' + classpath 'com.google.gms:google-services:4.4.2' + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +apply from: "variables.gradle" + +allprojects { + repositories { + google() + mavenCentral() + } + configurations.all { + resolutionStrategy { + force 'org.jetbrains.kotlin:kotlin-stdlib:1.8.22' + force 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.22' + force 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.22' + } + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/packages/mobile/android/capacitor.settings.gradle b/packages/mobile/android/capacitor.settings.gradle new file mode 100644 index 00000000..4516c4b1 --- /dev/null +++ b/packages/mobile/android/capacitor.settings.gradle @@ -0,0 +1,21 @@ +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN +include ':capacitor-android' +project(':capacitor-android').projectDir = new File('../../../node_modules/.bun/@capacitor+android@8.4.1+767ac80cbab8ae50/node_modules/@capacitor/android/capacitor') + +include ':aparajita-capacitor-secure-storage' +project(':aparajita-capacitor-secure-storage').projectDir = new File('../../../node_modules/.bun/@aparajita+capacitor-secure-storage@8.0.0/node_modules/@aparajita/capacitor-secure-storage/android') + +include ':capacitor-mlkit-barcode-scanning' +project(':capacitor-mlkit-barcode-scanning').projectDir = new File('../../../node_modules/.bun/@capacitor-mlkit+barcode-scanning@8.1.0+767ac80cbab8ae50/node_modules/@capacitor-mlkit/barcode-scanning/android') + +include ':capacitor-app' +project(':capacitor-app').projectDir = new File('../../../node_modules/.bun/@capacitor+app@8.1.0+767ac80cbab8ae50/node_modules/@capacitor/app/android') + +include ':capacitor-keyboard' +project(':capacitor-keyboard').projectDir = new File('../../../node_modules/.bun/@capacitor+keyboard@8.0.5+767ac80cbab8ae50/node_modules/@capacitor/keyboard/android') + +include ':capacitor-push-notifications' +project(':capacitor-push-notifications').projectDir = new File('../../../node_modules/.bun/@capacitor+push-notifications@8.1.1+767ac80cbab8ae50/node_modules/@capacitor/push-notifications/android') + +include ':capacitor-status-bar' +project(':capacitor-status-bar').projectDir = new File('../../../node_modules/.bun/@capacitor+status-bar@8.0.2+767ac80cbab8ae50/node_modules/@capacitor/status-bar/android') diff --git a/packages/mobile/android/gradle.properties b/packages/mobile/android/gradle.properties new file mode 100644 index 00000000..2e87c52f --- /dev/null +++ b/packages/mobile/android/gradle.properties @@ -0,0 +1,22 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx1536m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true diff --git a/packages/mobile/android/gradle/wrapper/gradle-wrapper.jar b/packages/mobile/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..a4b76b95 Binary files /dev/null and b/packages/mobile/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/packages/mobile/android/gradle/wrapper/gradle-wrapper.properties b/packages/mobile/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..c1d5e018 --- /dev/null +++ b/packages/mobile/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/packages/mobile/android/gradlew b/packages/mobile/android/gradlew new file mode 100755 index 00000000..f5feea6d --- /dev/null +++ b/packages/mobile/android/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/packages/mobile/android/gradlew.bat b/packages/mobile/android/gradlew.bat new file mode 100644 index 00000000..9b42019c --- /dev/null +++ b/packages/mobile/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/packages/mobile/android/settings.gradle b/packages/mobile/android/settings.gradle new file mode 100644 index 00000000..3b4431d7 --- /dev/null +++ b/packages/mobile/android/settings.gradle @@ -0,0 +1,5 @@ +include ':app' +include ':capacitor-cordova-android-plugins' +project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/') + +apply from: 'capacitor.settings.gradle' \ No newline at end of file diff --git a/packages/mobile/android/variables.gradle b/packages/mobile/android/variables.gradle new file mode 100644 index 00000000..fefc2451 --- /dev/null +++ b/packages/mobile/android/variables.gradle @@ -0,0 +1,13 @@ +ext { + minSdkVersion = 24 + compileSdkVersion = 35 + targetSdkVersion = 35 + androidxActivityVersion = '1.9.2' + androidxAppCompatVersion = '1.7.0' + androidxCoordinatorLayoutVersion = '1.2.0' + androidxCoreVersion = '1.15.0' + androidxFragmentVersion = '1.8.4' + coreSplashScreenVersion = '1.0.1' + androidxWebkitVersion = '1.12.1' + cordovaAndroidVersion = '13.0.0' +} diff --git a/packages/mobile/assets/icon-background.png b/packages/mobile/assets/icon-background.png new file mode 100644 index 00000000..00751d9f Binary files /dev/null and b/packages/mobile/assets/icon-background.png differ diff --git a/packages/mobile/assets/icon-foreground.png b/packages/mobile/assets/icon-foreground.png new file mode 100644 index 00000000..1e566110 Binary files /dev/null and b/packages/mobile/assets/icon-foreground.png differ diff --git a/packages/mobile/assets/icon-only.png b/packages/mobile/assets/icon-only.png new file mode 100644 index 00000000..2171b7dc Binary files /dev/null and b/packages/mobile/assets/icon-only.png differ diff --git a/packages/mobile/capacitor.config.ts b/packages/mobile/capacitor.config.ts new file mode 100644 index 00000000..d397caff --- /dev/null +++ b/packages/mobile/capacitor.config.ts @@ -0,0 +1,33 @@ +import type { CapacitorConfig } from '@capacitor/cli'; + +const config: CapacitorConfig = { + appId: 'com.openchamber.app', + appName: 'OpenChamber', + webDir: 'dist', + server: { + androidScheme: 'https', + }, + plugins: { + Keyboard: { + // 'none' leaves the WebView at full height; the UI follows the keyboard + // itself via the --oc-keyboard-inset CSS variable driven by keyboardWillShow + // (see useNativeMobileChrome). The built-in 'native' resize lands only after + // the keyboard animation finishes, which looked like a ~1.5s lag. + resize: 'none', + resizeOnFullScreen: true, + autoBackdropColor: 'dom', + }, + StatusBar: { + overlaysWebView: true, + style: 'DEFAULT', + }, + PushNotifications: { + // Never display an APNs alert while the app is foreground. The server always sends + // (no racy visibility gate); iOS suppresses the foreground banner, so there is no + // notification when the app is active. Background pushes are shown by iOS as usual. + presentationOptions: [], + }, + }, +}; + +export default config; diff --git a/packages/mobile/ios/.gitignore b/packages/mobile/ios/.gitignore new file mode 100644 index 00000000..f4702997 --- /dev/null +++ b/packages/mobile/ios/.gitignore @@ -0,0 +1,13 @@ +App/build +App/Pods +App/output +App/App/public +DerivedData +xcuserdata + +# Cordova plugins for Capacitor +capacitor-cordova-ios-plugins + +# Generated Config files +App/App/capacitor.config.json +App/App/config.xml diff --git a/packages/mobile/ios/App/App.xcodeproj/project.pbxproj b/packages/mobile/ios/App/App.xcodeproj/project.pbxproj new file mode 100644 index 00000000..94498243 --- /dev/null +++ b/packages/mobile/ios/App/App.xcodeproj/project.pbxproj @@ -0,0 +1,738 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; }; + 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; }; + 504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; }; + 504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; }; + 504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; }; + D0C2000000000000000000B1 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = D0C1000000000000000000A1 /* PrivacyInfo.xcprivacy */; }; + 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; }; + 50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; }; + 8E7A4F1A2C4B4C749E0A1001 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 8E7A4F192C4B4C749E0A1001 /* AppIcon.icon */; }; + A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; }; + D0A2000000000000000000B1 /* WidgetShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1000000000000000000A1 /* WidgetShared.swift */; }; + D0A2000000000000000000B2 /* OpenChamberWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1000000000000000000A2 /* OpenChamberWidgets.swift */; }; + D0A2000000000000000000B3 /* OpenChamberControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1000000000000000000A3 /* OpenChamberControl.swift */; }; + D0A2000000000000000000B6 /* OpenChamberControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1000000000000000000A3 /* OpenChamberControl.swift */; }; + D0A2000000000000000000B4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D0A1000000000000000000A4 /* Assets.xcassets */; }; + D0A2000000000000000000B5 /* OpenChamberWidget.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = D0A1000000000000000000A7 /* OpenChamberWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + D0B2000000000000000000B1 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B1000000000000000000A1 /* NotificationService.swift */; }; + D0B2000000000000000000B5 /* OpenChamberNotificationService.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = D0B1000000000000000000A7 /* OpenChamberNotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = ""; }; + 50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = ""; }; + 504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + D0C1000000000000000000A1 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + 504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = ""; }; + 8E7A4F192C4B4C749E0A1001 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; name = AppIcon.icon; path = ../../../electron/resources/icons/AppIcon.icon; sourceTree = SOURCE_ROOT; }; + AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = ""; }; + FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = ""; }; + D0A1000000000000000000A1 /* WidgetShared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetShared.swift; sourceTree = ""; }; + D0A1000000000000000000A2 /* OpenChamberWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenChamberWidgets.swift; sourceTree = ""; }; + D0A1000000000000000000A3 /* OpenChamberControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenChamberControl.swift; sourceTree = ""; }; + D0A1000000000000000000A4 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + D0A1000000000000000000A5 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + D0A1000000000000000000A6 /* OpenChamberWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = OpenChamberWidget.entitlements; sourceTree = ""; }; + D0A1000000000000000000A7 /* OpenChamberWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OpenChamberWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + D0B1000000000000000000A1 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; + D0B1000000000000000000A2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + D0B1000000000000000000A3 /* OpenChamberNotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = OpenChamberNotificationService.entitlements; sourceTree = ""; }; + D0B1000000000000000000A7 /* OpenChamberNotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OpenChamberNotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 504EC3011FED79650016851F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D0A3000000000000000000C2 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D0B3000000000000000000C2 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXContainerItemProxy section */ + D0A4000000000000000000D3 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 504EC2FC1FED79650016851F /* Project object */; + proxyType = 1; + remoteGlobalIDString = D0A4000000000000000000D1; + remoteInfo = OpenChamberWidget; + }; + D0B4000000000000000000D3 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 504EC2FC1FED79650016851F /* Project object */; + proxyType = 1; + remoteGlobalIDString = D0B4000000000000000000D1; + remoteInfo = OpenChamberNotificationService; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + D0A3000000000000000000C4 /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + D0A2000000000000000000B5 /* OpenChamberWidget.appex in Embed App Extensions */, + D0B2000000000000000000B5 /* OpenChamberNotificationService.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXGroup section */ + 27E2DDA53C4D2A4D1A88CE4A /* Frameworks */ = { + isa = PBXGroup; + children = ( + AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 504EC2FB1FED79650016851F = { + isa = PBXGroup; + children = ( + 504EC3061FED79650016851F /* App */, + D0A6000000000000000000F1 /* OpenChamberWidget */, + D0B6000000000000000000F1 /* OpenChamberNotificationService */, + 504EC3051FED79650016851F /* Products */, + 7F8756D8B27F46E3366F6CEA /* Pods */, + 27E2DDA53C4D2A4D1A88CE4A /* Frameworks */, + ); + sourceTree = ""; + }; + 504EC3051FED79650016851F /* Products */ = { + isa = PBXGroup; + children = ( + 504EC3041FED79650016851F /* App.app */, + D0A1000000000000000000A7 /* OpenChamberWidget.appex */, + D0B1000000000000000000A7 /* OpenChamberNotificationService.appex */, + ); + name = Products; + sourceTree = ""; + }; + D0A6000000000000000000F1 /* OpenChamberWidget */ = { + isa = PBXGroup; + children = ( + D0A1000000000000000000A1 /* WidgetShared.swift */, + D0A1000000000000000000A2 /* OpenChamberWidgets.swift */, + D0A1000000000000000000A3 /* OpenChamberControl.swift */, + D0A1000000000000000000A4 /* Assets.xcassets */, + D0A1000000000000000000A5 /* Info.plist */, + D0A1000000000000000000A6 /* OpenChamberWidget.entitlements */, + ); + path = OpenChamberWidget; + sourceTree = ""; + }; + D0B6000000000000000000F1 /* OpenChamberNotificationService */ = { + isa = PBXGroup; + children = ( + D0B1000000000000000000A1 /* NotificationService.swift */, + D0B1000000000000000000A2 /* Info.plist */, + D0B1000000000000000000A3 /* OpenChamberNotificationService.entitlements */, + ); + path = OpenChamberNotificationService; + sourceTree = ""; + }; + 504EC3061FED79650016851F /* App */ = { + isa = PBXGroup; + children = ( + 50379B222058CBB4000EE86E /* capacitor.config.json */, + 504EC3071FED79650016851F /* AppDelegate.swift */, + 504EC30B1FED79650016851F /* Main.storyboard */, + 504EC30E1FED79650016851F /* Assets.xcassets */, + D0C1000000000000000000A1 /* PrivacyInfo.xcprivacy */, + 8E7A4F192C4B4C749E0A1001 /* AppIcon.icon */, + 504EC3101FED79650016851F /* LaunchScreen.storyboard */, + 504EC3131FED79650016851F /* Info.plist */, + 2FAD9762203C412B000D30F8 /* config.xml */, + 50B271D01FEDC1A000F3C39B /* public */, + ); + path = App; + sourceTree = ""; + }; + 7F8756D8B27F46E3366F6CEA /* Pods */ = { + isa = PBXGroup; + children = ( + FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */, + AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */, + ); + name = Pods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 504EC3031FED79650016851F /* App */ = { + isa = PBXNativeTarget; + buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */; + buildPhases = ( + 6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */, + 504EC3001FED79650016851F /* Sources */, + 504EC3011FED79650016851F /* Frameworks */, + 504EC3021FED79650016851F /* Resources */, + 9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */, + D0A3000000000000000000C4 /* Embed App Extensions */, + ); + buildRules = ( + ); + dependencies = ( + D0A4000000000000000000D2 /* PBXTargetDependency */, + D0B4000000000000000000D2 /* PBXTargetDependency */, + ); + name = App; + productName = App; + productReference = 504EC3041FED79650016851F /* App.app */; + productType = "com.apple.product-type.application"; + }; + D0A4000000000000000000D1 /* OpenChamberWidget */ = { + isa = PBXNativeTarget; + buildConfigurationList = D0A5000000000000000000E1 /* Build configuration list for PBXNativeTarget "OpenChamberWidget" */; + buildPhases = ( + D0A3000000000000000000C1 /* Sources */, + D0A3000000000000000000C2 /* Frameworks */, + D0A3000000000000000000C3 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = OpenChamberWidget; + productName = OpenChamberWidget; + productReference = D0A1000000000000000000A7 /* OpenChamberWidget.appex */; + productType = "com.apple.product-type.app-extension"; + }; + D0B4000000000000000000D1 /* OpenChamberNotificationService */ = { + isa = PBXNativeTarget; + buildConfigurationList = D0B5000000000000000000E1 /* Build configuration list for PBXNativeTarget "OpenChamberNotificationService" */; + buildPhases = ( + D0B3000000000000000000C1 /* Sources */, + D0B3000000000000000000C2 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = OpenChamberNotificationService; + productName = OpenChamberNotificationService; + productReference = D0B1000000000000000000A7 /* OpenChamberNotificationService.appex */; + productType = "com.apple.product-type.app-extension"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 504EC2FC1FED79650016851F /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 2700; + TargetAttributes = { + 504EC3031FED79650016851F = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + }; + D0A4000000000000000000D1 = { + CreatedOnToolsVersion = 16.0; + ProvisioningStyle = Automatic; + }; + D0B4000000000000000000D1 = { + CreatedOnToolsVersion = 16.0; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */; + compatibilityVersion = "Xcode 8.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 504EC2FB1FED79650016851F; + packageReferences = ( + ); + productRefGroup = 504EC3051FED79650016851F /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 504EC3031FED79650016851F /* App */, + D0A4000000000000000000D1 /* OpenChamberWidget */, + D0B4000000000000000000D1 /* OpenChamberNotificationService */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 504EC3021FED79650016851F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */, + 50B271D11FEDC1A000F3C39B /* public in Resources */, + 504EC30F1FED79650016851F /* Assets.xcassets in Resources */, + D0C2000000000000000000B1 /* PrivacyInfo.xcprivacy in Resources */, + 8E7A4F1A2C4B4C749E0A1001 /* AppIcon.icon in Resources */, + 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */, + 504EC30D1FED79650016851F /* Main.storyboard in Resources */, + 2FAD9763203C412B000D30F8 /* config.xml in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D0A3000000000000000000C3 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D0A2000000000000000000B4 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-App-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "[CP] Embed Pods Frameworks"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App/Pods-App-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 504EC3001FED79650016851F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 504EC3081FED79650016851F /* AppDelegate.swift in Sources */, + D0A2000000000000000000B6 /* OpenChamberControl.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D0A3000000000000000000C1 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D0A2000000000000000000B1 /* WidgetShared.swift in Sources */, + D0A2000000000000000000B2 /* OpenChamberWidgets.swift in Sources */, + D0A2000000000000000000B3 /* OpenChamberControl.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D0B3000000000000000000C1 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D0B2000000000000000000B1 /* NotificationService.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + D0A4000000000000000000D2 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D0A4000000000000000000D1 /* OpenChamberWidget */; + targetProxy = D0A4000000000000000000D3 /* PBXContainerItemProxy */; + }; + D0B4000000000000000000D2 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D0B4000000000000000000D1 /* OpenChamberNotificationService */; + targetProxy = D0B4000000000000000000D3 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 504EC30B1FED79650016851F /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 504EC30C1FED79650016851F /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 504EC3101FED79650016851F /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 504EC3111FED79650016851F /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 504EC3141FED79650016851F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = 5J7WJGPA2Q; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 504EC3151FED79650016851F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 5J7WJGPA2Q; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 504EC3171FED79650016851F /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CODE_SIGN_ENTITLEMENTS = App/App.entitlements; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = App/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = OpenChamber; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + IPHONEOS_DEPLOYMENT_TARGET = "$(RECOMMENDED_IPHONEOS_DEPLOYMENT_TARGET)"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; + PRODUCT_BUNDLE_IDENTIFIER = com.openchamber.app; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 504EC3181FED79650016851F /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CODE_SIGN_ENTITLEMENTS = App/App.entitlements; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = App/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = OpenChamber; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + IPHONEOS_DEPLOYMENT_TARGET = "$(RECOMMENDED_IPHONEOS_DEPLOYMENT_TARGET)"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.openchamber.app; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + D0A5000000000000000000E2 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = OpenChamberWidget/OpenChamberWidget.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 5J7WJGPA2Q; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = OpenChamberWidget/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.openchamber.app.OpenChamberWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + D0A5000000000000000000E3 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = OpenChamberWidget/OpenChamberWidget.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 5J7WJGPA2Q; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = OpenChamberWidget/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.openchamber.app.OpenChamberWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + D0B5000000000000000000E2 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = OpenChamberNotificationService/OpenChamberNotificationService.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 5J7WJGPA2Q; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = OpenChamberNotificationService/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.openchamber.app.OpenChamberNotificationService; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + D0B5000000000000000000E3 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = OpenChamberNotificationService/OpenChamberNotificationService.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 5J7WJGPA2Q; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = OpenChamberNotificationService/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.openchamber.app.OpenChamberNotificationService; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 504EC3141FED79650016851F /* Debug */, + 504EC3151FED79650016851F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 504EC3171FED79650016851F /* Debug */, + 504EC3181FED79650016851F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + D0A5000000000000000000E1 /* Build configuration list for PBXNativeTarget "OpenChamberWidget" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D0A5000000000000000000E2 /* Debug */, + D0A5000000000000000000E3 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + D0B5000000000000000000E1 /* Build configuration list for PBXNativeTarget "OpenChamberNotificationService" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D0B5000000000000000000E2 /* Debug */, + D0B5000000000000000000E3 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 504EC2FC1FED79650016851F /* Project object */; +} diff --git a/packages/mobile/ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme b/packages/mobile/ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme new file mode 100644 index 00000000..f57acce6 --- /dev/null +++ b/packages/mobile/ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mobile/ios/App/App.xcodeproj/xcshareddata/xcschemes/OpenChamberWidget.xcscheme b/packages/mobile/ios/App/App.xcodeproj/xcshareddata/xcschemes/OpenChamberWidget.xcscheme new file mode 100644 index 00000000..427e1b90 --- /dev/null +++ b/packages/mobile/ios/App/App.xcodeproj/xcshareddata/xcschemes/OpenChamberWidget.xcscheme @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mobile/ios/App/App.xcworkspace/contents.xcworkspacedata b/packages/mobile/ios/App/App.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..b301e824 --- /dev/null +++ b/packages/mobile/ios/App/App.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/packages/mobile/ios/App/App.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/mobile/ios/App/App.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/packages/mobile/ios/App/App.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/mobile/ios/App/App/App.entitlements b/packages/mobile/ios/App/App/App.entitlements new file mode 100644 index 00000000..14e33ec3 --- /dev/null +++ b/packages/mobile/ios/App/App/App.entitlements @@ -0,0 +1,18 @@ + + + + + + aps-environment + development + + com.apple.security.application-groups + + group.com.openchamber.app + + + diff --git a/packages/mobile/ios/App/App/AppDelegate.swift b/packages/mobile/ios/App/App/AppDelegate.swift new file mode 100644 index 00000000..d9364555 --- /dev/null +++ b/packages/mobile/ios/App/App/AppDelegate.swift @@ -0,0 +1,162 @@ +import UIKit +import Capacitor +import UserNotifications +import WidgetKit + +@UIApplicationMain +class AppDelegate: UIResponder, UIApplicationDelegate { + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + return true + } + + 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. + } + + func applicationDidEnterBackground(_ application: UIApplication) { + // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. + // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. + } + + func applicationWillEnterForeground(_ application: UIApplication) { + // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. + } + + func applicationDidBecomeActive(_ application: UIApplication) { + // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. + } + + func applicationWillTerminate(_ application: UIApplication) { + // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. + } + + func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + // Called when the app was launched with a url. Feel free to add additional processing here, + // but if you want the App API to support tracking app url opens, make sure to keep this call + return ApplicationDelegateProxy.shared.application(app, open: url, options: options) + } + + func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { + // Called when the app was launched with an activity, including Universal Links. + // Feel free to add additional processing here, but if you want the App API to support + // tracking app url opens, make sure to keep this call + return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler) + } + + // Forward APNs registration to Capacitor so @capacitor/push-notifications can + // deliver the device token / error to the JS `registration` / `registrationError` + // listeners. Required because this app uses a custom AppDelegate (not the stock + // Capacitor template, which already posts these notifications). + 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) + } + +} + +// iOS 26 (TN3187) requires apps built with the latest SDK to adopt the UIScene +// lifecycle. Capacitor 7's template still uses the legacy window setup, so we host a +// minimal scene delegate here that loads the Main storyboard (CAPBridgeViewController) +// and forwards deep links / universal links into Capacitor's delegate proxy. +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + guard let windowScene = scene as? UIWindowScene else { return } + let window = UIWindow(windowScene: windowScene) + let storyboard = UIStoryboard(name: "Main", bundle: nil) + window.rootViewController = storyboard.instantiateInitialViewController() + self.window = window + window.makeKeyAndVisible() + + configureWebViewChrome() + + if let urlContext = connectionOptions.urlContexts.first { + _ = ApplicationDelegateProxy.shared.application(UIApplication.shared, open: urlContext.url, options: [:]) + } + if let userActivity = connectionOptions.userActivities.first { + _ = ApplicationDelegateProxy.shared.application(UIApplication.shared, continue: userActivity) { _ in } + } + } + + func sceneDidBecomeActive(_ scene: UIScene) { + // Re-assert in case the WebView wasn't ready at scene-connect time, or the + // effect was re-enabled while backgrounded. + configureWebViewChrome() + + // Clear the app-icon badge whenever the app becomes active. The server sends + // an absolute badge count (sessions needing attention) on each push; once the + // user is looking at the app, the in-app indicators take over, so reset to 0. + if #available(iOS 17.0, *) { + UNUserNotificationCenter.current().setBadgeCount(0) + } else { + UIApplication.shared.applicationIconBadgeNumber = 0 + } + + // Refresh the widgets' session overview now that the WebView is loaded and state is fresh. + writeWidgetSnapshot() + } + + func sceneWillResignActive(_ scene: UIScene) { + // Capture the latest session overview before the app leaves the foreground, so the + // home/lock-screen/Control Center widgets reflect what the user just saw. + writeWidgetSnapshot() + } + + private static let widgetAppGroup = "group.com.openchamber.app" + private static let widgetSnapshotKey = "widgetSnapshot" + + /// Pulls the session overview JSON from the web layer (window.__OPENCHAMBER_WIDGET_SNAPSHOT__), + /// stores it in the shared App Group, and reloads the widget timelines. localStorage/stores + /// aren't reachable from the widget process, so this is how the bundled UI feeds the widgets — + /// no server involved. Failures are ignored so a transient read never clobbers a good snapshot. + private func writeWidgetSnapshot() { + guard let bridge = window?.rootViewController as? CAPBridgeViewController, + let webView = bridge.webView else { return } + let js = "(typeof window.__OPENCHAMBER_WIDGET_SNAPSHOT__ === 'function') ? window.__OPENCHAMBER_WIDGET_SNAPSHOT__() : null" + webView.evaluateJavaScript(js) { result, _ in + guard let json = result as? String, !json.isEmpty, + let defaults = UserDefaults(suiteName: SceneDelegate.widgetAppGroup) else { return } + // Only write + reload when the overview actually changed. We write this on every + // scene activate/resign; reloading WidgetCenter every time burns the WidgetKit + // reload budget and leaves some widgets stale (the snapshot no longer contains a + // per-call timestamp, so identical overviews compare equal). + if defaults.string(forKey: SceneDelegate.widgetSnapshotKey) == json { return } + defaults.set(json, forKey: SceneDelegate.widgetSnapshotKey) + WidgetCenter.shared.reloadAllTimelines() + } + } + + /// iOS 26 (Liquid Glass) automatically applies a "scroll edge effect" — a blur + + /// appearance-coloured dim — to the top/bottom of a scroll view beneath the system + /// bars. On the full-screen WKWebView that renders as a dark band behind the status + /// bar in Dark Mode (independent of the in-app theme). Hide it so the web content + /// (which paints its own themed background) is what shows under the status bar. + private func configureWebViewChrome() { + guard let bridge = window?.rootViewController as? CAPBridgeViewController, + let webView = bridge.webView else { return } + webView.isOpaque = false + webView.backgroundColor = .clear + webView.scrollView.backgroundColor = .clear + if #available(iOS 26.0, *) { + webView.scrollView.topEdgeEffect.isHidden = true + webView.scrollView.bottomEdgeEffect.isHidden = true + } + } + + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + guard let urlContext = URLContexts.first else { return } + _ = ApplicationDelegateProxy.shared.application(UIApplication.shared, open: urlContext.url, options: [:]) + } + + func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { + _ = ApplicationDelegateProxy.shared.application(UIApplication.shared, continue: userActivity) { _ in } + } +} diff --git a/packages/mobile/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png b/packages/mobile/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png new file mode 100644 index 00000000..adf6ba01 Binary files /dev/null and b/packages/mobile/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png differ diff --git a/packages/mobile/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json b/packages/mobile/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..9b7d382d --- /dev/null +++ b/packages/mobile/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-512@2x.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/packages/mobile/ios/App/App/Assets.xcassets/Contents.json b/packages/mobile/ios/App/App/Assets.xcassets/Contents.json new file mode 100644 index 00000000..da4a164c --- /dev/null +++ b/packages/mobile/ios/App/App/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json b/packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json new file mode 100644 index 00000000..d7d96a67 --- /dev/null +++ b/packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "splash-2732x2732-2.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "splash-2732x2732-1.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "splash-2732x2732.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png b/packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png new file mode 100644 index 00000000..33ea6c97 Binary files /dev/null and b/packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png differ diff --git a/packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png b/packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png new file mode 100644 index 00000000..33ea6c97 Binary files /dev/null and b/packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png differ diff --git a/packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png b/packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png new file mode 100644 index 00000000..33ea6c97 Binary files /dev/null and b/packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png differ diff --git a/packages/mobile/ios/App/App/Base.lproj/LaunchScreen.storyboard b/packages/mobile/ios/App/App/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 00000000..e7ae5d78 --- /dev/null +++ b/packages/mobile/ios/App/App/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mobile/ios/App/App/Base.lproj/Main.storyboard b/packages/mobile/ios/App/App/Base.lproj/Main.storyboard new file mode 100644 index 00000000..b44df7be --- /dev/null +++ b/packages/mobile/ios/App/App/Base.lproj/Main.storyboard @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/packages/mobile/ios/App/App/Info.plist b/packages/mobile/ios/App/App/Info.plist new file mode 100644 index 00000000..3ffde0d6 --- /dev/null +++ b/packages/mobile/ios/App/App/Info.plist @@ -0,0 +1,92 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + OpenChamber + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + ITSAppUsesNonExemptEncryption + + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAllowsArbitraryLoadsInWebContent + + NSAllowsLocalNetworking + + + NSLocalNetworkUsageDescription + OpenChamber connects to OpenChamber servers on your local network. + NSCameraUsageDescription + OpenChamber uses the camera to scan a server's pairing QR code. + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + com.openchamber.app.deeplink + CFBundleURLSchemes + + openchamber + + + + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + UILaunchStoryboardName + LaunchScreen + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/packages/mobile/ios/App/App/PrivacyInfo.xcprivacy b/packages/mobile/ios/App/App/PrivacyInfo.xcprivacy new file mode 100644 index 00000000..71a204d6 --- /dev/null +++ b/packages/mobile/ios/App/App/PrivacyInfo.xcprivacy @@ -0,0 +1,30 @@ + + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + + NSPrivacyCollectedDataTypes + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + C56D.1 + + + + + diff --git a/packages/mobile/ios/App/OpenChamberNotificationService/Info.plist b/packages/mobile/ios/App/OpenChamberNotificationService/Info.plist new file mode 100644 index 00000000..51328fd3 --- /dev/null +++ b/packages/mobile/ios/App/OpenChamberNotificationService/Info.plist @@ -0,0 +1,29 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + OpenChamberNotificationService + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationService + + + diff --git a/packages/mobile/ios/App/OpenChamberNotificationService/NotificationService.swift b/packages/mobile/ios/App/OpenChamberNotificationService/NotificationService.swift new file mode 100644 index 00000000..ed29076a --- /dev/null +++ b/packages/mobile/ios/App/OpenChamberNotificationService/NotificationService.swift @@ -0,0 +1,71 @@ +import UserNotifications +import WidgetKit + +/// Runs on every incoming push that carries `mutable-content: 1` — even when the app is closed +/// — and refreshes the widgets' shared snapshot so the home/lock-screen attention count and +/// unread dot stay current without the app having to foreground. It makes NO network calls: +/// it reads the count the server already put in `aps.badge` and the `sessionId` from the push, +/// updates the App Group snapshot the app wrote, and reloads the widget timelines. The app +/// still overwrites the snapshot with the authoritative full list on its next foreground. +class NotificationService: UNNotificationServiceExtension { + private static let appGroup = "group.com.openchamber.app" + private static let snapshotKey = "widgetSnapshot" + + private var contentHandler: ((UNNotificationContent) -> Void)? + private var bestAttempt: UNMutableNotificationContent? + + override func didReceive( + _ request: UNNotificationRequest, + withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void + ) { + self.contentHandler = contentHandler + self.bestAttempt = request.content.mutableCopy() as? UNMutableNotificationContent + + refreshWidgetSnapshot(from: request) + + // Deliver the notification unchanged (we only used the push to refresh widgets). + contentHandler(bestAttempt ?? request.content) + } + + override func serviceExtensionTimeWillExpire() { + if let handler = contentHandler { + handler(bestAttempt ?? UNNotificationContent()) + } + } + + private func refreshWidgetSnapshot(from request: UNNotificationRequest) { + guard let defaults = UserDefaults(suiteName: Self.appGroup) else { return } + + var snapshot: [String: Any] = [ + "attentionCount": 0, + "recentSessions": [], + ] + if let json = defaults.string(forKey: Self.snapshotKey), + let data = json.data(using: .utf8), + let stored = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + snapshot = stored + } + + // Attention count: authoritative server value carried in aps.badge. + if let badge = request.content.badge as? Int { + snapshot["attentionCount"] = badge + } + + // Mark the pushed session unread in the existing recent list (best-effort; the full + // list/titles only refresh when the app next foregrounds). + if let sessionId = request.content.userInfo["sessionId"] as? String, + var sessions = snapshot["recentSessions"] as? [[String: Any]] { + for index in sessions.indices where sessions[index]["id"] as? String == sessionId { + sessions[index]["unread"] = true + } + snapshot["recentSessions"] = sessions + } + + if let data = try? JSONSerialization.data(withJSONObject: snapshot), + let json = String(data: data, encoding: .utf8) { + defaults.set(json, forKey: Self.snapshotKey) + } + + WidgetCenter.shared.reloadAllTimelines() + } +} diff --git a/packages/mobile/ios/App/OpenChamberNotificationService/OpenChamberNotificationService.entitlements b/packages/mobile/ios/App/OpenChamberNotificationService/OpenChamberNotificationService.entitlements new file mode 100644 index 00000000..149617ae --- /dev/null +++ b/packages/mobile/ios/App/OpenChamberNotificationService/OpenChamberNotificationService.entitlements @@ -0,0 +1,11 @@ + + + + + + com.apple.security.application-groups + + group.com.openchamber.app + + + diff --git a/packages/mobile/ios/App/OpenChamberWidget/Assets.xcassets/Contents.json b/packages/mobile/ios/App/OpenChamberWidget/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/packages/mobile/ios/App/OpenChamberWidget/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/packages/mobile/ios/App/OpenChamberWidget/Assets.xcassets/OCLogoSymbol.symbolset/Contents.json b/packages/mobile/ios/App/OpenChamberWidget/Assets.xcassets/OCLogoSymbol.symbolset/Contents.json new file mode 100644 index 00000000..03c3f191 --- /dev/null +++ b/packages/mobile/ios/App/OpenChamberWidget/Assets.xcassets/OCLogoSymbol.symbolset/Contents.json @@ -0,0 +1,13 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "filename" : "oclogo-symbol.svg", + "idiom" : "universal", + "rendering-intent" : "template" + } + ] +} diff --git a/packages/mobile/ios/App/OpenChamberWidget/Assets.xcassets/OCLogoSymbol.symbolset/oclogo-symbol.svg b/packages/mobile/ios/App/OpenChamberWidget/Assets.xcassets/OCLogoSymbol.symbolset/oclogo-symbol.svg new file mode 100644 index 00000000..e7ac3b37 --- /dev/null +++ b/packages/mobile/ios/App/OpenChamberWidget/Assets.xcassets/OCLogoSymbol.symbolset/oclogo-symbol.svg @@ -0,0 +1,55 @@ + + + + + + Small + Medium + Large + + + Ultralight + Regular + Black + Template v.3.0 + + https://github.com/swhitty/SwiftDraw + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/mobile/ios/App/OpenChamberWidget/Info.plist b/packages/mobile/ios/App/OpenChamberWidget/Info.plist new file mode 100644 index 00000000..3eb8c038 --- /dev/null +++ b/packages/mobile/ios/App/OpenChamberWidget/Info.plist @@ -0,0 +1,27 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + OpenChamber + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/packages/mobile/ios/App/OpenChamberWidget/OpenChamberControl.swift b/packages/mobile/ios/App/OpenChamberWidget/OpenChamberControl.swift new file mode 100644 index 00000000..93f345f8 --- /dev/null +++ b/packages/mobile/ios/App/OpenChamberWidget/OpenChamberControl.swift @@ -0,0 +1,38 @@ +import AppIntents +import SwiftUI +import WidgetKit + +// Control Center control (iOS 18+): tap the OpenChamber logo to start a new session. +// +// IMPORTANT: this file is a member of BOTH the app target and the widget extension target. +// iOS requires the control's AppIntent to exist in the app target too, otherwise tapping the +// control can't open the app (the tap does nothing). It's kept self-contained (inline URL, no +// dependency on the widget's shared code) so it compiles cleanly in the app target. +@available(iOS 18.0, *) +struct OpenChamberNewSessionControl: ControlWidget { + var body: some ControlWidgetConfiguration { + StaticControlConfiguration(kind: "OpenChamberNewSessionControl") { + ControlWidgetButton(action: OpenNewSessionIntent()) { + // Custom symbol is referenced via `image:` (the asset-catalog symbol path; + // `systemImage:` only finds Apple's system SF Symbols → shows a "?"). The glyph + // uses bold strokes so it stays visible at the control's small, tinted size — + // thin strokes rendered blank. + Label("New Session", image: "OCLogoSymbol") + } + } + .displayName("New Session") + .description("Start a new OpenChamber session.") + } +} + +@available(iOS 18.0, *) +struct OpenNewSessionIntent: AppIntent { + static let title: LocalizedStringResource = "New OpenChamber Session" + static let openAppWhenRun: Bool = true + static let isDiscoverable: Bool = true + + @MainActor + func perform() async throws -> some IntentResult & OpensIntent { + return .result(opensIntent: OpenURLIntent(URL(string: "openchamber://new")!)) + } +} diff --git a/packages/mobile/ios/App/OpenChamberWidget/OpenChamberWidget.entitlements b/packages/mobile/ios/App/OpenChamberWidget/OpenChamberWidget.entitlements new file mode 100644 index 00000000..3fc5495f --- /dev/null +++ b/packages/mobile/ios/App/OpenChamberWidget/OpenChamberWidget.entitlements @@ -0,0 +1,11 @@ + + + + + + com.apple.security.application-groups + + group.com.openchamber.app + + + diff --git a/packages/mobile/ios/App/OpenChamberWidget/OpenChamberWidgets.swift b/packages/mobile/ios/App/OpenChamberWidget/OpenChamberWidgets.swift new file mode 100644 index 00000000..51122f3c --- /dev/null +++ b/packages/mobile/ios/App/OpenChamberWidget/OpenChamberWidgets.swift @@ -0,0 +1,321 @@ +import SwiftUI +import WidgetKit + +// MARK: - Medium home-screen widget: recent sessions (left) + quick actions (right) + +struct OverviewWidgetView: View { + let entry: OverviewEntry + + var body: some View { + HStack(alignment: .center, spacing: 16) { + sessionsColumn + actionsGrid + } + } + + private var sessionsColumn: some View { + VStack(alignment: .leading, spacing: 0) { + if entry.snapshot.recentSessions.isEmpty { + Text("No sessions yet") + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } else { + ForEach(entry.snapshot.recentSessions.prefix(4)) { session in + Link(destination: WidgetDeepLink.session(session.id)) { + HStack(spacing: 8) { + // Every row shows a same-size dot so titles align: a filled orange + // dot for unread, a hollow grey ring for read. + unreadIndicator(session.unread) + Text(session.title.isEmpty ? "Untitled" : session.title) + .font(.subheadline) + .fontWeight(session.unread ? .semibold : .regular) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 0) + } + // Each row claims an equal share of the height → even distribution, no gap. + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + .foregroundStyle(.primary) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + + @ViewBuilder + private func unreadIndicator(_ unread: Bool) -> some View { + if unread { + Circle() + .fill(Color.orange) + .frame(width: 7, height: 7) + } else { + Circle() + .strokeBorder(Color.secondary.opacity(0.4), lineWidth: 1.5) + .frame(width: 7, height: 7) + } + } + + private var actionsGrid: some View { + VStack(spacing: 16) { + HStack(spacing: 16) { + actionButton(systemImage: "plus", url: WidgetDeepLink.newSession()) + actionButton(systemImage: "square.stack.3d.up", url: WidgetDeepLink.status()) + } + HStack(spacing: 16) { + actionButton(systemImage: "server.rack", url: WidgetDeepLink.instances()) + actionButton(systemImage: "gearshape", url: WidgetDeepLink.settings()) + } + } + .frame(maxHeight: .infinity) + } + + private func actionButton(systemImage: String, url: URL) -> some View { + Link(destination: url) { + Image(systemName: systemImage) + .font(.system(size: 22, weight: .medium)) + .frame(width: 56, height: 56) + .background(.quaternary, in: Circle()) + } + .foregroundStyle(.primary) + } +} + +struct OverviewWidget: Widget { + let kind = "OpenChamberOverview" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: OverviewProvider()) { entry in + OverviewWidgetView(entry: entry) + .containerBackground(.fill.tertiary, for: .widget) + } + .configurationDisplayName("OpenChamber") + .description("Recent sessions and quick actions.") + .supportedFamilies([.systemMedium]) + } +} + +// MARK: - Small home-screen widget: New Session + quick actions + +struct QuickActionsWidgetView: View { + var body: some View { + VStack(spacing: 10) { + // Wide primary button: New Session. + Link(destination: WidgetDeepLink.newSession()) { + HStack(spacing: 8) { + CubeLogoView() + .frame(width: 26, height: 26) + Text("Chat") + .font(.title3) + .fontWeight(.semibold) + .lineLimit(1) + Spacer(minLength: 0) + } + .padding(.horizontal, 14) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.quaternary, in: Capsule()) + } + .foregroundStyle(.primary) + + // Two round secondary actions. + HStack(spacing: 10) { + quickCircle(systemImage: "square.stack.3d.up", url: WidgetDeepLink.status()) + quickCircle(systemImage: "server.rack", url: WidgetDeepLink.instances()) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private func quickCircle(systemImage: String, url: URL) -> some View { + Link(destination: url) { + Image(systemName: systemImage) + .font(.system(size: 20, weight: .medium)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.quaternary, in: Circle()) + } + .foregroundStyle(.primary) + } +} + +struct QuickActionsWidget: Widget { + let kind = "OpenChamberQuickActions" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: OverviewProvider()) { _ in + QuickActionsWidgetView() + .containerBackground(.fill.tertiary, for: .widget) + } + .configurationDisplayName("Quick Actions") + .description("New session, status and instances.") + .supportedFamilies([.systemSmall]) + } +} + +// MARK: - Large home-screen widget: full session list with project labels + +struct SessionsWidgetView: View { + let entry: OverviewEntry + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + header + if entry.snapshot.recentSessions.isEmpty { + Text("No sessions yet") + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } else { + VStack(spacing: 0) { + ForEach(entry.snapshot.recentSessions.prefix(6)) { session in + row(session) + } + } + .frame(maxHeight: .infinity, alignment: .top) + } + } + } + + private var header: some View { + HStack(spacing: 8) { + CubeLogoView() + .frame(width: 20, height: 20) + Text("Sessions") + .font(.headline) + Spacer(minLength: 0) + if entry.snapshot.attentionCount > 0 { + Text("\(entry.snapshot.attentionCount)") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.orange) + } + Link(destination: WidgetDeepLink.newSession()) { + Image(systemName: "plus") + .font(.system(size: 15, weight: .semibold)) + .frame(width: 30, height: 30) + .background(.quaternary, in: Circle()) + } + .foregroundStyle(.primary) + } + } + + private func row(_ session: WidgetSession) -> some View { + Link(destination: WidgetDeepLink.session(session.id)) { + HStack(spacing: 10) { + Group { + if session.unread { + Circle().fill(Color.orange) + } else { + Circle().strokeBorder(Color.secondary.opacity(0.4), lineWidth: 1.5) + } + } + .frame(width: 8, height: 8) + VStack(alignment: .leading, spacing: 2) { + Text(session.title.isEmpty ? "Untitled" : session.title) + .font(.subheadline) + .fontWeight(session.unread ? .semibold : .regular) + .lineLimit(1) + if let project = session.project, !project.isEmpty { + Text(project) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 7) + } + .foregroundStyle(.primary) + } +} + +struct SessionsWidget: Widget { + let kind = "OpenChamberSessions" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: OverviewProvider()) { entry in + SessionsWidgetView(entry: entry) + .containerBackground(.fill.tertiary, for: .widget) + } + .configurationDisplayName("Sessions") + .description("Recent sessions with their project.") + .supportedFamilies([.systemLarge]) + } +} + +// MARK: - Lock Screen: logo → new session + +struct LockNewSessionView: View { + var body: some View { + ZStack { + AccessoryWidgetBackground() + CubeLogoView() + .padding(7) + } + .widgetURL(WidgetDeepLink.newSession()) + } +} + +struct LockNewSessionWidget: Widget { + let kind = "OpenChamberLockNew" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: OverviewProvider()) { _ in + LockNewSessionView() + .containerBackground(.clear, for: .widget) + } + .configurationDisplayName("New Session") + .description("Start a new OpenChamber session.") + .supportedFamilies([.accessoryCircular]) + } +} + +// MARK: - Lock Screen: attention counter + +struct LockAttentionView: View { + let entry: OverviewEntry + + var body: some View { + ZStack { + AccessoryWidgetBackground() + VStack(spacing: 0) { + Text("\(entry.snapshot.attentionCount)") + .font(.system(size: 22, weight: .semibold, design: .rounded)) + Image(systemName: "bell.badge") + .font(.system(size: 10)) + } + } + .widgetURL(WidgetDeepLink.attention()) + } +} + +struct LockAttentionWidget: Widget { + let kind = "OpenChamberLockAttention" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: OverviewProvider()) { entry in + LockAttentionView(entry: entry) + .containerBackground(.clear, for: .widget) + } + .configurationDisplayName("Needs Attention") + .description("How many sessions need attention.") + .supportedFamilies([.accessoryCircular]) + } +} + +// MARK: - Bundle + +@main +struct OpenChamberWidgetBundle: WidgetBundle { + var body: some Widget { + OverviewWidget() + SessionsWidget() + QuickActionsWidget() + LockNewSessionWidget() + LockAttentionWidget() + if #available(iOS 18.0, *) { + OpenChamberNewSessionControl() + } + } +} diff --git a/packages/mobile/ios/App/OpenChamberWidget/WidgetShared.swift b/packages/mobile/ios/App/OpenChamberWidget/WidgetShared.swift new file mode 100644 index 00000000..e98e08e5 --- /dev/null +++ b/packages/mobile/ios/App/OpenChamberWidget/WidgetShared.swift @@ -0,0 +1,137 @@ +import SwiftUI +import WidgetKit + +// MARK: - Shared model + App Group reader + +/// One row of the session overview the app writes to the shared App Group. +/// Mirrors MobileWidgetSession in packages/ui/src/apps/mobileWidgetSnapshot.ts. +struct WidgetSession: Codable, Identifiable, Hashable { + let id: String + let title: String + let unread: Bool + /// Project label for the session's directory. Optional so snapshots written before this + /// field existed still decode. + var project: String? +} + +/// The session overview snapshot. Mirrors MobileWidgetSnapshot (same field names) so the +/// JSON the app stores decodes directly. +struct WidgetSnapshot: Codable { + let attentionCount: Int + let recentSessions: [WidgetSession] + + static let empty = WidgetSnapshot(attentionCount: 0, recentSessions: []) +} + +enum WidgetStore { + static let appGroup = "group.com.openchamber.app" + static let snapshotKey = "widgetSnapshot" + + /// Reads the latest snapshot the app persisted. Returns `.empty` when nothing has been + /// written yet (fresh install / app never foregrounded) so widgets render a clean state. + static func load() -> WidgetSnapshot { + guard let defaults = UserDefaults(suiteName: appGroup), + let json = defaults.string(forKey: snapshotKey), + let data = json.data(using: .utf8), + let snapshot = try? JSONDecoder().decode(WidgetSnapshot.self, from: data) else { + return .empty + } + return snapshot + } +} + +// MARK: - Deep links (mirror packages/ui/src/apps/deepLinks.ts) + +enum WidgetDeepLink { + static func newSession() -> URL { URL(string: "openchamber://new")! } + static func attention() -> URL { URL(string: "openchamber://sessions?filter=attention")! } + static func status() -> URL { URL(string: "openchamber://status")! } + static func settings() -> URL { URL(string: "openchamber://settings")! } + static func changes() -> URL { URL(string: "openchamber://changes")! } + static func files() -> URL { URL(string: "openchamber://view/files")! } + static func instances() -> URL { URL(string: "openchamber://view/instances")! } + static func session(_ id: String) -> URL { + let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + return URL(string: "openchamber://session/\(encoded)") ?? newSession() + } +} + +// MARK: - Timeline provider + +struct OverviewEntry: TimelineEntry { + let date: Date + let snapshot: WidgetSnapshot +} + +struct OverviewProvider: TimelineProvider { + func placeholder(in context: Context) -> OverviewEntry { + OverviewEntry(date: Date(), snapshot: .empty) + } + + func getSnapshot(in context: Context, completion: @escaping (OverviewEntry) -> Void) { + completion(OverviewEntry(date: Date(), snapshot: WidgetStore.load())) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + // The app/NSE reload timelines (WidgetCenter) when the snapshot changes, but with several + // widgets sharing the app's WidgetKit reload budget iOS can refresh them unevenly and + // leave one stale. Ask for a periodic refresh too so every widget independently re-reads + // the shared snapshot and converges to the latest state (budget permitting). + let entry = OverviewEntry(date: Date(), snapshot: WidgetStore.load()) + let nextRefresh = Date().addingTimeInterval(10 * 60) + completion(Timeline(entries: [entry], policy: .after(nextRefresh))) + } +} + +// MARK: - Logo (full OpenChamber mark drawn from the SVG) + +/// The OpenChamber logo, drawn to match packages/web/public/logo-dark-512x512.svg: an +/// isometric cube with translucent face fills, stroked edges, and the OpenCode mark on the +/// top face. Faces use low-opacity `.primary` so the system tint on the Lock Screen / Control +/// Center reads as a translucent fill (no colour) rather than a flat wireframe. Coordinates are +/// the SVG inner group (range x:-41.568…41.568, y:-48…48). +struct CubeLogoView: View { + var body: some View { + Canvas { context, size in + let halfW: CGFloat = 41.568 + let halfH: CGFloat = 48 + let scale = min(size.width / (halfW * 2), size.height / (halfH * 2)) + let cx = size.width / 2 + let cy = size.height / 2 + let lineWidth = max(1.5, 3 * scale) + + // Cube coordinate → canvas point. + func p(_ x: CGFloat, _ y: CGFloat) -> CGPoint { CGPoint(x: cx + x * scale, y: cy + y * scale) } + // OpenCode-mark local coordinate → canvas point (SVG: matrix(0.866,0.5,-0.866,0.5,0,-24) · scale(0.75)). + func m(_ x: CGFloat, _ y: CGFloat) -> CGPoint { + let s: CGFloat = 0.75 + let mx = 0.866 * s * x - 0.866 * s * y + let my = 0.5 * s * x + 0.5 * s * y - 24 + return p(mx, my) + } + + var left = Path() + left.move(to: p(0, 0)); left.addLine(to: p(-halfW, -24)); left.addLine(to: p(-halfW, 24)); left.addLine(to: p(0, 48)); left.closeSubpath() + var right = Path() + right.move(to: p(0, 0)); right.addLine(to: p(halfW, -24)); right.addLine(to: p(halfW, 24)); right.addLine(to: p(0, 48)); right.closeSubpath() + var top = Path() + top.move(to: p(0, -48)); top.addLine(to: p(-halfW, -24)); top.addLine(to: p(0, 0)); top.addLine(to: p(halfW, -24)); top.closeSubpath() + + context.fill(left, with: .color(.primary.opacity(0.2))) + context.fill(right, with: .color(.primary.opacity(0.35))) + context.stroke(left, with: .color(.primary), style: StrokeStyle(lineWidth: lineWidth, lineJoin: .round)) + context.stroke(right, with: .color(.primary), style: StrokeStyle(lineWidth: lineWidth, lineJoin: .round)) + context.stroke(top, with: .color(.primary), style: StrokeStyle(lineWidth: lineWidth, lineJoin: .round)) + + // OpenCode mark: square ring (even-odd) + a partial inner fill. + var ring = Path() + ring.move(to: m(-16, -20)); ring.addLine(to: m(16, -20)); ring.addLine(to: m(16, 20)); ring.addLine(to: m(-16, 20)); ring.closeSubpath() + ring.move(to: m(-8, -12)); ring.addLine(to: m(-8, 12)); ring.addLine(to: m(8, 12)); ring.addLine(to: m(8, -12)); ring.closeSubpath() + context.fill(ring, with: .color(.primary), style: FillStyle(eoFill: true)) + + var inner = Path() + inner.move(to: m(-8, -4)); inner.addLine(to: m(8, -4)); inner.addLine(to: m(8, 12)); inner.addLine(to: m(-8, 12)); inner.closeSubpath() + context.fill(inner, with: .color(.primary.opacity(0.4))) + } + } +} diff --git a/packages/mobile/ios/App/Podfile b/packages/mobile/ios/App/Podfile new file mode 100644 index 00000000..cb62cfa5 --- /dev/null +++ b/packages/mobile/ios/App/Podfile @@ -0,0 +1,40 @@ +require_relative '../../../../node_modules/.bun/@capacitor+ios@8.4.1+767ac80cbab8ae50/node_modules/@capacitor/ios/scripts/pods_helpers' + +platform :ios, '15.5' +use_frameworks! + +# workaround to avoid Xcode caching of Pods that requires +# Product -> Clean Build Folder after new Cordova plugins installed +# Requires CocoaPods 1.6 or newer +install! 'cocoapods', :disable_input_output_paths => true + +def capacitor_pods + pod 'Capacitor', :path => '../../../../node_modules/.bun/@capacitor+ios@8.4.1+767ac80cbab8ae50/node_modules/@capacitor/ios' + pod 'CapacitorCordova', :path => '../../../../node_modules/.bun/@capacitor+ios@8.4.1+767ac80cbab8ae50/node_modules/@capacitor/ios' + pod 'AparajitaCapacitorSecureStorage', :path => '../../../../node_modules/.bun/@aparajita+capacitor-secure-storage@8.0.0/node_modules/@aparajita/capacitor-secure-storage' + pod 'CapacitorMlkitBarcodeScanning', :path => '../../../../node_modules/.bun/@capacitor-mlkit+barcode-scanning@8.1.0+767ac80cbab8ae50/node_modules/@capacitor-mlkit/barcode-scanning' + pod 'CapacitorApp', :path => '../../../../node_modules/.bun/@capacitor+app@8.1.0+767ac80cbab8ae50/node_modules/@capacitor/app' + pod 'CapacitorKeyboard', :path => '../../../../node_modules/.bun/@capacitor+keyboard@8.0.5+767ac80cbab8ae50/node_modules/@capacitor/keyboard' + pod 'CapacitorPushNotifications', :path => '../../../../node_modules/.bun/@capacitor+push-notifications@8.1.1+767ac80cbab8ae50/node_modules/@capacitor/push-notifications' + pod 'CapacitorStatusBar', :path => '../../../../node_modules/.bun/@capacitor+status-bar@8.0.2+767ac80cbab8ae50/node_modules/@capacitor/status-bar' +end + +target 'App' do + capacitor_pods + # Add your Pods here +end + +post_install do |installer| + assertDeploymentTarget(installer) + # Xcode 16+/iOS 26 SDK rejects deployment targets below 15.0, and GoogleMLKit + # (pulled in by the barcode scanner) requires iOS 15.5+. Force every Pods target + # up so the Capacitor/Cordova/MLKit pods build for a real device. + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.5' + # Capacitor's Cordova compatibility headers use quoted includes; newer Xcode + # treats those as errors in framework headers. Keep it a (non-fatal) warning. + config.build_settings['CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER'] = 'NO' + end + end +end diff --git a/packages/mobile/ios/App/Podfile.lock b/packages/mobile/ios/App/Podfile.lock new file mode 100644 index 00000000..cd65e1a5 --- /dev/null +++ b/packages/mobile/ios/App/Podfile.lock @@ -0,0 +1,134 @@ +PODS: + - AparajitaCapacitorSecureStorage (8.0.0): + - Capacitor + - KeychainSwift (~> 21.0) + - Capacitor (8.4.1): + - CapacitorCordova + - CapacitorApp (8.1.0): + - Capacitor + - CapacitorCordova (8.4.1) + - CapacitorKeyboard (8.0.5): + - Capacitor + - CapacitorMlkitBarcodeScanning (8.1.0): + - Capacitor + - GoogleMLKit/BarcodeScanning (~> 8.0.0) + - CapacitorPushNotifications (8.1.1): + - Capacitor + - CapacitorStatusBar (8.0.2): + - Capacitor + - GoogleDataTransport (10.1.0): + - nanopb (~> 3.30910.0) + - PromisesObjC (~> 2.4) + - GoogleMLKit/BarcodeScanning (8.0.0): + - GoogleMLKit/MLKitCore + - MLKitBarcodeScanning (~> 7.0.0) + - GoogleMLKit/MLKitCore (8.0.0): + - MLKitCommon (~> 13.0.0) + - GoogleToolboxForMac/Defines (4.2.1) + - GoogleToolboxForMac/Logger (4.2.1): + - GoogleToolboxForMac/Defines (= 4.2.1) + - "GoogleToolboxForMac/NSData+zlib (4.2.1)": + - GoogleToolboxForMac/Defines (= 4.2.1) + - GoogleUtilities/Environment (8.1.1): + - GoogleUtilities/Privacy + - GoogleUtilities/Logger (8.1.1): + - GoogleUtilities/Environment + - GoogleUtilities/Privacy + - GoogleUtilities/Privacy (8.1.1) + - GoogleUtilities/UserDefaults (8.1.1): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GTMSessionFetcher/Core (3.5.0) + - KeychainSwift (21.0.0) + - MLImage (1.0.0-beta7) + - MLKitBarcodeScanning (7.0.0): + - MLKitCommon (~> 13.0) + - MLKitVision (~> 9.0) + - MLKitCommon (13.0.0): + - GoogleDataTransport (~> 10.0) + - GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1) + - "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)" + - GoogleUtilities/Logger (~> 8.0) + - GoogleUtilities/UserDefaults (~> 8.0) + - GTMSessionFetcher/Core (< 4.0, >= 3.3.2) + - MLKitVision (9.0.0): + - GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1) + - "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)" + - GTMSessionFetcher/Core (< 4.0, >= 3.3.2) + - MLImage (= 1.0.0-beta7) + - MLKitCommon (~> 13.0) + - nanopb (3.30910.0): + - nanopb/decode (= 3.30910.0) + - nanopb/encode (= 3.30910.0) + - nanopb/decode (3.30910.0) + - nanopb/encode (3.30910.0) + - PromisesObjC (2.4.1) + +DEPENDENCIES: + - "AparajitaCapacitorSecureStorage (from `../../../../node_modules/.bun/@aparajita+capacitor-secure-storage@8.0.0/node_modules/@aparajita/capacitor-secure-storage`)" + - "Capacitor (from `../../../../node_modules/.bun/@capacitor+ios@8.4.1+767ac80cbab8ae50/node_modules/@capacitor/ios`)" + - "CapacitorApp (from `../../../../node_modules/.bun/@capacitor+app@8.1.0+767ac80cbab8ae50/node_modules/@capacitor/app`)" + - "CapacitorCordova (from `../../../../node_modules/.bun/@capacitor+ios@8.4.1+767ac80cbab8ae50/node_modules/@capacitor/ios`)" + - "CapacitorKeyboard (from `../../../../node_modules/.bun/@capacitor+keyboard@8.0.5+767ac80cbab8ae50/node_modules/@capacitor/keyboard`)" + - "CapacitorMlkitBarcodeScanning (from `../../../../node_modules/.bun/@capacitor-mlkit+barcode-scanning@8.1.0+767ac80cbab8ae50/node_modules/@capacitor-mlkit/barcode-scanning`)" + - "CapacitorPushNotifications (from `../../../../node_modules/.bun/@capacitor+push-notifications@8.1.1+767ac80cbab8ae50/node_modules/@capacitor/push-notifications`)" + - "CapacitorStatusBar (from `../../../../node_modules/.bun/@capacitor+status-bar@8.0.2+767ac80cbab8ae50/node_modules/@capacitor/status-bar`)" + +SPEC REPOS: + trunk: + - GoogleDataTransport + - GoogleMLKit + - GoogleToolboxForMac + - GoogleUtilities + - GTMSessionFetcher + - KeychainSwift + - MLImage + - MLKitBarcodeScanning + - MLKitCommon + - MLKitVision + - nanopb + - PromisesObjC + +EXTERNAL SOURCES: + AparajitaCapacitorSecureStorage: + :path: "../../../../node_modules/.bun/@aparajita+capacitor-secure-storage@8.0.0/node_modules/@aparajita/capacitor-secure-storage" + Capacitor: + :path: "../../../../node_modules/.bun/@capacitor+ios@8.4.1+767ac80cbab8ae50/node_modules/@capacitor/ios" + CapacitorApp: + :path: "../../../../node_modules/.bun/@capacitor+app@8.1.0+767ac80cbab8ae50/node_modules/@capacitor/app" + CapacitorCordova: + :path: "../../../../node_modules/.bun/@capacitor+ios@8.4.1+767ac80cbab8ae50/node_modules/@capacitor/ios" + CapacitorKeyboard: + :path: "../../../../node_modules/.bun/@capacitor+keyboard@8.0.5+767ac80cbab8ae50/node_modules/@capacitor/keyboard" + CapacitorMlkitBarcodeScanning: + :path: "../../../../node_modules/.bun/@capacitor-mlkit+barcode-scanning@8.1.0+767ac80cbab8ae50/node_modules/@capacitor-mlkit/barcode-scanning" + CapacitorPushNotifications: + :path: "../../../../node_modules/.bun/@capacitor+push-notifications@8.1.1+767ac80cbab8ae50/node_modules/@capacitor/push-notifications" + CapacitorStatusBar: + :path: "../../../../node_modules/.bun/@capacitor+status-bar@8.0.2+767ac80cbab8ae50/node_modules/@capacitor/status-bar" + +SPEC CHECKSUMS: + AparajitaCapacitorSecureStorage: 8128d05cafcb13b00448e20fb388a0edccd44b12 + Capacitor: 35242afe195b1e53c58ca1b827d1b444c5e6602b + CapacitorApp: 449ffe26375e96f8aaaee625ac6e01e5c57c8650 + CapacitorCordova: eebe6bcf807b1b06f3f48237650f96bbcd0eef09 + CapacitorKeyboard: b6b0744890cdb1d9a96e2cafcc9253fdcc55de3b + CapacitorMlkitBarcodeScanning: 31c6af9f39873ff69e16ed5b39ebe2e1915f16fe + CapacitorPushNotifications: ec08d589c226a2c0db7c032ec1bf5b044ec85f8e + CapacitorStatusBar: 01d5763b4ed720de5ce2edbc938de6a98f4c8f32 + GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 + GoogleMLKit: ddd51d7dff36ff28defa69afedd9cdce684fd857 + GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8 + GoogleUtilities: 4f2618a4a1e762a1ee134a1e2323bba9843e06da + GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 + KeychainSwift: 4a71a45c802fd9e73906457c2dcbdbdc06c9419d + MLImage: 2ab9c968e75f57911c16f4c9d9e8a8e9604a86a1 + MLKitBarcodeScanning: 72c6437f13a900833b400136be53a8a5d86f42fa + MLKitCommon: 26b779f072a182c1603d4c88a101c350cac837b1 + MLKitVision: fa8dea9012ac59497c79ddbe9ebf32051047ac4c + nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 + PromisesObjC: 752c3227f599e3467650e47ea36f433eeb10c273 + +PODFILE CHECKSUM: 99c62a30e73aa8ec805869506d2b1969ee4fb92d + +COCOAPODS: 1.16.2 diff --git a/packages/mobile/package.json b/packages/mobile/package.json new file mode 100644 index 00000000..75596240 --- /dev/null +++ b/packages/mobile/package.json @@ -0,0 +1,47 @@ +{ + "name": "@openchamber/mobile", + "version": "1.13.2", + "private": true, + "type": "module", + "scripts": { + "build": "bun run --cwd ../web build && node scripts/prepare-web-assets.mjs", + "sync": "node scripts/with-mobile-env.mjs \"bun run build && cap sync\"", + "add:ios": "cap add ios", + "add:android": "cap add android", + "build:android:debug": "node scripts/with-mobile-env.mjs \"bun run sync && ./android/gradlew -p android assembleDebug\"", + "android:devices": "node scripts/with-mobile-env.mjs \"node scripts/android-device.mjs devices\"", + "android:install": "node scripts/with-mobile-env.mjs \"node scripts/android-device.mjs install\"", + "android:launch": "node scripts/with-mobile-env.mjs \"node scripts/android-device.mjs launch\"", + "android:run": "node scripts/with-mobile-env.mjs \"node scripts/android-device.mjs run\"", + "android:logcat": "node scripts/with-mobile-env.mjs \"node scripts/android-device.mjs logcat\"", + "build:ios:simulator": "node scripts/with-mobile-env.mjs \"node scripts/ios-sim-build.mjs\"", + "sim:boot": "node scripts/with-mobile-env.mjs \"node scripts/ios-sim.mjs boot\"", + "sim:install": "node scripts/with-mobile-env.mjs \"node scripts/ios-sim.mjs install\"", + "sim:launch": "node scripts/with-mobile-env.mjs \"node scripts/ios-sim.mjs launch\"", + "sim:run": "node scripts/with-mobile-env.mjs \"node scripts/ios-sim.mjs run\"", + "sim:serve": "node scripts/with-mobile-env.mjs \"serve-sim --detach -q\"", + "sim:list": "node scripts/with-mobile-env.mjs \"serve-sim --list -q\"", + "sim:kill": "node scripts/with-mobile-env.mjs \"serve-sim --kill\"", + "open:ios": "cap open ios", + "open:android": "cap open android", + "type-check": "tsc --noEmit", + "lint": "eslint \"./**/*.{ts,tsx,js,mjs}\" --config ../../eslint.config.js --ignore-pattern dist --ignore-pattern ios --ignore-pattern android" + }, + "dependencies": { + "@aparajita/capacitor-secure-storage": "^8.0.0", + "@capacitor-mlkit/barcode-scanning": "^8.1.0", + "@capacitor/app": "^8.0.0", + "@capacitor/core": "^8.4.1", + "@capacitor/keyboard": "^8.0.0", + "@capacitor/push-notifications": "^8.1.1", + "@capacitor/status-bar": "^8.0.0" + }, + "devDependencies": { + "@capacitor/android": "^8.4.1", + "@capacitor/cli": "^8.4.1", + "@capacitor/ios": "^8.4.1", + "@types/node": "^24.3.1", + "serve-sim": "^0.1.34", + "typescript": "~5.9.0" + } +} diff --git a/packages/mobile/scripts/android-device.mjs b/packages/mobile/scripts/android-device.mjs new file mode 100644 index 00000000..82aab84c --- /dev/null +++ b/packages/mobile/scripts/android-device.mjs @@ -0,0 +1,93 @@ +// Install / launch the debug APK on a connected Android device via adb. +// +// Mirrors scripts/ios-sim.mjs for the iOS simulator. Run through with-mobile-env.mjs so adb +// (ANDROID_HOME/platform-tools) and the JDK are on PATH. Build the APK first with +// `bun run build:android:debug`; `run` installs + launches it. + +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const mobileRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); +const APK_PATH = join(mobileRoot, 'android', 'app', 'build', 'outputs', 'apk', 'debug', 'app-debug.apk'); +const APP_ID = 'com.openchamber.app'; +const LAUNCH_ACTIVITY = `${APP_ID}/.MainActivity`; + +const adb = (args, { capture = false, allowFail = false } = {}) => { + const result = spawnSync('adb', args, { stdio: capture ? 'pipe' : 'inherit', encoding: 'utf8' }); + if (!allowFail && result.status !== 0) { + throw new Error(`adb ${args.join(' ')} exited with ${result.status ?? result.signal}`); + } + return result; +}; + +const connectedDevices = () => { + const output = adb(['devices'], { capture: true, allowFail: true }).stdout || ''; + return output + .split('\n') + .slice(1) + .map((line) => line.trim()) + .filter((line) => line.endsWith('\tdevice')) + .map((line) => line.split('\t')[0]); +}; + +const requireDevice = () => { + const devices = connectedDevices(); + if (devices.length === 0) { + console.error( + 'No authorized Android device found. Enable Developer options + USB debugging on the device, ' + + 'connect it, and accept the "Allow USB debugging" prompt. Check with: bun run android:devices', + ); + process.exit(1); + } + return devices; +}; + +const requireApk = () => { + if (!existsSync(APK_PATH)) { + throw new Error(`Debug APK not found at ${APK_PATH}. Build it first: bun run build:android:debug`); + } +}; + +const install = () => { + requireDevice(); + requireApk(); + adb(['install', '-r', APK_PATH]); +}; + +const launch = () => { + requireDevice(); + adb(['shell', 'am', 'start', '-n', LAUNCH_ACTIVITY]); +}; + +const command = process.argv[2]; +switch (command) { + case 'devices': + adb(['devices', '-l']); + break; + case 'install': + install(); + break; + case 'launch': + launch(); + break; + case 'run': + install(); + launch(); + break; + case 'logcat': { + requireDevice(); + const pid = (adb(['shell', 'pidof', APP_ID], { capture: true, allowFail: true }).stdout || '').trim().split(/\s+/)[0]; + if (pid) { + adb(['logcat', `--pid=${pid}`]); + } else { + console.warn(`[android] ${APP_ID} is not running; streaming Capacitor/Chromium logs. Launch the app to see its logs.`); + adb(['logcat', '-s', 'Capacitor:V', 'Capacitor/Console:V', 'chromium:V']); + } + break; + } + default: + console.error('Usage: node scripts/android-device.mjs '); + process.exit(1); +} diff --git a/packages/mobile/scripts/ios-sim-build.mjs b/packages/mobile/scripts/ios-sim-build.mjs new file mode 100644 index 00000000..78b8a1fd --- /dev/null +++ b/packages/mobile/scripts/ios-sim-build.mjs @@ -0,0 +1,69 @@ +// Builds the iOS app for the Apple-Silicon simulator. +// +// Why this is special: the barcode scanner (`@capacitor-mlkit/barcode-scanning` → +// GoogleMLKit) ships only device-arm64 + simulator-x86_64 slices — there is NO +// arm64-simulator slice. CocoaPods therefore adds `EXCLUDED_ARCHS[sdk=iphonesimulator*] = +// arm64`, so a normal build produces an x86_64-only binary that can't install on an +// arm64-only iOS 26+ simulator ("does not contain code for ... arm64"). +// +// QR scanning needs a camera, which the simulator doesn't have, so dropping the scanner for +// simulator builds loses nothing: this script temporarily removes the MLKit pod, builds an +// arm64 simulator binary, then restores the Podfile + Pods so device/TestFlight builds keep +// the scanner. The JS side already degrades cleanly when the native plugin is absent +// (mobileQrScan: getScannerPlugin() → null → isQrScanSupported() false). + +import { spawnSync } from 'node:child_process'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const mobileRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); +const iosAppDir = join(mobileRoot, 'ios', 'App'); +const podfilePath = join(iosAppDir, 'Podfile'); + +const run = (command, args, cwd = mobileRoot) => { + const result = spawnSync(command, args, { stdio: 'inherit', cwd, env: process.env }); + if (result.status !== 0) { + throw new Error(`${command} ${args.join(' ')} exited with ${result.status ?? result.signal}`); + } +}; + +// 1. Build the web bundle and copy it into the iOS project (no pod regen — `copy`, not `sync`). +run('bun', ['run', 'build']); +run('cap', ['copy', 'ios']); + +// 2. Strip the MLKit barcode-scanning pod, then reinstall pods without it. +const originalPodfile = readFileSync(podfilePath, 'utf8'); +const strippedPodfile = originalPodfile + .split('\n') + .filter((line) => !line.includes('CapacitorMlkitBarcodeScanning')) + .join('\n'); + +if (strippedPodfile === originalPodfile) { + console.warn('[ios-sim-build] CapacitorMlkitBarcodeScanning not found in Podfile — building as-is.'); +} + +try { + writeFileSync(podfilePath, strippedPodfile); + run('pod', ['install'], iosAppDir); + + // 3. Build for the simulator. With MLKit gone the arm64 simulator slice builds cleanly. + run('xcodebuild', [ + '-workspace', 'ios/App/App.xcworkspace', + '-scheme', 'App', + '-configuration', 'Debug', + '-sdk', 'iphonesimulator', + '-destination', 'generic/platform=iOS Simulator', + 'CODE_SIGNING_ALLOWED=NO', + 'build', + ]); +} finally { + // 4. Always restore the Podfile + Pods so device/TestFlight builds keep the scanner. Pods/ + // and Podfile.lock return to their original state (a no-op for git once this completes). + if (strippedPodfile !== originalPodfile) { + writeFileSync(podfilePath, originalPodfile); + run('pod', ['install'], iosAppDir); + } +} + +console.log('[ios-sim-build] Simulator build complete. Run `bun run sim:run` to install + launch.'); diff --git a/packages/mobile/scripts/ios-sim.mjs b/packages/mobile/scripts/ios-sim.mjs new file mode 100644 index 00000000..c5fe3a0e --- /dev/null +++ b/packages/mobile/scripts/ios-sim.mjs @@ -0,0 +1,95 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; + +const BUNDLE_ID = 'com.openchamber.app'; +const DEFAULT_DEVICE = 'iPhone 17 Pro'; + +const run = (command, args, options = {}) => { + const result = spawnSync(command, args, { + env: process.env, + stdio: options.capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', + encoding: 'utf8', + }); + + if (result.status !== 0) { + if (options.capture && result.stderr) process.stderr.write(result.stderr); + process.exit(result.status ?? 1); + } + + return result.stdout?.trim() ?? ''; +}; + +const getBootedDevice = () => { + const json = run('xcrun', ['simctl', 'list', 'devices', 'booted', '--json'], { capture: true }); + const data = JSON.parse(json); + for (const devices of Object.values(data.devices ?? {})) { + const device = devices.find((item) => item.state === 'Booted'); + if (device) return device; + } + return null; +}; + +const bootDevice = (name = DEFAULT_DEVICE) => { + const booted = getBootedDevice(); + if (booted) return booted.udid; + + const json = run('xcrun', ['simctl', 'list', 'devices', 'available', '--json'], { capture: true }); + const data = JSON.parse(json); + for (const devices of Object.values(data.devices ?? {})) { + const match = devices.find((device) => device.name === name && device.isAvailable !== false); + if (!match) continue; + run('xcrun', ['simctl', 'boot', match.udid]); + return match.udid; + } + + throw new Error(`No available simulator named "${name}" found.`); +}; + +const getBuiltAppPath = () => { + const appPath = run('xcodebuild', [ + '-workspace', 'ios/App/App.xcworkspace', + '-scheme', 'App', + '-configuration', 'Debug', + '-sdk', 'iphonesimulator', + '-showBuildSettings', + ], { capture: true }) + .split('\n') + .map((line) => line.trim()) + .find((line) => line.startsWith('TARGET_BUILD_DIR = ')) + ?.replace('TARGET_BUILD_DIR = ', ''); + + if (!appPath) throw new Error('Unable to resolve iOS simulator build output directory.'); + const fullPath = path.join(appPath, 'App.app'); + if (!existsSync(fullPath)) throw new Error(`Built app not found at ${fullPath}. Run bun run build:ios:simulator first.`); + return fullPath; +}; + +const command = process.argv[2]; + +switch (command) { + case 'boot': { + const udid = bootDevice(process.argv.slice(3).join(' ') || DEFAULT_DEVICE); + console.log(udid); + break; + } + case 'install': { + const udid = bootDevice(); + run('xcrun', ['simctl', 'install', udid, getBuiltAppPath()]); + break; + } + case 'launch': { + const udid = bootDevice(); + run('xcrun', ['simctl', 'launch', udid, BUNDLE_ID]); + break; + } + case 'run': { + const udid = bootDevice(); + run('xcrun', ['simctl', 'install', udid, getBuiltAppPath()]); + run('xcrun', ['simctl', 'launch', udid, BUNDLE_ID]); + break; + } + default: + console.error('Usage: node scripts/ios-sim.mjs [device name]'); + process.exit(1); +} diff --git a/packages/mobile/scripts/prepare-web-assets.mjs b/packages/mobile/scripts/prepare-web-assets.mjs new file mode 100644 index 00000000..13103ff1 --- /dev/null +++ b/packages/mobile/scripts/prepare-web-assets.mjs @@ -0,0 +1,16 @@ +import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const mobileRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const webDist = path.resolve(mobileRoot, '../web/dist'); +const mobileDist = path.resolve(mobileRoot, 'dist'); +const mobileHtml = path.join(mobileDist, 'mobile.html'); +const indexHtml = path.join(mobileDist, 'index.html'); + +await rm(mobileDist, { recursive: true, force: true }); +await mkdir(mobileDist, { recursive: true }); +await cp(webDist, mobileDist, { recursive: true }); + +const html = await readFile(mobileHtml, 'utf8'); +await writeFile(indexHtml, html); diff --git a/packages/mobile/scripts/with-mobile-env.mjs b/packages/mobile/scripts/with-mobile-env.mjs new file mode 100644 index 00000000..100c0f8b --- /dev/null +++ b/packages/mobile/scripts/with-mobile-env.mjs @@ -0,0 +1,48 @@ +import { spawn, spawnSync } from 'node:child_process'; + +const command = process.argv.slice(2).join(' '); + +if (!command) { + console.error('Usage: node scripts/with-mobile-env.mjs '); + process.exit(1); +} + +// Respect an explicit DEVELOPER_DIR, then fall back to whatever the user selected via +// `xcode-select` (so an Xcode beta / non-default install is honoured). Hardcoding +// /Applications/Xcode.app overrode `xcode-select` and forced builds onto the wrong Xcode, +// whose simulator runtimes may not match — xcodebuild then can't find the chosen simulator. +const selectedDeveloperDir = () => { + try { + const result = spawnSync('xcode-select', ['-p'], { encoding: 'utf8' }); + const path = result.status === 0 ? result.stdout.trim() : ''; + return path.length > 0 ? path : null; + } catch { + return null; + } +}; + +const developerDir = + process.env.DEVELOPER_DIR || selectedDeveloperDir() || '/Applications/Xcode.app/Contents/Developer'; +const javaHome = process.env.JAVA_HOME || '/opt/homebrew/opt/openjdk@21'; +const androidHome = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT || '/opt/homebrew/share/android-commandlinetools'; + +const child = spawn(command, { + env: { + ...process.env, + DEVELOPER_DIR: developerDir, + JAVA_HOME: javaHome, + ANDROID_HOME: androidHome, + ANDROID_SDK_ROOT: androidHome, + PATH: `${javaHome}/bin:${androidHome}/platform-tools:${process.env.PATH || ''}`, + }, + shell: true, + stdio: 'inherit', +}); + +child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 1); +}); diff --git a/packages/mobile/tsconfig.json b/packages/mobile/tsconfig.json new file mode 100644 index 00000000..80af150c --- /dev/null +++ b/packages/mobile/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "moduleDetection": "force", + "strict": true, + "skipLibCheck": true, + "types": ["node"], + "noEmit": true + }, + "include": ["capacitor.config.ts"] +} diff --git a/packages/ui/package.json b/packages/ui/package.json index 85c4e41d..509eb743 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -11,7 +11,13 @@ "lint": "eslint \"./src/**/*.{ts,tsx}\" --config ../../eslint.config.js" }, "dependencies": { + "@aparajita/capacitor-secure-storage": "^8.0.0", "@base-ui/react": "^1.4.0", + "@capacitor/app": "^8.0.0", + "@capacitor/core": "^8.4.1", + "@capacitor/keyboard": "^8.0.0", + "@capacitor/push-notifications": "^8.1.1", + "@capacitor/status-bar": "^8.0.0", "@codemirror/autocomplete": "^6.20.0", "@codemirror/commands": "^6.10.1", "@codemirror/lang-cpp": "^6.0.3", diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 8ce5f1b6..842e8372 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -34,7 +34,6 @@ import { markSessionViewed } from '@/sync/notification-store'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { opencodeClient } from '@/lib/opencode/client'; -import { disposeTerminalInputTransport } from '@/lib/terminalApi'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { getRuntimeKey, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch'; import { useAutoReviewStore } from '@/stores/useAutoReviewStore'; @@ -57,8 +56,8 @@ import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; import { useI18n } from '@/lib/i18n'; import { applyMobileKeyboardMode } from '@/lib/mobileKeyboardMode'; import { SyncAppEffects } from '@/apps/AppEffects'; +import { resetAppForRuntimeEndpointChange } from '@/apps/runtimeEndpointReset'; import { useAppFontEffects } from '@/apps/useAppFontEffects'; -import { resetStreamingState } from '@/sync/streaming'; import { OpenCodeUpdateToast } from '@/components/update/OpenCodeUpdateToast'; import { markStartupTrace, startupTraceEnabled } from '@/lib/startupTrace'; @@ -268,25 +267,7 @@ function App({ apis }: AppProps) { React.useEffect(() => { return subscribeRuntimeEndpointChanged((detail) => { - useSessionUIStore.getState().prepareForRuntimeSwitch(detail.previousRuntimeKey); - useUIStore.getState().prepareForRuntimeSwitch(detail.previousRuntimeKey); - if (detail.previousRuntimeKey) { - useAutoReviewStore.getState().stopRunningRunsForRuntime(detail.previousRuntimeKey); - } - disposeTerminalInputTransport(); - opencodeClient.reconnectToRuntimeBaseUrl(); - useConfigStore.setState({ - providers: [], - agents: [], - isConnected: false, - isInitialized: false, - connectionPhase: 'connecting', - lastDisconnectReason: null, - }); - useProjectsStore.getState().resetForRuntimeSwitch(); - useSessionUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey); - useUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey); - resetStreamingState(); + resetAppForRuntimeEndpointChange(detail); setRuntimeEndpointEpoch((epoch) => epoch + 1); setInitRetryExhausted(false); setInitRetryEpoch((epoch) => epoch + 1); diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx index 1dc98318..756e564c 100644 --- a/packages/ui/src/apps/MobileApp.tsx +++ b/packages/ui/src/apps/MobileApp.tsx @@ -7,6 +7,8 @@ import { McpDropdownContent } from '@/components/mcp/McpDropdown'; import { AboutSettings } from '@/components/sections/openchamber/AboutSettings'; import { OpenCodeUpdateToast } from '@/components/update/OpenCodeUpdateToast'; import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay'; +import { Button } from '@/components/ui/button'; +import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo'; import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { ChatView } from '@/components/views/ChatView'; import { SettingsView } from '@/components/views/SettingsView'; @@ -29,6 +31,7 @@ import { resolveProjectForDirectory, resolveProjectForSessionDirectory } from '@ import { clampPercent, formatQuotaResetLabel, formatQuotaValueLabel, formatWindowLabel, QUOTA_PROVIDERS, resolveUsageTone } from '@/lib/quota'; import { getDisplayModelName } from '@/lib/quota/model-families'; import { runtimeFetch } from '@/lib/runtime-fetch'; +import { getRuntimeApiBaseUrl, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch'; import { sessionEvents } from '@/lib/sessionEvents'; import { cn } from '@/lib/utils'; import { useConfigStore } from '@/stores/useConfigStore'; @@ -55,7 +58,14 @@ import { MobileFilesSurface } from './MobileFilesSurface'; import { MobileSessionsSheet } from './MobileSessionsSheet'; import { MobileSurfaceShell } from './MobileSurfaceShell'; import { DedicatedMobileAppProvider, type MobileAppActions } from './mobileAppContext'; +import { autoConnectLastInstance, isSameConnectionUrl, useMobileConnection } from './mobileConnections'; +import { isQrScanSupported, parseConnectionPayload, scanConnectionQr } from './mobileQrScan'; +import { resetAppForRuntimeEndpointChange } from './runtimeEndpointReset'; import { useAppFontEffects } from './useAppFontEffects'; +import { useFontsReady } from './useFontsReady'; +import { useDeepLinkHandlers, useDeepLinkSource } from './deepLinkNavigation'; +import { useEdgeSwipeSessionSwitch } from './useEdgeSwipeSessionSwitch'; +import { useNativePushRegistration } from './useNativePushRegistration'; const MOBILE_SETTINGS_PAGES = [ 'appearance', @@ -76,6 +86,177 @@ type MobileAppProps = { apis: RuntimeAPIs; }; +const isCapacitorMobileApp = (): boolean => { + if (typeof window === 'undefined') return false; + const maybeCapacitor = (window as typeof window & { + Capacitor?: { isNativePlatform?: () => boolean; getPlatform?: () => string }; + }).Capacitor; + if (maybeCapacitor?.isNativePlatform?.() === true) return true; + return window.location.protocol === 'capacitor:'; +}; + +const useNativeMobileChrome = (): void => { + React.useEffect(() => { + if (!isCapacitorMobileApp()) return; + + let disposed = false; + const cleanup: Array<() => void> = []; + const root = document.documentElement; + // Marks the Capacitor shell so keyboard-inset CSS only applies here, not in + // the browser-hosted PWA (which handles the keyboard via dvh / interactive-widget). + root.classList.add('oc-capacitor-app'); + // Platform marker: Android resizes the window for the keyboard (no manual inset), so the + // shell's height transition (meant for iOS's animated --oc-keyboard-inset) must be off there + // — otherwise the height animates against the instant native resize and the header bounces. + const capacitorPlatform = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.(); + if (capacitorPlatform === 'android') { + root.classList.add('oc-platform-android'); + } + + const setInset = (px: number) => { + root.style.setProperty('--oc-keyboard-inset', `${Math.max(0, Math.round(px))}px`); + }; + + void import('@capacitor/status-bar').then(async ({ StatusBar, Style }) => { + if (disposed) return; + // Keep the status bar transparent over the WebView. A custom UIScene lifecycle + // (iOS 26) plus returning from background can silently drop the overlay state, + // letting an opaque status-bar background flash in at the top — so re-assert it + // on mount, once shortly after (startup race), and whenever the app re-activates. + const platform = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.(); + const applyStatusBar = async () => { + if (platform === 'android') { + // Android doesn't feed env(safe-area-inset-top) to CSS, so overlaying the status bar + // makes content render under it. Inset the WebView below the bar instead and paint the + // bar with the resolved theme background (the splash colours the theme system persists). + const isDark = document.documentElement.classList.contains('dark'); + const themeBg = + (isDark ? localStorage.getItem('splashBgDark') : localStorage.getItem('splashBgLight')) || + (isDark ? '#171515' : '#fffdf4'); + await StatusBar.setOverlaysWebView({ overlay: false }).catch(() => undefined); + await StatusBar.setBackgroundColor({ color: themeBg }).catch(() => undefined); + // Capacitor Style is named for the CONTENT: Style.Light = dark text (light bg), + // Style.Dark = light text (dark bg). So dark theme → Style.Dark, light theme → Style.Light. + await StatusBar.setStyle({ style: isDark ? Style.Dark : Style.Light }).catch(() => undefined); + await StatusBar.show().catch(() => undefined); + return; + } + await StatusBar.setStyle({ style: Style.Default }).catch(() => undefined); + await StatusBar.setOverlaysWebView({ overlay: true }).catch(() => undefined); + await StatusBar.show().catch(() => undefined); + }; + await applyStatusBar(); + const retry = window.setTimeout(() => void applyStatusBar(), 400); + cleanup.push(() => window.clearTimeout(retry)); + + const { App } = await import('@capacitor/app'); + const stateHandle = await App.addListener('appStateChange', ({ isActive }) => { + if (isActive) void applyStatusBar(); + }); + if (disposed) { + void stateHandle.remove(); + return; + } + cleanup.push(() => void stateHandle.remove()); + }).catch(() => undefined); + + void import('@capacitor/keyboard').then(async ({ Keyboard }) => { + if (disposed) return; + // iOS (WKWebView, resize: 'none') keeps 100dvh at full height with the keyboard + // overlaying, so we lift the UI manually via --oc-keyboard-inset. Android resizes the + // window for the keyboard (dvh already shrinks), so applying the inset on top double- + // counts and floats the composer a keyboard-height above the keyboard — skip it there. + const platform = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.(); + if (platform === 'android') return; + await Keyboard.setAccessoryBarVisible({ isVisible: true }).catch(() => undefined); + + // `keyboardWillShow` fires at the START of the iOS keyboard animation and + // carries the final height, so we set the inset once here and let the CSS + // transition (tuned to mimic the iOS keyboard curve/duration) carry the rise. + // visualViewport tracking was tried but doesn't shrink under WKWebView's + // `resize: 'none'`, so it never reported the keyboard — this event is the + // reliable signal. + const showHandle = await Keyboard.addListener('keyboardWillShow', (info) => { + root.classList.add('oc-keyboard-open'); + setInset(info.keyboardHeight); + }); + const hideHandle = await Keyboard.addListener('keyboardWillHide', () => { + root.classList.remove('oc-keyboard-open'); + setInset(0); + }); + if (disposed) { + void showHandle.remove(); + void hideHandle.remove(); + return; + } + cleanup.push(() => void showHandle.remove(), () => void hideHandle.remove()); + }).catch(() => undefined); + + return () => { + disposed = true; + cleanup.forEach((remove) => remove()); + root.classList.remove('oc-capacitor-app', 'oc-keyboard-open', 'oc-platform-android'); + root.style.removeProperty('--oc-keyboard-inset'); + }; + }, []); +}; + +const useNativeMobileLifecycle = (onResume: () => void): void => { + React.useEffect(() => { + if (!isCapacitorMobileApp()) return; + + let disposed = false; + const cleanup: Array<() => void> = []; + + void import('@capacitor/app').then(async ({ App }) => { + if (disposed) return; + const state = await App.addListener('appStateChange', ({ isActive }) => { + document.documentElement.classList.toggle('oc-native-app-active', isActive); + if (isActive) onResume(); + }); + const resume = await App.addListener('resume', onResume); + if (disposed) { + void state.remove(); + void resume.remove(); + return; + } + cleanup.push(() => void state.remove(), () => void resume.remove()); + }).catch(() => undefined); + + return () => { + disposed = true; + cleanup.forEach((remove) => remove()); + }; + }, [onResume]); +}; + +const useNativeAndroidBackButton = (onBack: () => boolean): void => { + React.useEffect(() => { + if (!isCapacitorMobileApp()) return; + + let disposed = false; + let remove: (() => void) | null = null; + + void import('@capacitor/app').then(async ({ App }) => { + if (disposed) return; + const listener = await App.addListener('backButton', () => { + if (onBack()) return; + void App.minimizeApp().catch(() => undefined); + }); + if (disposed) { + void listener.remove(); + return; + } + remove = () => void listener.remove(); + }).catch(() => undefined); + + return () => { + disposed = true; + remove?.(); + }; + }, [onBack]); +}; + const normalizePath = (value?: string | null): string => (value || '').replace(/\\/g, '/').replace(/\/+$/g, ''); @@ -95,6 +276,12 @@ const formatTokens = (value: number): string => { return String(value); }; +const mobileInputKeyboardProps = { + autoComplete: 'off', + autoCorrect: 'off', + spellCheck: false, +} as const; + const getProjectLabel = (path: string): string => { const normalized = normalizePath(path); if (!normalized) return ''; @@ -103,7 +290,7 @@ const getProjectLabel = (path: string): string => { }; type OverflowItem = { - key: 'files' | 'changes' | 'mcp' | 'update' | 'settings'; + key: 'files' | 'changes' | 'mcp' | 'instances' | 'update' | 'settings'; icon?: IconName; iconNode?: React.ReactNode; label: string; @@ -122,6 +309,540 @@ const getProjectDisplayLabel = (project: ProjectEntry | null, fallbackDirectory: return getProjectLabel(fallbackDirectory); }; +const MobileConnectionWelcome: React.FC<{ onConnected: () => void }> = ({ onConnected }) => { + const { t } = useI18n(); + const conn = useMobileConnection(onConnected); + const { connections, isBusy, isPasswordBusy, error, pendingConnection } = conn; + const [serverUrl, setServerUrl] = React.useState(''); + const [connectionName, setConnectionName] = React.useState(''); + const [clientToken, setClientToken] = React.useState(''); + const [isScanning, setIsScanning] = React.useState(false); + const [advancedOpen, setAdvancedOpen] = React.useState(false); + const qrScanSupported = React.useMemo(() => isQrScanSupported(), []); + const [password, setPassword] = React.useState(''); + + const handleSubmit = React.useCallback((event: React.FormEvent) => { + event.preventDefault(); + void conn.connect({ url: serverUrl, clientToken, label: connectionName }); + }, [clientToken, conn, connectionName, serverUrl]); + + // Accept a pasted pairing link (openchamber://connect?...) in the URL field and + // split it back into the server URL + token, revealing the token field when present. + const handleUrlChange = React.useCallback((value: string) => { + if (/^openchamber:\/\//i.test(value.trim())) { + const payload = parseConnectionPayload(value); + if (payload) { + setServerUrl(payload.url); + if (payload.label) setConnectionName(payload.label); + if (payload.clientToken) setClientToken(payload.clientToken); + if (payload.label || payload.clientToken) setAdvancedOpen(true); + return; + } + } + setServerUrl(value); + }, []); + + const handleScanQr = React.useCallback(async () => { + if (isScanning || isBusy) return; + conn.setError(null); + setIsScanning(true); + try { + const result = await scanConnectionQr(); + switch (result.status) { + case 'ok': + setServerUrl(result.url); + if (result.label) setConnectionName(result.label); + if (result.clientToken) setClientToken(result.clientToken); + if (result.label || result.clientToken) setAdvancedOpen(true); + await conn.connect({ url: result.url, clientToken: result.clientToken, label: result.label }); + break; + case 'permission-denied': + conn.setError(t('mobile.connect.scan.permissionDenied')); + break; + case 'invalid': + conn.setError(t('mobile.connect.scan.invalid')); + break; + case 'unsupported': + conn.setError(t('mobile.connect.scan.unsupported')); + break; + case 'failed': + conn.setError(t('mobile.connect.scan.failed')); + break; + case 'cancelled': + default: + break; + } + } finally { + setIsScanning(false); + } + }, [conn, isBusy, isScanning, t]); + + const handlePasswordSubmit = React.useCallback((event: React.FormEvent) => { + event.preventDefault(); + void conn.submitPassword(password); + }, [conn, password]); + + const cancelPassword = React.useCallback(() => { + setPassword(''); + conn.cancelPassword(); + }, [conn]); + + return ( +
+
+
+ +

{t('mobile.connect.welcome.title')}

+
+ + {pendingConnection ? ( +
+
+ + + +
+

{pendingConnection.label}

+

{pendingConnection.url}

+
+
+ setPassword(event.target.value)} + placeholder={t('mobile.connect.password.placeholder')} + aria-label={t('mobile.connect.password.label')} + type="password" + autoFocus + className="h-12 w-full rounded-[16px] border border-border/70 bg-surface-elevated px-4 text-[16px] text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary focus:ring-2 focus:ring-primary/20" + /> + {error ?

{error}

: null} + + +
+ ) : ( +
+
+ setConnectionName(event.target.value)} + placeholder={t('mobile.instances.label.placeholder')} + aria-label={t('mobile.instances.label.label')} + autoComplete="off" + autoCapitalize="words" + autoCorrect="off" + spellCheck={false} + className="h-12 w-full rounded-[16px] border border-border/70 bg-surface-elevated px-4 text-center text-[16px] text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary focus:ring-2 focus:ring-primary/20" + /> + handleUrlChange(event.target.value)} + placeholder={t('mobile.connect.url.placeholder')} + aria-label={t('mobile.connect.url.label')} + type="url" + inputMode="url" + autoCapitalize="none" + className="h-12 w-full rounded-[16px] border border-border/70 bg-surface-elevated px-4 text-center text-[16px] text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary focus:ring-2 focus:ring-primary/20" + /> + +
+ +
+
+
+ +

{t('mobile.connect.token.hint')}

+
+
+
+
+ + {error ?

{error}

: null} + + +
+ + + {!qrScanSupported ? ( +

+ {t('mobile.connect.scan.unsupported')} +

+ ) : null} +
+ )} + + {!pendingConnection && connections.length > 0 ? ( +
+

+ {t('mobile.connect.saved.title')} +

+
+ {connections.map((connection) => ( + + ))} +
+
+ ) : null} +
+
+ ); +}; + +const MobileInstancesSurface: React.FC<{ + onConnect: () => void; + onActiveConnectionDeleted: () => void; +}> = ({ onActiveConnectionDeleted, onConnect }) => { + const { t } = useI18n(); + const conn = useMobileConnection(onConnect); + const { + connections, isBusy, isPasswordBusy, error, pendingConnection, + connect, submitPassword, cancelPassword, saveConnection, removeConnection, setError, + } = conn; + const [editingId, setEditingId] = React.useState(null); + const editingConnection = editingId ? connections.find((connection) => connection.id === editingId) ?? null : null; + const [confirmingDeleteId, setConfirmingDeleteId] = React.useState(null); + const [url, setUrl] = React.useState(''); + const [label, setLabel] = React.useState(''); + const [clientToken, setClientToken] = React.useState(''); + const [password, setPassword] = React.useState(''); + const [isScanning, setIsScanning] = React.useState(false); + const qrScanSupported = React.useMemo(() => isQrScanSupported(), []); + + // Populate/clear the form imperatively (on edit tap / cancel / save) rather than via + // an effect keyed on the derived connection object. With an effect, any churn of the + // connections list re-fires it and overwrites what the user is typing — the keyboard + // "resets" mid-edit. Imperative population is immune to that. + const resetForm = React.useCallback(() => { + setEditingId(null); + setUrl(''); + setLabel(''); + setClientToken(''); + setError(null); + }, [setError]); + + const saveInstance = React.useCallback((event: React.FormEvent) => { + event.preventDefault(); + void saveConnection({ url, label, clientToken }).then((saved) => { + if (saved) resetForm(); + }); + }, [clientToken, label, resetForm, saveConnection, url]); + + // Scan a pairing QR into the add/edit form fields (does not change edit mode, so + // the form-reset effect doesn't wipe the scanned values). The user reviews + saves. + const handleScanInstance = React.useCallback(async () => { + if (isScanning) return; + setError(null); + setIsScanning(true); + try { + const result = await scanConnectionQr(); + switch (result.status) { + case 'ok': + setUrl(result.url); + if (result.label) setLabel(result.label); + if (result.clientToken) setClientToken(result.clientToken); + break; + case 'permission-denied': + setError(t('mobile.connect.scan.permissionDenied')); + break; + case 'invalid': + setError(t('mobile.connect.scan.invalid')); + break; + case 'unsupported': + setError(t('mobile.connect.scan.unsupported')); + break; + case 'failed': + setError(t('mobile.connect.scan.failed')); + break; + case 'cancelled': + default: + break; + } + } finally { + setIsScanning(false); + } + }, [isScanning, setError, t]); + + const handlePasswordSubmit = React.useCallback((event: React.FormEvent) => { + event.preventDefault(); + void submitPassword(password); + }, [password, submitPassword]); + + const cancelPasswordPrompt = React.useCallback(() => { + setPassword(''); + cancelPassword(); + }, [cancelPassword]); + + // Two-step delete (mirrors the session sheet): the trash icon arms the row, a + // second tap on the destructive button confirms, the X disarms. No hover relied on. + const toggleConfirmDelete = React.useCallback((id: string) => { + setConfirmingDeleteId((current) => (current === id ? null : id)); + }, []); + + const confirmDelete = React.useCallback((id: string) => { + setConfirmingDeleteId(null); + if (editingId === id) resetForm(); + void removeConnection(id).then((removed) => { + if (removed && isSameConnectionUrl(removed.url, getRuntimeApiBaseUrl())) { + onActiveConnectionDeleted(); + } + }); + }, [editingId, onActiveConnectionDeleted, removeConnection, resetForm]); + + const inputClass = 'h-12 w-full rounded-[16px] border border-border/70 bg-surface-elevated px-4 text-[16px] text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary focus:ring-2 focus:ring-primary/20'; + + if (pendingConnection) { + return ( +
+
+
+
+ + + +
+

{pendingConnection.label}

+

{pendingConnection.url}

+
+
+ setPassword(event.target.value)} + placeholder={t('mobile.connect.password.placeholder')} + aria-label={t('mobile.connect.password.label')} + type="password" + autoFocus + className={inputClass} + /> + {error ?

{error}

: null} + + +
+
+
+ ); + } + + return ( +
+
+
+ {connections.length > 0 ? ( +
+ {connections.map((connection) => { + const confirming = confirmingDeleteId === connection.id; + return ( +
+ +
+ {confirming ? ( + + ) : ( + + )} + +
+
+ ); + })} +
+ ) : ( +

+ {t('mobile.connect.saved.empty')} +

+ )} + +
+
+

+ {editingConnection ? t('mobile.instances.editTitle') : t('mobile.instances.addTitle')} +

+ {editingConnection ? ( + + ) : null} +
+
+ + {!qrScanSupported ? ( +

{t('mobile.connect.scan.unsupported')}

+ ) : null} +
+ + + + {error ?

{error}

: null} + +
+
+
+
+ ); +}; + type MobileUsageLimitRow = { key: string; label: string; @@ -787,12 +1508,13 @@ const MobileHeader: React.FC<{ ); }; -const MobileShell: React.FC = () => { +const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onActiveConnectionDeleted }) => { const { t } = useI18n(); const [sessionsSheetOpen, setSessionsSheetOpen] = React.useState(false); const [filesOpen, setFilesOpen] = React.useState(false); const [changesOpen, setChangesOpen] = React.useState(false); const [mcpOpen, setMcpOpen] = React.useState(false); + const [instancesOpen, setInstancesOpen] = React.useState(false); const [isMcpRefreshing, setIsMcpRefreshing] = React.useState(false); const [settingsOpen, setSettingsOpen] = React.useState(false); const [updateOpen, setUpdateOpen] = React.useState(false); @@ -804,6 +1526,7 @@ const MobileShell: React.FC = () => { const setSettingsPage = useUIStore((state) => state.setSettingsPage); const updateAvailable = useUpdateStore((state) => state.available); const updateRuntimeType = useUpdateStore((state) => state.runtimeType); + const showCapacitorOnlyFeatures = React.useMemo(() => isCapacitorMobileApp(), []); const mcpServers = useMcpConfigStore((state) => state.mcpServers); const setMcpDraft = useMcpConfigStore((state) => state.setMcpDraft); const setSelectedMcp = useMcpConfigStore((state) => state.setSelectedMcp); @@ -832,6 +1555,101 @@ const MobileShell: React.FC = () => { setPendingChangesDiff(null); }, []); + // Expose the shell's panel-opening actions to the deep-link layer so openchamber:// URLs + // (and notification taps / widgets) can navigate to these surfaces. Session and + // new-session intents resolve directly against the store, so they aren't wired here. + const deepLinkHandlers = React.useMemo( + () => ({ + openSessions: () => setSessionsSheetOpen(true), + openView: (target: 'files' | 'mcp' | 'instances' | 'update') => { + if (target === 'files') setFilesOpen(true); + else if (target === 'mcp') setMcpOpen(true); + else if (target === 'instances') setInstancesOpen(true); + else if (target === 'update') setUpdateOpen(true); + }, + openChanges: ({ path, staged }: { path?: string; staged?: boolean } = {}) => { + setPendingChangesDiff(path ? { path, staged: staged === true } : null); + setChangesOpen(true); + }, + openSettings: (section?: string) => { + if (section) setSettingsPage(section as Parameters[0]); + setSettingsInitialMobileStage(section ? 'page-content' : 'nav'); + setSettingsOpen(true); + }, + }), + [setSettingsPage], + ); + useDeepLinkHandlers(deepLinkHandlers); + + // Edge swipe (left/right screen edge → centre) switches between sessions, with a directional + // slide+fade on the chat content so it's obvious the session changed. + const chatMainRef = React.useRef(null); + const chatAnimRef = React.useRef(null); + const swipeDirectionRef = React.useRef<'prev' | 'next' | null>(null); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + // Record the swipe direction; the animation itself runs in the layout effect below, once the + // new session's content has committed — running it inline in the swipe callback raced the + // re-render and dropped the animation on roughly every other switch. + const recordSwipeDirection = React.useCallback((direction: 'prev' | 'next') => { + swipeDirectionRef.current = direction; + }, []); + useEdgeSwipeSessionSwitch(chatMainRef, { onSwitch: recordSwipeDirection }); + + React.useLayoutEffect(() => { + const direction = swipeDirectionRef.current; + swipeDirectionRef.current = null; + if (!direction) return; // only animate swipe-driven switches + const element = chatAnimRef.current; + if (!element || typeof element.animate !== 'function') return; + element.getAnimations().forEach((animation) => animation.cancel()); + const fromX = direction === 'prev' ? -70 : 70; + element.animate( + [ + { opacity: 0.1, transform: `translateX(${fromX}px)` }, + { opacity: 1, transform: 'translateX(0)' }, + ], + { duration: 300, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' }, + ); + }, [currentSessionId]); + + const handleNativeBack = React.useCallback(() => { + if (overflowOpen) { + setOverflowOpen(false); + return true; + } + if (sessionsSheetOpen) { + setSessionsSheetOpen(false); + return true; + } + if (filesOpen) { + setFilesOpen(false); + return true; + } + if (changesOpen) { + closeChanges(); + return true; + } + if (mcpOpen) { + setMcpOpen(false); + return true; + } + if (instancesOpen) { + setInstancesOpen(false); + return true; + } + if (settingsOpen) { + setSettingsOpen(false); + return true; + } + if (updateOpen) { + setUpdateOpen(false); + return true; + } + return false; + }, [changesOpen, closeChanges, filesOpen, instancesOpen, mcpOpen, overflowOpen, sessionsSheetOpen, settingsOpen, updateOpen]); + + useNativeAndroidBackButton(handleNativeBack); + const showUpdateItem = updateAvailable && (updateRuntimeType === 'desktop' || updateRuntimeType === 'web'); const openMcpCreateSettings = React.useCallback(() => { @@ -881,7 +1699,8 @@ const MobileShell: React.FC = () => { }, [currentDirectory, isMcpRefreshing, loadMcpConfigs, refreshMcpStatus]); const overflowItems: OverflowItem[] = React.useMemo( - () => [ + () => { + const items: OverflowItem[] = [ { key: 'files', icon: 'file-text', @@ -901,13 +1720,24 @@ const MobileShell: React.FC = () => { label: t('mobile.menu.mcp'), onSelect: () => setMcpOpen(true), }, - ...(showUpdateItem ? [{ - key: 'update' as const, - icon: 'download' as const, - label: t('mobile.menu.update'), - onSelect: () => setUpdateOpen(true), - }] : []), - { + ]; + if (showCapacitorOnlyFeatures) { + items.push({ + key: 'instances', + icon: 'server', + label: t('mobile.menu.instances'), + onSelect: () => setInstancesOpen(true), + }); + } + if (showUpdateItem) { + items.push({ + key: 'update', + icon: 'download', + label: t('mobile.menu.update'), + onSelect: () => setUpdateOpen(true), + }); + } + items.push({ key: 'settings', icon: 'settings-3', label: t('mobile.menu.settings'), @@ -915,25 +1745,28 @@ const MobileShell: React.FC = () => { setSettingsInitialMobileStage('nav'); setSettingsOpen(true); }, - }, - ], - [dirtyChangeCount, showUpdateItem, t], + }); + return items; + }, + [dirtyChangeCount, showCapacitorOnlyFeatures, showUpdateItem, t], ); return (
setSessionsSheetOpen(true)} onOpenMenu={() => setOverflowOpen(true)} /> -
- - - +
+
+ + + +
{ ) : null} + {instancesOpen && showCapacitorOnlyFeatures ? ( + setInstancesOpen(false)} + ariaLabel={t('mobile.menu.instances')} + title={t('mobile.menu.instances')} + > + setInstancesOpen(false)} + onActiveConnectionDeleted={onActiveConnectionDeleted} + /> + + ) : null} + {settingsOpen ? ( { }; export function MobileApp({ apis }: MobileAppProps) { + const { t } = useI18n(); const initializeApp = useConfigStore((state) => state.initializeApp); const isInitialized = useConfigStore((state) => state.isInitialized); const isConnected = useConfigStore((state) => state.isConnected); + const connectionPhase = useConfigStore((state) => state.connectionPhase); const providersCount = useConfigStore((state) => state.providers.length); const agentsCount = useConfigStore((state) => state.agents.length); const loadProviders = useConfigStore((state) => state.loadProviders); @@ -1089,19 +1938,75 @@ export function MobileApp({ apis }: MobileAppProps) { const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus); const setPlanModeEnabled = useFeatureFlagsStore((state) => state.setPlanModeEnabled); const projects = useProjectsStore((state) => state.projects); + const [connectionEpoch, setConnectionEpoch] = React.useState(0); + const [runtimeEndpointEpoch, setRuntimeEndpointEpoch] = React.useState(0); + const [showConnectionRecovery, setShowConnectionRecovery] = React.useState(false); + // Cold-launch auto-connect to the last instance: 'pending'/'attempting' hold the + // splash so we don't flash the connect screen; 'done' means we either connected or + // exhausted the attempt (then the connect screen shows). + const [autoConnectPhase, setAutoConnectPhase] = React.useState<'pending' | 'attempting' | 'done'>('pending'); + const isNativeMobileApp = React.useMemo(() => isCapacitorMobileApp(), []); + + const handleNativeResume = React.useCallback(() => { + if (!getRuntimeApiBaseUrl()) return; + void initializeApp(); + void refreshGitHubAuthStatus(apis.github, { force: true }); + if (providersCount === 0) void loadProviders({ source: 'mobileApp:nativeResume' }); + if (agentsCount === 0) void loadAgents({ source: 'mobileApp:nativeResume' }); + }, [agentsCount, apis.github, initializeApp, loadAgents, loadProviders, providersCount, refreshGitHubAuthStatus]); + + useNativeMobileChrome(); + useNativeMobileLifecycle(handleNativeResume); React.useEffect(() => { registerRuntimeAPIs(apis); return () => registerRuntimeAPIs(null); }, [apis]); + // Switching instances (or disconnecting) only changes the runtime endpoint; the + // stores still hold the previous instance's data. Mirror the web App.tsx reset + // sequence so the UI fully re-bootstraps against the new server instead of going + // stale. The SyncProvider is keyed by runtimeEndpointEpoch so it remounts too. + React.useEffect(() => { + return subscribeRuntimeEndpointChanged((detail) => { + resetAppForRuntimeEndpointChange(detail); + setRuntimeEndpointEpoch((epoch) => epoch + 1); + setConnectionEpoch((epoch) => epoch + 1); + }); + }, []); + + // On cold launch, silently reconnect to the most-recent saved instance so a + // returning user — and notification deep-links — land in the app instead of the + // connect screen. The splash is held while we try (see render below). If there's + // no saved instance, it's unreachable, or it needs a (re)login, we fall through + // to the connect screen. A successful switchRuntimeEndpoint fires the endpoint- + // changed subscription above, which bumps the epochs and bootstraps the app. + React.useEffect(() => { + if (!isNativeMobileApp || isConnected || getRuntimeApiBaseUrl()) { + setAutoConnectPhase('done'); + return; + } + let cancelled = false; + setAutoConnectPhase('attempting'); + void autoConnectLastInstance() + .catch(() => false) + .then(() => { + if (!cancelled) setAutoConnectPhase('done'); + }); + return () => { + cancelled = true; + }; + // Run once on mount — auto-connect is a cold-launch concern only. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + React.useEffect(() => { setIsMobile(true); }, [setIsMobile]); React.useEffect(() => { void initializeApp(); - }, [initializeApp]); + }, [connectionEpoch, initializeApp]); React.useEffect(() => { if (!isConnected) return; @@ -1187,21 +2092,116 @@ export function MobileApp({ apis }: MobileAppProps) { return () => window.clearTimeout(timeout); }, [clearError, error]); + React.useEffect(() => { + if (!isNativeMobileApp || isConnected || !getRuntimeApiBaseUrl()) { + setShowConnectionRecovery(false); + return; + } + const timeout = window.setTimeout(() => setShowConnectionRecovery(true), 8000); + return () => window.clearTimeout(timeout); + }, [isConnected, isNativeMobileApp, connectionEpoch, runtimeEndpointEpoch]); + useAppFontEffects(); usePushVisibilityBeacon({ enabled: true }); useUpdatePolling(); useWindowTitle(); useRouter(); + // APNs is the only notification channel on the native app (background-capable, + // focus-suppressed server-side via the visibility beacon). Local notifications are + // intentionally disabled — they can't tell foreground from background in a WKWebView + // (document.hasFocus() is unreliable) and leaked while the app was open; the in-app SSE + // notification dispatch is no-op'd for native in renderMobileApp. + useNativePushRegistration({ enabled: isNativeMobileApp && isConnected }); + // Single native deep-link entry point: notification taps AND the openchamber:// URL + // scheme (widgets, Live Activities, external links). Registered unconditionally so a + // cold-launch tap/open isn't lost on the connect/splash screen; intents stash until + // the app is ready (connected + initialized) and shell handlers are registered. + useDeepLinkSource({ ready: isNativeMobileApp && isConnected && isInitialized }); + const fontsReady = useFontsReady(); + + // `isConnected` is a LIVE flag that flips false on every transient SSE/WS drop and + // back true on reconnect. We must NOT blank the whole app to a loader on those — + // only on the initial connect / instance switch (connectionPhase 'connecting'). + // While 'reconnecting' (we were connected before), keep MobileShell mounted so the + // UI doesn't reload on every network blip. + const isReconnecting = !isConnected && connectionPhase === 'reconnecting'; + + // Hold a logo splash until the UI web font is loaded, so the first UI the user sees + // already uses the real font instead of flashing the fallback and reflowing (FOUT). + if (!fontsReady) { + return ( +
+ +
+ ); + } + + if (!isConnected && !isReconnecting && isNativeMobileApp) { + // A runtime endpoint is already selected (first connect or switching instances): + // show a loader while it re-bootstraps instead of flashing the onboarding screen. + if (getRuntimeApiBaseUrl()) { + return ( +
+
+ + {showConnectionRecovery ? ( + <> +
+

{t('sessionAuth.error.networkTitle')}

+

{t('sessionAuth.error.networkDescription')}

+
+ + + ) : null} +
+
+ ); + } + // Cold-launch auto-connect is still resolving — hold the splash instead of + // flashing the connect screen. Only show the connect screen once we've finished + // (no saved instance, unreachable, or needs re-login). + if (autoConnectPhase !== 'done') { + return ( +
+ +
+ ); + } + return setConnectionEpoch((value) => value + 1)} />; + } + + if (!isConnected && !isReconnecting) { + return ( +
+
+

{t('sessionAuth.error.networkTitle')}

+

{t('sessionAuth.error.networkDescription')}

+
+
+ ); + } return ( - +
- + { + switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' }); + setConnectionEpoch((value) => value + 1); + }} /> {isInitialized ? : null}
diff --git a/packages/ui/src/apps/MobileSurfaceShell.tsx b/packages/ui/src/apps/MobileSurfaceShell.tsx index 5ba246e5..ad9f4af1 100644 --- a/packages/ui/src/apps/MobileSurfaceShell.tsx +++ b/packages/ui/src/apps/MobileSurfaceShell.tsx @@ -67,6 +67,15 @@ export const MobileSurfaceShell: React.FC = ({ const isDraggingRef = React.useRef(false); const surfaceRef = React.useRef(null); const previousFocusRef = React.useRef(null); + // Keep onClose in a ref so the focus/keydown effect below depends only on `open`. + // The parent passes a fresh inline onClose on every render; if the effect depended + // on it, each parent re-render (e.g. an SSE store update) would re-run it and + // refocus the first element — stealing focus from whatever input the user is in + // and collapsing the keyboard mid-edit. + const onCloseRef = React.useRef(onClose); + React.useEffect(() => { + onCloseRef.current = onClose; + }, [onClose]); if (typeof document !== 'undefined' && !rootRef.current) { rootRef.current = ensureSurfaceRoot(); @@ -112,7 +121,7 @@ export const MobileSurfaceShell: React.FC = ({ const focusTimer = window.setTimeout(focusFirstElement, ENTER_DELAY_MS); const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { - onClose(); + onCloseRef.current(); return; } if (event.key !== 'Tab') return; @@ -145,7 +154,7 @@ export const MobileSurfaceShell: React.FC = ({ previousFocusRef.current?.focus?.({ preventScroll: true }); previousFocusRef.current = null; }; - }, [onClose, open]); + }, [open]); const handleDragStart = (event: React.TouchEvent) => { if (disableSwipeDismiss) return; @@ -208,7 +217,7 @@ export const MobileSurfaceShell: React.FC = ({ return createPortal(
void) | null = null; +let resolved = false; + +export const appBootReadyPromise = new Promise((resolve) => { + resolveBoot = resolve; +}); + +export function markAppBootReady(): void { + if (resolved) return; + resolved = true; + resolveBoot?.(); +} diff --git a/packages/ui/src/apps/deepLinkNavigation.ts b/packages/ui/src/apps/deepLinkNavigation.ts new file mode 100644 index 00000000..cff08359 --- /dev/null +++ b/packages/ui/src/apps/deepLinkNavigation.ts @@ -0,0 +1,198 @@ +import React from 'react'; + +import { isCapacitorApp } from '@/lib/platform'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useUIStore } from '@/stores/useUIStore'; + +import { buildDeepLink, parseDeepLink, type DeepLinkIntent, type SessionsFilter, type ViewTarget } from './deepLinks'; + +/** + * Navigation layer for {@link DeepLinkIntent}s — the only place that knows how to *apply* a + * deep link. Producers (notification taps, widget `widgetURL`, Live Activities) feed intents + * in via {@link useDeepLinkSource}; the surfaces that can satisfy them register imperative + * handlers via {@link useDeepLinkHandlers}. Session/new-session navigation goes straight to + * the session store (always available), so those resolve even before the shell has mounted. + * + * Intents that arrive before the app is ready (cold launch from a tap/widget) or before their + * handler is registered are stashed in a module-level holder that survives the connect flow + * and SyncProvider remount, then applied as soon as the app becomes ready / the handler + * appears. Only the most recent intent is kept (newest wins) — a burst of taps shouldn't queue. + */ + +export interface DeepLinkHandlers { + /** Open the sessions sheet, optionally pre-filtered (filter support is best-effort for now). */ + openSessions?: (filter?: SessionsFilter) => void; + /** Open a non-session surface (files / mcp / instances / update). */ + openView?: (target: ViewTarget) => void; + /** Open the Changes surface, optionally jumping straight to a file diff. */ + openChanges?: (options?: { path?: string; staged?: boolean }) => void; + /** Open Settings, optionally at a specific section. */ + openSettings?: (section?: string) => void; +} + +let handlers: DeepLinkHandlers = {}; +let ready = false; +let pending: DeepLinkIntent | null = null; + +const execute = (intent: DeepLinkIntent): boolean => { + switch (intent.type) { + case 'session': + void useSessionUIStore.getState().setCurrentSession(intent.sessionId, intent.directory ?? null); + return true; + + case 'new-session': { + const store = useSessionUIStore.getState(); + store.openNewSessionDraft(); + if (intent.directory || intent.projectId) { + store.setNewSessionDraftTarget({ + directoryOverride: intent.directory ?? null, + projectId: intent.projectId ?? null, + selectedProjectId: intent.projectId ?? null, + }); + } + return true; + } + + case 'sessions': + if (!handlers.openSessions) return false; + handlers.openSessions(intent.filter); + return true; + + case 'status': + // The session status panel is store-backed (useUIStore.mobileSessionPanelOpen), + // so it opens without a shell handler — like session/new-session. + useUIStore.getState().setMobileSessionPanelOpen(true); + return true; + + case 'view': + if (!handlers.openView) return false; + handlers.openView(intent.target); + return true; + + case 'changes': + if (!handlers.openChanges) return false; + handlers.openChanges({ path: intent.path, staged: intent.staged }); + return true; + + case 'settings': + if (!handlers.openSettings) return false; + handlers.openSettings(intent.section); + return true; + } +}; + +const flush = (): void => { + if (!ready || !pending) return; + const intent = pending; + // Drop the stash before executing; if the handler isn't registered yet, execute() returns + // false and we re-stash so a later registerDeepLinkHandlers() flush can retry it. + pending = null; + if (!execute(intent)) { + pending = intent; + } +}; + +/** Apply an intent now if possible, otherwise stash it until the app is ready / a handler appears. */ +export const applyDeepLinkIntent = (intent: DeepLinkIntent): void => { + pending = intent; + flush(); +}; + +/** Convenience: parse a raw `openchamber://…` URL and apply it. No-op for unrecognised URLs. */ +export const applyDeepLinkUrl = (raw: string | null | undefined): void => { + const intent = parseDeepLink(raw); + if (intent) { + applyDeepLinkIntent(intent); + } +}; + +const setReady = (value: boolean): void => { + ready = value; + flush(); +}; + +/** + * Register the surfaces that can satisfy shell-scoped intents (sessions/settings/views/changes). + * Call from the component that owns those panels; the handlers are torn down on unmount. + * Registering also flushes any pending intent that was waiting for these handlers. + */ +export const useDeepLinkHandlers = (next: DeepLinkHandlers): void => { + React.useEffect(() => { + handlers = next; + flush(); + return () => { + if (handlers === next) { + handlers = {}; + } + }; + }, [next]); +}; + +/** + * Single native entry point for deep links. Subscribes to both the custom URL scheme + * (`App.appUrlOpen` — widgets, Live Activities, external links) and notification taps + * (`pushNotificationActionPerformed`), normalising each into a {@link DeepLinkIntent}. + * Both listeners are registered UNCONDITIONALLY so a cold-launch tap/open isn't lost while + * the app is still connecting; intents stash until `ready` (connected + initialized). + */ +export const useDeepLinkSource = (options: { ready: boolean }): void => { + const { ready: isReady } = options; + + React.useEffect(() => { + setReady(isReady); + }, [isReady]); + + React.useEffect(() => { + if (!isCapacitorApp()) return; + let disposed = false; + const cleanup: Array<() => void> = []; + + void import('@capacitor/app') + .then(async ({ App }) => { + if (disposed) return; + const handle = await App.addListener('appUrlOpen', (event) => { + applyDeepLinkUrl(event?.url); + }); + if (disposed) { + void handle.remove(); + return; + } + cleanup.push(() => void handle.remove()); + }) + .catch(() => undefined); + + void import('@capacitor/push-notifications') + .then(async ({ PushNotifications }) => { + if (disposed) return; + const handle = await PushNotifications.addListener('pushNotificationActionPerformed', (action) => { + const data = action?.notification?.data as Record | undefined; + // Prefer an explicit deep link in the payload (richest); fall back to a bare + // sessionId for backwards compatibility with existing push senders. + const url = typeof data?.url === 'string' ? data.url : typeof data?.deeplink === 'string' ? data.deeplink : undefined; + if (url) { + applyDeepLinkUrl(url); + return; + } + const sessionId = typeof data?.sessionId === 'string' ? data.sessionId : undefined; + if (sessionId) { + applyDeepLinkIntent({ type: 'session', sessionId }); + } + }); + if (disposed) { + void handle.remove(); + return; + } + cleanup.push(() => void handle.remove()); + }) + .catch(() => undefined); + + return () => { + disposed = true; + cleanup.forEach((remove) => remove()); + }; + }, []); +}; + +// Re-export so producers (notifications, future widgets) have one import for the whole vocabulary. +export { buildDeepLink, parseDeepLink }; +export type { DeepLinkIntent, SessionsFilter, ViewTarget }; diff --git a/packages/ui/src/apps/deepLinks.ts b/packages/ui/src/apps/deepLinks.ts new file mode 100644 index 00000000..f4c3f213 --- /dev/null +++ b/packages/ui/src/apps/deepLinks.ts @@ -0,0 +1,169 @@ +/** + * OpenChamber deep-link vocabulary — the single source of truth for the `openchamber://` + * URL scheme used across every native entry point: notification taps, home-screen / lock- + * screen widgets, and (later) Live Activities. Anything that wants to drive navigation + * builds a URL with {@link buildDeepLink} and anything that receives one parses it with + * {@link parseDeepLink} into a typed {@link DeepLinkIntent}; the navigation layer + * (deepLinkNavigation) is the only place that knows how to *apply* an intent. + * + * Keep this file pure (no React, no stores, no Capacitor) so it can be imported from any + * context — including, eventually, a tiny encoder shared with the native widget/extension. + */ + +export const DEEP_LINK_SCHEME = 'openchamber'; + +export type SessionsFilter = 'all' | 'attention' | 'recent'; +export type ViewTarget = 'files' | 'mcp' | 'instances' | 'update'; + +/** + * Every navigable destination the app exposes to the outside world. New widget/notification + * ideas should add a variant here first, then teach deepLinkNavigation how to apply it — + * that keeps the "blocks" composable without leaking ad-hoc URL parsing into features. + */ +export type DeepLinkIntent = + | { type: 'session'; sessionId: string; directory?: string } + | { type: 'new-session'; directory?: string; projectId?: string; agent?: string; model?: string } + | { type: 'sessions'; filter?: SessionsFilter } + | { type: 'status' } + | { type: 'settings'; section?: string } + | { type: 'changes'; path?: string; staged?: boolean } + | { type: 'view'; target: ViewTarget }; + +const trimSlashes = (value: string): string => value.replace(/^\/+|\/+$/g, ''); + +const segmentsOf = (url: URL): string[] => { + // Custom-scheme URLs put the first route token in `host` (openchamber://session/), + // but be tolerant of authority-less forms (openchamber:/session/) where it lands in + // the pathname instead. + const pathSegments = trimSlashes(url.pathname).split('/').filter(Boolean); + if (url.host) { + return [url.host, ...pathSegments]; + } + return pathSegments; +}; + +/** + * Parse a raw `openchamber://…` string into a typed intent, or `null` if it isn't a + * recognised OpenChamber deep link. Tolerant by design: unknown routes return `null` + * rather than throwing, so callers can fall back without a try/catch. + */ +export function parseDeepLink(raw: string | null | undefined): DeepLinkIntent | null { + if (typeof raw !== 'string' || raw.length === 0) { + return null; + } + + let url: URL; + try { + url = new URL(raw); + } catch { + return null; + } + + if (url.protocol !== `${DEEP_LINK_SCHEME}:`) { + return null; + } + + const segments = segmentsOf(url); + const route = (segments[0] ?? '').toLowerCase(); + const rest = segments.slice(1); + const query = url.searchParams; + + switch (route) { + case 'session': { + const sessionId = rest[0] || query.get('id') || ''; + if (!sessionId) { + return null; + } + return { type: 'session', sessionId, directory: query.get('dir') ?? undefined }; + } + + case 'new': + case 'new-session': + return { + type: 'new-session', + directory: query.get('dir') ?? undefined, + projectId: query.get('project') ?? undefined, + agent: query.get('agent') ?? undefined, + model: query.get('model') ?? undefined, + }; + + case 'sessions': { + const filter = query.get('filter'); + return { + type: 'sessions', + filter: filter === 'attention' || filter === 'recent' || filter === 'all' ? filter : undefined, + }; + } + + case 'status': + return { type: 'status' }; + + case 'settings': + return { type: 'settings', section: rest[0] || query.get('section') || undefined }; + + case 'changes': + return { + type: 'changes', + path: rest.join('/') || query.get('path') || undefined, + staged: query.get('staged') === 'true', + }; + + case 'view': { + const target = (rest[0] || '').toLowerCase(); + // `changes` has its own richer intent (diff path); route the bare view token to it. + if (target === 'changes') { + return { type: 'changes' }; + } + if (target === 'files' || target === 'mcp' || target === 'instances' || target === 'update') { + return { type: 'view', target }; + } + return null; + } + + default: + return null; + } +} + +/** + * Build a canonical `openchamber://…` URL for an intent. Used by anything that needs to hand + * a deep link to iOS — notification payloads, `widgetURL(...)`, Live Activity tap targets — + * so every producer emits the exact shape {@link parseDeepLink} understands. + */ +export function buildDeepLink(intent: DeepLinkIntent): string { + const base = `${DEEP_LINK_SCHEME}://`; + const withQuery = (path: string, params: Record): string => { + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (typeof value === 'string' && value.length > 0) { + search.set(key, value); + } + } + const query = search.toString(); + return query ? `${base}${path}?${query}` : `${base}${path}`; + }; + + switch (intent.type) { + case 'session': + return withQuery(`session/${encodeURIComponent(intent.sessionId)}`, { dir: intent.directory }); + case 'new-session': + return withQuery('new', { + dir: intent.directory, + project: intent.projectId, + agent: intent.agent, + model: intent.model, + }); + case 'sessions': + return withQuery('sessions', { filter: intent.filter }); + case 'status': + return `${base}status`; + case 'settings': + return intent.section ? `${base}settings/${encodeURIComponent(intent.section)}` : `${base}settings`; + case 'changes': + return withQuery(intent.path ? `changes/${intent.path}` : 'changes', { + staged: intent.staged ? 'true' : undefined, + }); + case 'view': + return `${base}view/${intent.target}`; + } +} diff --git a/packages/ui/src/apps/mobileConnections.ts b/packages/ui/src/apps/mobileConnections.ts new file mode 100644 index 00000000..35a748af --- /dev/null +++ b/packages/ui/src/apps/mobileConnections.ts @@ -0,0 +1,680 @@ +// Saved-connection storage + the shared connect/unlock flow for the dedicated +// mobile app. Both the onboarding welcome screen and the Instances sheet drive +// connections through `useMobileConnection` so the health-check + progressive +// password unlock + client-token issuance + runtime switch all behave identically. +// +// Persistence model (deliberately simple so it is correct-by-inspection): +// - Instance *metadata* (id/label/url/lastUsedAt + a `hasToken` flag) lives in +// localStorage. On native it NEVER contains the client token. +// - The client token lives in the OS secure store (iOS Keychain / Android +// Keystore) via @aparajita/capacitor-secure-storage, keyed per instance URL. +// - On web (browser-hosted mobile.html) there is no secure store, so the token +// stays inline in localStorage — that surface is not the native security target. +// +// Token writes are AWAITED before we switch the runtime endpoint, so a successful +// unlock guarantees the token is actually persisted (no fire-and-forget). + +import { SecureStorage } from '@aparajita/capacitor-secure-storage'; +import React from 'react'; + +import { useI18n } from '@/lib/i18n'; +import { isCapacitorApp } from '@/lib/platform'; +import { switchRuntimeEndpoint } from '@/lib/runtime-switch'; + +const MOBILE_CONNECTIONS_STORAGE_KEY = 'openchamber.mobile.connections.v1'; +const MOBILE_SECURE_STORAGE_PREFIX = 'openchamber.mobile.'; +const MOBILE_CONNECTIONS_LIMIT = 12; +const MOBILE_CONNECT_TIMEOUT_MS = 8000; +const MOBILE_NATIVE_HTTP_TIMEOUT_MS = 2500; +const MOBILE_SECURE_TIMEOUT_MS = 3000; + +export type MobileSavedConnection = { + id: string; + label: string; + url: string; + lastUsedAt: number; + // Native: indicates a token exists in the secure store. Web: unused. + hasToken?: boolean; + // Web only: the token stored inline. On native this stays undefined in the list. + clientToken?: string; +}; + +export type MobilePendingConnection = { + label: string; + url: string; +}; + +export type MobileConnectInput = { + url: string; + clientToken?: string; + label?: string; +}; + +type MobileFetchResponse = { + ok: boolean; + status: number; + source: 'native-http' | 'browser-fetch'; + json: () => Promise; +}; + +type MobileSessionStatus = { + authenticated?: boolean; + disabled?: boolean; + scope?: string; +}; + +// --------------------------------------------------------------------------- +// URL helpers +// --------------------------------------------------------------------------- + +export const normalizeConnectionUrl = (value: string): string => { + const trimmed = value.trim(); + if (!trimmed) return ''; + const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`; + const url = new URL(withScheme); + url.hash = ''; + url.search = ''; + url.pathname = url.pathname.replace(/\/+$/, ''); + return url.toString().replace(/\/+$/, ''); +}; + +export const getConnectionLabel = (url: string): string => { + try { + return new URL(url).host; + } catch { + return url; + } +}; + +const getConnectionStorageKey = (url: string): string => { + try { + return normalizeConnectionUrl(url); + } catch { + return url.trim().replace(/\/+$/g, ''); + } +}; + +export const isSameConnectionUrl = (left: string, right: string): boolean => + getConnectionStorageKey(left) === getConnectionStorageKey(right); + +// --------------------------------------------------------------------------- +// Request helpers (native CapacitorHttp first — needed to reach plain-http LAN +// servers the secure webview cannot fetch — then a browser-fetch fallback). +// --------------------------------------------------------------------------- + +const logConnect = (step: string, detail: Record = {}): void => { + console.info('[mobile-connect]', step, detail); +}; + +const logStorage = (step: string, detail: Record = {}): void => { + console.info('[mobile-storage]', step, detail); +}; + +const parseMaybeJson = (value: unknown): unknown => { + if (typeof value !== 'string') return value; + try { + return JSON.parse(value) as unknown; + } catch { + return value; + } +}; + +const getJsonRequestData = (body: BodyInit | null | undefined): unknown => { + if (typeof body !== 'string') return body ?? undefined; + try { + return JSON.parse(body) as unknown; + } catch { + return body; + } +}; + +const nativeHttpRequest = async (url: string, init?: RequestInit): Promise => { + if (!isCapacitorApp()) return null; + try { + const { CapacitorHttp } = await import('@capacitor/core'); + const headers = Object.fromEntries(new Headers(init?.headers).entries()); + const response = await CapacitorHttp.request({ + url, + method: init?.method || 'GET', + headers, + data: getJsonRequestData(init?.body), + }); + return { + ok: response.status >= 200 && response.status < 300, + status: response.status, + source: 'native-http', + json: async () => parseMaybeJson(response.data), + }; + } catch (error) { + console.warn('[mobile-connect] native-http failed', { url, error }); + return null; + } +}; + +const browserFetchRequest = async (url: string, init?: RequestInit): Promise => { + const response = await fetch(url, init).catch((error) => { + console.warn('[mobile-connect] browser-fetch failed', { url, error }); + return null; + }); + if (!response) return null; + return { ok: response.ok, status: response.status, source: 'browser-fetch', json: () => response.json() }; +}; + +const raceWithTimeout = async (timeoutMs: number, operation: Promise, onTimeout?: () => void): Promise => { + let timeoutId: number | undefined; + const timeout = new Promise((resolve) => { + timeoutId = window.setTimeout(() => { + onTimeout?.(); + resolve(null); + }, timeoutMs); + }); + try { + return await Promise.race([operation, timeout]); + } catch { + return null; + } finally { + if (timeoutId !== undefined) window.clearTimeout(timeoutId); + } +}; + +const requestWithTimeout = async (url: string, init?: RequestInit): Promise => { + const startedAt = Date.now(); + const native = await raceWithTimeout( + Math.min(MOBILE_NATIVE_HTTP_TIMEOUT_MS, MOBILE_CONNECT_TIMEOUT_MS), + nativeHttpRequest(url, init), + ); + if (native) return native; + + const controller = new AbortController(); + const remainingMs = Math.max(1000, MOBILE_CONNECT_TIMEOUT_MS - (Date.now() - startedAt)); + return raceWithTimeout( + remainingMs, + browserFetchRequest(url, { ...init, signal: controller.signal }), + () => controller.abort(), + ); +}; + +const readSessionStatus = async (response: MobileFetchResponse | null): Promise => { + if (!response) return null; + const payload = await response.json().catch(() => null); + if (!payload || typeof payload !== 'object') return null; + const record = payload as Record; + return { + authenticated: typeof record.authenticated === 'boolean' ? record.authenticated : undefined, + disabled: typeof record.disabled === 'boolean' ? record.disabled : undefined, + scope: typeof record.scope === 'string' ? record.scope : undefined, + }; +}; + +// --------------------------------------------------------------------------- +// Metadata storage (localStorage) — never holds the token on native. +// --------------------------------------------------------------------------- + +const readConnections = (): MobileSavedConnection[] => { + if (typeof window === 'undefined') return []; + let parsed: unknown; + try { + parsed = JSON.parse(window.localStorage.getItem(MOBILE_CONNECTIONS_STORAGE_KEY) || '[]'); + } catch { + return []; + } + if (!Array.isArray(parsed)) return []; + const native = isCapacitorApp(); + return parsed + .flatMap((item): MobileSavedConnection[] => { + if (!item || typeof item !== 'object') return []; + const c = item as Partial; + if (typeof c.id !== 'string' || typeof c.url !== 'string') return []; + const inlineToken = typeof c.clientToken === 'string' && c.clientToken.trim() ? c.clientToken : undefined; + const base: MobileSavedConnection = { + id: c.id, + label: typeof c.label === 'string' && c.label.trim() ? c.label : getConnectionLabel(c.url), + url: c.url, + lastUsedAt: typeof c.lastUsedAt === 'number' ? c.lastUsedAt : 0, + }; + if (native) return [{ ...base, hasToken: Boolean(c.hasToken) || Boolean(inlineToken) }]; + return [{ ...base, clientToken: inlineToken, hasToken: Boolean(inlineToken) }]; + }) + .sort((a, b) => b.lastUsedAt - a.lastUsedAt); +}; + +const writeConnections = (connections: MobileSavedConnection[]): void => { + if (typeof window === 'undefined') return; + const native = isCapacitorApp(); + const serialized = connections.slice(0, MOBILE_CONNECTIONS_LIMIT).map((c) => ( + native + ? { id: c.id, label: c.label, url: c.url, lastUsedAt: c.lastUsedAt, hasToken: Boolean(c.hasToken || c.clientToken) } + : { id: c.id, label: c.label, url: c.url, lastUsedAt: c.lastUsedAt, clientToken: c.clientToken } + )); + try { + window.localStorage.setItem(MOBILE_CONNECTIONS_STORAGE_KEY, JSON.stringify(serialized)); + } catch (error) { + console.warn('[mobile-storage] failed to persist connection metadata', error); + } +}; + +const upsertConnectionInList = ( + connections: MobileSavedConnection[], + draft: { label: string; url: string; clientToken?: string; hasToken?: boolean }, +): MobileSavedConnection[] => { + const key = getConnectionStorageKey(draft.url); + const existing = connections.find((item) => getConnectionStorageKey(item.url) === key); + const native = isCapacitorApp(); + const next: MobileSavedConnection = { + id: existing?.id || crypto.randomUUID(), + label: draft.label, + url: draft.url, + lastUsedAt: Date.now(), + ...(native + ? { hasToken: draft.hasToken ?? (Boolean(draft.clientToken) || existing?.hasToken || false) } + : { clientToken: draft.clientToken ?? existing?.clientToken, hasToken: Boolean(draft.clientToken ?? existing?.clientToken) }), + }; + return [ + next, + ...connections.filter((item) => item.id !== next.id && getConnectionStorageKey(item.url) !== key), + ].slice(0, MOBILE_CONNECTIONS_LIMIT); +}; + +// --------------------------------------------------------------------------- +// Secure token storage (native only), per-instance URL. Every call is bounded +// so a hung/unavailable Keychain can never block the connect flow. +// --------------------------------------------------------------------------- + +// We call the plugin's NATIVE methods (`internalSetItem`/`internalGetItem`/ +// `internalRemoveItem`) directly. Capacitor routes native methods straight to the +// iOS/Android plugin via the bridge — unlike the high-level `setItem`/`setKeyPrefix` +// JS methods, which make the `registerPlugin` proxy lazy-load its platform JS module +// (the step that stalls in this webview). We also build the prefixed key ourselves +// so we never touch the JS-only `setKeyPrefix`. +type NativeSecureStorage = { + internalSetItem: (options: { prefixedKey: string; data: string; sync: boolean; access: number }) => Promise; + internalGetItem: (options: { prefixedKey: string; sync: boolean }) => Promise<{ data: string | null }>; + internalRemoveItem: (options: { prefixedKey: string; sync: boolean }) => Promise<{ success: boolean }>; +}; + +const nativeSecure = SecureStorage as unknown as NativeSecureStorage; +const KEYCHAIN_ACCESS_WHEN_UNLOCKED = 0; // KeychainAccess.whenUnlocked + +const prefixedTokenKey = (url: string): string => + `${MOBILE_SECURE_STORAGE_PREFIX}token.${encodeURIComponent(getConnectionStorageKey(url))}`; + +const withTimeout = async (operation: Promise, fallback: T): Promise => { + let timeoutId: number | undefined; + const timeout = new Promise((resolve) => { + timeoutId = window.setTimeout(() => resolve(fallback), MOBILE_SECURE_TIMEOUT_MS); + }); + try { + return await Promise.race([operation.catch(() => fallback), timeout]); + } finally { + if (timeoutId !== undefined) window.clearTimeout(timeoutId); + } +}; + +// Bound a native Keychain call so a stalled/failed bridge can never hang the flow. +const boundedSecure = async (label: string, run: () => Promise, fallback: T): Promise => { + if (!isCapacitorApp()) return fallback; + return withTimeout( + run().catch((error) => { + console.warn(`[mobile-storage] ${label} failed`, error); + return fallback; + }), + fallback, + ); +}; + +const readSecureToken = async (url: string): Promise => { + logStorage('secure:read-start', { url }); + const value = await boundedSecure( + 'secure:read', + async () => (await nativeSecure.internalGetItem({ prefixedKey: prefixedTokenKey(url), sync: false })).data, + null, + ); + const token = typeof value === 'string' && value.trim() ? value : undefined; + logStorage('secure:read', { url, hasToken: Boolean(token) }); + return token; +}; + +const writeSecureToken = async (url: string, token: string): Promise => { + logStorage('secure:write-start', { url }); + const ok = await boundedSecure('secure:write', async () => { + await nativeSecure.internalSetItem({ + prefixedKey: prefixedTokenKey(url), + data: token, + sync: false, + access: KEYCHAIN_ACCESS_WHEN_UNLOCKED, + }); + return true; + }, false); + logStorage('secure:write', { url, ok }); + return ok; +}; + +const deleteSecureToken = async (url: string): Promise => { + await boundedSecure('secure:delete', async () => { + await nativeSecure.internalRemoveItem({ prefixedKey: prefixedTokenKey(url), sync: false }); + return true; + }, false); +}; + +// --------------------------------------------------------------------------- +// Public storage API +// --------------------------------------------------------------------------- + +// One-time migration: a legacy localStorage record on native might still carry an +// inline `clientToken`. Move it into the secure store and strip the metadata. +const migrateLegacyInlineTokens = async (): Promise => { + if (typeof window === 'undefined' || !isCapacitorApp()) return; + let parsed: unknown; + try { + parsed = JSON.parse(window.localStorage.getItem(MOBILE_CONNECTIONS_STORAGE_KEY) || '[]'); + } catch { + return; + } + if (!Array.isArray(parsed)) return; + const legacy = parsed.filter((item): item is { url: string; clientToken: string } => + Boolean(item) && typeof item === 'object' + && typeof (item as { url?: unknown }).url === 'string' + && typeof (item as { clientToken?: unknown }).clientToken === 'string' + && Boolean((item as { clientToken: string }).clientToken.trim())); + if (legacy.length === 0) return; + logStorage('secure:migrate-start', { count: legacy.length }); + for (const { url, clientToken } of legacy) { + await writeSecureToken(url, clientToken); + } + writeConnections(readConnections()); + logStorage('secure:migrate-done', { count: legacy.length }); +}; + +export const loadMobileConnections = async (): Promise => { + await migrateLegacyInlineTokens(); + return readConnections(); +}; + +export const upsertMobileConnection = async ( + connection: { label: string; url: string; clientToken?: string }, +): Promise => { + const next = upsertConnectionInList(readConnections(), connection); + writeConnections(next); + if (isCapacitorApp() && connection.clientToken) { + await writeSecureToken(connection.url, connection.clientToken); + } + return next; +}; + +export const deleteMobileConnection = async (id: string): Promise => { + const connections = readConnections(); + const removed = connections.find((connection) => connection.id === id) ?? null; + const next = connections.filter((connection) => connection.id !== id); + writeConnections(next); + if (removed && isCapacitorApp()) await deleteSecureToken(removed.url); + return next; +}; + +// Cold-launch auto-connect: silently reconnect to the most-recently-used saved +// instance so a returning user (and notification deep-links) land straight in the +// app instead of the connect screen. Returns true and switches the runtime endpoint +// when the instance is reachable AND we already have a usable bearer token; returns +// false — caller shows the connect screen — when there is no saved instance, it's +// unreachable, or it needs a (re)login. Mirrors the success path of +// `useMobileConnection.connect`, with no prompts or UI state. +export const autoConnectLastInstance = async (): Promise => { + await migrateLegacyInlineTokens(); + const candidate = readConnections()[0]; // sorted most-recent-first + if (!candidate) return false; + + const url = normalizeConnectionUrl(candidate.url); + if (!url) return false; + + // The native runtime transport needs a bearer token; only auto-connect when one is + // already saved. A missing/expired token must go through the login UI, not silently. + let token: string | undefined; + if (isCapacitorApp()) { + if (!candidate.hasToken) return false; + token = await readSecureToken(url); + if (!token) return false; + } else { + token = candidate.clientToken; + } + + const headers = token ? { Authorization: `Bearer ${token}` } : undefined; + + const health = await requestWithTimeout(`${url}/health`, { method: 'GET', headers }); + if (!health?.ok) return false; + + const session = await requestWithTimeout(`${url}/auth/session`, { method: 'GET', credentials: 'include', headers }); + // Token rejected / session invalid → fall back to the login screen. + if (!session || (!session.ok && session.status !== 404)) return false; + const status = await readSessionStatus(session); + if (status && status.disabled !== true && status.authenticated === false) return false; + + await upsertMobileConnection({ label: candidate.label, url }); // bump lastUsedAt (keeps hasToken) + switchRuntimeEndpoint({ apiBaseUrl: url, clientToken: token ?? null }); + return true; +}; + +// --------------------------------------------------------------------------- +// Shared connection controller +// --------------------------------------------------------------------------- + +export type UseMobileConnection = { + connections: MobileSavedConnection[]; + isBusy: boolean; + isPasswordBusy: boolean; + error: string | null; + pendingConnection: MobilePendingConnection | null; + connect: (input: MobileConnectInput) => Promise; + submitPassword: (password: string) => Promise; + cancelPassword: () => void; + saveConnection: (input: MobileConnectInput) => Promise; + removeConnection: (id: string) => Promise; + setError: (message: string | null) => void; +}; + +// `onConnected` fires once the runtime endpoint is switched (the caller navigates +// away / closes its surface from there). +export const useMobileConnection = (onConnected: () => void): UseMobileConnection => { + const { t } = useI18n(); + const [connections, setConnections] = React.useState(() => readConnections()); + const [busyOperation, setBusyOperation] = React.useState<'connect' | 'password' | null>(null); + const [error, setError] = React.useState(null); + const [pendingConnection, setPendingConnection] = React.useState(null); + const connectionsRef = React.useRef(connections); + const busyRef = React.useRef<'connect' | 'password' | null>(null); + + const applyConnections = React.useCallback((next: MobileSavedConnection[]) => { + connectionsRef.current = next; + setConnections(next); + }, []); + + const beginBusy = React.useCallback((operation: 'connect' | 'password') => { + busyRef.current = operation; + setBusyOperation(operation); + }, []); + + const endBusy = React.useCallback((operation: 'connect' | 'password') => { + if (busyRef.current !== operation) return; + busyRef.current = null; + setBusyOperation(null); + }, []); + + // Refresh from storage on mount (runs the legacy-token migration too). + React.useEffect(() => { + let disposed = false; + void loadMobileConnections().then((loaded) => { + if (!disposed) applyConnections(loaded); + }); + return () => { disposed = true; }; + }, [applyConnections]); + + // Persist metadata for a connection and reflect it in state immediately. + const persistMetadata = React.useCallback((draft: { label: string; url: string; clientToken?: string }) => { + const next = upsertConnectionInList(connectionsRef.current, draft); + applyConnections(next); + writeConnections(next); + return next; + }, [applyConnections]); + + const connect = React.useCallback(async (input: MobileConnectInput) => { + setError(null); + beginBusy('connect'); + try { + const url = normalizeConnectionUrl(input.url); + if (!url) { + setError(t('mobile.connect.error.urlRequired')); + return; + } + + const label = input.label?.trim() + || connectionsRef.current.find((c) => isSameConnectionUrl(c.url, url))?.label + || getConnectionLabel(url); + + // Resolve a token: explicit input wins, otherwise read the saved one from + // the secure store (single bounded read — never blocks the flow). + let token = input.clientToken?.trim() || undefined; + const tokenIsNew = Boolean(token); + if (!token && isCapacitorApp()) { + const saved = connectionsRef.current.find((c) => isSameConnectionUrl(c.url, url)); + if (saved?.hasToken) token = await readSecureToken(url); + } + + const headers = token ? { Authorization: `Bearer ${token}` } : undefined; + + logConnect('health:start', { url }); + const health = await requestWithTimeout(`${url}/health`, { method: 'GET', headers }); + logConnect('health:done', { ok: health?.ok === true, source: health?.source ?? null, status: health?.status ?? null }); + if (!health?.ok) { + setError(t('mobile.connect.error.unreachable')); + return; + } + + logConnect('session:start', { url, hasToken: Boolean(token) }); + const session = await requestWithTimeout(`${url}/auth/session`, { method: 'GET', credentials: 'include', headers }); + const status = await readSessionStatus(session); + logConnect('session:done', { ok: session?.ok === true, status: session?.status ?? null, scope: status?.scope ?? null, disabled: status?.disabled === true }); + + // A cookie-only native session (authenticated, but not a `client` bearer + // scope and not auth-disabled) is not enough — the runtime transport needs a + // bearer token, so fall through to the password flow to mint one. + const cookieOnlyNeedsToken = isCapacitorApp() + && session?.ok === true + && !token + && status?.authenticated === true + && status.disabled !== true + && status.scope !== 'client'; + + if (!token && (session?.status === 401 || cookieOnlyNeedsToken)) { + persistMetadata({ label, url }); + setPendingConnection({ label, url }); + return; + } + + if (!session || (!session.ok && session.status !== 404)) { + setError(t('mobile.connect.error.authRequired')); + return; + } + + // Connected. If the token came from the user (not the secure store), persist + // it first so a cold restart won't re-prompt. + if (token && tokenIsNew && isCapacitorApp()) { + await writeSecureToken(url, token); + } + persistMetadata({ label, url, clientToken: token }); + switchRuntimeEndpoint({ apiBaseUrl: url, clientToken: token ?? null }); + onConnected(); + } catch (error) { + console.warn('[mobile-connect] connect threw', error); + setError(t('mobile.connect.error.invalidUrl')); + } finally { + endBusy('connect'); + } + }, [beginBusy, endBusy, onConnected, persistMetadata, t]); + + const submitPassword = React.useCallback(async (password: string) => { + if (!pendingConnection || !password.trim() || busyRef.current === 'password') return; + setError(null); + beginBusy('password'); + const { url, label } = pendingConnection; + try { + logConnect('password:start', { url }); + const response = await requestWithTimeout(`${url}/auth/session`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ password, trustDevice: true, issueClientToken: true, clientLabel: 'OpenChamber Mobile' }), + }); + logConnect('password:done', { ok: response?.ok === true, status: response?.status ?? null }); + if (!response?.ok) { + setError(t('mobile.connect.error.passwordFailed')); + return; + } + + const payload = await response.json().catch(() => null) as { clientToken?: unknown } | null; + const issuedToken = typeof payload?.clientToken === 'string' ? payload.clientToken.trim() : ''; + logConnect('password:token', { issued: Boolean(issuedToken) }); + + // Native runtime transport needs a bearer token; a cookie-only success is + // not acceptable for a saved protected instance. + if (isCapacitorApp() && !issuedToken) { + setError(t('mobile.connect.error.authRequired')); + return; + } + + // Guarantee the token is persisted BEFORE switching (no fire-and-forget). + if (isCapacitorApp() && issuedToken) { + await writeSecureToken(url, issuedToken); + } + persistMetadata({ label, url, clientToken: issuedToken || undefined }); + setPendingConnection(null); + switchRuntimeEndpoint({ apiBaseUrl: url, clientToken: issuedToken || null }); + onConnected(); + } catch (error) { + console.warn('[mobile-connect] password threw', error); + setError(t('mobile.connect.error.passwordFailed')); + } finally { + endBusy('password'); + } + }, [beginBusy, endBusy, onConnected, pendingConnection, persistMetadata, t]); + + const cancelPassword = React.useCallback(() => { + setPendingConnection(null); + setError(null); + }, []); + + const saveConnection = React.useCallback(async (input: MobileConnectInput): Promise => { + setError(null); + const url = normalizeConnectionUrl(input.url); + if (!url) { + setError(t('mobile.connect.error.urlRequired')); + return null; + } + const clientToken = input.clientToken?.trim() || undefined; + const label = input.label?.trim() || getConnectionLabel(url); + // Awaited token write so "Save" truly persisted the secret before returning. + if (isCapacitorApp() && clientToken) { + await writeSecureToken(url, clientToken); + } + const next = persistMetadata({ label, url, clientToken }); + return next.find((connection) => isSameConnectionUrl(connection.url, url)) ?? null; + }, [persistMetadata, t]); + + const removeConnection = React.useCallback(async (id: string): Promise => { + const removed = connectionsRef.current.find((connection) => connection.id === id) ?? null; + const next = await deleteMobileConnection(id); + applyConnections(next); + return removed; + }, [applyConnections]); + + return { + connections, + isBusy: busyOperation !== null, + isPasswordBusy: busyOperation === 'password', + error, + pendingConnection, + connect, + submitPassword, + cancelPassword, + saveConnection, + removeConnection, + setError, + }; +}; diff --git a/packages/ui/src/apps/mobileQrScan.ts b/packages/ui/src/apps/mobileQrScan.ts new file mode 100644 index 00000000..7fd77b1a --- /dev/null +++ b/packages/ui/src/apps/mobileQrScan.ts @@ -0,0 +1,176 @@ +// Connection payload parsing + native QR scanning for the dedicated mobile app. +// +// The pairing link format is produced by `openchamber connect-url --qr`: +// openchamber://connect?v=1&server=&token=&label=
- {nativeNotificationsEnabled && canShowNotifications && ( + {/* The native Capacitor app never notifies while focused (hard rule) and uses + generic, non-customizable text, so the "notify while focused" toggle and the + test button are hidden there. */} + {nativeNotificationsEnabled && canShowNotifications && !isNativeApp && ( <>
{
- {/* --- Template Customization --- */} + {/* --- Template Customization (not on the native app — it uses generic text) --- */} + {!isNativeApp && (

@@ -666,6 +680,7 @@ export const NotificationSettings: React.FC = () => { ))}

+ )} )} diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx index 509a7ca3..f25a821c 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -7,6 +7,7 @@ import type { ThemeMode } from '@/types/theme'; import { useUIStore } from '@/stores/useUIStore'; import { useMessageQueueStore, type FollowUpBehavior } from '@/stores/messageQueueStore'; import { cn } from '@/lib/utils'; +import { isCapacitorApp } from '@/lib/platform'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { NumberInput } from '@/components/ui/number-input'; @@ -321,6 +322,10 @@ export const OpenChamberVisualSettings: React.FC const setShowSplitAssistantMessageActions = useUIStore(state => state.setShowSplitAssistantMessageActions); const messageStreamTransport = useConfigStore((state) => state.settingsMessageStreamTransport); const setMessageStreamTransport = useConfigStore((state) => state.setSettingsMessageStreamTransport); + // Capacitor apps are locked to SSE (native WebSocket streaming is unreliable on mobile); + // sync-context forces it too. Show SSE selected and disable the other options here. + const isCapacitorAppRuntime = React.useMemo(() => isCapacitorApp(), []); + const effectiveMessageStreamTransport = isCapacitorAppRuntime ? 'sse' : messageStreamTransport; const settingsDefaultFileViewerPreview = useConfigStore((state) => state.settingsDefaultFileViewerPreview); const setSettingsDefaultFileViewerPreview = useConfigStore((state) => state.setSettingsDefaultFileViewerPreview); const isSettingsDialogOpen = useUIStore(state => state.isSettingsDialogOpen); @@ -1525,7 +1530,8 @@ export const OpenChamberVisualSettings: React.FC key={option.id} variant="chip" size="xs" - aria-pressed={messageStreamTransport === option.id} + aria-pressed={effectiveMessageStreamTransport === option.id} + disabled={isCapacitorAppRuntime && option.id !== 'sse'} className="!font-normal" onClick={() => handleMessageStreamTransportChange(option.id)} > @@ -1535,7 +1541,7 @@ export const OpenChamberVisualSettings: React.FC
{(() => { - const option = MESSAGE_STREAM_TRANSPORT_OPTIONS.find((item) => item.id === messageStreamTransport); + const option = MESSAGE_STREAM_TRANSPORT_OPTIONS.find((item) => item.id === effectiveMessageStreamTransport); return option?.descriptionKey ? tUnsafe(option.descriptionKey) : ''; })()} diff --git a/packages/ui/src/components/ui/MobileOverlayPanel.tsx b/packages/ui/src/components/ui/MobileOverlayPanel.tsx index acf21d3b..156fe911 100644 --- a/packages/ui/src/components/ui/MobileOverlayPanel.tsx +++ b/packages/ui/src/components/ui/MobileOverlayPanel.tsx @@ -85,7 +85,7 @@ export const MobileOverlayPanel: React.FC = ({ 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), + }); + }, });