ccdeck 3.30.0 → 3.32.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.
@@ -62,8 +62,8 @@
62
62
  stored === "light" || (stored !== "dark" && light) ? "light" : "dark");
63
63
  })();
64
64
  </script>
65
- <script type="module" crossorigin src="/assets/index-BVTWHDta.js"></script>
66
- <link rel="stylesheet" crossorigin href="/assets/index-3vS6A0vt.css">
65
+ <script type="module" crossorigin src="/assets/index-DiTUBs9l.js"></script>
66
+ <link rel="stylesheet" crossorigin href="/assets/index-DKn7nc15.css">
67
67
  </head>
68
68
  <body>
69
69
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccdeck",
3
- "version": "3.30.0",
3
+ "version": "3.32.0",
4
4
  "description": "Live dashboard for Claude Code and Codex — which agent is waiting on you, tool calls, token spend and every Claude Code subagent on one calm canvas. Run it with npx ccdeck.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -59,6 +59,30 @@
59
59
  "3.29.7",
60
60
  "3.29.9"
61
61
  ],
62
+ "3.32.0": [
63
+ {
64
+ "title": "🗺️ A map of your network",
65
+ "body": "A new button on the Local network row, and in its view's header, opens a map of every deck around this one. This deck is at the centre and the paired decks that are on stand around it. Further out are the quiet ones, the machines nearby and any deck asking to pair. Wires are solid on your local network and dashed over Tailscale. Pointing at a deck says how it is reached, what it runs and which logins the two share, and pressing it opens that deck's own dialog."
66
+ }
67
+ ],
68
+ "3.31.0": [
69
+ {
70
+ "title": "📊 Account capacity",
71
+ "body": "A gauge button in the accounts panel's header opens Account capacity: how many of your Claude accounts are ready now, what the 5-hour and 7-day windows have left across all of them, and a row for each account with its use, its state (Ready, Limited, Exhausted or Stale) and when it was last read. Each window also says whose reset comes next."
72
+ },
73
+ {
74
+ "title": "🚦 Claude and Codex incidents",
75
+ "body": "When Anthropic's or OpenAI's status page reports an incident on something your sessions use, the deck says so in the topbar, the Usage panel and the tray, and every mark links to the status page. A provider with nothing wrong draws nothing."
76
+ },
77
+ {
78
+ "title": "↕️ Order your other accounts",
79
+ "body": "The Other accounts row can sort the list by slot, by how full a window is, or by most room, and open every account at once. Both stay as you left them after a reload."
80
+ },
81
+ {
82
+ "title": "🔐 A settings folder left by sudo can be given back",
83
+ "body": "On a Mac, running ccdeck once with sudo left its settings folder owned by another user, and every settings change was refused, sharing an account included. The message now offers \"give it back\": macOS asks for your password, the folder is yours again, and the deck reads its settings back without a restart."
84
+ }
85
+ ],
62
86
  "3.30.0": [
63
87
  {
64
88
  "title": "🧮 Token counts and costs are no longer doubled",
@@ -29,8 +29,12 @@ import { join, resolve, posix as posixPath, win32 as winPath } from "node:path";
29
29
  * The environment and the home directory are parameters purely so callers that
30
30
  * are already working from an injected environment can stay consistent with it;
31
31
  * every caller in the deck passes nothing and gets the real machine's answer.
32
+ * `platform` is the same kind of parameter: the answer for another platform's
33
+ * paths, joined with that platform's separator, which is what legacyDeckDir
34
+ * needs to be checked for a Mac on a Windows runner (#1711).
32
35
  */
33
- export function claudeConfigDir(env = process.env, home = homedir()) {
36
+ export function claudeConfigDir(env = process.env, home = homedir(), platform = process.platform) {
37
+ const { join, resolve } = platform === "win32" ? winPath : posixPath;
34
38
  const override = env.CLAUDE_CONFIG_DIR?.trim();
35
39
  return override ? resolve(override) : join(home, ".claude");
36
40
  }
@@ -61,7 +61,7 @@ const HOME_ENV = "CCDECK_HOME";
61
61
  * live. Also the place a migration reads from. */
62
62
  export function legacyDeckDir(env = process.env, home = homedir(), platform = process.platform) {
63
63
  const { join } = platform === "win32" ? winPath : posixPath;
64
- return join(claudeConfigDir(env, home), "agent-dag");
64
+ return join(claudeConfigDir(env, home, platform), "agent-dag");
65
65
  }
66
66
 
67
67
  /**
@@ -472,6 +472,13 @@ export function withAlias(fp, name) {
472
472
  };
473
473
  }
474
474
 
475
+ /** loadPrefs, behind every write already queued (#1711): a re-read that lands
476
+ * between a write's read and its rename, or that answers with a copy older
477
+ * than a write that finished first, is a copy the next write merges onto. */
478
+ export function loadPrefsInTurn(home = deckDataDir(), deps = {}) {
479
+ return queued(() => loadPrefs(home, deps));
480
+ }
481
+
475
482
  /** One read-modify-write, behind every other one. Both entry points go through
476
483
  * here so there is a single queue and a single merge. */
477
484
  function queued(job) {
@@ -134,7 +134,7 @@ import { applyLanPrefs, resetLanLoaded } from "./lan-deck.mjs";
134
134
  // The Local network panel's four routes — see lan-routes.mjs.
135
135
  import { handleLanInvite, handleLanPeer, handleLanStatus, handleLanSync } from "./lan-routes.mjs";
136
136
  // GET and POST /api/prefs — see prefs-routes.mjs.
137
- import { handlePrefsRead, handlePrefsWrite } from "./prefs-routes.mjs";
137
+ import { handlePrefsGiveBack, handlePrefsRead, handlePrefsWrite } from "./prefs-routes.mjs";
138
138
  import { MANIFEST_PATH, offerManifest } from "./app-manifest.mjs";
139
139
  import { historySnapshot, readProcesses, startSystemMetrics, systemSnapshot } from "./system-metrics.mjs";
140
140
  // How every route reads a body and answers, and the answer for one that threw
@@ -150,7 +150,7 @@ import { handleBrowserWatch, handleBrowserWatchDismiss, handleBrowserWatchSettin
150
150
  // The music routes, every one behind AGENTS_DECK_NO_MUSIC — see music-routes.mjs.
151
151
  import { handleBestOfNostalgia, handleCafeMusicBgm, handleClaudeFm, handleFmStation, handleGoodLifeRadio, handleLiveRadioMix, handleLofiGirl } from "./music-routes.mjs";
152
152
  // The usage panel's quota and history reads — see usage-routes.mjs.
153
- import { handleCcusage, handleCodexQuota, handleCodexUsage, handleQuota } from "./usage-routes.mjs";
153
+ import { handleCcusage, handleCodexQuota, handleCodexUsage, handleProviderStatus, handleQuota } from "./usage-routes.mjs";
154
154
  // The port fallback, one listen attempt, and the words for a failed one — see
155
155
  // listen.mjs. The loop that uses them is startServer's.
156
156
  import { listenFailure, portRetryable, randomPort, tryListen } from "./listen.mjs";
@@ -293,6 +293,7 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
293
293
  if (req.method === "POST" && url.pathname === "/api/lan/invite") return guard(handleLanInvite(req, res), res);
294
294
  if (req.method === "POST" && url.pathname === "/api/lan/sync") return guard(handleLanSync(req, res), res);
295
295
  if (req.method === "POST" && url.pathname === "/api/prefs") return guard(handlePrefsWrite(req, res), res);
296
+ if (req.method === "POST" && url.pathname === "/api/prefs/give-back") return guard(handlePrefsGiveBack(req, res), res);
296
297
  if (req.method === "GET" && url.pathname === "/api/system") return send(res, 200, systemSnapshot());
297
298
  // On demand only — the process list costs a subprocess on every platform,
298
299
  // so it is fetched while the detail panel is open and never on the timer.
@@ -327,6 +328,7 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
327
328
  }
328
329
  if (req.method === "GET" && url.pathname === "/api/codex-quota") return guard(handleCodexQuota(req, res), res);
329
330
  if (req.method === "GET" && url.pathname === "/api/ccusage") return guard(handleCcusage(req, res), res);
331
+ if (req.method === "GET" && url.pathname === "/api/provider-status") return guard(handleProviderStatus(req, res), res);
330
332
  if (req.method === "GET" && url.pathname === "/api/browser-watch") return guard(handleBrowserWatch(req, res), res);
331
333
  if (req.method === "POST" && url.pathname === "/api/browser-watch") return guard(handleBrowserWatchSettings(req, res), res);
332
334
  if (req.method === "POST" && url.pathname === "/api/browser-watch/dismiss") return guard(handleBrowserWatchDismiss(req, res), res);
@@ -82,6 +82,8 @@ const PINNED_MODULES = [
82
82
  "account-projects.mjs",
83
83
  "swap-log.mjs",
84
84
  "lan-sync.mjs",
85
+ // The providers' status pages (#1311), reached only from their route.
86
+ "provider-status.mjs",
85
87
  ];
86
88
 
87
89
  let _pinned = null;
@@ -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
+ }
@@ -62,16 +62,25 @@ const ERRNO = /^E?[A-Z][A-Z0-9]{1,15}$/;
62
62
  *
63
63
  * Every field is picked from a closed set. Still no path and no uid: the route's
64
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.
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.
66
67
  */
67
- export function prefsRefusalDetail(err) {
68
+ export function prefsRefusalDetail(err, platform = process.platform) {
68
69
  const blocked = err?.code === "PREFS_UNREADABLE" ? err.blocked
69
70
  : NOT_WRITABLE.has(err?.code) ? { code: err.code, owner: "unknown", on: "folder" }
70
71
  : null;
71
72
  if (!blocked || typeof blocked !== "object") return null;
72
- return {
73
+ const detail = {
73
74
  code: ERRNO.test(blocked.code ?? "") ? blocked.code : "",
74
75
  owner: OWNERS.has(blocked.owner) ? blocked.owner : "unknown",
75
76
  on: blocked.on === "folder" ? "folder" : "file",
76
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";
77
86
  }
@@ -10,7 +10,8 @@
10
10
  import { notificationsOn, notificationsVetoed, prefsRefusalDetail, prefsWriteRefusal, publicPrefs } from "./deck-prefs.mjs";
11
11
  import { PRODUCT } from "./brand.mjs";
12
12
  import { heldPrefs } from "./prefs-state.mjs";
13
- import { applyLanPrefs, forgetReach } from "./lan-deck.mjs";
13
+ import { applyLanPrefs, forgetReach, resetLanLoaded } from "./lan-deck.mjs";
14
+ import { giveBackDeckFolders } from "./prefs-give-back.mjs";
14
15
  import { readBody, send } from "./http-io.mjs";
15
16
 
16
17
  /**
@@ -74,3 +75,25 @@ export async function handlePrefsWrite(req, res) {
74
75
  if (body.lan?.enabled === true) forgetReach();
75
76
  return send(res, 200, prefsPayload());
76
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
+ }
@@ -15,7 +15,7 @@
15
15
  // The disk half is deck-prefs.mjs, unchanged: the queue, the merge and the
16
16
  // refusal to write over a file it could not read all live there. This holds
17
17
  // what they return and nothing else.
18
- import { DEFAULTS, readPrefs, updatePrefs, writePrefs } from "./deck-prefs.mjs";
18
+ import { DEFAULTS, loadPrefs, loadPrefsInTurn, updatePrefs, writePrefs } from "./deck-prefs.mjs";
19
19
 
20
20
  /**
21
21
  * The deck's own settings, in memory, refreshed whenever they are written.
@@ -29,6 +29,12 @@ import { DEFAULTS, readPrefs, updatePrefs, writePrefs } from "./deck-prefs.mjs";
29
29
  * same freshness every other cross-deck setting has.
30
30
  */
31
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;
32
38
 
33
39
  // Read at import, so `consider` has its answer from the first event. LAN sync
34
40
  // used to start from this same read, and it must not: bin/deck.js imports the
@@ -37,7 +43,7 @@ let _prefs = { ...DEFAULTS };
37
43
  // in — which is the `lan sync (listen): EADDRINUSE` line a second `ccdeck`
38
44
  // printed above `deck already running`. The engine starts from the listen that
39
45
  // succeeds, in startServer, which is the only process that may hold a port.
40
- export const prefsRead = readPrefs().then(p => { _prefs = p; }).catch(() => {});
46
+ export const prefsRead = loadPrefs().then(r => { _prefs = r.prefs; _unread = r.source === "unreadable"; }).catch(() => {});
41
47
 
42
48
  export const heldPrefs = Object.freeze({
43
49
  /** What the last read or write left here. Never a disk read — see above. */
@@ -48,4 +54,17 @@ export const heldPrefs = Object.freeze({
48
54
  /** `updatePrefs(mutate)`, and keep what it wrote: for a patch that has to be
49
55
  * computed from the file rather than from `current()` — see updatePrefs. */
50
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
+ },
51
70
  });