diff --git a/.github/workflows/desktop-release-guard.yml b/.github/workflows/desktop-release-guard.yml index d121602e4..f6e4cd9a9 100644 --- a/.github/workflows/desktop-release-guard.yml +++ b/.github/workflows/desktop-release-guard.yml @@ -149,7 +149,7 @@ jobs: if: matrix.platform == 'linux' run: | sudo apt-get update - sudo apt-get install --yes cpio fakeroot rpm zip + sudo apt-get install --yes cpio dbus-x11 desktop-file-utils fakeroot libglib2.0-bin libsecret-1-0 rpm unzip xdg-utils xvfb zip - name: Package desktop app from clean checkout shell: bash @@ -264,6 +264,25 @@ jobs: --make-directory apps/desktop/out/make \ --output "desktop-release-${{ matrix.platform }}-${{ matrix.arch }}" + - name: Exercise staged native install, deep-link, relaunch, and removal lifecycle + if: matrix.platform == 'linux' || matrix.platform == 'darwin' + shell: bash + run: | + if [ "${{ matrix.platform }}" = linux ]; then + dbus-run-session -- xvfb-run --auto-servernum \ + node apps/desktop/scripts/test-native-artifact-lifecycle.mjs \ + --version "$PROPR_DESKTOP_VERSION" \ + --platform linux \ + --arch "${{ matrix.arch }}" \ + --artifact-directory "desktop-release-${{ matrix.platform }}-${{ matrix.arch }}" + else + node apps/desktop/scripts/test-native-artifact-lifecycle.mjs \ + --version "$PROPR_DESKTOP_VERSION" \ + --platform darwin \ + --arch "${{ matrix.arch }}" \ + --artifact-directory "desktop-release-${{ matrix.platform }}-${{ matrix.arch }}" + fi + - name: Upload unsigned validation target uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: diff --git a/apps/desktop/README.md b/apps/desktop/README.md index c33d0488f..7cc1b14b6 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -104,6 +104,88 @@ PROPR_DESKTOP_ENABLE_RPM=1 \ npm run make -w @propr/desktop -- --arch="$(node -p process.arch)" ``` +### Unsigned internal-RC install and removal (macOS/Linux) + +Use the artifact whose final `x64` or `arm64` suffix matches the machine. These commands are for internal release +candidates only. They do not assert signing or notarization, do not change Gatekeeper policy, and do not use `xattr`, +`spctl --add`, or another quarantine bypass. + +On Debian/Ubuntu, exercise the DEB and remove it with the native package manager: + +```sh +ARCH=x64 # use arm64 on an ARM64 Linux machine +VERSION=0.8.15 +sudo apt install "./ProPR-Desktop-${VERSION}-linux-${ARCH}.deb" +propr-desktop +xdg-open 'propr://connect?api=http%3A%2F%2Flocalhost%3A4000' +xdg-open 'propr://connect?api=https%3A%2F%2Ft-your-tunnel.propr.dev' +sudo apt remove propr-desktop +``` + +On Fedora/RHEL-family systems, use the RPM. The ZIP is the non-package-manager alternative on either family: + +```sh +ARCH=x64 # use arm64 on an ARM64 Linux machine +VERSION=0.8.15 +sudo rpm --install "ProPR-Desktop-${VERSION}-linux-${ARCH}.rpm" +propr-desktop +sudo rpm --erase propr-desktop + +install_root="$(mktemp -d)" +unzip "ProPR-Desktop-${VERSION}-linux-${ARCH}.zip" -d "$install_root" +"$install_root/propr-desktop-linux-${ARCH}/propr-desktop" +rm -r "$install_root" +``` + +On either Intel (`x64`) or Apple Silicon (`arm64`) macOS, the DMG flow mounts and copies the app; the ZIP flow uses +the system archive tool. Quit ProPR Desktop before removal: + +```sh +ARCH=arm64 # use x64 on an Intel Mac +VERSION=0.8.15 +mount_point="$(mktemp -d)" +hdiutil attach -readonly -nobrowse -mountpoint "$mount_point" \ + "ProPR-Desktop-${VERSION}-macos-${ARCH}.dmg" +ditto "$mount_point/propr-desktop.app" '/Applications/propr-desktop.app' +hdiutil detach "$mount_point" +rmdir "$mount_point" +open '/Applications/propr-desktop.app' +open 'propr://connect?api=http%3A%2F%2Flocalhost%3A4000' +open 'propr://connect?api=https%3A%2F%2Ft-your-tunnel.propr.dev' +osascript -e 'tell application id "dev.propr.desktop" to quit' +rm -r '/Applications/propr-desktop.app' + +install_root="$(mktemp -d)" +ditto -x -k "ProPR-Desktop-${VERSION}-macos-${ARCH}.zip" "$install_root" +open "$install_root/propr-desktop.app" +osascript -e 'tell application id "dev.propr.desktop" to quit' +rm -r "$install_root" +``` + +The pull-request native gate runs on `ubuntu-24.04`/`ubuntu-24.04-arm` and +`macos-15-intel`/`macos-15`. It consumes the canonical staged bytes: DEB/RPM are extracted with `dpkg-deb`/`rpm2cpio` +and ZIP with the platform archive tool; DMG is mounted read-only and copied with `ditto`. DMG authority begins as soon +as attach succeeds, and detach plus an absent-mount postcondition is mandatory before its mount root is removed. Every +format gets a first launch, clean shutdown, preserved-state relaunch, and owned-root removal. The gate rechecks the executable architecture, +identity/version, launcher or bundle, safe paths/symlinks, 0700/0600 profile authority, unchanged artifact bytes, and +absence of default-profile leakage. A fixed non-secret custody probe must either round-trip through OS encryption or be +refused with no `basic_text`/plaintext fallback; macOS additionally requires the OS-protected Keychain backend. +Evidence files contain fixed event names only—never endpoints, paths, credentials, +or process output. Deep-link events are written only after the already-loaded renderer acknowledges the exact consumed +confirmation or queued navigation state; each dispatch waits for that bounded acknowledgement before the next begins. + +Linux DEB/RPM protocol evidence uses an isolated XDG MIME database and a CI-relocated copy of the package's real +desktop launcher, then dispatches with `gio`; ZIP has no registered launcher, so its single-instance dispatch evidence +is direct and is reported as that limitation. macOS registers the copied bundle with LaunchServices and dispatches with +`open -b`; unregister failure or a stale exact copied-bundle record fails cleanup. LaunchServices' database writes are +OS-managed state, distinct from app-owned profile writes, and are not reported as cleaned while that copied record remains. +Cold starts for every format are direct executable argv launches, not OS protocol launches; OS protocol dispatch evidence +is warm-only. The Linux job does not forward its outer session-bus address or provision a scoped Secret Service, so its +secure-storage result is explicitly fallback-only: `basic_text`/plaintext storage must be refused, and installed +`libsecret` is not claimed as exercised custody. These checks do +not claim end-user Gatekeeper approval for unsigned builds, signing, notarization, or behavior on a desktop session that +the hosted runner cannot provide. + ### CI preflight, signing, and notarization configuration Repository-ruleset inspection uses a dedicated GitHub App installed only on this repository. Configure the App with diff --git a/apps/desktop/forge.config.ts b/apps/desktop/forge.config.ts index 16a29fb1c..405223f4f 100644 --- a/apps/desktop/forge.config.ts +++ b/apps/desktop/forge.config.ts @@ -162,6 +162,7 @@ const config: ForgeConfig = { productName: 'ProPR Desktop', version: releaseVersion, bin: DESKTOP_EXECUTABLE_NAME, + mimeType: ['x-scheme-handler/propr'], }, })] : []), @@ -172,6 +173,7 @@ const config: ForgeConfig = { productName: 'ProPR Desktop', version: releaseVersion, bin: DESKTOP_EXECUTABLE_NAME, + mimeType: ['x-scheme-handler/propr'], }, })] : []), diff --git a/apps/desktop/scripts/packaged-smoke-support.mjs b/apps/desktop/scripts/packaged-smoke-support.mjs index 86ade3ef3..87b151670 100644 --- a/apps/desktop/scripts/packaged-smoke-support.mjs +++ b/apps/desktop/scripts/packaged-smoke-support.mjs @@ -294,6 +294,7 @@ export const createSmokeChildEnvironment = async ({ profileApiUrl, parentEnvironment = process.env, inspectPath = lstat, + preserveMacosKeychainContext = false, }) => { if (!profile || !createdProfiles.has(profile)) { throw new Error('Packaged smoke child environment rejected an unknown profile'); @@ -330,8 +331,21 @@ export const createSmokeChildEnvironment = async ({ }); } if (platform === 'darwin') { + let home = profile.home; + if (preserveMacosKeychainContext) { + const runnerHome = parentEnvironment.HOME; + if (typeof runnerHome !== 'string' || runnerHome.length > 4096 + || !isAbsolute(runnerHome) || resolve(runnerHome) !== runnerHome) { + throw new Error('Packaged smoke macOS Keychain home is invalid'); + } + const homeStats = await inspectPath(runnerHome); + if (!homeStats.isDirectory() || homeStats.isSymbolicLink()) { + throw new Error('Packaged smoke macOS Keychain home is invalid'); + } + home = runnerHome; + } return Object.freeze({ - HOME: profile.home, + HOME: home, ...triggers, TEMP: profile.temporary, TMP: profile.temporary, diff --git a/apps/desktop/scripts/packaged-smoke-support.test.mjs b/apps/desktop/scripts/packaged-smoke-support.test.mjs index 93e2d3e91..0927b1de6 100644 --- a/apps/desktop/scripts/packaged-smoke-support.test.mjs +++ b/apps/desktop/scripts/packaged-smoke-support.test.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { chmod, readFile, writeFile } from 'node:fs/promises'; +import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { basename, join, relative } from 'node:path'; import { describe, test } from 'node:test'; @@ -103,6 +103,41 @@ describe('packaged smoke native window layout', () => { }); describe('packaged smoke child environment', () => { + test('preserves a validated runner HOME only for an explicit native macOS Keychain context', async () => { + const profile = await createPrivateSmokeProfile(tmpdir()); + const runnerHome = await mkdtemp(join(tmpdir(), 'propr-runner-home-')); + try { + const isolated = await createSmokeChildEnvironment({ + platform: 'darwin', + profile, + profileApiUrl: 'http://127.0.0.1:43123', + parentEnvironment: { HOME: runnerHome }, + }); + assert.equal(isolated.HOME, profile.home); + + const keychainEnabled = await createSmokeChildEnvironment({ + platform: 'darwin', + profile, + profileApiUrl: 'http://127.0.0.1:43123', + parentEnvironment: { HOME: runnerHome }, + preserveMacosKeychainContext: true, + }); + assert.equal(keychainEnabled.HOME, runnerHome); + assert.equal(keychainEnabled.TMPDIR, profile.temporary); + + await assert.rejects(createSmokeChildEnvironment({ + platform: 'darwin', + profile, + profileApiUrl: 'http://127.0.0.1:43123', + parentEnvironment: { HOME: 'relative-home' }, + preserveMacosKeychainContext: true, + }), /Keychain home is invalid/); + } finally { + await removePrivateSmokeProfile(profile); + await rm(runnerHome, { recursive: true, force: true }); + } + }); + test('passes only platform launch inputs and private profile paths from a hostile parent', async () => { const parent = await createPrivateSmokeProfile(tmpdir()); const xAuthority = join(parent.root, 'Xauthority'); diff --git a/apps/desktop/scripts/test-native-artifact-lifecycle.mjs b/apps/desktop/scripts/test-native-artifact-lifecycle.mjs new file mode 100644 index 000000000..7d6c56277 --- /dev/null +++ b/apps/desktop/scripts/test-native-artifact-lifecycle.mjs @@ -0,0 +1,1457 @@ +import { spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { constants as fsConstants } from 'node:fs'; +import { + chmod, + copyFile, + lstat, + mkdir, + mkdtemp, + open, + readFile, + readdir, + realpath, + rm, + stat, + writeFile, +} from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { createConnection } from 'node:net'; +import { arch as hostArch, platform as hostPlatform, tmpdir } from 'node:os'; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { once } from 'node:events'; +import { fileURLToPath } from 'node:url'; +import { + createHeldDmgArtifact, + inspectArtifactArchitecture, + inspectExecutableBytes, +} from './release-architecture.mjs'; +import { + createPrivateSmokeProfile, + createSmokeChildEnvironment, + removePrivateSmokeProfile, +} from './packaged-smoke-support.mjs'; + +const EXECUTABLE = 'propr-desktop'; +const APP_ID = 'dev.propr.desktop'; +const PROCESS_TIMEOUT_MS = 45_000; +const COMMAND_TIMEOUT_MS = 10 * 60_000; +const OUTPUT_CAP = 64 * 1024; +const CLEANUP_GRACE_MS = 2_000; +const COLD_MANUAL = 'propr://connect?api=http%3A%2F%2Flocalhost%3A44111'; +const COLD_TUNNEL = 'propr://connect?api=https%3A%2F%2Ft-native-relaunch.propr.dev'; +const WARM_MANUAL = 'propr://connect?api=http%3A%2F%2F127.0.0.1%3A44112'; +const WARM_TUNNEL = 'propr://connect?api=https%3A%2F%2Ft-native-evidence.propr.dev'; +const WARM_OPEN = 'propr://open?path=%2Ftasks%3Fstatus%3Dopen'; +const REQUIRED_FIRST_EVENTS = [ + 'desktop.smoke.authorized', + 'desktop.native.identity_verified', + 'desktop.deeplink.cold_manual_once', + 'desktop.native.secure_storage_probe.started', + 'desktop.native.secure_storage_probe.completed', + 'desktop.native.secure_storage_enforced', + 'desktop.native.profile_fresh', + 'desktop.renderer.ready', + 'desktop.deeplink.warm_manual_once', + 'desktop.deeplink.warm_tunnel_once', + 'desktop.deeplink.warm_open_once', + 'desktop.deeplink.rejected_malformed', + 'desktop.deeplink.rejected_oversized', + 'desktop.deeplink.rejected_unsafe_scheme', + 'desktop.deeplink.confirmation_required', + 'desktop.app.shutdown', +]; +const REQUIRED_RELAUNCH_EVENTS = [ + 'desktop.smoke.authorized', + 'desktop.native.identity_verified', + 'desktop.deeplink.cold_tunnel_once', + 'desktop.native.profile_preserved', + 'desktop.deeplink.confirmation_required', + 'desktop.renderer.ready', + 'desktop.app.shutdown', +]; + +export const parseArguments = args => { + const values = new Map(); + for (let index = 0; index < args.length; index += 2) { + const name = args[index]; + const value = args[index + 1]; + if (!name?.startsWith('--') || !value || values.has(name)) { + throw new Error('Native artifact lifecycle arguments are missing, duplicated, or malformed'); + } + values.set(name, value); + } + const platform = values.get('--platform'); + const arch = values.get('--arch'); + const version = values.get('--version'); + const artifactDirectory = values.get('--artifact-directory'); + if (!['linux', 'darwin'].includes(platform) || !['x64', 'arm64'].includes(arch) + || !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version ?? '') + || !artifactDirectory || values.size !== 4) { + throw new Error('Native artifact lifecycle target is invalid'); + } + return { platform, arch, version, artifactDirectory: resolve(artifactDirectory) }; +}; + +const appendBounded = (current, chunk) => { + const next = Buffer.concat([current, Buffer.from(chunk)]); + return next.length <= OUTPUT_CAP ? next : next.subarray(next.length - OUTPUT_CAP); +}; + +const run = (file, args, { cwd, env, timeout = COMMAND_TIMEOUT_MS, input } = {}) => new Promise((resolveRun, reject) => { + const child = spawn(file, args, { + cwd, + env, + shell: false, + stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'], + }); + let stdout = Buffer.alloc(0); + let stderr = Buffer.alloc(0); + let stdoutOverflow = false; + let stderrOverflow = false; + child.stdout.on('data', chunk => { + stdoutOverflow ||= stdout.length + chunk.length > OUTPUT_CAP; + stdout = appendBounded(stdout, chunk); + }); + child.stderr.on('data', chunk => { + stderrOverflow ||= stderr.length + chunk.length > OUTPUT_CAP; + stderr = appendBounded(stderr, chunk); + }); + if (input !== undefined) child.stdin.end(input); + const timer = setTimeout(() => child.kill('SIGKILL'), timeout); + child.once('error', error => { + clearTimeout(timer); + reject(error); + }); + child.once('close', (code, signal) => { + clearTimeout(timer); + if (code !== 0) { + reject(new Error(`${basename(file)} failed with code ${code ?? 'null'} signal ${signal ?? 'none'}`)); + return; + } + resolveRun({ stderr, stderrOverflow, stdout, stdoutOverflow }); + }); +}); + +const delay = milliseconds => new Promise(resolveDelay => setTimeout(resolveDelay, milliseconds)); + +const errorFrom = (error, fallback) => error instanceof Error ? error : new Error(fallback); + +export const NATIVE_LIFECYCLE_OPERATION_STAGES = Object.freeze([ + 'PREPARE_WORK_ROOT', + 'CREATE_PROFILE', + 'START_PROFILE_API', + 'BASELINE_BEFORE', + 'INSPECT_ARTIFACT', + 'EXTRACT_ARTIFACT', + 'VALIDATE_ARTIFACT', + 'PREPARE_LINUX_SANDBOX', + 'CREATE_CHILD_ENVIRONMENT', + 'FIRST_LAUNCH', + 'FIRST_INITIAL_EVIDENCE', + 'FIRST_SECURE_STORAGE_PROBE', + 'FIRST_RENDERER_READY', + 'WARM_MANUAL_DISPATCH', + 'WARM_MANUAL_EVIDENCE', + 'PROTOCOL_DISPATCH', + 'LS_REGISTER', + 'OPEN_DISPATCH', + 'PROTOCOL_EVIDENCE', + 'WARM_OPEN_DISPATCH', + 'WARM_OPEN_EVIDENCE', + 'MALFORMED_DISPATCH', + 'MALFORMED_EVIDENCE', + 'OVERSIZED_DISPATCH', + 'OVERSIZED_EVIDENCE', + 'UNSAFE_SCHEME_DISPATCH', + 'UNSAFE_SCHEME_EVIDENCE', + 'FIRST_EXIT', + 'FIRST_EVIDENCE_VALIDATION', + 'RELAUNCH', + 'RELAUNCH_EXIT', + 'RELAUNCH_EVIDENCE', + 'FINAL_VALIDATION', +]); + +export const NATIVE_LIFECYCLE_EVIDENCE_RESULT_CLASSES = Object.freeze([ + 'CLEAN_EXIT', + 'FAILED_EXIT', + 'SIGNALLED', + 'EVIDENCE_DEADLINE', +]); + +export const FIRST_EVIDENCE_MILESTONES = Object.freeze([ + 'NO_EVIDENCE', + 'AUTHORIZED', + 'IDENTITY', + 'DEEP_LINK_DELIVERY_FAILURE', + 'COLD_ACK', + 'SECURE_STORAGE_STARTED', + 'SECURE_STORAGE_COMPLETED', + 'RENDERER', +]); + +export class NativeLifecycleEvidenceWaitFailure extends Error { + constructor(resultClass) { + if (!NATIVE_LIFECYCLE_EVIDENCE_RESULT_CLASSES.includes(resultClass)) { + throw new Error('Native lifecycle evidence result class is invalid'); + } + super(resultClass === 'EVIDENCE_DEADLINE' + ? 'Native application evidence deadline expired' + : 'Native application exited before producing required evidence'); + this.name = 'NativeLifecycleEvidenceWaitFailure'; + this.resultClass = resultClass; + } +} + +export class NativeLifecycleOperationFailure extends Error { + constructor(stage, operationError, evidenceClassification) { + if (!NATIVE_LIFECYCLE_OPERATION_STAGES.includes(stage)) { + throw new Error('Native lifecycle failure stage is invalid'); + } + if (evidenceClassification + && (!FIRST_EVIDENCE_MILESTONES.includes(evidenceClassification.milestone) + || !NATIVE_LIFECYCLE_EVIDENCE_RESULT_CLASSES.includes(evidenceClassification.resultClass))) { + throw new Error('Native lifecycle evidence failure classification is invalid'); + } + const classification = evidenceClassification + ? ` [milestone:${evidenceClassification.milestone}] [result:${evidenceClassification.resultClass}]` + : ''; + super(`Native lifecycle operation failed [stage:${stage}]${classification}`); + this.name = 'NativeLifecycleOperationFailure'; + this.stage = stage; + if (evidenceClassification) { + this.milestone = evidenceClassification.milestone; + this.resultClass = evidenceClassification.resultClass; + } + Object.defineProperty(this, 'operationError', { value: operationError, enumerable: false }); + } +} + +export class NativeLifecycleFailure extends AggregateError { + constructor(primaryError, cleanupFailures) { + const cleanupLabels = cleanupFailures.map(failure => failure.label).sort(); + const classification = primaryError instanceof NativeLifecycleOperationFailure + ? [ + ` [stage:${primaryError.stage}]`, + ...(primaryError.milestone ? [` [milestone:${primaryError.milestone}]`] : []), + ...(primaryError.resultClass ? [` [result:${primaryError.resultClass}]`] : []), + ].join('') + : ''; + const message = primaryError + ? `Native lifecycle failed${classification}; cleanup also failed: ${cleanupLabels.join(', ')}` + : `Native lifecycle cleanup failed: ${cleanupLabels.join(', ')}`; + const safeErrors = [ + ...(primaryError ? [new Error( + primaryError instanceof NativeLifecycleOperationFailure + ? primaryError.message + : 'Native lifecycle primary operation failed', + )] : []), + ...cleanupLabels.map(label => new Error(`Native lifecycle cleanup failed: ${label}`)), + ]; + super(safeErrors, message); + this.name = 'NativeLifecycleFailure'; + Object.defineProperties(this, { + primaryError: { value: primaryError, enumerable: false }, + cleanupFailures: { value: cleanupFailures, enumerable: false }, + }); + } +} + +const throwCombined = (primaryError, cleanupFailures) => { + if (cleanupFailures.length > 0) throw new NativeLifecycleFailure(primaryError, cleanupFailures); + if (primaryError) throw primaryError; +}; + +export const runningProcessGroupMembersFromPs = (output, processGroupId) => { + if (!Number.isSafeInteger(processGroupId) || processGroupId <= 0) { + throw new Error('Native process-group identity is invalid'); + } + const members = []; + for (const line of output.toString('utf8').split(/\r?\n/).filter(candidate => candidate.trim())) { + const match = /^\s*(\d+)\s+(\d+)\s+(\S+)\s*$/.exec(line); + if (!match) throw new Error('Native process-group inspection returned an invalid record'); + const pid = Number(match[1]); + const pgid = Number(match[2]); + const state = match[3][0]; + if (pgid === processGroupId && state !== 'Z') members.push(pid); + } + return members; +}; + +export const inspectRunningProcessGroupMembers = async processGroupId => { + if (!processGroupId) return []; + const result = await run('/bin/ps', ['-axo', 'pid=,pgid=,stat='], { timeout: CLEANUP_GRACE_MS }); + if (result.stdoutOverflow || result.stderrOverflow) { + throw new Error('Native process-group inspection exceeded its fixed output bound'); + } + return runningProcessGroupMembersFromPs(result.stdout, processGroupId); +}; + +const waitUntil = async (predicate, timeout) => { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + if (!await predicate()) return true; + await delay(25); + } + return !await predicate(); +}; + +class OwnedProcessGroup { + constructor(child) { + this.child = child; + this.pid = child.pid; + this.closed = false; + this.released = false; + this.result = null; + this.closePromise = new Promise(resolveClose => { + child.once('error', error => { + if (!this.result) this.result = { code: null, error, signal: null }; + }); + child.once('close', (code, signal) => { + this.closed = true; + this.result = { code, error: this.result?.error, signal }; + resolveClose(this.result); + }); + }); + } + + signal(signal) { + if (!this.pid) return; + try { + process.kill(-this.pid, signal); + } catch (error) { + if (error?.code !== 'ESRCH') throw error; + } + } + + async waitForClose(timeout) { + if (this.closed) return this.result; + return Promise.race([ + this.closePromise, + delay(timeout).then(() => { throw new Error('Native application close deadline expired'); }), + ]); + } + + async terminate() { + if (this.released) { + await this.waitForClose(CLEANUP_GRACE_MS); + return; + } + let initialMembers; + try { + initialMembers = await inspectRunningProcessGroupMembers(this.pid); + } catch { + // Inspection failure cannot relinquish authority: still bound TERM/KILL before reporting + // that the running-member postcondition could not be proved. + this.signal('SIGTERM'); + await delay(CLEANUP_GRACE_MS); + this.signal('SIGKILL'); + try { + await this.waitForClose(CLEANUP_GRACE_MS); + } catch { + throw new Error('Native application close and process-group inspection deadlines expired'); + } + throw new Error('Native application process-group inspection failed after bounded cleanup'); + } + if (initialMembers.length > 0) { + this.signal('SIGTERM'); + if (!await waitUntil( + async () => (await inspectRunningProcessGroupMembers(this.pid)).length > 0, + CLEANUP_GRACE_MS, + )) { + this.signal('SIGKILL'); + } + } + const groupGone = await waitUntil( + async () => (await inspectRunningProcessGroupMembers(this.pid)).length > 0, + CLEANUP_GRACE_MS, + ); + let closeError; + try { + await this.waitForClose(CLEANUP_GRACE_MS); + } catch (error) { + closeError = errorFrom(error, 'Native application close postcondition failed'); + } + if (!groupGone && closeError) { + throw new Error('Native application close and process-group cleanup deadlines expired'); + } + if (!groupGone) throw new Error('Native application process-group cleanup deadline expired'); + if (closeError) throw closeError; + if ((await inspectRunningProcessGroupMembers(this.pid)).length > 0) { + throw new Error('Native application left a running process in its owned process group'); + } + this.released = true; + } + + async waitForSuccessfulExit(timeout = PROCESS_TIMEOUT_MS) { + let result; + try { + result = await this.waitForClose(timeout); + } catch (error) { + const cleanupFailures = []; + try { + await this.terminate(); + } catch (cleanupError) { + cleanupFailures.push({ + label: 'process-groups', + error: errorFrom(cleanupError, 'Process-group cleanup failed'), + }); + } + throwCombined(errorFrom(error, 'Native application close failed'), cleanupFailures); + } + let resultError = result?.error; + if (!resultError && result?.code !== 0) { + resultError = new Error( + `Native application exited with code ${result?.code ?? 'null'} signal ${result?.signal ?? 'none'}`, + ); + } + let naturallyDrained; + try { + naturallyDrained = await waitUntil( + async () => (await inspectRunningProcessGroupMembers(this.pid)).length > 0, + CLEANUP_GRACE_MS, + ); + } catch (error) { + const cleanupFailures = []; + try { + await this.terminate(); + } catch (cleanupError) { + cleanupFailures.push({ + label: 'process-groups', + error: errorFrom(cleanupError, 'Process-group cleanup failed'), + }); + } + throwCombined( + resultError ?? errorFrom(error, 'Native process-group drain inspection failed'), + cleanupFailures, + ); + } + if (!naturallyDrained) { + const primaryError = resultError + ?? new Error('Native application main process exited before its owned process group drained'); + const cleanupFailures = []; + try { + await this.terminate(); + } catch (cleanupError) { + cleanupFailures.push({ + label: 'process-groups', + error: errorFrom(cleanupError, 'Process-group cleanup failed'), + }); + } + throwCombined(primaryError, cleanupFailures); + } + // Relinquish authority only after proving that the complete group is gone; + // this also prevents a later cleanup pass from acting on a reused PID. + this.released = true; + if (resultError) throw resultError; + } +} + +export class OwnedProcessGroups { + constructor() { + this.groups = []; + } + + track(child) { + const group = new OwnedProcessGroup(child); + this.groups.push(group); + return group; + } + + async cleanup() { + const failures = []; + for (const group of [...this.groups].reverse()) { + try { + await group.terminate(); + } catch (error) { + failures.push({ label: 'process-groups', error: errorFrom(error, 'Process-group cleanup failed') }); + } + } + return failures; + } +} + +const digest = async path => createHash('sha256').update(await readFile(path)).digest('hex'); + +const inspectStagedArtifact = async ({ artifact, kind, target, workRoot }) => { + if (kind !== 'dmg') { + return inspectArtifactArchitecture({ path: artifact, kind, platform: target.platform, arch: target.arch }); + } + const privatePath = join(workRoot, 'held-artifact.dmg'); + await copyFile(artifact, privatePath, fsConstants.COPYFILE_EXCL); + await chmod(privatePath, 0o600); + const handle = await open(privatePath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW); + try { + const heldArtifact = createHeldDmgArtifact(handle, 'staged native lifecycle DMG', privatePath); + return await inspectArtifactArchitecture({ + heldArtifact, + kind, + platform: target.platform, + arch: target.arch, + }); + } finally { + await handle.close(); + await rm(privatePath, { force: true }); + } +}; + +const canonicalArtifact = ({ directory, platform, arch, version, kind }) => join( + directory, + `ProPR-Desktop-${version}-${platform === 'darwin' ? 'macos' : 'linux'}-${arch}.${kind}`, +); + +export const assertArtifactSet = async target => { + const expectedKinds = target.platform === 'linux' ? ['deb', 'rpm', 'zip'] : ['dmg', 'zip']; + const entries = await readdir(target.artifactDirectory, { withFileTypes: true }); + for (const kind of expectedKinds) { + const path = canonicalArtifact({ directory: target.artifactDirectory, ...target, kind }); + const entry = entries.find(candidate => candidate.name === basename(path)); + if (!entry?.isFile() || entry.isSymbolicLink()) { + throw new Error(`Native lifecycle requires exactly the canonical staged ${kind} artifact`); + } + } + const unexpected = entries.filter(entry => { + if (entry.name === 'release-fragment.json') return false; + return !expectedKinds.some(kind => entry.name === basename(canonicalArtifact({ + directory: target.artifactDirectory, ...target, kind, + }))); + }); + if (unexpected.length) throw new Error('Native lifecycle artifact directory contains an unexpected or duplicate identity'); + return expectedKinds; +}; + +export const assertSafeExtractedTree = async root => { + const canonicalRoot = await realpath(root); + const visit = async directory => { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isSymbolicLink()) { + const target = await realpath(path); + const fromRoot = relative(canonicalRoot, target); + if (!fromRoot || fromRoot === '..' || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)) { + throw new Error('Native artifact contains a symlink escaping its install root'); + } + const targetStats = await stat(path); + if (!targetStats.isFile() && !targetStats.isDirectory()) { + throw new Error('Native artifact contains a symlink to an unsupported filesystem entry'); + } + } else if (entry.isDirectory()) { + await visit(path); + } else if (!entry.isFile()) { + throw new Error('Native artifact contains an unsupported filesystem entry'); + } + } + }; + await visit(root); +}; + +const waitForPipelineProcess = child => new Promise(resolveProcess => { + let spawnError; + child.once('error', error => { spawnError = error; }); + child.once('close', (code, signal) => resolveProcess({ code, error: spawnError, signal })); +}); + +const stopPipelineProcess = async (child, completion) => { + const running = child.exitCode === null + && (child.signalCode === undefined || child.signalCode === null); + if (running) child.kill('SIGTERM'); + const result = await Promise.race([completion, delay(CLEANUP_GRACE_MS).then(() => null)]); + if (result) return result; + child.kill('SIGKILL'); + return Promise.race([ + completion, + delay(CLEANUP_GRACE_MS).then(() => { throw new Error('RPM extraction process cleanup deadline expired'); }), + ]); +}; + +export const extractRpm = async (artifact, root, { + converterFile = '/usr/bin/rpm2cpio', + extractorFile = '/usr/bin/cpio', + spawnProcess = spawn, + timeout = COMMAND_TIMEOUT_MS, +} = {}) => { + const converter = spawnProcess(converterFile, [artifact], { shell: false, stdio: ['ignore', 'pipe', 'pipe'] }); + const extractor = spawnProcess( + extractorFile, + ['--extract', '--make-directories', '--no-absolute-filenames', '--quiet'], + { cwd: root, shell: false, stdio: ['pipe', 'ignore', 'pipe'] }, + ); + const converterCompletion = waitForPipelineProcess(converter); + const extractorCompletion = waitForPipelineProcess(extractor); + const stopExtractorOnConverterFailure = converterCompletion.then(result => ( + result.error || result.code !== 0 ? stopPipelineProcess(extractor, extractorCompletion) : undefined + )); + const stopConverterOnExtractorFailure = extractorCompletion.then(result => ( + result.error || result.code !== 0 ? stopPipelineProcess(converter, converterCompletion) : undefined + )); + let diagnostics = Buffer.alloc(0); + converter.stderr.on('data', chunk => { diagnostics = appendBounded(diagnostics, chunk); }); + extractor.stderr.on('data', chunk => { diagnostics = appendBounded(diagnostics, chunk); }); + converter.stdout.pipe(extractor.stdin); + + let results; + try { + results = await Promise.race([ + Promise.all([ + converterCompletion, + extractorCompletion, + stopExtractorOnConverterFailure, + stopConverterOnExtractorFailure, + ]).then(([converterResult, extractorResult]) => [converterResult, extractorResult]), + delay(timeout).then(() => { throw new Error('RPM extraction deadline expired'); }), + ]); + } catch (error) { + const cleanup = await Promise.allSettled([ + stopPipelineProcess(converter, converterCompletion), + stopPipelineProcess(extractor, extractorCompletion), + ]); + const cleanupFailures = cleanup + .filter(result => result.status === 'rejected') + .map(result => ({ label: 'rpm-processes', error: result.reason })); + throwCombined(errorFrom(error, 'RPM extraction failed'), cleanupFailures); + } + const [converterResult, extractorResult] = results; + if (converterResult.error) throw converterResult.error; + if (extractorResult.error) throw extractorResult.error; + if (converterResult.code !== 0) { + throw new Error(`rpm2cpio failed with code ${converterResult.code ?? 'null'} signal ${converterResult.signal ?? 'none'}`); + } + if (extractorResult.code !== 0) { + throw new Error(`cpio failed with code ${extractorResult.code ?? 'null'} signal ${extractorResult.signal ?? 'none'}`); + } + if (diagnostics.length !== 0) throw new Error('RPM extraction emitted unexpected diagnostics'); +}; + +const locateApplication = async ({ platform, arch, kind, installRoot }) => { + if (platform === 'linux') { + const packagePayload = join(installRoot, 'usr', 'lib', EXECUTABLE); + const zipPayload = join(installRoot, `propr-desktop-linux-${arch}`); + const applicationRoot = kind === 'zip' ? zipPayload : packagePayload; + return { + applicationRoot, + executable: join(applicationRoot, EXECUTABLE), + desktopFile: kind === 'zip' ? null : join(installRoot, 'usr', 'share', 'applications', `${EXECUTABLE}.desktop`), + }; + } + const candidates = (await readdir(installRoot, { withFileTypes: true })) + .filter(entry => entry.isDirectory() && entry.name.endsWith('.app')); + if (candidates.length !== 1) throw new Error('Native macOS artifact has a missing or duplicate application identity'); + const applicationRoot = join(installRoot, candidates[0].name); + return { + applicationRoot, + executable: join(applicationRoot, 'Contents', 'MacOS', EXECUTABLE), + desktopFile: null, + }; +}; + +const mountOutputContains = (output, mountRoot) => output.toString('utf8') + .split(/\r?\n/) + .some(line => line.trimEnd().endsWith(mountRoot)); + +export class DmgMountAuthority { + constructor(mountRoot, { runCommand = run } = {}) { + this.mountRoot = mountRoot; + this.runCommand = runCommand; + this.mounted = false; + } + + async attach(artifact) { + await this.runCommand('/usr/bin/hdiutil', [ + 'attach', '-readonly', '-nobrowse', '-mountpoint', this.mountRoot, artifact, + ]); + this.mounted = true; + } + + async detach() { + if (!this.mounted) return; + let detachError; + try { + await this.runCommand('/usr/bin/hdiutil', ['detach', this.mountRoot], { timeout: 30_000 }); + } catch (error) { + detachError = errorFrom(error, 'DMG detach failed'); + } + let mounts; + let queryError; + try { + mounts = await this.runCommand('/usr/bin/hdiutil', ['info'], { timeout: 30_000 }); + } catch (error) { + queryError = errorFrom(error, 'DMG mount postcondition query failed'); + } + const stale = mounts ? mountOutputContains(mounts.stdout, this.mountRoot) : false; + if (!queryError && !stale) this.mounted = false; + const failures = []; + if (detachError) failures.push({ label: 'dmg-detach', error: detachError }); + if (queryError) failures.push({ label: 'dmg-mount-query', error: queryError }); + if (stale) failures.push({ label: 'dmg-mounted-postcondition', error: new Error('DMG mount remained active') }); + throwCombined(null, failures); + } +} + +export const extractDmg = async ({ + artifact, + installRoot, + mountAuthority, + readDirectory = readdir, + runCommand = run, +}) => { + let primaryError; + try { + await mountAuthority.attach(artifact); + const applications = (await readDirectory(mountAuthority.mountRoot, { withFileTypes: true })) + .filter(entry => entry.isDirectory() && entry.name.endsWith('.app')); + if (applications.length !== 1) throw new Error('Mounted DMG has a missing or duplicate application identity'); + await runCommand('/usr/bin/ditto', [ + join(mountAuthority.mountRoot, applications[0].name), + join(installRoot, applications[0].name), + ]); + } catch (error) { + primaryError = errorFrom(error, 'DMG extraction failed'); + } + const cleanupFailures = []; + if (mountAuthority.mounted) { + try { + await mountAuthority.detach(); + } catch (error) { + cleanupFailures.push({ label: 'dmg-mount', error: errorFrom(error, 'DMG cleanup failed') }); + } + } + throwCombined(primaryError, cleanupFailures); +}; + +const extractArtifact = async ({ artifact, kind, target, installRoot, mountAuthority }) => { + if (kind === 'deb') { + await run('/usr/bin/dpkg-deb', ['--extract', artifact, installRoot]); + } else if (kind === 'rpm') { + await extractRpm(artifact, installRoot); + } else if (kind === 'zip' && target.platform === 'linux') { + await run('/usr/bin/unzip', ['-q', artifact, '-d', installRoot]); + } else if (kind === 'zip') { + await run('/usr/bin/ditto', ['-x', '-k', artifact, installRoot]); + } else { + await extractDmg({ artifact, installRoot, mountAuthority }); + } +}; + +const validateIdentity = async ({ target, kind, application }) => { + const handle = await open(application.executable, 'r'); + const bytes = Buffer.alloc(4096); + let bytesRead; + try { + ({ bytesRead } = await handle.read(bytes, 0, bytes.length, 0)); + } finally { + await handle.close(); + } + const executable = inspectExecutableBytes(bytes.subarray(0, bytesRead)); + const expectedFormat = target.platform === 'linux' ? 'elf' : 'mach-o'; + if (executable.format !== expectedFormat || executable.architectures.length !== 1 + || executable.architectures[0] !== target.arch) { + throw new Error('Extracted native artifact executable architecture mismatch'); + } + const executableStats = await lstat(application.executable); + if (!executableStats.isFile() || executableStats.isSymbolicLink() || (executableStats.mode & 0o111) === 0) { + throw new Error('Extracted native artifact executable identity is invalid'); + } + if (target.platform === 'linux') { + if (kind !== 'zip') { + const desktop = await readFile(application.desktopFile, 'utf8'); + if (!/^Name=ProPR Desktop$/m.test(desktop) || !/^Exec=propr-desktop(?:\s+%U)?$/m.test(desktop) + || !/^MimeType=.*x-scheme-handler\/propr;.*$/m.test(desktop)) { + throw new Error('Linux package launcher identity or protocol declaration is invalid'); + } + } + return; + } + const plist = join(application.applicationRoot, 'Contents', 'Info.plist'); + const readPlist = async key => (await run('/usr/bin/plutil', ['-extract', key, 'raw', '-o', '-', plist])).stdout.toString().trim(); + if (await readPlist('CFBundleIdentifier') !== APP_ID + || await readPlist('CFBundleShortVersionString') !== target.version + || await readPlist('CFBundleExecutable') !== EXECUTABLE + || await readPlist('CFBundleURLTypes.0.CFBundleURLSchemes.0') !== 'propr') { + throw new Error('macOS application identity, version, or protocol declaration is invalid'); + } +}; + +const readFixedEvidenceEvents = async path => { + const records = (await readFile(path, 'utf8')).trim().split('\n').filter(Boolean).map(line => JSON.parse(line)); + if (records.some(record => Object.keys(record).length !== 1 || typeof record.event !== 'string')) { + throw new Error('Native application emitted secret-capable evidence fields'); + } + return records.map(record => record.event); +}; + +export const waitForEvents = async (path, events, child, timeout = PROCESS_TIMEOUT_MS) => { + const deadline = Date.now() + timeout; + const hasRequiredEvents = async () => { + const names = await readFixedEvidenceEvents(path); + return events.every(event => names.includes(event)); + }; + while (true) { + try { + if (await hasRequiredEvents()) return; + } catch (error) { + if (error?.code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error; + } + const signalled = child.signalCode !== undefined && child.signalCode !== null; + const exited = child.exitCode !== null || signalled; + if (exited) { + // Exit can become observable after the first read even though the child's + // final fsynced event preceded that exit. Re-read the now-stable file once. + try { + if (await hasRequiredEvents()) return; + } catch (error) { + if (error?.code !== 'ENOENT') { + if (error instanceof SyntaxError) throw new Error('Native application evidence was malformed'); + throw error; + } + } + const resultClass = signalled ? 'SIGNALLED' : child.exitCode === 0 ? 'CLEAN_EXIT' : 'FAILED_EXIT'; + throw new NativeLifecycleEvidenceWaitFailure(resultClass); + } + const remaining = deadline - Date.now(); + if (remaining <= 0) break; + await delay(Math.min(50, remaining)); + } + throw new NativeLifecycleEvidenceWaitFailure('EVIDENCE_DEADLINE'); +}; + +const assertEvidenceOrdering = async (path, requiredEvents) => { + const records = (await readFile(path, 'utf8')).trim().split('\n').filter(Boolean).map(line => JSON.parse(line)); + const names = records.map(record => record.event); + let previous = -1; + for (const event of requiredEvents) { + const occurrences = names.reduce((count, name) => count + Number(name === event), 0); + const index = names.indexOf(event); + if (occurrences !== 1 || index <= previous) { + throw new Error('Native application evidence was duplicated or out of order'); + } + previous = index; + } +}; + +const startApplication = (application, args, env, cwd, processGroups) => processGroups.track(spawn(application.executable, args, { + cwd, + env, + detached: true, + shell: false, + stdio: ['ignore', 'ignore', 'ignore'], +})); + +const dispatchDirect = async (application, userData, link, env, processGroups) => { + const group = startApplication( + application, + [`--user-data-dir=${userData}`, link], + env, + dirname(application.applicationRoot), + processGroups, + ); + await group.waitForSuccessfulExit(15_000); +}; + +const linuxProtocolDispatch = async ({ application, profile, link, env, processGroups }) => { + if (!application.desktopFile) { + await dispatchDirect(application, profile.userData, link, env, processGroups); + return 'direct-second-instance; ZIP has no OS launcher registration'; + } + const applications = join(profile.xdgData, 'applications'); + await mkdir(applications, { recursive: true, mode: 0o700 }); + const registered = join(applications, `${EXECUTABLE}.desktop`); + const source = await readFile(application.desktopFile, 'utf8'); + const relocated = source.replace(/^Exec=.*$/m, `Exec=${application.executable} --user-data-dir=${profile.userData} %U`); + if (relocated === source) throw new Error('Linux launcher relocation did not replace exactly one Exec declaration'); + await writeFile(registered, relocated, { mode: 0o600 }); + await run('/usr/bin/update-desktop-database', [applications], { env }); + await run('/usr/bin/xdg-mime', ['default', `${EXECUTABLE}.desktop`, 'x-scheme-handler/propr'], { env }); + const query = await run('/usr/bin/xdg-mime', ['query', 'default', 'x-scheme-handler/propr'], { env }); + if (query.stdout.toString().trim() !== `${EXECUTABLE}.desktop`) { + throw new Error('Linux native protocol registration query did not resolve the installed launcher'); + } + await run('/usr/bin/gio', ['open', link], { env, timeout: 15_000 }); + return 'xdg-mime-registration+gio-dispatch (CI-relocated package launcher)'; +}; + +const LAUNCH_SERVICES = '/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister'; + +export class LaunchServicesAuthority { + constructor(applicationRoot, environment, { runCommand = run } = {}) { + this.applicationRoot = applicationRoot; + this.environment = environment; + this.runCommand = runCommand; + this.registered = false; + } + + async register() { + await this.runCommand(LAUNCH_SERVICES, ['-f', this.applicationRoot], { env: this.environment, timeout: 30_000 }); + this.registered = true; + } + + async dispatch(link) { + if (!this.registered) throw new Error('Copied application must be registered before LaunchServices dispatch'); + await this.runCommand('/usr/bin/open', ['-a', this.applicationRoot, link], { + env: this.environment, + timeout: 15_000, + }); + } + + async unregister() { + if (!this.registered) return; + await this.runCommand(LAUNCH_SERVICES, ['-u', this.applicationRoot], { env: this.environment, timeout: 30_000 }); + } + + async assertGone() { + const result = await this.runCommand(LAUNCH_SERVICES, ['-dump'], { env: this.environment, timeout: 30_000 }); + if (result.stdout.toString('utf8').split(/\r?\n/).some(line => { + const record = line.trim(); + const index = record.indexOf(this.applicationRoot); + if (index < 0) return false; + const before = record[index - 1]; + const after = record[index + this.applicationRoot.length]; + return (index === 0 || /[\s:"'=]/.test(before)) + && (after === undefined || /[\s"',)]/.test(after)); + })) { + throw new Error('Copied application remained registered with LaunchServices'); + } + this.registered = false; + } +} + +export const removeCopiedApplicationWithLaunchServicesAuthority = async ({ + installRoot, + launchServices, +}, { + removeInstallRoot = path => rm(path, { recursive: true, force: true }), + assertInstallRootAbsent = path => assertAbsent( + path, + 'Native uninstall/remove left an owned install root behind', + ), +} = {}) => { + const failures = []; + const attempt = async (label, operation) => { + try { + await operation(); + } catch (error) { + failures.push({ label, error: errorFrom(error, 'Native lifecycle cleanup failed') }); + } + }; + if (launchServices?.registered) { + await attempt('launchservices-unregister', () => launchServices.unregister()); + await attempt('launchservices-postcondition', () => launchServices.assertGone()); + } + if (failures.length === 0) { + await attempt('install-root', () => removeInstallRoot(installRoot)); + await attempt('install-postcondition', () => assertInstallRootAbsent(installRoot)); + } + return failures; +}; + +const processGroupAbsenceWasProved = cleanupFailures => ( + !cleanupFailures.some(failure => failure.label === 'process-groups') +); + +export const removeLifecycleRootsWithAuthority = async ({ + cleanupFailures, + installRoot, + launchServices, + workRoot, +}, { + removeCopiedApplication = removeCopiedApplicationWithLaunchServicesAuthority, + removeWorkRoot = path => rm(path, { recursive: true, force: true }), + assertWorkRootAbsent = path => assertAbsent( + path, + 'Native lifecycle work root remained after cleanup', + ), +} = {}) => { + const failures = [...cleanupFailures]; + const attempt = async (label, operation) => { + try { + await operation(); + } catch (error) { + failures.push({ label, error: errorFrom(error, 'Native lifecycle cleanup failed') }); + } + }; + + // A copied executable remains the only bounded remediation authority if the owned + // process group could still contain a live member. Do not unregister or remove it. + if (processGroupAbsenceWasProved(failures)) { + failures.push(...await removeCopiedApplication({ installRoot, launchServices })); + } + const blocksOuterRemoval = failures.some(failure => [ + 'process-groups', + 'dmg-mount', + 'mount-postcondition', + 'profile-authority', + 'launchservices-unregister', + 'launchservices-postcondition', + 'install-root', + 'install-postcondition', + ].includes(failure.label)); + if (!blocksOuterRemoval) { + await attempt('work-root', () => removeWorkRoot(workRoot)); + await attempt('work-postcondition', () => assertWorkRootAbsent(workRoot)); + } + return failures; +}; + +const assertProfileAuthority = async profile => { + const desktop = join(profile.userData, 'desktop'); + const state = join(desktop, 'profiles.json'); + const logs = join(profile.userData, 'logs'); + const logsFromRoot = relative(profile.root, logs); + const log = join(logs, 'desktop.jsonl'); + const [rootStats, desktopStats, stateStats, logsStats, logStats] = await Promise.all([ + lstat(profile.root), + lstat(desktop), + lstat(state), + lstat(logs), + lstat(log), + ]); + if ((rootStats.mode & 0o777) !== 0o700 || (desktopStats.mode & 0o777) !== 0o700 + || (stateStats.mode & 0o777) !== 0o600 || stateStats.isSymbolicLink() + || !logsFromRoot || logsFromRoot.startsWith('..') || isAbsolute(logsFromRoot) + || (logsStats.mode & 0o777) !== 0o700 || logsStats.isSymbolicLink() + || (logStats.mode & 0o777) !== 0o600 || logStats.isSymbolicLink()) { + throw new Error('Native profile state did not retain 0700/0600 authority'); + } + const contents = await readFile(state, 'utf8'); + if (/credential|token|password|secret/i.test(contents)) { + throw new Error('Native non-secret profile state contains a secret-bearing field'); + } +}; + +const createProfileApi = async () => { + const server = createServer((request, response) => { + const allowed = request.method === 'GET' + && ['/api/compatibility', '/api/desktop/discovery'].includes(request.url ?? '') + && request.headers.origin === 'propr-app://renderer'; + response.writeHead(allowed ? 200 : 403, { + 'Access-Control-Allow-Credentials': 'true', + 'Access-Control-Allow-Origin': 'propr-app://renderer', + 'Content-Type': 'application/json', + }); + response.end(request.url === '/api/desktop/discovery' + ? '{"product":"ProPR","desktopAuthentication":{"protocolVersion":1}}' + : '{"profileEndpoint":true}'); + }); + try { + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Native profile API did not bind safely'); + return { port: address.port, server, url: `http://127.0.0.1:${address.port}` }; + } catch (error) { + const primaryError = errorFrom(error, 'Native profile API creation failed'); + const cleanupFailures = []; + if (server.listening) { + try { + const address = server.address(); + if (!address || typeof address === 'string') { + try { + server.closeAllConnections(); + } finally { + await Promise.race([ + new Promise((resolveClose, rejectClose) => server.close(closeError => ( + closeError ? rejectClose(closeError) : resolveClose() + ))), + delay(CLEANUP_GRACE_MS).then(() => { + throw new Error('Native profile API setup close deadline expired'); + }), + ]); + } + if (server.listening || server.address() !== null) { + throw new Error('Native profile API retained listening authority after setup failure'); + } + } else { + await closeProfileApi({ server, port: address.port }); + } + } catch (cleanupError) { + cleanupFailures.push({ + label: 'profile-api-setup', + error: errorFrom(cleanupError, 'Native profile API setup cleanup failed'), + }); + } + } + throwCombined(primaryError, cleanupFailures); + } +}; + +const assertPortClosed = (port, timeout = CLEANUP_GRACE_MS) => new Promise((resolveClosed, rejectClosed) => { + const socket = createConnection({ host: '127.0.0.1', port }); + const timer = setTimeout(() => { + socket.destroy(); + rejectClosed(new Error('Native profile API close postcondition deadline expired')); + }, timeout); + socket.once('connect', () => { + clearTimeout(timer); + socket.destroy(); + rejectClosed(new Error('Native profile API remained reachable after close')); + }); + socket.once('error', error => { + clearTimeout(timer); + if (error?.code === 'ECONNREFUSED') resolveClosed(); + else rejectClosed(new Error('Native profile API close postcondition failed')); + }); +}); + +export const closeProfileApi = async ({ server, port }, { + closeDeadline = CLEANUP_GRACE_MS, + probeClosed = assertPortClosed, +} = {}) => { + const failures = []; + let closeError; + if (server.listening) { + try { + server.closeAllConnections(); + } catch (error) { + failures.push({ + label: 'profile-api-connections', + error: errorFrom(error, 'Native profile API connection cleanup failed'), + }); + } + try { + await Promise.race([ + new Promise((resolveClose, rejectClose) => server.close(error => ( + error ? rejectClose(error) : resolveClose() + ))), + delay(closeDeadline).then(() => { throw new Error('Native profile API close deadline expired'); }), + ]); + } catch (error) { + closeError = errorFrom(error, 'Native profile API close failed'); + } + } + if (closeError) failures.push({ label: 'profile-api-close', error: closeError }); + if (server.listening || server.address() !== null) { + failures.push({ label: 'profile-api-listening', error: new Error('Native profile API retained listening authority') }); + } else { + try { + await probeClosed(port); + } catch (error) { + failures.push({ label: 'profile-api-postcondition', error: errorFrom(error, 'Native profile API postcondition failed') }); + } + } + throwCombined(null, failures); +}; + +const assertAbsent = async (path, message) => { + try { + await lstat(path); + } catch (error) { + if (error?.code === 'ENOENT') return; + throw error; + } + throw new Error(message); +}; + +export const removeAuthorizedProfile = async (profile, { + inspectPath = lstat, + removeProfile = removePrivateSmokeProfile, +} = {}) => { + let removalError; + try { + await removeProfile(profile); + } catch (error) { + removalError = errorFrom(error, 'Native private profile authority cleanup failed'); + } + let postconditionError; + try { + await inspectPath(profile.root); + postconditionError = new Error('Native private profile remained after authority cleanup'); + } catch (error) { + if (error?.code !== 'ENOENT') postconditionError = errorFrom(error, 'Native private profile postcondition failed'); + } + const failures = []; + if (removalError) failures.push({ label: 'profile-authority', error: removalError }); + if (postconditionError) failures.push({ label: 'profile-postcondition', error: postconditionError }); + throwCombined(null, failures); +}; + +const defaultUserDataCandidates = target => { + const home = process.env.HOME; + if (!home || !isAbsolute(home)) throw new Error('Native lifecycle runner home is invalid'); + const applicationNames = [EXECUTABLE, 'ProPR Desktop']; + return target.platform === 'darwin' + ? applicationNames.flatMap(name => [ + join(home, 'Library', 'Application Support', name), + join(home, 'Library', 'Logs', name), + ]) + : applicationNames.flatMap(name => [join(home, '.config', name), join(home, '.cache', name)]); +}; + +const assertDefaultUserDataUntouched = async target => { + for (const path of defaultUserDataCandidates(target)) { + try { + await lstat(path); + throw new Error('Native lifecycle wrote outside the isolated user-data root'); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + } +}; + +export const classifyFirstEvidenceFailure = async (path, resultClass) => { + if (!NATIVE_LIFECYCLE_EVIDENCE_RESULT_CLASSES.includes(resultClass)) { + throw new Error('Native lifecycle evidence result class is invalid'); + } + let milestone = 'NO_EVIDENCE'; + try { + const events = new Set(await readFixedEvidenceEvents(path)); + if (events.has('desktop.smoke.authorized')) milestone = 'AUTHORIZED'; + if (events.has('desktop.native.identity_verified')) milestone = 'IDENTITY'; + if (events.has('desktop.deeplink.delivery_failed')) milestone = 'DEEP_LINK_DELIVERY_FAILURE'; + if (events.has('desktop.deeplink.cold_manual_once')) milestone = 'COLD_ACK'; + if (events.has('desktop.native.secure_storage_probe.started')) milestone = 'SECURE_STORAGE_STARTED'; + if (events.has('desktop.native.secure_storage_probe.completed')) milestone = 'SECURE_STORAGE_COMPLETED'; + if (events.has('desktop.renderer.ready')) milestone = 'RENDERER'; + } catch { + // Only fixed classifications may cross the native-gate diagnostic boundary. + } + const stage = milestone === 'SECURE_STORAGE_STARTED' + ? 'FIRST_SECURE_STORAGE_PROBE' + : ['SECURE_STORAGE_COMPLETED', 'RENDERER'].includes(milestone) + ? 'FIRST_RENDERER_READY' + : 'FIRST_INITIAL_EVIDENCE'; + return { milestone, resultClass, stage }; +}; + +const lifecycleForArtifact = async ({ target, kind, artifact, report }) => { + const workRoot = await mkdtemp(join(tmpdir(), `propr-native-${kind}-`)); + const installRoot = join(workRoot, 'install'); + const mountRoot = join(workRoot, 'mount'); + const processGroups = new OwnedProcessGroups(); + const mountAuthority = kind === 'dmg' ? new DmgMountAuthority(mountRoot) : null; + let profile; + let profileApi; + let application; + let launchServices; + let sandboxPrepared = false; + let primaryError; + let evidenceClassification; + let operationStage = 'PREPARE_WORK_ROOT'; + try { + await chmod(workRoot, 0o700); + await mkdir(installRoot, { mode: 0o700 }); + await mkdir(mountRoot, { mode: 0o700 }); + const beforeDigest = await digest(artifact); + operationStage = 'CREATE_PROFILE'; + profile = await createPrivateSmokeProfile(workRoot); + const logsDirectory = join(profile.userData, 'logs'); + await mkdir(logsDirectory, { mode: 0o700 }); + await chmod(logsDirectory, 0o700); + operationStage = 'START_PROFILE_API'; + profileApi = await createProfileApi(); + operationStage = 'BASELINE_BEFORE'; + await assertDefaultUserDataUntouched(target); + operationStage = 'INSPECT_ARTIFACT'; + await inspectStagedArtifact({ artifact, kind, target, workRoot }); + operationStage = 'EXTRACT_ARTIFACT'; + await extractArtifact({ artifact, kind, target, installRoot, mountAuthority }); + operationStage = 'VALIDATE_ARTIFACT'; + application = await locateApplication({ ...target, kind, installRoot }); + await assertSafeExtractedTree(installRoot); + await validateIdentity({ target, kind, application }); + if (target.platform === 'linux') { + operationStage = 'PREPARE_LINUX_SANDBOX'; + const sandbox = join(application.applicationRoot, 'chrome-sandbox'); + await run('/usr/bin/sudo', ['/usr/bin/chown', 'root:root', sandbox]); + await run('/usr/bin/sudo', ['/usr/bin/chmod', '4755', sandbox]); + sandboxPrepared = true; + } + + operationStage = 'CREATE_CHILD_ENVIRONMENT'; + const baseEnvironment = await createSmokeChildEnvironment({ + platform: target.platform, + profile, + profileApiUrl: profileApi.url, + preserveMacosKeychainContext: target.platform === 'darwin', + }); + const firstEnvironment = Object.freeze({ + ...baseEnvironment, + PROPR_DESKTOP_NATIVE_ARTIFACT_PHASE: 'first', + PROPR_DESKTOP_NATIVE_EXPECTED_ARCH: target.arch, + PROPR_DESKTOP_NATIVE_EXPECTED_PLATFORM: target.platform, + PROPR_DESKTOP_NATIVE_EXPECTED_VERSION: target.version, + }); + const dispatchEnvironment = { ...baseEnvironment }; + delete dispatchEnvironment.PROPR_DESKTOP_SMOKE_TEST; + delete dispatchEnvironment.PROPR_DESKTOP_SMOKE_PROFILE_API_URL; + + operationStage = 'FIRST_LAUNCH'; + const first = startApplication(application, [ + '--propr-smoke-test', + `--user-data-dir=${profile.userData}`, + COLD_MANUAL, + ], firstEnvironment, workRoot, processGroups); + const firstEvidence = join(profile.userData, 'application.smoke-evidence.first.jsonl'); + operationStage = 'FIRST_INITIAL_EVIDENCE'; + try { + await waitForEvents(firstEvidence, ['desktop.renderer.ready', 'desktop.deeplink.cold_manual_once'], first.child); + } catch (error) { + if (error instanceof NativeLifecycleEvidenceWaitFailure) { + evidenceClassification = await classifyFirstEvidenceFailure(firstEvidence, error.resultClass); + operationStage = evidenceClassification.stage; + } + throw error; + } + operationStage = 'WARM_MANUAL_DISPATCH'; + await dispatchDirect(application, profile.userData, WARM_MANUAL, dispatchEnvironment, processGroups); + operationStage = 'WARM_MANUAL_EVIDENCE'; + await waitForEvents(firstEvidence, ['desktop.deeplink.warm_manual_once'], first.child); + if (target.platform === 'darwin') { + launchServices = new LaunchServicesAuthority(application.applicationRoot, dispatchEnvironment); + } + let protocol; + if (target.platform === 'linux') { + operationStage = 'PROTOCOL_DISPATCH'; + protocol = await linuxProtocolDispatch({ + application, + profile, + link: WARM_TUNNEL, + env: dispatchEnvironment, + processGroups, + }); + } else { + operationStage = 'LS_REGISTER'; + await launchServices.register(); + operationStage = 'OPEN_DISPATCH'; + await launchServices.dispatch(WARM_TUNNEL); + protocol = 'LaunchServices-registration+open-exact-application-dispatch'; + } + operationStage = 'PROTOCOL_EVIDENCE'; + await waitForEvents(firstEvidence, ['desktop.deeplink.warm_tunnel_once'], first.child); + operationStage = 'WARM_OPEN_DISPATCH'; + await dispatchDirect(application, profile.userData, WARM_OPEN, dispatchEnvironment, processGroups); + operationStage = 'WARM_OPEN_EVIDENCE'; + await waitForEvents(firstEvidence, ['desktop.deeplink.warm_open_once'], first.child); + operationStage = 'MALFORMED_DISPATCH'; + await dispatchDirect(application, profile.userData, 'native-evidence-malformed', dispatchEnvironment, processGroups); + operationStage = 'MALFORMED_EVIDENCE'; + await waitForEvents(firstEvidence, ['desktop.deeplink.rejected_malformed'], first.child); + operationStage = 'OVERSIZED_DISPATCH'; + await dispatchDirect( + application, + profile.userData, + `propr://connect?api=https%3A%2F%2Ft-native-evidence.propr.dev%2F${'a'.repeat(2_100)}`, + dispatchEnvironment, + processGroups, + ); + operationStage = 'OVERSIZED_EVIDENCE'; + await waitForEvents(firstEvidence, ['desktop.deeplink.rejected_oversized'], first.child); + operationStage = 'UNSAFE_SCHEME_DISPATCH'; + await dispatchDirect( + application, + profile.userData, + 'https://native-evidence.invalid/unsafe', + dispatchEnvironment, + processGroups, + ); + operationStage = 'UNSAFE_SCHEME_EVIDENCE'; + await waitForEvents(firstEvidence, ['desktop.deeplink.rejected_unsafe_scheme'], first.child); + operationStage = 'FIRST_EXIT'; + await first.waitForSuccessfulExit(); + const requiredFirstEvents = target.platform === 'linux' + ? REQUIRED_FIRST_EVENTS.flatMap(event => event === 'desktop.native.secure_storage_probe.completed' + ? ['desktop.native.secure_storage_fallback_refused', event] + : [event]) + : REQUIRED_FIRST_EVENTS; + operationStage = 'FIRST_EVIDENCE_VALIDATION'; + await waitForEvents(firstEvidence, requiredFirstEvents, { exitCode: null }); + await assertEvidenceOrdering(firstEvidence, requiredFirstEvents); + await assertProfileAuthority(profile); + + const relaunchEnvironment = Object.freeze({ + ...baseEnvironment, + PROPR_DESKTOP_NATIVE_ARTIFACT_PHASE: 'relaunch', + PROPR_DESKTOP_NATIVE_EXPECTED_ARCH: target.arch, + PROPR_DESKTOP_NATIVE_EXPECTED_PLATFORM: target.platform, + PROPR_DESKTOP_NATIVE_EXPECTED_VERSION: target.version, + }); + operationStage = 'RELAUNCH'; + const relaunch = startApplication(application, [ + '--propr-smoke-test', + `--user-data-dir=${profile.userData}`, + COLD_TUNNEL, + ], relaunchEnvironment, workRoot, processGroups); + operationStage = 'RELAUNCH_EXIT'; + await relaunch.waitForSuccessfulExit(); + const relaunchEvidence = join(profile.userData, 'application.smoke-evidence.relaunch.jsonl'); + operationStage = 'RELAUNCH_EVIDENCE'; + await waitForEvents( + relaunchEvidence, + REQUIRED_RELAUNCH_EVENTS, + { exitCode: null }, + ); + await assertEvidenceOrdering(relaunchEvidence, REQUIRED_RELAUNCH_EVENTS); + await assertProfileAuthority(profile); + operationStage = 'FINAL_VALIDATION'; + if (await digest(artifact) !== beforeDigest) throw new Error('Native lifecycle mutated the staged artifact bytes'); + await assertDefaultUserDataUntouched(target); + report.push({ + coldDispatch: 'direct-argv (not OS protocol launch)', + kind, + lifecycle: 'extract-or-mount-copy/launch/shutdown/relaunch/remove', + protocol, + secureStorage: target.platform === 'linux' + ? 'fallback-only; plaintext refused; libsecret custody not exercised' + : 'OS-protected Keychain round-trip and deletion', + }); + } catch (error) { + primaryError = new NativeLifecycleOperationFailure( + operationStage, + errorFrom(error, 'Native lifecycle operation failed'), + evidenceClassification, + ); + } + + const cleanupFailures = await processGroups.cleanup(); + const cleanup = async (label, operation) => { + try { + await operation(); + } catch (error) { + cleanupFailures.push({ label, error: errorFrom(error, 'Native lifecycle cleanup failed') }); + } + }; + if (profileApi) await cleanup('profile-api', () => closeProfileApi(profileApi)); + if (mountAuthority?.mounted) await cleanup('dmg-mount', () => mountAuthority.detach()); + if (processGroupAbsenceWasProved(cleanupFailures) && sandboxPrepared && application) { + await cleanup('linux-sandbox', () => run('/usr/bin/sudo', [ + '/bin/rm', '-f', join(application.applicationRoot, 'chrome-sandbox'), + ])); + } + if (!mountAuthority?.mounted) { + await cleanup('mount-root', () => rm(mountRoot, { recursive: true, force: true })); + await cleanup('mount-postcondition', () => assertAbsent(mountRoot, 'Native DMG mount root remained after detach')); + } + if (profile) { + await cleanup('profile-authority', () => removeAuthorizedProfile(profile)); + } + const finalCleanupFailures = await removeLifecycleRootsWithAuthority({ + cleanupFailures, + installRoot, + launchServices, + workRoot, + }); + throwCombined(primaryError, finalCleanupFailures); +}; + +export const runNativeArtifactLifecycle = async target => { + if (hostPlatform() !== target.platform || hostArch() !== target.arch) { + throw new Error(`Native lifecycle requires ${target.platform}-${target.arch}, got ${hostPlatform()}-${hostArch()}`); + } + const kinds = await assertArtifactSet(target); + const report = []; + for (const kind of kinds) { + const artifact = canonicalArtifact({ directory: target.artifactDirectory, ...target, kind }); + await lifecycleForArtifact({ target, kind, artifact, report }); + } + console.log(JSON.stringify({ + schemaVersion: 1, + target: `${target.platform}-${target.arch}`, + evidence: report, + limitations: target.platform === 'linux' + ? 'Cold launch is direct argv. ZIP warm dispatch is direct. Package warm dispatch uses isolated XDG/GIO. Secure storage is fallback-only; libsecret custody is not exercised.' + : 'Cold launch is direct argv. Warm protocol evidence uses local LaunchServices. Unsigned internal-RC evidence does not claim signing, notarization, or Gatekeeper assessment.', + })); +}; + +const invokedDirectly = process.argv[1] + && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); +if (invokedDirectly) await runNativeArtifactLifecycle(parseArguments(process.argv.slice(2))); diff --git a/apps/desktop/scripts/test-native-artifact-lifecycle.test.mjs b/apps/desktop/scripts/test-native-artifact-lifecycle.test.mjs new file mode 100644 index 000000000..4d45c5149 --- /dev/null +++ b/apps/desktop/scripts/test-native-artifact-lifecycle.test.mjs @@ -0,0 +1,479 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { chmod, mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, test } from 'node:test'; +import { inspect } from 'node:util'; +import { + assertArtifactSet, + assertSafeExtractedTree, + classifyFirstEvidenceFailure, + closeProfileApi, + DmgMountAuthority, + extractDmg, + extractRpm, + inspectRunningProcessGroupMembers, + LaunchServicesAuthority, + NativeLifecycleEvidenceWaitFailure, + NativeLifecycleFailure, + NativeLifecycleOperationFailure, + OwnedProcessGroups, + parseArguments, + removeCopiedApplicationWithLaunchServicesAuthority, + removeLifecycleRootsWithAuthority, + removeAuthorizedProfile, + runningProcessGroupMembersFromPs, + waitForEvents, +} from './test-native-artifact-lifecycle.mjs'; + +describe('native staged artifact lifecycle authority', () => { + test('accepts only the exact four native target coordinates', () => { + assert.deepEqual(parseArguments([ + '--version', '1.2.3', + '--platform', 'linux', + '--arch', 'arm64', + '--artifact-directory', 'artifacts', + ]), { + version: '1.2.3', + platform: 'linux', + arch: 'arm64', + artifactDirectory: join(process.cwd(), 'artifacts'), + }); + for (const args of [ + ['--version', '1.2.3', '--platform', 'win32', '--arch', 'x64', '--artifact-directory', 'artifacts'], + ['--version', '1.2.3', '--platform', 'darwin', '--arch', 'ia32', '--artifact-directory', 'artifacts'], + ['--version', '1.2.3-beta', '--platform', 'darwin', '--arch', 'arm64', '--artifact-directory', 'artifacts'], + ['--version', '1.2.3', '--version', '1.2.4', '--platform', 'linux', '--arch', 'x64'], + ]) assert.throws(() => parseArguments(args), /invalid|missing|duplicated|malformed/); + }); + + test('fails closed for a missing kind, foreign file, or symlinked canonical artifact', async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-native-artifact-set-')); + const target = { platform: 'linux', arch: 'x64', version: '1.2.3', artifactDirectory: directory }; + const names = ['deb', 'rpm', 'zip'].map(kind => `ProPR-Desktop-1.2.3-linux-x64.${kind}`); + try { + await Promise.all(names.map(name => writeFile(join(directory, name), name))); + assert.deepEqual(await assertArtifactSet(target), ['deb', 'rpm', 'zip']); + await writeFile(join(directory, 'foreign.zip'), 'foreign'); + await assert.rejects(assertArtifactSet(target), /unexpected or duplicate identity/); + await rm(join(directory, 'foreign.zip')); + await rm(join(directory, names[0])); + await assert.rejects(assertArtifactSet(target), /canonical staged deb/); + await symlink(join(directory, names[1]), join(directory, names[0])); + await assert.rejects(assertArtifactSet(target), /canonical staged deb/); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + test('cleans a live detached process group after evidence timeout', { skip: process.platform === 'win32' }, async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-native-process-')); + const groups = new OwnedProcessGroups(); + const child = spawn(process.execPath, ['-e', ` + const { spawn } = require('node:child_process'); + const child = spawn('/bin/sleep', ['30'], { stdio: 'ignore' }); + process.on('SIGTERM', () => child.once('close', () => process.exit(0))); + setInterval(() => undefined, 1000); + `], { + detached: true, + shell: false, + stdio: 'ignore', + }); + groups.track(child); + try { + await assert.rejects( + waitForEvents(join(directory, 'missing.jsonl'), ['never'], child, 60), + /evidence deadline/, + ); + assert.doesNotThrow(() => process.kill(-child.pid, 0)); + assert.deepEqual(await groups.cleanup(), []); + assert.throws(() => process.kill(-child.pid, 0), error => error?.code === 'ESRCH'); + } finally { + await groups.cleanup(); + await rm(directory, { recursive: true, force: true }); + } + }); + + test('reads fixed evidence before classifying a clean child exit', async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-native-exited-evidence-')); + const complete = join(directory, 'complete.jsonl'); + const incomplete = join(directory, 'incomplete.jsonl'); + const exitedChild = { exitCode: 0, signalCode: null }; + try { + await writeFile(complete, [ + JSON.stringify({ event: 'first' }), + JSON.stringify({ event: 'second' }), + ].join('\n')); + await writeFile(incomplete, `${JSON.stringify({ event: 'first' })}\n`); + + await assert.doesNotReject(waitForEvents(complete, ['first', 'second'], exitedChild, 10)); + await assert.rejects(waitForEvents(incomplete, ['first', 'second'], exitedChild, 10), error => ( + error instanceof NativeLifecycleEvidenceWaitFailure + && error.resultClass === 'CLEAN_EXIT' + )); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + test('allows a successful parent a bounded natural same-group descendant drain', { + skip: process.platform === 'win32', + }, async () => { + const groups = new OwnedProcessGroups(); + const child = spawn(process.execPath, ['-e', ` + const { spawn } = require('node:child_process'); + spawn('/bin/sleep', ['0.15'], { stdio: 'ignore' }).unref(); + `], { detached: true, shell: false, stdio: 'ignore' }); + const group = groups.track(child); + try { + const started = Date.now(); + await group.waitForSuccessfulExit(3_000); + assert.ok(Date.now() - started >= 100, 'owned group was released before its descendant drained'); + assert.deepEqual(await inspectRunningProcessGroupMembers(child.pid), []); + } finally { + await groups.cleanup(); + } + }); + + test('kills and proves absence for a genuinely lingering successful-parent descendant', { + skip: process.platform === 'win32', + }, async () => { + const groups = new OwnedProcessGroups(); + const child = spawn(process.execPath, ['-e', ` + const { spawn } = require('node:child_process'); + spawn('/bin/sleep', ['30'], { stdio: 'ignore' }).unref(); + `], { detached: true, shell: false, stdio: 'ignore' }); + const group = groups.track(child); + try { + await assert.rejects(group.waitForSuccessfulExit(3_000), /owned process group drained/); + assert.deepEqual(await inspectRunningProcessGroupMembers(child.pid), []); + assert.deepEqual(await groups.cleanup(), []); + } finally { + await groups.cleanup(); + } + }); + + test('treats zombie-only process-group records as non-running without hiding live members', () => { + const records = Buffer.from([ + ' 410 410 Z', + ' 411 410 Z+', + ' 412 410 S', + ' 510 510 R+', + ].join('\n')); + assert.deepEqual(runningProcessGroupMembersFromPs(records, 410), [412]); + assert.deepEqual(runningProcessGroupMembersFromPs(Buffer.from(' 410 410 Z\n'), 410), []); + assert.throws( + () => runningProcessGroupMembersFromPs(Buffer.from('secret-capable malformed output\n'), 410), + /invalid record/, + ); + }); + + for (const failurePoint of ['scan', 'copy']) { + test(`detaches and verifies a DMG when ${failurePoint} fails after attach`, async () => { + const calls = []; + const runCommand = async (file, args) => { + calls.push([file, ...args]); + if (args[0] === 'info') return { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) }; + if (failurePoint === 'copy' && file.endsWith('/ditto')) throw new Error('injected copy failure'); + return { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) }; + }; + const authority = new DmgMountAuthority('/private/mount', { runCommand }); + await assert.rejects(extractDmg({ + artifact: '/private/artifact.dmg', + installRoot: '/private/install', + mountAuthority: authority, + readDirectory: failurePoint === 'scan' + ? async () => { throw new Error('injected scan failure'); } + : async () => [{ name: 'ProPR.app', isDirectory: () => true }], + runCommand, + }), new RegExp(`injected ${failurePoint} failure`)); + assert.equal(authority.mounted, false); + assert.deepEqual(calls.map(call => call[1]), [ + 'attach', + ...(failurePoint === 'copy' ? ['/private/mount/ProPR.app'] : []), + 'detach', + 'info', + ]); + }); + } + + test('retains DMG authority and fails when detach cannot prove the mount absent', async () => { + const authority = new DmgMountAuthority('/private/mount', { + runCommand: async (_file, args) => ({ + stdout: Buffer.from(args[0] === 'info' ? '/dev/disk9 /private/mount\n' : ''), + stderr: Buffer.alloc(0), + }), + }); + authority.mounted = true; + await assert.rejects(authority.detach(), /dmg-mounted-postcondition/); + assert.equal(authority.mounted, true); + }); + + test('preserves a DMG primary failure without exposing it through cleanup diagnostics', async () => { + const privateFailure = new Error('scan failed at /private/profile with https://secret.invalid/token'); + const authority = new DmgMountAuthority('/private/mount', { + runCommand: async (_file, args) => ({ + stdout: Buffer.from(args[0] === 'info' ? '/dev/disk9 /private/mount\n' : ''), + stderr: Buffer.alloc(0), + }), + }); + await assert.rejects(extractDmg({ + artifact: '/private/artifact.dmg', + installRoot: '/private/install', + mountAuthority: authority, + readDirectory: async () => { throw privateFailure; }, + }), error => { + assert.ok(error instanceof NativeLifecycleFailure); + assert.equal(error.primaryError, privateFailure); + assert.match(error.message, /dmg-mount/); + assert.doesNotMatch(String(error), /private\/profile|secret\.invalid/); + assert.doesNotMatch(JSON.stringify(error), /private\/profile|secret\.invalid/); + assert.doesNotMatch(inspect(error), /private\/profile|secret\.invalid/); + return true; + }); + assert.equal(authority.mounted, true); + }); + + test('classifies first-evidence exits by fixed non-secret milestone and result class', async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-native-stage-')); + const evidence = join(directory, 'evidence.jsonl'); + const privateFailure = new Error('failed at /private/profile with https://secret.invalid/token'); + try { + const cases = [ + { event: null, milestone: 'NO_EVIDENCE', stage: 'FIRST_INITIAL_EVIDENCE' }, + { event: 'desktop.smoke.authorized', milestone: 'AUTHORIZED', stage: 'FIRST_INITIAL_EVIDENCE' }, + { event: 'desktop.native.identity_verified', milestone: 'IDENTITY', stage: 'FIRST_INITIAL_EVIDENCE' }, + { + event: 'desktop.deeplink.delivery_failed', + milestone: 'DEEP_LINK_DELIVERY_FAILURE', + stage: 'FIRST_INITIAL_EVIDENCE', + }, + { event: 'desktop.deeplink.cold_manual_once', milestone: 'COLD_ACK', stage: 'FIRST_INITIAL_EVIDENCE' }, + { + event: 'desktop.native.secure_storage_probe.started', + milestone: 'SECURE_STORAGE_STARTED', + stage: 'FIRST_SECURE_STORAGE_PROBE', + }, + { + event: 'desktop.native.secure_storage_probe.completed', + milestone: 'SECURE_STORAGE_COMPLETED', + stage: 'FIRST_RENDERER_READY', + }, + { event: 'desktop.renderer.ready', milestone: 'RENDERER', stage: 'FIRST_RENDERER_READY' }, + ]; + for (const fixture of cases) { + await writeFile(evidence, fixture.event ? `${JSON.stringify({ event: fixture.event })}\n` : ''); + assert.deepEqual(await classifyFirstEvidenceFailure(evidence, 'FAILED_EXIT'), { + milestone: fixture.milestone, + resultClass: 'FAILED_EXIT', + stage: fixture.stage, + }); + } + + const classification = await classifyFirstEvidenceFailure(evidence, 'FAILED_EXIT'); + const operationFailure = new NativeLifecycleOperationFailure( + classification.stage, + privateFailure, + classification, + ); + const aggregate = new NativeLifecycleFailure(operationFailure, [{ + label: 'process-groups', + error: new Error('private cleanup output'), + }]); + assert.match(aggregate.message, /stage:FIRST_RENDERER_READY/); + assert.match(aggregate.message, /milestone:RENDERER/); + assert.match(aggregate.message, /result:FAILED_EXIT/); + assert.doesNotMatch(String(aggregate), /private\/profile|secret\.invalid|private cleanup output/); + assert.doesNotMatch(JSON.stringify(aggregate), /private\/profile|secret\.invalid|private cleanup output/); + assert.doesNotMatch(inspect(aggregate), /private\/profile|secret\.invalid|private cleanup output/); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + test('surfaces LaunchServices unregister failure and stale exact registration', async () => { + const applicationRoot = '/private/copied/ProPR Desktop.app'; + const unregisterFailure = new LaunchServicesAuthority(applicationRoot, {}, { + runCommand: async (_file, args) => { + if (args[0] === '-u') throw new Error('injected unregister failure'); + return { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) }; + }, + }); + unregisterFailure.registered = true; + await assert.rejects(unregisterFailure.unregister(), /injected unregister failure/); + + const stale = new LaunchServicesAuthority(applicationRoot, {}, { + runCommand: async () => ({ + stdout: Buffer.from(`path: ${applicationRoot}\n`), + stderr: Buffer.alloc(0), + }), + }); + stale.registered = true; + await assert.rejects(stale.assertGone(), /remained registered/); + assert.equal(stale.registered, true); + }); + + test('registers before dispatching through the exact copied macOS application path', async () => { + const applicationRoot = '/private/copied/ProPR Desktop.app'; + const link = 'propr://connect?api=https%3A%2F%2Ft-native-evidence.propr.dev'; + const calls = []; + const authority = new LaunchServicesAuthority(applicationRoot, { FIXED: 'environment' }, { + runCommand: async (file, args, options) => { + calls.push({ file, args, options }); + return { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) }; + }, + }); + + await assert.rejects(authority.dispatch(link), /must be registered/); + await authority.register(); + await authority.dispatch(link); + + assert.equal(calls[0].args[0], '-f'); + assert.equal(calls[0].args[1], applicationRoot); + assert.deepEqual(calls[1], { + file: '/usr/bin/open', + args: ['-a', applicationRoot, link], + options: { env: { FIXED: 'environment' }, timeout: 15_000 }, + }); + }); + + test('retains the copied application until unregister and exact absence both succeed', async () => { + for (const failurePoint of ['unregister', 'postcondition']) { + const calls = []; + const launchServices = { + registered: true, + unregister: async () => { + calls.push('unregister'); + if (failurePoint === 'unregister') throw new Error('injected unregister failure'); + }, + assertGone: async () => { + calls.push('postcondition'); + if (failurePoint === 'postcondition') throw new Error('injected stale record'); + launchServices.registered = false; + }, + }; + const failures = await removeCopiedApplicationWithLaunchServicesAuthority({ + installRoot: '/private/install', + launchServices, + }, { + removeInstallRoot: async () => { calls.push('remove'); }, + assertInstallRootAbsent: async () => { calls.push('install-postcondition'); }, + }); + assert.deepEqual(calls, ['unregister', 'postcondition']); + assert.deepEqual(failures.map(failure => failure.label), [ + failurePoint === 'unregister' ? 'launchservices-unregister' : 'launchservices-postcondition', + ]); + } + + const calls = []; + const launchServices = { + registered: true, + unregister: async () => { calls.push('unregister'); }, + assertGone: async () => { + calls.push('postcondition'); + launchServices.registered = false; + }, + }; + assert.deepEqual(await removeCopiedApplicationWithLaunchServicesAuthority({ + installRoot: '/private/install', + launchServices, + }, { + removeInstallRoot: async () => { calls.push('remove'); }, + assertInstallRootAbsent: async () => { calls.push('install-postcondition'); }, + }), []); + assert.deepEqual(calls, ['unregister', 'postcondition', 'remove', 'install-postcondition']); + }); + + test('retains copied install and outer work roots when process-group absence cannot be proved', async () => { + const calls = []; + const processGroupFailure = { + label: 'process-groups', + error: new Error('injected process-group postcondition failure'), + }; + const failures = await removeLifecycleRootsWithAuthority({ + cleanupFailures: [processGroupFailure], + installRoot: '/private/work/install', + launchServices: { registered: true }, + workRoot: '/private/work', + }, { + removeCopiedApplication: async () => { + calls.push('remove-copied-application'); + return []; + }, + removeWorkRoot: async () => { calls.push('remove-work-root'); }, + assertWorkRootAbsent: async () => { calls.push('work-postcondition'); }, + }); + assert.deepEqual(calls, []); + assert.deepEqual(failures, [processGroupFailure]); + }); + + test('does not mask profile API or private-profile authority cleanup failures', async () => { + const server = { + listening: true, + address: () => ({ address: '127.0.0.1', family: 'IPv4', port: 1 }), + close: callback => callback(new Error('injected close failure')), + closeAllConnections: () => undefined, + }; + await assert.rejects( + closeProfileApi({ server, port: 1 }), + error => error instanceof NativeLifecycleFailure && /profile-api-close, profile-api-listening/.test(error.message), + ); + await assert.rejects(closeProfileApi({ + server: { + ...server, + close: () => undefined, + }, + port: 1, + }, { closeDeadline: 10 }), error => ( + error instanceof NativeLifecycleFailure + && /profile-api-close, profile-api-listening/.test(error.message) + )); + await assert.rejects(removeAuthorizedProfile({ root: '/private/profile' }, { + removeProfile: async () => { throw new Error('injected profile failure'); }, + inspectPath: async () => ({ isDirectory: () => true }), + }), error => error instanceof NativeLifecycleFailure && /profile-authority, profile-postcondition/.test(error.message)); + }); + + test('rejects escaping symlinks and symlinks to special files', { skip: process.platform === 'win32' }, async () => { + const parent = await mkdtemp(join(tmpdir(), 'propr-native-tree-')); + const root = join(parent, 'root'); + try { + await mkdir(root); + const outside = join(parent, 'outside target with spaces'); + await writeFile(outside, 'outside'); + await symlink(outside, join(root, 'escaping link')); + await assert.rejects(assertSafeExtractedTree(root), /escaping its install root/); + await rm(join(root, 'escaping link')); + + const fifo = join(root, 'owned fifo'); + const mkfifo = spawn('/usr/bin/mkfifo', [fifo], { shell: false, stdio: 'ignore' }); + const code = await new Promise(resolve => mkfifo.once('close', resolve)); + assert.equal(code, 0); + await symlink(fifo, join(root, 'fifo link')); + await assert.rejects(assertSafeExtractedTree(root), /symlink to an unsupported filesystem entry/); + } finally { + await rm(parent, { recursive: true, force: true }); + } + }); + + test('waits for a late rpm2cpio failure after extractor completion', { skip: process.platform === 'win32' }, async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-native-rpm-')); + const converter = join(directory, 'late-converter.sh'); + const extractor = join(directory, 'early-extractor.sh'); + try { + await writeFile(converter, '#!/bin/sh\nexec 1>&-\nsleep 0.15\nexit 29\n'); + await writeFile(extractor, '#!/bin/sh\ncat >/dev/null\nexit 0\n'); + await chmod(converter, 0o700); + await chmod(extractor, 0o700); + const started = Date.now(); + await assert.rejects( + extractRpm('fixture.rpm', directory, { converterFile: converter, extractorFile: extractor, timeout: 2_000 }), + /rpm2cpio failed with code 29/, + ); + assert.ok(Date.now() - started >= 100, 'extraction resolved before the converter reported its late failure'); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/desktop/src/deep-link-delivery.test.ts b/apps/desktop/src/deep-link-delivery.test.ts index 099fc4755..71c841463 100644 --- a/apps/desktop/src/deep-link-delivery.test.ts +++ b/apps/desktop/src/deep-link-delivery.test.ts @@ -1,63 +1,143 @@ import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; import { describe, it } from 'node:test'; -import { DeepLinkDelivery, type DeepLinkWindow } from './deep-link-delivery'; +import { + DeepLinkDelivery, + deepLinkAcknowledgementTimeoutMs, + type DeepLinkWindow, +} from './deep-link-delivery'; +import type { DesktopDeepLinkDelivery } from './shared/contract'; describe('desktop deep-link delivery', () => { - const createWindow = (sent: Array<{ channel: string; value: string }>): DeepLinkWindow => ({ + const createWindow = (sent: DesktopDeepLinkDelivery[]): DeepLinkWindow => ({ isDestroyed: () => false, webContents: { isLoading: () => false, - send: (channel, value) => sent.push({ channel, value }), + send: (_channel, value) => sent.push(value), }, }); + const tick = () => new Promise(resolve => setImmediate(resolve)); - it('queues links received after did-finish-load until the ready window is registered', () => { - const sent: Array<{ channel: string; value: string }> = []; - const window = createWindow(sent); - const delivery = new DeepLinkDelivery('desktop:deep-link', ['propr://open?task=initial']); - - delivery.didFinishLoad(window); - delivery.deliver('propr://open?task=between'); - - assert.deepEqual(sent, []); + it('keeps the production acknowledgement deadline while bounding a native-smoke allowance', () => { + assert.equal(deepLinkAcknowledgementTimeoutMs(false), 5_000); + assert.equal(deepLinkAcknowledgementTimeoutMs(true), 15_000); + }); + it('queues across the load boundary and waits for renderer consumption in order', async () => { + const sent: DesktopDeepLinkDelivery[] = []; + const consumed: string[] = []; + const delivery = new DeepLinkDelivery( + 'desktop:deep-link', + ['propr://connect?api=http%3A%2F%2Flocalhost%3A4000'], + value => { consumed.push(value); }, + ); + const window = createWindow(sent); + delivery.deliver('propr://open?path=%2Ftasks'); delivery.setWindow(window); - assert.deepEqual(sent, [ - { channel: 'desktop:deep-link', value: 'propr://open?task=initial' }, - { channel: 'desktop:deep-link', value: 'propr://open?task=between' }, + assert.equal(sent.length, 1); + assert.deepEqual(consumed, []); + assert.equal(delivery.acknowledge(window, { + ...sent[0], + consumption: { kind: 'connect-confirmation', target: 'http://localhost:4000' }, + }), true); + await tick(); + assert.equal(sent.length, 2); + assert.deepEqual(consumed, ['propr://connect?api=http%3A%2F%2Flocalhost%3A4000']); + assert.equal(delivery.acknowledge(window, { + ...sent[1], + consumption: { kind: 'open-queued', target: '/tasks' }, + }), true); + await delivery.whenIdle(); + + assert.deepEqual(consumed, [ + 'propr://connect?api=http%3A%2F%2Flocalhost%3A4000', + 'propr://open?path=%2Ftasks', ]); }); - it('delivers a queued initial Connect URL before packaged smoke asserts it and only once', () => { - const main = readFileSync(new URL('./main.ts', import.meta.url), 'utf8'); - const preloadReady = main.indexOf("throw new Error('Desktop preload bridge was not exposed to the renderer')"); - const readyWindowRegistration = main.indexOf('deepLinkDelivery.setWindow(window);'); - const packagedSmokeStart = main.indexOf('const smokeProfileApiUrl ='); - assert.ok(preloadReady < readyWindowRegistration); - assert.ok(readyWindowRegistration < packagedSmokeStart); - assert.equal(main.match(/deepLinkDelivery\.setWindow\(/g)?.length, 1); - - const sent: Array<{ channel: string; value: string }> = []; + it('rejects duplicate delivery and duplicate or out-of-order acknowledgements', async () => { + const sent: DesktopDeepLinkDelivery[] = []; + const consumed: string[] = []; + let now = 1_000; + const link = 'propr://open?path=%2Ftasks'; + const delivery = new DeepLinkDelivery( + 'desktop:deep-link', + [], + value => { consumed.push(value); }, + error => { throw error; }, + () => now, + 1_000, + ); const window = createWindow(sent); - const connectUrl = 'propr://connect?api=https%3A%2F%2Fconnect.propr.dev'; - const delivery = new DeepLinkDelivery('desktop:deep-link', [connectUrl]); - - delivery.didFinishLoad(window); - assert.deepEqual(sent, []); - delivery.setWindow(window); - const assertPackagedSmokeDeepLink = () => { - assert.deepEqual(sent.filter(({ value }) => value === connectUrl), [ - { channel: 'desktop:deep-link', value: connectUrl }, - ]); + + assert.equal(delivery.deliver(link), true); + assert.equal(delivery.deliver(link), false); + assert.equal(sent.length, 1); + assert.equal(delivery.acknowledge(window, { + deliveryId: sent[0].deliveryId + 1, + url: link, + consumption: { kind: 'open-queued', target: '/tasks' }, + }), false); + const acknowledgement = { + ...sent[0], + consumption: { kind: 'open-queued' as const, target: '/tasks' }, }; - assertPackagedSmokeDeepLink(); + assert.equal(delivery.acknowledge(window, acknowledgement), true); + assert.equal(delivery.acknowledge(window, acknowledgement), false); + await delivery.whenIdle(); + assert.deepEqual(consumed, [link]); - delivery.didFinishLoad(window); - delivery.setWindow(window); + now += 1_001; + assert.equal(delivery.deliver(link), true); + await tick(); + assert.equal(sent.length, 2); + assert.equal(delivery.acknowledge(window, { + ...sent[1], + consumption: { kind: 'open-queued', target: '/tasks' }, + }), true); + await delivery.whenIdle(); + assert.deepEqual(consumed, [link, link]); + }); - assert.equal(sent.filter(({ value }) => value === connectUrl).length, 1); + it('fails closed when the renderer does not acknowledge consumption', async () => { + const sent: DesktopDeepLinkDelivery[] = []; + let failure: Error | undefined; + const delivery = new DeepLinkDelivery( + 'desktop:deep-link', + [], + undefined, + error => { failure = error; }, + Date.now, + 1_000, + 20, + ); + delivery.setWindow(createWindow(sent)); + delivery.deliver('propr://open?path=%2Ftasks'); + await delivery.whenIdle(); + assert.equal(sent.length, 1); + assert.match(failure?.message ?? '', /acknowledgement deadline/); + }); + + it('deduplicates a cold link reported through argv and open-url before delivery', async () => { + const sent: DesktopDeepLinkDelivery[] = []; + const link = 'propr://connect?api=https%3A%2F%2Ft-native-evidence.propr.dev'; + const delivery = new DeepLinkDelivery( + 'desktop:deep-link', + [link], + undefined, + undefined, + () => 10, + ); + const window = createWindow(sent); + assert.equal(delivery.deliver(link), false); + delivery.setWindow(window); + assert.equal(sent.length, 1); + delivery.acknowledge(window, { + ...sent[0], + consumption: { kind: 'connect-confirmation', target: 'https://t-native-evidence.propr.dev' }, + }); + await delivery.whenIdle(); + assert.equal(sent.length, 1); }); }); diff --git a/apps/desktop/src/deep-link-delivery.ts b/apps/desktop/src/deep-link-delivery.ts index aaf9ead26..ca19319e2 100644 --- a/apps/desktop/src/deep-link-delivery.ts +++ b/apps/desktop/src/deep-link-delivery.ts @@ -1,26 +1,75 @@ +import type { + DesktopDeepLinkAcknowledgement, + DesktopDeepLinkConsumption, + DesktopDeepLinkDelivery, +} from './shared/contract'; + +export const DEFAULT_DEEP_LINK_ACKNOWLEDGEMENT_TIMEOUT_MS = 5_000; +export const NATIVE_SMOKE_DEEP_LINK_ACKNOWLEDGEMENT_TIMEOUT_MS = 15_000; + +export const deepLinkAcknowledgementTimeoutMs = (nativeArtifactSmoke: boolean): number => ( + nativeArtifactSmoke + ? NATIVE_SMOKE_DEEP_LINK_ACKNOWLEDGEMENT_TIMEOUT_MS + : DEFAULT_DEEP_LINK_ACKNOWLEDGEMENT_TIMEOUT_MS +); + export interface DeepLinkWindow { isDestroyed(): boolean; webContents: { isLoading(): boolean; - send(channel: string, value: string): void; + send(channel: string, value: DesktopDeepLinkDelivery): void; }; } /** Coordinates protocol delivery across the window creation/load boundary. */ export class DeepLinkDelivery { private window: TWindow | null = null; + private readonly recentlyAccepted = new Map(); + private deliveryId = 0; + private draining = false; + private active: { + acknowledged: boolean; + delivery: DesktopDeepLinkDelivery; + resolve: (consumption: DesktopDeepLinkConsumption) => void; + timer: ReturnType; + window: TWindow; + } | null = null; + private readonly idleWaiters = new Set<() => void>(); constructor( private readonly channel: string, private readonly pending: string[] = [], - ) {} + private readonly delivered: ( + value: string, + consumption: DesktopDeepLinkConsumption, + window: TWindow, + ) => void | Promise = () => undefined, + private readonly failed: (error: Error) => void = () => undefined, + private readonly now: () => number = Date.now, + private readonly duplicateWindowMs = 1_000, + private readonly acknowledgementTimeoutMs = DEFAULT_DEEP_LINK_ACKNOWLEDGEMENT_TIMEOUT_MS, + ) { + if (!Number.isFinite(duplicateWindowMs) || duplicateWindowMs < 0 + || !Number.isFinite(acknowledgementTimeoutMs) || acknowledgementTimeoutMs <= 0) { + throw new Error('Desktop deep-link timing configuration is invalid'); + } + const uniquePending = [...new Set(pending)]; + pending.splice(0, pending.length, ...uniquePending); + const acceptedAt = this.now(); + uniquePending.forEach(value => this.recentlyAccepted.set(value, acceptedAt)); + } - deliver(value: string): void { - if (!this.window || this.window.isDestroyed() || this.window.webContents.isLoading()) { - this.pending.push(value); - return; + deliver(value: string): boolean { + const acceptedAt = this.now(); + const previous = this.recentlyAccepted.get(value); + if (previous !== undefined && acceptedAt - previous <= this.duplicateWindowMs) return false; + this.recentlyAccepted.set(value, acceptedAt); + for (const [candidate, time] of this.recentlyAccepted) { + if (acceptedAt - time > this.duplicateWindowMs) this.recentlyAccepted.delete(candidate); } - this.window.webContents.send(this.channel, value); + this.pending.push(value); + void this.drain(); + return true; } didFinishLoad(window: TWindow): void { @@ -29,16 +78,83 @@ export class DeepLinkDelivery { setWindow(window: TWindow): void { this.window = window; - this.flush(window); + void this.drain(); } clearWindow(window: TWindow): void { if (this.window === window) this.window = null; } - private flush(window: TWindow): void { - if (window.isDestroyed() || window.webContents.isLoading()) return; - const linksToDeliver = this.pending.splice(0); - linksToDeliver.forEach(value => window.webContents.send(this.channel, value)); + acknowledge(window: TWindow, acknowledgement: DesktopDeepLinkAcknowledgement): boolean { + if (!this.active || this.active.acknowledged || this.active.window !== window + || acknowledgement.deliveryId !== this.active.delivery.deliveryId + || acknowledgement.url !== this.active.delivery.url) return false; + this.active.acknowledged = true; + clearTimeout(this.active.timer); + this.active.resolve(acknowledgement.consumption); + return true; + } + + acknowledgeSender(sender: unknown, acknowledgement: DesktopDeepLinkAcknowledgement): boolean { + if (!this.active || this.active.window.webContents !== sender) return false; + return this.acknowledge(this.active.window, acknowledgement); + } + + whenIdle(): Promise { + if (!this.draining && !this.active && this.pending.length === 0) return Promise.resolve(); + return new Promise(resolve => this.idleWaiters.add(resolve)); + } + + private flush(_window: TWindow): void { + void this.drain(); + } + + private async drain(): Promise { + if (this.draining) return; + this.draining = true; + try { + while (this.pending.length > 0) { + const window = this.window; + if (!window || window.isDestroyed() || window.webContents.isLoading()) return; + const value = this.pending.shift(); + if (value === undefined) return; + const delivery = { deliveryId: ++this.deliveryId, url: value }; + let resolveAcknowledgement!: (value: DesktopDeepLinkConsumption) => void; + let rejectAcknowledgement!: (error: Error) => void; + const acknowledgement = new Promise((resolve, reject) => { + resolveAcknowledgement = resolve; + rejectAcknowledgement = reject; + }); + const timer = setTimeout( + () => rejectAcknowledgement(new Error('Desktop renderer deep-link acknowledgement deadline expired')), + this.acknowledgementTimeoutMs, + ); + this.active = { + acknowledged: false, + delivery, + resolve: resolveAcknowledgement, + timer, + window, + }; + window.webContents.send(this.channel, delivery); + try { + const consumption = await acknowledgement; + await this.delivered(value, consumption, window); + } catch (error) { + this.pending.splice(0); + this.failed(error instanceof Error ? error : new Error('Desktop renderer deep-link acknowledgement failed')); + return; + } finally { + clearTimeout(timer); + this.active = null; + } + } + } finally { + this.draining = false; + if (!this.active && this.pending.length === 0) { + this.idleWaiters.forEach(resolve => resolve()); + this.idleWaiters.clear(); + } + } } } diff --git a/apps/desktop/src/deep-link-failure-policy.test.ts b/apps/desktop/src/deep-link-failure-policy.test.ts new file mode 100644 index 000000000..19d2a256f --- /dev/null +++ b/apps/desktop/src/deep-link-failure-policy.test.ts @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { handleDeepLinkDeliveryFailure } from './deep-link-failure-policy'; + +describe('desktop deep-link failure policy', () => { + const exercise = (nativeArtifactSmoke: boolean) => { + const exits: number[] = []; + const logs: Array<{ event: string; fields: Readonly> }> = []; + handleDeepLinkDeliveryFailure(nativeArtifactSmoke, { + exit: code => { exits.push(code); }, + log: (_level, event, fields) => { logs.push({ event, fields }); }, + }); + return { exits, logs }; + }; + + it('is fatal when native artifact smoke loses renderer acknowledgement', () => { + const result = exercise(true); + assert.deepEqual(result.exits, [1]); + assert.deepEqual(result.logs, [ + { + event: 'desktop.deeplink.delivery_failed', + fields: { failure: 'renderer_acknowledgement' }, + }, + { + event: 'desktop.app.start_failed', + fields: { failure: 'renderer_acknowledgement' }, + }, + ]); + }); + + it('logs a fixed non-secret diagnostic without exiting normal production', () => { + const result = exercise(false); + assert.deepEqual(result.exits, []); + assert.deepEqual(result.logs, [{ + event: 'desktop.deeplink.delivery_failed', + fields: { failure: 'renderer_acknowledgement' }, + }]); + assert.equal(JSON.stringify(result), JSON.stringify(result).slice(0, 512)); + }); + + it('does not crash or exit production when the diagnostic sink fails', () => { + const exits: number[] = []; + assert.doesNotThrow(() => handleDeepLinkDeliveryFailure(false, { + exit: code => { exits.push(code); }, + log: () => { throw new Error('secret-bearing logger failure'); }, + })); + assert.deepEqual(exits, []); + }); +}); diff --git a/apps/desktop/src/deep-link-failure-policy.ts b/apps/desktop/src/deep-link-failure-policy.ts new file mode 100644 index 000000000..8615d80f2 --- /dev/null +++ b/apps/desktop/src/deep-link-failure-policy.ts @@ -0,0 +1,29 @@ +export interface DeepLinkFailurePolicyActions { + exit(code: number): void; + log( + level: 'error', + event: string, + fields: Readonly>, + ): void; +} + +/** Keeps renderer acknowledgement failures observable without exposing the link or renderer output. */ +export const handleDeepLinkDeliveryFailure = ( + nativeArtifactSmoke: boolean, + actions: DeepLinkFailurePolicyActions, +): void => { + const fields = { failure: 'renderer_acknowledgement' } as const; + try { + actions.log('error', 'desktop.deeplink.delivery_failed', fields); + } catch { + // A diagnostic sink must not turn an ordinary production delivery failure into a crash. + } + if (nativeArtifactSmoke) { + try { + actions.log('error', 'desktop.app.start_failed', fields); + } catch { + // Native evidence remains fail-closed even if its diagnostic sink is unavailable. + } + actions.exit(1); + } +}; diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index 93245534b..1932d2b1c 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -6,6 +6,7 @@ import type { LocalLifecycleController } from './lifecycle'; import type { ProfileStore } from './profile-store'; import { isSafeExternalUrl, isTrustedRendererUrl } from './security'; import { IPC_CHANNELS } from './shared/contract'; +import type { DesktopDeepLinkAcknowledgement } from './shared/contract'; interface RegisterIpcOptions { app: App; @@ -16,6 +17,7 @@ interface RegisterIpcOptions { desktopSession: Session; devServerUrl: string | undefined; packagedRendererUrl: string; + acknowledgeDeepLink: (event: IpcMainInvokeEvent, acknowledgement: DesktopDeepLinkAcknowledgement) => boolean; } type Handler = (event: IpcMainInvokeEvent, ...args: any[]) => unknown; @@ -47,6 +49,20 @@ export const registerIpcHandlers = (options: RegisterIpcOptions): void => { arch: process.arch, packaged: options.app.isPackaged, })); + handle(IPC_CHANNELS.deepLinkAcknowledgement, (event, acknowledgement) => { + if (!acknowledgement || typeof acknowledgement !== 'object' + || !Number.isSafeInteger(acknowledgement.deliveryId) || acknowledgement.deliveryId <= 0 + || typeof acknowledgement.url !== 'string' + || !acknowledgement.consumption || typeof acknowledgement.consumption !== 'object' + || !['connect-confirmation', 'open-queued', 'open-navigated'].includes(acknowledgement.consumption.kind) + || typeof acknowledgement.consumption.target !== 'string') { + throw new Error('Invalid desktop deep-link acknowledgement'); + } + if (!options.acknowledgeDeepLink(event, acknowledgement)) { + throw new Error('Unexpected desktop deep-link acknowledgement'); + } + return undefined; + }); handle(IPC_CHANNELS.authLogout, (_event, apiBaseUrl) => logoutDesktopSession(options.desktopSession, apiBaseUrl)); handle(IPC_CHANNELS.openExternal, async (_event, value: unknown) => { if (typeof value !== 'string' || !isSafeExternalUrl(value)) throw new Error('External URL is not allowed'); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 910c57b46..de6577a3f 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -4,7 +4,8 @@ import { pathToFileURL } from 'node:url'; import { app, BrowserWindow, ipcMain, net, protocol, safeStorage, screen, session, shell } from 'electron'; import type { Rectangle } from 'electron'; import { DESKTOP_RENDERER_ORIGIN } from '@propr/shared'; -import { DeepLinkDelivery } from './deep-link-delivery'; +import { DeepLinkDelivery, deepLinkAcknowledgementTimeoutMs } from './deep-link-delivery'; +import { handleDeepLinkDeliveryFailure } from './deep-link-failure-policy'; import { registerIpcHandlers } from './ipc'; import { LocalLifecycleController } from './lifecycle'; import { createDesktopLogger, type DesktopLogger } from './logger'; @@ -19,9 +20,11 @@ import { validatedDevServerUrl, } from './security'; import { DESKTOP_PROTOCOL, IPC_CHANNELS } from './shared/contract'; +import type { DesktopDeepLinkConsumption } from './shared/contract'; import { checkForSignedUpdates } from './signed-updates'; import { authorizePackagedSmokeTest } from './smoke-test-authorization'; import { createPackagedSmokeEvidenceSink } from './smoke-test-evidence'; +import { configureNativeSmokeLogsPath } from './smoke-log-path'; import { createBrowserWindowOptions, MINIMUM_BROWSER_WINDOW_SIZE, @@ -35,10 +38,17 @@ const PACKAGED_RENDERER_SCHEME = 'propr-app'; const PACKAGED_RENDERER_HOST = 'renderer'; const PACKAGED_LAYOUT_READY_EVENT = 'desktop.renderer.layout.ready'; const PACKAGED_REDUCED_NATIVE_WINDOW_READY_EVENT = 'desktop.native.reduced_window.ready'; +const NATIVE_COLD_MANUAL_LINK = 'propr://connect?api=http%3A%2F%2Flocalhost%3A44111'; +const NATIVE_COLD_TUNNEL_LINK = 'propr://connect?api=https%3A%2F%2Ft-native-relaunch.propr.dev'; +const NATIVE_WARM_MANUAL_LINK = 'propr://connect?api=http%3A%2F%2F127.0.0.1%3A44112'; +const NATIVE_WARM_TUNNEL_LINK = 'propr://connect?api=https%3A%2F%2Ft-native-evidence.propr.dev'; +const NATIVE_WARM_OPEN_LINK = 'propr://open?path=%2Ftasks%3Fstatus%3Dopen'; +type NativeSmokePhase = 'first' | 'relaunch'; const packagedRendererRoot = join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}`); const packagedRendererUrl = `${DESKTOP_RENDERER_ORIGIN}/renderer.html`; let packagedSmokeUserDataDirectory: string | null = null; let packagedSmokeEvidence: ReturnType = null; +let nativeSmokePhase: NativeSmokePhase | undefined; try { packagedSmokeUserDataDirectory = authorizePackagedSmokeTest({ argv: process.argv, @@ -48,12 +58,27 @@ try { platform: process.platform, }); if (packagedSmokeUserDataDirectory) { + const requestedNativePhase = process.env.PROPR_DESKTOP_NATIVE_ARTIFACT_PHASE; + if (requestedNativePhase !== undefined) { + if (requestedNativePhase !== 'first' && requestedNativePhase !== 'relaunch') { + throw new Error('Packaged desktop native artifact phase is invalid'); + } + nativeSmokePhase = requestedNativePhase; + } const smokeDirectoryStats = lstatSync(packagedSmokeUserDataDirectory); if (!smokeDirectoryStats.isDirectory() || smokeDirectoryStats.isSymbolicLink()) { throw new Error('Packaged desktop smoke --user-data-dir must be an existing non-link directory'); } app.setPath('userData', packagedSmokeUserDataDirectory); - packagedSmokeEvidence = createPackagedSmokeEvidenceSink(packagedSmokeUserDataDirectory); + configureNativeSmokeLogsPath({ + app, + authorizedNativeSmoke: nativeSmokePhase !== undefined, + platform: process.platform, + userDataDirectory: packagedSmokeUserDataDirectory, + }); + packagedSmokeEvidence = nativeSmokePhase + ? createPackagedSmokeEvidenceSink(packagedSmokeUserDataDirectory, nativeSmokePhase) + : createPackagedSmokeEvidenceSink(packagedSmokeUserDataDirectory); packagedSmokeEvidence?.write('desktop.smoke.authorized'); } } catch { @@ -61,10 +86,78 @@ try { } const packagedSmokeTest = packagedSmokeUserDataDirectory !== null; let mainWindow: BrowserWindow | null = null; +let nativeSmokeWindow: BrowserWindow | null = null; const initialDeepLink = deepLinkFromArguments(process.argv); +const nativeObservedEvents = new Set(); +let nativeRendererReady = false; +let nativeCompletionStarted = false; +let nativeProfiles: ProfileStore | null = null; +const recordNativeEvent = (event: string): void => { + if (!nativeSmokePhase) return; + if (event.endsWith('_once') && nativeObservedEvents.has(event)) { + packagedSmokeEvidence?.write('desktop.app.start_failed'); + app.exit(1); + return; + } + nativeObservedEvents.add(event); + packagedSmokeEvidence?.write(event); +}; +const nativeEventForDeliveredLink = (value: string): string | null => { + if (nativeSmokePhase === 'first') { + if (value === NATIVE_COLD_MANUAL_LINK) return 'desktop.deeplink.cold_manual_once'; + if (value === NATIVE_WARM_MANUAL_LINK) return 'desktop.deeplink.warm_manual_once'; + if (value === NATIVE_WARM_TUNNEL_LINK) return 'desktop.deeplink.warm_tunnel_once'; + if (value === NATIVE_WARM_OPEN_LINK) return 'desktop.deeplink.warm_open_once'; + } + if (nativeSmokePhase === 'relaunch' && value === NATIVE_COLD_TUNNEL_LINK) { + return 'desktop.deeplink.cold_tunnel_once'; + } + return null; +}; +const assertNativeRendererConsumption = ( + value: string, + consumption: DesktopDeepLinkConsumption, + window: BrowserWindow, +): void => { + const expected = value === NATIVE_COLD_MANUAL_LINK + ? { kind: 'connect-confirmation', target: 'http://localhost:44111' } + : value === NATIVE_COLD_TUNNEL_LINK + ? { kind: 'connect-confirmation', target: 'https://t-native-relaunch.propr.dev' } + : value === NATIVE_WARM_MANUAL_LINK + ? { kind: 'connect-confirmation', target: 'http://127.0.0.1:44112' } + : value === NATIVE_WARM_TUNNEL_LINK + ? { kind: 'connect-confirmation', target: 'https://t-native-evidence.propr.dev' } + : value === NATIVE_WARM_OPEN_LINK + ? { kind: 'open-queued', target: '/tasks?status=open' } + : null; + if (!expected) return; + if (consumption.kind !== expected.kind || consumption.target !== expected.target) { + throw new Error('Native renderer deep-link acknowledgement did not prove the intended state'); + } + if (nativeSmokePhase + && [NATIVE_WARM_MANUAL_LINK, NATIVE_WARM_TUNNEL_LINK, NATIVE_WARM_OPEN_LINK].includes(value) + && nativeSmokeWindow !== window) { + throw new Error('Native warm deep link did not reach the already-running renderer'); + } +}; const deepLinkDelivery = new DeepLinkDelivery( IPC_CHANNELS.deepLink, initialDeepLink ? [initialDeepLink] : [], + (value, consumption, window) => { + assertNativeRendererConsumption(value, consumption, window); + const event = nativeEventForDeliveredLink(value); + if (event) recordNativeEvent(event); + maybeCompleteNativeFirstLaunch(); + }, + _error => { + handleDeepLinkDeliveryFailure(nativeSmokePhase !== undefined, { + exit: code => app.exit(code), + log, + }); + }, + Date.now, + 1_000, + deepLinkAcknowledgementTimeoutMs(nativeSmokePhase !== undefined), ); let logger: DesktopLogger | null = null; let shutdownStarted = false; @@ -107,6 +200,62 @@ const deliverDeepLink = (value: string): void => { deepLinkDelivery.deliver(value); }; +function maybeCompleteNativeFirstLaunch(): void { + const required = [ + 'desktop.deeplink.cold_manual_once', + 'desktop.deeplink.warm_manual_once', + 'desktop.deeplink.warm_tunnel_once', + 'desktop.deeplink.warm_open_once', + 'desktop.deeplink.rejected_malformed', + 'desktop.deeplink.rejected_oversized', + 'desktop.deeplink.rejected_unsafe_scheme', + ]; + if (nativeSmokePhase !== 'first' || !nativeRendererReady || nativeCompletionStarted + || !required.every(event => nativeObservedEvents.has(event)) || !mainWindow || !nativeProfiles) return; + nativeCompletionStarted = true; + const window = mainWindow; + void (async () => { + const uiRequiresConfirmation = await window.webContents.executeJavaScript(`(async () => { + const deadline = performance.now() + 2000; + do { + const labels = Array.from(document.querySelectorAll('.desktop-connection-card form > label')); + if (labels[1]?.querySelector('input')?.value === 'https://t-native-evidence.propr.dev') return true; + await new Promise(resolve => setTimeout(resolve, 25)); + } while (performance.now() < deadline); + return false; + })()`); + const stored = await nativeProfiles?.list(); + const deepLinkedEndpoints = new Set([ + 'http://localhost:44111', + 'http://127.0.0.1:44112', + 'https://t-native-evidence.propr.dev', + ]); + if (!uiRequiresConfirmation || stored?.activeProfileId !== null || stored?.profiles.length !== 2 + || stored.profiles.some(profile => deepLinkedEndpoints.has(profile.apiBaseUrl))) { + throw new Error('Native deep-link endpoint was trusted without UI confirmation'); + } + recordNativeEvent('desktop.deeplink.confirmation_required'); + app.quit(); + })().catch(error => { + log('error', 'desktop.app.start_failed', { error }); + app.exit(1); + }); +} + +const recordNativeRejectedArguments = (argv: readonly string[]): void => { + if (nativeSmokePhase !== 'first') return; + if (argv.includes('native-evidence-malformed')) { + recordNativeEvent('desktop.deeplink.rejected_malformed'); + } + if (argv.includes('https://native-evidence.invalid/unsafe')) { + recordNativeEvent('desktop.deeplink.rejected_unsafe_scheme'); + } + if (argv.some(value => value.length > 2_048 && value.startsWith('propr://connect?api='))) { + recordNativeEvent('desktop.deeplink.rejected_oversized'); + } + maybeCompleteNativeFirstLaunch(); +}; + const configureSessionSecurity = (): void => { const desktopSession = session.defaultSession; desktopSession.setPermissionCheckHandler(() => false); @@ -278,7 +427,9 @@ const createMainWindow = async (): Promise => { if (preloadBridgeExposed !== true) { throw new Error('Desktop preload bridge was not exposed to the renderer'); } + if (nativeSmokePhase && !nativeSmokeWindow) nativeSmokeWindow = window; deepLinkDelivery.setWindow(window); + if (nativeSmokePhase) await deepLinkDelivery.whenIdle(); const smokeProfileApiUrl = process.env.PROPR_DESKTOP_SMOKE_PROFILE_API_URL; if (packagedSmokeTest && smokeProfileApiUrl) { const normalizedSmokeApiUrl = normalizeApiBaseUrl(smokeProfileApiUrl); @@ -304,7 +455,79 @@ const createMainWindow = async (): Promise => { } log('info', 'desktop.renderer.profile_api.ready', { origin: DESKTOP_RENDERER_ORIGIN }); } - if (packagedSmokeTest) { + if (nativeSmokePhase) { + const profiles = await nativeProfiles?.list(); + const expectedInitialApi = nativeSmokePhase === 'first' + ? 'http://localhost:44111' + : 'https://t-native-relaunch.propr.dev'; + const initialEndpointVisible = await window.webContents.executeJavaScript(`(async () => { + const deadline = performance.now() + 2000; + do { + const labels = Array.from(document.querySelectorAll('.desktop-connection-card form > label')); + if (labels[1]?.querySelector('input')?.value === ${JSON.stringify(expectedInitialApi)}) return true; + await new Promise(resolve => setTimeout(resolve, 25)); + } while (performance.now() < deadline); + return false; + })()`); + if (!initialEndpointVisible || !profiles) { + throw new Error('Native cold deep link did not reach the confirmation UI'); + } + if (nativeSmokePhase === 'first') { + if (profiles.profiles.length !== 0 || profiles.activeProfileId !== null) { + throw new Error('Native first launch did not start with an isolated profile'); + } + await nativeProfiles?.save({ + id: 'native-local', + label: 'Preserved local profile', + apiBaseUrl: 'http://localhost:44221', + }); + await nativeProfiles?.save({ + id: 'native-tunnel', + label: 'Native ProPR Connect tunnel', + apiBaseUrl: 'https://t-preserved.propr.dev', + }); + recordNativeEvent('desktop.native.secure_storage_probe.started'); + const storage = nativeProfiles?.security(); + const credentialWrite = await nativeProfiles?.writeCredential('native-local', 'native-custody-probe'); + if (!storage || !credentialWrite) throw new Error('Native secure-storage custody probe did not run'); + if (process.platform === 'linux') { + if (storage.available || credentialWrite.stored + || (await nativeProfiles?.readCredential('native-local'))?.available !== false) { + throw new Error('Native Linux fallback-only proof unexpectedly claimed libsecret custody'); + } + recordNativeEvent('desktop.native.secure_storage_fallback_refused'); + } else if (storage.available) { + if (storage.backend === 'basic_text' || !credentialWrite.stored + || (await nativeProfiles?.readCredential('native-local'))?.value !== 'native-custody-probe') { + throw new Error('Native secure-storage custody probe did not use OS encryption'); + } + await nativeProfiles?.removeCredential('native-local'); + if ((await nativeProfiles?.readCredential('native-local'))?.value !== null) { + throw new Error('Native secure-storage custody probe cleanup failed'); + } + } else if (credentialWrite.stored + || (await nativeProfiles?.readCredential('native-local'))?.available !== false) { + throw new Error('Native secure-storage custody probe allowed plaintext fallback'); + } + if (process.platform === 'darwin' && (!storage.available || storage.backend !== 'os-protected')) { + throw new Error('Native macOS artifact did not retain Keychain-backed custody'); + } + recordNativeEvent('desktop.native.secure_storage_probe.completed'); + recordNativeEvent('desktop.native.secure_storage_enforced'); + recordNativeEvent('desktop.native.profile_fresh'); + } else { + const expected = new Map([ + ['native-local', 'http://localhost:44221'], + ['native-tunnel', 'https://t-preserved.propr.dev'], + ]); + if (profiles.activeProfileId !== null || profiles.profiles.length !== expected.size + || profiles.profiles.some(profile => expected.get(profile.id) !== profile.apiBaseUrl)) { + throw new Error('Native relaunch did not preserve the non-secret profile state exactly'); + } + recordNativeEvent('desktop.native.profile_preserved'); + recordNativeEvent('desktop.deeplink.confirmation_required'); + } + } else if (packagedSmokeTest) { const profileFlow = await window.webContents.executeJavaScript(`(async () => { const bridge = window.proprDesktop; const local = await bridge.profiles.save({ label: 'Local setup', apiBaseUrl: 'http://localhost:4000' }); @@ -339,7 +562,9 @@ const createMainWindow = async (): Promise => { }); } log('info', 'desktop.renderer.ready', { preloadBridgeExposed: true }); - if (packagedSmokeTest) { + nativeRendererReady = true; + maybeCompleteNativeFirstLaunch(); + if (packagedSmokeTest && nativeSmokePhase !== 'first') { app.quit(); } else { window.show(); @@ -360,6 +585,7 @@ if (!hasSingleInstanceLock) { app.on('second-instance', (_event, argv) => { const deepLink = deepLinkFromArguments(argv); if (deepLink) deliverDeepLink(deepLink); + else recordNativeRejectedArguments(argv); if (mainWindow) { if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.show(); @@ -374,6 +600,16 @@ if (!hasSingleInstanceLock) { () => packagedSmokeEvidence?.write('desktop.log.write_failed'), ); log('info', 'desktop.app.ready', { version: app.getVersion(), platform: process.platform }); + if (nativeSmokePhase) { + const expectedVersion = process.env.PROPR_DESKTOP_NATIVE_EXPECTED_VERSION; + const expectedPlatform = process.env.PROPR_DESKTOP_NATIVE_EXPECTED_PLATFORM; + const expectedArch = process.env.PROPR_DESKTOP_NATIVE_EXPECTED_ARCH; + if (!expectedVersion || app.getVersion() !== expectedVersion + || expectedPlatform !== process.platform || expectedArch !== process.arch) { + throw new Error('Native artifact application identity, version, or architecture mismatch'); + } + recordNativeEvent('desktop.native.identity_verified'); + } configureSessionSecurity(); configurePackagedRendererProtocol(); @@ -391,6 +627,7 @@ if (!hasSingleInstanceLock) { decrypt: value => safeStorage.decryptString(value), }; const profiles = new ProfileStore(app.getPath('userData'), encryption); + nativeProfiles = profiles; const lifecycle = new LocalLifecycleController(); registerIpcHandlers({ app, @@ -401,6 +638,8 @@ if (!hasSingleInstanceLock) { desktopSession: session.defaultSession, devServerUrl, packagedRendererUrl, + acknowledgeDeepLink: (event, acknowledgement) => + deepLinkDelivery.acknowledgeSender(event.sender, acknowledgement), }); app.on('before-quit', event => { diff --git a/apps/desktop/src/preload-bridge.test.ts b/apps/desktop/src/preload-bridge.test.ts index 81db36bef..32a7e03b0 100644 --- a/apps/desktop/src/preload-bridge.test.ts +++ b/apps/desktop/src/preload-bridge.test.ts @@ -5,18 +5,18 @@ import { IPC_CHANNELS } from './shared/contract'; class FakeIpc implements PreloadIpc { readonly invocations: Array<{ channel: string; args: unknown[] }> = []; - readonly listeners = new Map void>(); + readonly listeners = new Map void>(); async invoke(channel: string, ...args: unknown[]): Promise { this.invocations.push({ channel, args }); return undefined; } - on(channel: string, listener: (event: unknown, value: string) => void): void { + on(channel: string, listener: (event: unknown, value: unknown) => void): void { this.listeners.set(channel, listener); } - removeListener(channel: string, listener: (event: unknown, value: string) => void): void { + removeListener(channel: string, listener: (event: unknown, value: unknown) => void): void { if (this.listeners.get(channel) === listener) this.listeners.delete(channel); } } @@ -53,9 +53,23 @@ describe('desktop preload bridge', () => { const ipc = new FakeIpc(); const bridge = createDesktopBridge(ipc); const received: string[] = []; - const unsubscribe = bridge.app.onDeepLink(value => received.push(value)); - ipc.listeners.get(IPC_CHANNELS.deepLink)?.({ sender: 'must-not-leak' }, 'propr://open?path=%2Ftasks'); + const unsubscribe = bridge.app.onDeepLink(value => { + received.push(value); + return { kind: 'open-queued', target: '/tasks' }; + }); + ipc.listeners.get(IPC_CHANNELS.deepLink)?.({ sender: 'must-not-leak' }, { + deliveryId: 1, + url: 'propr://open?path=%2Ftasks', + }); assert.deepEqual(received, ['propr://open?path=%2Ftasks']); + assert.deepEqual(ipc.invocations, [{ + channel: IPC_CHANNELS.deepLinkAcknowledgement, + args: [{ + deliveryId: 1, + url: 'propr://open?path=%2Ftasks', + consumption: { kind: 'open-queued', target: '/tasks' }, + }], + }]); unsubscribe(); assert.equal(ipc.listeners.has(IPC_CHANNELS.deepLink), true); }); @@ -66,11 +80,16 @@ describe('desktop preload bridge', () => { const receiveDeepLink = ipc.listeners.get(IPC_CHANNELS.deepLink); assert.ok(receiveDeepLink, 'preload must register its IPC listener eagerly'); - receiveDeepLink({}, 'propr://connect?api=http%3A%2F%2Flocalhost%3A4000'); - receiveDeepLink({}, 'propr://open?path=%2Ftasks'); + receiveDeepLink({}, { deliveryId: 1, url: 'propr://connect?api=http%3A%2F%2Flocalhost%3A4000' }); + receiveDeepLink({}, { deliveryId: 2, url: 'propr://open?path=%2Ftasks' }); const received: string[] = []; - bridge.app.onDeepLink(value => received.push(value)); + bridge.app.onDeepLink(value => { + received.push(value); + return value.includes('connect') + ? { kind: 'connect-confirmation', target: 'http://localhost:4000' } + : { kind: 'open-queued', target: '/tasks' }; + }); assert.deepEqual(received, [ 'propr://connect?api=http%3A%2F%2Flocalhost%3A4000', 'propr://open?path=%2Ftasks', diff --git a/apps/desktop/src/preload-bridge.ts b/apps/desktop/src/preload-bridge.ts index 3bba8300e..917635ede 100644 --- a/apps/desktop/src/preload-bridge.ts +++ b/apps/desktop/src/preload-bridge.ts @@ -1,24 +1,47 @@ -import type { DesktopBridge } from './shared/contract'; +import type { + DesktopBridge, + DesktopDeepLinkAcknowledgement, + DesktopDeepLinkConsumption, + DesktopDeepLinkDelivery, +} from './shared/contract'; import { IPC_CHANNELS } from './shared/contract'; export interface PreloadIpc { invoke(channel: string, ...args: unknown[]): Promise; - on(channel: string, listener: (event: unknown, value: string) => void): void; - removeListener(channel: string, listener: (event: unknown, value: string) => void): void; + on(channel: string, listener: (event: unknown, value: unknown) => void): void; + removeListener(channel: string, listener: (event: unknown, value: unknown) => void): void; } const invoke = (ipc: PreloadIpc, channel: string, ...args: unknown[]): Promise => ipc.invoke(channel, ...args) as Promise; export const createDesktopBridge = (ipc: PreloadIpc): DesktopBridge => { - const deepLinkListeners = new Set<(url: string) => void>(); - const pendingDeepLinks: string[] = []; + const deepLinkListeners = new Set<(url: string) => DesktopDeepLinkConsumption | null>(); + const pendingDeepLinks: DesktopDeepLinkDelivery[] = []; + const isDelivery = (value: unknown): value is DesktopDeepLinkDelivery => Boolean( + value && typeof value === 'object' + && Number.isSafeInteger((value as DesktopDeepLinkDelivery).deliveryId) + && (value as DesktopDeepLinkDelivery).deliveryId > 0 + && typeof (value as DesktopDeepLinkDelivery).url === 'string', + ); + const consume = (delivery: DesktopDeepLinkDelivery): void => { + const acknowledgements = [...deepLinkListeners] + .map(listener => listener(delivery.url)) + .filter((value): value is DesktopDeepLinkConsumption => value !== null); + if (acknowledgements.length !== 1) return; + const acknowledgement: DesktopDeepLinkAcknowledgement = { + ...delivery, + consumption: acknowledgements[0], + }; + void invoke(ipc, IPC_CHANNELS.deepLinkAcknowledgement, acknowledgement).catch(() => undefined); + }; ipc.on(IPC_CHANNELS.deepLink, (_event, value) => { + if (!isDelivery(value)) return; if (deepLinkListeners.size === 0) { pendingDeepLinks.push(value); return; } - deepLinkListeners.forEach(listener => listener(value)); + consume(value); }); const bridge: DesktopBridge = { @@ -26,7 +49,7 @@ export const createDesktopBridge = (ipc: PreloadIpc): DesktopBridge => { getMetadata: () => invoke(ipc, IPC_CHANNELS.appMetadata), onDeepLink: (listener) => { deepLinkListeners.add(listener); - pendingDeepLinks.splice(0).forEach(value => listener(value)); + pendingDeepLinks.splice(0).forEach(consume); return () => deepLinkListeners.delete(listener); }, }, diff --git a/apps/desktop/src/release-workflow.test.ts b/apps/desktop/src/release-workflow.test.ts index bf0a28a03..d716216d0 100644 --- a/apps/desktop/src/release-workflow.test.ts +++ b/apps/desktop/src/release-workflow.test.ts @@ -26,6 +26,10 @@ const verifyDarwinImage = normalizeWorkflowText(readFileSync( fileURLToPath(new URL('../scripts/verify-darwin-image.mjs', import.meta.url)), 'utf8', )); +const nativeArtifactLifecycle = normalizeWorkflowText(readFileSync( + fileURLToPath(new URL('../scripts/test-native-artifact-lifecycle.mjs', import.meta.url)), + 'utf8', +)); const releasePreflight = normalizeWorkflowText(readFileSync( fileURLToPath(new URL('../scripts/release-preflight.mjs', import.meta.url)), 'utf8', @@ -317,6 +321,33 @@ describe('desktop trusted release workflow', () => { ); }); + test('exercises every staged Linux and macOS format through the native lifecycle gate', () => { + const validation = job('package', 'finalize'); + const stage = validation.indexOf('Stage architecture-verified validation artifacts with native DMG mount evidence'); + const lifecycle = validation.indexOf('Exercise staged native install, deep-link, relaunch, and removal lifecycle'); + const upload = validation.indexOf('Upload unsigned validation target'); + assert.ok(stage >= 0 && lifecycle > stage && upload > lifecycle); + assert.match(validation, /if: matrix\.platform == 'linux' \|\| matrix\.platform == 'darwin'/); + assert.match(validation, /dbus-run-session -- xvfb-run --auto-servernum/); + assert.equal(validation.match(/test-native-artifact-lifecycle\.mjs/g)?.length, 2); + assert.match(validation, /--artifact-directory "desktop-release-\$\{\{ matrix\.platform \}\}-\$\{\{ matrix\.arch \}\}"/); + assert.match(forgeConfig, /mimeType: \['x-scheme-handler\/propr'\]/); + + for (const kind of ['deb', 'rpm', 'zip', 'dmg']) { + assert.ok(nativeArtifactLifecycle.includes(`kind === '${kind}'`) || nativeArtifactLifecycle.includes(`kind !== '${kind}'`)); + } + for (const linkEvidence of [ + 'cold_manual_once', 'cold_tunnel_once', 'warm_manual_once', 'warm_tunnel_once', 'warm_open_once', + 'rejected_malformed', 'rejected_oversized', 'rejected_unsafe_scheme', 'confirmation_required', + ]) assert.ok(nativeArtifactLifecycle.includes(`desktop.deeplink.${linkEvidence}`)); + assert.match(nativeArtifactLifecycle, /inspectArtifactArchitecture/); + assert.match(nativeArtifactLifecycle, /assertSafeExtractedTree/); + assert.match(nativeArtifactLifecycle, /assertProfileAuthority/); + assert.match(nativeArtifactLifecycle, /LaunchServices-registration\+open-exact-application-dispatch/); + assert.match(nativeArtifactLifecycle, /xdg-mime-registration\+gio-dispatch/); + assert.doesNotMatch(nativeArtifactLifecycle, /xattr|spctl|--no-sandbox|--disable-sandbox/); + }); + test('keeps both Windows architectures and the complete machine-scope installer contract mandatory', () => { for (const [jobName, section] of [ diff --git a/apps/desktop/src/shared/contract.ts b/apps/desktop/src/shared/contract.ts index f34d23298..95d4e32ed 100644 --- a/apps/desktop/src/shared/contract.ts +++ b/apps/desktop/src/shared/contract.ts @@ -17,8 +17,23 @@ export const IPC_CHANNELS = Object.freeze({ lifecycleStop: 'desktop:lifecycle-stop', lifecycleRestart: 'desktop:lifecycle-restart', deepLink: 'desktop:deep-link', + deepLinkAcknowledgement: 'desktop:deep-link-acknowledgement', } as const); +export interface DesktopDeepLinkDelivery { + deliveryId: number; + url: string; +} + +export type DesktopDeepLinkConsumption = { + kind: 'connect-confirmation' | 'open-queued' | 'open-navigated'; + target: string; +}; + +export interface DesktopDeepLinkAcknowledgement extends DesktopDeepLinkDelivery { + consumption: DesktopDeepLinkConsumption; +} + export type DesktopPlatform = 'aix' | 'android' | 'darwin' | 'freebsd' | 'haiku' | 'linux' | 'openbsd' | 'sunos' | 'win32' | 'cygwin' | 'netbsd'; @@ -80,7 +95,7 @@ export type LocalLifecycleOperationResult = export interface DesktopBridge { app: { getMetadata(): Promise; - onDeepLink(listener: (url: string) => void): () => void; + onDeepLink(listener: (url: string) => DesktopDeepLinkConsumption | null): () => void; }; auth: { logout(apiBaseUrl: string): Promise; diff --git a/apps/desktop/src/smoke-log-path.test.ts b/apps/desktop/src/smoke-log-path.test.ts new file mode 100644 index 000000000..5006e0eb8 --- /dev/null +++ b/apps/desktop/src/smoke-log-path.test.ts @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import { chmodSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { isAbsolute, join, relative } from 'node:path'; +import { describe, it } from 'node:test'; +import { createDesktopLogger } from './logger'; +import { configureNativeSmokeLogsPath } from './smoke-log-path'; + +const waitForFile = async (path: string): Promise => { + const deadline = Date.now() + 2_000; + while (Date.now() < deadline) { + try { + readFileSync(path); + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + await new Promise(resolve => setTimeout(resolve, 10)); + } + } + throw new Error('Timed out waiting for the smoke log file'); +}; + +describe('native smoke Electron logs path', () => { + it('leaves ordinary production and Windows paths unchanged', () => { + const calls: Array<[string, string]> = []; + const app = { setPath: (name: 'logs', path: string) => calls.push([name, path]) }; + assert.equal(configureNativeSmokeLogsPath({ + app, + authorizedNativeSmoke: false, + platform: 'darwin', + userDataDirectory: '/private/unused', + }), null); + assert.equal(configureNativeSmokeLogsPath({ + app, + authorizedNativeSmoke: true, + platform: 'win32', + userDataDirectory: 'C:\\unused', + }), null); + assert.deepEqual(calls, []); + }); + + it('keeps authorized Mac/Linux smoke logs at 0700/0600 inside the isolated profile', async () => { + for (const platform of ['darwin', 'linux'] as const) { + const profile = mkdtempSync(join(tmpdir(), 'propr-desktop-smoke-log-')); + const logs = join(profile, 'logs'); + const calls: Array<[string, string]> = []; + try { + mkdirSync(logs, { mode: 0o700 }); + chmodSync(logs, 0o700); + const configured = configureNativeSmokeLogsPath({ + app: { setPath: (name, path) => calls.push([name, path]) }, + authorizedNativeSmoke: true, + platform, + userDataDirectory: profile, + }); + assert.equal(configured, logs); + assert.deepEqual(calls, [['logs', logs]]); + assert.ok(configured); + + const log = join(configured, 'desktop.jsonl'); + createDesktopLogger(log).log('info', 'desktop.test'); + await waitForFile(log); + const logsFromProfile = relative(profile, configured); + assert.ok(logsFromProfile && !logsFromProfile.startsWith('..') && !isAbsolute(logsFromProfile)); + assert.equal(lstatSync(configured).mode & 0o777, 0o700); + assert.equal(lstatSync(log).mode & 0o777, 0o600); + } finally { + rmSync(profile, { recursive: true, force: true }); + } + } + }); +}); diff --git a/apps/desktop/src/smoke-log-path.ts b/apps/desktop/src/smoke-log-path.ts new file mode 100644 index 000000000..798cdb675 --- /dev/null +++ b/apps/desktop/src/smoke-log-path.ts @@ -0,0 +1,39 @@ +import { lstatSync, type Stats } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; + +type ElectronLogsPath = { + setPath(name: 'logs', path: string): void; +}; + +export const configureNativeSmokeLogsPath = ({ + app, + authorizedNativeSmoke, + platform, + userDataDirectory, + inspectDirectory = lstatSync, + currentUserId = typeof process.getuid === 'function' ? process.getuid() : undefined, +}: { + app: ElectronLogsPath; + authorizedNativeSmoke: boolean; + platform: NodeJS.Platform; + userDataDirectory: string; + inspectDirectory?: (path: string) => Stats; + currentUserId?: number; +}): string | null => { + if (!authorizedNativeSmoke || platform === 'win32') return null; + + const resolvedUserData = resolve(userDataDirectory); + const logsDirectory = join(resolvedUserData, 'logs'); + const logsFromUserData = relative(resolvedUserData, logsDirectory); + if (!logsFromUserData || logsFromUserData.startsWith('..')) { + throw new Error('Native smoke logs directory escaped the isolated user-data root'); + } + + const stats = inspectDirectory(logsDirectory); + if (!stats.isDirectory() || stats.isSymbolicLink() || (stats.mode & 0o777) !== 0o700 + || currentUserId === undefined || stats.uid !== currentUserId) { + throw new Error('Native smoke logs directory does not have owned 0700 authority'); + } + app.setPath('logs', logsDirectory); + return logsDirectory; +}; diff --git a/apps/desktop/src/smoke-test-authorization.test.ts b/apps/desktop/src/smoke-test-authorization.test.ts index 39058a3c1..bd730f8b3 100644 --- a/apps/desktop/src/smoke-test-authorization.test.ts +++ b/apps/desktop/src/smoke-test-authorization.test.ts @@ -101,8 +101,11 @@ describe('packaged smoke profile authorization', () => { const main = readFileSync(fileURLToPath(new URL('./main.ts', import.meta.url)), 'utf8'); const authorization = main.indexOf('authorizePackagedSmokeTest({'); const isolation = main.indexOf("app.setPath('userData', packagedSmokeUserDataDirectory)"); + const logsIsolation = main.indexOf('configureNativeSmokeLogsPath({', isolation); + const logsRead = main.indexOf("app.getPath('logs')"); assert.notEqual(authorization, -1); assert.ok(authorization < isolation); + assert.ok(isolation < logsIsolation && logsIsolation < logsRead); assert.ok(isolation < main.indexOf('new ProfileStore(')); assert.ok(isolation < main.indexOf('new LocalLifecycleController(')); assert.ok(authorization < main.indexOf('new ProfileStore(')); diff --git a/apps/desktop/src/smoke-test-evidence.test.ts b/apps/desktop/src/smoke-test-evidence.test.ts index d0ff7beea..885552e16 100644 --- a/apps/desktop/src/smoke-test-evidence.test.ts +++ b/apps/desktop/src/smoke-test-evidence.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; import { describe, it } from 'node:test'; import { createPackagedSmokeEvidenceSink, + NATIVE_SMOKE_EVIDENCE_FILES, PACKAGED_SMOKE_EVIDENCE_EVENTS, PACKAGED_SMOKE_EVIDENCE_FILE, } from './smoke-test-evidence'; @@ -73,4 +74,17 @@ describe('packaged smoke evidence', () => { assert.ok(Buffer.byteLength(contents, 'utf8') < 1024); }); }); + + it('uses separate fixed event-only files for native first launch and relaunch', () => { + withSmokeDirectory(directory => { + const first = createPackagedSmokeEvidenceSink(directory, 'first'); + const relaunch = createPackagedSmokeEvidenceSink(directory, 'relaunch'); + assert.ok(first && relaunch); + first.write('desktop.native.profile_fresh'); + relaunch.write('desktop.native.profile_preserved'); + first.close(); + relaunch.close(); + assert.deepEqual(readdirSync(directory).sort(), Object.values(NATIVE_SMOKE_EVIDENCE_FILES).sort()); + }); + }); }); diff --git a/apps/desktop/src/smoke-test-evidence.ts b/apps/desktop/src/smoke-test-evidence.ts index a9d26bfb6..cb0262349 100644 --- a/apps/desktop/src/smoke-test-evidence.ts +++ b/apps/desktop/src/smoke-test-evidence.ts @@ -9,6 +9,10 @@ import { import { join } from 'node:path'; export const PACKAGED_SMOKE_EVIDENCE_FILE = 'application.smoke-evidence.jsonl'; +export const NATIVE_SMOKE_EVIDENCE_FILES = Object.freeze({ + first: 'application.smoke-evidence.first.jsonl', + relaunch: 'application.smoke-evidence.relaunch.jsonl', +}); export const PACKAGED_SMOKE_EVIDENCE_EVENTS = [ 'desktop.smoke.authorized', @@ -23,9 +27,32 @@ export const PACKAGED_SMOKE_EVIDENCE_EVENTS = [ 'desktop.log.write_failed', ] as const; +export const NATIVE_SMOKE_EVIDENCE_EVENTS = [ + 'desktop.deeplink.delivery_failed', + 'desktop.native.identity_verified', + 'desktop.native.profile_fresh', + 'desktop.native.profile_preserved', + 'desktop.native.secure_storage_enforced', + 'desktop.native.secure_storage_fallback_refused', + 'desktop.native.secure_storage_probe.started', + 'desktop.native.secure_storage_probe.completed', + 'desktop.deeplink.cold_manual_once', + 'desktop.deeplink.cold_tunnel_once', + 'desktop.deeplink.warm_manual_once', + 'desktop.deeplink.warm_tunnel_once', + 'desktop.deeplink.warm_open_once', + 'desktop.deeplink.confirmation_required', + 'desktop.deeplink.rejected_malformed', + 'desktop.deeplink.rejected_oversized', + 'desktop.deeplink.rejected_unsafe_scheme', +] as const; + export type PackagedSmokeEvidenceEvent = typeof PACKAGED_SMOKE_EVIDENCE_EVENTS[number]; -const allowedEvents = new Set(PACKAGED_SMOKE_EVIDENCE_EVENTS); +const allowedEvents = new Set([ + ...PACKAGED_SMOKE_EVIDENCE_EVENTS, + ...NATIVE_SMOKE_EVIDENCE_EVENTS, +]); export interface PackagedSmokeEvidenceSink { write(event: string): void; @@ -34,10 +61,12 @@ export interface PackagedSmokeEvidenceSink { export const createPackagedSmokeEvidenceSink = ( authorizedUserDataDirectory: string | null, + nativePhase?: keyof typeof NATIVE_SMOKE_EVIDENCE_FILES, ): PackagedSmokeEvidenceSink | null => { if (authorizedUserDataDirectory === null) return null; - const evidencePath = join(authorizedUserDataDirectory, PACKAGED_SMOKE_EVIDENCE_FILE); + const evidenceFile = nativePhase ? NATIVE_SMOKE_EVIDENCE_FILES[nativePhase] : PACKAGED_SMOKE_EVIDENCE_FILE; + const evidencePath = join(authorizedUserDataDirectory, evidenceFile); const descriptor = openSync(evidencePath, 'wx', 0o600); let closed = false; const emitted = new Set(); diff --git a/propr-ui/src/desktop-deep-link.test.ts b/propr-ui/src/desktop-deep-link.test.ts index b431da2ff..6067aeb71 100644 --- a/propr-ui/src/desktop-deep-link.test.ts +++ b/propr-ui/src/desktop-deep-link.test.ts @@ -6,7 +6,10 @@ describe('desktop open deep-link navigation', () => { const navigate = vi.fn(); const navigation = new DesktopDeepLinkNavigation(navigate); - expect(navigation.receive('propr://open?path=%2Ftasks')).toBe(true); + expect(navigation.receiveWithState('propr://open?path=%2Ftasks')).toEqual({ + path: '/tasks', + state: 'queued', + }); expect(navigate).not.toHaveBeenCalled(); navigation.setDashboardReady(); @@ -30,7 +33,10 @@ describe('desktop open deep-link navigation', () => { const navigation = new DesktopDeepLinkNavigation(navigate); navigation.setDashboardReady(); - expect(navigation.receive('propr://open?path=%2Ftasks%3Fstatus%3Dopen%23recent')).toBe(true); + expect(navigation.receiveWithState('propr://open?path=%2Ftasks%3Fstatus%3Dopen%23recent')).toEqual({ + path: '/tasks?status=open#recent', + state: 'navigated', + }); expect(navigate).toHaveBeenCalledWith('/tasks?status=open#recent'); }); diff --git a/propr-ui/src/desktop-deep-link.ts b/propr-ui/src/desktop-deep-link.ts index 6972698d1..3911498c4 100644 --- a/propr-ui/src/desktop-deep-link.ts +++ b/propr-ui/src/desktop-deep-link.ts @@ -7,12 +7,16 @@ export class DesktopDeepLinkNavigation { constructor(private readonly navigate: (path: string) => void) {} - receive(value: string): boolean { + receiveWithState(value: string): { path: string; state: 'queued' | 'navigated' } | null { const path = dashboardPathFromDeepLink(value); - if (!path) return false; + if (!path) return null; if (this.dashboardReady) this.navigate(path); else this.pendingPaths.push(path); - return true; + return { path, state: this.dashboardReady ? 'navigated' : 'queued' }; + } + + receive(value: string): boolean { + return this.receiveWithState(value) !== null; } setDashboardReady(): void { diff --git a/propr-ui/src/desktop.tsx b/propr-ui/src/desktop.tsx index 7bfee062f..c81d64104 100644 --- a/propr-ui/src/desktop.tsx +++ b/propr-ui/src/desktop.tsx @@ -166,13 +166,21 @@ export const DesktopRoot = () => { const deepLink = new URL(value); if (deepLink.hostname === 'connect') { const apiUrl = deepLink.searchParams.get('api'); - if (apiUrl) setInitialApiUrl(apiUrl); + if (apiUrl) { + setInitialApiUrl(apiUrl); + return { kind: 'connect-confirmation', target: apiUrl }; + } } else if (deepLink.hostname === 'open') { - deepLinkNavigation.receive(value); + const result = deepLinkNavigation.receiveWithState(value); + if (result) return { + kind: result.state === 'queued' ? 'open-queued' : 'open-navigated', + target: result.path, + }; } } catch { // Main validates protocol input; ignore malformed values defensively. } + return null; }); void Promise.all([bridge.app.getMetadata(), bridge.storage.security(), bridge.profiles.list()]) .then(async ([appMetadata, storageSecurity, profiles]) => {