diff --git a/structure/desktop-shell.md b/structure/desktop-shell.md index bc93235d22d..23100ff6c4e 100644 --- a/structure/desktop-shell.md +++ b/structure/desktop-shell.md @@ -141,6 +141,18 @@ The bundled CLI answers it. Until that contract lands, `resolve` returns *unavai not the same as "nobody owns it" — the question has not been put — so the shell attempts no takeover and records nothing, and the startup state and the diagnostic say which of the two it is. +### Desktop runtime ownership acceptance + +The consent surface labels the exact ownership subject it is about to record. A relaunch of the +same desktop installation reuses consent when the recorded `owner` and app-local `installId` still +match. Package update and service repair also preserve that grant instead of reviving the package +service when the approved subject is unchanged. Any different `owner`, different `installId`, moved +`consentGeneration` or unreadable ownership record is not reuse: the app must ask again or refuse +closed before writing. Uninstall or an explicit handback releases only the live claim and keeps the +generation ceiling, so a later grant cannot be mistaken for the old one. A runtime still attached to +an old package-owned registration is only attachable as a guest until an ownership-aware CLI records +protocol support; the shell must not treat that attachment as durable takeover consent. + `desktop/src-tauri/src/first_run.rs` turns Start at Login on once per installation, before the tray is built so its checkbox reads the resulting state. A menu bar app that is not running has no menu bar item, so leaving autostart off by default left an diff --git a/tests/clients/desktop-install-identity.test.ts b/tests/clients/desktop-install-identity.test.ts index 4772e1a6c95..811ab8c64d8 100644 --- a/tests/clients/desktop-install-identity.test.ts +++ b/tests/clients/desktop-install-identity.test.ts @@ -22,6 +22,8 @@ const IDENTITY = repoPath(`${SHELL}/identity.rs`); const OWNERSHIP = repoPath(`${SHELL}/ownership.rs`); const STARTUP = repoPath(`${SHELL}/startup.rs`); const STATE = repoPath("src/service/state.ts"); +const COMPATIBILITY = repoPath("src/service/ownership-compatibility.ts"); +const DESKTOP_SHELL_DOC = repoPath("structure/desktop-shell.md"); function code(path: string): string { return readFileSync(path, "utf8").replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, ""); @@ -31,6 +33,8 @@ describe("desktop install identity", () => { const identity = code(IDENTITY); const ownership = code(OWNERSHIP); const state = code(STATE); + const compatibility = code(COMPATIBILITY); + const desktopShellDoc = readFileSync(DESKTOP_SHELL_DOC, "utf8"); test("the installation's id is minted once and never rewritten", () => { // Exclusive, because two launches racing to mint would answer to two ids, and the second one @@ -125,4 +129,50 @@ describe("desktop install identity", () => { expect(startup).toContain("identity::install_id(app)"); expect(startup).toContain('format!("runtime ownership: {}", registration.identity)'); }); + + test("the desktop acceptance text names the consent reuse boundaries", () => { + expect(desktopShellDoc).toContain("Desktop runtime ownership acceptance"); + expect(desktopShellDoc).toMatch(/same desktop installation reuses consent/i); + expect(desktopShellDoc).toMatch(/Package update and service repair also preserve that grant/i); + expect(desktopShellDoc).toMatch(/different `owner`, different `installId`, moved\s+`consentGeneration`/i); + expect(desktopShellDoc).toMatch(/unreadable ownership record is not reuse/i); + expect(desktopShellDoc).toMatch(/Uninstall or an explicit handback releases only the live claim/i); + expect(desktopShellDoc).toMatch(/old package-owned registration is only attachable as a guest/i); + }); + + test("service refresh preserves consent and handback releases only the live claim", () => { + const writer = state.slice(state.indexOf("export function writeServiceInstallState")); + const writerBody = writer.slice(0, writer.indexOf("\n}")); + expect(writerBody).toContain("...preservedConsent(current)"); + const preserve = state.slice(state.indexOf("function preservedConsent")); + const preserveBody = preserve.slice(0, preserve.indexOf("\n}")); + expect(preserveBody).toContain("const ownership = current?.ownership"); + expect(preserveBody).toContain("consentGenerationCeiling"); + const release = state.slice(state.indexOf("export function releaseServiceOwner")); + const releaseBody = release.slice(0, release.indexOf("\n}")); + expect(releaseBody).toContain("const { ownership: _released, ...withoutOwnership } = current"); + expect(releaseBody).toContain("consentGenerationCeiling"); + }); + + test("recording consent revalidates the exact approved subject before reuse", () => { + expect(state).toContain("export class ServiceOwnershipSubjectMismatchError extends Error"); + expect(state).toContain("export class ServiceOwnershipSubjectUnknownError extends Error"); + const record = state.slice(state.indexOf("export function recordServiceOwner")); + expect(record).toContain("sameServiceOwnershipSubject(request.expectedSubject, actualSubject)"); + expect(record).toContain("throw new ServiceOwnershipSubjectMismatchError"); + expect(record).toContain("unknownStateError: reason => new ServiceOwnershipSubjectUnknownError"); + // A moved generation is part of the subject, so a pending approval cannot be reused after + // another writer grants, releases or re-grants ownership. + const sameSubject = state.slice(state.indexOf("export function sameServiceOwnershipSubject")); + const sameSubjectBody = sameSubject.slice(0, sameSubject.indexOf("\n}")); + expect(sameSubjectBody).toContain("left.ownership.consentGeneration === right.ownership.consentGeneration"); + }); + + test("old package-owned registrations cannot become durable desktop ownership", () => { + expect(compatibility).toContain("SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION"); + const assess = compatibility.slice(compatibility.indexOf("export function assessServiceTakeoverCompatibility")); + expect(assess).toContain('reason: "managing-cli-unsupported"'); + expect(assess).toContain('reason: "service-protocol-unsupported"'); + expect(assess).toContain("input.state?.ownershipProtocolVersion !== SERVICE_OWNERSHIP_PROTOCOL_VERSION"); + }); });