diff --git a/.github/scripts/otter-adapt-upstream.sh b/.github/scripts/otter-adapt-upstream.sh index 53a986a43aef..61d91542e969 100755 --- a/.github/scripts/otter-adapt-upstream.sh +++ b/.github/scripts/otter-adapt-upstream.sh @@ -80,6 +80,52 @@ preview_path.write_text(adapt_job(preview_path.read_text(), 'deploy', 'stage-pre ])) PY +# Keep upstream's provisioning-profile path, but make it optional for Otter's +# existing Developer ID releases. Otter Accounts has no Clerk RP domain. +python3 - <<'PY' +from pathlib import Path + +path = Path('.github/workflows/release-desktop.yml') +text = path.read_text() +if ' MACOS_PROVISIONING_PROFILE:\n' not in text: + text = text.replace(' AZURE_TENANT_ID:\n', + ' MACOS_PROVISIONING_PROFILE:\n required: false\n AZURE_TENANT_ID:\n', 1) +if ' MACOS_PROVISIONING_PROFILE:' not in text: + text = text.replace(' APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}\n', + ' APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}\n' + ' MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }}\n', 1) +text = text.replace(' T3CODE_CLERK_PASSKEY_RP_DOMAINS: ${{ vars.CLERK_PASSKEY_RP_DOMAINS }}\n', '') +if ' T3CODE_MACOS_PASSKEY_RP_DOMAINS:' not in text: + text = text.replace(' MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }}\n', + ' MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }}\n' + ' T3CODE_MACOS_PASSKEY_RP_DOMAINS: ${{ vars.MACOS_PASSKEY_RP_DOMAINS }}\n', 1) +start_anchor = ' if has_all "$CSC_LINK" "$CSC_KEY_PASSWORD" "$APPLE_API_KEY" "$APPLE_API_KEY_ID" "$APPLE_API_ISSUER"; then\n' +end_anchor = ' echo "macOS signing enabled."\n' +start = text.index(start_anchor) + len(start_anchor) +end = text.index(end_anchor, start) +text = text[:start] + ''' key_path="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY_ID}.p8" + printf '%s' "$APPLE_API_KEY" > "$key_path" + export APPLE_API_KEY="$key_path" + + if [[ -n "$MACOS_PROVISIONING_PROFILE" ]]; then + if [[ -z "$APPLE_TEAM_ID" ]]; then + echo "MACOS_PROVISIONING_PROFILE requires APPLE_TEAM_ID." >&2 + exit 1 + fi + profile_path="$RUNNER_TEMP/t3code.provisionprofile" + printf '%s' "$MACOS_PROVISIONING_PROFILE" | base64 -D > "$profile_path" + security cms -D -i "$profile_path" >/dev/null + export T3CODE_APPLE_TEAM_ID="$APPLE_TEAM_ID" + export T3CODE_MACOS_PROVISIONING_PROFILE="$profile_path" + echo "macOS provisioning profile validated; entitlements selected by the packager." + else + echo "No macOS provisioning profile; signing and notarization continue without passkey entitlements." + fi + +''' + text[end:] +path.write_text(text) +PY + # Point agents at the fork guide from AGENTS.md, which every agent reads first. perl -0pi -e ' s/\A(# [^\n]*\n)/$1\n> **This is the Otter Code fork.** Read [OTTER.md](OTTER.md) first: it covers how the fork\n> stays in sync with upstream, its identities and infrastructure, releases, and moving machines.\n/ diff --git a/.github/workflows/mobile-fingerprint-check.yml b/.github/workflows/mobile-fingerprint-check.yml index 5849b71ddbb8..bfeda857a8c6 100644 --- a/.github/workflows/mobile-fingerprint-check.yml +++ b/.github/workflows/mobile-fingerprint-check.yml @@ -89,7 +89,9 @@ jobs: env: BASE_SHA: ${{ steps.changes.outputs.base_sha }} run: | - git checkout --quiet "$BASE_SHA" + # Filtered installs can rewrite tracked dependency metadata. Fingerprint + # artifacts are in RUNNER_TEMP; discard install edits in this CI checkout. + git checkout --quiet --force "$BASE_SHA" # Re-sync node_modules to the base commit's lockfile before # fingerprinting — a dep-changing PR must not fingerprint the base # against head's installed packages. diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 6002c289b2bd..ca123938c724 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -19,6 +19,8 @@ on: required: false APPLE_API_ISSUER: required: false + MACOS_PROVISIONING_PROFILE: + required: false AZURE_TENANT_ID: required: false AZURE_CLIENT_ID: @@ -409,6 +411,8 @@ jobs: APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} + MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }} + T3CODE_MACOS_PASSKEY_RP_DOMAINS: ${{ vars.MACOS_PASSKEY_RP_DOMAINS }} AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} @@ -441,6 +445,21 @@ jobs: printf '%s' "$APPLE_API_KEY" > "$key_path" export APPLE_API_KEY="$key_path" + if [[ -n "$MACOS_PROVISIONING_PROFILE" ]]; then + if [[ -z "$APPLE_TEAM_ID" ]]; then + echo "MACOS_PROVISIONING_PROFILE requires APPLE_TEAM_ID." >&2 + exit 1 + fi + profile_path="$RUNNER_TEMP/t3code.provisionprofile" + printf '%s' "$MACOS_PROVISIONING_PROFILE" | base64 -D > "$profile_path" + security cms -D -i "$profile_path" >/dev/null + export T3CODE_APPLE_TEAM_ID="$APPLE_TEAM_ID" + export T3CODE_MACOS_PROVISIONING_PROFILE="$profile_path" + echo "macOS provisioning profile validated; entitlements selected by the packager." + else + echo "No macOS provisioning profile; signing and notarization continue without passkey entitlements." + fi + echo "macOS signing enabled." args+=(--signed) else diff --git a/OTTER.md b/OTTER.md index 39702e3d8426..3ca5a62f51d7 100644 --- a/OTTER.md +++ b/OTTER.md @@ -52,7 +52,6 @@ sync_date=$(date +%F) git merge-base --is-ancestor "$old_base" "$old_main" git merge-base --is-ancestor "$old_base" "$new_base" last_sync=$(git log -1 --format=%H --grep='^chore(otter): changes before the .* sync' "$old_main") -git branch "backup/otter-before-$sync_date" "$old_main" # Commits since the last sync wait here; they become this sync's squash commit. git branch "otter/sync-$sync_date" "$old_main" git switch -c "otter/stack-$sync_date" "$last_sync" @@ -103,12 +102,12 @@ Run focused tests and typechecks for conflicted code and the preserved features ### Publishing a prepared sync -A sync rewrites Otter's shared history. Preserve the old `main` as a remote backup, then update -`main` and `upstream-base` together with explicit leases. If another writer moved either ref, stop +A sync rewrites Otter's shared history. Update `main` and `upstream-base` together with explicit +leases; the squash commits are the record of the fork's changes, so no backup branches are kept. +If another writer moved either ref, stop and prepare against the new state instead of overriding it: ```sh -git push origin "$old_main:refs/heads/backup/otter-before-$sync_date" git push --atomic origin \ --force-with-lease="refs/heads/main:$old_main" \ --force-with-lease="refs/heads/upstream-base:$old_base" \ @@ -183,6 +182,17 @@ widget's `T3Mark.svg`, `agent_activity_mark.xml`, the DMG backgrounds) is the ki packages with the `NPM_TOKEN` secret (an npm account that is a member of the `otterware` org), tagged `nightly` and also `latest`, so `npx otter-code` and `npm i -g otter-code` get the newest build. The token is needed because new packages have no npm trusted publisher yet. +- **macOS passkeys:** `MACOS_PROVISIONING_PROFILE` is the base64 Developer ID profile for + `dev.otterware.code`, signed by Clary GmbH's team `YNJ5WLH965` (`APPLE_TEAM_ID`). The + Otterware branch must pass its own `OTTERWARE_MACOS_PROVISIONING_PROFILE` secret into the + reusable workflow's `MACOS_PROVISIONING_PROFILE` input and use app ID `dev.otterware.suite`. + The packager derives the WebAuthn keychain group from the team and app IDs and claims only + used profile grants. Apple's managed `com.apple.developer.web-browser.public-key-credential` + capability is pending approval; after approval, regenerate both profiles and update their + secrets. Associated Domains are optional (`MACOS_PASSKEY_RP_DOMAINS`, comma-separated) and + unnecessary for browser passkeys; no Clerk key is required. Without a profile, existing + signing and notarization continue. Otter's policy lives in `scripts/otter/macos-profile.ts`; + regenerate workflow adaptations with `.github/scripts/otter-adapt-upstream.sh`. - **Hosted web:** the release stages a Cloudflare Worker version, then promotes it after the desktop/CLI release publishes. `scripts/otter/web/` owns its routing and static assets; the adaptation script keeps upstream's Vercel path separate. The `Deploy Otter Code web` diff --git a/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift b/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift index 1cde09d65f61..8caa227af0df 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift @@ -16,7 +16,8 @@ final class T3LayoutMetricsView: ExpoView { } #if compiler(>=6.4) if #available(iOS 27.1, *) { - hasHinge = nil + // Publish as hinge-less right away: phones without a hinge may never get an update, + // and the workspace waits for metrics before attaching any screen. addInteraction(UIHingeInteraction { [weak self] _, update in guard let self, self.window != nil else { return } // Capability survives temporary detachment and does not follow the hinge angle. @@ -35,21 +36,9 @@ final class T3LayoutMetricsView: ExpoView { override func didMoveToWindow() { super.didMoveToWindow() lastMetrics = nil - assumeNoHingeIfSilent() publishMetrics() } - /// UIKit only sends hinge updates on devices that have a hinge. Without this, - /// an ordinary iPhone never publishes and the navigator never attaches. - private func assumeNoHingeIfSilent() { - guard window != nil, hasHinge == nil else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in - guard let self, self.window != nil, self.hasHinge == nil else { return } - self.hasHinge = false - self.publishMetrics() - } - } - override func layoutSubviews() { super.layoutSubviews() publishMetrics() diff --git a/docs/internals/server-updates.md b/docs/internals/server-updates.md index 51bc16775d08..8b75cab1b7ad 100644 --- a/docs/internals/server-updates.md +++ b/docs/internals/server-updates.md @@ -7,6 +7,14 @@ their service definition or select their own replacement. Local service commands may replace the launcher and state while the service is stopped. Foreground CLI processes do not self-update. +SSH launch defers to service ownership: durable `runtime/service-state.json` +(including pending updates or invalid state), an installed Otter systemd unit or +launchd plist, or an enabled `otter-code.service` prevents an SSH-managed spawn. +It waits up to 120 seconds for the default home's runtime metadata and HTTP +readiness, then connects as external. A service restart gap must never create a +second writer on the same database. If the service stays down, SSH fails with +service recovery commands instead of taking over the home. + Exact-version installs keep restarts independent of npm cache eviction or a moving release tag. Installation and preflight happen in staging before publishing an immutable runtime. Preflight checks the launcher protocol because a target that diff --git a/docs/operations/release.md b/docs/operations/release.md index b9984417f776..ea14989a2630 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -524,7 +524,6 @@ Required secrets used by the workflow: - `APPLE_API_KEY` - `APPLE_API_KEY_ID` - `APPLE_API_ISSUER` -- `MACOS_PROVISIONING_PROFILE` (base64-encoded provisioning profile with Associated Domains) Required repository variables: @@ -532,15 +531,19 @@ Required repository variables: Optional repository variables: -domain from the production Otter Accounts issuer. +- `MACOS_PASSKEY_RP_DOMAINS`: explicitly configured, comma-separated Associated Domains. + Browser passkeys need none. The profile must authorize each configured domain. + +Optional secret: `MACOS_PROVISIONING_PROFILE`, a base64-encoded Developer ID profile. Without +it, signing and notarization continue with the existing hardened-runtime entitlements. Checklist: 1. Apple Developer account access: - Team has rights to create Developer ID certificates. -2. Create an explicit App ID for `com.t3tools.t3code` and enable Associated Domains. +2. Create an explicit App ID for `dev.otterware.code`; enable Associated Domains only if used. 3. Create a `Developer ID Application` certificate and a compatible provisioning profile for that - App ID with Associated Domains enabled. + App ID with the passkey capabilities Apple has granted (see [OTTER.md](../../OTTER.md)). 4. Export the certificate + private key as `.p12` from Keychain. 5. Base64-encode the `.p12` and store as `CSC_LINK`. 6. Base64-encode the provisioning profile and store it as `MACOS_PROVISIONING_PROFILE`. @@ -551,8 +554,8 @@ Checklist: - `APPLE_API_KEY`: contents of the downloaded `.p8` - `APPLE_API_KEY_ID`: Key ID - `APPLE_API_ISSUER`: Issuer ID -10. Re-run a tag release and confirm macOS artifacts are signed/notarized and contain the expected - `com.apple.developer.associated-domains` entitlement. +10. Re-run a release and confirm macOS artifacts are signed/notarized and contain only the + configured, profile-authorized passkey entitlements reported by the packager. Notes: @@ -563,7 +566,7 @@ Notes: - In-app browser passkeys depend on the same profile. The packager adds each entitlement only when the profile grants it, because macOS will not launch an app that claims more than its profile allows. The build log reports which ones it enabled. - - `keychain-access-groups` (`.com.t3tools.t3code.webauthn`) enables Touch ID passkeys. + - `keychain-access-groups` (`..webauthn`) enables Touch ID passkeys. Profiles that grant the team's keychain groups (`.*`) cover it. - `com.apple.developer.web-browser.public-key-credential` lets the system passkey sheet (iCloud Keychain, password managers, phones, security keys) serve any site. Apple grants it as a diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 449a71b43d6b..0f2b48bd0518 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -16,6 +16,7 @@ import { HttpClient, HttpClientResponse } from "effect/http"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import * as SshAuth from "./auth.ts"; +import { collectProcessOutput } from "./command.ts"; import { SshCommandError } from "./errors.ts"; import * as SshTunnel from "./tunnel.ts"; @@ -339,7 +340,7 @@ describe("ssh tunnel scripts", () => { }).pipe(Effect.provide(layerProcess)); }); - it.effect("allows cold remote launches to exceed the default SSH command timeout", () => { + it.effect("allows node-script service restarts to exceed the default SSH command timeout", () => { const target = { alias: "devbox", hostname: "devbox.example.com", @@ -347,7 +348,7 @@ describe("ssh tunnel scripts", () => { port: 2222, } as const; const spawner = ChildProcessSpawner.make(() => - Effect.succeed(makeDelayedSuccessfulProcess('{"remotePort":3774}\n', 75_000)), + Effect.succeed(makeDelayedSuccessfulProcess('{"remotePort":3774}\n', 125_000)), ); const layerSpawner = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner); const layerProcess = Layer.mergeAll(NodeServices.layer, layerSpawner, TestClock.layer()); @@ -357,7 +358,7 @@ describe("ssh tunnel scripts", () => { SshTunnel.launchOrReuseRemoteServer(target, undefined, NODE_SCRIPT), ); yield* Effect.yieldNow; - yield* TestClock.adjust(Duration.seconds(75)); + yield* TestClock.adjust(Duration.seconds(125)); const result = yield* Fiber.join(fiber); assert.equal(result.remotePort, 3774); @@ -696,6 +697,220 @@ describe("ssh tunnel scripts", () => { ); }); +// Exercise the generated shell with fake helpers and a deterministic clock. +// No fixture starts a server or signals a real PID. +describe.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "remote launch service ownership", + () => { + const cases = [ + { name: "active service with a ready server", marker: "active", server: "up" }, + { + name: "pending trial publishes runtime before readiness", + marker: "pending", + server: "restart", + }, + { name: "service never starts", marker: "active", server: "down" }, + { name: "enabled systemd service restarts", marker: "enabled", server: "restart" }, + { name: "installed systemd unit with no service state", marker: "unit", server: "up" }, + { name: "installed launchd plist with no service state", marker: "plist", server: "up" }, + { name: "invalid service state fails closed", marker: "invalid", server: "down" }, + { name: "host without a service spawns managed", marker: "none", server: "down" }, + { name: "host without a service reuses external", marker: "none", server: "up" }, + { + name: "ready service stops stale managed PID", + marker: "active", + server: "up", + stale: "other", + }, + { + name: "stale ownership cannot kill the service PID", + marker: "active", + server: "up", + stale: "same", + }, + { + name: "down service never reuses stale managed server", + marker: "active", + server: "down", + stale: "other", + }, + ] as const; + + it.effect.each(cases)("$name", (testCase) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const root = yield* fs.makeTempDirectoryScoped({ + directory: "/tmp", + prefix: "otter-ssh-service-", + }); + const home = `${root}/home`; + const serverHome = `${home}/.otter-code`; + const stateDir = `${serverHome}/ssh-launch/fixture`; + const runtimeDir = `${serverHome}/runtime/versions/${ARCHIVE.archiveVersion}`; + const runtimeFile = `${serverHome}/userdata/server-runtime.json`; + for (const dir of [stateDir, runtimeDir, `${serverHome}/userdata`, `${root}/bin`]) { + yield* fs.makeDirectory(dir, { recursive: true }); + } + yield* fs.writeFileString(`${home}/ticks`, "0"); + yield* fs.writeFileString(`${runtimeDir}/.install-complete`, ARCHIVE.archiveVersion); + const runtime = JSON.stringify({ + pid: process.pid, + port: 4773, + origin: "http://127.0.0.1:4773", + }); + if (testCase.server === "up") { + yield* fs.writeFileString(runtimeFile, runtime); + yield* fs.writeFileString(`${home}/ready`, "4773\n"); + } else if (testCase.server === "restart") { + // A dead child's metadata must be reread, and readiness may lag it. + yield* fs.writeFileString(runtimeFile, JSON.stringify({ pid: 0, port: 4772 })); + yield* fs.writeFileString(`${home}/next-runtime`, runtime); + } + if (["active", "pending", "invalid"].includes(testCase.marker)) { + const state = + testCase.marker === "active" + ? { activeVersion: "1.2.3" } + : { update: { status: "pending" } }; + yield* fs.writeFileString( + `${serverHome}/runtime/service-state.json`, + testCase.marker === "invalid" ? "{" : JSON.stringify(state), + ); + } else if (testCase.marker === "unit" || testCase.marker === "plist") { + const unitDir = + testCase.marker === "unit" + ? `${home}/.config/systemd/user` + : `${home}/Library/LaunchAgents`; + yield* fs.makeDirectory(unitDir, { recursive: true }); + yield* fs.writeFileString( + `${unitDir}/${testCase.marker === "unit" ? "otter-code.service" : "dev.otterware.code.service.plist"}`, + "fixture", + ); + } else if (testCase.marker === "enabled") { + yield* fs.writeFileString(`${home}/enabled`, ""); + } + const stale = "stale" in testCase ? testCase.stale : undefined; + if (stale) { + yield* fs.writeFileString( + `${stateDir}/pid`, + String(stale === "same" ? process.pid : 424242), + ); + yield* fs.writeFileString(`${stateDir}/port`, "4774"); + yield* fs.writeFileString(`${stateDir}/managed`, "managed"); + } + const executable = `${runtimeDir}/t3`; + yield* fs.writeFileString( + executable, + `#!/bin/sh +set -eu +case "$1" in + --version) exit 0 ;; + __ssh-helper) + shift + case "$1" in + runtime-port) + "$T3_TEST_NODE" -e 'try { const r = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")); if (!r.pid) process.exit(1); process.kill(r.pid, 0); process.stdout.write(r.pid + " " + r.port); } catch { process.exit(1); }' "$2" ;; + wait-ready) + if [ "$2" = "4774" ]; then exit 0; fi + [ -f "$HOME/ready" ] && [ "$(cat "$HOME/ready")" = "$2" ] ;; + pick-port) printf '4774' ;; + *) exit 99 ;; + esac ;; + *) exit 99 ;; +esac +`, + ); + yield* fs.chmod(executable, 0o700); + // Shadow systemctl unconditionally so tests cannot query the live unit. + yield* fs.writeFileString( + `${root}/bin/systemctl`, + `#!/bin/sh +printf '%s\\n' "$*" > "$HOME/systemctl-call" +[ "$*" = "--user is-enabled otter-code.service" ] && [ -f "$HOME/enabled" ] +`, + ); + yield* fs.chmod(`${root}/bin/systemctl`, 0o700); + const fakeCommands = ` +kill() { + if [ "$1" = "-0" ]; then [ ! -f "$HOME/killed" ]; return; fi + printf '%s' "$1" > "$HOME/killed" +} +date() { cat "$HOME/ticks"; } +sleep() { + TICKS=$(($(cat "$HOME/ticks") + 1)) + printf '%s' "$TICKS" > "$HOME/ticks" + if [ "$TICKS" -eq 2 ] && [ -f "$HOME/next-runtime" ]; then + mv "$HOME/next-runtime" "$HOME/.otter-code/userdata/server-runtime.json" + fi + if [ "$TICKS" -eq 3 ] && [ "${testCase.server}" = "restart" ]; then printf '4773' > "$HOME/ready"; fi +} +nohup() { printf '%s\\n' "$*" > "$HOME/spawned"; } +`; + const script = `${root}/launch.sh`; + yield* fs.writeFileString( + script, + fakeCommands + SshTunnel.buildRemoteLaunchScript(ARCHIVE), + ); + const child = yield* spawner.spawn( + ChildProcess.make("sh", [script, "fixture"], { + env: { + HOME: home, + PATH: `${root}/bin:${process.env.PATH ?? ""}`, + T3_TEST_NODE: process.execPath, + }, + extendEnv: false, + }), + ); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectProcessOutput(child.stdout), + collectProcessOutput(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ); + const failure = testCase.marker !== "none" && testCase.server === "down"; + assert.equal(exitCode, failure ? 1 : 0, stderr); + const managed = testCase.marker === "none" && testCase.server === "down"; + assert.equal(yield* fs.exists(`${home}/spawned`), managed); + if (failure) { + assert.equal(stdout, ""); + assert.include(stderr, "This host's Otter Code service is not running"); + assert.include(stderr, "otter-code service restart"); + assert.include(stderr, "systemctl --user start otter-code.service"); + assert.equal(Number(yield* fs.readFileString(`${home}/ticks`)), 120); + } else { + assert.deepEqual(JSON.parse(stdout), { + remotePort: managed ? 4774 : 4773, + serverKind: managed ? "managed" : "external", + }); + assert.equal( + (yield* fs.readFileString(`${stateDir}/managed`)).trim(), + managed ? "managed" : "external", + ); + assert.equal(yield* fs.exists(`${stateDir}/pid`), managed); + if (testCase.server === "restart") + assert.equal(Number(yield* fs.readFileString(`${home}/ticks`)), 3); + if (managed) + assert.include( + yield* fs.readFileString(`${home}/spawned`), + `serve --host 127.0.0.1 --port 4774 --base-dir ${serverHome}`, + ); + } + assert.equal(yield* fs.exists(`${home}/killed`), stale === "other" && !failure); + if (stale === "other" && !failure) + assert.equal(yield* fs.readFileString(`${home}/killed`), "424242"); + if (testCase.marker === "enabled" || testCase.marker === "none") { + assert.equal( + (yield* fs.readFileString(`${home}/systemctl-call`)).trim(), + "--user is-enabled otter-code.service", + ); + } + }).pipe(Effect.provide(NodeServices.layer)), + ); + }, +); + // The archive runner is generated shell; string assertions cannot prove the // lock excludes concurrent installers. Run the real script against a tiny // fake archive served from a file:// mirror. diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 9fb00d0bb04b..6915701196da 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -52,7 +52,8 @@ const SSH_READY_TIMEOUT_MS = 20_000; const SSH_READY_PROBE_TIMEOUT_MS = 1_000; const TUNNEL_SHUTDOWN_TIMEOUT_MS = 2_000; const REMOTE_READY_TIMEOUT_MS = 60_000; -const REMOTE_LAUNCH_TIMEOUT_MS = 90_000; +const REMOTE_LAUNCH_TIMEOUT_MS = 150_000; +const REMOTE_SERVICE_WAIT_SECONDS = 120; // A cold archive launch also downloads and unpacks a ~70 MB release archive // and may wait on another installer's lock. The budgets nest: the checksum // file is tiny and the archive download is bounded; a waiter outlasts both @@ -631,39 +632,52 @@ NODE REMOTE_PID="$(cat "$PID_FILE" 2>/dev/null || true)" REMOTE_PORT="$(cat "$PORT_FILE" 2>/dev/null || true)" REMOTE_MANAGED="$(cat "$MANAGED_FILE" 2>/dev/null || true)" -DEFAULT_RUNTIME_INFO="$(resolve_default_runtime_port 2>/dev/null || true)" -DEFAULT_RUNTIME_PID="" -DEFAULT_REMOTE_PORT="" -if [ -n "$DEFAULT_RUNTIME_INFO" ]; then +service_manages_default_home() { + # Durable service state reserves this home even while its child is down. + # Fail closed on unreadable/malformed state rather than risking two writers. + [ -f "$DEFAULT_SERVER_HOME/runtime/service-state.json" ] || + [ -f "$HOME/.config/systemd/user/otter-code.service" ] || + [ -f "$HOME/Library/LaunchAgents/dev.otterware.code.service.plist" ] || + { command -v systemctl >/dev/null 2>&1 && systemctl --user is-enabled otter-code.service >/dev/null 2>&1; } +} +connect_default_runtime() { + DEFAULT_RUNTIME_INFO="$(resolve_default_runtime_port 2>/dev/null || true)" + [ -n "$DEFAULT_RUNTIME_INFO" ] || return 1 DEFAULT_RUNTIME_PID="\${DEFAULT_RUNTIME_INFO%% *}" DEFAULT_REMOTE_PORT="\${DEFAULT_RUNTIME_INFO#* }" -fi -if [ -n "$DEFAULT_REMOTE_PORT" ]; then REMOTE_PORT="$DEFAULT_REMOTE_PORT" - if wait_ready "@@T3_REUSE_READY_TIMEOUT_MS@@"; then - if [ "$REMOTE_MANAGED" = "managed" ]; then - PID_TO_STOP="\${REMOTE_PID:-$DEFAULT_RUNTIME_PID}" - if [ -n "$PID_TO_STOP" ] && kill -0 "$PID_TO_STOP" 2>/dev/null; then - kill "$PID_TO_STOP" 2>/dev/null || true - wait_for_pid_exit "$PID_TO_STOP" - fi - REMOTE_PID="" - REMOTE_PORT="$DEFAULT_REMOTE_PORT" - REMOTE_MANAGED="external" - rm -f "$PID_FILE" - printf '%s\\n' "$REMOTE_PORT" >"$PORT_FILE" - printf 'external\\n' >"$MANAGED_FILE" - else - printf '%s\\n' "$REMOTE_PORT" >"$PORT_FILE" - printf 'external\\n' >"$MANAGED_FILE" - REMOTE_PID="" - REMOTE_MANAGED="external" + wait_ready "@@T3_REUSE_READY_TIMEOUT_MS@@" || return 1 + if [ "$REMOTE_MANAGED" = "managed" ]; then + PID_TO_STOP="\${REMOTE_PID:-$DEFAULT_RUNTIME_PID}" + # A leftover ownership file must never kill the service's current child. + if [ -n "$PID_TO_STOP" ] && [ "$PID_TO_STOP" != "$DEFAULT_RUNTIME_PID" ] && kill -0 "$PID_TO_STOP" 2>/dev/null; then + kill "$PID_TO_STOP" 2>/dev/null || true + wait_for_pid_exit "$PID_TO_STOP" fi - else - REMOTE_PID="$(cat "$PID_FILE" 2>/dev/null || true)" - REMOTE_PORT="$(cat "$PORT_FILE" 2>/dev/null || true)" - REMOTE_MANAGED="$(cat "$MANAGED_FILE" 2>/dev/null || true)" fi + REMOTE_PID="" + REMOTE_PORT="$DEFAULT_REMOTE_PORT" + REMOTE_MANAGED="external" + rm -f "$PID_FILE" + printf '%s\\n' "$REMOTE_PORT" >"$PORT_FILE" + printf 'external\\n' >"$MANAGED_FILE" +} +if service_manages_default_home; then + SERVICE_WAIT_DEADLINE=$(($(date +%s) + @@T3_SERVICE_WAIT_SECONDS@@)) + while ! connect_default_runtime; do + if [ "$(date +%s)" -ge "$SERVICE_WAIT_DEADLINE" ]; then + printf "This host's Otter Code service is not running; start it with otter-code service restart (or systemctl --user start otter-code.service on Linux).\\n" >&2 + exit 1 + fi + sleep 1 + done + printf '{"remotePort":%s,"serverKind":"external"}\\n' "$REMOTE_PORT" + exit 0 +fi +if ! connect_default_runtime; then + REMOTE_PID="$(cat "$PID_FILE" 2>/dev/null || true)" + REMOTE_PORT="$(cat "$PORT_FILE" 2>/dev/null || true)" + REMOTE_MANAGED="$(cat "$MANAGED_FILE" 2>/dev/null || true)" fi if [ "$REMOTE_MANAGED" = "external" ]; then if [ -z "$REMOTE_PORT" ] || ! wait_ready "@@T3_REUSE_READY_TIMEOUT_MS@@"; then @@ -836,6 +850,7 @@ export function buildRemoteLaunchScript(input?: RemoteT3RunnerOptions): string { T3_REMOTE_PORT_SCAN_WINDOW: String(REMOTE_PORT_SCAN_WINDOW), T3_READY_TIMEOUT_MS: String(REMOTE_READY_TIMEOUT_MS), T3_REUSE_READY_TIMEOUT_MS: String(REMOTE_REUSE_READY_TIMEOUT_MS), + T3_SERVICE_WAIT_SECONDS: String(REMOTE_SERVICE_WAIT_SECONDS), T3_READY_PROBE_TIMEOUT_MS: String(SSH_READY_PROBE_TIMEOUT_MS), }); } diff --git a/patches/react-native-screens@5.0.0-t3.27.patch b/patches/react-native-screens@5.0.0-t3.27.patch new file mode 100644 index 000000000000..9082132baa15 --- /dev/null +++ b/patches/react-native-screens@5.0.0-t3.27.patch @@ -0,0 +1,18 @@ +diff --git a/ios/header/RNSHeaderCoordinator.mm b/ios/header/RNSHeaderCoordinator.mm +--- a/ios/header/RNSHeaderCoordinator.mm ++++ b/ios/header/RNSHeaderCoordinator.mm +@@ -578,7 +578,10 @@ + #if RNS_IPHONE_OS_VERSION_AVAILABLE(27_0) ++ // Early iOS 27 betas lack this setter; calling it there crashes on launch. + if (@available(iOS 27.0, *)) { +- UIBarMinimization *minimization = [UIBarMinimization new]; +- minimization.minimizationBehavior = _configDataProvider.minimizationBehavior; +- minimization.restorationBehavior = _configDataProvider.restorationBehavior; +- navItem.navigationBarMinimization = minimization; ++ if ([navItem respondsToSelector:@selector(setNavigationBarMinimization:)]) { ++ UIBarMinimization *minimization = [UIBarMinimization new]; ++ minimization.minimizationBehavior = _configDataProvider.minimizationBehavior; ++ minimization.restorationBehavior = _configDataProvider.restorationBehavior; ++ navItem.navigationBarMinimization = minimization; ++ } + } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf6e6a7eb5b1..0483f42234af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,9 @@ catalogs: '@pierre/diffs': specifier: 1.5.2 version: 1.5.2 + '@types/plist': + specifier: 3.0.5 + version: 3.0.5 electron-webauthn: specifier: 1.3.1 version: 1.3.1 @@ -42,9 +45,9 @@ catalogs: micromark-util-character: specifier: 2.1.1 version: 2.1.1 - micromark-util-types: - specifier: 2.0.2 - version: 2.0.2 + plist: + specifier: 3.1.1 + version: 3.1.1 rehype-raw: specifier: 7.0.0 version: 7.0.0 @@ -113,6 +116,7 @@ overrides: electron-webauthn>@electron-webauthn/macos: 1.3.1 '@electron-webauthn/macos>objc-js': 1.5.0 lightningcss: 1.33.0 + micromark-util-types: 2.0.2 tailwindcss: 4.3.3 '@types/ws@^8': 8.18.2 undici@^8: 8.11.2 @@ -147,6 +151,7 @@ patchedDependencies: node-pty@1.2.0-beta.15: f2fe901c61cde17986240002d05c172d5d0272d83ffaab8d0ebeb922763be414 react-native-gesture-handler@3.2.1: 3037216c0dd1b14e8877441bde1a7404d1c6e9c09f835bd184c3cdba4aa066ad react-native-keyboard-controller@1.22.6: 6e448411781347cd0b87868ee279117514a399801c18f233ddca4fa3935be75d + react-native-screens@5.0.0-t3.27: 126b3f3ccc4d6529c2c5c97239428c364239f1c03b363919366a574b5b11af8d react-native-webview@14.0.1: 81caa279ec3dc1f81209062133b66e8f824e13f106cca57f1fa732a72c2ab378 importers: @@ -319,7 +324,7 @@ importers: version: 7.3.4(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) '@react-navigation/native-stack': specifier: 7.17.6 - version: 7.17.6(patch_hash=d2d52f3776e30765519748fd5e177592dc3b9cc778c52fb00e02674dfa9042c5)(6b53c4f789a319cd2c00cd296dcea732) + version: 7.17.6(patch_hash=d2d52f3776e30765519748fd5e177592dc3b9cc778c52fb00e02674dfa9042c5)(cabcdeb384288e6f0e9c097f5093503d) '@shikijs/core': specifier: 4.2.0 version: 4.2.0 @@ -520,7 +525,7 @@ importers: version: 5.9.1(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) react-native-screens: specifier: file:deps/react-native-screens-5.0.0-t3.27.tgz - version: file:apps/mobile/deps/react-native-screens-5.0.0-t3.27.tgz(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) + version: file:apps/mobile/deps/react-native-screens-5.0.0-t3.27.tgz(patch_hash=126b3f3ccc4d6529c2c5c97239428c364239f1c03b363919366a574b5b11af8d)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) react-native-shiki-engine: specifier: ^0.3.12 version: 0.3.12(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) @@ -1543,7 +1548,7 @@ importers: specifier: 24.12.4 version: 24.12.4 micromark-util-types: - specifier: 'catalog:' + specifier: 2.0.2 version: 2.0.2 vite-plus: specifier: 1.0.0 @@ -1840,6 +1845,9 @@ importers: effect: specifier: 4.0.2 version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) + plist: + specifier: 'catalog:' + version: 3.1.1 pngjs: specifier: 7.0.0 version: 7.0.0 @@ -1850,6 +1858,9 @@ importers: '@effect/vitest': specifier: 4.0.2 version: 4.0.2(patch_hash=595b7d26fb635caba9c3d39fcb189bb78a000192f8d3b60ead7174b310d1ade9)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + '@types/plist': + specifier: 'catalog:' + version: 3.0.5 '@types/pngjs': specifier: 6.0.5 version: 6.0.5 @@ -6292,6 +6303,9 @@ packages: '@types/node@24.12.4': resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} + '@types/plist@3.0.5': + resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==} + '@types/pngjs@6.0.5': resolution: {integrity: sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==} @@ -15699,7 +15713,7 @@ snapshots: optionalDependencies: '@react-native-masked-view/masked-view': 0.3.2(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) - '@react-navigation/native-stack@7.17.6(patch_hash=d2d52f3776e30765519748fd5e177592dc3b9cc778c52fb00e02674dfa9042c5)(6b53c4f789a319cd2c00cd296dcea732)': + '@react-navigation/native-stack@7.17.6(patch_hash=d2d52f3776e30765519748fd5e177592dc3b9cc778c52fb00e02674dfa9042c5)(cabcdeb384288e6f0e9c097f5093503d)': dependencies: '@react-navigation/elements': 2.9.26(83de68753219ecf7f3321e065bd378be) '@react-navigation/native': 7.3.4(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) @@ -15707,7 +15721,7 @@ snapshots: react: 19.3.0 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) react-native-safe-area-context: 5.9.1(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) - react-native-screens: file:apps/mobile/deps/react-native-screens-5.0.0-t3.27.tgz(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) + react-native-screens: file:apps/mobile/deps/react-native-screens-5.0.0-t3.27.tgz(patch_hash=126b3f3ccc4d6529c2c5c97239428c364239f1c03b363919366a574b5b11af8d)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) sf-symbols-typescript: 2.2.0 warn-once: 0.1.1 transitivePeerDependencies: @@ -16659,6 +16673,11 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/plist@3.0.5': + dependencies: + '@types/node': 24.12.4 + xmlbuilder: 15.1.1 + '@types/pngjs@6.0.5': dependencies: '@types/node': 24.12.4 @@ -21628,7 +21647,7 @@ snapshots: react: 19.3.0 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) - react-native-screens@file:apps/mobile/deps/react-native-screens-5.0.0-t3.27.tgz(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): + react-native-screens@file:apps/mobile/deps/react-native-screens-5.0.0-t3.27.tgz(patch_hash=126b3f3ccc4d6529c2c5c97239428c364239f1c03b363919366a574b5b11af8d)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): dependencies: react: 19.3.0 react-freeze: 1.0.4(react@19.3.0) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 28bb049ef3ba..96be13690327 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -44,12 +44,14 @@ catalog: "@tailwindcss/oxide": 4.3.3 "@tailwindcss/vite": 4.3.3 "@types/node": 24.12.4 + "@types/plist": 3.0.5 "@types/ws": 8.18.2 effect: 4.0.2 electron-webauthn: 1.3.1 jose: 6.2.2 lightningcss: 1.33.0 objc-js: 1.5.0 + plist: 3.1.1 tailwindcss: 4.3.3 tldts: 7.4.2 typescript: 7.0.2 @@ -239,6 +241,8 @@ overrides: "electron-webauthn>@electron-webauthn/macos": "catalog:" "@electron-webauthn/macos>objc-js": "catalog:" lightningcss: "catalog:" + # Micromark extension types must share the parser's catalog version. + micromark-util-types: "catalog:" tailwindcss: "catalog:" # Ranges from @effect/platform-node, jsdom, unifont, Expo and libsql otherwise # settle on two patch releases of each major. Older majors stay as they are. @@ -260,6 +264,7 @@ patchedDependencies: alchemy@2.0.0-beta.80: patches/alchemy@2.0.0-beta.80.patch expo-glass-effect@58.0.3: patches/expo-glass-effect@58.0.3.patch expo-blur@58.0.3: patches/expo-blur@58.0.3.patch + react-native-screens@5.0.0-t3.27: patches/react-native-screens@5.0.0-t3.27.patch expo-audio@58.0.4: patches/expo-audio@58.0.4.patch "@effect/platform-node-shared@4.0.2": patches/@effect__platform-node-shared@4.0.2.patch "@effect/vitest@4.0.2": patches/@effect__vitest@4.0.2.patch diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 367b9865fd92..90d6aae2c444 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -1970,6 +1970,26 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { ), ); + it.effect("keeps default signing when no macOS provisioning profile is configured", () => + Effect.gen(function* () { + const config = yield* createBuildConfig( + "mac", + "dmg", + "1.2.3", + true, + false, + undefined, + undefined, + ); + const mac = config.mac as Record; + assert.notProperty(mac, "entitlements"); + assert.notProperty(mac, "provisioningProfile"); + // electron-builder enables hardened runtime by default. + assert.notProperty(mac, "hardenedRuntime"); + assert.match(String(mac.sign), /[\\/]scripts[\\/]sign-macos\.ts$/); + }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))), + ); + it.effect("adds signing entitlements and both renderer protocols to signed macOS builds", () => Effect.gen(function* () { const config = yield* createBuildConfig("mac", "dmg", "1.2.3", true, false, undefined, { diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 227b4361063c..8f1ee16d98ca 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -4,7 +4,6 @@ import * as NodeFSP from "node:fs/promises"; import * as NodeCrypto from "node:crypto"; import * as NodeModule from "node:module"; -// plist is CommonJS; Node cannot load its named exports from an ES module. import { createPackageWithOptions, @@ -34,6 +33,13 @@ import { selectCliRuntimeExternalDependencies, } from "./lib/cli-external-packages.ts"; import { selectDesktopRuntimeExternalDependencies } from "./lib/desktop-external-packages.ts"; +import { loadRepoEnv } from "./lib/public-config.ts"; +import { + resolveMacPasskeySigningConfiguration, + resolveMacWebAuthnEntitlements, + renderMacPasskeyEntitlements, + type MacWebAuthnEntitlements, +} from "./otter/macos-profile.ts"; import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; @@ -198,6 +204,28 @@ const getDefaultArch = Effect.fn("getDefaultArch")(function* (platform: typeof B return yield* getDefaultBuildArch(platform, config); }); +export class MacPasskeySigningConfigurationResolutionError extends Schema.TaggedError()( + "MacPasskeySigningConfigurationResolutionError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to resolve macOS passkey signing configuration."; + } +} + +export class MacProvisioningProfileNotFoundError extends Schema.TaggedError()( + "MacProvisioningProfileNotFoundError", + { + provisioningProfilePath: Schema.String, + }, +) { + override get message(): string { + return `macOS provisioning profile not found: ${this.provisioningProfilePath}`; + } +} + export class KeyringNativePackageMissingError extends Schema.TaggedError()( "KeyringNativePackageMissingError", { @@ -909,6 +937,7 @@ interface StagePackageJson { readonly version: string; readonly buildVersion: string; readonly t3codeCommitHash: string; + readonly t3codeWebAuthn?: MacWebAuthnEntitlements; readonly private: true; readonly packageManager: string; readonly description: string; @@ -3442,6 +3471,49 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( const stageProdResourcesDir = path.join(stageAppDir, "apps/desktop/prod-resources"); yield* fs.copy(stageResourcesDir, stageProdResourcesDir); + const configuredMacPasskeySigning = + options.platform === "mac" && options.signed + ? yield* Effect.try({ + try: () => + resolveMacPasskeySigningConfiguration(loadRepoEnv({ repoRoot }), DESKTOP_APP_ID), + catch: (cause) => new MacPasskeySigningConfigurationResolutionError({ cause }), + }) + : undefined; + const macPasskeySigning = configuredMacPasskeySigning + ? { + ...configuredMacPasskeySigning, + provisioningProfilePath: path.resolve( + repoRoot, + configuredMacPasskeySigning.provisioningProfilePath, + ), + } + : undefined; + const macEntitlementsPath = macPasskeySigning + ? path.join(stageAppDir, "entitlements.mac.plist") + : undefined; + let macWebAuthn: MacWebAuthnEntitlements | undefined; + if (macPasskeySigning && macEntitlementsPath) { + if (!(yield* fs.exists(macPasskeySigning.provisioningProfilePath))) { + return yield* new MacProvisioningProfileNotFoundError({ + provisioningProfilePath: macPasskeySigning.provisioningProfilePath, + }); + } + const profile = yield* fs.readFileString(macPasskeySigning.provisioningProfilePath); + macWebAuthn = resolveMacWebAuthnEntitlements(profile, macPasskeySigning); + yield* Effect.log( + `[desktop-artifact] In-app browser passkeys: Touch ID ${macWebAuthn.touchIdKeychainAccessGroup ? "enabled" : "disabled"}, browser passkeys ${macWebAuthn.browserPasskeys ? "enabled" : "disabled"}.`, + ); + const entitlements = renderMacPasskeyEntitlements(macPasskeySigning, macWebAuthn, profile); + yield* fs.writeFileString(macEntitlementsPath, entitlements); + yield* Effect.log( + `[desktop-artifact] macOS provisioning profile embedded; enabled entitlements: ${[...entitlements.matchAll(/([^<]+)<\/key>/gu)].map((match) => match[1]).join(", ")}.`, + ); + } else if (options.platform === "mac") { + yield* Effect.log( + "[desktop-artifact] No macOS provisioning profile embedded; passkey entitlements disabled; default hardened-runtime entitlements retained.", + ); + } + // Windows splits dependencies per process: app.asar carries only the // desktop main-process externals, while the server bundle's externals live // in the server.asar sidecar (see stageWindowsServerSidecar). macOS and @@ -3469,6 +3541,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( version: appVersion, buildVersion: appVersion, t3codeCommitHash: commitHash, + // Read by apps/desktop/src/preview/Passkeys.ts; must match the signed entitlements. + ...(macWebAuthn ? { t3codeWebAuthn: macWebAuthn } : {}), private: true, packageManager: rootPackageJson.packageManager, description: @@ -3485,7 +3559,12 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( options.signed, options.mockUpdates, options.mockUpdateServerPort, - undefined, + macPasskeySigning && macEntitlementsPath + ? { + entitlementsPath: macEntitlementsPath, + provisioningProfilePath: macPasskeySigning.provisioningProfilePath, + } + : undefined, bundlesWslRuntime({ platform: options.platform, runtimeArchivePath: options.wslRuntime }), options.arch, ), diff --git a/scripts/otter/fixtures/macos-profile.plist b/scripts/otter/fixtures/macos-profile.plist new file mode 100644 index 000000000000..3fa8fe146790 --- /dev/null +++ b/scripts/otter/fixtures/macos-profile.plist @@ -0,0 +1,21 @@ + + + + + Entitlements + + com.apple.application-identifier + YNJ5WLH965.dev.otterware.code + com.apple.developer.team-identifier + YNJ5WLH965 + keychain-access-groups + YNJ5WLH965.* + com.apple.developer.associated-domains + * + com.apple.developer.aps-environment + production + com.apple.security.application-groups + group.dev.otterware.codeYNJ5WLH965.* + + + diff --git a/scripts/otter/macos-profile.test.ts b/scripts/otter/macos-profile.test.ts new file mode 100644 index 000000000000..d701daff44c0 --- /dev/null +++ b/scripts/otter/macos-profile.test.ts @@ -0,0 +1,206 @@ +// @effect-diagnostics nodeBuiltinImport:off - Tests read a synthetic profile fixture, never signing secrets. +import * as NodeFS from "node:fs"; +import Plist from "plist"; +import { assert, it } from "@effect/vitest"; + +import { + InvalidAppleTeamIdError, + InvalidMacPasskeyRpDomainError, + renderMacPasskeyEntitlements, + resolveMacPasskeySigningConfiguration, + resolveMacWebAuthnEntitlements, +} from "./macos-profile.ts"; + +const appId = "dev.otterware.code"; +const teamId = "YNJ5WLH965"; +const configuration = { appId, teamId, rpDomains: [], provisioningProfilePath: "/tmp/profile" }; +const fixture = NodeFS.readFileSync( + new URL("./fixtures/macos-profile.plist", import.meta.url), + "utf8", +); +// Like upstream's tests, include a CMS envelope around the XML payload. +const profile = (entitlements: string, outside = "") => + `0\x82\x1f\x9a\x06\t*\x86H${outside}Entitlements${entitlements}\x00\x01`; +const render = (payload: string, rpDomains: readonly string[] = []) => + Plist.parse( + renderMacPasskeyEntitlements( + { ...configuration, rpDomains }, + resolveMacWebAuthnEntitlements(payload, configuration), + payload, + ), + ) as Record; + +it("claims only the used grants from today's profile and retains hardened-runtime entitlements", () => { + assert.deepStrictEqual(render(fixture), { + "com.apple.application-identifier": `${teamId}.${appId}`, + "com.apple.developer.team-identifier": teamId, + "keychain-access-groups": [`${teamId}.${appId}.webauthn`], + "com.apple.security.cs.allow-jit": true, + "com.apple.security.cs.allow-unsigned-executable-memory": true, + "com.apple.security.cs.disable-library-validation": true, + }); + assert.deepStrictEqual(resolveMacWebAuthnEntitlements(fixture, configuration), { + touchIdKeychainAccessGroup: `${teamId}.${appId}.webauthn`, + browserPasskeys: false, + }); +}); + +it("enables the managed browser capability only after the profile grants it", () => { + const approved = fixture.replace( + "Entitlements\n ", + "Entitlements\n com.apple.developer.web-browser.public-key-credential", + ); + assert.deepStrictEqual(render(approved), { + ...render(fixture), + "com.apple.developer.web-browser.public-key-credential": true, + }); + assert.isTrue(resolveMacWebAuthnEntitlements(approved, configuration).browserPasskeys); +}); + +it("does not infer Associated Domains from Clerk keys or the Otter Accounts issuer", () => { + const resolved = resolveMacPasskeySigningConfiguration( + { + APPLE_TEAM_ID: teamId.toLowerCase(), + T3CODE_MACOS_PROVISIONING_PROFILE: " /tmp/profile ", + T3CODE_CLERK_PUBLISHABLE_KEY: "pk_test_invalid", + T3CODE_ACCOUNTS_URL: "https://accounts.otterware.app", + }, + appId, + ); + assert.deepStrictEqual(resolved, configuration); +}); + +it("preserves profile-free signing even without a team ID or domain configuration", () => { + assert.isUndefined(resolveMacPasskeySigningConfiguration({}, appId)); + assert.isUndefined( + resolveMacPasskeySigningConfiguration( + { + T3CODE_MACOS_PROVISIONING_PROFILE: " ", + T3CODE_MACOS_PASSKEY_RP_DOMAINS: "bad://domain", + }, + appId, + ), + ); +}); + +it("claims only explicitly configured Associated Domains that the profile authorizes", () => { + assert.deepStrictEqual(render(fixture, ["accounts.otterware.app"]), { + ...render(fixture), + "com.apple.developer.associated-domains": ["webcredentials:accounts.otterware.app"], + }); + const restricted = profile(`com.apple.developer.associated-domains + webcredentials:accounts.otterware.app + webcredentials:*.example.com + `); + const selected = render(restricted, [ + "accounts.otterware.app", + "login.example.com", + "example.com", + "other.com", + ]); + assert.deepPropertyVal(selected, "com.apple.developer.associated-domains", [ + "webcredentials:accounts.otterware.app", + "webcredentials:login.example.com", + ]); + assert.notProperty( + render(profile(""), ["accounts.otterware.app"]), + "com.apple.developer.associated-domains", + ); +}); + +it("normalizes and deduplicates explicit domains, and rejects unsafe values", () => { + const env = { T3CODE_APPLE_TEAM_ID: teamId, T3CODE_MACOS_PROVISIONING_PROFILE: "/tmp/profile" }; + assert.deepStrictEqual( + resolveMacPasskeySigningConfiguration( + { + ...env, + T3CODE_MACOS_PASSKEY_RP_DOMAINS: " Accounts.Otterware.app,accounts.otterware.app ", + }, + appId, + )?.rpDomains, + ["accounts.otterware.app"], + ); + for (const value of [ + "https://example.com", + "example.com:8443", + "user:password@example.com", + "example.com/path", + "example.com?token=secret", + "example.com#fragment", + "example.com,", + ]) { + assert.throws( + () => + resolveMacPasskeySigningConfiguration( + { + ...env, + T3CODE_MACOS_PASSKEY_RP_DOMAINS: value, + }, + appId, + ), + InvalidMacPasskeyRpDomainError, + ); + } + assert.throws( + () => + resolveMacPasskeySigningConfiguration( + { + ...env, + T3CODE_APPLE_TEAM_ID: "wrong", + }, + appId, + ), + InvalidAppleTeamIdError, + ); +}); + +it("ignores comments, unrelated keys, false capabilities, and unreadable profiles", () => { + const groups = "keychain-access-groupsYNJ5WLH965.*"; + const browser = "com.apple.developer.web-browser.public-key-credential"; + for (const payload of [ + "", + "unreadable", + profile(""), + profile(``), + profile( + "com.apple.developer.web-browser.public-key-credential", + groups + browser, + ), + ]) { + assert.deepStrictEqual(resolveMacWebAuthnEntitlements(payload, configuration), { + touchIdKeychainAccessGroup: undefined, + browserPasskeys: false, + }); + assert.deepStrictEqual(render(payload), { + "com.apple.security.cs.allow-jit": true, + "com.apple.security.cs.allow-unsigned-executable-memory": true, + "com.apple.security.cs.disable-library-validation": true, + }); + } +}); + +it("accepts an exact keychain grant and rejects another team's grant", () => { + assert.equal( + resolveMacWebAuthnEntitlements( + profile( + `keychain-access-groups${teamId}.${appId}.webauthn`, + ), + configuration, + ).touchIdKeychainAccessGroup, + `${teamId}.${appId}.webauthn`, + ); + assert.isUndefined( + resolveMacWebAuthnEntitlements( + profile("keychain-access-groupsOTHERTEAM1.*"), + configuration, + ).touchIdKeychainAccessGroup, + ); +}); + +it("derives the Otterware keychain group from its own app ID", () => { + const suite = { ...configuration, appId: "dev.otterware.suite" }; + assert.equal( + resolveMacWebAuthnEntitlements(fixture, suite).touchIdKeychainAccessGroup, + "YNJ5WLH965.dev.otterware.suite.webauthn", + ); +}); diff --git a/scripts/otter/macos-profile.ts b/scripts/otter/macos-profile.ts new file mode 100644 index 000000000000..ac719ddbd2cf --- /dev/null +++ b/scripts/otter/macos-profile.ts @@ -0,0 +1,252 @@ +// Ported from upstream 3e01c4bc57 and 454b94a13a. Otter's account/domain policy +// lives here so the upstream desktop packager can keep its signing flow. +// plist is CommonJS; Node cannot load its named exports from an ES module. +import Plist from "plist"; +import * as Schema from "effect/Schema"; + +const APPLE_TEAM_ID_PATTERN = /^[A-Z0-9]{10}$/u; + +export interface MacPasskeySigningConfiguration { + readonly appId: string; + readonly teamId: string; + readonly rpDomains: readonly string[]; + readonly provisioningProfilePath: string; +} + +export const InvalidMacPasskeyRpDomainReason = Schema.Literals([ + "empty", + "scheme-not-allowed", + "parse-failed", + "credentials-not-allowed", + "port-not-allowed", + "path-not-allowed", + "query-not-allowed", + "fragment-not-allowed", + "hostname-mismatch", +]); +export type InvalidMacPasskeyRpDomainReason = typeof InvalidMacPasskeyRpDomainReason.Type; + +export class InvalidMacPasskeyRpDomainError extends Schema.TaggedError()( + "InvalidMacPasskeyRpDomainError", + { + reason: InvalidMacPasskeyRpDomainReason, + inputLength: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + cause: Schema.optionalKey(Schema.Defect()), + }, +) { + override get message(): string { + return `Invalid passkey RP domain (${this.reason}).`; + } +} + +export class InvalidAppleTeamIdError extends Schema.TaggedError()( + "InvalidAppleTeamIdError", + { + teamId: Schema.String, + }, +) { + override get message(): string { + return `T3CODE_APPLE_TEAM_ID '${this.teamId}' must be a 10-character Apple Developer Team ID.`; + } +} + +function normalizePasskeyRpDomain(value: string): string { + const normalized = value.trim().toLowerCase(); + const inputLength = value.length; + if (normalized.length === 0) { + throw new InvalidMacPasskeyRpDomainError({ reason: "empty", inputLength }); + } + if (/^[a-z][a-z\d+.-]*:\/\//u.test(normalized)) { + throw new InvalidMacPasskeyRpDomainError({ + reason: "scheme-not-allowed", + inputLength, + }); + } + + let parsed: URL; + try { + parsed = new URL(`https://${normalized}`); + } catch (cause) { + throw new InvalidMacPasskeyRpDomainError({ reason: "parse-failed", inputLength, cause }); + } + + let reason: InvalidMacPasskeyRpDomainReason | undefined; + if (parsed.username.length > 0 || parsed.password.length > 0) { + reason = "credentials-not-allowed"; + } else if (parsed.port.length > 0) { + reason = "port-not-allowed"; + } else if (parsed.pathname !== "/") { + reason = "path-not-allowed"; + } else if (parsed.search.length > 0) { + reason = "query-not-allowed"; + } else if (parsed.hash.length > 0) { + reason = "fragment-not-allowed"; + } else if (parsed.host !== normalized) { + reason = "hostname-mismatch"; + } + if (reason) { + throw new InvalidMacPasskeyRpDomainError({ reason, inputLength }); + } + + return parsed.hostname; +} + +export function resolveMacPasskeySigningConfiguration( + env: Readonly>, + appId: string, +): MacPasskeySigningConfiguration | undefined { + const provisioningProfilePath = env.T3CODE_MACOS_PROVISIONING_PROFILE?.trim() ?? ""; + // A profile is optional: keep existing Developer ID signing/notarization. + if (provisioningProfilePath.length === 0) return undefined; + + const teamId = (env.T3CODE_APPLE_TEAM_ID ?? env.APPLE_TEAM_ID)?.trim().toUpperCase() ?? ""; + if (!APPLE_TEAM_ID_PATTERN.test(teamId)) { + throw new InvalidAppleTeamIdError({ teamId }); + } + + // Otter Accounts uses no Clerk. Only explicitly configured domains are claimed. + const configuredRpDomains = env.T3CODE_MACOS_PASSKEY_RP_DOMAINS?.trim(); + const rpDomains = configuredRpDomains + ? configuredRpDomains.split(",").map(normalizePasskeyRpDomain) + : []; + return { appId, teamId, rpDomains: [...new Set(rpDomains)], provisioningProfilePath }; +} + +function escapeXml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +/** + * Passkey entitlements for the in-app browser. Each is granted only when the + * provisioning profile authorizes it: macOS refuses to launch an app that + * claims a restricted entitlement its embedded profile does not carry. + */ +export interface MacWebAuthnEntitlements { + /** Keychain group for Electron's Touch ID passkeys. */ + readonly touchIdKeychainAccessGroup: string | undefined; + /** Apple's managed browser entitlement, which allows passkeys for any site. */ + readonly browserPasskeys: boolean; +} + +const BROWSER_PASSKEYS_ENTITLEMENT = "com.apple.developer.web-browser.public-key-credential"; + +const ProvisioningProfilePlist = Schema.Struct({ + Entitlements: Schema.Struct({ + "com.apple.application-identifier": Schema.optional(Schema.String), + "com.apple.developer.team-identifier": Schema.optional(Schema.String), + // Developer ID profiles grant a wildcard as the bare string "*", not a list. + "com.apple.developer.associated-domains": Schema.optional( + Schema.Union([Schema.String, Schema.Array(Schema.String)]), + ), + "keychain-access-groups": Schema.optional(Schema.Array(Schema.String)), + [BROWSER_PASSKEYS_ENTITLEMENT]: Schema.optional(Schema.Boolean), + }), +}); +const isProvisioningProfilePlist = Schema.is(ProvisioningProfilePlist); + +/** + * Reads the Entitlements dict of the XML plist a provisioning profile wraps in + * its CMS envelope. Anything unreadable grants nothing. + */ +const readProfileEntitlements = (provisioningProfile: string) => { + const start = provisioningProfile.indexOf("", start); + if (start === -1 || end === -1) return undefined; + try { + const profile: unknown = Plist.parse(provisioningProfile.slice(start, end + "".length)); + return isProvisioningProfilePlist(profile) ? profile.Entitlements : undefined; + } catch { + return undefined; + } +}; + +export function resolveMacWebAuthnEntitlements( + provisioningProfile: string, + configuration: Pick, +): MacWebAuthnEntitlements { + const entitlements = readProfileEntitlements(provisioningProfile); + const keychainAccessGroup = `${configuration.teamId}.${configuration.appId}.webauthn`; + const keychainGroupAuthorized = (entitlements?.["keychain-access-groups"] ?? []).some((group) => + group.endsWith("*") + ? keychainAccessGroup.startsWith(group.slice(0, -1)) + : group === keychainAccessGroup, + ); + return { + touchIdKeychainAccessGroup: keychainGroupAuthorized ? keychainAccessGroup : undefined, + browserPasskeys: entitlements?.[BROWSER_PASSKEYS_ENTITLEMENT] === true, + }; +} + +export function renderMacPasskeyEntitlements( + configuration: MacPasskeySigningConfiguration, + webAuthn: MacWebAuthnEntitlements, + provisioningProfile: string, +): string { + const grants = readProfileEntitlements(provisioningProfile); + const appIdentifier = `${configuration.teamId}.${configuration.appId}`; + const applicationIdentifier = + grants?.["com.apple.application-identifier"] === appIdentifier + ? ` + com.apple.application-identifier + ${escapeXml(appIdentifier)}` + : ""; + const teamIdentifier = + grants?.["com.apple.developer.team-identifier"] === configuration.teamId + ? ` + com.apple.developer.team-identifier + ${escapeXml(configuration.teamId)}` + : ""; + const associatedDomains = configuration.rpDomains + .filter((domain) => + [grants?.["com.apple.developer.associated-domains"] ?? []] + .flat() + .some( + (grant) => + grant === "*" || + grant === `webcredentials:${domain}` || + (grant.startsWith("webcredentials:*.") && + domain.endsWith(grant.slice("webcredentials:*".length))), + ), + ) + .map((domain) => ` webcredentials:${escapeXml(domain)}`) + .join("\n"); + const associatedDomainsEntitlement = + associatedDomains.length > 0 + ? ` + com.apple.developer.associated-domains + +${associatedDomains} + ` + : ""; + const keychainAccessGroups = webAuthn.touchIdKeychainAccessGroup + ? ` + keychain-access-groups + + ${escapeXml(webAuthn.touchIdKeychainAccessGroup)} + ` + : ""; + const browserPasskeys = webAuthn.browserPasskeys + ? ` + ${BROWSER_PASSKEYS_ENTITLEMENT} + ` + : ""; + + return ` + + + ${applicationIdentifier}${teamIdentifier}${associatedDomainsEntitlement}${keychainAccessGroups}${browserPasskeys} + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + + +`; +} diff --git a/scripts/package.json b/scripts/package.json index 8057944b129e..23a3c194cecd 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -13,11 +13,13 @@ "@t3tools/shared": "workspace:*", "@t3tools/tailscale": "workspace:*", "effect": "catalog:", + "plist": "catalog:", "pngjs": "7.0.0", "sharp": "0.35.4" }, "devDependencies": { "@effect/vitest": "catalog:", + "@types/plist": "catalog:", "@types/pngjs": "6.0.5", "typescript": "catalog:", "typescript-legacy": "npm:typescript@~6.0.3",