local-operator-ui 0.29.5 → 0.29.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/out/main/index.js +833 -13
- package/out/preload/index.js +91 -0
- package/out/renderer/assets/{_basePickBy-Cz5Hn1h_.js → _basePickBy-DcTwJYO_.js} +1 -1
- package/out/renderer/assets/{_baseUniq-DBMiau7H.js → _baseUniq-DvPSd9xZ.js} +1 -1
- package/out/renderer/assets/{agent-details-page-BE8L9f8W.js → agent-details-page-DnhScOnI.js} +1 -1
- package/out/renderer/assets/{agent-hub-page-Cb66-VC4.js → agent-hub-page-BQ4Wcnxb.js} +2 -2
- package/out/renderer/assets/{agents-page-DEhKgzKH.js → agents-page-BCsceEiI.js} +2 -2
- package/out/renderer/assets/{arc-B8ZJ0Opl.js → arc-CDMjXyzV.js} +1 -1
- package/out/renderer/assets/{architectureDiagram-IEHRJDOE-_QECYpX2.js → architectureDiagram-IEHRJDOE-DAy3J7qv.js} +1 -1
- package/out/renderer/assets/{blockDiagram-JOT3LUYC-BSbei2oa.js → blockDiagram-JOT3LUYC-THM4jJLl.js} +1 -1
- package/out/renderer/assets/browser-page-BkOVSS3V.js +1 -0
- package/out/renderer/assets/browser-webauthn-prompt-CsnZvLQM.js +1 -0
- package/out/renderer/assets/{c4Diagram-VJAJSXHY-CXeq7sjB.js → c4Diagram-VJAJSXHY-DmKqFqN2.js} +1 -1
- package/out/renderer/assets/channel-D5pOQ8_v.js +1 -0
- package/out/renderer/assets/{chunk-4BMEZGHF-B-MieEVG.js → chunk-4BMEZGHF-CTvxe0Qv.js} +1 -1
- package/out/renderer/assets/{chunk-A2AXSNBT-DPl3382d.js → chunk-A2AXSNBT-Bs0eNJeb.js} +1 -1
- package/out/renderer/assets/{chunk-AEK57VVT-DXC6Ji27.js → chunk-AEK57VVT-7zo4HKxz.js} +1 -1
- package/out/renderer/assets/{chunk-D6G4REZN-BGkgJZ89.js → chunk-D6G4REZN-CujTp3N4.js} +1 -1
- package/out/renderer/assets/{chunk-RZ5BOZE2-BUqZ1y68.js → chunk-RZ5BOZE2-DagQiTA1.js} +1 -1
- package/out/renderer/assets/{chunk-XZIHB7SX-C04aAxL7.js → chunk-XZIHB7SX-EUyPfdjE.js} +1 -1
- package/out/renderer/assets/classDiagram-GIVACNV2-C2odzhaf.js +1 -0
- package/out/renderer/assets/classDiagram-v2-COTLJTTW-C2odzhaf.js +1 -0
- package/out/renderer/assets/clone-Dzzjex5C.js +1 -0
- package/out/renderer/assets/{compact-pagination-IeHGr4uH.js → compact-pagination-4Yt2xb-Y.js} +1 -1
- package/out/renderer/assets/{dagre-OKDRZEBW-DKL0WkFm.js → dagre-OKDRZEBW-CE_XdZbQ.js} +1 -1
- package/out/renderer/assets/{diagram-SSKATNLV-Byh0kPuR.js → diagram-SSKATNLV-Ptt_uQaX.js} +1 -1
- package/out/renderer/assets/{diagram-VNBRO52H-mi4_sMEb.js → diagram-VNBRO52H-DUi-Kwq3.js} +1 -1
- package/out/renderer/assets/{erDiagram-Q7BY3M3F-0hepDeC4.js → erDiagram-Q7BY3M3F-rWuUD2VL.js} +1 -1
- package/out/renderer/assets/{flowDiagram-4HSFHLVR-3uOHMo-Q.js → flowDiagram-4HSFHLVR-DggskyIO.js} +1 -1
- package/out/renderer/assets/{ganttDiagram-APWFNJXF-CrQsZiws.js → ganttDiagram-APWFNJXF-BjyQcRgJ.js} +1 -1
- package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-BAcyUQYe.js → gitGraphDiagram-7IBYFJ6S-D73cnsKt.js} +1 -1
- package/out/renderer/assets/{graph-iU7FQXVs.js → graph-Blj6eB_I.js} +1 -1
- package/out/renderer/assets/{icon-D1d2UB7T.js → icon-B19NuJYm.js} +31 -31
- package/out/renderer/assets/{icon-ChKJ4xic.css → icon-DryJ1IIw.css} +1 -1
- package/out/renderer/assets/{index-yeCMZRpk.js → index-C3_lkDT_.js} +1 -1
- package/out/renderer/assets/{index-BaouUS0v.js → index-CanR4Tr_.js} +350 -350
- package/out/renderer/assets/{index-BPzVu2Vx.js → index-Ci7qsPse.js} +1 -1
- package/out/renderer/assets/{infoDiagram-PH2N3AL5-DCmQ4NN3.js → infoDiagram-PH2N3AL5-CZhLBS28.js} +1 -1
- package/out/renderer/assets/{installer-DfRiHVwp.js → installer-DL2KRfdq.js} +1 -1
- package/out/renderer/assets/{journeyDiagram-U35MCT3I-DgPDU07x.js → journeyDiagram-U35MCT3I-C9vwvYUe.js} +1 -1
- package/out/renderer/assets/{kanban-definition-NDS4AKOZ-Cc407HQo.js → kanban-definition-NDS4AKOZ-noOVueME.js} +1 -1
- package/out/renderer/assets/{layout-CI-wUNSu.js → layout-BO2FtYI8.js} +1 -1
- package/out/renderer/assets/{legacy-agents-page-CPt0LlBn.js → legacy-agents-page-CpvPE5Y4.js} +2 -2
- package/out/renderer/assets/{mermaid.core-Dw1AnBwY.js → mermaid.core-BylrYsTL.js} +5 -5
- package/out/renderer/assets/{mindmap-definition-ALO5MXBD-QW5mFHZd.js → mindmap-definition-ALO5MXBD-BCMhPSkA.js} +1 -1
- package/out/renderer/assets/{page-header-8NSHzS_8.js → page-header-D1a45b0y.js} +1 -1
- package/out/renderer/assets/{parseISO-BtaE72sw.js → parseISO-BtKaZLD8.js} +1 -1
- package/out/renderer/assets/{pieDiagram-IB7DONF6-DgAVovLV.js → pieDiagram-IB7DONF6-rcahe9BM.js} +1 -1
- package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-GHLolX9O.js → quadrantDiagram-7GDLP6J5-DxyeVGPJ.js} +1 -1
- package/out/renderer/assets/{radar-MK3ICKWK-BNcBxOZs.js → radar-MK3ICKWK-nm5lycWc.js} +1 -1
- package/out/renderer/assets/{radient-auth-buttons-BANSg89w.js → radient-auth-buttons-Ctvl_4_t.js} +1 -1
- package/out/renderer/assets/{requirementDiagram-KVF5MWMF-3d05NjvC.js → requirementDiagram-KVF5MWMF-CRS0W_DU.js} +1 -1
- package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-B4KYiLzq.js → sankeyDiagram-QLVOVGJD-CQL_jB_D.js} +1 -1
- package/out/renderer/assets/{schedules-page-DpuU_roc.js → schedules-page-DMy2NLrj.js} +1 -1
- package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-p17TfmOo.js → sequenceDiagram-X6HHIX6F-CLZ4yPPV.js} +1 -1
- package/out/renderer/assets/settings-page-D5Dxhcm-.js +317 -0
- package/out/renderer/assets/{stateDiagram-DGXRK772-DLFv4NAL.js → stateDiagram-DGXRK772-Bx_8B6PV.js} +1 -1
- package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-CHFeLpR9.js +1 -0
- package/out/renderer/assets/{timeline-definition-BDJGKUSR-u6bEh2-f.js → timeline-definition-BDJGKUSR-D2TdXm7n.js} +1 -1
- package/out/renderer/assets/{use-agent-like-mutation-C-WrCoPC.js → use-agent-like-mutation-C-7u11gk.js} +1 -1
- package/out/renderer/assets/{xychartDiagram-VJFVF3MP-czliybr_.js → xychartDiagram-VJFVF3MP-ncq15Qkv.js} +1 -1
- package/out/renderer/index.html +3 -3
- package/out/renderer/installer.html +3 -3
- package/package.json +2 -3
- package/out/renderer/assets/browser-page-CPulmBST.js +0 -1
- package/out/renderer/assets/channel-udGM0o33.js +0 -1
- package/out/renderer/assets/classDiagram-GIVACNV2-C7L-JcyP.js +0 -1
- package/out/renderer/assets/classDiagram-v2-COTLJTTW-C7L-JcyP.js +0 -1
- package/out/renderer/assets/clone-Ceo5LLkI.js +0 -1
- package/out/renderer/assets/settings-page-xOfS6Ugo.js +0 -317
- package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-BWe4Tret.js +0 -1
package/out/main/index.js
CHANGED
|
@@ -2387,6 +2387,14 @@ const BUNDLED_PYTHON_LAYOUT = {
|
|
|
2387
2387
|
checkoutSeedNames,
|
|
2388
2388
|
machOMagics
|
|
2389
2389
|
};
|
|
2390
|
+
const embeddedProvisioningProfilePath = "Contents/embedded.provisionprofile";
|
|
2391
|
+
const unrestrictedEntitlementKeys = ["com.apple.security.app-sandbox", "com.apple.security.application-groups", "com.apple.security.get-task-allow", "com.apple.security.inherit", "com.apple.security.print", "com.apple.security.smartcard"];
|
|
2392
|
+
const unrestrictedEntitlementPrefixes = ["com.apple.security.cs.", "com.apple.security.network.", "com.apple.security.files.", "com.apple.security.device.", "com.apple.security.personal-information.", "com.apple.security.assets.", "com.apple.security.automation."];
|
|
2393
|
+
const MACOS_ENTITLEMENT_POLICY = {
|
|
2394
|
+
embeddedProvisioningProfilePath,
|
|
2395
|
+
unrestrictedEntitlementKeys,
|
|
2396
|
+
unrestrictedEntitlementPrefixes
|
|
2397
|
+
};
|
|
2390
2398
|
const PENDING_INSTALL_MARKER_FILE = "pending-update-install.json";
|
|
2391
2399
|
const INSTALL_DISK_SLACK_BYTES = 256 * 1024 * 1024;
|
|
2392
2400
|
const DOWNLOAD_PAGE_URL = "https://local-operator.com/download";
|
|
@@ -2688,6 +2696,70 @@ function resolveStagedArtifactPath(input) {
|
|
|
2688
2696
|
}
|
|
2689
2697
|
return null;
|
|
2690
2698
|
}
|
|
2699
|
+
const EMBEDDED_PROVISIONING_PROFILE_PATH = MACOS_ENTITLEMENT_POLICY.embeddedProvisioningProfilePath;
|
|
2700
|
+
function isProfileBackedEntitlement(key) {
|
|
2701
|
+
if (MACOS_ENTITLEMENT_POLICY.unrestrictedEntitlementKeys.includes(key)) {
|
|
2702
|
+
return false;
|
|
2703
|
+
}
|
|
2704
|
+
return !MACOS_ENTITLEMENT_POLICY.unrestrictedEntitlementPrefixes.some(
|
|
2705
|
+
(prefix) => key.startsWith(prefix)
|
|
2706
|
+
);
|
|
2707
|
+
}
|
|
2708
|
+
function profileBackedEntitlementKeys(plistText) {
|
|
2709
|
+
const keys = [];
|
|
2710
|
+
for (const match of String(plistText ?? "").matchAll(
|
|
2711
|
+
/<key>([^<]+)<\/key>/g
|
|
2712
|
+
)) {
|
|
2713
|
+
const key = match[1];
|
|
2714
|
+
if (isProfileBackedEntitlement(key) && !keys.includes(key)) keys.push(key);
|
|
2715
|
+
}
|
|
2716
|
+
return keys;
|
|
2717
|
+
}
|
|
2718
|
+
function zipListingHasEmbeddedProfile(listing) {
|
|
2719
|
+
const suffix = `/${EMBEDDED_PROVISIONING_PROFILE_PATH}`;
|
|
2720
|
+
return String(listing ?? "").split("\n").some((entry) => entry.trim().endsWith(suffix));
|
|
2721
|
+
}
|
|
2722
|
+
function zipMainExecutableEntry(listing) {
|
|
2723
|
+
for (const raw of String(listing ?? "").split("\n")) {
|
|
2724
|
+
const entry = raw.trim();
|
|
2725
|
+
if (!entry || entry.endsWith("/")) continue;
|
|
2726
|
+
if (/^[^/]+\.app\/Contents\/MacOS\/[^/]+$/.test(entry)) return entry;
|
|
2727
|
+
}
|
|
2728
|
+
return null;
|
|
2729
|
+
}
|
|
2730
|
+
function stagedSignatureBlock(facts) {
|
|
2731
|
+
if (facts.entitlementsPlist == null) {
|
|
2732
|
+
if (facts.embeddedProfile) return null;
|
|
2733
|
+
return {
|
|
2734
|
+
code: "artifact-cannot-launch",
|
|
2735
|
+
heading: "The update couldn't be checked",
|
|
2736
|
+
message: facts.version ? `The update to version ${facts.version} can't be checked for launch, so it wasn't installed.` : "The downloaded update can't be checked for launch, so it wasn't installed.",
|
|
2737
|
+
remedy: {
|
|
2738
|
+
text: "Check for updates again to re-download the release."
|
|
2739
|
+
},
|
|
2740
|
+
detail: `${facts.artifactName} carries no ${EMBEDDED_PROVISIONING_PROFILE_PATH} and its signature could not be read.`,
|
|
2741
|
+
dismissLabel: "Not now"
|
|
2742
|
+
};
|
|
2743
|
+
}
|
|
2744
|
+
const claimed = profileBackedEntitlementKeys(facts.entitlementsPlist);
|
|
2745
|
+
if (claimed.length === 0) return null;
|
|
2746
|
+
if (facts.embeddedProfile) return null;
|
|
2747
|
+
return {
|
|
2748
|
+
code: "artifact-cannot-launch",
|
|
2749
|
+
message: facts.version ? `The update to version ${facts.version} can't be launched by macOS, so it wasn't installed.` : "The downloaded update can't be launched by macOS, so it wasn't installed.",
|
|
2750
|
+
remedy: {
|
|
2751
|
+
text: facts.version ? `Keep using this copy, and skip version ${facts.version} if you download one by hand — the next release will be offered here as usual.` : "Keep using this copy; the next release will be offered here as usual."
|
|
2752
|
+
},
|
|
2753
|
+
// The facts a reader can check, and nothing else: which archive, which
|
|
2754
|
+
// claim, which file macOS found missing. TN3125's rule (a restricted
|
|
2755
|
+
// entitlement needs a profile) is what makes the refusal TRUE, but a
|
|
2756
|
+
// reader who wants the rule wants Apple's document, and 250 characters of
|
|
2757
|
+
// it in a mono column made the details block 44% of the panel (design
|
|
2758
|
+
// round 1, D4). It belongs in this comment, not in the card.
|
|
2759
|
+
detail: `${facts.artifactName} claims ${claimed.join(", ")} and carries no ${EMBEDDED_PROVISIONING_PROFILE_PATH}.`,
|
|
2760
|
+
dismissLabel: "Not now"
|
|
2761
|
+
};
|
|
2762
|
+
}
|
|
2691
2763
|
function pendingInstallMarkerPath(dir) {
|
|
2692
2764
|
return path$1.join(dir, PENDING_INSTALL_MARKER_FILE);
|
|
2693
2765
|
}
|
|
@@ -2854,6 +2926,20 @@ function recordInstallFailure(dir, input) {
|
|
|
2854
2926
|
fs.renameSync(temp, target2);
|
|
2855
2927
|
return record;
|
|
2856
2928
|
}
|
|
2929
|
+
function installAttemptSupersededBy(record, runningVersion) {
|
|
2930
|
+
const standing = targetStanding(record.targetVersion, runningVersion);
|
|
2931
|
+
return standing === "reached" || standing === "passed";
|
|
2932
|
+
}
|
|
2933
|
+
function clearLastInstallAttempt(dir) {
|
|
2934
|
+
const path2 = lastInstallAttemptPath(dir);
|
|
2935
|
+
if (!fs.existsSync(path2)) return false;
|
|
2936
|
+
try {
|
|
2937
|
+
fs.rmSync(path2, { force: true });
|
|
2938
|
+
return true;
|
|
2939
|
+
} catch {
|
|
2940
|
+
return false;
|
|
2941
|
+
}
|
|
2942
|
+
}
|
|
2857
2943
|
const VERSION_TRIPLE_REGEX = /^v?(\d+)\.(\d+)\.(\d+)/;
|
|
2858
2944
|
function compareVersions(a, b) {
|
|
2859
2945
|
const parse = (value) => {
|
|
@@ -2870,14 +2956,20 @@ function compareVersions(a, b) {
|
|
|
2870
2956
|
}
|
|
2871
2957
|
return 0;
|
|
2872
2958
|
}
|
|
2959
|
+
function targetStanding(target2, runningVersion) {
|
|
2960
|
+
if (target2 === runningVersion) return "reached";
|
|
2961
|
+
const order = compareVersions(target2, runningVersion);
|
|
2962
|
+
if (order === null) return "unorderable";
|
|
2963
|
+
if (order < 0) return "passed";
|
|
2964
|
+
if (order > 0) return "ahead";
|
|
2965
|
+
return "same-triple";
|
|
2966
|
+
}
|
|
2873
2967
|
function evaluatePendingInstall(input) {
|
|
2874
2968
|
const { marker } = input;
|
|
2875
2969
|
if (marker == null) return { kind: "none" };
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
}
|
|
2879
|
-
const order = compareVersions(marker.targetVersion, input.runningVersion);
|
|
2880
|
-
if (order !== null && order < 0) return { kind: "stale", marker };
|
|
2970
|
+
const standing = targetStanding(marker.targetVersion, input.runningVersion);
|
|
2971
|
+
if (standing === "reached") return { kind: "succeeded", marker };
|
|
2972
|
+
if (standing === "passed") return { kind: "stale", marker };
|
|
2881
2973
|
if (input.installInFlight === true) return { kind: "in-flight", marker };
|
|
2882
2974
|
return { kind: "failed", marker };
|
|
2883
2975
|
}
|
|
@@ -3700,6 +3792,23 @@ function generationInstallRoot(identity) {
|
|
|
3700
3792
|
dir = parent;
|
|
3701
3793
|
}
|
|
3702
3794
|
}
|
|
3795
|
+
function installPointerPath(filePath) {
|
|
3796
|
+
if (!filePath) return null;
|
|
3797
|
+
const identity = readInstallIdentity(filePath);
|
|
3798
|
+
const generation = generationInstallRoot(identity);
|
|
3799
|
+
if (!generation) return filePath;
|
|
3800
|
+
let resolved;
|
|
3801
|
+
try {
|
|
3802
|
+
resolved = fs.realpathSync(identity.realPath ?? filePath);
|
|
3803
|
+
} catch {
|
|
3804
|
+
return filePath;
|
|
3805
|
+
}
|
|
3806
|
+
const inside2 = path$1.relative(generation, resolved);
|
|
3807
|
+
if (inside2 === "" || inside2.startsWith("..") || path$1.isAbsolute(inside2)) {
|
|
3808
|
+
return filePath;
|
|
3809
|
+
}
|
|
3810
|
+
return path$1.join(path$1.dirname(path$1.dirname(generation)), "current", inside2);
|
|
3811
|
+
}
|
|
3703
3812
|
function classifyGlobalInstall(identity) {
|
|
3704
3813
|
if (!identity.path) return "global-unknown";
|
|
3705
3814
|
if (identity.editable) return "editable";
|
|
@@ -9584,6 +9693,428 @@ class BackendInstaller {
|
|
|
9584
9693
|
}
|
|
9585
9694
|
}
|
|
9586
9695
|
}
|
|
9696
|
+
const WEBAUTHN_GROUP_SUFFIX = ".webauthn";
|
|
9697
|
+
const WEBAUTHN_PROMPT_REASON = "use a passkey for $1";
|
|
9698
|
+
const WEBAUTHN_CHOOSER_TIMEOUT_MS = 6e4;
|
|
9699
|
+
const CODESIGN_TIMEOUT_MS = 5e3;
|
|
9700
|
+
function webauthnKeychainAccessGroup(teamIdentifier, bundleIdentifier) {
|
|
9701
|
+
return `${teamIdentifier}.${bundleIdentifier}${WEBAUTHN_GROUP_SUFFIX}`;
|
|
9702
|
+
}
|
|
9703
|
+
function appBundlePathFromExecutable(execPath) {
|
|
9704
|
+
const parts = execPath.split("/");
|
|
9705
|
+
const macOs = parts.lastIndexOf("MacOS");
|
|
9706
|
+
if (macOs < 2 || parts[macOs - 1] !== "Contents") {
|
|
9707
|
+
return execPath;
|
|
9708
|
+
}
|
|
9709
|
+
return parts.slice(0, macOs - 1).join("/") || "/";
|
|
9710
|
+
}
|
|
9711
|
+
const TEAM_IDENTIFIER_LINE = /^TeamIdentifier=(.*)$/m;
|
|
9712
|
+
const IDENTIFIER_LINE = /^Identifier=(.*)$/m;
|
|
9713
|
+
function parseTeamIdentifier(codesignVerboseOutput) {
|
|
9714
|
+
const match = TEAM_IDENTIFIER_LINE.exec(codesignVerboseOutput);
|
|
9715
|
+
const value = (match?.[1] ?? "").trim();
|
|
9716
|
+
return value && value !== "not set" ? value : null;
|
|
9717
|
+
}
|
|
9718
|
+
function parseBundleIdentifier(codesignVerboseOutput) {
|
|
9719
|
+
const match = IDENTIFIER_LINE.exec(codesignVerboseOutput);
|
|
9720
|
+
const value = (match?.[1] ?? "").trim();
|
|
9721
|
+
return value || null;
|
|
9722
|
+
}
|
|
9723
|
+
function parseKeychainAccessGroups(entitlementsPlist) {
|
|
9724
|
+
const key = entitlementsPlist.indexOf("<key>keychain-access-groups</key>");
|
|
9725
|
+
if (key === -1) return [];
|
|
9726
|
+
const after = entitlementsPlist.slice(key);
|
|
9727
|
+
const arrayStart = after.indexOf("<array>");
|
|
9728
|
+
const arrayEnd = after.indexOf("</array>", arrayStart + 1);
|
|
9729
|
+
if (arrayStart === -1 || arrayEnd === -1) return [];
|
|
9730
|
+
const body = after.slice(arrayStart + "<array>".length, arrayEnd);
|
|
9731
|
+
const groups = [];
|
|
9732
|
+
const pattern = /<string>([^<]*)<\/string>/g;
|
|
9733
|
+
let match = pattern.exec(body);
|
|
9734
|
+
while (match !== null) {
|
|
9735
|
+
const value = match[1].trim();
|
|
9736
|
+
if (value) groups.push(value);
|
|
9737
|
+
match = pattern.exec(body);
|
|
9738
|
+
}
|
|
9739
|
+
return groups;
|
|
9740
|
+
}
|
|
9741
|
+
function decideWebauthnGate(facts) {
|
|
9742
|
+
if (facts.platform !== "darwin") {
|
|
9743
|
+
return {
|
|
9744
|
+
enabled: false,
|
|
9745
|
+
group: null,
|
|
9746
|
+
reason: "not darwin",
|
|
9747
|
+
detail: `the platform authenticator is macOS only (platform ${facts.platform})`
|
|
9748
|
+
};
|
|
9749
|
+
}
|
|
9750
|
+
if (!facts.packaged) {
|
|
9751
|
+
return {
|
|
9752
|
+
enabled: false,
|
|
9753
|
+
group: null,
|
|
9754
|
+
reason: "unpackaged",
|
|
9755
|
+
detail: "an unpackaged run has no signature to carry the entitlement, so there is no group to configure"
|
|
9756
|
+
};
|
|
9757
|
+
}
|
|
9758
|
+
if (facts.unreadable) {
|
|
9759
|
+
return {
|
|
9760
|
+
enabled: false,
|
|
9761
|
+
group: null,
|
|
9762
|
+
reason: "signature unreadable",
|
|
9763
|
+
detail: facts.unreadable
|
|
9764
|
+
};
|
|
9765
|
+
}
|
|
9766
|
+
if (!facts.bundleIdentifier) {
|
|
9767
|
+
return {
|
|
9768
|
+
enabled: false,
|
|
9769
|
+
group: null,
|
|
9770
|
+
reason: "no bundle id",
|
|
9771
|
+
detail: `codesign reported no Identifier for ${facts.bundlePath}`
|
|
9772
|
+
};
|
|
9773
|
+
}
|
|
9774
|
+
if (!facts.teamIdentifier) {
|
|
9775
|
+
return {
|
|
9776
|
+
enabled: false,
|
|
9777
|
+
group: null,
|
|
9778
|
+
reason: "no team id",
|
|
9779
|
+
detail: `${facts.bundlePath} is not team-signed (an ad-hoc signature has no TeamIdentifier)`
|
|
9780
|
+
};
|
|
9781
|
+
}
|
|
9782
|
+
const group = webauthnKeychainAccessGroup(
|
|
9783
|
+
facts.teamIdentifier,
|
|
9784
|
+
facts.bundleIdentifier
|
|
9785
|
+
);
|
|
9786
|
+
if (facts.keychainAccessGroups.length === 0) {
|
|
9787
|
+
return {
|
|
9788
|
+
enabled: false,
|
|
9789
|
+
group,
|
|
9790
|
+
reason: "entitlement absent",
|
|
9791
|
+
detail: `the signature carries no keychain-access-groups entitlement, so ${group} cannot be used`
|
|
9792
|
+
};
|
|
9793
|
+
}
|
|
9794
|
+
if (!facts.keychainAccessGroups.includes(group)) {
|
|
9795
|
+
return {
|
|
9796
|
+
enabled: false,
|
|
9797
|
+
group,
|
|
9798
|
+
reason: "entitlement mismatch",
|
|
9799
|
+
detail: `the signature's keychain-access-groups are [${facts.keychainAccessGroups.join(", ")}] but the authenticator needs ${group}`
|
|
9800
|
+
};
|
|
9801
|
+
}
|
|
9802
|
+
return {
|
|
9803
|
+
enabled: true,
|
|
9804
|
+
group,
|
|
9805
|
+
reason: "enabled",
|
|
9806
|
+
detail: `the signature carries ${group}`
|
|
9807
|
+
};
|
|
9808
|
+
}
|
|
9809
|
+
const defaultCodesign = (file, args) => new Promise((resolve, reject) => {
|
|
9810
|
+
node_child_process.execFile(
|
|
9811
|
+
file,
|
|
9812
|
+
args,
|
|
9813
|
+
{ timeout: CODESIGN_TIMEOUT_MS },
|
|
9814
|
+
(error, stdout, stderr) => {
|
|
9815
|
+
if (error) {
|
|
9816
|
+
reject(
|
|
9817
|
+
new Error(
|
|
9818
|
+
`${error.message}${stderr ? ` (${String(stderr).trim()})` : ""}`
|
|
9819
|
+
)
|
|
9820
|
+
);
|
|
9821
|
+
return;
|
|
9822
|
+
}
|
|
9823
|
+
resolve({ stdout: String(stdout), stderr: String(stderr) });
|
|
9824
|
+
}
|
|
9825
|
+
);
|
|
9826
|
+
});
|
|
9827
|
+
async function readAppSignature(options) {
|
|
9828
|
+
const bundlePath = appBundlePathFromExecutable(options.execPath);
|
|
9829
|
+
const base = {
|
|
9830
|
+
platform: options.platform,
|
|
9831
|
+
packaged: options.packaged,
|
|
9832
|
+
bundlePath,
|
|
9833
|
+
bundleIdentifier: null,
|
|
9834
|
+
teamIdentifier: null,
|
|
9835
|
+
keychainAccessGroups: [],
|
|
9836
|
+
unreadable: null
|
|
9837
|
+
};
|
|
9838
|
+
if (options.platform !== "darwin" || !options.packaged) return base;
|
|
9839
|
+
const run = options.run ?? defaultCodesign;
|
|
9840
|
+
try {
|
|
9841
|
+
const verbose = await run("/usr/bin/codesign", [
|
|
9842
|
+
"-dv",
|
|
9843
|
+
"--verbose=4",
|
|
9844
|
+
bundlePath
|
|
9845
|
+
]);
|
|
9846
|
+
base.bundleIdentifier = parseBundleIdentifier(verbose.stdout);
|
|
9847
|
+
base.teamIdentifier = parseTeamIdentifier(verbose.stdout);
|
|
9848
|
+
} catch (error) {
|
|
9849
|
+
return { ...base, unreadable: `codesign -dv failed: ${messageOf$1(error)}` };
|
|
9850
|
+
}
|
|
9851
|
+
try {
|
|
9852
|
+
const entitlements = await run("/usr/bin/codesign", [
|
|
9853
|
+
"-d",
|
|
9854
|
+
"--entitlements",
|
|
9855
|
+
"-",
|
|
9856
|
+
"--xml",
|
|
9857
|
+
bundlePath
|
|
9858
|
+
]);
|
|
9859
|
+
base.keychainAccessGroups = parseKeychainAccessGroups(entitlements.stdout);
|
|
9860
|
+
} catch (error) {
|
|
9861
|
+
return {
|
|
9862
|
+
...base,
|
|
9863
|
+
unreadable: `codesign -d --entitlements failed: ${messageOf$1(error)}`
|
|
9864
|
+
};
|
|
9865
|
+
}
|
|
9866
|
+
return base;
|
|
9867
|
+
}
|
|
9868
|
+
function messageOf$1(error) {
|
|
9869
|
+
return error instanceof Error ? error.message : String(error);
|
|
9870
|
+
}
|
|
9871
|
+
let ownSignature = null;
|
|
9872
|
+
function ownAppSignature() {
|
|
9873
|
+
ownSignature ??= readAppSignature({
|
|
9874
|
+
platform: process.platform,
|
|
9875
|
+
packaged: electron.app.isPackaged,
|
|
9876
|
+
execPath: process.execPath
|
|
9877
|
+
});
|
|
9878
|
+
return ownSignature;
|
|
9879
|
+
}
|
|
9880
|
+
async function installWebauthn(options) {
|
|
9881
|
+
const facts = await (options.facts ?? ownAppSignature)();
|
|
9882
|
+
const gate = decideWebauthnGate(facts);
|
|
9883
|
+
if (!gate.enabled || !gate.group) {
|
|
9884
|
+
options.log(
|
|
9885
|
+
`[webauthn] Touch ID passkeys are off: ${gate.reason} — ${gate.detail}`
|
|
9886
|
+
);
|
|
9887
|
+
return { gate, configured: false };
|
|
9888
|
+
}
|
|
9889
|
+
const configure = options.configure ?? ((value) => electron.app.configureWebAuthn(value));
|
|
9890
|
+
try {
|
|
9891
|
+
configure({
|
|
9892
|
+
touchID: {
|
|
9893
|
+
keychainAccessGroup: gate.group,
|
|
9894
|
+
promptReason: WEBAUTHN_PROMPT_REASON
|
|
9895
|
+
}
|
|
9896
|
+
});
|
|
9897
|
+
} catch (error) {
|
|
9898
|
+
options.log(
|
|
9899
|
+
`[webauthn] Touch ID passkeys are off: configureWebAuthn failed — ${messageOf$1(error)}`
|
|
9900
|
+
);
|
|
9901
|
+
return { gate: { ...gate, enabled: false }, configured: false };
|
|
9902
|
+
}
|
|
9903
|
+
options.log(
|
|
9904
|
+
`[webauthn] Touch ID platform authenticator enabled (${gate.group}); credentials are device-bound and never leave this Mac`
|
|
9905
|
+
);
|
|
9906
|
+
return { gate, configured: true };
|
|
9907
|
+
}
|
|
9908
|
+
class WebauthnChooser {
|
|
9909
|
+
constructor(options) {
|
|
9910
|
+
this.options = options;
|
|
9911
|
+
}
|
|
9912
|
+
pending = /* @__PURE__ */ new Map();
|
|
9913
|
+
counter = 0;
|
|
9914
|
+
/** Handle the Session event. `accounts` is Electron's own array. */
|
|
9915
|
+
handle(details, callback) {
|
|
9916
|
+
const answer = onceAnswer(callback);
|
|
9917
|
+
try {
|
|
9918
|
+
this.prepare(details, answer);
|
|
9919
|
+
} catch (error) {
|
|
9920
|
+
this.options.log(
|
|
9921
|
+
`[webauthn] a passkey request could not be prepared (${String(
|
|
9922
|
+
error
|
|
9923
|
+
)}); cancelling it`
|
|
9924
|
+
);
|
|
9925
|
+
answer(null);
|
|
9926
|
+
}
|
|
9927
|
+
}
|
|
9928
|
+
/** The pre-timer half of `handle`: everything that can still throw. */
|
|
9929
|
+
prepare(details, answer) {
|
|
9930
|
+
const relyingPartyId = typeof details.relyingPartyId === "string" ? details.relyingPartyId : "";
|
|
9931
|
+
const accounts = Array.isArray(details.accounts) ? details.accounts.filter(
|
|
9932
|
+
(account) => typeof account === "object" && account !== null
|
|
9933
|
+
).map((account) => toAccountView(account)) : [];
|
|
9934
|
+
if (accounts.length === 0) {
|
|
9935
|
+
this.options.log(
|
|
9936
|
+
`[webauthn] ${relyingPartyId || "a site"} asked for a passkey with no account to choose; cancelling the request`
|
|
9937
|
+
);
|
|
9938
|
+
answer(null);
|
|
9939
|
+
return;
|
|
9940
|
+
}
|
|
9941
|
+
const requestId2 = this.options.nextRequestId ? this.options.nextRequestId() : `webauthn-${++this.counter}-${Date.now().toString(36)}`;
|
|
9942
|
+
const source = this.options.describeSource?.(details.frame) ?? {
|
|
9943
|
+
tabId: null,
|
|
9944
|
+
pageTitle: null
|
|
9945
|
+
};
|
|
9946
|
+
const timer = setTimeout(
|
|
9947
|
+
() => this.expire(requestId2),
|
|
9948
|
+
this.options.timeoutMs ?? WEBAUTHN_CHOOSER_TIMEOUT_MS
|
|
9949
|
+
);
|
|
9950
|
+
timer.unref?.();
|
|
9951
|
+
const request = {
|
|
9952
|
+
requestId: requestId2,
|
|
9953
|
+
relyingPartyId,
|
|
9954
|
+
accounts,
|
|
9955
|
+
tabId: source.tabId,
|
|
9956
|
+
pageTitle: source.pageTitle
|
|
9957
|
+
};
|
|
9958
|
+
this.options.log(
|
|
9959
|
+
`[webauthn] ${relyingPartyId || "a site"} matched ${accounts.length} passkeys; asking the user which one to use (${requestId2})`
|
|
9960
|
+
);
|
|
9961
|
+
this.options.notify(request);
|
|
9962
|
+
this.pending.set(requestId2, {
|
|
9963
|
+
requestId: requestId2,
|
|
9964
|
+
relyingPartyId,
|
|
9965
|
+
accounts,
|
|
9966
|
+
tabId: source.tabId,
|
|
9967
|
+
pageTitle: source.pageTitle,
|
|
9968
|
+
answer,
|
|
9969
|
+
timer
|
|
9970
|
+
});
|
|
9971
|
+
}
|
|
9972
|
+
/**
|
|
9973
|
+
* Answer a surfaced chooser. Returns whether a pending request was settled.
|
|
9974
|
+
*
|
|
9975
|
+
* A `credentialId` that is not one of the offered accounts is treated as a
|
|
9976
|
+
* dismissal rather than passed through: Electron would cancel the request
|
|
9977
|
+
* anyway, and doing it here keeps the log honest about what happened.
|
|
9978
|
+
*/
|
|
9979
|
+
respond(requestId2, credentialId) {
|
|
9980
|
+
const entry = this.pending.get(requestId2);
|
|
9981
|
+
if (!entry) {
|
|
9982
|
+
this.options.log(
|
|
9983
|
+
`[webauthn] ignored a choice for an unknown or already-answered request (${requestId2})`
|
|
9984
|
+
);
|
|
9985
|
+
return false;
|
|
9986
|
+
}
|
|
9987
|
+
if (credentialId !== null && !entry.accounts.some((a) => a.credentialId === credentialId)) {
|
|
9988
|
+
this.options.log(
|
|
9989
|
+
`[webauthn] a choice for ${entry.relyingPartyId} named a credential that was not offered; cancelling the request`
|
|
9990
|
+
);
|
|
9991
|
+
this.settle(entry, null, "credential-not-offered");
|
|
9992
|
+
return true;
|
|
9993
|
+
}
|
|
9994
|
+
if (credentialId === null) {
|
|
9995
|
+
this.options.log(
|
|
9996
|
+
`[webauthn] the passkey choice for ${entry.relyingPartyId} was dismissed; cancelling the request`
|
|
9997
|
+
);
|
|
9998
|
+
this.settle(entry, null, "dismissed");
|
|
9999
|
+
return true;
|
|
10000
|
+
}
|
|
10001
|
+
const chosen = entry.accounts.find((a) => a.credentialId === credentialId);
|
|
10002
|
+
this.options.log(
|
|
10003
|
+
`[webauthn] the user chose ${labelOf(chosen)} for ${entry.relyingPartyId}`
|
|
10004
|
+
);
|
|
10005
|
+
this.settle(entry, credentialId, "chosen");
|
|
10006
|
+
return true;
|
|
10007
|
+
}
|
|
10008
|
+
/** Ids still waiting for an answer. Read by tests and by nothing else. */
|
|
10009
|
+
pendingRequestIds() {
|
|
10010
|
+
return [...this.pending.keys()];
|
|
10011
|
+
}
|
|
10012
|
+
/**
|
|
10013
|
+
* Every request still waiting, OLDEST FIRST.
|
|
10014
|
+
*
|
|
10015
|
+
* This is what a surface that mounts late reads instead of a request it never
|
|
10016
|
+
* received (UX round 1, U2: the request was raised while the browser route was
|
|
10017
|
+
* not mounted, so the push had nowhere to go). Order is the map's own insertion
|
|
10018
|
+
* order, which is why it is stated: the renderer offers the oldest request and
|
|
10019
|
+
* names the rest rather than silently replacing one with the next.
|
|
10020
|
+
*/
|
|
10021
|
+
pendingRequests() {
|
|
10022
|
+
return [...this.pending.keys()].map((requestId2) => this.view(requestId2));
|
|
10023
|
+
}
|
|
10024
|
+
/** Cancel everything still pending. Called when the host stops, because a
|
|
10025
|
+
* callback left behind would keep the page's promise pending in a window
|
|
10026
|
+
* that is going away. */
|
|
10027
|
+
dispose() {
|
|
10028
|
+
for (const entry of [...this.pending.values()]) {
|
|
10029
|
+
this.options.log(
|
|
10030
|
+
`[webauthn] the passkey request from ${entry.relyingPartyId} was cancelled: the browser host stopped`
|
|
10031
|
+
);
|
|
10032
|
+
this.settle(entry, null, "host-stopped");
|
|
10033
|
+
}
|
|
10034
|
+
}
|
|
10035
|
+
expire(requestId2) {
|
|
10036
|
+
const entry = this.pending.get(requestId2);
|
|
10037
|
+
if (!entry) return;
|
|
10038
|
+
this.options.log(
|
|
10039
|
+
`[webauthn] nobody chose a passkey for ${entry.relyingPartyId} within ${Math.round((this.options.timeoutMs ?? WEBAUTHN_CHOOSER_TIMEOUT_MS) / 1e3)}s; cancelling the request`
|
|
10040
|
+
);
|
|
10041
|
+
this.settle(entry, null, "expired");
|
|
10042
|
+
}
|
|
10043
|
+
/** The wire view of one pending request. Never a credential id in the token
|
|
10044
|
+
* position: `requestId` is this module's own. */
|
|
10045
|
+
view(requestId2) {
|
|
10046
|
+
const entry = this.pending.get(requestId2);
|
|
10047
|
+
if (!entry) {
|
|
10048
|
+
throw new Error(`no pending passkey request ${requestId2}`);
|
|
10049
|
+
}
|
|
10050
|
+
return {
|
|
10051
|
+
requestId: entry.requestId,
|
|
10052
|
+
relyingPartyId: entry.relyingPartyId,
|
|
10053
|
+
accounts: entry.accounts,
|
|
10054
|
+
tabId: entry.tabId,
|
|
10055
|
+
pageTitle: entry.pageTitle
|
|
10056
|
+
};
|
|
10057
|
+
}
|
|
10058
|
+
/** Answer exactly once, then tell the renderer the request is gone.
|
|
10059
|
+
*
|
|
10060
|
+
* Both halves matter. The map entry and the guarded callback are the
|
|
10061
|
+
* exactly-once half; `onSettled` is what stops the renderer offering a choice
|
|
10062
|
+
* for a request main has already answered — the dead-end dialog of design
|
|
10063
|
+
* round 1, D2 — and it fires for EVERY path, including the host's own stop.
|
|
10064
|
+
*/
|
|
10065
|
+
settle(entry, credentialId, outcome) {
|
|
10066
|
+
if (!this.pending.has(entry.requestId)) return;
|
|
10067
|
+
this.pending.delete(entry.requestId);
|
|
10068
|
+
clearTimeout(entry.timer);
|
|
10069
|
+
entry.answer(credentialId);
|
|
10070
|
+
this.options.onSettled?.(entry.requestId, outcome);
|
|
10071
|
+
}
|
|
10072
|
+
}
|
|
10073
|
+
const answerableCallbacks = /* @__PURE__ */ new WeakMap();
|
|
10074
|
+
function onceAnswer(callback) {
|
|
10075
|
+
const existing = answerableCallbacks.get(callback);
|
|
10076
|
+
if (existing) return existing;
|
|
10077
|
+
let answered = false;
|
|
10078
|
+
const guarded = (credentialId) => {
|
|
10079
|
+
if (answered) return;
|
|
10080
|
+
answered = true;
|
|
10081
|
+
callback(credentialId ?? void 0);
|
|
10082
|
+
};
|
|
10083
|
+
answerableCallbacks.set(callback, guarded);
|
|
10084
|
+
return guarded;
|
|
10085
|
+
}
|
|
10086
|
+
const chooserListeners = /* @__PURE__ */ new WeakMap();
|
|
10087
|
+
function attachWebauthnChooser(session, chooser, log) {
|
|
10088
|
+
const previous = chooserListeners.get(session);
|
|
10089
|
+
if (previous) {
|
|
10090
|
+
session.removeListener("select-webauthn-account", previous);
|
|
10091
|
+
log(
|
|
10092
|
+
"[webauthn] replaced the previous passkey chooser listener (a second browser host started in this process)"
|
|
10093
|
+
);
|
|
10094
|
+
}
|
|
10095
|
+
const listener = (_event, details, callback) => {
|
|
10096
|
+
chooser.handle(details, callback);
|
|
10097
|
+
};
|
|
10098
|
+
session.on("select-webauthn-account", listener);
|
|
10099
|
+
chooserListeners.set(session, listener);
|
|
10100
|
+
return () => {
|
|
10101
|
+
if (chooserListeners.get(session) !== listener) return;
|
|
10102
|
+
session.removeListener("select-webauthn-account", listener);
|
|
10103
|
+
chooserListeners.delete(session);
|
|
10104
|
+
};
|
|
10105
|
+
}
|
|
10106
|
+
function toAccountView(account) {
|
|
10107
|
+
return {
|
|
10108
|
+
credentialId: typeof account.credentialId === "string" ? account.credentialId : "",
|
|
10109
|
+
name: typeof account.name === "string" ? account.name : null,
|
|
10110
|
+
displayName: typeof account.displayName === "string" ? account.displayName : null,
|
|
10111
|
+
userHandle: typeof account.userHandle === "string" ? account.userHandle : null
|
|
10112
|
+
};
|
|
10113
|
+
}
|
|
10114
|
+
function labelOf(account) {
|
|
10115
|
+
if (!account) return "an unnamed passkey";
|
|
10116
|
+
return account.displayName || account.name || "an unnamed passkey";
|
|
10117
|
+
}
|
|
9587
10118
|
const PRIVATE_FILE_MODE = 384;
|
|
9588
10119
|
const PRIVATE_DIR_MODE = 448;
|
|
9589
10120
|
function writeJsonAtomic(path2, value) {
|
|
@@ -11000,14 +11531,48 @@ function settle(contents, timeoutMs = 3e4) {
|
|
|
11000
11531
|
function permittedScheme(url) {
|
|
11001
11532
|
return url.protocol === "http:" || url.protocol === "https:";
|
|
11002
11533
|
}
|
|
11534
|
+
async function resolveMainFrameId(ctx, contents) {
|
|
11535
|
+
let pageEnabled = false;
|
|
11536
|
+
try {
|
|
11537
|
+
await ctx.cdp.send(contents, "Page.enable", {});
|
|
11538
|
+
pageEnabled = true;
|
|
11539
|
+
const tree = await ctx.cdp.send(contents, "Page.getFrameTree", {});
|
|
11540
|
+
const id2 = tree?.frameTree?.frame?.id;
|
|
11541
|
+
return {
|
|
11542
|
+
mainFrameId: typeof id2 === "string" && id2 ? id2 : null,
|
|
11543
|
+
pageEnabled
|
|
11544
|
+
};
|
|
11545
|
+
} catch {
|
|
11546
|
+
return { mainFrameId: null, pageEnabled };
|
|
11547
|
+
}
|
|
11548
|
+
}
|
|
11003
11549
|
async function withOriginGate(ctx, view, requester, approved, operation) {
|
|
11004
11550
|
const contents = view.webContents;
|
|
11005
11551
|
const blocked = [];
|
|
11006
11552
|
const seen = /* @__PURE__ */ new Set();
|
|
11553
|
+
const frameTree = await resolveMainFrameId(ctx, contents);
|
|
11554
|
+
const mainFrameId = frameTree.mainFrameId;
|
|
11555
|
+
if (mainFrameId === null) {
|
|
11556
|
+
ctx.log(
|
|
11557
|
+
`[browser] could not read the frame tree for ${requester}; this navigation is gated on every Document request, which may refuse cross-origin frames the page needs${// The domain's state travels with the fallback because it is not
|
|
11558
|
+
// inferable from the failure: an enable that resolved and a read that
|
|
11559
|
+
// did not left `Page` armed, an enable that never resolved did not
|
|
11560
|
+
// (agent review round 2, R3).
|
|
11561
|
+
frameTree.pageEnabled ? " (the Page domain may still be enabled)" : ""}`
|
|
11562
|
+
);
|
|
11563
|
+
}
|
|
11564
|
+
let unattributed = 0;
|
|
11007
11565
|
const unsubscribe = ctx.cdp.subscribe(contents.id, (method, params) => {
|
|
11008
11566
|
if (method !== "Fetch.requestPaused") return;
|
|
11009
11567
|
const requestId2 = typeof params.requestId === "string" ? params.requestId : "";
|
|
11010
11568
|
if (!requestId2) return;
|
|
11569
|
+
const frameId = typeof params.frameId === "string" ? params.frameId : "";
|
|
11570
|
+
if (mainFrameId !== null && frameId && frameId !== mainFrameId) {
|
|
11571
|
+
void ctx.cdp.send(contents, "Fetch.continueRequest", { requestId: requestId2 }).catch(() => {
|
|
11572
|
+
});
|
|
11573
|
+
return;
|
|
11574
|
+
}
|
|
11575
|
+
if (mainFrameId !== null && !frameId) unattributed += 1;
|
|
11011
11576
|
const rawUrl = typeof params.request === "object" && params.request !== null ? params.request.url : void 0;
|
|
11012
11577
|
let url = null;
|
|
11013
11578
|
try {
|
|
@@ -11056,10 +11621,21 @@ async function withOriginGate(ctx, view, requester, approved, operation) {
|
|
|
11056
11621
|
throw error;
|
|
11057
11622
|
} finally {
|
|
11058
11623
|
unsubscribe();
|
|
11624
|
+
if (unattributed > 0) {
|
|
11625
|
+
ctx.log(
|
|
11626
|
+
`[browser] ${unattributed} paused document request(s) during ${requester}'s navigation carried no frame id; they were gated as navigation hops`
|
|
11627
|
+
);
|
|
11628
|
+
}
|
|
11059
11629
|
try {
|
|
11060
11630
|
await ctx.cdp.send(contents, "Fetch.disable", {});
|
|
11061
11631
|
} catch {
|
|
11062
11632
|
}
|
|
11633
|
+
if (frameTree.pageEnabled) {
|
|
11634
|
+
try {
|
|
11635
|
+
await ctx.cdp.send(contents, "Page.disable", {});
|
|
11636
|
+
} catch {
|
|
11637
|
+
}
|
|
11638
|
+
}
|
|
11063
11639
|
}
|
|
11064
11640
|
}
|
|
11065
11641
|
async function navigateView(ctx, view, url, requester, approved, timeoutMs = 3e4) {
|
|
@@ -11681,6 +12257,20 @@ class TabRegistry {
|
|
|
11681
12257
|
list() {
|
|
11682
12258
|
return [...this.tabs.values()].sort((a, b) => a.tabId - b.tabId);
|
|
11683
12259
|
}
|
|
12260
|
+
/**
|
|
12261
|
+
* The tab whose view owns this webContents id, if any.
|
|
12262
|
+
*
|
|
12263
|
+
* The passkey chooser needs it to name the page a request came from: Electron
|
|
12264
|
+
* hands the initiating frame, `webContents.fromFrame` turns that into a
|
|
12265
|
+
* webContents, and this is the mapping from there to a tab of THIS host — the
|
|
12266
|
+
* frame may belong to no tab here at all, which is why the answer is optional
|
|
12267
|
+
* rather than an error.
|
|
12268
|
+
*/
|
|
12269
|
+
byWebContentsId(webContentsId) {
|
|
12270
|
+
return this.list().find(
|
|
12271
|
+
(record) => !record.view.webContents.isDestroyed() && record.view.webContents.id === webContentsId
|
|
12272
|
+
);
|
|
12273
|
+
}
|
|
11684
12274
|
get activeTab() {
|
|
11685
12275
|
return this.activeTabId === null ? null : this.tabs.get(this.activeTabId) ?? null;
|
|
11686
12276
|
}
|
|
@@ -13027,7 +13617,9 @@ const BROWSER_IPC_CHANNELS = [
|
|
|
13027
13617
|
"browser-revoke-approval",
|
|
13028
13618
|
"browser-revoke-all-approvals",
|
|
13029
13619
|
"browser-forget-site",
|
|
13030
|
-
"browser-clear-data"
|
|
13620
|
+
"browser-clear-data",
|
|
13621
|
+
"browser-webauthn-respond",
|
|
13622
|
+
"browser-webauthn-pending"
|
|
13031
13623
|
];
|
|
13032
13624
|
function registerBrowserIpc(options) {
|
|
13033
13625
|
function authorize(event) {
|
|
@@ -13097,6 +13689,25 @@ function registerBrowserIpc(options) {
|
|
|
13097
13689
|
const host = authorize(event);
|
|
13098
13690
|
return host.revokeHandOver(numberOrThrow(tabId, "tabId"));
|
|
13099
13691
|
});
|
|
13692
|
+
electron.ipcMain.handle(
|
|
13693
|
+
"browser-webauthn-respond",
|
|
13694
|
+
(event, requestId2, credentialId) => {
|
|
13695
|
+
authorize(event);
|
|
13696
|
+
const chooser = options.webauthn();
|
|
13697
|
+
if (!chooser) throw new Error("The browser host is not running.");
|
|
13698
|
+
if (typeof requestId2 !== "string" || !requestId2) {
|
|
13699
|
+
throw new Error("A pending passkey request id is required.");
|
|
13700
|
+
}
|
|
13701
|
+
if (credentialId !== null && typeof credentialId !== "string") {
|
|
13702
|
+
throw new Error("A passkey choice must name a credential or nothing.");
|
|
13703
|
+
}
|
|
13704
|
+
return chooser.respond(requestId2, credentialId);
|
|
13705
|
+
}
|
|
13706
|
+
);
|
|
13707
|
+
electron.ipcMain.handle("browser-webauthn-pending", (event) => {
|
|
13708
|
+
authorize(event);
|
|
13709
|
+
return options.webauthn()?.pendingRequests() ?? [];
|
|
13710
|
+
});
|
|
13100
13711
|
electron.ipcMain.handle(
|
|
13101
13712
|
"browser-consent-respond",
|
|
13102
13713
|
(event, entryId, decision) => {
|
|
@@ -13488,8 +14099,9 @@ class OwnershipLedger {
|
|
|
13488
14099
|
const BROWSER_PARTITION = "persist:local-operator-browser";
|
|
13489
14100
|
function browserUserAgent() {
|
|
13490
14101
|
const chrome = process.versions.chrome ?? "0";
|
|
14102
|
+
const major = chrome.split(".")[0] || "0";
|
|
13491
14103
|
const platform = process.platform === "darwin" ? "Macintosh; Intel Mac OS X 10_15_7" : process.platform === "win32" ? "Windows NT 10.0; Win64; x64" : "X11; Linux x86_64";
|
|
13492
|
-
return `Mozilla/5.0 (${platform}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${
|
|
14104
|
+
return `Mozilla/5.0 (${platform}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${major}.0.0.0 Safari/537.36`;
|
|
13493
14105
|
}
|
|
13494
14106
|
function resolveBrowserSession() {
|
|
13495
14107
|
return electron.session.fromPartition(BROWSER_PARTITION);
|
|
@@ -14930,6 +15542,43 @@ async function startBrowserHost(options) {
|
|
|
14930
15542
|
log
|
|
14931
15543
|
});
|
|
14932
15544
|
configurePslRules(PSL_RULES);
|
|
15545
|
+
await installWebauthn({ log });
|
|
15546
|
+
const describeSource = (frame) => {
|
|
15547
|
+
if (!frame || typeof frame !== "object") {
|
|
15548
|
+
return { tabId: null, pageTitle: null };
|
|
15549
|
+
}
|
|
15550
|
+
const contents = electron.webContents.fromFrame(frame);
|
|
15551
|
+
const record = contents === void 0 ? void 0 : registry.byWebContentsId(contents.id);
|
|
15552
|
+
if (!record) return { tabId: null, pageTitle: null };
|
|
15553
|
+
const title = record.view.webContents.isDestroyed() ? "" : record.view.webContents.getTitle();
|
|
15554
|
+
return { tabId: record.tabId, pageTitle: title || null };
|
|
15555
|
+
};
|
|
15556
|
+
const webauthn = new WebauthnChooser({
|
|
15557
|
+
notify: (request) => {
|
|
15558
|
+
if (options.window.isDestroyed()) return;
|
|
15559
|
+
options.window.webContents.send("browser-webauthn-request", request);
|
|
15560
|
+
},
|
|
15561
|
+
/*
|
|
15562
|
+
* AND THE OTHER DIRECTION: every settle is pushed, not only every arrival.
|
|
15563
|
+
*
|
|
15564
|
+
* Without this the renderer cannot tell a live request from one main has
|
|
15565
|
+
* already cancelled, so a chooser whose request expired while the user was
|
|
15566
|
+
* away stays on screen offering rows that can no longer do anything (design
|
|
15567
|
+
* round 1, D2) — and the click is then discarded silently. The outcome travels
|
|
15568
|
+
* with the push because the two cases differ for the user: their own answer is
|
|
15569
|
+
* already off the screen, an expiry has to be explained.
|
|
15570
|
+
*/
|
|
15571
|
+
onSettled: (requestId2, outcome) => {
|
|
15572
|
+
if (options.window.isDestroyed()) return;
|
|
15573
|
+
options.window.webContents.send("browser-webauthn-settled", {
|
|
15574
|
+
requestId: requestId2,
|
|
15575
|
+
outcome
|
|
15576
|
+
});
|
|
15577
|
+
},
|
|
15578
|
+
describeSource,
|
|
15579
|
+
log
|
|
15580
|
+
});
|
|
15581
|
+
const detachWebauthn = attachWebauthnChooser(browserSession, webauthn, log);
|
|
14933
15582
|
const cookieJar = createHiddenCookieJarTarget(
|
|
14934
15583
|
new electron.WebContentsView({
|
|
14935
15584
|
webPreferences: { ...HIDDEN_COOKIE_JAR_WEB_PREFERENCES }
|
|
@@ -15074,6 +15723,7 @@ async function startBrowserHost(options) {
|
|
|
15074
15723
|
window: () => options.window,
|
|
15075
15724
|
expectedUrl: options.expectedUrl,
|
|
15076
15725
|
host: () => host,
|
|
15726
|
+
webauthn: () => webauthn,
|
|
15077
15727
|
clearData: (what) => sessionCookies.clearBrowsingData(what),
|
|
15078
15728
|
log
|
|
15079
15729
|
});
|
|
@@ -15102,6 +15752,8 @@ async function startBrowserHost(options) {
|
|
|
15102
15752
|
await cdp.close();
|
|
15103
15753
|
await server.close();
|
|
15104
15754
|
unregisterBrowserIpc();
|
|
15755
|
+
detachWebauthn();
|
|
15756
|
+
webauthn.dispose();
|
|
15105
15757
|
stateWriter?.clear();
|
|
15106
15758
|
approvals.resetPending();
|
|
15107
15759
|
ownership.clear();
|
|
@@ -17075,6 +17727,7 @@ const INSTALL_IN_FLIGHT_RECHECK_MS = 1e4;
|
|
|
17075
17727
|
const LAUNCHCTL_NOT_LOADED_REGEX = /could not find|no such process/i;
|
|
17076
17728
|
const APP_OWNED_PUBLISH_HEADROOM_BYTES = 450 * 1024 * 1024;
|
|
17077
17729
|
const SEAL_PROBE_TIMEOUT_MS = 45e3;
|
|
17730
|
+
const STAGED_SIGNATURE_PROBE_TIMEOUT_MS = 2e4;
|
|
17078
17731
|
const STARTUP_SEAL_PROBE_DELAY_MS = 15e3;
|
|
17079
17732
|
const POST_WAKE_CHECK_DELAY_MS = 2e4;
|
|
17080
17733
|
function versionSuffix(version) {
|
|
@@ -17767,6 +18420,7 @@ class UpdateService {
|
|
|
17767
18420
|
);
|
|
17768
18421
|
this.reapWatchdog(outcome.marker, true);
|
|
17769
18422
|
clearPendingInstallMarker(this.markerDir());
|
|
18423
|
+
this.retireSupersededInstallRecord();
|
|
17770
18424
|
return;
|
|
17771
18425
|
case "stale":
|
|
17772
18426
|
logger.info(
|
|
@@ -17775,6 +18429,7 @@ class UpdateService {
|
|
|
17775
18429
|
);
|
|
17776
18430
|
this.reapWatchdog(outcome.marker, true);
|
|
17777
18431
|
clearPendingInstallMarker(this.markerDir());
|
|
18432
|
+
this.retireSupersededInstallRecord();
|
|
17778
18433
|
return;
|
|
17779
18434
|
case "in-flight": {
|
|
17780
18435
|
this.installWasInFlight = true;
|
|
@@ -18551,10 +19206,123 @@ ${heal.removed.join("\n")}`,
|
|
|
18551
19206
|
`Staged artifact verified: ${artifactPath} (${verdict.size} bytes, sha512 matches, ${required} bytes required free, ${installedBundleSize ?? "unknown"} byte app).`,
|
|
18552
19207
|
LogFileType.UPDATE_SERVICE
|
|
18553
19208
|
);
|
|
18554
|
-
return
|
|
19209
|
+
return this.stagedSignatureBlock(artifactPath, info);
|
|
18555
19210
|
}
|
|
18556
19211
|
return verdict.block;
|
|
18557
19212
|
}
|
|
19213
|
+
/**
|
|
19214
|
+
* Refuse a staged artifact macOS would not launch, before the app quits.
|
|
19215
|
+
*
|
|
19216
|
+
* Why this is worth a probe of its own. On 2026-09-19 the operator's install
|
|
19217
|
+
* updated itself to 0.29.6, which was Developer-ID signed, notarized, stapled,
|
|
19218
|
+
* `codesign --verify --deep --strict`-clean and `spctl -t exec`-accepted - and
|
|
19219
|
+
* completely unlaunchable: every executable in the bundle claimed the
|
|
19220
|
+
* restricted `keychain-access-groups` entitlement while the bundle embedded no
|
|
19221
|
+
* provisioning profile to authorize it, so amfid refused every Mach-O at exec
|
|
19222
|
+
* (`AppleMobileFileIntegrityError Code=-413 "No matching profile found"`).
|
|
19223
|
+
* `open` reported `RBSRequestErrorDomain Code=5 / NSPOSIXErrorDomain Code=163
|
|
19224
|
+
* "Launchd job spawn failed"`. The ShipIt that should have relaunched the app
|
|
19225
|
+
* was refused for the same reason, so the install completed and left the user
|
|
19226
|
+
* with no app, no message and no automatic way back.
|
|
19227
|
+
*
|
|
19228
|
+
* The signature check is done on the STAGED artifact rather than on the
|
|
19229
|
+
* installed bundle because by the time the installed bundle is the broken one,
|
|
19230
|
+
* there is nothing left running to report it.
|
|
19231
|
+
*
|
|
19232
|
+
* A probe that could not read the artifact at all is not a refusal by itself -
|
|
19233
|
+
* `stagedSignatureBlock` decides that - and neither is a non-zip artifact: the
|
|
19234
|
+
* macOS in-app update is a `.zip`, and an artifact this cannot read is reported
|
|
19235
|
+
* as such rather than treated as clean.
|
|
19236
|
+
*/
|
|
19237
|
+
async stagedSignatureBlock(artifactPath, info) {
|
|
19238
|
+
const facts = await this.inspectStagedSignature(artifactPath, info);
|
|
19239
|
+
if (!facts) return null;
|
|
19240
|
+
const block = stagedSignatureBlock(facts);
|
|
19241
|
+
if (!block) {
|
|
19242
|
+
logger.info(
|
|
19243
|
+
`Staged artifact signature checked: ${facts.artifactName} claims no entitlement macOS needs a provisioning profile for.`,
|
|
19244
|
+
LogFileType.UPDATE_SERVICE
|
|
19245
|
+
);
|
|
19246
|
+
return null;
|
|
19247
|
+
}
|
|
19248
|
+
logger.warn(
|
|
19249
|
+
`Refusing the update to ${info?.version ?? "an unknown version"}: ${block.detail}`,
|
|
19250
|
+
LogFileType.UPDATE_SERVICE
|
|
19251
|
+
);
|
|
19252
|
+
return block;
|
|
19253
|
+
}
|
|
19254
|
+
/**
|
|
19255
|
+
* The facts `stagedSignatureBlock` decides on, read out of the archive.
|
|
19256
|
+
*
|
|
19257
|
+
* Two members of the zip and one `codesign`, which is cheap because the file
|
|
19258
|
+
* that carries the entitlements is the bundle's LAUNCHER rather than the app
|
|
19259
|
+
* (`Contents/MacOS/Local Operator` is ~54 KB beside a ~376 MB bundle). The
|
|
19260
|
+
* listing comes from the central directory, so neither the interpreter nor the
|
|
19261
|
+
* frameworks are ever decompressed.
|
|
19262
|
+
*
|
|
19263
|
+
* Returns null when there is nothing this can ask about - not a zip, or a zip
|
|
19264
|
+
* whose listing could not be read - and says so in the log. That is a
|
|
19265
|
+
* deliberate "we could not tell", which is different from "we looked and it is
|
|
19266
|
+
* clean"; the caller logs the clean case itself.
|
|
19267
|
+
*/
|
|
19268
|
+
async inspectStagedSignature(artifactPath, info) {
|
|
19269
|
+
const artifactName = path$1.basename(artifactPath);
|
|
19270
|
+
if (!artifactPath.toLowerCase().endsWith(".zip")) {
|
|
19271
|
+
logger.info(
|
|
19272
|
+
`Staged artifact ${artifactName} is not a zip; its signature was not inspected before the install.`,
|
|
19273
|
+
LogFileType.UPDATE_SERVICE
|
|
19274
|
+
);
|
|
19275
|
+
return null;
|
|
19276
|
+
}
|
|
19277
|
+
const listing = await runCommand("/usr/bin/unzip", ["-Z1", artifactPath], {
|
|
19278
|
+
timeoutMs: STAGED_SIGNATURE_PROBE_TIMEOUT_MS
|
|
19279
|
+
});
|
|
19280
|
+
if (!listing.ran || listing.exitCode !== 0) {
|
|
19281
|
+
logger.warn(
|
|
19282
|
+
`Could not list ${artifactName} to check its signature (exit ${listing.exitCode}): ${listing.stderr.trim() || "no output"}`,
|
|
19283
|
+
LogFileType.UPDATE_SERVICE
|
|
19284
|
+
);
|
|
19285
|
+
return null;
|
|
19286
|
+
}
|
|
19287
|
+
const embeddedProfile = zipListingHasEmbeddedProfile(listing.stdout);
|
|
19288
|
+
const entry = zipMainExecutableEntry(listing.stdout);
|
|
19289
|
+
const base = {
|
|
19290
|
+
entitlementsPlist: null,
|
|
19291
|
+
embeddedProfile,
|
|
19292
|
+
artifactName,
|
|
19293
|
+
version: info?.version ?? null
|
|
19294
|
+
};
|
|
19295
|
+
if (!entry) {
|
|
19296
|
+
logger.warn(
|
|
19297
|
+
`${artifactName} has no Contents/MacOS entry to read a signature from; its entitlements were not inspected before the install.`,
|
|
19298
|
+
LogFileType.UPDATE_SERVICE
|
|
19299
|
+
);
|
|
19300
|
+
return base;
|
|
19301
|
+
}
|
|
19302
|
+
const scratch = fs.mkdtempSync(path$1.join(os.tmpdir(), "lo-staged-signature-"));
|
|
19303
|
+
try {
|
|
19304
|
+
const extracted = await runCommand(
|
|
19305
|
+
"/usr/bin/unzip",
|
|
19306
|
+
["-o", "-q", artifactPath, entry, "-d", scratch],
|
|
19307
|
+
{ timeoutMs: STAGED_SIGNATURE_PROBE_TIMEOUT_MS }
|
|
19308
|
+
);
|
|
19309
|
+
if (!extracted.ran || extracted.exitCode !== 0) {
|
|
19310
|
+
logger.warn(
|
|
19311
|
+
`Could not extract ${entry} from ${artifactName} (exit ${extracted.exitCode}): ${extracted.stderr.trim() || "no output"}`,
|
|
19312
|
+
LogFileType.UPDATE_SERVICE
|
|
19313
|
+
);
|
|
19314
|
+
return base;
|
|
19315
|
+
}
|
|
19316
|
+
const signature = await runCommand(
|
|
19317
|
+
"/usr/bin/codesign",
|
|
19318
|
+
["-d", "--entitlements", "-", "--xml", path$1.join(scratch, entry)],
|
|
19319
|
+
{ timeoutMs: STAGED_SIGNATURE_PROBE_TIMEOUT_MS }
|
|
19320
|
+
);
|
|
19321
|
+
return signature.ran && signature.exitCode === 0 ? { ...base, entitlementsPlist: signature.stdout } : base;
|
|
19322
|
+
} finally {
|
|
19323
|
+
fs.rmSync(scratch, { recursive: true, force: true });
|
|
19324
|
+
}
|
|
19325
|
+
}
|
|
18558
19326
|
/**
|
|
18559
19327
|
* Everything that has to hold before the app is allowed to quit.
|
|
18560
19328
|
*
|
|
@@ -18772,9 +19540,39 @@ ${heal.removed.join("\n")}`,
|
|
|
18772
19540
|
* a dismiss: the panel that reports a failure is not the only place the user
|
|
18773
19541
|
* can find out what happened, which is what made Dismiss an information loss
|
|
18774
19542
|
* (reviews U1, D3).
|
|
19543
|
+
*
|
|
19544
|
+
* AND IT RETIRES ONCE THE MACHINE HAS ARRIVED, which is the other half of that
|
|
19545
|
+
* promise: a record that survives a dismiss must not survive the state it
|
|
19546
|
+
* describes, or "Version 0.28.2 is running" is printed to a user on 0.29.1 for
|
|
19547
|
+
* ever (the operator's report, 2026-09-18 - a 0.28.3 record against a running
|
|
19548
|
+
* 0.29.1). The test is read-time because the fact becomes true without this
|
|
19549
|
+
* process doing anything: the installer ran, the user relaunched, and the app
|
|
19550
|
+
* that comes up is the one that answers every reader.
|
|
18775
19551
|
*/
|
|
18776
19552
|
lastInstallAttempt() {
|
|
18777
|
-
return
|
|
19553
|
+
return this.retireSupersededInstallRecord();
|
|
19554
|
+
}
|
|
19555
|
+
/**
|
|
19556
|
+
* The record, retired first when the version now running has reached its target.
|
|
19557
|
+
*
|
|
19558
|
+
* One function rather than the same test written at each site, because there are two
|
|
19559
|
+
* moments the fact becomes true - the launch that reads the record, and a start-up
|
|
19560
|
+
* that observes an install having succeeded or been superseded - and two spellings of
|
|
19561
|
+
* one rule is how the arms drift apart. The predicate keeps a record whose versions
|
|
19562
|
+
* cannot be ordered, so nothing is dropped on a guess (`installAttemptSupersededBy`).
|
|
19563
|
+
*/
|
|
19564
|
+
retireSupersededInstallRecord() {
|
|
19565
|
+
const record = readLastInstallAttempt(this.markerDir());
|
|
19566
|
+
if (!record) return null;
|
|
19567
|
+
const running = electron.app.getVersion();
|
|
19568
|
+
if (!installAttemptSupersededBy(record, running)) return record;
|
|
19569
|
+
if (clearLastInstallAttempt(this.markerDir())) {
|
|
19570
|
+
logger.info(
|
|
19571
|
+
`Retired the recorded install failure for ${record.targetVersion}: this app is running ${running}, so the target has been reached and the record describes a state the machine has left`,
|
|
19572
|
+
LogFileType.UPDATE_SERVICE
|
|
19573
|
+
);
|
|
19574
|
+
}
|
|
19575
|
+
return null;
|
|
18778
19576
|
}
|
|
18779
19577
|
/**
|
|
18780
19578
|
* Note that a check was asked for, and let an explicit one re-offer a release
|
|
@@ -19612,9 +20410,15 @@ ${heal.removed.join("\n")}`,
|
|
|
19612
20410
|
* uv tool root), so a prefix held from minutes ago can name a tree nothing is
|
|
19613
20411
|
* using any more. The reading is what `didSourceRebuildLand` compares, in both
|
|
19614
20412
|
* directions.
|
|
20413
|
+
*
|
|
20414
|
+
* Read through the install's own pointer for the same reason the version is (see
|
|
20415
|
+
* `readInstallVersionAt`): a rebuild keeps `pyproject.toml`'s version and moves the
|
|
20416
|
+
* `.lop-source` ref, so the marker is the ONLY evidence of that move on this route -
|
|
20417
|
+
* and a marker read from the frozen `generations/<id>` the daemon started from cannot
|
|
20418
|
+
* show it any more than the version could.
|
|
19615
20419
|
*/
|
|
19616
20420
|
readRebuildMarkerState(installPath) {
|
|
19617
|
-
const identity = readInstallIdentity(installPath);
|
|
20421
|
+
const identity = readInstallIdentity(installPointerPath(installPath));
|
|
19618
20422
|
return readSourceMarkerState(identity.venvPrefix ?? null);
|
|
19619
20423
|
}
|
|
19620
20424
|
/**
|
|
@@ -20400,9 +21204,16 @@ ${heal.removed.join("\n")}`,
|
|
|
20400
21204
|
* than into a plausible number. The one caller with a record and no path - the
|
|
20401
21205
|
* reconciliation reading a marker an older build wrote - names its fallback explicitly
|
|
20402
21206
|
* and logs it.
|
|
21207
|
+
*
|
|
21208
|
+
* AND THE INSTALL IS READ THROUGH ITS OWN POINTER, because on a generation layout the
|
|
21209
|
+
* install it names cannot move under it: the tree `/health` reports as the install root
|
|
21210
|
+
* is `generations/<id>`, an install lands BESIDE it, and only `<stable>/current` moves.
|
|
21211
|
+
* Reading the concrete path here is what produced the false "did not take effect" verdict
|
|
21212
|
+
* of 2026-09-18; `installPointerPath` is the same install named so the flip is visible,
|
|
21213
|
+
* and it is a no-op on every other layout.
|
|
20403
21214
|
*/
|
|
20404
21215
|
readInstallVersionAt(installPath) {
|
|
20405
|
-
return readInstallIdentity(installPath)?.version ?? null;
|
|
21216
|
+
return readInstallIdentity(installPointerPath(installPath))?.version ?? null;
|
|
20406
21217
|
}
|
|
20407
21218
|
/**
|
|
20408
21219
|
* Run the install's own front end: `<resolved shim> update`.
|
|
@@ -20632,8 +21443,17 @@ ${heal.removed.join("\n")}`,
|
|
|
20632
21443
|
* The install this attempt runs, recorded so a later launch does not have to resolve
|
|
20633
21444
|
* one of its own: the reconciliation reads this, and a marker without it (an older
|
|
20634
21445
|
* build's) is the only case that falls back.
|
|
21446
|
+
*
|
|
21447
|
+
* RECORDED THROUGH ITS OWN POINTER where the layout has one, so the record outlives
|
|
21448
|
+
* the tree it names: on a generation install the flip an install performs is visible
|
|
21449
|
+
* only through `<stable>/current`, and the generation this attempt started from is
|
|
21450
|
+
* unreferenced the moment it is superseded - which makes it prunable, and a record
|
|
21451
|
+
* naming a pruned tree reads as "nothing moved" for an install that landed. A record
|
|
21452
|
+
* an older build wrote carries the concrete generation path instead, which the reader
|
|
21453
|
+
* resolves through the same pointer (`installPointerPath` is idempotent, so both
|
|
21454
|
+
* spellings land in the same place).
|
|
20635
21455
|
*/
|
|
20636
|
-
installPath
|
|
21456
|
+
installPath: installPointerPath(installPath)
|
|
20637
21457
|
};
|
|
20638
21458
|
writePendingServerUpdateMarker(this.markerDir(), marker);
|
|
20639
21459
|
const run = await this.runGlobalUpdate(
|
|
@@ -20655,7 +21475,7 @@ ${heal.removed.join("\n")}`,
|
|
|
20655
21475
|
deadlineAt: marker.deadlineAt,
|
|
20656
21476
|
groupPid: pid,
|
|
20657
21477
|
groupStartedAt: readProcessStartStamp(pid),
|
|
20658
|
-
installPath
|
|
21478
|
+
installPath: installPointerPath(installPath)
|
|
20659
21479
|
});
|
|
20660
21480
|
}
|
|
20661
21481
|
);
|