ccdeck 3.29.11 → 3.31.0
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/README.md +4 -3
- package/bin/agent-dag.js +67 -118
- package/bin/cli/help.js +67 -0
- package/bin/cli/login-item.js +182 -0
- package/bin/cli/one-shot.js +254 -0
- package/bin/cli/package.js +39 -0
- package/bin/cli/pulse.js +98 -0
- package/bin/cli/restart.js +179 -0
- package/bin/cli/screen.js +166 -0
- package/bin/cli/second-start.js +120 -0
- package/bin/cli/startup.js +459 -0
- package/bin/cli/uninstall.js +169 -0
- package/bin/deck.js +112 -1559
- package/dist/web/assets/BrowserWatchModal-n63GjLhv.js +1 -0
- package/dist/web/assets/UsageHistoryModal-DEwBX4Fk.js +1 -0
- package/dist/web/assets/index-CZJf1Cpp.js +299 -0
- package/dist/web/assets/index-D4q1ElXU.css +1 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +15 -14
- package/package.json +1 -1
- package/release-notes.json +40 -0
- package/src/server/account-projects.mjs +25 -11
- package/src/server/account-routes.mjs +254 -0
- package/src/server/activity.mjs +3 -3
- package/src/server/append-budget.mjs +185 -0
- package/src/server/append-failures.mjs +147 -0
- package/src/server/args.mjs +37 -5
- package/src/server/atomic-write.mjs +300 -0
- package/src/server/block-notify.mjs +0 -5
- package/src/server/boot-lock.mjs +1 -1
- package/src/server/brand.mjs +7 -6
- package/src/server/browser-react.mjs +10 -2
- package/src/server/browser-watch-decks.mjs +146 -0
- package/src/server/browser-watch-feed.mjs +69 -0
- package/src/server/browser-watch-log.mjs +173 -0
- package/src/server/browser-watch-mtime.mjs +18 -0
- package/src/server/browser-watch-relay.mjs +112 -0
- package/src/server/browser-watch-routes.mjs +172 -0
- package/src/server/browser-watch-seen.mjs +127 -0
- package/src/server/browser-watch-store.mjs +14 -143
- package/src/server/browser-watch.mjs +278 -626
- package/src/server/canonical-path.mjs +150 -0
- package/src/server/ccusage-failure.mjs +64 -0
- package/src/server/ccusage-install.mjs +459 -0
- package/src/server/ccusage-runner.mjs +271 -0
- package/src/server/ccusage.mjs +21 -756
- package/src/server/claude-accounts.mjs +220 -464
- package/src/server/claude-collector.mjs +189 -0
- package/src/server/claude-dir.mjs +7 -3
- package/src/server/claude-identity.mjs +5 -4
- package/src/server/claude-reset-credits.mjs +130 -0
- package/src/server/claude-verdicts.mjs +174 -0
- package/src/server/clear-route.mjs +191 -0
- package/src/server/codex-auth.mjs +4 -4
- package/src/server/codex-dir.mjs +52 -8
- package/src/server/codex-enrichment.mjs +310 -0
- package/src/server/codex-quota.mjs +5 -3
- package/src/server/codex-translate.mjs +407 -0
- package/src/server/codex-usage.mjs +8 -5
- package/src/server/codex-watch.mjs +496 -0
- package/src/server/cswap-auto-loop.mjs +342 -0
- package/src/server/cswap-auto-readers.mjs +303 -0
- package/src/server/cswap-auto.mjs +51 -609
- package/src/server/cswap-install.mjs +69 -265
- package/src/server/cswap-layout.mjs +242 -0
- package/src/server/deck-home.mjs +2 -2
- package/src/server/deck-prefs.mjs +58 -184
- package/src/server/deck-scope.mjs +52 -0
- package/src/server/desktop-update-routes.mjs +75 -0
- package/src/server/detach.mjs +78 -0
- package/src/server/discovery.mjs +298 -0
- package/src/server/event-log.mjs +328 -0
- package/src/server/event-pipeline.mjs +235 -0
- package/src/server/event-ring.mjs +214 -0
- package/src/server/event-routes.mjs +352 -0
- package/src/server/event-sink.mjs +26 -0
- package/src/server/exec-children.mjs +128 -0
- package/src/server/exec-not-found.mjs +213 -0
- package/src/server/exec-output.mjs +23 -0
- package/src/server/exec-spec.mjs +341 -0
- package/src/server/exec.mjs +74 -661
- package/src/server/health-route.mjs +42 -0
- package/src/server/hook-challenge.mjs +37 -0
- package/src/server/http-io.mjs +104 -0
- package/src/server/index.mjs +174 -7620
- package/src/server/install-layout.mjs +506 -0
- package/src/server/installer.mjs +79 -608
- package/src/server/jsonl-chunks.mjs +139 -0
- package/src/server/lan-accounts.mjs +27 -0
- package/src/server/lan-beacon.mjs +457 -0
- package/src/server/lan-call.mjs +239 -0
- package/src/server/lan-copies.mjs +270 -0
- package/src/server/lan-deck.mjs +583 -0
- package/src/server/lan-dials.mjs +161 -0
- package/src/server/lan-engine.mjs +475 -1024
- package/src/server/lan-hearing.mjs +49 -0
- package/src/server/lan-inbound.mjs +100 -0
- package/src/server/lan-invite-offer.mjs +77 -0
- package/src/server/lan-invite.mjs +207 -0
- package/src/server/lan-lines.mjs +73 -0
- package/src/server/lan-manifest.mjs +74 -0
- package/src/server/lan-reach.mjs +82 -69
- package/src/server/lan-requests.mjs +249 -0
- package/src/server/lan-round-record.mjs +45 -0
- package/src/server/lan-round-timer.mjs +62 -0
- package/src/server/lan-routes.mjs +143 -0
- package/src/server/lan-serve.mjs +112 -0
- package/src/server/lan-socket.mjs +243 -925
- package/src/server/lan-sync.mjs +58 -1026
- package/src/server/lan-tailnet-poll.mjs +57 -0
- package/src/server/lan-turns.mjs +80 -0
- package/src/server/lan-wire.mjs +620 -0
- package/src/server/lifecycle.mjs +421 -0
- package/src/server/listen.mjs +110 -0
- package/src/server/live-decks.mjs +138 -0
- package/src/server/load-average.mjs +32 -0
- package/src/server/log-election.mjs +235 -0
- package/src/server/log-replay.mjs +374 -0
- package/src/server/log-writer.mjs +72 -557
- package/src/server/login-service.mjs +3 -2
- package/src/server/macmon.mjs +1 -1
- package/src/server/memory-files.mjs +163 -0
- package/src/server/memory-metrics.mjs +178 -0
- package/src/server/metrics-history.mjs +94 -0
- package/src/server/metrics-run.mjs +100 -0
- package/src/server/music-routes.mjs +134 -0
- package/src/server/network-metrics.mjs +5 -3
- package/src/server/network-sampler.mjs +256 -0
- package/src/server/npm-latest.mjs +385 -0
- package/src/server/npm-upgrade.mjs +352 -0
- package/src/server/npx.mjs +41 -7
- package/src/server/output-watch.mjs +5 -4
- package/src/server/pinned-build.mjs +110 -0
- package/src/server/prefs-give-back.mjs +206 -0
- package/src/server/prefs-path.mjs +14 -0
- package/src/server/prefs-refusal.mjs +86 -0
- package/src/server/prefs-routes.mjs +99 -0
- package/src/server/prefs-set-aside.mjs +86 -0
- package/src/server/prefs-state.mjs +70 -0
- package/src/server/process-command.mjs +104 -0
- package/src/server/process-list.mjs +547 -0
- package/src/server/provider-status.mjs +307 -0
- package/src/server/pulse-line.mjs +132 -0
- package/src/server/purge-key.mjs +7 -7
- package/src/server/quota-cli.mjs +174 -0
- package/src/server/quota-oauth.mjs +190 -0
- package/src/server/quota-shape.mjs +229 -0
- package/src/server/quota-store-resets.mjs +206 -0
- package/src/server/quota.mjs +123 -509
- package/src/server/relay-guard.mjs +3 -3
- package/src/server/request-gates.mjs +441 -0
- package/src/server/restart-note.mjs +180 -0
- package/src/server/retire-sound-hook.mjs +3 -3
- package/src/server/ring-bounds.mjs +215 -0
- package/src/server/running-deck.mjs +1 -1
- package/src/server/running-version.mjs +24 -0
- package/src/server/self-update.mjs +39 -1371
- package/src/server/session-enrichment.mjs +622 -0
- package/src/server/session-read-gate.mjs +49 -0
- package/src/server/session-tracking.mjs +242 -0
- package/src/server/sse-clients.mjs +222 -0
- package/src/server/stale-discovery.mjs +30 -0
- package/src/server/static-serve.mjs +81 -0
- package/src/server/supervisor.mjs +73 -12
- package/src/server/system-metrics.mjs +169 -1935
- package/src/server/term.mjs +11 -249
- package/src/server/thermal-metrics.mjs +520 -0
- package/src/server/thermal-sampler.mjs +204 -0
- package/src/server/token-redact.mjs +123 -0
- package/src/server/transcript-gate.mjs +107 -0
- package/src/server/transcript-scan.mjs +563 -0
- package/src/server/usage-routes.mjs +108 -0
- package/src/server/uv-bootstrap.mjs +1 -1
- package/src/server/wordmark.mjs +127 -0
- package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
- package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
- package/dist/web/assets/index-Ck2RAla3.css +0 -1
- package/dist/web/assets/index-jmrcEsCj.js +0 -298
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// Giving the deck's own folders back to the person running it, from the panel
|
|
2
|
+
// (#1711).
|
|
3
|
+
//
|
|
4
|
+
// #1335 taught the deck to say, precisely, that its settings folder belongs to
|
|
5
|
+
// another user — which is what a `sudo ccdeck` leaves on a Mac, where sudo keeps
|
|
6
|
+
// HOME. It could not do more: the folder is root's, and only root can give it
|
|
7
|
+
// back. So the way out was a `sudo chown -R` line in a log the person reporting
|
|
8
|
+
// it did not know how to find, and until they ran it every settings write was
|
|
9
|
+
// refused, the share tick included.
|
|
10
|
+
//
|
|
11
|
+
// On macOS the deck can ask for that root itself, through the operating
|
|
12
|
+
// system's own password dialog. That makes this the one place ccdeck acts with
|
|
13
|
+
// administrator rights, and everything below is about keeping it that narrow:
|
|
14
|
+
//
|
|
15
|
+
// THE PATHS ARE THE DECK'S OWN. Computed here, from the same functions that
|
|
16
|
+
// decide where the deck keeps its things. Nothing the request carries reaches
|
|
17
|
+
// the command; the route takes no body at all.
|
|
18
|
+
//
|
|
19
|
+
// EVERY TARGET IS CHECKED BEFORE THE DIALOG. A real directory, not a symlink,
|
|
20
|
+
// named like one of the deck's, strictly inside the user's home both as
|
|
21
|
+
// written and after realpath, and owned by somebody else. A folder that is
|
|
22
|
+
// already the user's is left out, so the dialog never appears for nothing.
|
|
23
|
+
//
|
|
24
|
+
// THE PATHS ARE DATA, NOT SOURCE. They reach AppleScript as argv, after `--`
|
|
25
|
+
// (see notify in browser-react.mjs for what a leading dash did without it),
|
|
26
|
+
// and reach the shell through `quoted form of`. The script text is a
|
|
27
|
+
// constant, and osascript is named by its full path.
|
|
28
|
+
//
|
|
29
|
+
// THE FOLDER ROOT CHANGES IS THE FOLDER THAT WAS CHECKED. The checks run
|
|
30
|
+
// before the dialog and root acts after it, minutes later if the person is
|
|
31
|
+
// slow, and the parent folder is the user's: anything running as the user
|
|
32
|
+
// could put a folder of its own in place of the checked one, holding a hard
|
|
33
|
+
// link to a file root owns, and `chown -R` would hand that file over. So the
|
|
34
|
+
// root step does not trust the path. It enters the folder, compares the
|
|
35
|
+
// device and inode of where it landed with the ones surveyed here, refuses
|
|
36
|
+
// a tree holding any file with a second hard link, and only then changes
|
|
37
|
+
// `.`, which a rename of the path can no longer move.
|
|
38
|
+
//
|
|
39
|
+
// `chown -n -x -R -P`: numeric ids with no name lookup, no mount point
|
|
40
|
+
// crossed, and no symlink inside the tree followed.
|
|
41
|
+
import { lstat, realpath } from "node:fs/promises";
|
|
42
|
+
import { homedir } from "node:os";
|
|
43
|
+
// POSIX, not the host's: every path here is a Mac's, and the suite checks
|
|
44
|
+
// them on Windows too, where the host's `normalize` would turn every slash
|
|
45
|
+
// into a backslash and find nothing inside any home (see deckDataDir).
|
|
46
|
+
import { posix } from "node:path";
|
|
47
|
+
import { PRODUCT } from "./brand.mjs";
|
|
48
|
+
import { deckDataDir, deckLogDir, legacyDeckDir } from "./deck-home.mjs";
|
|
49
|
+
import { run } from "./exec.mjs";
|
|
50
|
+
import { prefsDir } from "./prefs-path.mjs";
|
|
51
|
+
|
|
52
|
+
/** The folder names the deck files itself under. A CCDECK_HOME named anything
|
|
53
|
+
* else is somebody's own choice of place, and the log's command is still there
|
|
54
|
+
* for it; the deck does not take root to change the owner of it. */
|
|
55
|
+
const DECK_FOLDER_NAMES = new Set(["ccdeck", "agent-dag"]);
|
|
56
|
+
|
|
57
|
+
/** How long the password dialog may stay open. It waits on a person, so the
|
|
58
|
+
* exec default of twenty seconds would close it under them. */
|
|
59
|
+
export const GIVE_BACK_TIMEOUT_MS = 180_000;
|
|
60
|
+
|
|
61
|
+
/** What the dialog says it is for, above macOS's own "wants to make changes". */
|
|
62
|
+
export const GIVE_BACK_PROMPT =
|
|
63
|
+
`${PRODUCT} needs your password to make its settings folder yours again. It was left owned by another user, most likely by a run with sudo.`;
|
|
64
|
+
|
|
65
|
+
/** osascript, by its full path: this is the one call that asks for the
|
|
66
|
+
* administrator's password, and a PATH lookup is a way to put a different
|
|
67
|
+
* dialog in front of it. */
|
|
68
|
+
export const OSASCRIPT = "/usr/bin/osascript";
|
|
69
|
+
|
|
70
|
+
/** The whole of what runs as root. Item 1 is `uid:gid`, item 2 the prompt, and
|
|
71
|
+
* after them a folder and its `device:inode` for each folder; every one is
|
|
72
|
+
* quoted by AppleScript, never by hand. A folder that is not the one surveyed,
|
|
73
|
+
* or that holds a file with a second hard link, stops the whole command with
|
|
74
|
+
* status 3 before anything in it changes owner. */
|
|
75
|
+
export const GIVE_BACK_SCRIPT = [
|
|
76
|
+
"on run argv",
|
|
77
|
+
"set owner to quoted form of (item 1 of argv)",
|
|
78
|
+
'set cmd to "true"',
|
|
79
|
+
"repeat with i from 3 to (count of argv) by 2",
|
|
80
|
+
"set target to quoted form of (item i of argv)",
|
|
81
|
+
"set wanted to quoted form of (item (i + 1) of argv)",
|
|
82
|
+
'set cmd to cmd & " && { cd -P -- " & target & " && [ \\"$(/usr/bin/stat -f %d:%i .)\\" = " & wanted & " ] && [ -z \\"$(/usr/bin/find -x -P . ! -type d -links +1 -print)\\" ] || exit 3; } && /usr/sbin/chown -n -x -R -P -- " & owner & " ."',
|
|
83
|
+
"end repeat",
|
|
84
|
+
"do shell script cmd with prompt (item 2 of argv) with administrator privileges",
|
|
85
|
+
"end run",
|
|
86
|
+
].join("\n");
|
|
87
|
+
|
|
88
|
+
/** osascript's "User canceled." — the person closed the dialog. */
|
|
89
|
+
const CANCELLED = /\(-128\)/;
|
|
90
|
+
/** The root step's own refusal: the folder was not the one surveyed, or held a
|
|
91
|
+
* hard link. osascript reports the shell's status in brackets. */
|
|
92
|
+
const GUARD_TRIPPED = /\(3\)\s*$/m;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Which of the deck's folders belong to somebody else, and whether each one is
|
|
96
|
+
* safe to hand back.
|
|
97
|
+
*
|
|
98
|
+
* The settings folder is the one the refusal was about, so it comes first and
|
|
99
|
+
* its verdict decides the answer. The log folder and the legacy folder are
|
|
100
|
+
* where the same sudo run wrote too — the event log, the hook, the port
|
|
101
|
+
* registry — and are given back in the same dialog when they need it, or
|
|
102
|
+
* skipped when they do not exist or already belong to the user.
|
|
103
|
+
*/
|
|
104
|
+
export function deckFolders(platform = process.platform, env = process.env, home = homedir()) {
|
|
105
|
+
return [prefsDir(deckDataDir(platform, env, home)), deckLogDir(platform, env, home), legacyDeckDir(env, home, platform)]
|
|
106
|
+
.filter((path, i, all) => all.indexOf(path) === i);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function survey(path, { uid, home, fs }) {
|
|
110
|
+
let st;
|
|
111
|
+
try {
|
|
112
|
+
// BigInt, because the inode is handed to the root step to compare with
|
|
113
|
+
// stat(1)'s, and a number past 2^53 would compare unequal to itself.
|
|
114
|
+
st = await fs.lstat(path, { bigint: true });
|
|
115
|
+
} catch (err) {
|
|
116
|
+
return { path, state: err?.code === "ENOENT" ? "missing" : "unsafe" };
|
|
117
|
+
}
|
|
118
|
+
if (Number(st.uid) === uid) return { path, state: "yours" };
|
|
119
|
+
if (!st.isDirectory() || st.isSymbolicLink()) return { path, state: "unsafe" };
|
|
120
|
+
if (!DECK_FOLDER_NAMES.has(basename(path))) return { path, state: "unsafe" };
|
|
121
|
+
if (!strictlyInside(path, home)) return { path, state: "unsafe" };
|
|
122
|
+
const real = await fs.realpath(path).catch(() => "");
|
|
123
|
+
const realHome = await fs.realpath(home).catch(() => "");
|
|
124
|
+
if (!real || !realHome || !strictlyInside(real, realHome)) return { path, state: "unsafe" };
|
|
125
|
+
return { path, state: "foreign", id: `${st.dev}:${st.ino}` };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const { basename, isAbsolute, normalize, sep } = posix;
|
|
129
|
+
|
|
130
|
+
function strictlyInside(path, home) {
|
|
131
|
+
if (!isAbsolute(path) || !isAbsolute(home) || normalize(path) !== path) return false;
|
|
132
|
+
const root = home.endsWith(sep) ? home : home + sep;
|
|
133
|
+
return path.startsWith(root) && path.length > root.length;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let _inFlight = false;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Ask macOS for the password, and give the deck's folders back to this user.
|
|
140
|
+
*
|
|
141
|
+
* Answers `{ ok: true, changed }` — `changed: false` when nothing needed it,
|
|
142
|
+
* which is also what a folder somebody already fixed by hand looks like — or
|
|
143
|
+
* `{ ok: false, reason }` from a closed set, because the route hands it to a
|
|
144
|
+
* page and a path names the user:
|
|
145
|
+
*
|
|
146
|
+
* unsupported not macOS, or no uid to compare, or running as root already
|
|
147
|
+
* busy a dialog from an earlier press is still open
|
|
148
|
+
* not_eligible the settings folder failed one of the checks above
|
|
149
|
+
* cancelled the person closed the dialog
|
|
150
|
+
* timed_out nobody answered it
|
|
151
|
+
* refused macOS or chown said no; the log has what it said
|
|
152
|
+
* still_foreign chown answered and the folder is still not theirs
|
|
153
|
+
*/
|
|
154
|
+
export async function giveBackDeckFolders(deps = {}) {
|
|
155
|
+
const platform = deps.platform ?? process.platform;
|
|
156
|
+
if (platform !== "darwin") return { ok: false, reason: "unsupported" };
|
|
157
|
+
const uid = deps.getuid ? deps.getuid() : process.getuid?.();
|
|
158
|
+
const gid = deps.getgid ? deps.getgid() : process.getgid?.();
|
|
159
|
+
if (uid === undefined || gid === undefined || uid === 0) return { ok: false, reason: "unsupported" };
|
|
160
|
+
if (_inFlight) return { ok: false, reason: "busy" };
|
|
161
|
+
_inFlight = true;
|
|
162
|
+
try {
|
|
163
|
+
return await giveBack({ ...deps, platform, uid, gid });
|
|
164
|
+
} finally {
|
|
165
|
+
_inFlight = false;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function giveBack({ platform, uid, gid, env = process.env, home = homedir(), fs = { lstat, realpath }, exec = run, warn = console.error }) {
|
|
170
|
+
const folders = await Promise.all(deckFolders(platform, env, home).map(path => survey(path, { uid, home, fs })));
|
|
171
|
+
const [settings] = folders;
|
|
172
|
+
if (settings.state === "unsafe") {
|
|
173
|
+
warn(`${PRODUCT}: ${settings.path} is not a folder this deck will change the owner of from the panel — it has to be a real folder named like the deck's, inside your home. Give it back by hand: sudo chown -R "$(id -un)" "${settings.path}"`);
|
|
174
|
+
return { ok: false, reason: "not_eligible" };
|
|
175
|
+
}
|
|
176
|
+
const foreign = folders.filter(f => f.state === "foreign");
|
|
177
|
+
if (!foreign.length) return { ok: true, changed: false };
|
|
178
|
+
|
|
179
|
+
const r = await exec(OSASCRIPT, [
|
|
180
|
+
"-e", GIVE_BACK_SCRIPT, "--", `${uid}:${gid}`, GIVE_BACK_PROMPT, ...foreign.flatMap(f => [f.path, f.id]),
|
|
181
|
+
], { timeout: GIVE_BACK_TIMEOUT_MS });
|
|
182
|
+
const said = (r?.stderr || r?.code || "osascript failed").toString().trim();
|
|
183
|
+
if (!r?.ok && !r?.timedOut && CANCELLED.test(r?.stderr ?? "")) return { ok: false, reason: "cancelled" };
|
|
184
|
+
|
|
185
|
+
// WHO OWNS THEM NOW, WHATEVER osascript ANSWERED. One command gives every
|
|
186
|
+
// folder back, so a failure on the log folder fails the exit after the
|
|
187
|
+
// settings folder was already changed, and a password typed just before the
|
|
188
|
+
// deadline can land after it. Answering that as a refusal would leave the
|
|
189
|
+
// deck on defaults with its writes now succeeding.
|
|
190
|
+
const owners = await Promise.all(foreign.map(f => fs.lstat(f.path).then(st => Number(st.uid), () => undefined)));
|
|
191
|
+
const left = foreign.filter((_, i) => owners[i] !== uid).map(f => f.path);
|
|
192
|
+
const settingsGiven = settings.state === "foreign" && !left.includes(settings.path);
|
|
193
|
+
if (!left.length || settingsGiven) {
|
|
194
|
+
if (left.length) warn(`${PRODUCT}: the settings folder is yours again, but ${left.join(", ")} still belongs to another user: ${said}`);
|
|
195
|
+
return { ok: true, changed: true };
|
|
196
|
+
}
|
|
197
|
+
if (r?.timedOut) return { ok: false, reason: "timed_out" };
|
|
198
|
+
if (!r?.ok) {
|
|
199
|
+
warn(GUARD_TRIPPED.test(r?.stderr ?? "")
|
|
200
|
+
? `${PRODUCT}: did not give ${left.join(", ")} back: the folder changed between the check and the password, or holds a file with a second hard link. Nothing in it changed owner.`
|
|
201
|
+
: `${PRODUCT}: could not give ${left.join(", ")} back: ${said}`);
|
|
202
|
+
return { ok: false, reason: "refused" };
|
|
203
|
+
}
|
|
204
|
+
warn(`${PRODUCT}: chown answered, but ${left.join(", ")} still belongs to another user.`);
|
|
205
|
+
return { ok: false, reason: "still_foreign" };
|
|
206
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Where prefs.json lives. Its own module because two do the arithmetic:
|
|
2
|
+
// deck-prefs.mjs, which reads and writes the file, and the half that moves a
|
|
3
|
+
// file nobody can use out of its way.
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { deckDataDir } from "./deck-home.mjs";
|
|
6
|
+
|
|
7
|
+
/* WHERE THIS FILE LIVES, AND WHY IT MOVED. It sat in ~/.claude/agent-dag — the
|
|
8
|
+
directory Claude Code owns — which meant a person clearing Claude Code's
|
|
9
|
+
configuration cleared this deck's private key, and every machine that had
|
|
10
|
+
pinned it had to be told to trust this one again. deck-home.mjs owns the new
|
|
11
|
+
answer and the reasons; what matters here is that the parameter is still a
|
|
12
|
+
DIRECTORY, so every caller that passes one is unchanged. */
|
|
13
|
+
export const prefsDir = (home = deckDataDir()) => home;
|
|
14
|
+
export const prefsPath = (home = deckDataDir()) => join(prefsDir(home), "prefs.json");
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// How the deck says it will not write prefs.json, and why.
|
|
2
|
+
//
|
|
3
|
+
// Moved out of deck-prefs.mjs whole: the error writePrefs throws rather than
|
|
4
|
+
// merge onto a base it knows is not the user's, and the two readings of it the
|
|
5
|
+
// route hands the panel (#1335) — a reason from a closed set, and the detail,
|
|
6
|
+
// every field of which is picked from a closed set too, because a route's body
|
|
7
|
+
// is readable by a DNS-rebound page and a path names the user.
|
|
8
|
+
import { PRODUCT } from "./brand.mjs";
|
|
9
|
+
|
|
10
|
+
/** The refusal `writePrefs` throws rather than merge onto a base it knows is
|
|
11
|
+
* not the user's. Shaped like installer.mjs's SETTINGS_UNREADABLE, which is
|
|
12
|
+
* the same policy on the other file this deck rewrites: a file we cannot
|
|
13
|
+
* reproduce is never treated as an empty one. */
|
|
14
|
+
export function unreadablePrefs(path, why, blocked) {
|
|
15
|
+
const err = new Error(
|
|
16
|
+
`${path} could not be read (${why}). Refusing to overwrite it — this deck's ` +
|
|
17
|
+
`LAN key and its pairings are in there and cannot be re-derived. Fix the ` +
|
|
18
|
+
`file or move it aside, then restart ${PRODUCT}.`,
|
|
19
|
+
);
|
|
20
|
+
err.code = "PREFS_UNREADABLE";
|
|
21
|
+
err.prefsPath = path;
|
|
22
|
+
err.why = why;
|
|
23
|
+
err.blocked = blocked;
|
|
24
|
+
return err;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The errors a filesystem raises for a place this user may not write — most
|
|
28
|
+
* often a settings folder a `sudo` run left owned by root (#1335). */
|
|
29
|
+
const NOT_WRITABLE = new Set(["EACCES", "EPERM", "EROFS"]);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Why a settings write failed, as a reason the panel can name — or null when
|
|
33
|
+
* the failure is not one of the two a person can fix from outside the deck.
|
|
34
|
+
*
|
|
35
|
+
* A code, never the message: the message carries the absolute path, and a
|
|
36
|
+
* route's body is readable by a DNS-rebound page (see sendInternalError). The
|
|
37
|
+
* deck's log keeps the path for the person who has to go and look.
|
|
38
|
+
*/
|
|
39
|
+
export function prefsWriteRefusal(err) {
|
|
40
|
+
if (err?.code === "PREFS_UNREADABLE") return "prefs_unreadable";
|
|
41
|
+
if (NOT_WRITABLE.has(err?.code)) return "prefs_not_writable";
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** What blocked a prefs.json that is not JSON and could not be moved aside —
|
|
46
|
+
* no errno for it, so a name of the deck's own in the same shape. */
|
|
47
|
+
export const NOT_JSON = Object.freeze({ code: "BADJSON", owner: "unknown", on: "file" });
|
|
48
|
+
|
|
49
|
+
const OWNERS = new Set(["you", "other", "unknown"]);
|
|
50
|
+
/** An errno as Node spells one, or the deck's BADJSON. Anything else — which
|
|
51
|
+
* nothing here produces — is dropped rather than echoed to the page. */
|
|
52
|
+
const ERRNO = /^E?[A-Z][A-Z0-9]{1,15}$/;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The same refusal, told precisely enough to act on from a screenshot (#1335).
|
|
56
|
+
*
|
|
57
|
+
* 3.29.3's panel could only guess — "the file may belong to another user" — and
|
|
58
|
+
* the one Mac it was written for turned out not to be the case 3.29.4 repairs,
|
|
59
|
+
* with nothing on the screen to say which case it was. So the page now gets the
|
|
60
|
+
* errno, who owns what blocked the read (`you`, `other`, `unknown`), and whether
|
|
61
|
+
* that is the `file` or its `folder`.
|
|
62
|
+
*
|
|
63
|
+
* Every field is picked from a closed set. Still no path and no uid: the route's
|
|
64
|
+
* body is readable by a DNS-rebound page (see prefsWriteRefusal), and a path
|
|
65
|
+
* names the user. Null when there is nothing precise to add. `fix` is present
|
|
66
|
+
* only when the panel can offer the repair itself — see givesBack.
|
|
67
|
+
*/
|
|
68
|
+
export function prefsRefusalDetail(err, platform = process.platform) {
|
|
69
|
+
const blocked = err?.code === "PREFS_UNREADABLE" ? err.blocked
|
|
70
|
+
: NOT_WRITABLE.has(err?.code) ? { code: err.code, owner: "unknown", on: "folder" }
|
|
71
|
+
: null;
|
|
72
|
+
if (!blocked || typeof blocked !== "object") return null;
|
|
73
|
+
const detail = {
|
|
74
|
+
code: ERRNO.test(blocked.code ?? "") ? blocked.code : "",
|
|
75
|
+
owner: OWNERS.has(blocked.owner) ? blocked.owner : "unknown",
|
|
76
|
+
on: blocked.on === "folder" ? "folder" : "file",
|
|
77
|
+
};
|
|
78
|
+
return givesBack(detail, platform) ? { ...detail, fix: "give_back" } : detail;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The one refusal the panel can repair with a press (#1711): a settings folder
|
|
82
|
+
* another user owns, on the platform whose password dialog can give it back.
|
|
83
|
+
* Everywhere else the log's command is still the way out. */
|
|
84
|
+
function givesBack({ owner, on }, platform) {
|
|
85
|
+
return platform === "darwin" && owner === "other" && on === "folder";
|
|
86
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// The deck's settings routes: GET /api/prefs, which answers what a page may see
|
|
2
|
+
// of them and what the machine allows, and POST /api/prefs, which writes a
|
|
3
|
+
// patch and hands the result to the LAN engine.
|
|
4
|
+
//
|
|
5
|
+
// These lived in src/server/index.mjs, beside the desktop notifier that reads
|
|
6
|
+
// the same settings, and moved once the settings had (prefs-state.mjs) and the
|
|
7
|
+
// engine had (lan-deck.mjs): what is left of them is a body read, a write
|
|
8
|
+
// through the one, a push to the other, and an answer. The notifier stays in
|
|
9
|
+
// index.mjs, beside the desktop-app connections it notifies through.
|
|
10
|
+
import { notificationsOn, notificationsVetoed, prefsRefusalDetail, prefsWriteRefusal, publicPrefs } from "./deck-prefs.mjs";
|
|
11
|
+
import { PRODUCT } from "./brand.mjs";
|
|
12
|
+
import { heldPrefs } from "./prefs-state.mjs";
|
|
13
|
+
import { applyLanPrefs, forgetReach, resetLanLoaded } from "./lan-deck.mjs";
|
|
14
|
+
import { giveBackDeckFolders } from "./prefs-give-back.mjs";
|
|
15
|
+
import { readBody, send } from "./http-io.mjs";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* GET the deck's settings, and what the machine is allowing.
|
|
19
|
+
*
|
|
20
|
+
* THREE fields, not two, and the third is the one a first attempt got wrong.
|
|
21
|
+
* `notificationsAllowed` is the effective answer, after the environment
|
|
22
|
+
* variable has had its say — but "false" there means either "the user switched
|
|
23
|
+
* it off" or "the machine forbids it", and the menu has to say a different
|
|
24
|
+
* sentence for each. Deriving the second from the first made the switch read
|
|
25
|
+
* "off — set at launch" the moment anybody turned it off on a deck launched
|
|
26
|
+
* with no variable at all, which is the deck telling the user their own press
|
|
27
|
+
* was somebody else's doing. `notificationsVetoed` answers only the machine's
|
|
28
|
+
* half.
|
|
29
|
+
*/
|
|
30
|
+
function prefsPayload() {
|
|
31
|
+
const prefs = heldPrefs.current();
|
|
32
|
+
return {
|
|
33
|
+
ok: true,
|
|
34
|
+
// `publicPrefs`, never the held prefs whole. This route answers anything
|
|
35
|
+
// that can reach the loopback port, and the LAN group passphrase is in the
|
|
36
|
+
// stored object — one field, and sending it here would put it in front of
|
|
37
|
+
// every page on the machine. What goes out is whether one is set.
|
|
38
|
+
prefs: publicPrefs(prefs),
|
|
39
|
+
notificationsAllowed: notificationsOn(prefs),
|
|
40
|
+
notificationsVetoed: notificationsVetoed(),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function handlePrefsRead(req, res) {
|
|
45
|
+
return send(res, 200, prefsPayload());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** POST a patch. Fields nobody sent keep their value — see writePrefs. */
|
|
49
|
+
export async function handlePrefsWrite(req, res) {
|
|
50
|
+
const raw = await readBody(req, res).catch(() => null);
|
|
51
|
+
let body = null;
|
|
52
|
+
try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
|
|
53
|
+
if (!body || typeof body !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
|
|
54
|
+
try {
|
|
55
|
+
await heldPrefs.write(body);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
// A settings file or folder this user cannot reach is the machine's to fix,
|
|
58
|
+
// not the deck's, and `guard`'s bare 500 left the panel with nothing to say
|
|
59
|
+
// about it (#1335). Anything else is still a bug and still goes to `guard`.
|
|
60
|
+
const reason = prefsWriteRefusal(err);
|
|
61
|
+
if (!reason) throw err;
|
|
62
|
+
console.error(`${PRODUCT}: settings were not saved:`, err?.message ?? err);
|
|
63
|
+
// What blocked it, from closed sets and without the path, so a screenshot
|
|
64
|
+
// of the panel is enough to tell the cases apart (#1335).
|
|
65
|
+
const detail = prefsRefusalDetail(err);
|
|
66
|
+
return send(res, 500, detail ? { ok: false, reason, detail } : { ok: false, reason });
|
|
67
|
+
}
|
|
68
|
+
// The engine reads its settings from here rather than holding its own copy,
|
|
69
|
+
// so turning the switch off in the panel really does stop the sockets rather
|
|
70
|
+
// than only changing what the panel says.
|
|
71
|
+
await applyLanPrefs();
|
|
72
|
+
// Somebody has just switched this on and is watching the panel for the answer
|
|
73
|
+
// to one question: is anything going to turn up. Whatever the probe said
|
|
74
|
+
// before is about a deck whose sockets were down — see forgetReach.
|
|
75
|
+
if (body.lan?.enabled === true) forgetReach();
|
|
76
|
+
return send(res, 200, prefsPayload());
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* POST: give the deck's settings folder back to this user, through macOS's own
|
|
81
|
+
* password dialog (#1711). No body — every path is the deck's own; see
|
|
82
|
+
* prefs-give-back.mjs.
|
|
83
|
+
*
|
|
84
|
+
* AND THEN READ THE FILE AGAIN, AS A BOOT WOULD. A deck that started on a folder
|
|
85
|
+
* it could not open is running on the defaults, and its LAN engine on a key it
|
|
86
|
+
* made up and could not keep. Once the file is readable that is the wrong deck:
|
|
87
|
+
* the settings are re-read, and the engine is handed the fields it authors off
|
|
88
|
+
* the file again — the stored key and pairings — which is what a restart would
|
|
89
|
+
* have done, without the restart.
|
|
90
|
+
*/
|
|
91
|
+
export async function handlePrefsGiveBack(req, res) {
|
|
92
|
+
const out = await giveBackDeckFolders();
|
|
93
|
+
if (!out.ok) return send(res, 409, { ok: false, reason: out.reason });
|
|
94
|
+
if ((await heldPrefs.reload()) === "file") {
|
|
95
|
+
resetLanLoaded();
|
|
96
|
+
await applyLanPrefs();
|
|
97
|
+
}
|
|
98
|
+
return send(res, 200, { ...prefsPayload(), changed: out.changed });
|
|
99
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Moving a prefs.json nobody can use out of the way, before anything can write
|
|
2
|
+
// over it: the bytes of a file that is not JSON (#1002), and a file another
|
|
3
|
+
// user owns (#1335). Moved out of deck-prefs.mjs unchanged; loadPrefs decides
|
|
4
|
+
// when, and this is how.
|
|
5
|
+
import { stat } from "node:fs/promises";
|
|
6
|
+
import { renameWithRetry } from "./atomic-write.mjs";
|
|
7
|
+
import { deckDataDir } from "./deck-home.mjs";
|
|
8
|
+
import { prefsDir, prefsPath } from "./prefs-path.mjs";
|
|
9
|
+
|
|
10
|
+
/** Where the bytes of a prefs.json nothing could parse are put.
|
|
11
|
+
*
|
|
12
|
+
* `Date.now()` rather than an ISO timestamp because a colon is not a legal
|
|
13
|
+
* filename character on Windows, and a quarantine that cannot be created on
|
|
14
|
+
* the platform it is protecting is not a quarantine. */
|
|
15
|
+
export const quarantinePath = (home = deckDataDir(), at = Date.now()) =>
|
|
16
|
+
`${prefsPath(home)}.corrupt-${at}`;
|
|
17
|
+
|
|
18
|
+
/** The read errors that can mean "not yours" rather than "broken". Only these
|
|
19
|
+
* send a read to setAsideForeign; EISDIR, EIO and EMFILE never do. */
|
|
20
|
+
export const DENIED = new Set(["EACCES", "EPERM"]);
|
|
21
|
+
|
|
22
|
+
/** Where a prefs.json another user owns is put. Beside the file, like
|
|
23
|
+
* quarantinePath, and stamped in milliseconds for the same Windows reason —
|
|
24
|
+
* but a name of its own, because nothing is wrong with these bytes. */
|
|
25
|
+
export const foreignPath = (home = deckDataDir(), at = Date.now()) =>
|
|
26
|
+
`${prefsPath(home)}.foreign-${at}`;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Move a prefs.json that ANOTHER USER OWNS out of the way, or say why not.
|
|
30
|
+
*
|
|
31
|
+
* #1335. A `sudo ccdeck` that kept the user's HOME writes a root-owned 0600
|
|
32
|
+
* prefs.json into the user's own folder. From then on every
|
|
33
|
+
* deck that user starts is refused the read, refuses every settings write on
|
|
34
|
+
* top of it — rightly — and so can never keep anything: a new LAN identity on
|
|
35
|
+
* every start, a share tick that says "Could not share that account", and no
|
|
36
|
+
* way out from inside the deck.
|
|
37
|
+
*
|
|
38
|
+
* WHY MOVING IT IS SAFE HERE WHEN IT IS NOT FOR "unreadable". The refusal
|
|
39
|
+
* exists so the deck never loses a key it could still have read. A file owned
|
|
40
|
+
* by somebody else is not one this user's deck will ever read — waiting does
|
|
41
|
+
* not help, and the deck is already running on defaults. A rename reads and
|
|
42
|
+
* changes nothing: owner and mode travel with the file, so the key in it stays
|
|
43
|
+
* private and whole, and `sudo chown` plus a move puts it back.
|
|
44
|
+
*
|
|
45
|
+
* Only when the owner is KNOWN to be somebody else. A file this user owns and
|
|
46
|
+
* still cannot read (a mode bit, a lock, a sandbox) is left exactly where it
|
|
47
|
+
* is, and so is anything on Windows, where there is no uid to compare and
|
|
48
|
+
* EPERM is usually another program holding the file.
|
|
49
|
+
*
|
|
50
|
+
* Answers `{ moved, uid }`, `{ gone }` when the file vanished under it, or,
|
|
51
|
+
* when it stayed, `{ why, owner, on }`: a sentence for the log, possibly empty,
|
|
52
|
+
* and who owns what blocked the read — "you", "other" or "unknown" — and
|
|
53
|
+
* whether that is the "file" or its "folder". The last two are what the panel
|
|
54
|
+
* is told (see prefsRefusalDetail); the sentence, which has the path, is not.
|
|
55
|
+
*/
|
|
56
|
+
export async function setAsideForeign(path, home, deps) {
|
|
57
|
+
const uid = deps.getuid ? deps.getuid() : process.getuid?.();
|
|
58
|
+
if (uid === undefined) return { why: "", owner: "unknown", on: "file" };
|
|
59
|
+
const look = deps.stat ?? stat;
|
|
60
|
+
let st;
|
|
61
|
+
try {
|
|
62
|
+
st = await look(path);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
if (err?.code === "ENOENT") return { gone: true };
|
|
65
|
+
// The folder itself is out of reach, so nothing in it can be looked at, let
|
|
66
|
+
// alone moved. Name the folder and its owner, which is what a person needs.
|
|
67
|
+
const folder = prefsDir(home);
|
|
68
|
+
const owner = await look(folder).then(s => s.uid, () => undefined);
|
|
69
|
+
if (owner === undefined) return { why: "", owner: "unknown", on: "folder" };
|
|
70
|
+
if (owner === uid) return { why: "", owner: "you", on: "folder" };
|
|
71
|
+
return {
|
|
72
|
+
why: ` The folder ${folder} belongs to another user (uid ${owner}), most likely from a run with sudo; sudo chown -R "$(id -un)" "${folder}" gives it back.`,
|
|
73
|
+
owner: "other",
|
|
74
|
+
on: "folder",
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
if (st.uid === uid) return { why: "", owner: "you", on: "file" };
|
|
78
|
+
const to = foreignPath(home);
|
|
79
|
+
try {
|
|
80
|
+
await (deps.rename ?? renameWithRetry)(path, to);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
if (err?.code === "ENOENT") return { gone: true };
|
|
83
|
+
return { why: ` It belongs to another user (uid ${st.uid}) and could not be moved aside either: ${err?.message ?? err}.`, owner: "other", on: "file" };
|
|
84
|
+
}
|
|
85
|
+
return { moved: to, uid: st.uid };
|
|
86
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// The deck's settings as this process holds them: the one in-memory copy every
|
|
2
|
+
// reader asks, and the one place a write's answer is kept.
|
|
3
|
+
//
|
|
4
|
+
// This was `let _prefs` in src/server/index.mjs, and it is the reason the LAN
|
|
5
|
+
// section could not leave that file. The notifier and the away-update read it;
|
|
6
|
+
// the settings route, the LAN peer route and six of the LAN engine's callbacks
|
|
7
|
+
// each assigned it back with `_prefs = await writePrefs(...)`; and a
|
|
8
|
+
// module-level `let` is one no other module can assign. So the variable moved
|
|
9
|
+
// first, behind the three things its callers actually do with it: read it,
|
|
10
|
+
// write a patch, and compute a patch from the file. Every write now goes
|
|
11
|
+
// through here, which is also what keeps the promise the comment below has
|
|
12
|
+
// always made — that the copy and the file cannot drift within one process —
|
|
13
|
+
// true by construction rather than by every caller remembering the assignment.
|
|
14
|
+
//
|
|
15
|
+
// The disk half is deck-prefs.mjs, unchanged: the queue, the merge and the
|
|
16
|
+
// refusal to write over a file it could not read all live there. This holds
|
|
17
|
+
// what they return and nothing else.
|
|
18
|
+
import { DEFAULTS, loadPrefs, loadPrefsInTurn, updatePrefs, writePrefs } from "./deck-prefs.mjs";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The deck's own settings, in memory, refreshed whenever they are written.
|
|
22
|
+
*
|
|
23
|
+
* Read once at boot and then kept here rather than read per event: `consider`
|
|
24
|
+
* is on the ingest path every hook event goes through, and a file read there
|
|
25
|
+
* would be a syscall per event to answer a question that changes when somebody
|
|
26
|
+
* presses a switch. The write path below updates this, so the in-memory copy
|
|
27
|
+
* and the file cannot drift within one process — and a second deck writing the
|
|
28
|
+
* file is picked up on ITS next write or this one's next boot, which is the
|
|
29
|
+
* same freshness every other cross-deck setting has.
|
|
30
|
+
*/
|
|
31
|
+
let _prefs = { ...DEFAULTS };
|
|
32
|
+
/** Whether the boot read failed on a file that is still there (#1711): the
|
|
33
|
+
* copy here is the defaults, and the file holds settings — the LAN key, the
|
|
34
|
+
* pairings — this process never saw. Only a re-read that finds the file clears
|
|
35
|
+
* it. A write that lands first merges onto the file, but leaves the engine on
|
|
36
|
+
* what it made up, so it does not count. */
|
|
37
|
+
let _unread = false;
|
|
38
|
+
|
|
39
|
+
// Read at import, so `consider` has its answer from the first event. LAN sync
|
|
40
|
+
// used to start from this same read, and it must not: bin/deck.js imports the
|
|
41
|
+
// server module to ASK whether a deck is already up, and a launcher that then
|
|
42
|
+
// attaches and exits had already bound the beacon and the sync port on the way
|
|
43
|
+
// in — which is the `lan sync (listen): EADDRINUSE` line a second `ccdeck`
|
|
44
|
+
// printed above `deck already running`. The engine starts from the listen that
|
|
45
|
+
// succeeds, in startServer, which is the only process that may hold a port.
|
|
46
|
+
export const prefsRead = loadPrefs().then(r => { _prefs = r.prefs; _unread = r.source === "unreadable"; }).catch(() => {});
|
|
47
|
+
|
|
48
|
+
export const heldPrefs = Object.freeze({
|
|
49
|
+
/** What the last read or write left here. Never a disk read — see above. */
|
|
50
|
+
current: () => _prefs,
|
|
51
|
+
/** `writePrefs(patch)`, and keep what it wrote. A write that throws keeps
|
|
52
|
+
* nothing, so the copy is never ahead of the file. */
|
|
53
|
+
write: async patch => (_prefs = await writePrefs(patch)),
|
|
54
|
+
/** `updatePrefs(mutate)`, and keep what it wrote: for a patch that has to be
|
|
55
|
+
* computed from the file rather than from `current()` — see updatePrefs. */
|
|
56
|
+
update: async mutate => (_prefs = await updatePrefs(mutate)),
|
|
57
|
+
/** Read the file again, as a boot would, once something outside the deck has
|
|
58
|
+
* made it readable (#1711) — and only for a deck whose boot read could not.
|
|
59
|
+
* Any other deck is left alone and answers "held": re-reading it would race
|
|
60
|
+
* the engine's own writes for nothing. Otherwise the read waits its turn
|
|
61
|
+
* behind the writes, and is kept only when it really is the user's file; a
|
|
62
|
+
* read that still fails leaves the copy this process was running on.
|
|
63
|
+
* Answers "held", or the read's `source`. */
|
|
64
|
+
reload: async () => {
|
|
65
|
+
if (!_unread) return "held";
|
|
66
|
+
const { prefs, source } = await loadPrefsInTurn();
|
|
67
|
+
if (source === "file") { _prefs = prefs; _unread = false; }
|
|
68
|
+
return source;
|
|
69
|
+
},
|
|
70
|
+
});
|