mirror of
https://github.com/morluto/rea.git
synced 2026-11-12 01:41:21 +00:00
* fix(desktop): recognize captured file URL documents * fix(desktop): render captured file URLs for runtime home * fix(desktop): distinguish phone numbers from save metadata * fix(desktop): preserve launch URLs and user text * fix(desktop): preserve captured URLs and user text * fix(desktop): preserve local file URL authority * fix(desktop): preserve local file URL authority * fix(desktop): preserve local file URL authority * fix(desktop): render captured file URLs at launch * fix(desktop): classify numeric timestamps using field context * fix(desktop): classify numeric timestamps using field context * fix(desktop): classify numeric timestamps using field context * fix(desktop): retain context for generated draft values * fix(desktop): retain context for generated draft values * fix(desktop): retain context for generated draft values * test(desktop): cover generated IDs in text guards * test(desktop): cover generated IDs in text guards * test(desktop): cover generated IDs in text guards * fix(desktop): isolate launcher URL encoding * fix(desktop): isolate launcher URL encoding * fix(desktop): isolate launcher URL encoding * fix(desktop): isolate launcher URL encoding * fix(desktop): retain clean text in noisy record fields * fix(desktop): classify added line values in one pass * fix(desktop): apply XML quote rules to text drafts * fix(desktop): distinguish XML attributes from quoted text * fix(desktop): preserve parsed XML line context * Format appTaskChecks.ts with the pinned oxfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(desktop): retain partial XML syntax facts * fix(desktop): recognize markdown autolinks in drafts * fix(desktop): reuse export exclusions in task drafts * fix(desktop): report unchecked client build paths * docs(desktop): clarify gzip scrub limits * fix(desktop): reuse export exclusions in task drafts * docs(desktop): clarify gzip scrub limits * fix(setup): own desktop build process cleanup * fix(doctor): compare desktop settings with resolved defaults * style(desktop): format the noisy-record draft files with the pinned oxfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(desktop): give the root-array draft test its own describe The records describe grew past the 150-line lint limit with it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(doctor): inspect client-recorded desktop runners * fix(doctor): reuse matching runner probes * fix(setup): propagate cancellation to desktop actions * fix(desktop): rewrite a download's launch keys written with spaces around = The desktop entry parser accepts `Exec = notes %F` and an indented `TryExec =notes`, so the install script now matches those keys the same way instead of leaving the original program in place. Adds the feature's tasks.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(setup): stop Hopper on cancel, never report a cancelled setup ready, and keep a failed action's client setting - The Hopper install gets setup's signal: Linux already honoured it, and the macOS installer now stops its downloads and stops before mounting or copying. - runSetup checks the signal after the final doctor and close, so a cancellation there raises instead of returning ready. - A failed pack or agentenv install no longer writes its archive or runner to client configs, so the setting the client already had stays. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(setup): keep the completed setup result in a cancellation after cleanup A cancellation during the final doctor or host close carried only the applied actions, dropping each client's result and backup path and the doctor report. It now carries the whole result as setup_result, as doctor's cancellation carries its report. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(desktop): move launch execution to boundary suite * fix(desktop): redact build schema failure output * refactor(desktop): normalize schema failure once * fix(desktop): omit unsafe schema parser snippets * fix(desktop): preserve draft change and selected path evidence * test(desktop): group retained dconf note regressions * fix(desktop): respect archive entry format in draft checks * test(desktop): separate unowned-file draft note cases * fix(desktop): preserve explicitly selected journal documents * fix(xml): avoid scanning selected ancestors per tag * fix(desktop): exclude covered text runs from draft counts * fix: preserve text when drafting desktop checks * fix: restore nested desktop state aliases * refactor: share UUID recognition across draft checks * fix: retain multiline XML attribute context * fix: retain relationship identifiers in task drafts * docs: clarify desktop draft identifier handling * test(desktop): type formatting run cases as pairs * fix(desktop): carry document format into selector evaluation * test(desktop): separate markup boundary regression group * fix(desktop): propagate formats through draft selectors * fix(desktop): switch selector format within archive entries * test(desktop): remove duplicate seed filename case * fix(setup): retain diagnostics when cancellation overlaps failure * refactor(setup): keep command cancellation at its owner * test(setup): verify cancellation at the application boundary * test(desktop): pass JSON format in exported index parity * docs(desktop): clarify task checks before scratch export * fix(desktop): respell selected paths in file URLs * test(desktop): group captured document spelling cases * test(desktop): run installer scripts at boundary * style(desktop): remove trailing blank line * fix(desktop): bound draft preview serialization * fix(desktop): restore selected state aliases * fix(desktop): respect entry format in draft noise * test(desktop): match exported alias evidence at its JSON boundary * fix(desktop): retain app state logs in exported seeds * fix: align desktop numeric seed comparisons * test(desktop): keep one draft exclusion fixture import * test(desktop): retain log eligibility across draft test groups * fix(doctor): resolve empty registered desktop runner * style: fix desktop launch test formatting * style: format native command path schema * fix(desktop): map selected external launch paths * style(desktop): format seed document regressions * test(desktop): group launch URL regressions * fix(desktop): omit redundant nested state aliases * fix(desktop): compare added free text as a whole * fix(desktop): check recompressed seeds as shipped * docs(desktop): clarify recovered seed format * test(desktop): group shipped gzip seed checks * fix: retain quoted identifier text in task drafts * fix: recognize Unicode XML in desktop state readers * fix(desktop): bound nested draft checks * fix(desktop): explain missing arrays in truncated views * fix(desktop): find XML type references in archives * test(desktop): remove duplicated capture assertions * fix(desktop): retain nested omitted text boundaries * style(desktop): format draft value imports * test(desktop): group model and draft value checks * fix(desktop): map captured launch paths once * refactor: remove unused whole-text guard state * refactor: stop passing unused text children * test: align free-text checks with whole-note policy * fix: validate recompressed desktop archive seeds * style(desktop): format archive seed regression * fix(desktop): retain placed-item sibling guards * fix(desktop): preserve archive paths and bound doctor reads * fix(doctor): refuse non-files before descriptor admission * fix(setup): track remaining cleanup resources across retries * test(desktop): specify XML format in heap pairing fixture * fix(setup): skip Hopper launch after cancelled copy * fix(doctor): query agentenv for model endpoint * fix(doctor): preserve uncertainty for empty endpoint override * fix(desktop): redact runtime schema errors * fix(desktop): normalize captured file URLs * fix(desktop): report seeded files by capture path * fix(desktop): retain startup readiness failure stage * fix(desktop): retain JSON date checks * fix(desktop): preserve archive formatting checks * fix(desktop): preserve explicitly selected journal files * fix(desktop): report build command transport limits * docs(desktop): clarify unreadable dconf versions * test(desktop): remove duplicate history regression * fix(desktop): align XML names with exported reader * fix(desktop): create selected state alias targets * fix(desktop): inspect supported runtime archives * fix(desktop): keep ordinary text checks exact * fix(desktop): verify registered build host paths * fix(desktop): retain record identity in draft checks * test(desktop): preserve text reference numbers * fix(desktop): bound download install input reads * fix(desktop): match placed notes by whole text * fix(desktop): scrub XML independently of reader compatibility * fix(desktop): preserve file aliases and reader diagnostics * style(desktop): format review fixes * fix(desktop): inspect archives with registered runner * test(desktop): verify registered archive interpreter * style(desktop): format capture review fixes --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
657 lines
23 KiB
TypeScript
657 lines
23 KiB
TypeScript
import { execFile } from "node:child_process";
|
|
import {
|
|
access,
|
|
mkdir,
|
|
mkdtemp,
|
|
readdir,
|
|
readFile,
|
|
rename,
|
|
rm,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join, posix } from "node:path";
|
|
import { promisify } from "node:util";
|
|
|
|
import { z } from "zod";
|
|
|
|
import { appendCallerPath } from "../filesystem/callerPath.js";
|
|
import type { AnalysisCleanupObservation } from "../domain/analysisErrorBase.js";
|
|
import { HOPPER_PUBLISHER } from "../domain/hopperPublisher.js";
|
|
import { macDeveloperIdRequirement } from "../process/MacCodeSignature.js";
|
|
import { macHopperUserApplicationPath } from "./MacHopperPaths.js";
|
|
import {
|
|
downloadHopperPackage,
|
|
HOPPER_METADATA_TIMEOUT_MS,
|
|
type HopperDownload,
|
|
type HopperRequestFailure,
|
|
} from "./HopperDownload.js";
|
|
import {
|
|
HOPPER_RELEASES_URL,
|
|
HOPPER_DOWNLOAD_PREFIX,
|
|
parseHopperReleaseJson,
|
|
parseMacHopperReleases,
|
|
hopperArchiveIntegrityMatches,
|
|
macHopperReleaseMinimumSystemVersion,
|
|
} from "./HopperReleaseFeed.js";
|
|
import type { HopperInstallFailureReason } from "./SetupInstallFailure.js";
|
|
import { decodeInfoPlistValue } from "./AppBundleExecutable.js";
|
|
import {
|
|
macosMajorVersion,
|
|
macosVersionMeetsMinimum,
|
|
} from "../domain/macosVersion.js";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
const HOPPER_PUBLISHER_REQUIREMENT = macDeveloperIdRequirement(
|
|
HOPPER_PUBLISHER.bundleIdentifier,
|
|
HOPPER_PUBLISHER.teamIdentifier,
|
|
);
|
|
|
|
/** Outcome of reading a mounted bundle's signature and publisher identity. */
|
|
export type MacHopperBundleVerification =
|
|
| "verified"
|
|
| "invalid_signature"
|
|
| "unverified_publisher";
|
|
|
|
type MacHopperInstallFailureReason = Extract<
|
|
HopperInstallFailureReason,
|
|
| "unsupported_host"
|
|
| "incompatible_host_version"
|
|
| "release_metadata"
|
|
| "download"
|
|
| "integrity"
|
|
| "launcher_missing"
|
|
| "destination_exists"
|
|
| "destination_inspection"
|
|
| "mount"
|
|
| "bundle"
|
|
| "publisher_verification_failed"
|
|
| "copy"
|
|
| "cancelled"
|
|
>;
|
|
|
|
export type MacHopperBundleInstallResult =
|
|
| { readonly status: "installed"; readonly cleanupFailure?: string }
|
|
| {
|
|
readonly status: "failed";
|
|
readonly reason: "copy" | "launcher_missing";
|
|
readonly diagnostic: string;
|
|
readonly cleanupFailure?: string;
|
|
};
|
|
|
|
/** Filesystem effects for one installer-owned bundle replacement transaction. */
|
|
export interface MacHopperBundleInstallOperations {
|
|
createStagingRoot(parent: string): Promise<string>;
|
|
copyBundle(source: string, stage: string): Promise<void>;
|
|
requireLauncher(path: string): Promise<void>;
|
|
pathExists(path: string): Promise<boolean>;
|
|
rename(source: string, destination: string): Promise<void>;
|
|
removeStagingRoot(path: string): Promise<void>;
|
|
}
|
|
|
|
/** Stable effects and checks disclosed before macOS Hopper setup. */
|
|
export const macHopperInstallDisclosure = {
|
|
detail:
|
|
"Download the official Hopper package, verify it, and install it. Before the download, and again from the signed app before copying, setup compares the release's minimum macOS version with this Mac's and stops if this Mac is older. Hopper may show its demo or license prompt when first opened.",
|
|
networkOrigins: [HOPPER_RELEASES_URL, HOPPER_DOWNLOAD_PREFIX] as const,
|
|
commands: [
|
|
"hdiutil attach -readonly -nobrowse -mountpoint <private-mount> <verified-hopper.dmg>",
|
|
"codesign --verify --deep --strict <mounted-hopper.app>",
|
|
`codesign --verify --strict '-R${HOPPER_PUBLISHER_REQUIREMENT}' <mounted-hopper.app>`,
|
|
"ditto <mounted-hopper.app> <user-Applications-stage>",
|
|
"open <installed-hopper.app>",
|
|
] as const,
|
|
};
|
|
|
|
/** Result of installing the official Hopper macOS application. */
|
|
export type MacHopperInstallResult =
|
|
| {
|
|
readonly status: "installed";
|
|
readonly launcherPath: string;
|
|
readonly cleanupFailure?: string;
|
|
readonly openFailure?: string;
|
|
}
|
|
| {
|
|
readonly status: "failed";
|
|
readonly reason: MacHopperInstallFailureReason;
|
|
readonly diagnostic?: string;
|
|
/** The vendor request (feed or package) whose transport failure stopped setup. */
|
|
readonly download?: HopperRequestFailure;
|
|
readonly cleanupFailure?: string;
|
|
};
|
|
|
|
/** External effects used by the bounded macOS Hopper installer. */
|
|
export interface MacHopperInstallHost {
|
|
download(
|
|
url: string,
|
|
options?: { readonly timeoutMs?: number; readonly signal?: AbortSignal },
|
|
): Promise<HopperDownload>;
|
|
createTemporaryDirectory(): Promise<string>;
|
|
createMountDirectory(path: string): Promise<void>;
|
|
writePackage(path: string, bytes: Uint8Array): Promise<void>;
|
|
mount(packagePath: string, mountPath: string): Promise<boolean>;
|
|
unmount(mountPath: string): Promise<void>;
|
|
appBundle(mountPath: string): Promise<string | undefined>;
|
|
validBundle(appPath: string): Promise<MacHopperBundleVerification>;
|
|
/** This Mac's version, read through the probe setup planning used. */
|
|
macosVersion(): Promise<string | undefined>;
|
|
/** The mounted app's declared LSMinimumSystemVersion; undefined when it declares none. */
|
|
minimumSystemVersion(appPath: string): Promise<string | undefined>;
|
|
destinationExists(path: string): Promise<boolean>;
|
|
installBundle(
|
|
source: string,
|
|
destination: string,
|
|
replaceExisting: boolean,
|
|
): Promise<MacHopperBundleInstallResult>;
|
|
openApplication(path: string): Promise<void>;
|
|
cleanup(path: string): Promise<void>;
|
|
/** Retain the cleanup capability until the owning setup host retries it. */
|
|
retainCleanup?(
|
|
cleanup: () => Promise<void>,
|
|
resources: readonly string[],
|
|
): void;
|
|
destination(): string;
|
|
}
|
|
|
|
/**
|
|
* Download, verify, and install Hopper into the current user's Applications
|
|
* directory. The selected release's own macOS minimum gates installation: the
|
|
* feed's `deployment_target` before the package is downloaded, and the signed
|
|
* app's `LSMinimumSystemVersion` before it is copied.
|
|
*/
|
|
export const installMacHopper = async (
|
|
options: {
|
|
readonly replaceExisting?: boolean;
|
|
/** Stops downloads and the steps after them; a started copy finishes. */
|
|
readonly signal?: AbortSignal;
|
|
},
|
|
host: MacHopperInstallHost,
|
|
): Promise<MacHopperInstallResult> => {
|
|
const cancellation =
|
|
options.signal === undefined ? {} : { signal: options.signal };
|
|
const hostVersion = await host.macosVersion();
|
|
// Without a comparable host version no declared minimum can be checked.
|
|
if (hostVersion === undefined || macosMajorVersion(hostVersion) === undefined)
|
|
return { status: "failed", reason: "unsupported_host" };
|
|
let temporary: string | undefined;
|
|
let mounted = false;
|
|
let bundleCleanupFailure: string | undefined;
|
|
let installStage: MacHopperInstallFailureReason = "release_metadata";
|
|
let result: MacHopperInstallResult;
|
|
try {
|
|
result = await (async (): Promise<MacHopperInstallResult> => {
|
|
const metadata = await host.download(HOPPER_RELEASES_URL, {
|
|
timeoutMs: HOPPER_METADATA_TIMEOUT_MS,
|
|
...cancellation,
|
|
});
|
|
if (!metadata.ok)
|
|
return {
|
|
status: "failed",
|
|
reason: "release_metadata",
|
|
download: { request: "release_feed", ...metadata.error },
|
|
};
|
|
const decoded = parseHopperReleaseJson(metadata.value);
|
|
if (!decoded.ok) throw decoded.cause;
|
|
const parsed = parseMacHopperReleases(decoded.value);
|
|
if (!parsed.ok) return { status: "failed", reason: "release_metadata" };
|
|
const release = parsed.value;
|
|
const releaseMinimum = macHopperReleaseMinimumSystemVersion(release);
|
|
// An unrecognized feed value defers to the signed app checked below.
|
|
if (
|
|
releaseMinimum !== undefined &&
|
|
macosVersionMeetsMinimum(hostVersion, releaseMinimum) === false
|
|
)
|
|
return {
|
|
status: "failed",
|
|
reason: "incompatible_host_version",
|
|
diagnostic: macHopperHostVersionDetail({
|
|
source: "release_feed",
|
|
packageUrl: release.filename,
|
|
minimumVersion: releaseMinimum,
|
|
hostVersion,
|
|
}),
|
|
};
|
|
installStage = "download";
|
|
const archive = await host.download(release.filename, cancellation);
|
|
if (!archive.ok)
|
|
return {
|
|
status: "failed",
|
|
reason: "download",
|
|
download: { request: "package", ...archive.error },
|
|
};
|
|
installStage = "integrity";
|
|
if (!hopperArchiveIntegrityMatches(archive.value, release))
|
|
return { status: "failed", reason: "integrity" };
|
|
|
|
installStage = "destination_inspection";
|
|
const destination = host.destination();
|
|
const destinationExists = await host.destinationExists(destination);
|
|
if (destinationExists && options.replaceExisting !== true)
|
|
return { status: "failed", reason: "destination_exists" };
|
|
options.signal?.throwIfAborted();
|
|
installStage = "mount";
|
|
temporary = await host.createTemporaryDirectory();
|
|
const packagePath = posix.join(temporary, "hopper.dmg");
|
|
const mountPath = posix.join(temporary, "mounted");
|
|
await host.createMountDirectory(mountPath);
|
|
await host.writePackage(packagePath, archive.value);
|
|
if (!(await host.mount(packagePath, mountPath)))
|
|
return { status: "failed", reason: "mount" };
|
|
mounted = true;
|
|
installStage = "bundle";
|
|
const source = await host.appBundle(mountPath);
|
|
if (source === undefined) return { status: "failed", reason: "bundle" };
|
|
const verification = await host.validBundle(source);
|
|
if (verification === "invalid_signature")
|
|
return { status: "failed", reason: "bundle" };
|
|
if (verification === "unverified_publisher")
|
|
return { status: "failed", reason: "publisher_verification_failed" };
|
|
// An app that declares no minimum is accepted, as macOS does.
|
|
const appMinimum = await host.minimumSystemVersion(source);
|
|
if (appMinimum !== undefined) {
|
|
const compatible = macosVersionMeetsMinimum(hostVersion, appMinimum);
|
|
if (compatible === false)
|
|
return {
|
|
status: "failed",
|
|
reason: "incompatible_host_version",
|
|
diagnostic: macHopperHostVersionDetail({
|
|
source: "signed_app",
|
|
minimumVersion: appMinimum,
|
|
hostVersion,
|
|
}),
|
|
};
|
|
if (compatible === undefined)
|
|
return {
|
|
status: "failed",
|
|
reason: "bundle",
|
|
diagnostic: macHopperIncomparableMinimumDetail(
|
|
appMinimum,
|
|
hostVersion,
|
|
),
|
|
};
|
|
}
|
|
options.signal?.throwIfAborted();
|
|
installStage = "copy";
|
|
const installation = await host.installBundle(
|
|
source,
|
|
destination,
|
|
destinationExists,
|
|
);
|
|
if (installation.status === "failed")
|
|
return {
|
|
status: "failed",
|
|
reason: installation.reason,
|
|
diagnostic: installation.diagnostic,
|
|
...(installation.cleanupFailure === undefined
|
|
? {}
|
|
: { cleanupFailure: installation.cleanupFailure }),
|
|
};
|
|
bundleCleanupFailure = installation.cleanupFailure;
|
|
const launcherPath = appendCallerPath(
|
|
destination,
|
|
"Contents/MacOS/hopper",
|
|
"darwin",
|
|
);
|
|
let openFailure: string | undefined;
|
|
if (options.signal?.aborted === true)
|
|
openFailure = "Setup was cancelled before Hopper was opened";
|
|
else
|
|
try {
|
|
await host.openApplication(destination);
|
|
} catch (cause: unknown) {
|
|
openFailure = failureMessage(cause);
|
|
}
|
|
return {
|
|
status: "installed",
|
|
launcherPath,
|
|
...(openFailure === undefined ? {} : { openFailure }),
|
|
};
|
|
})();
|
|
} catch (cause: unknown) {
|
|
result = {
|
|
status: "failed",
|
|
reason: options.signal?.aborted === true ? "cancelled" : installStage,
|
|
diagnostic: failureMessage(cause),
|
|
};
|
|
}
|
|
|
|
let cleanupFailure: string | undefined;
|
|
if (temporary !== undefined) {
|
|
const temporaryPath = temporary;
|
|
const mountPath = posix.join(temporary, "mounted");
|
|
const cleanup = async (): Promise<void> => {
|
|
if (mounted) {
|
|
await host.unmount(mountPath);
|
|
mounted = false;
|
|
}
|
|
await host.cleanup(temporaryPath);
|
|
};
|
|
try {
|
|
await cleanup();
|
|
} catch (cause: unknown) {
|
|
cleanupFailure = `Hopper DMG cleanup failed: ${failureMessage(cause)}`;
|
|
host.retainCleanup?.(cleanup, [temporaryPath]);
|
|
}
|
|
}
|
|
|
|
const retainedCleanupFailure = [
|
|
bundleCleanupFailure,
|
|
cleanupFailure,
|
|
result.cleanupFailure,
|
|
]
|
|
.filter((failure): failure is string => failure !== undefined)
|
|
.join(" ");
|
|
return retainedCleanupFailure === ""
|
|
? result
|
|
: { ...result, cleanupFailure: retainedCleanupFailure };
|
|
};
|
|
|
|
/** Replace a bundle through a fresh same-volume root owned by this transaction. */
|
|
export const installMacHopperBundle = async (
|
|
source: string,
|
|
destination: string,
|
|
replaceExisting: boolean,
|
|
operations: MacHopperBundleInstallOperations,
|
|
retainCleanup: (
|
|
cleanup: () => Promise<void>,
|
|
resources: readonly string[],
|
|
) => void,
|
|
): Promise<MacHopperBundleInstallResult> => {
|
|
const stagingRoot = await operations.createStagingRoot(
|
|
posix.dirname(destination),
|
|
);
|
|
const stagedBundle = appendCallerPath(stagingRoot, "new.app", "darwin");
|
|
const previousBundle = appendCallerPath(
|
|
stagingRoot,
|
|
"previous.app",
|
|
"darwin",
|
|
);
|
|
let originalBackedUp = false;
|
|
let newCommitted = false;
|
|
let failureReason: "copy" | "launcher_missing" = "copy";
|
|
|
|
try {
|
|
await operations.copyBundle(source, stagedBundle);
|
|
failureReason = "launcher_missing";
|
|
await operations.requireLauncher(
|
|
appendCallerPath(stagedBundle, "Contents/MacOS/hopper", "darwin"),
|
|
);
|
|
failureReason = "copy";
|
|
if (replaceExisting) {
|
|
await operations.rename(destination, previousBundle);
|
|
originalBackedUp = true;
|
|
}
|
|
await operations.rename(stagedBundle, destination);
|
|
newCommitted = true;
|
|
} catch (cause: unknown) {
|
|
const diagnostics = [failureMessage(cause)];
|
|
if (originalBackedUp && !newCommitted) {
|
|
try {
|
|
if (await operations.pathExists(destination))
|
|
throw new Error(
|
|
"destination now contains another installation; preserving the previous bundle in its owned staging root",
|
|
);
|
|
await operations.rename(previousBundle, destination);
|
|
originalBackedUp = false;
|
|
} catch (rollbackCause: unknown) {
|
|
diagnostics.push(`rollback failed: ${failureMessage(rollbackCause)}`);
|
|
const cleanupFailure = `The previous Hopper bundle remains owned at ${previousBundle}; cleanup will retry restoration only if the destination is empty.`;
|
|
retainCleanup(async () => {
|
|
if (originalBackedUp) {
|
|
if (await operations.pathExists(destination))
|
|
throw new Error(
|
|
`Hopper destination is occupied; preserving owned backup ${previousBundle}`,
|
|
);
|
|
await operations.rename(previousBundle, destination);
|
|
originalBackedUp = false;
|
|
}
|
|
await operations.removeStagingRoot(stagingRoot);
|
|
}, [stagingRoot]);
|
|
return {
|
|
status: "failed",
|
|
reason: failureReason,
|
|
diagnostic: diagnostics.join("; "),
|
|
cleanupFailure,
|
|
};
|
|
}
|
|
}
|
|
try {
|
|
await operations.removeStagingRoot(stagingRoot);
|
|
} catch (cleanupCause: unknown) {
|
|
const cleanupFailure = `Hopper staging root cleanup failed: ${failureMessage(cleanupCause)}`;
|
|
retainCleanup(
|
|
() => operations.removeStagingRoot(stagingRoot),
|
|
[stagingRoot],
|
|
);
|
|
diagnostics.push(cleanupFailure);
|
|
return {
|
|
status: "failed",
|
|
reason: failureReason,
|
|
diagnostic: diagnostics.join("; "),
|
|
cleanupFailure,
|
|
};
|
|
}
|
|
return {
|
|
status: "failed",
|
|
reason: failureReason,
|
|
diagnostic: diagnostics.join("; "),
|
|
};
|
|
}
|
|
|
|
try {
|
|
await operations.removeStagingRoot(stagingRoot);
|
|
return { status: "installed" };
|
|
} catch (cause: unknown) {
|
|
const cleanupFailure = `Hopper staging root cleanup failed after installation committed: ${failureMessage(cause)}`;
|
|
retainCleanup(
|
|
() => operations.removeStagingRoot(stagingRoot),
|
|
[stagingRoot],
|
|
);
|
|
return { status: "installed", cleanupFailure };
|
|
}
|
|
};
|
|
|
|
/** Compose installer paths and subprocesses from one selected caller context. */
|
|
export const systemMacHopperInstallHost = (
|
|
homeDirectory: string,
|
|
environment: Readonly<NodeJS.ProcessEnv>,
|
|
macosVersion: () => Promise<string | undefined>,
|
|
): MacHopperInstallHost & {
|
|
close(): Promise<AnalysisCleanupObservation | undefined>;
|
|
} => {
|
|
const pendingCleanup = new Map<() => Promise<void>, readonly string[]>();
|
|
return {
|
|
download: downloadHopperPackage,
|
|
createTemporaryDirectory: () => mkdtemp(join(tmpdir(), "rea-hopper-mac-")),
|
|
createMountDirectory: (path) => mkdir(path),
|
|
writePackage: (path, bytes) => writeFile(path, bytes, { mode: 0o600 }),
|
|
async mount(packagePath, mountPath) {
|
|
await execFileAsync(
|
|
"hdiutil",
|
|
[
|
|
"attach",
|
|
"-readonly",
|
|
"-nobrowse",
|
|
"-mountpoint",
|
|
mountPath,
|
|
packagePath,
|
|
],
|
|
{ env: environment },
|
|
);
|
|
return true;
|
|
},
|
|
async unmount(mountPath) {
|
|
await execFileAsync("hdiutil", ["detach", mountPath, "-force"], {
|
|
env: environment,
|
|
});
|
|
},
|
|
async appBundle(mountPath) {
|
|
const entries = await readdir(mountPath, { withFileTypes: true });
|
|
const app = entries.find(
|
|
(entry) => entry.isDirectory() && /^Hopper.*\.app$/iu.test(entry.name),
|
|
);
|
|
return app === undefined ? undefined : posix.join(mountPath, app.name);
|
|
},
|
|
async validBundle(appPath) {
|
|
if (
|
|
!(await commandSucceeds(
|
|
"codesign",
|
|
["--verify", "--deep", "--strict", appPath],
|
|
environment,
|
|
))
|
|
)
|
|
return "invalid_signature";
|
|
if (
|
|
!(await commandSucceeds(
|
|
"codesign",
|
|
[
|
|
"--verify",
|
|
"--strict",
|
|
`-R${HOPPER_PUBLISHER_REQUIREMENT}`,
|
|
appPath,
|
|
],
|
|
environment,
|
|
))
|
|
)
|
|
return "unverified_publisher";
|
|
return "verified";
|
|
},
|
|
macosVersion,
|
|
async minimumSystemVersion(appPath) {
|
|
const plist = posix.join(appPath, "Contents", "Info.plist");
|
|
const declared = minimumSystemVersionSchema.safeParse(
|
|
decodeInfoPlistValue(await readFile(plist)),
|
|
);
|
|
if (!declared.success)
|
|
throw new TypeError(
|
|
`${plist} does not declare LSMinimumSystemVersion as text`,
|
|
);
|
|
return declared.data.LSMinimumSystemVersion;
|
|
},
|
|
async destinationExists(path) {
|
|
try {
|
|
await access(path);
|
|
return true;
|
|
} catch (cause: unknown) {
|
|
if (isMissingPath(cause)) return false;
|
|
throw cause;
|
|
}
|
|
},
|
|
installBundle: (source, destination, replaceExisting) =>
|
|
installMacHopperBundle(
|
|
source,
|
|
destination,
|
|
replaceExisting,
|
|
{
|
|
async createStagingRoot(parent) {
|
|
await mkdir(parent, { recursive: true });
|
|
return mkdtemp(
|
|
appendCallerPath(parent, ".rea-hopper-stage-", "darwin"),
|
|
);
|
|
},
|
|
copyBundle: async (bundle, staged) => {
|
|
await execFileAsync("ditto", [bundle, staged], {
|
|
env: environment,
|
|
});
|
|
},
|
|
requireLauncher: (path) => access(path).then(() => undefined),
|
|
pathExists,
|
|
rename,
|
|
removeStagingRoot: (path) => rm(path, { recursive: true }),
|
|
},
|
|
(cleanup, resources) => pendingCleanup.set(cleanup, resources),
|
|
),
|
|
async openApplication(path) {
|
|
await execFileAsync("open", [path], { env: environment });
|
|
},
|
|
cleanup: (path) => rm(path, { recursive: true, force: true }),
|
|
retainCleanup: (cleanup, resources) =>
|
|
pendingCleanup.set(cleanup, resources),
|
|
async close() {
|
|
const failures: string[] = [];
|
|
for (const cleanup of pendingCleanup.keys()) {
|
|
try {
|
|
await cleanup();
|
|
pendingCleanup.delete(cleanup);
|
|
} catch (cause: unknown) {
|
|
failures.push(failureMessage(cause));
|
|
}
|
|
}
|
|
return failures.length === 0
|
|
? undefined
|
|
: {
|
|
reason: `Hopper installer cleanup remains incomplete: ${failures.join("; ")}`,
|
|
resources: [...new Set([...pendingCleanup.values()].flat())],
|
|
};
|
|
},
|
|
destination: () => macHopperUserApplicationPath(homeDirectory),
|
|
};
|
|
};
|
|
|
|
/** Installer detail naming the declared minimum this Mac's version does not meet. */
|
|
const macHopperHostVersionDetail = (
|
|
refusal: (
|
|
| { readonly source: "release_feed"; readonly packageUrl: string }
|
|
| { readonly source: "signed_app" }
|
|
) & { readonly minimumVersion: string; readonly hostVersion: string },
|
|
): string =>
|
|
refusal.source === "release_feed"
|
|
? `The current Hopper release (${refusal.packageUrl}) declares deployment_target ${refusal.minimumVersion}; this Mac runs macOS ${refusal.hostVersion}. The package was not downloaded.`
|
|
: `The signed Hopper application declares LSMinimumSystemVersion ${refusal.minimumVersion}; this Mac runs macOS ${refusal.hostVersion}. Nothing was copied.`;
|
|
|
|
/** Installer detail for a signed-app minimum REA cannot compare with this Mac's version. */
|
|
const macHopperIncomparableMinimumDetail = (
|
|
declared: string,
|
|
hostVersion: string,
|
|
): string =>
|
|
`The signed Hopper application declares LSMinimumSystemVersion ${JSON.stringify(declared)}, which REA cannot compare with macOS ${hostVersion}. Nothing was copied.`;
|
|
|
|
const minimumSystemVersionSchema = z.looseObject({
|
|
LSMinimumSystemVersion: z.string().optional(),
|
|
});
|
|
|
|
const commandSucceeds = async (
|
|
command: string,
|
|
args: readonly string[],
|
|
environment: Readonly<NodeJS.ProcessEnv>,
|
|
): Promise<boolean> => {
|
|
try {
|
|
await execFileAsync(command, args, { env: environment });
|
|
return true;
|
|
} catch (cause: unknown) {
|
|
if (
|
|
typeof cause === "object" &&
|
|
cause !== null &&
|
|
"code" in cause &&
|
|
typeof cause.code === "number"
|
|
)
|
|
return false;
|
|
throw cause;
|
|
}
|
|
};
|
|
const failureMessage = (cause: unknown): string =>
|
|
cause instanceof Error
|
|
? [
|
|
cause.message,
|
|
"stderr" in cause && typeof cause.stderr === "string"
|
|
? cause.stderr.trim()
|
|
: "",
|
|
]
|
|
.filter((value) => value !== "")
|
|
.join(": ")
|
|
: String(cause);
|
|
const isMissingPath = (cause: unknown): boolean =>
|
|
typeof cause === "object" &&
|
|
cause !== null &&
|
|
"code" in cause &&
|
|
cause.code === "ENOENT";
|
|
const pathExists = async (path: string): Promise<boolean> => {
|
|
try {
|
|
await access(path);
|
|
return true;
|
|
} catch (cause: unknown) {
|
|
if (isMissingPath(cause)) return false;
|
|
throw cause;
|
|
}
|
|
};
|