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.
- package/README.md +3 -2
- package/dist/web/assets/{BrowserWatchModal-DP3YMtEi.js → BrowserWatchModal-h_p7Ym4c.js} +1 -1
- package/dist/web/assets/{UsageHistoryModal-CcdPVXAw.js → UsageHistoryModal-F84RRMyC.js} +1 -1
- package/dist/web/assets/index-DKn7nc15.css +1 -0
- package/dist/web/assets/index-DiTUBs9l.js +299 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +24 -0
- package/src/server/claude-dir.mjs +5 -1
- package/src/server/deck-home.mjs +1 -1
- package/src/server/deck-prefs.mjs +7 -0
- package/src/server/index.mjs +4 -2
- package/src/server/pinned-build.mjs +2 -0
- package/src/server/prefs-give-back.mjs +206 -0
- package/src/server/prefs-refusal.mjs +12 -3
- package/src/server/prefs-routes.mjs +24 -1
- package/src/server/prefs-state.mjs +21 -2
- package/src/server/provider-status.mjs +307 -0
- package/src/server/usage-routes.mjs +17 -2
- package/dist/web/assets/index-3vS6A0vt.css +0 -1
- package/dist/web/assets/index-BVTWHDta.js +0 -298
package/dist/web/index.html
CHANGED
|
@@ -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-
|
|
66
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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.
|
|
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": {
|
package/release-notes.json
CHANGED
|
@@ -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
|
}
|
package/src/server/deck-home.mjs
CHANGED
|
@@ -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) {
|
package/src/server/index.mjs
CHANGED
|
@@ -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);
|
|
@@ -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
|
-
|
|
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,
|
|
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 =
|
|
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
|
});
|