diff --git a/.github/workflows/desktop-release-guard.yml b/.github/workflows/desktop-release-guard.yml index 44d4ce757..3b9d1de15 100644 --- a/.github/workflows/desktop-release-guard.yml +++ b/.github/workflows/desktop-release-guard.yml @@ -209,7 +209,7 @@ jobs: if: matrix.platform == 'linux' run: | sudo apt-get update - sudo apt-get install --yes cpio dbus-x11 fakeroot gnome-keyring libsecret-1-0 rpm zip + sudo apt-get install --yes cpio dbus-x11 desktop-file-utils fakeroot gnome-keyring libglib2.0-bin libsecret-1-0 rpm unzip xdg-utils xvfb zip - name: Package desktop app from clean checkout shell: bash @@ -337,6 +337,30 @@ 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 + keyring_root="$(mktemp -d)" + trap 'rm -rf -- "$keyring_root"' EXIT + dbus-run-session -- bash -euo pipefail -c ' + export XDG_DATA_HOME="$1" + eval "$(printf "%s\n" "propr-native-lifecycle" | gnome-keyring-daemon --unlock --components=secrets)" + xvfb-run --auto-servernum \ + node apps/desktop/scripts/test-native-artifact-lifecycle.mjs \ + --version "$2" \ + --platform linux \ + --arch "$3" \ + --artifact-directory "$4" + ' bash "$keyring_root" "$PROPR_DESKTOP_VERSION" "${{ matrix.arch }}" \ + "desktop-release-${{ matrix.platform }}-${{ matrix.arch }}" + else + bash apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh \ + "${{ matrix.arch }}" native-lifecycle "$PROPR_DESKTOP_VERSION" \ + "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 cd68199b1..ac5bdac0e 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -126,6 +126,81 @@ PROPR_DESKTOP_ENABLE_RPM=1 \ npm run make -w @propr/desktop -- --arch="$(node -p process.arch)" ``` +### Unsigned internal-RC install and removal (macOS/Linux) + +Choose the artifact whose `x64` or `arm64` suffix matches the machine. These are internal validation builds: they do +not claim signing, notarization, or Gatekeeper approval, and the commands below do not weaken quarantine or trust +policy. An unsigned macOS build may therefore be rejected on a normal end-user machine. + +Debian/Ubuntu DEB installation and native removal: + +```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 +``` + +Fedora/RHEL-family RPM installation, followed by the package-manager-independent ZIP flow: + +```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 Intel (`x64`) or Apple Silicon (`arm64`) macOS, mount and copy the DMG or extract the ZIP. Quit the app before +removing it: + +```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" +sudo 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' +sudo 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 DEB/RPM/ZIP on `ubuntu-24.04` and `ubuntu-24.04-arm`, and DMG/ZIP on +`macos-15-intel` and `macos-15`. Every staged format is extracted or mounted and copied, launched, shut down, +relaunched with the same isolated profile, and removed. It verifies the staged hash remains unchanged, executable +architecture and native launcher registration, profile permissions and state preservation, warm OS protocol dispatch, +renderer exactly-once acknowledgement, explicit confirmation of untrusted Connect candidates, and cleanup of owned +processes, mounts, LaunchServices registration, profiles, and install roots. + +For copied macOS test apps only, CI reuses the packaged-Connect harness to generate one disposable, non-production +code-signing identity in an isolated keychain. It signs the copied app (never the staged DMG/ZIP), verifies the same +designated requirement before and after both launches, and restores the runner's original keychain list/default before +deleting the identity and temporary keychain. This stabilizes the Safe Storage application identity without changing +trust settings and is not evidence of Developer ID signing, notarization, Gatekeeper approval, or end-user launchability. +Linux runs each artifact against one isolated, unlocked D-Bus/libsecret session and proves credential round-trip and +deletion without permitting plaintext/basic-text fallback. Cold launches are direct argv; Linux package warm dispatch uses an +isolated XDG MIME database and `gio`, ZIP warm dispatch is direct because ZIP has no registered launcher, and macOS +warm dispatch uses LaunchServices against the exact copied bundle. + ### 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 a46744a44..4d2da2860 100644 --- a/apps/desktop/forge.config.ts +++ b/apps/desktop/forge.config.ts @@ -194,6 +194,7 @@ const config: ForgeConfig = { productName: 'ProPR Desktop', version: releaseVersion, bin: DESKTOP_EXECUTABLE_NAME, + mimeType: ['x-scheme-handler/propr'], }, })] : []), @@ -204,6 +205,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-connect-platform.test.mjs b/apps/desktop/scripts/packaged-connect-platform.test.mjs index 2e24d3207..995a34886 100644 --- a/apps/desktop/scripts/packaged-connect-platform.test.mjs +++ b/apps/desktop/scripts/packaged-connect-platform.test.mjs @@ -137,6 +137,7 @@ describe('packaged Connect target-native credential setup', () => { 'APPLICATION_SIGNING', 'INITIAL_SIGNATURE_VERIFICATION', 'PAIR_REPROBE_JOURNEY', + 'NATIVE_ARTIFACT_LIFECYCLE', 'STABLE_SIGNATURE_VERIFICATION', 'KEYCHAIN_RESTORATION_DELETION', 'TEMPORARY_FILE_CLEANUP', 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 5e7fba41d..2c32c88c2 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'; @@ -109,6 +109,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('defines four isolated launches with exact per-mode environment, argv, and marker contracts', () => { const firstOrigin = 'http://127.0.0.1:41001'; const secondOrigin = 'http://127.0.0.1:41002'; diff --git a/apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh b/apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh index 955bfbfdb..26d268bd3 100644 --- a/apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh +++ b/apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh @@ -12,6 +12,11 @@ if [[ "$architecture" != 'arm64' && "$architecture" != 'x64' ]]; then echo 'Packaged Darwin Connect acceptance requires an explicit supported architecture.' >&2 exit 1 fi +mode="${2:-connect}" +if [[ "$mode" != 'connect' && "$mode" != 'native-lifecycle' ]]; then + echo 'Packaged Darwin acceptance mode is invalid.' >&2 + exit 1 +fi script_directory="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" repository_root="$(cd "$script_directory/../../.." && pwd -P)" @@ -19,7 +24,10 @@ application="$repository_root/apps/desktop/out/propr-desktop-darwin-$architectur signature_verifier="$script_directory/verify-darwin-packaged-connect-signature.mjs" application_signer="$script_directory/sign-darwin-packaged-connect.mjs" bounded_runner="$script_directory/run-bounded-darwin-command.mjs" -if [[ ! -d "$application" || ! -f "$signature_verifier" || ! -f "$application_signer" +native_lifecycle="$script_directory/test-native-artifact-lifecycle.mjs" +if [[ ( "$mode" == 'connect' && ! -d "$application" ) + || ( "$mode" == 'native-lifecycle' && ! -f "$native_lifecycle" ) + || ! -f "$signature_verifier" || ! -f "$application_signer" || ! -f "$bounded_runner" ]]; then echo 'Packaged Darwin Connect acceptance artifact is missing.' >&2 exit 1 @@ -30,6 +38,7 @@ readonly COMMAND_TIMEOUT_MS=30000 readonly CLEANUP_TIMEOUT_MS=10000 readonly SIGNING_TIMEOUT_MS=180000 readonly JOURNEY_TIMEOUT_MS=240000 +readonly NATIVE_LIFECYCLE_TIMEOUT_MS=1200000 readonly TERMINATION_GRACE_MS=5000 readonly MAX_OUTPUT_BYTES=262144 @@ -37,7 +46,7 @@ stage_marker() { local stage="$1" local code="$2" case "$stage" in - KEY_CERTIFICATE_GENERATION|KEYCHAIN_CREATION_SELECTION|IDENTITY_IMPORT|PARTITION_LIST_UPDATE|APPLICATION_SIGNING|INITIAL_SIGNATURE_VERIFICATION|PAIR_REPROBE_JOURNEY|STABLE_SIGNATURE_VERIFICATION|KEYCHAIN_RESTORATION_DELETION|TEMPORARY_FILE_CLEANUP) ;; + KEY_CERTIFICATE_GENERATION|KEYCHAIN_CREATION_SELECTION|IDENTITY_IMPORT|PARTITION_LIST_UPDATE|APPLICATION_SIGNING|INITIAL_SIGNATURE_VERIFICATION|PAIR_REPROBE_JOURNEY|NATIVE_ARTIFACT_LIFECYCLE|STABLE_SIGNATURE_VERIFICATION|KEYCHAIN_RESTORATION_DELETION|TEMPORARY_FILE_CLEANUP) ;; *) return 1 ;; esac case "$code" in @@ -249,12 +258,30 @@ verify_stable_signature() { "$application" "$identity_sha1" "$requirement_proof" "$keychain_path" } +run_native_artifact_lifecycle() { + local version="${1:-}" + local artifact_directory="${2:-}" + if [[ ! "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ || ! -d "$artifact_directory" ]]; then + echo 'Native Darwin lifecycle arguments are invalid.' >&2 + return 1 + fi + PROPR_DESKTOP_NATIVE_SIGNING_KEYCHAIN="$keychain_path" \ + PROPR_DESKTOP_NATIVE_SIGNING_CERTIFICATE_SHA1="$identity_sha1" \ + run_bounded_forward "$NATIVE_LIFECYCLE_TIMEOUT_MS" node "$native_lifecycle" \ + --version "$version" --platform darwin --arch "$architecture" \ + --artifact-directory "$artifact_directory" +} + run_stage KEY_CERTIFICATE_GENERATION generate_key_and_certificates run_stage KEYCHAIN_CREATION_SELECTION create_and_select_keychain run_stage IDENTITY_IMPORT import_identity run_stage PARTITION_LIST_UPDATE update_partition_list unset keychain_password identity_password -run_stage APPLICATION_SIGNING sign_application -run_stage INITIAL_SIGNATURE_VERIFICATION verify_initial_signature -run_stage PAIR_REPROBE_JOURNEY run_pair_and_reprobe -run_stage STABLE_SIGNATURE_VERIFICATION verify_stable_signature +if [[ "$mode" == 'connect' ]]; then + run_stage APPLICATION_SIGNING sign_application + run_stage INITIAL_SIGNATURE_VERIFICATION verify_initial_signature + run_stage PAIR_REPROBE_JOURNEY run_pair_and_reprobe + run_stage STABLE_SIGNATURE_VERIFICATION verify_stable_signature +else + run_stage NATIVE_ARTIFACT_LIFECYCLE run_native_artifact_lifecycle "${3:-}" "${4:-}" +fi 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..80fdba4e1 --- /dev/null +++ b/apps/desktop/scripts/test-native-artifact-lifecycle.mjs @@ -0,0 +1,1595 @@ +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'; +import { signDarwinPackagedConnectApplication } from './sign-darwin-packaged-connect.mjs'; +import { verifyDarwinPackagedConnectSignature } from './verify-darwin-packaged-connect-signature.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 DBUS_SESSION_ADDRESS = /^unix:path=\/[^\0\r\n,]+(?:,guid=[0-9a-f]{32})?$/; +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) }; +}; + +export const createNativeLaunchContext = ({ platform, baseEnvironment, sessionAddress }) => { + if (platform !== 'linux') { + return Object.freeze({ + environment: Object.freeze({ ...baseEnvironment }), + arguments: Object.freeze([]), + }); + } + if (typeof sessionAddress !== 'string' || sessionAddress.length > 4096 || /[\0\r\n]/.test(sessionAddress) + || !DBUS_SESSION_ADDRESS.test(sessionAddress)) { + throw new Error('Native Linux lifecycle requires one validated D-Bus session address'); + } + return Object.freeze({ + environment: Object.freeze({ ...baseEnvironment, DBUS_SESSION_BUS_ADDRESS: sessionAddress }), + arguments: Object.freeze(['--disable-gpu', '--password-store=gnome-libsecret']), + }); +}; + +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_DARWIN_ACCEPTANCE_SIGNATURE', + '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_DARWIN_SIGNATURE_VALIDATION', + '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', + 'SECURE_STORAGE_BACKEND', + 'DEEP_LINK_DELIVERY_FAILURE', + 'COLD_ACK', + 'SECURE_STORAGE_STARTED', + 'SECURE_STORAGE_COMPLETED', + 'RENDERER', +]); + +export const FIRST_EVIDENCE_FAILURE_CATEGORIES = Object.freeze([ + 'START_FAILED', + 'UNCAUGHT_EXCEPTION', + 'COLD_CONFIRMATION_INSPECTION_FAILED', + 'COLD_CONFIRMATION_NOT_VISIBLE', + 'RENDERER_GONE', +]); + +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) + || (evidenceClassification.failureCategory !== undefined + && !FIRST_EVIDENCE_FAILURE_CATEGORIES.includes(evidenceClassification.failureCategory)))) { + throw new Error('Native lifecycle evidence failure classification is invalid'); + } + const classification = evidenceClassification + ? ` [milestone:${evidenceClassification.milestone}] [result:${evidenceClassification.resultClass}]${ + evidenceClassification.failureCategory + ? ` [category:${evidenceClassification.failureCategory}]` + : '' + }` + : ''; + super(`Native lifecycle operation failed [stage:${stage}]${classification}`); + this.name = 'NativeLifecycleOperationFailure'; + this.stage = stage; + if (evidenceClassification) { + this.milestone = evidenceClassification.milestone; + this.resultClass = evidenceClassification.resultClass; + if (evidenceClassification.failureCategory) { + this.failureCategory = evidenceClassification.failureCategory; + } + } + 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}]`] : []), + ...(primaryError.failureCategory ? [` [category:${primaryError.failureCategory}]`] : []), + ].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) { + // hdiutil can mount successfully before returning a failure or being killed. + // Hold tentative authority until an exact post-failure query proves absence. + this.mounted = true; + try { + await this.runCommand('/usr/bin/hdiutil', [ + 'attach', '-readonly', '-nobrowse', '-mountpoint', this.mountRoot, artifact, + ]); + } catch (error) { + try { + const mounts = await this.runCommand('/usr/bin/hdiutil', ['info'], { timeout: 30_000 }); + if (!mountOutputContains(mounts.stdout, this.mountRoot)) this.mounted = false; + } catch { + // A failed query cannot release tentative mount authority. The caller's + // cleanup pass will retry detach plus the exact absence postcondition. + } + throw error; + } + } + + 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, launchArguments = []) => { + const group = startApplication( + application, + [...launchArguments, `--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} --disable-gpu --password-store=gnome-libsecret --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() { + // lsregister can update its database before returning a failure or timeout. + // Keep tentative authority so every exit path unregisters and proves absence. + this.registered = true; + await this.runCommand(LAUNCH_SERVICES, ['-f', this.applicationRoot], { env: this.environment, timeout: 30_000 }); + } + + 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'); + let persisted; + try { + persisted = JSON.parse(contents); + } catch { + throw new Error('Native profile state is not valid JSON'); + } + if (persisted?.version !== 3 + || !persisted.credentialSlots || Object.keys(persisted.credentialSlots).length !== 0 + || !persisted.credentialEpochs || Object.keys(persisted.credentialEpochs).length !== 0 + || !persisted.pendingRevocations || Object.keys(persisted.pendingRevocations).length !== 0 + || /native-custody-probe|propr_it_/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'; + let failureCategory; + 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.native.secure_storage_backend_invalid')) milestone = 'SECURE_STORAGE_BACKEND'; + 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'; + if (events.has('desktop.app.start_failed')) failureCategory = 'START_FAILED'; + if (events.has('desktop.main_process.uncaught_exception')) failureCategory = 'UNCAUGHT_EXCEPTION'; + if (events.has('desktop.native.cold_confirmation_inspection_failed')) { + failureCategory = 'COLD_CONFIRMATION_INSPECTION_FAILED'; + } + if (events.has('desktop.native.cold_confirmation_not_visible')) { + failureCategory = 'COLD_CONFIRMATION_NOT_VISIBLE'; + } + if (events.has('desktop.renderer.gone')) failureCategory = 'RENDERER_GONE'; + } 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, ...(failureCategory ? { failureCategory } : {}) }; +}; + +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 }); + let darwinSignatureProof; + if (target.platform === 'darwin') { + operationStage = 'PREPARE_DARWIN_ACCEPTANCE_SIGNATURE'; + const keychain = process.env.PROPR_DESKTOP_NATIVE_SIGNING_KEYCHAIN; + const certificateSha1 = process.env.PROPR_DESKTOP_NATIVE_SIGNING_CERTIFICATE_SHA1; + if (!keychain?.endsWith('.keychain-db') || !/^[A-F0-9]{40}$/.test(certificateSha1 ?? '')) { + throw new Error('Native Darwin lifecycle requires the CI-only acceptance signing identity'); + } + darwinSignatureProof = join(workRoot, 'designated-requirement.txt'); + await signDarwinPackagedConnectApplication({ + application: application.applicationRoot, + keychain, + certificateSha1, + }); + await verifyDarwinPackagedConnectSignature({ + mode: 'establish', + application: application.applicationRoot, + expectedCertificateSha1: certificateSha1, + proofPath: darwinSignatureProof, + keychain, + }); + } + 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 launchContext = createNativeLaunchContext({ + platform: target.platform, + baseEnvironment, + sessionAddress: process.env.DBUS_SESSION_BUS_ADDRESS, + }); + const firstEnvironment = Object.freeze({ + ...launchContext.environment, + 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 = { ...launchContext.environment }; + delete dispatchEnvironment.PROPR_DESKTOP_SMOKE_TEST; + delete dispatchEnvironment.PROPR_DESKTOP_SMOKE_PROFILE_API_URL; + + operationStage = 'FIRST_LAUNCH'; + const first = startApplication(application, [ + ...launchContext.arguments, + '--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, + launchContext.arguments, + ); + 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, + launchContext.arguments, + ); + 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, + launchContext.arguments, + ); + 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, + launchContext.arguments, + ); + 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, + launchContext.arguments, + ); + operationStage = 'UNSAFE_SCHEME_EVIDENCE'; + await waitForEvents(firstEvidence, ['desktop.deeplink.rejected_unsafe_scheme'], first.child); + operationStage = 'FIRST_EXIT'; + await first.waitForSuccessfulExit(); + const requiredFirstEvents = REQUIRED_FIRST_EVENTS; + operationStage = 'FIRST_EVIDENCE_VALIDATION'; + await waitForEvents(firstEvidence, requiredFirstEvents, { exitCode: null }); + await assertEvidenceOrdering(firstEvidence, requiredFirstEvents); + await assertProfileAuthority(profile); + + const relaunchEnvironment = Object.freeze({ + ...launchContext.environment, + 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, [ + ...launchContext.arguments, + '--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); + if (target.platform === 'darwin') { + operationStage = 'FINAL_DARWIN_SIGNATURE_VALIDATION'; + await verifyDarwinPackagedConnectSignature({ + mode: 'stable', + application: application.applicationRoot, + expectedCertificateSha1: process.env.PROPR_DESKTOP_NATIVE_SIGNING_CERTIFICATE_SHA1, + proofPath: darwinSignatureProof, + keychain: process.env.PROPR_DESKTOP_NATIVE_SIGNING_KEYCHAIN, + }); + } + 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' + ? 'isolated gnome-libsecret round-trip and deletion' + : '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 uses one isolated gnome-libsecret session.' + : '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..388c2fb66 --- /dev/null +++ b/apps/desktop/scripts/test-native-artifact-lifecycle.test.mjs @@ -0,0 +1,613 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { chmod, mkdtemp, mkdir, readFile, 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, + createNativeLaunchContext, + 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('signs only copied Darwin apps with the shared disposable identity and verifies stability', async () => { + const source = await readFile(new URL('./test-native-artifact-lifecycle.mjs', import.meta.url), 'utf8'); + assert.match(source, /signDarwinPackagedConnectApplication/); + assert.match(source, /verifyDarwinPackagedConnectSignature/); + assert.match(source, /mode: 'establish'[\s\S]*mode: 'stable'/u); + assert.match(source, /PROPR_DESKTOP_NATIVE_SIGNING_KEYCHAIN/); + assert.match(source, /beforeDigest[\s\S]*digest\(artifact\) !== beforeDigest/u); + assert.doesNotMatch(source, /add-trusted-cert|remove-trusted-cert|xattr|spctl/u); + }); + + 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('binds Linux launches to one validated libsecret session without disabling the sandbox', () => { + const baseEnvironment = Object.freeze({ + HOME: '/private/profile/home', + PROPR_DESKTOP_SMOKE_TEST: '1', + }); + const sessionAddress = 'unix:path=/run/user/1000/bus,guid=0123456789abcdef0123456789abcdef'; + const linux = createNativeLaunchContext({ + platform: 'linux', + baseEnvironment, + sessionAddress, + }); + assert.deepEqual(linux, { + environment: { ...baseEnvironment, DBUS_SESSION_BUS_ADDRESS: sessionAddress }, + arguments: ['--disable-gpu', '--password-store=gnome-libsecret'], + }); + assert.throws(() => createNativeLaunchContext({ + platform: 'linux', + baseEnvironment, + sessionAddress: 'tcp:host=attacker.invalid', + }), /validated D-Bus session/); + assert.deepEqual(createNativeLaunchContext({ + platform: 'darwin', + baseEnvironment, + sessionAddress: undefined, + }), { environment: baseEnvironment, arguments: [] }); + }); + + 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('detaches and proves absence when attach fails after mounting the exact DMG root', async () => { + const calls = []; + let infoCalls = 0; + const runCommand = async (file, args) => { + calls.push([file, ...args]); + if (args[0] === 'attach') throw new Error('injected partial attach failure'); + if (args[0] === 'info') { + infoCalls += 1; + return { + stdout: Buffer.from(infoCalls === 1 ? '/dev/disk9 /private/mount\n' : ''), + stderr: Buffer.alloc(0), + }; + } + 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: async () => { throw new Error('scan must not run'); }, + runCommand, + }), /injected partial attach failure/); + + assert.equal(authority.mounted, false); + assert.deepEqual(calls.map(call => call[1]), ['attach', 'info', '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.native.secure_storage_backend_invalid', + milestone: 'SECURE_STORAGE_BACKEND', + 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 categories = [ + { event: 'desktop.app.start_failed', failureCategory: 'START_FAILED' }, + { event: 'desktop.main_process.uncaught_exception', failureCategory: 'UNCAUGHT_EXCEPTION' }, + { + event: 'desktop.native.cold_confirmation_inspection_failed', + failureCategory: 'COLD_CONFIRMATION_INSPECTION_FAILED', + }, + { + event: 'desktop.native.cold_confirmation_not_visible', + failureCategory: 'COLD_CONFIRMATION_NOT_VISIBLE', + }, + { event: 'desktop.renderer.gone', failureCategory: 'RENDERER_GONE' }, + ]; + for (const fixture of categories) { + await writeFile(evidence, [ + JSON.stringify({ event: 'desktop.deeplink.cold_manual_once' }), + JSON.stringify({ event: fixture.event }), + ].join('\n')); + assert.deepEqual(await classifyFirstEvidenceFailure(evidence, 'FAILED_EXIT'), { + milestone: 'COLD_ACK', + resultClass: 'FAILED_EXIT', + stage: 'FIRST_INITIAL_EVIDENCE', + failureCategory: fixture.failureCategory, + }); + } + + await writeFile(evidence, [ + JSON.stringify({ event: 'desktop.renderer.ready' }), + JSON.stringify({ event: 'desktop.renderer.gone' }), + ].join('\n')); + 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.match(aggregate.message, /category:RENDERER_GONE/); + 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('unregisters and proves absence when registration fails after partial success', async () => { + const applicationRoot = '/private/copied/ProPR Desktop.app'; + const calls = []; + const authority = new LaunchServicesAuthority(applicationRoot, { FIXED: 'environment' }, { + runCommand: async (file, args) => { + calls.push([file, ...args]); + if (args[0] === '-f') throw new Error('injected partial registration failure'); + return { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) }; + }, + }); + + await assert.rejects(authority.register(), /injected partial registration failure/); + assert.equal(authority.registered, true); + assert.deepEqual(await removeCopiedApplicationWithLaunchServicesAuthority({ + installRoot: '/private/install', + launchServices: authority, + }, { + removeInstallRoot: async () => { calls.push(['remove-install-root']); }, + assertInstallRootAbsent: async () => { calls.push(['install-postcondition']); }, + }), []); + + assert.equal(authority.registered, false); + assert.deepEqual(calls.map(call => call[1] ?? call[0]), [ + '-f', + '-u', + '-dump', + 'remove-install-root', + 'install-postcondition', + ]); + }); + + 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..a37167dcd 100644 --- a/apps/desktop/src/deep-link-delivery.test.ts +++ b/apps/desktop/src/deep-link-delivery.test.ts @@ -1,63 +1,248 @@ 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 }> = []; + 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); - const delivery = new DeepLinkDelivery('desktop:deep-link', ['propr://open?task=initial']); + delivery.deliver('propr://open?path=%2Ftasks'); + delivery.setWindow(window); - delivery.didFinishLoad(window); - delivery.deliver('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(sent, []); + assert.deepEqual(consumed, [ + 'propr://connect?api=http%3A%2F%2Flocalhost%3A4000', + 'propr://open?path=%2Ftasks', + ]); + }); + 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); 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(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' }, + }; + assert.equal(delivery.acknowledge(window, acknowledgement), true); + assert.equal(delivery.acknowledge(window, acknowledgement), false); + await delivery.whenIdle(); + assert.deepEqual(consumed, [link]); + + 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]); }); - 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); + 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/); + }); - const sent: Array<{ channel: string; value: string }> = []; - const window = createWindow(sent); - const connectUrl = 'propr://connect?api=https%3A%2F%2Fconnect.propr.dev'; - const delivery = new DeepLinkDelivery('desktop:deep-link', [connectUrl]); + it('reports a synchronous send failure and settles idle before accepting later work', async () => { + const sent: DesktopDeepLinkDelivery[] = []; + const failures: Error[] = []; + let failNextSend = true; + const window: DeepLinkWindow = { + isDestroyed: () => false, + webContents: { + isLoading: () => false, + send: (_channel, value) => { + if (failNextSend) { + failNextSend = false; + throw new Error('window destroyed during send'); + } + sent.push(value); + }, + }, + }; + const delivery = new DeepLinkDelivery( + 'desktop:deep-link', + [], + undefined, + error => { failures.push(error); }, + ); + delivery.setWindow(window); - delivery.didFinishLoad(window); - assert.deepEqual(sent, []); + assert.equal(delivery.deliver('propr://open?path=%2Ftasks'), true); + await delivery.whenIdle(); + assert.deepEqual(failures.map(error => error.message), ['window destroyed during send']); + assert.equal(delivery.deliver('propr://open?path=%2Fplans'), true); + assert.equal(sent.length, 1); + assert.equal(delivery.acknowledge(window, { + ...sent[0], + consumption: { kind: 'open-queued', target: '/plans' }, + }), true); + await delivery.whenIdle(); + }); + + it('continues with accepted queued links after one acknowledgement timeout', async () => { + const sent: DesktopDeepLinkDelivery[] = []; + const consumed: string[] = []; + const failures: Error[] = []; + const window = createWindow(sent); + const delivery = new DeepLinkDelivery( + 'desktop:deep-link', + [], + value => { consumed.push(value); }, + error => { failures.push(error); }, + Date.now, + 1_000, + 20, + ); delivery.setWindow(window); - const assertPackagedSmokeDeepLink = () => { - assert.deepEqual(sent.filter(({ value }) => value === connectUrl), [ - { channel: 'desktop:deep-link', value: connectUrl }, - ]); - }; - assertPackagedSmokeDeepLink(); - delivery.didFinishLoad(window); + assert.equal(delivery.deliver('propr://open?path=%2Ftasks'), true); + assert.equal(delivery.deliver('propr://open?path=%2Fplans'), true); + while (sent.length < 2) await tick(); + assert.equal(delivery.acknowledge(window, { + ...sent[1], + consumption: { kind: 'open-queued', target: '/plans' }, + }), true); + await delivery.whenIdle(); + + assert.equal(failures.length, 1); + assert.match(failures[0].message, /acknowledgement deadline/); + assert.deepEqual(consumed, ['propr://open?path=%2Fplans']); + }); + + it('closes admission while draining active and pending acknowledgements during shutdown', async () => { + const sent: DesktopDeepLinkDelivery[] = []; + const consumed: string[] = []; + const failures: Error[] = []; + const delivery = new DeepLinkDelivery( + 'desktop:deep-link', + [], + value => { consumed.push(value); }, + error => { failures.push(error); }, + ); + const window = createWindow(sent); delivery.setWindow(window); + assert.equal(delivery.deliver('propr://open?path=%2Ftasks'), true); + assert.equal(delivery.deliver('propr://open?path=%2Fplans'), true); + assert.equal(sent.length, 1); - assert.equal(sent.filter(({ value }) => value === connectUrl).length, 1); + delivery.close(); + assert.equal(delivery.deliver('propr://open?path=%2Finbox'), false); + assert.equal(delivery.acknowledge(window, { + ...sent[0], + consumption: { kind: 'open-queued', target: '/tasks' }, + }), true); + await tick(); + assert.equal(sent.length, 2); + assert.equal(delivery.acknowledge(window, { + ...sent[1], + consumption: { kind: 'open-queued', target: '/plans' }, + }), true); + await delivery.whenIdle(); + + assert.deepEqual(consumed, [ + 'propr://open?path=%2Ftasks', + 'propr://open?path=%2Fplans', + ]); + assert.deepEqual(failures, []); + }); + + 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..ffaa130fc 100644 --- a/apps/desktop/src/deep-link-delivery.ts +++ b/apps/desktop/src/deep-link-delivery.ts @@ -1,26 +1,77 @@ +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 closed = 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 { + if (this.closed) return false; + 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 +80,89 @@ 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)); + } + + /** Closes admission without canceling deliveries accepted before shutdown. */ + close(): void { + if (this.closed) return; + this.closed = true; + } + + 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, + }; + try { + window.webContents.send(this.channel, delivery); + const consumption = await acknowledgement; + await this.delivered(value, consumption, window); + } catch (error) { + if (!this.closed) { + this.failed(error instanceof Error ? error : new Error('Desktop renderer deep-link acknowledgement failed')); + } + } 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-lifecycle.test.ts b/apps/desktop/src/ipc-lifecycle.test.ts index e67442def..a555cc7f5 100644 --- a/apps/desktop/src/ipc-lifecycle.test.ts +++ b/apps/desktop/src/ipc-lifecycle.test.ts @@ -2,12 +2,13 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import type { App, IpcMain, IpcMainInvokeEvent, Session } from 'electron'; import type { DesktopCredentialService } from './credential-service'; +import { DeepLinkDelivery, type DeepLinkWindow } from './deep-link-delivery'; import { registerIpcHandlers } from './ipc'; import type { LocalLifecycleController } from './lifecycle'; import type { DesktopLogger } from './logger'; import type { ProfileStore } from './profile-store'; import { rendererContentSecurityPolicy } from './security'; -import { IPC_CHANNELS } from './shared/contract'; +import { IPC_CHANNELS, type DesktopDeepLinkDelivery } from './shared/contract'; import { createDesktopShutdownCoordinator } from './shutdown'; const deferred = () => { @@ -862,7 +863,7 @@ describe('desktop IPC shutdown gate', () => { assert.equal(removalCommitted, false); }); - it('replaces every handler with a fixed closing failure and drains admitted work before disposal', async () => { + it('closes new work admission and drains admitted work before disposal', async () => { const handlers = new Map unknown>(); const ipcMain = { handle: (channel: string, handler: (...args: any[]) => unknown) => { handlers.set(channel, handler); }, @@ -916,6 +917,105 @@ describe('desktop IPC shutdown gate', () => { assert.equal(handlers.size, 0); }); + it('keeps active and pending deep-link acknowledgement IPC open during shutdown drain', async () => { + const handlers = new Map unknown>(); + const ipcMain = { + handle: (channel: string, handler: (...args: any[]) => unknown) => { handlers.set(channel, handler); }, + removeHandler: (channel: string) => { handlers.delete(channel); }, + } as unknown as IpcMain; + const sent: DesktopDeepLinkDelivery[] = []; + const consumed: string[] = []; + const deepLinks = new DeepLinkDelivery( + IPC_CHANNELS.deepLink, + [], + value => { consumed.push(value); }, + ); + const sender = { + isLoading: () => false, + send: (_channel: string, delivery: DesktopDeepLinkDelivery) => { sent.push(delivery); }, + }; + const window: DeepLinkWindow = { + isDestroyed: () => false, + webContents: sender, + }; + deepLinks.setWindow(window); + + let profileListCalls = 0; + const registered = registerIpcHandlers({ + app: { + getName: () => 'ProPR', getVersion: () => '0.8.15', isPackaged: true, + } as unknown as App, + ipcMain, + profiles: {} as ProfileStore, + credentials: { + listProfiles: async () => { + profileListCalls += 1; + return { profiles: [], activeProfileId: null }; + }, + } as unknown as DesktopCredentialService, + connectDiscovery, + lifecycle: {} as LocalLifecycleController, + logger: { log: () => undefined } as unknown as DesktopLogger, + desktopSession: {} as Session, + devServerUrl: undefined, + packagedRendererUrl: 'propr-renderer://app/index.html', + openExternal: async () => undefined, + acknowledgeDeepLink: (event, acknowledgement) => + deepLinks.acknowledgeSender(event.sender, acknowledgement), + }); + const event = { + sender, + senderFrame: { url: 'propr-renderer://app/index.html' }, + } as unknown as IpcMainInvokeEvent; + const invoke = (channel: string, ...args: unknown[]) => + Promise.resolve(handlers.get(channel)!(event, ...args)); + + assert.equal(deepLinks.deliver('propr://open?path=%2Ftasks'), true); + assert.equal(deepLinks.deliver('propr://open?path=%2Fplans'), true); + assert.equal(sent.length, 1); + + let windowDestroyed = false; + const shutdown = createDesktopShutdownCoordinator({ + credentials: { dispose: async () => undefined }, + lifecycle: { shutdown: async () => undefined }, + deepLinks, + ipc: registered, + profiles: { close: async () => undefined }, + sessionSecurity: { close: () => undefined, dispose: () => undefined }, + disposeRendererProtocol: () => undefined, + getWindow: () => ({ + isDestroyed: () => windowDestroyed, + destroy: () => { windowDestroyed = true; }, + }), + quit: () => undefined, + onStarted: () => undefined, + log: () => undefined, + }); + shutdown.beforeQuit({ preventDefault: () => undefined }); + + assert.equal(deepLinks.deliver('propr://open?path=%2Finbox'), false); + await assert.rejects(invoke(IPC_CHANNELS.profilesList), /DESKTOP_CLOSING/); + assert.equal(profileListCalls, 0); + await invoke(IPC_CHANNELS.deepLinkAcknowledgement, { + ...sent[0], + consumption: { kind: 'open-queued', target: '/tasks' }, + }); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(sent.length, 2); + await invoke(IPC_CHANNELS.deepLinkAcknowledgement, { + ...sent[1], + consumption: { kind: 'open-queued', target: '/plans' }, + }); + await shutdown.awaitFinished(); + + assert.deepEqual(consumed, [ + 'propr://open?path=%2Ftasks', + 'propr://open?path=%2Fplans', + ]); + assert.equal(windowDestroyed, true); + assert.equal(handlers.size, 0); + }); + for (const category of ['profile', 'pairing', 'session'] as const) { it(`runs an admitted ${category} handler through the production before-quit drain`, async () => { const handlers = new Map unknown>(); @@ -976,6 +1076,10 @@ describe('desktop IPC shutdown gate', () => { const shutdown = createDesktopShutdownCoordinator({ credentials: { dispose: async () => { order.push('credentials-dispose'); } }, lifecycle: { shutdown: async () => { order.push('lifecycle-shutdown'); } }, + deepLinks: { + close: () => { order.push('deep-links-close'); }, + whenIdle: async () => { order.push('deep-links-drain'); }, + }, ipc: { close: () => { order.push('ipc-close'); registered.close(); }, awaitIdle: () => { order.push('ipc-drain'); return registered.awaitIdle(); }, @@ -1006,6 +1110,9 @@ describe('desktop IPC shutdown gate', () => { await shutdown.awaitFinished(); assert.equal(handlers.size, 0); + assert.equal(order.indexOf('deep-links-close') > order.indexOf('shutdown-started'), true); + assert.equal(order.indexOf('deep-links-close') < order.indexOf('ipc-close'), true); + assert.equal(order.indexOf('deep-links-drain') > order.indexOf('ipc-close'), true); assert.equal(order.indexOf('profiles-close') > order.indexOf('ipc-drain'), true); assert.equal(order.indexOf('session-dispose') > order.indexOf('profiles-close'), true); assert.deepEqual(order.slice(-3), ['ipc-dispose', 'window-destroy', 'app-quit']); diff --git a/apps/desktop/src/ipc.test.ts b/apps/desktop/src/ipc.test.ts index fea2b754e..45eb9799c 100644 --- a/apps/desktop/src/ipc.test.ts +++ b/apps/desktop/src/ipc.test.ts @@ -2,8 +2,33 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import type { Session } from 'electron'; import { clearDesktopInstanceCookies, logoutDesktopSession } from './desktop-session'; +import { isValidDesktopDeepLinkAcknowledgement } from './ipc'; describe('desktop session IPC operations', () => { + it('accepts only acknowledgements semantically bound to the delivered deep link', () => { + const connect = { + deliveryId: 1, + url: 'propr://connect?api=https%3A%2F%2Fconnect.propr.dev', + consumption: { kind: 'connect-confirmation', target: 'https://connect.propr.dev' }, + }; + const open = { + deliveryId: 2, + url: 'propr://open?path=%2Ftasks%3Fstatus%3Dopen', + consumption: { kind: 'open-queued', target: '/tasks?status=open' }, + }; + assert.equal(isValidDesktopDeepLinkAcknowledgement(connect), true); + assert.equal(isValidDesktopDeepLinkAcknowledgement(open), true); + assert.equal(isValidDesktopDeepLinkAcknowledgement({ + ...connect, + consumption: { kind: 'connect-confirmation', target: 'https://attacker.example' }, + }), false); + assert.equal(isValidDesktopDeepLinkAcknowledgement({ + ...open, + consumption: { kind: 'open-navigated', target: '/plans' }, + }), false); + assert.equal(isValidDesktopDeepLinkAcknowledgement({ ...connect, extra: true }), false); + }); + it('logs out through the active Electron session with credentials and without following redirects', async () => { const requests: Array<{ url: string; init: RequestInit | undefined }> = []; const desktopSession: Pick = { diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index 30b6cc3f7..ae029a342 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -6,9 +6,14 @@ import type { DesktopLogger } from './logger'; import type { LocalLifecycleController } from './lifecycle'; import type { ProfileStore } from './profile-store'; import type { DesktopSetupController } from './setup-controller'; -import { isSafeExternalUrl, isTrustedRendererUrl } from './security'; +import { + connectApiBaseUrlFromDeepLink, + dashboardPathFromDeepLink, + isSafeExternalUrl, + isTrustedRendererUrl, +} from './security'; import { IPC_CHANNELS } from './shared/contract'; -import type { DesktopAcceptanceJourneyStage } from './shared/contract'; +import type { DesktopAcceptanceJourneyStage, DesktopDeepLinkAcknowledgement } from './shared/contract'; export type DesktopAcceptanceOperation = 'PROFILE_SAVE' | 'PAIR' | 'PROBE' | 'ACTIVATE'; export type DesktopAcceptanceOperationStatus = @@ -32,6 +37,7 @@ interface RegisterIpcOptions { devServerUrl: string | undefined; packagedRendererUrl: string; openExternal(url: string): Promise; + acknowledgeDeepLink?(event: IpcMainInvokeEvent, acknowledgement: DesktopDeepLinkAcknowledgement): boolean; onRendererActiveProfileChanged?(origin: string | null): void; /** @internal Deterministic admitted-work accounting for lifecycle proof. */ observeInvocation?(phase: 'entry' | 'exit', channel: string): void; @@ -80,6 +86,31 @@ const acceptanceStatus = (result: unknown): DesktopAcceptanceOperationStatus => return 'COMPLETED'; }; +export const isValidDesktopDeepLinkAcknowledgement = ( + value: unknown, +): value is DesktopDeepLinkAcknowledgement => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const acknowledgement = value as Record; + if (Object.keys(acknowledgement).some(key => !['deliveryId', 'url', 'consumption'].includes(key)) + || !Number.isSafeInteger(acknowledgement.deliveryId) + || (acknowledgement.deliveryId as number) <= 0 + || typeof acknowledgement.url !== 'string' + || !acknowledgement.consumption || typeof acknowledgement.consumption !== 'object' + || Array.isArray(acknowledgement.consumption)) return false; + const consumption = acknowledgement.consumption as Record; + if (Object.keys(consumption).some(key => !['kind', 'target'].includes(key)) + || !['connect-confirmation', 'open-queued', 'open-navigated'].includes(consumption.kind as string) + || typeof consumption.target !== 'string' || consumption.target.length === 0 + || consumption.target.length > 2_048) return false; + const expectedConnectTarget = connectApiBaseUrlFromDeepLink(acknowledgement.url); + const expectedOpenTarget = dashboardPathFromDeepLink(acknowledgement.url); + return expectedConnectTarget !== null + ? consumption.kind === 'connect-confirmation' && consumption.target === expectedConnectTarget + : expectedOpenTarget !== null + && (consumption.kind === 'open-queued' || consumption.kind === 'open-navigated') + && consumption.target === expectedOpenTarget; +}; + export const registerIpcHandlers = (options: RegisterIpcOptions): RegisteredIpcHandlers => { const channels = new Set(); const active = new Set>(); @@ -89,10 +120,10 @@ export const registerIpcHandlers = (options: RegisterIpcOptions): RegisteredIpcH const senderUrl = event.senderFrame?.url ?? ''; return isTrustedRendererUrl(senderUrl, options.devServerUrl, options.packagedRendererUrl); }; - const handle = (channel: string, handler: Handler): void => { + const handle = (channel: string, handler: Handler, completesAdmittedWork = false): void => { channels.add(channel); options.ipcMain.handle(channel, async (event, ...args) => { - if (closing) throw closingError(); + if (closing && !completesAdmittedWork) throw closingError(); if (!trusted(event)) { options.logger.log('warn', 'desktop.ipc.rejected', { channel }); throw new Error('Untrusted desktop IPC sender'); @@ -142,6 +173,14 @@ export const registerIpcHandlers = (options: RegisterIpcOptions): RegisteredIpcH arch: process.arch, packaged: options.app.isPackaged, })); + handle(IPC_CHANNELS.deepLinkAcknowledgement, (event, acknowledgement, ...args) => { + if (args.length || !isValidDesktopDeepLinkAcknowledgement(acknowledgement)) { + throw new Error('Invalid desktop deep-link acknowledgement'); + } + if (!options.acknowledgeDeepLink?.(event, acknowledgement)) { + throw new Error('Unexpected desktop deep-link acknowledgement'); + } + }, true); 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'); @@ -266,6 +305,9 @@ export const registerIpcHandlers = (options: RegisterIpcOptions): RegisteredIpcH if (closing) return; closing = true; for (const channel of channels) { + // This channel completes deep links accepted before admission closed. + // Final disposal removes it after the bounded shutdown drain. + if (channel === IPC_CHANNELS.deepLinkAcknowledgement) continue; options.ipcMain.removeHandler(channel); options.ipcMain.handle(channel, () => Promise.reject(closingError())); } diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index c28f53214..4af1e56d1 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -18,7 +18,8 @@ import { createDesktopSetupHost } from '@propr/cli/desktop-local-setup'; import type { SetupActions } from '@propr/local-setup'; import { launchDesktopAuthentication } from './authentication-handoff'; import { DesktopConnectDiscoveryService } from './connect-discovery'; -import { DeepLinkDelivery } from './deep-link-delivery'; +import { DeepLinkDelivery, deepLinkAcknowledgementTimeoutMs } from './deep-link-delivery'; +import { handleDeepLinkDeliveryFailure } from './deep-link-failure-policy'; import { clearDesktopInstanceCookies } from './desktop-session'; import { DesktopCredentialService, type DesktopPairingBrowserRequest } from './credential-service'; import { @@ -53,10 +54,12 @@ import { DESKTOP_PROTOCOL, IPC_CHANNELS, type DesktopAcceptanceJourneyStage, + 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 { configureDesktopSessionSecurity, type DesktopNetworkPermissionEvidence, @@ -80,6 +83,12 @@ const PACKAGED_CONNECT_JOURNEY_STAGE_EVENT = 'desktop.renderer.connect_journey.s const PACKAGED_CONNECT_JOURNEY_FAILURE_EVENT = 'desktop.renderer.connect_journey.failure'; const PACKAGED_CONNECT_JOURNEY_OPERATION_EVENT = 'desktop.renderer.connect_journey.operation'; const PACKAGED_CONNECT_RENDERER_OWNERSHIP_EVENT = 'desktop.renderer.connect_request_ownership'; +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'; type PackagedConnectJourneyStage = | 'JOURNEY_DISCOVERY_RENDERER' | 'JOURNEY_DISCOVERY_VALIDATED' @@ -122,6 +131,7 @@ const packagedRendererRoot = join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAM 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, @@ -131,12 +141,25 @@ 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 = createPackagedSmokeEvidenceSink(packagedSmokeUserDataDirectory, nativeSmokePhase); packagedSmokeEvidence?.write('desktop.smoke.authorized'); } } catch { @@ -144,11 +167,12 @@ try { } const packagedSmokeTest = packagedSmokeUserDataDirectory !== null; let mainWindow: BrowserWindow | null = null; +let nativeSmokeWindow: BrowserWindow | null = null; const initialDeepLink = deepLinkFromArguments(process.argv); -const deepLinkDelivery = new DeepLinkDelivery( - IPC_CHANNELS.deepLink, - initialDeepLink ? [initialDeepLink] : [], -); +const nativeObservedEvents = new Set(); +let nativeRendererReady = false; +let nativeCompletionStarted = false; +let nativeProfiles: ProfileStore | null = null; let logger: DesktopLogger | null = null; let shutdownStarted = false; if (process.platform === 'win32') { @@ -255,6 +279,174 @@ const log = (level: 'debug' | 'info' | 'warn' | 'error', event: string, fields?: } }; +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(); + }, + () => { + handleDeepLinkDeliveryFailure(nativeSmokePhase !== undefined, { + exit: code => app.exit(code), + log, + }); + }, + Date.now, + 1_000, + deepLinkAcknowledgementTimeoutMs(nativeSmokePhase !== undefined), +); + +const runNativeSecureStorageProbe = async (): Promise => { + if (nativeSmokePhase !== 'first' || !nativeProfiles) return; + recordNativeEvent('desktop.native.secure_storage_probe.started'); + const storage = nativeProfiles.security(); + const credential = { + version: 2 as const, + profileId: 'native-local', + origin: 'http://localhost:44221', + publicInstanceIdentity: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + token: `propr_it_${'a'.repeat(43)}`, + }; + const credentialWrite = await nativeProfiles.writeCredential(credential); + if (storage.available) { + const stored = await nativeProfiles.readCredential('native-local'); + if (storage.backend === 'basic_text' || !credentialWrite.stored + || stored?.token !== credential.token || stored.origin !== credential.origin) { + throw new Error('Native secure-storage custody probe did not use OS encryption'); + } + await nativeProfiles.removeCredential('native-local'); + if (await nativeProfiles.readCredential('native-local') !== null) { + throw new Error('Native secure-storage custody probe cleanup failed'); + } + const pending = await nativeProfiles.pendingRevocations(); + if (pending.length !== 1 || pending[0].credential.token !== credential.token + || !await nativeProfiles.completePendingRevocation( + pending[0].id, + pending[0].credential, + pending[0].credentialGeneration, + )) { + throw new Error('Native secure-storage custody probe cleanup failed'); + } + } else if (credentialWrite.stored || await nativeProfiles.readCredential('native-local') !== null) { + throw new Error('Native secure-storage custody probe allowed plaintext fallback'); + } + const expectedBackend = process.platform === 'linux' ? 'gnome_libsecret' : 'os-protected'; + if (!storage.available || storage.backend !== expectedBackend) { + throw new Error('Native artifact did not retain its expected secure-storage custody'); + } + recordNativeEvent('desktop.native.secure_storage_probe.completed'); + recordNativeEvent('desktop.native.secure_storage_enforced'); +}; + +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 input = Array.from(document.querySelectorAll('label')).find(label => + label.textContent?.includes('Instance URL'))?.querySelector('input'); + if (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 !== 'native-local' || 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 reportPackagedConnectJourneyStage = ( code: PackagedConnectJourneyStage, evidence: { storageBackend: 'gnome_libsecret' | 'os-protected' } | undefined = undefined, @@ -1041,7 +1233,41 @@ const createMainWindow = async ( 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 expectedInitialApi = nativeSmokePhase === 'first' + ? 'http://localhost:44111' + : 'https://t-native-relaunch.propr.dev'; + let initialEndpointVisible: unknown; + try { + initialEndpointVisible = await window.webContents.executeJavaScript(`(async () => { + const deadline = performance.now() + 2000; + do { + const input = Array.from(document.querySelectorAll('label')).find(label => + label.textContent?.includes('Instance URL'))?.querySelector('input'); + if (input?.value === ${JSON.stringify(expectedInitialApi)}) return true; + await new Promise(resolve => setTimeout(resolve, 25)); + } while (performance.now() < deadline); + return false; + })()`); + } catch (error) { + recordNativeEvent('desktop.native.cold_confirmation_inspection_failed'); + throw error; + } + if (!initialEndpointVisible) { + recordNativeEvent('desktop.native.cold_confirmation_not_visible'); + throw new Error('Native cold deep link did not reach the confirmation UI'); + } + if (nativeSmokePhase === 'first') { + await runNativeSecureStorageProbe(); + recordNativeEvent('desktop.native.profile_fresh'); + } else { + recordNativeEvent('desktop.native.profile_preserved'); + recordNativeEvent('desktop.deeplink.confirmation_required'); + } + } const smokeProfileApiUrl = process.env.PROPR_DESKTOP_SMOKE_PROFILE_API_URL; if (packagedSmokeTest && !transportSmoke && smokeProfileApiUrl) { const normalizedSmokeApiUrl = normalizeApiBaseUrl(smokeProfileApiUrl); @@ -1068,43 +1294,7 @@ const createMainWindow = async ( log('info', 'desktop.renderer.profile_api.ready', { origin: DESKTOP_RENDERER_ORIGIN }); } let mvpFlowProof: Record = { connectDiscovery: true }; - if (packagedSmokeTest && !transportSmoke && !connectJourney) { - const profileFlow = await window.webContents.executeJavaScript(`(async () => { - const bridge = window.proprDesktop; - const local = await bridge.profiles.save({ label: 'Local setup', apiBaseUrl: 'http://localhost:4000' }); - const remote = await bridge.profiles.save({ label: 'ProPR Connect', apiBaseUrl: 'https://connect.propr.dev' }); - await bridge.profiles.setActive(remote.id); - const profiles = await bridge.profiles.list(); - const lifecycle = await bridge.lifecycle.start(); - const deadline = performance.now() + 2000; - let connectDeepLink = false; - do { - const labels = Array.from(document.querySelectorAll('.desktop-welcome-card form > label')); - connectDeepLink = labels[1]?.querySelector('input')?.value === 'https://connect.propr.dev'; - if (connectDeepLink) break; - await new Promise(resolve => setTimeout(resolve, 25)); - } while (performance.now() < deadline); - return { - active: profiles.activeProfileId === remote.id, - local: profiles.profiles.some(profile => profile.id === local.id && profile.apiBaseUrl === 'http://localhost:4000'), - remote: profiles.profiles.some(profile => profile.id === remote.id && profile.apiBaseUrl === 'https://connect.propr.dev'), - lifecycleBoundary: lifecycle.ok === false && lifecycle.code === 'not-implemented', - connectDeepLink, - }; - })()`); - if (!profileFlow?.active || !profileFlow?.local || !profileFlow?.remote - || !profileFlow?.lifecycleBoundary || !profileFlow?.connectDeepLink) { - throw new Error('Packaged desktop local/remote/API profile flow failed'); - } - mvpFlowProof = { - connectDiscovery: true, - localProfile: profileFlow.local, - remoteActiveProfile: profileFlow.active && profileFlow.remote, - lifecycleBoundary: profileFlow.lifecycleBoundary, - connectUiPopulated: profileFlow.connectDeepLink, - }; - await closePackagedProfileEditorAndWaitForWelcomeChooser(window); - } else if (packagedSmokeTest) { + const assertPackagedMvpBoundary = async (): Promise => { const boundary = await window.webContents.executeJavaScript(`(async () => { const bridge = window.proprDesktop; const metadata = await bridge.app.getMetadata(); @@ -1119,6 +1309,49 @@ const createMainWindow = async ( if (!boundary?.packaged || !boundary?.profiles || !boundary?.lifecycleBoundary) { throw new Error('Packaged desktop transport smoke did not preserve the MVP bridge boundaries'); } + }; + if (packagedSmokeTest && !transportSmoke && !connectJourney) { + if (nativeSmokePhase) { + await assertPackagedMvpBoundary(); + } else { + const profileFlow = await window.webContents.executeJavaScript(`(async () => { + const bridge = window.proprDesktop; + const local = await bridge.profiles.save({ label: 'Local setup', apiBaseUrl: 'http://localhost:4000' }); + const remote = await bridge.profiles.save({ label: 'ProPR Connect', apiBaseUrl: 'https://connect.propr.dev' }); + await bridge.profiles.setActive(remote.id); + const profiles = await bridge.profiles.list(); + const lifecycle = await bridge.lifecycle.start(); + const deadline = performance.now() + 2000; + let connectDeepLink = false; + do { + const labels = Array.from(document.querySelectorAll('.desktop-welcome-card form > label')); + connectDeepLink = labels[1]?.querySelector('input')?.value === 'https://connect.propr.dev'; + if (connectDeepLink) break; + await new Promise(resolve => setTimeout(resolve, 25)); + } while (performance.now() < deadline); + return { + active: profiles.activeProfileId === remote.id, + local: profiles.profiles.some(profile => profile.id === local.id && profile.apiBaseUrl === 'http://localhost:4000'), + remote: profiles.profiles.some(profile => profile.id === remote.id && profile.apiBaseUrl === 'https://connect.propr.dev'), + lifecycleBoundary: lifecycle.ok === false && lifecycle.code === 'not-implemented', + connectDeepLink, + }; + })()`); + if (!profileFlow?.active || !profileFlow?.local || !profileFlow?.remote + || !profileFlow?.lifecycleBoundary || !profileFlow?.connectDeepLink) { + throw new Error('Packaged desktop local/remote/API profile flow failed'); + } + mvpFlowProof = { + connectDiscovery: true, + localProfile: profileFlow.local, + remoteActiveProfile: profileFlow.active && profileFlow.remote, + lifecycleBoundary: profileFlow.lifecycleBoundary, + connectUiPopulated: profileFlow.connectDeepLink, + }; + await closePackagedProfileEditorAndWaitForWelcomeChooser(window); + } + } else if (packagedSmokeTest) { + await assertPackagedMvpBoundary(); } if (packagedSmokeTest && !connectJourney) { log('info', 'desktop.renderer.mvp_flows.ready', mvpFlowProof); @@ -1128,6 +1361,8 @@ const createMainWindow = async ( }); } log('info', 'desktop.renderer.ready', { preloadBridgeExposed: true }); + nativeRendererReady = true; + maybeCompleteNativeFirstLaunch(); return window; }; @@ -1146,6 +1381,7 @@ if (!hasSingleInstanceLock) { if (shutdownStarted) return; const deepLink = deepLinkFromArguments(argv); if (deepLink) deliverDeepLink(deepLink); + else recordNativeRejectedArguments(argv); if (mainWindow) { if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.show(); @@ -1160,6 +1396,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'); + } const transportSmoke = packagedTransportSmoke(); activePackagedTransportSmoke = transportSmoke; const connectSmoke = packagedConnectSmoke(); @@ -1207,6 +1453,14 @@ if (!hasSingleInstanceLock) { decrypt: value => safeStorage.decryptString(value), }; const profiles = new ProfileStore(app.getPath('userData'), productionEncryption); + if (nativeSmokePhase) { + const security = profiles.security(); + const expectedBackend = process.platform === 'linux' ? 'gnome_libsecret' : 'os-protected'; + if (!security.available || security.backend !== expectedBackend) { + recordNativeEvent('desktop.native.secure_storage_backend_invalid'); + throw new Error('Native artifact secure-storage backend is unavailable'); + } + } const connectDiscovery = new DesktopConnectDiscoveryService(profiles, { supported: DESKTOP_CONNECT_DISCOVERY_PLATFORMS.has(process.platform), discover: async () => { @@ -1277,6 +1531,32 @@ if (!hasSingleInstanceLock) { retryPending: credentialInitialization.retryPending, }); } + if (nativeSmokePhase) { + const current = await profiles.list(); + const expected = new Map([ + ['native-local', 'http://localhost:44221'], + ['native-tunnel', 'https://t-preserved.propr.dev'], + ]); + if (nativeSmokePhase === 'first') { + if (current.activeProfileId !== null || current.profiles.length !== 0) { + throw new Error('Native first launch did not start with an isolated profile'); + } + await profiles.save({ + id: 'native-local', + label: 'Preserved local profile', + apiBaseUrl: expected.get('native-local')!, + }); + await profiles.save({ + id: 'native-tunnel', + label: 'Native ProPR Connect tunnel', + apiBaseUrl: expected.get('native-tunnel')!, + }); + await profiles.setActive('native-local'); + } else if (current.activeProfileId !== 'native-local' || current.profiles.length !== expected.size + || current.profiles.some(profile => expected.get(profile.id) !== profile.apiBaseUrl)) { + throw new Error('Native relaunch did not preserve the non-secret profile state exactly'); + } + } if (app.isPackaged && !rendererPolicyPinnedForSmoke) { const current = await credentials.listProfiles(); const activeOrigin = current.profiles @@ -1284,6 +1564,7 @@ if (!hasSingleInstanceLock) { rendererPolicyOrigins = activeOrigin?.startsWith('http://') ? [activeOrigin] : []; } const lifecycle = new LocalLifecycleController(); + nativeProfiles = profiles; const setupHost = process.platform === 'linux' ? await createDesktopSetupHost({ configDir: join(app.getPath('userData'), 'local-setup', 'cli'), @@ -1322,6 +1603,8 @@ if (!hasSingleInstanceLock) { devServerUrl, packagedRendererUrl, openExternal: openAllowedExternalUrl, + acknowledgeDeepLink: (event, acknowledgement) => + deepLinkDelivery.acknowledgeSender(event.sender, acknowledgement), ...(app.isPackaged && !rendererPolicyPinnedForSmoke ? { onRendererActiveProfileChanged: (origin: string | null) => { const nextOrigins = origin?.startsWith('http://') ? [origin] : []; @@ -1352,6 +1635,7 @@ if (!hasSingleInstanceLock) { const shutdown = createDesktopShutdownCoordinator({ credentials, lifecycle: shutdownLifecycle, + deepLinks: deepLinkDelivery, setup, ipc: registeredIpc, profiles, @@ -1394,7 +1678,7 @@ if (!hasSingleInstanceLock) { log('info', 'desktop.app.shutdown_retry_requested'); app.quit(); } - } else if (packagedSmokeTest) { + } else if (packagedSmokeTest && nativeSmokePhase !== 'first') { app.quit(); } else { mainWindow.show(); diff --git a/apps/desktop/src/preload-bridge.test.ts b/apps/desktop/src/preload-bridge.test.ts index 895e7167e..a44514ce9 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); } } @@ -104,27 +104,57 @@ 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']); unsubscribe(); assert.equal(ipc.listeners.has(IPC_CHANNELS.deepLink), true); }); - it('buffers startup and second-instance deep links until the renderer subscribes', () => { + it('buffers startup and second-instance deep links until the renderer subscribes', async () => { const ipc = new FakeIpc(); const bridge = createDesktopBridge(ipc); 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', ]); + await new Promise(resolve => setImmediate(resolve)); + assert.deepEqual(ipc.invocations, [ + { + channel: IPC_CHANNELS.deepLinkAcknowledgement, + args: [{ + deliveryId: 1, + url: 'propr://connect?api=http%3A%2F%2Flocalhost%3A4000', + consumption: { kind: 'connect-confirmation', target: 'http://localhost:4000' }, + }], + }, + { + channel: IPC_CHANNELS.deepLinkAcknowledgement, + args: [{ + deliveryId: 2, + url: 'propr://open?path=%2Ftasks', + consumption: { kind: 'open-queued', target: '/tasks' }, + }], + }, + ]); }); }); diff --git a/apps/desktop/src/preload-bridge.ts b/apps/desktop/src/preload-bridge.ts index 6c0a9f83e..8b310fa66 100644 --- a/apps/desktop/src/preload-bridge.ts +++ b/apps/desktop/src/preload-bridge.ts @@ -1,10 +1,15 @@ -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: any) => void): void; - removeListener(channel: string, listener: (event: unknown, value: any) => 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 => @@ -17,18 +22,49 @@ export const createDesktopBridge = ( || process.platform === 'win32', connectJourneyAcceptance = false, ): DesktopBridge => { - const deepLinkListeners = new Set<(url: string) => void>(); - const pendingDeepLinks: string[] = []; + const deepLinkListeners = new Set<(url: string) => ( + DesktopDeepLinkConsumption | null | Promise + )>(); + 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 isConsumption = (value: unknown): value is DesktopDeepLinkConsumption => Boolean( + value && typeof value === 'object' + && ['connect-confirmation', 'open-queued', 'open-navigated'].includes( + (value as DesktopDeepLinkConsumption).kind, + ) + && typeof (value as DesktopDeepLinkConsumption).target === 'string' + && (value as DesktopDeepLinkConsumption).target.length > 0 + && (value as DesktopDeepLinkConsumption).target.length <= 2_048, + ); + const consume = async (delivery: DesktopDeepLinkDelivery): Promise => { + const acknowledgements = (await Promise.all( + [...deepLinkListeners].map(listener => listener(delivery.url)), + )).filter(isConsumption); + if (acknowledgements.length !== 1) return; + const acknowledgement: DesktopDeepLinkAcknowledgement = { + ...delivery, + consumption: acknowledgements[0], + }; + await invoke(ipc, IPC_CHANNELS.deepLinkAcknowledgement, acknowledgement).catch(() => undefined); + }; const setupProgressListeners = new Set<(value: Awaited>) => void>(); ipc.on(IPC_CHANNELS.deepLink, (_event, value) => { + if (!isDelivery(value)) return; if (deepLinkListeners.size === 0) { pendingDeepLinks.push(value); return; } - deepLinkListeners.forEach(listener => listener(value)); + void consume(value).catch(() => undefined); }); ipc.on(IPC_CHANNELS.setupProgress, (_event, value) => { - setupProgressListeners.forEach(listener => listener(value)); + setupProgressListeners.forEach(listener => listener( + value as Awaited>, + )); }); const bridge: DesktopBridge = { @@ -36,7 +72,7 @@ export const createDesktopBridge = ( getMetadata: () => invoke(ipc, IPC_CHANNELS.appMetadata), onDeepLink: (listener) => { deepLinkListeners.add(listener); - pendingDeepLinks.splice(0).forEach(value => listener(value)); + pendingDeepLinks.splice(0).forEach(delivery => { void consume(delivery).catch(() => undefined); }); return () => deepLinkListeners.delete(listener); }, }, diff --git a/apps/desktop/src/release-workflow.test.ts b/apps/desktop/src/release-workflow.test.ts index 960a5c36a..e85abcb93 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', @@ -307,6 +311,37 @@ 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, /keyring_root="\$\(mktemp -d\)"/); + assert.match(validation, /gnome-keyring-daemon --unlock --components=secrets/); + assert.match(validation, /dbus-run-session -- bash -euo pipefail -c '[\s\S]*xvfb-run --auto-servernum/); + assert.match(validation, /run-packaged-darwin-connect-smoke\.sh[\s\S]*native-lifecycle/); + assert.match(validation, /--artifact-directory "\$4"/); + assert.match(validation, /"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 evidence 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.${evidence}`)); + 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 standalone native Windows durability assertions runnable but non-blocking', () => { const section = job('native-windows-durability', 'validation-version'); diff --git a/apps/desktop/src/shared/contract.ts b/apps/desktop/src/shared/contract.ts index 2e6993c76..c7be392f3 100644 --- a/apps/desktop/src/shared/contract.ts +++ b/apps/desktop/src/shared/contract.ts @@ -29,9 +29,24 @@ export const IPC_CHANNELS = Object.freeze({ setupAcquireWebhookSecret: 'desktop:setup-acquire-webhook-secret', setupProgress: 'desktop:setup-progress', deepLink: 'desktop:deep-link', + deepLinkAcknowledgement: 'desktop:deep-link-acknowledgement', acceptanceJourneyStage: 'desktop:acceptance-journey-stage', } 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 DesktopAcceptanceJourneyStage = | 'AUTHENTICATION_REQUIRED' | 'CREDENTIAL_COMMITTED' @@ -178,7 +193,9 @@ export interface DesktopSetupSnapshot { export interface DesktopBridge { app: { getMetadata(): Promise; - onDeepLink(listener: (url: string) => void): () => void; + onDeepLink(listener: ( + url: string, + ) => DesktopDeepLinkConsumption | null | Promise): () => void; }; auth: { logout(apiBaseUrl: string): Promise; diff --git a/apps/desktop/src/shutdown.ts b/apps/desktop/src/shutdown.ts index 47754a871..6ef49ac7e 100644 --- a/apps/desktop/src/shutdown.ts +++ b/apps/desktop/src/shutdown.ts @@ -14,6 +14,7 @@ interface ShutdownOptions { lifecycle: { shutdown(): Promise }; setup?: { shutdown(): Promise }; ipc: RegisteredIpcHandlers; + deepLinks?: { close(): void; whenIdle(): Promise }; profiles: { close(): Promise }; sessionSecurity: { close(): void; dispose(): void }; disposeRendererProtocol(): void; @@ -75,6 +76,8 @@ export const createDesktopShutdownCoordinator = ( state = 'draining'; options.onStarted(); step('admission-closed'); + options.deepLinks?.close(); + if (options.deepLinks) step('deep-links-closed'); options.ipc.close(); step('ipc-closed'); options.sessionSecurity.close(); @@ -90,11 +93,14 @@ export const createDesktopShutdownCoordinator = ( step('setup-drain-started'); const ipcDrain = options.ipc.awaitIdle(); step('ipc-drain-started'); + const deepLinkDrain = options.deepLinks?.whenIdle() ?? Promise.resolve(); + if (options.deepLinks) step('deep-link-drain-started'); completion = bounded(Promise.allSettled([ credentialDrain, lifecycleDrain, setupDrain, ipcDrain, + deepLinkDrain, ]).then(results => { for (const result of results) if (result.status === 'rejected') throw result.reason; }), 'service-drain').then(async () => { 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 49b9cc89f..80c212a51 100644 --- a/apps/desktop/src/smoke-test-authorization.test.ts +++ b/apps/desktop/src/smoke-test-authorization.test.ts @@ -116,7 +116,7 @@ describe('packaged smoke profile authorization', () => { 'utf8', ); const isolation = main.indexOf("app.setPath('userData', packagedSmokeUserDataDirectory)"); - const sink = main.indexOf('createPackagedSmokeEvidenceSink(packagedSmokeUserDataDirectory)'); + const sink = main.indexOf('createPackagedSmokeEvidenceSink(packagedSmokeUserDataDirectory, nativeSmokePhase)'); const authorized = main.indexOf("packagedSmokeEvidence?.write('desktop.smoke.authorized')"); const appReady = main.indexOf("log('info', 'desktop.app.ready'"); const shutdownCoordinator = main.indexOf('const shutdown = createDesktopShutdownCoordinator({'); diff --git a/apps/desktop/src/smoke-test-evidence.test.ts b/apps/desktop/src/smoke-test-evidence.test.ts index d0ff7beea..39dbbf495 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,29 @@ 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'); + first.write('desktop.native.cold_confirmation_not_visible'); + first.write('desktop.renderer.gone'); + first.write('desktop.native.failure:/private/profile?credential=raw'); + relaunch.write('desktop.native.profile_preserved'); + first.close(); + relaunch.close(); + assert.deepEqual(readdirSync(directory).sort(), Object.values(NATIVE_SMOKE_EVIDENCE_FILES).sort()); + const firstRecords = readFileSync( + join(directory, NATIVE_SMOKE_EVIDENCE_FILES.first), + 'utf8', + ).trimEnd().split('\n').map(line => JSON.parse(line)); + assert.deepEqual(firstRecords, [ + { event: 'desktop.native.profile_fresh' }, + { event: 'desktop.native.cold_confirmation_not_visible' }, + { event: 'desktop.renderer.gone' }, + ]); + }); + }); }); diff --git a/apps/desktop/src/smoke-test-evidence.ts b/apps/desktop/src/smoke-test-evidence.ts index a9d26bfb6..018fcb554 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,35 @@ export const PACKAGED_SMOKE_EVIDENCE_EVENTS = [ 'desktop.log.write_failed', ] as const; +export const NATIVE_SMOKE_EVIDENCE_EVENTS = [ + 'desktop.deeplink.delivery_failed', + 'desktop.renderer.gone', + 'desktop.native.identity_verified', + 'desktop.native.cold_confirmation_inspection_failed', + 'desktop.native.cold_confirmation_not_visible', + 'desktop.native.secure_storage_backend_invalid', + 'desktop.native.profile_fresh', + 'desktop.native.profile_preserved', + 'desktop.native.secure_storage_enforced', + '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 +64,14 @@ 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 evidencePath = join( + authorizedUserDataDirectory, + nativePhase ? NATIVE_SMOKE_EVIDENCE_FILES[nativePhase] : PACKAGED_SMOKE_EVIDENCE_FILE, + ); 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 29ab7ec85..c97238acb 100644 --- a/propr-ui/src/desktop-deep-link.test.ts +++ b/propr-ui/src/desktop-deep-link.test.ts @@ -94,14 +94,21 @@ describe('desktop open deep-link navigation', () => { }); describe('desktop deep-link inbox', () => { - it('delivers values received before a consumer subscribes exactly once', () => { + it('delivers values received before a consumer subscribes exactly once with eventual consumption', async () => { const inbox = new DesktopDeepLinkInbox(); const first = vi.fn(); const second = vi.fn(); - inbox.receive('propr://connect?api=https%3A%2F%2Ffirst.example'); + const eventualConsumption = inbox.receive('propr://connect?api=https%3A%2F%2Ffirst.example'); - const unsubscribe = inbox.subscribe(first); + const unsubscribe = inbox.subscribe(value => { + first(value); + return { kind: 'connect-confirmation', target: 'https://first.example' }; + }); expect(first).toHaveBeenCalledOnce(); + await expect(eventualConsumption).resolves.toEqual({ + kind: 'connect-confirmation', + target: 'https://first.example', + }); unsubscribe(); const unsubscribeSecond = inbox.subscribe(second); expect(second).not.toHaveBeenCalled(); diff --git a/propr-ui/src/desktop-deep-link.ts b/propr-ui/src/desktop-deep-link.ts index a81070ed7..3cd0a2ab1 100644 --- a/propr-ui/src/desktop-deep-link.ts +++ b/propr-ui/src/desktop-deep-link.ts @@ -1,4 +1,5 @@ import { dashboardPathFromDeepLink } from '../../apps/desktop/src/security'; +import type { DesktopDeepLinkConsumption } from './desktop/types'; const validProfileId = (value: string): boolean => value.length > 0 && value.length <= 128 && !/[\u0000-\u001F\u007F]/.test(value); @@ -7,6 +8,11 @@ interface PendingNavigation { profileId: string; } +export interface DesktopDeepLinkNavigationResult { + path: string; + state: 'queued' | 'navigated'; +} + /** Holds accepted routes while binding each one to the profile active when it arrived. */ export class DesktopDeepLinkNavigation { private activeProfileId: string | null = null; @@ -17,19 +23,27 @@ export class DesktopDeepLinkNavigation { private readonly reject: () => void = () => undefined, ) {} - receive(value: string, profileId: string): boolean { + receiveWithState(value: string, profileId: string): DesktopDeepLinkNavigationResult | null { const path = dashboardPathFromDeepLink(value); if (!path || !validProfileId(profileId)) { this.reject(); - return false; + return null; + } + if (this.activeProfileId === profileId) { + this.navigate(path); + return { path, state: 'navigated' }; + } else if (this.activeProfileId === null) { + this.pending.push({ path, profileId }); + return { path, state: 'queued' }; } - if (this.activeProfileId === profileId) this.navigate(path); - else if (this.activeProfileId === null) this.pending.push({ path, profileId }); else { this.reject(); - return false; + return null; } - return true; + } + + receive(value: string, profileId: string): boolean { + return this.receiveWithState(value, profileId) !== null; } setDashboardReady(profileId: string): void { @@ -56,18 +70,21 @@ export class DesktopDeepLinkNavigation { /** One-consumer handoff between the desktop bridge and presentation experience. */ export class DesktopDeepLinkInbox { - private listener: ((value: string) => void) | null = null; - private readonly pending: string[] = []; + private listener: ((value: string) => DesktopDeepLinkConsumption | null) | null = null; + private readonly pending: Array<{ + resolve: (consumption: DesktopDeepLinkConsumption | null) => void; + value: string; + }> = []; - receive(value: string): void { - if (this.listener) this.listener(value); - else this.pending.push(value); + receive(value: string): DesktopDeepLinkConsumption | null | Promise { + if (this.listener) return this.listener(value); + return new Promise(resolve => this.pending.push({ resolve, value })); } - subscribe(listener: (value: string) => void): () => void { + subscribe(listener: (value: string) => DesktopDeepLinkConsumption | null): () => void { if (this.listener) throw new Error('Desktop deep-link inbox already has a consumer'); this.listener = listener; - this.pending.splice(0).forEach(value => listener(value)); + this.pending.splice(0).forEach(({ resolve, value }) => resolve(listener(value))); return () => { if (this.listener === listener) this.listener = null; }; diff --git a/propr-ui/src/desktop/DesktopExperience.test.tsx b/propr-ui/src/desktop/DesktopExperience.test.tsx index 384a4798c..5e5b4bc54 100644 --- a/propr-ui/src/desktop/DesktopExperience.test.tsx +++ b/propr-ui/src/desktop/DesktopExperience.test.tsx @@ -1,5 +1,7 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createDesktopBridge, type PreloadIpc } from '../../../apps/desktop/src/preload-bridge'; +import { IPC_CHANNELS } from '../../../apps/desktop/src/shared/contract'; import { DesktopDeepLinkInbox } from '../desktop-deep-link'; import { DEFAULT_LOCAL_API_BASE_URL } from '@propr/shared'; import { DesktopExperience } from './DesktopExperience'; @@ -67,9 +69,16 @@ describe('DesktopExperience', () => { expect(await screen.findByRole('heading', { name: 'Let’s set up this computer' })).toBeInTheDocument(); vi.clearAllMocks(); - act(() => deepLinks.receive('propr://connect?api=https%3A%2F%2Fconnect.propr.dev')); + let consumption: ReturnType = null; + act(() => { + consumption = deepLinks.receive('propr://connect?api=https%3A%2F%2Fconnect.propr.dev'); + }); expect(await screen.findByRole('status')).toHaveTextContent(/untrusted instance address/i); + expect(consumption).toEqual({ + kind: 'connect-confirmation', + target: 'https://connect.propr.dev', + }); expect(screen.getByLabelText('Instance URL')).toHaveValue('https://connect.propr.dev'); expect(screen.getByRole('button', { name: 'Connect' })).toBeInTheDocument(); expect(adapters.discovery.discover).not.toHaveBeenCalled(); @@ -88,6 +97,44 @@ describe('DesktopExperience', () => { expect(adapters.connection.activate).toHaveBeenCalledOnce(); }); + it('acknowledges a preload delivery after the inbox and hook consume buffered work', async () => { + const invocations: Array<{ channel: string; args: unknown[] }> = []; + let receiveFromMain: ((event: unknown, value: unknown) => void) | undefined; + const ipc: PreloadIpc = { + invoke: async (channel, ...args) => { invocations.push({ channel, args }); }, + on: (channel, listener) => { + if (channel === IPC_CHANNELS.deepLink) receiveFromMain = listener; + }, + removeListener: () => undefined, + }; + const bridge = createDesktopBridge(ipc); + const delivery = { + deliveryId: 41, + url: 'propr://connect?api=https%3A%2F%2Fconnect.propr.dev', + }; + receiveFromMain?.({}, delivery); + + const inbox = new DesktopDeepLinkInbox(); + const unsubscribe = bridge.app.onDeepLink(value => inbox.receive(value)); + const adapters = adaptersFor(); + const rendered = render( +
Shared route tree
+ ); + try { + expect(await screen.findByLabelText('Instance URL')).toHaveValue('https://connect.propr.dev'); + await waitFor(() => expect(invocations).toEqual([{ + channel: IPC_CHANNELS.deepLinkAcknowledgement, + args: [{ + ...delivery, + consumption: { kind: 'connect-confirmation', target: 'https://connect.propr.dev' }, + }], + }])); + } finally { + unsubscribe(); + rendered.unmount(); + } + }); + it('returns from the prefilled profile editor to every packaged-layout chooser element', async () => { const adapters = adaptersFor(); const deepLinks = new DesktopDeepLinkInbox(); @@ -120,9 +167,13 @@ describe('DesktopExperience', () => { render(
Connected app
); expect(await screen.findByText('Connected app')).toBeInTheDocument(); - act(() => deepLinks.receive('propr://open?path=%2Ftasks%3Fstatus%3Dopen')); + let consumption: ReturnType = null; + act(() => { + consumption = deepLinks.receive('propr://open?path=%2Ftasks%3Fstatus%3Dopen'); + }); expect(window.location.hash).toBe('#/tasks?status=open'); + expect(consumption).toEqual({ kind: 'open-navigated', target: '/tasks?status=open' }); expect(screen.queryByLabelText('Instance URL')).not.toBeInTheDocument(); }); @@ -133,11 +184,15 @@ describe('DesktopExperience', () => { expect(await screen.findByRole('heading', { name: 'Let’s set up this computer' })).toBeInTheDocument(); vi.clearAllMocks(); - act(() => deepLinks.receive('propr://connect?api=SENTINEL_ATTACKER_VALUE&token=secret')); + let consumption: ReturnType = null; + act(() => { + consumption = deepLinks.receive('propr://connect?api=SENTINEL_ATTACKER_VALUE&token=secret'); + }); const alert = await screen.findByRole('alert'); expect(alert).toHaveTextContent('ProPR Desktop could not use that link. Choose an instance and try again.'); expect(alert).not.toHaveTextContent('SENTINEL_ATTACKER_VALUE'); + expect(consumption).toBeNull(); expect(adapters.connection.probe).not.toHaveBeenCalled(); expect(adapters.authentication.authenticate).not.toHaveBeenCalled(); expect(adapters.profiles.save).not.toHaveBeenCalled(); diff --git a/propr-ui/src/desktop/types.ts b/propr-ui/src/desktop/types.ts index abfcb7129..1626388b8 100644 --- a/propr-ui/src/desktop/types.ts +++ b/propr-ui/src/desktop/types.ts @@ -1,5 +1,10 @@ export type DesktopPlatform = 'macos' | 'windows' | 'linux'; +export type DesktopDeepLinkConsumption = { + kind: 'connect-confirmation' | 'open-queued' | 'open-navigated'; + target: string; +}; + export interface DesktopProfile { id: string; name: string; @@ -101,7 +106,9 @@ export interface DesktopManagedTunnelRecoveryAdapter { export interface DesktopAdapters { platform: DesktopPlatform; app: { - onDeepLink(listener: (url: string) => void): () => void; + onDeepLink(listener: ( + url: string, + ) => DesktopDeepLinkConsumption | null | Promise): () => void; }; profiles: DesktopProfileAdapter; discovery: DesktopDiscoveryAdapter; diff --git a/propr-ui/src/desktop/useDesktopDeepLinks.ts b/propr-ui/src/desktop/useDesktopDeepLinks.ts index 0e8a7a745..cbf84ec2f 100644 --- a/propr-ui/src/desktop/useDesktopDeepLinks.ts +++ b/propr-ui/src/desktop/useDesktopDeepLinks.ts @@ -1,9 +1,9 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import type { RefObject } from 'react'; import { isProprLoopbackHostname } from '@propr/shared'; -import { connectApiBaseUrlFromDeepLink } from '../../../apps/desktop/src/security'; +import { connectApiBaseUrlFromDeepLink, dashboardPathFromDeepLink } from '../../../apps/desktop/src/security'; import { DesktopDeepLinkNavigation, type DesktopDeepLinkInbox } from '../desktop-deep-link'; -import type { DesktopProfile } from './types'; +import type { DesktopDeepLinkConsumption, DesktopProfile } from './types'; const REJECTED_DEEP_LINK_MESSAGE = 'ProPR Desktop could not use that link. Choose an instance and try again.'; const CONNECT_CANDIDATE_NOTICE = 'Review this untrusted instance address, then choose Connect to continue.'; @@ -55,7 +55,7 @@ export const useDesktopDeepLinks = ({ }, () => setDeepLinkError(REJECTED_DEEP_LINK_MESSAGE), )); - const handler = useRef<(value: string) => void>(() => undefined); + const handler = useRef<(value: string) => DesktopDeepLinkConsumption | null>(() => null); handler.current = value => { let action: string | null = null; @@ -70,7 +70,7 @@ export const useDesktopDeepLinks = ({ const baseUrl = connectApiBaseUrlFromDeepLink(value); if (!baseUrl) { setDeepLinkError(REJECTED_DEEP_LINK_MESSAGE); - return; + return null; } const candidate: DesktopProfile = { id: createProfileId(), @@ -82,26 +82,44 @@ export const useDesktopDeepLinks = ({ setDeepLinkError(null); setEditorNotice(CONNECT_CANDIDATE_NOTICE); stageCandidateRef.current(candidate, phaseRef.current); - return; + return { kind: 'connect-confirmation', target: baseUrl }; } if (action === 'open') { const currentPhase = phaseRef.current; const currentProfileId = profileIdRef.current; if (currentPhase === 'loading') { + const path = dashboardPathFromDeepLink(value); + if (!path) { + setDeepLinkError(REJECTED_DEEP_LINK_MESSAGE); + return null; + } startupOpenLinks.current.push(value); - return; + return { kind: 'open-queued', target: path }; } - if ((currentPhase === 'connecting' || currentPhase === 'connected') && currentProfileId) { - if (activeProfileId.current !== currentProfileId - || !navigation.receive(value, currentProfileId)) { + const boundProfileId = currentPhase === 'connecting' || currentPhase === 'connected' + ? currentProfileId + : activeProfileId.current; + if (boundProfileId) { + if ((currentPhase === 'connecting' || currentPhase === 'connected') + && activeProfileId.current !== boundProfileId) { + setDeepLinkError(REJECTED_DEEP_LINK_MESSAGE); + return null; + } + const result = navigation.receiveWithState(value, boundProfileId); + if (!result) { setDeepLinkError(REJECTED_DEEP_LINK_MESSAGE); + return null; } - return; + return { + kind: result.state === 'queued' ? 'open-queued' : 'open-navigated', + target: result.path, + }; } } setDeepLinkError(REJECTED_DEEP_LINK_MESSAGE); + return null; }; useEffect(() => deepLinks?.subscribe(value => handler.current(value)), [deepLinks]);