agent-dag 1.47.0 → 3.0.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 +6 -2
- package/dist/web/assets/index-6cAJDiBs.js +125 -0
- package/dist/web/assets/index-Bnn7d7u8.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +28 -0
- package/src/server/agent-activity.mjs +506 -0
- package/src/server/browser-history.mjs +467 -0
- package/src/server/browser-presence.mjs +157 -0
- package/src/server/browser-profiles.mjs +267 -0
- package/src/server/browser-react.mjs +204 -0
- package/src/server/browser-watch-store.mjs +288 -0
- package/src/server/browser-watch.mjs +717 -0
- package/src/server/claude-accounts.mjs +77 -7
- package/src/server/codex-usage.mjs +79 -1
- package/src/server/cswap-admin.mjs +485 -36
- package/src/server/index.mjs +172 -4
- package/src/server/installer.mjs +13 -0
- package/src/server/relay-guard.mjs +502 -0
- package/src/server/system-metrics.mjs +715 -32
- package/dist/web/assets/index-BgcITzCV.css +0 -1
- package/dist/web/assets/index-ChVeFKhn.js +0 -101
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// Which Chromium-family browsers are on this machine, which of their profiles
|
|
2
|
+
// exist, and which of those carry the Claude in Chrome extension.
|
|
3
|
+
//
|
|
4
|
+
// This is the floor of Browser Watch: every other part of the feature opens a
|
|
5
|
+
// profile's History or its Secure Preferences, and neither is findable until
|
|
6
|
+
// something has answered "where are the profiles". The whole module is a claim
|
|
7
|
+
// about somebody else's directory layout, which is why nothing here reaches the
|
|
8
|
+
// disk except through an injected `deps` — the Windows layout and the two Linux
|
|
9
|
+
// sandbox layouts have to be exercisable from a Mac, exactly as
|
|
10
|
+
// claudeCliCandidates() and cswapCandidates() are, or the list rots on every
|
|
11
|
+
// platform its author does not sit at.
|
|
12
|
+
//
|
|
13
|
+
// THE ASYMMETRY THAT SILENTLY BREAKS IT. Chromium's user-data directory is the
|
|
14
|
+
// parent of `Default` and `Profile N`, and macOS spells it differently from
|
|
15
|
+
// everywhere else:
|
|
16
|
+
//
|
|
17
|
+
// macOS ~/Library/Application Support/Google/Chrome/Default
|
|
18
|
+
// Windows %LOCALAPPDATA%\Google\Chrome\User Data\Default
|
|
19
|
+
// Linux ~/.config/google-chrome/Default
|
|
20
|
+
//
|
|
21
|
+
// There is NO `User Data` level on macOS — the app-support directory is itself
|
|
22
|
+
// the user-data directory — and the mistake is invisible in both directions.
|
|
23
|
+
// Insert the level on macOS and every root misses by one; drop it on Windows
|
|
24
|
+
// and every root misses by one. Both produce an empty list, and an empty list
|
|
25
|
+
// is also the honest answer for a machine that has no Chrome. So a bug here
|
|
26
|
+
// never looks like a bug. It looks like a user who does not use Chrome, on a
|
|
27
|
+
// panel whose entire job is to say which browsers are watched.
|
|
28
|
+
//
|
|
29
|
+
// Arc is the exception that proves the rule: it keeps `User Data` under its own
|
|
30
|
+
// app-support directory on macOS, because Arc is Chromium shipped by people who
|
|
31
|
+
// did not inherit Chrome's macOS habit. It is the reason the macOS list is a
|
|
32
|
+
// table of paths rather than a directory name plus a formula.
|
|
33
|
+
import { existsSync as fsExistsSync, readdirSync as fsReaddirSync, statSync as fsStatSync } from "node:fs";
|
|
34
|
+
import { homedir } from "node:os";
|
|
35
|
+
import { posix as posixPath, win32 as winPath } from "node:path";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Claude in Chrome's extension id, which is the same 32 characters in every
|
|
39
|
+
* Chromium-family browser that can install it.
|
|
40
|
+
*
|
|
41
|
+
* A Chrome extension id is derived from the packing key, not from the store or
|
|
42
|
+
* the browser, so Brave, Edge and Vivaldi all hold it under this exact name.
|
|
43
|
+
* That is what makes a directory test sufficient here and what lets one
|
|
44
|
+
* constant serve every row this module returns.
|
|
45
|
+
*/
|
|
46
|
+
export const CLAUDE_EXT_ID = "fcoeoabgfenejglbffodgkkbkcdhcgfn";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The directories inside a user-data root that are browsing profiles.
|
|
50
|
+
*
|
|
51
|
+
* `Default` and `Profile N` only. Chromium also keeps `Guest Profile` and
|
|
52
|
+
* `System Profile` beside them on every install — the second exists before the
|
|
53
|
+
* user has ever opened a window — and both carry a History file that never gets
|
|
54
|
+
* a row. Reporting them would put two permanently empty entries on the panel of
|
|
55
|
+
* every machine in the world and make "no browsing here yet" indistinguishable
|
|
56
|
+
* from "this is not a profile at all".
|
|
57
|
+
*/
|
|
58
|
+
const PROFILE_DIR = /^(?:Default|Profile \d+)$/;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The path rules a root is written in, read off the root itself.
|
|
62
|
+
*
|
|
63
|
+
* `profileDirs` and `hasExtension` take a path and no platform — they are handed
|
|
64
|
+
* a root `browserRoots` already spelled — so the flavour has to come out of the
|
|
65
|
+
* string. It matters more than it looks. POSIX `join` would answer
|
|
66
|
+
* `C:\…\User Data/Default`, a mixed-separator path that Windows itself would
|
|
67
|
+
* happily open, so nothing on a real machine would ever complain; but
|
|
68
|
+
* `posixPath.basename` of that string is the WHOLE string, so `profile` would
|
|
69
|
+
* come back as an absolute path instead of `Default` and every caller keying on
|
|
70
|
+
* the profile name would miss. The failure only ever appears on the leg that
|
|
71
|
+
* checks Windows from a Mac, which is every leg that exists for this module.
|
|
72
|
+
*
|
|
73
|
+
* Anchored at the front rather than sniffing for a backslash anywhere: a POSIX
|
|
74
|
+
* directory may legally be named `Brave\Browser`, and every root built here is
|
|
75
|
+
* absolute, so a drive letter or a UNC prefix is the entire test.
|
|
76
|
+
*/
|
|
77
|
+
const WINDOWS_ROOT = /^(?:[A-Za-z]:[\\/]|\\\\)/;
|
|
78
|
+
const flavourOf = p => (WINDOWS_ROOT.test(String(p)) ? winPath : posixPath);
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Whether `p` is a directory, answering false for everything else a real disk
|
|
82
|
+
* can put there.
|
|
83
|
+
*
|
|
84
|
+
* Never throws, and that is the point rather than tidiness. This runs once per
|
|
85
|
+
* candidate profile across every browser on the machine: one profile on a
|
|
86
|
+
* disconnected network home, one `Default` that a sync tool left as a file, one
|
|
87
|
+
* broken symlink into an unmounted volume would otherwise abort the whole walk
|
|
88
|
+
* and take every other browser's profiles down with it.
|
|
89
|
+
*/
|
|
90
|
+
function isDirectory(p, statSync) {
|
|
91
|
+
try { return statSync(p).isDirectory(); } catch { return false; }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Every known Chromium-family user-data root for this platform, whether or not
|
|
96
|
+
* it exists.
|
|
97
|
+
*
|
|
98
|
+
* Pure, and platform, environment and home are parameters, so the Windows and
|
|
99
|
+
* Linux answers are checkable from a Mac — the same reason claudeCliCandidates()
|
|
100
|
+
* takes them. Deliberately does NOT stat anything: "which browsers could be
|
|
101
|
+
* here" and "which are" are separate questions, and keeping the first one pure
|
|
102
|
+
* is what lets a test assert the whole table without a disk of any kind.
|
|
103
|
+
*
|
|
104
|
+
* `key` is the stable identifier the rest of Browser Watch keys rows on; `name`
|
|
105
|
+
* is the string a human reads. They are separate because Brave's directory is
|
|
106
|
+
* `Brave-Browser` on one platform and `com.brave.Browser` on another, and no
|
|
107
|
+
* panel should ever show either.
|
|
108
|
+
*/
|
|
109
|
+
export function browserRoots(platform = process.platform, env = process.env, home = homedir()) {
|
|
110
|
+
// The path flavour follows the PLATFORM ARGUMENT, not the host: node's `join`
|
|
111
|
+
// emits forward slashes when the Windows table is built on a Mac, which is
|
|
112
|
+
// both wrong for the caller and invisible in a test that only compares
|
|
113
|
+
// against a string it built the same wrong way.
|
|
114
|
+
const path = platform === "win32" ? winPath : posixPath;
|
|
115
|
+
|
|
116
|
+
if (platform === "darwin") {
|
|
117
|
+
// No `User Data` anywhere in here except Arc. See the module header.
|
|
118
|
+
const support = path.join(home, "Library", "Application Support");
|
|
119
|
+
return [
|
|
120
|
+
{ key: "chrome", name: "Google Chrome", root: path.join(support, "Google", "Chrome") },
|
|
121
|
+
{ key: "chrome-beta", name: "Google Chrome Beta", root: path.join(support, "Google", "Chrome Beta") },
|
|
122
|
+
{ key: "chrome-canary", name: "Google Chrome Canary", root: path.join(support, "Google", "Chrome Canary") },
|
|
123
|
+
{ key: "chromium", name: "Chromium", root: path.join(support, "Chromium") },
|
|
124
|
+
{ key: "brave", name: "Brave", root: path.join(support, "BraveSoftware", "Brave-Browser") },
|
|
125
|
+
{ key: "edge", name: "Microsoft Edge", root: path.join(support, "Microsoft Edge") },
|
|
126
|
+
{ key: "vivaldi", name: "Vivaldi", root: path.join(support, "Vivaldi") },
|
|
127
|
+
{ key: "arc", name: "Arc", root: path.join(support, "Arc", "User Data") },
|
|
128
|
+
];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (platform === "win32") {
|
|
132
|
+
// %LOCALAPPDATA% rather than a path under the home directory, because a
|
|
133
|
+
// roaming profile puts AppData\Roaming on a network share while Local stays
|
|
134
|
+
// on the machine — the same reason claudeCliCandidates() reads %APPDATA%.
|
|
135
|
+
// An empty value is what a broken login script leaves behind, not a
|
|
136
|
+
// relative path anybody means, so it falls back rather than joining onto "".
|
|
137
|
+
const local = env.LOCALAPPDATA?.trim() || path.join(home, "AppData", "Local");
|
|
138
|
+
return [
|
|
139
|
+
{ key: "chrome", name: "Google Chrome", root: path.join(local, "Google", "Chrome", "User Data") },
|
|
140
|
+
{ key: "chrome-beta", name: "Google Chrome Beta", root: path.join(local, "Google", "Chrome Beta", "User Data") },
|
|
141
|
+
// Canary is `Chrome SxS` here and `Chrome Canary` on macOS — the same
|
|
142
|
+
// channel under two names, because the Windows installer has always used
|
|
143
|
+
// the side-by-side codename. Spelling it the macOS way finds nothing, and
|
|
144
|
+
// finding nothing is this module's indistinguishable failure. Linux has
|
|
145
|
+
// no Canary channel at all, which is why the third table has no such row.
|
|
146
|
+
{ key: "chrome-canary", name: "Google Chrome Canary", root: path.join(local, "Google", "Chrome SxS", "User Data") },
|
|
147
|
+
{ key: "chromium", name: "Chromium", root: path.join(local, "Chromium", "User Data") },
|
|
148
|
+
{ key: "brave", name: "Brave", root: path.join(local, "BraveSoftware", "Brave-Browser", "User Data") },
|
|
149
|
+
{ key: "edge", name: "Microsoft Edge", root: path.join(local, "Microsoft", "Edge", "User Data") },
|
|
150
|
+
{ key: "vivaldi", name: "Vivaldi", root: path.join(local, "Vivaldi", "User Data") },
|
|
151
|
+
];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Linux, and every other POSIX that is not macOS. Falling through rather than
|
|
155
|
+
// testing for "linux" is deliberate: a deck on FreeBSD gets the XDG layout,
|
|
156
|
+
// which is the one its Chromium package actually uses, instead of nothing.
|
|
157
|
+
//
|
|
158
|
+
// XDG_CONFIG_HOME is honoured because Chromium honours it — it is where the
|
|
159
|
+
// browser itself puts the directory, not a preference of ours — and trimmed
|
|
160
|
+
// and truthiness-tested for the reason codexHome() spells out: an empty
|
|
161
|
+
// variable is a shell accident, and joining onto it yields a CWD-relative
|
|
162
|
+
// path that would have the deck reading profiles out of wherever it was
|
|
163
|
+
// started from.
|
|
164
|
+
const config = env.XDG_CONFIG_HOME?.trim() || path.join(home, ".config");
|
|
165
|
+
return [
|
|
166
|
+
{ key: "chrome", name: "Google Chrome", root: path.join(config, "google-chrome") },
|
|
167
|
+
{ key: "chrome-beta", name: "Google Chrome Beta", root: path.join(config, "google-chrome-beta") },
|
|
168
|
+
{ key: "chromium", name: "Chromium", root: path.join(config, "chromium") },
|
|
169
|
+
{ key: "brave", name: "Brave", root: path.join(config, "BraveSoftware", "Brave-Browser") },
|
|
170
|
+
{ key: "edge", name: "Microsoft Edge", root: path.join(config, "microsoft-edge") },
|
|
171
|
+
{ key: "vivaldi", name: "Vivaldi", root: path.join(config, "vivaldi") },
|
|
172
|
+
// Snap and Flatpak confine the browser to a private filesystem, so their
|
|
173
|
+
// profiles are NOT under XDG_CONFIG_HOME and never appear in the six above.
|
|
174
|
+
// On Ubuntu this is not an edge case: `chromium` from the archive has been
|
|
175
|
+
// a snap-only transitional package for years, so the snap path is the only
|
|
176
|
+
// Chromium root a default Ubuntu install has.
|
|
177
|
+
{ key: "chromium-snap", name: "Chromium (snap)", root: path.join(home, "snap", "chromium", "common", "chromium") },
|
|
178
|
+
{ key: "brave-flatpak", name: "Brave (Flatpak)", root: path.join(home, ".var", "app", "com.brave.Browser", "config", "BraveSoftware", "Brave-Browser") },
|
|
179
|
+
];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Where a profile name sits in the browser's own numbering; `Default` is first. */
|
|
183
|
+
const profileIndex = name => (name === "Default" ? -1 : Number(name.slice("Profile ".length)));
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The profile directories that actually exist inside `root`, `Default` first
|
|
187
|
+
* and then `Profile N` in numeric order.
|
|
188
|
+
*
|
|
189
|
+
* A root that is absent, unreadable, or a file is not an error — it is the
|
|
190
|
+
* ordinary state of five of the eight roots on any real machine — so it answers
|
|
191
|
+
* with an empty list and the caller moves on.
|
|
192
|
+
*
|
|
193
|
+
* THE SORT IS NUMERIC ON PURPOSE. readdir returns whatever order the filesystem
|
|
194
|
+
* feels like, and the obvious repair — a plain `.sort()` — puts `Profile 10`
|
|
195
|
+
* between `Profile 1` and `Profile 2`. Nothing breaks visibly; the panel just
|
|
196
|
+
* lists a user's profiles in an order that changes meaning as soon as they
|
|
197
|
+
* create their tenth, which is the sort of wrongness nobody reports and
|
|
198
|
+
* everybody notices.
|
|
199
|
+
*/
|
|
200
|
+
export function profileDirs(root, { readdirSync = fsReaddirSync, statSync = fsStatSync } = {}) {
|
|
201
|
+
const path = flavourOf(root);
|
|
202
|
+
let names;
|
|
203
|
+
try { names = readdirSync(root); } catch { return []; }
|
|
204
|
+
return names
|
|
205
|
+
.map(String)
|
|
206
|
+
.filter(name => PROFILE_DIR.test(name))
|
|
207
|
+
// `Default` is profile zero in everything but its name, and it sorts ahead
|
|
208
|
+
// of `Profile 1` here rather than by luck of the alphabet.
|
|
209
|
+
.sort((a, b) => profileIndex(a) - profileIndex(b))
|
|
210
|
+
.map(name => path.join(root, name))
|
|
211
|
+
.filter(dir => isDirectory(dir, statSync));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Whether this profile has the extension's payload on disk.
|
|
216
|
+
*
|
|
217
|
+
* Chromium unpacks an extension to `Extensions/<id>/<version>/`, one directory
|
|
218
|
+
* per profile, so the id directory is the cheap and complete answer to "is it
|
|
219
|
+
* installed here" — one stat per profile, at boot, across every browser found.
|
|
220
|
+
*
|
|
221
|
+
* It deliberately does NOT answer "is it enabled". That state lives in Secure
|
|
222
|
+
* Preferences, which is signed against the profile and is another part of this
|
|
223
|
+
* feature's job; conflating the two here would mean parsing a several-megabyte
|
|
224
|
+
* JSON file per profile to answer a question this function is not being asked.
|
|
225
|
+
*/
|
|
226
|
+
export function hasExtension(profileDir, extId = CLAUDE_EXT_ID, { existsSync = fsExistsSync } = {}) {
|
|
227
|
+
const path = flavourOf(profileDir);
|
|
228
|
+
return existsSync(path.join(profileDir, "Extensions", extId));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Every browsing profile on this machine, with the two files Browser Watch
|
|
233
|
+
* reads and whether Claude in Chrome is installed in it.
|
|
234
|
+
*
|
|
235
|
+
* A PROFILE WITHOUT THE EXTENSION IS REPORTED, NOT DROPPED. "You have Brave
|
|
236
|
+
* here and the extension is not in it" is the one sentence on this panel that
|
|
237
|
+
* gets somebody unstuck, and it can only be said by a row that exists. Filtering
|
|
238
|
+
* to extension-carrying profiles would make an un-extended browser look exactly
|
|
239
|
+
* like an absent one — the same indistinguishable-from-nothing failure the
|
|
240
|
+
* `User Data` trap produces, arrived at on purpose.
|
|
241
|
+
*
|
|
242
|
+
* A ROOT WITH NO PROFILES CONTRIBUTES NOTHING, which is not the same thing. On
|
|
243
|
+
* the machine this was written against, Edge, Vivaldi, Chromium and Arc all have
|
|
244
|
+
* a root and no profile inside it — installed, or merely left behind by an
|
|
245
|
+
* uninstall — and a row for one of them would point the History reader at a
|
|
246
|
+
* path that will never exist.
|
|
247
|
+
*/
|
|
248
|
+
export function discoverProfiles(platform = process.platform, env = process.env, home = homedir(), deps = {}) {
|
|
249
|
+
const profiles = [];
|
|
250
|
+
for (const { key, name, root } of browserRoots(platform, env, home)) {
|
|
251
|
+
// Read off the root rather than off `platform`, so the two can never
|
|
252
|
+
// disagree about which separator this row's paths are spelled with.
|
|
253
|
+
const path = flavourOf(root);
|
|
254
|
+
for (const dir of profileDirs(root, deps)) {
|
|
255
|
+
profiles.push({
|
|
256
|
+
browser: key,
|
|
257
|
+
name,
|
|
258
|
+
profile: path.basename(dir),
|
|
259
|
+
dir,
|
|
260
|
+
historyPath: path.join(dir, "History"),
|
|
261
|
+
securePrefsPath: path.join(dir, "Secure Preferences"),
|
|
262
|
+
hasClaudeExt: hasExtension(dir, CLAUDE_EXT_ID, deps),
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return profiles;
|
|
267
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// What the watch does when it finds something, beyond writing it down.
|
|
2
|
+
//
|
|
3
|
+
// THREE REACTIONS, AND ONLY TWO OF THEM EXIST EVERYWHERE. The panel offers a
|
|
4
|
+
// browser exactly the reactions its platform can actually perform, because a
|
|
5
|
+
// mode that silently does nothing is worse than one that was never offered —
|
|
6
|
+
// the user arms it, believes they are covered, and finds out on the day it
|
|
7
|
+
// mattered.
|
|
8
|
+
//
|
|
9
|
+
// notify every platform. A system notification.
|
|
10
|
+
// quit-browser every platform. Blunt, and the only one that takes the
|
|
11
|
+
// session away from whoever was driving it.
|
|
12
|
+
// close-tab macOS only. AppleScript is the one interface that can close
|
|
13
|
+
// ONE tab by URL. On Windows the nearest thing is walking the
|
|
14
|
+
// accessibility tree and matching on the page TITLE, which two
|
|
15
|
+
// tabs can share; under Wayland there is nothing at all.
|
|
16
|
+
//
|
|
17
|
+
// WHY CLOSING A TAB IS THE WEAKEST OF THE THREE, said plainly because the
|
|
18
|
+
// panel should not oversell it: by the time the deck sees the visit, Chrome has
|
|
19
|
+
// already loaded the page and sent the user's cookies. Closing it is cleanup.
|
|
20
|
+
// The session that opened it is still attached and can still read every other
|
|
21
|
+
// tab. Only quitting takes anything back.
|
|
22
|
+
import { run } from "./exec.mjs";
|
|
23
|
+
|
|
24
|
+
/** Reactions this platform can actually carry out, in the order the panel
|
|
25
|
+
* should offer them. Never a list the caller has to filter again. */
|
|
26
|
+
export function available(platform = process.platform) {
|
|
27
|
+
return platform === "darwin"
|
|
28
|
+
? ["notify", "close-tab", "quit-browser"]
|
|
29
|
+
: ["notify", "quit-browser"];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Whether a stored setting is still performable here. A store written on a Mac
|
|
33
|
+
* and carried to a Linux machine — or a browser that is not the one the
|
|
34
|
+
* setting was chosen for — must not silently do nothing. */
|
|
35
|
+
export const performable = (reaction, platform = process.platform) =>
|
|
36
|
+
available(platform).includes(reaction);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The AppleScript that closes one tab, given its URL through argv.
|
|
40
|
+
*
|
|
41
|
+
* THE URL GOES THROUGH argv AND NEVER INTO THE SOURCE. It is attacker-chosen
|
|
42
|
+
* text: the whole premise of this feature is that somebody else may have opened
|
|
43
|
+
* that page, so its address is the last string in the deck that should be
|
|
44
|
+
* pasted into a script. The shell tool this descends from verified that an
|
|
45
|
+
* interpolated URL could reach `do shell script`.
|
|
46
|
+
*
|
|
47
|
+
* The application name IS interpolated, because AppleScript will not load an
|
|
48
|
+
* app's terminology from a variable — `tell application appName` leaves `tabs`
|
|
49
|
+
* and `URL` unresolvable. It comes from the fixed table below and from nowhere
|
|
50
|
+
* else.
|
|
51
|
+
*
|
|
52
|
+
* The `is not running` guard is not politeness either: `tell application "X"`
|
|
53
|
+
* LAUNCHES X when it is not running, so without it a watch would resurrect a
|
|
54
|
+
* browser the user had quit in order to close a tab in it.
|
|
55
|
+
*/
|
|
56
|
+
export function closeTabScript(app) {
|
|
57
|
+
return `on run argv
|
|
58
|
+
set wanted to item 1 of argv
|
|
59
|
+
if application "${app}" is not running then return "not-running"
|
|
60
|
+
tell application "${app}"
|
|
61
|
+
repeat with w in windows
|
|
62
|
+
repeat with t in tabs of w
|
|
63
|
+
if (URL of t as string) is wanted then
|
|
64
|
+
close t
|
|
65
|
+
return "closed"
|
|
66
|
+
end if
|
|
67
|
+
end repeat
|
|
68
|
+
end repeat
|
|
69
|
+
end tell
|
|
70
|
+
return "missing"
|
|
71
|
+
end run`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The application name for a browser key, or null when the deck does not know
|
|
75
|
+
* one — which is a reason to do nothing rather than to guess. */
|
|
76
|
+
const APP = {
|
|
77
|
+
chrome: "Google Chrome",
|
|
78
|
+
"chrome-beta": "Google Chrome Beta",
|
|
79
|
+
"chrome-canary": "Google Chrome Canary",
|
|
80
|
+
chromium: "Chromium",
|
|
81
|
+
brave: "Brave Browser",
|
|
82
|
+
edge: "Microsoft Edge",
|
|
83
|
+
vivaldi: "Vivaldi",
|
|
84
|
+
arc: "Arc",
|
|
85
|
+
};
|
|
86
|
+
export const appName = key => APP[key] ?? null;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A system notification.
|
|
90
|
+
*
|
|
91
|
+
* The text is passed as an argument on every platform rather than built into a
|
|
92
|
+
* script, for the reason above: a host name reaching this function came out of
|
|
93
|
+
* a browser's history and is not the deck's own string.
|
|
94
|
+
*/
|
|
95
|
+
export async function notify(title, body, platform = process.platform, deps = {}) {
|
|
96
|
+
const exec = deps.run ?? run;
|
|
97
|
+
if (platform === "darwin") {
|
|
98
|
+
// `-e` with argv, so neither string is interpolated into the source. The
|
|
99
|
+
// shell tool this descends from built its notification by interpolation and
|
|
100
|
+
// that is the one place it had left the pattern it had banned everywhere
|
|
101
|
+
// else.
|
|
102
|
+
const r = await exec("osascript", [
|
|
103
|
+
"-e",
|
|
104
|
+
'on run argv\ndisplay notification (item 2 of argv) with title (item 1 of argv)\nend run',
|
|
105
|
+
title, body,
|
|
106
|
+
]).catch(() => null);
|
|
107
|
+
return r?.ok === true;
|
|
108
|
+
}
|
|
109
|
+
if (platform === "win32") {
|
|
110
|
+
// PowerShell's own toast, through the same argv discipline: the strings go
|
|
111
|
+
// in as parameters rather than as script text.
|
|
112
|
+
const r = await exec("powershell.exe", [
|
|
113
|
+
"-NoProfile", "-NonInteractive", "-Command",
|
|
114
|
+
"param($t,$b); [void][Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime];"
|
|
115
|
+
+ "$x = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent(0);"
|
|
116
|
+
+ "$n = $x.GetElementsByTagName('text'); $n.Item(0).AppendChild($x.CreateTextNode($t)) > $null;"
|
|
117
|
+
+ "$n.Item(1).AppendChild($x.CreateTextNode($b)) > $null;"
|
|
118
|
+
+ "[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('ccdeck').Show($x)",
|
|
119
|
+
"-t", title, "-b", body,
|
|
120
|
+
]).catch(() => null);
|
|
121
|
+
return r?.ok === true;
|
|
122
|
+
}
|
|
123
|
+
const r = await exec("notify-send", [title, body]).catch(() => null);
|
|
124
|
+
return r?.ok === true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Close one tab by its exact URL. macOS only; see `available`. */
|
|
128
|
+
export async function closeTab(browserKey, url, platform = process.platform, deps = {}) {
|
|
129
|
+
if (platform !== "darwin") return { ok: false, reason: "unsupported" };
|
|
130
|
+
const app = appName(browserKey);
|
|
131
|
+
if (!app) return { ok: false, reason: "unknown_browser" };
|
|
132
|
+
const exec = deps.run ?? run;
|
|
133
|
+
// Through `-e` rather than stdin: `run` closes the child's stdin immediately
|
|
134
|
+
// and says so in its own contract, so `osascript -` would read an empty
|
|
135
|
+
// script and report success having done nothing. The URL still travels as
|
|
136
|
+
// argv, which is the part that matters.
|
|
137
|
+
const r = await exec("osascript", ["-e", closeTabScript(app), url]).catch(() => null);
|
|
138
|
+
if (!r?.ok) return { ok: false, reason: "script_failed" };
|
|
139
|
+
const said = String(r.stdout ?? "").trim();
|
|
140
|
+
return { ok: said === "closed", reason: said || "unknown" };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Quit a browser. The only reaction that takes the session back. */
|
|
144
|
+
export async function quitBrowser(browserKey, platform = process.platform, deps = {}) {
|
|
145
|
+
const app = appName(browserKey);
|
|
146
|
+
if (!app) return { ok: false, reason: "unknown_browser" };
|
|
147
|
+
const exec = deps.run ?? run;
|
|
148
|
+
if (platform === "darwin") {
|
|
149
|
+
const r = await exec("osascript", [
|
|
150
|
+
"-e", 'on run argv\ntell application (item 1 of argv) to quit\nend run', app,
|
|
151
|
+
]).catch(() => null);
|
|
152
|
+
return { ok: r?.ok === true, reason: r?.ok ? "quit" : "script_failed" };
|
|
153
|
+
}
|
|
154
|
+
if (platform === "win32") {
|
|
155
|
+
const r = await exec("taskkill", ["/IM", `${app.replace(/ /g, "")}.exe`, "/F"]).catch(() => null);
|
|
156
|
+
return { ok: r?.ok === true, reason: r?.ok ? "quit" : "taskkill_failed" };
|
|
157
|
+
}
|
|
158
|
+
const r = await exec("pkill", ["-x", app.toLowerCase().replace(/ /g, "-")]).catch(() => null);
|
|
159
|
+
return { ok: r?.ok === true, reason: r?.ok ? "quit" : "pkill_failed" };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Carry out the armed reaction for one episode.
|
|
164
|
+
*
|
|
165
|
+
* Always notifies, whatever else it does. A tab that closed itself with no
|
|
166
|
+
* explanation is a mystery rather than a warning, and the point of the feature
|
|
167
|
+
* is that the user finds out.
|
|
168
|
+
*/
|
|
169
|
+
export async function react(reaction, episode, { platform = process.platform, deps = {} } = {}) {
|
|
170
|
+
const done = [];
|
|
171
|
+
const pages = `${episode.count} page${episode.count === 1 ? "" : "s"}`;
|
|
172
|
+
if (await notify("Browser watch", `${episode.host} — ${pages} while you were away`, platform, deps)) {
|
|
173
|
+
done.push("notified");
|
|
174
|
+
} else {
|
|
175
|
+
done.push("could not notify");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (!performable(reaction, platform) || reaction === "notify") return done;
|
|
179
|
+
|
|
180
|
+
// A REACTION THAT COULD NOT ACT MUST SAY SO. This reported only its
|
|
181
|
+
// successes, so every failure was silent — and there were two whole months of
|
|
182
|
+
// them: `episode.browser` was null until it was fixed, `appName(null)` is
|
|
183
|
+
// null, and both destructive reactions returned `unknown_browser` and pushed
|
|
184
|
+
// nothing. The panel said a finding had been handled and nothing had been.
|
|
185
|
+
//
|
|
186
|
+
// The failures that remain are ordinary and will happen: macOS asks once for
|
|
187
|
+
// permission to control another application and refuses forever if declined;
|
|
188
|
+
// a tab can be closed by hand before the poll reaches it; a browser can quit
|
|
189
|
+
// on its own. Each of those is something the reader has to be able to see,
|
|
190
|
+
// because the alternative is believing a tab was closed that is still open.
|
|
191
|
+
if (reaction === "close-tab") {
|
|
192
|
+
// Every URL in the episode, because an episode is a run and closing only
|
|
193
|
+
// its first page leaves the rest of the run open.
|
|
194
|
+
for (const u of episode.urls ?? []) {
|
|
195
|
+
const out = await closeTab(episode.browser, u.url, platform, deps);
|
|
196
|
+
done.push(out.ok ? `closed ${u.url}` : `could not close ${u.url} — ${out.reason}`);
|
|
197
|
+
}
|
|
198
|
+
return done;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const out = await quitBrowser(episode.browser, platform, deps);
|
|
202
|
+
done.push(out.ok ? "quit the browser" : `could not quit the browser — ${out.reason}`);
|
|
203
|
+
return done;
|
|
204
|
+
}
|