roger-roger 0.1.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/LICENSE +21 -0
- package/README.md +147 -0
- package/package.json +45 -0
- package/skills/roger-roger/SKILL.md +289 -0
- package/skills/roger-roger/herdr-plugin.toml +38 -0
- package/skills/roger-roger/scripts/agent.mjs +132 -0
- package/skills/roger-roger/scripts/audio.mjs +392 -0
- package/skills/roger-roger/scripts/client.mjs +121 -0
- package/skills/roger-roger/scripts/daemon.mjs +604 -0
- package/skills/roger-roger/scripts/decisions.mjs +158 -0
- package/skills/roger-roger/scripts/handlers.mjs +1151 -0
- package/skills/roger-roger/scripts/herdr.mjs +140 -0
- package/skills/roger-roger/scripts/hooks-codex.mjs +154 -0
- package/skills/roger-roger/scripts/hooks-opencode.mjs +167 -0
- package/skills/roger-roger/scripts/hooks.mjs +420 -0
- package/skills/roger-roger/scripts/inbox.mjs +381 -0
- package/skills/roger-roger/scripts/install.mjs +560 -0
- package/skills/roger-roger/scripts/lib.mjs +1133 -0
- package/skills/roger-roger/scripts/names.mjs +84 -0
- package/skills/roger-roger/scripts/progress.mjs +91 -0
- package/skills/roger-roger/scripts/protocol.mjs +71 -0
- package/skills/roger-roger/scripts/roger-roger.mjs +536 -0
- package/skills/roger-roger/scripts/router.mjs +86 -0
- package/skills/roger-roger/scripts/sessions.mjs +218 -0
- package/skills/roger-roger/scripts/slack.mjs +240 -0
- package/skills/roger-roger/scripts/slackapp.mjs +205 -0
- package/skills/roger-roger/scripts/slackcli.mjs +144 -0
- package/skills/roger-roger/scripts/speaker.mjs +224 -0
- package/skills/roger-roger/scripts/speechkey.mjs +106 -0
- package/skills/roger-roger/scripts/tray.mjs +128 -0
- package/skills/roger-roger/scripts/tts.mjs +275 -0
- package/skills/roger-roger/scripts/tui.mjs +465 -0
- package/skills/roger-roger/slack/manifest.json +34 -0
- package/skills/roger-roger/sounds/alert.wav +0 -0
- package/skills/roger-roger/sounds/bubble.wav +0 -0
- package/skills/roger-roger/sounds/chime.wav +0 -0
- package/skills/roger-roger/sounds/ding.wav +0 -0
- package/skills/roger-roger/sounds/marimba.wav +0 -0
- package/skills/roger-roger/tray/main.mjs +749 -0
- package/skills/roger-roger/tray/panel.html +501 -0
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
// The tray icon and the panel behind it.
|
|
2
|
+
//
|
|
3
|
+
// This process draws; it does not decide. Everything it shows comes from ~/.roger-roger/tray.json,
|
|
4
|
+
// which the daemon keeps up to date, and everything it offers runs an ordinary `roger-roger` command.
|
|
5
|
+
// So it can crash, be killed, or never be installed at all, and nothing else in the skill notices.
|
|
6
|
+
//
|
|
7
|
+
// The native part is @webviewjs/webview, installed on demand into ~/.roger-roger rather than vendored
|
|
8
|
+
// here, so the skill itself stays a dependency-free Node script.
|
|
9
|
+
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { createRequire } from "node:module";
|
|
14
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
15
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
16
|
+
|
|
17
|
+
// Decoded properly: a hand-trimmed URL pathname keeps its %20s and mangles UNC paths.
|
|
18
|
+
const MAIN = fileURLToPath(import.meta.url);
|
|
19
|
+
const HERE = path.dirname(MAIN);
|
|
20
|
+
const CLI = path.join(HERE, "..", "scripts", "roger-roger.mjs");
|
|
21
|
+
const HOME = process.env.ROGER_ROGER_HOME || path.join(os.homedir(), ".roger-roger");
|
|
22
|
+
const SNAPSHOT = path.join(HOME, "tray.json");
|
|
23
|
+
const PID_FILE = path.join(HOME, "tray.pid");
|
|
24
|
+
|
|
25
|
+
// The daemon rewrites the snapshot every 15 seconds. Four missed beats and nobody is writing it.
|
|
26
|
+
const STALE_MS = 60_000;
|
|
27
|
+
|
|
28
|
+
const PANEL = { width: 360, height: 480, minWidth: 300, minHeight: 240, gap: 10 };
|
|
29
|
+
|
|
30
|
+
// The size the user dragged the panel's edges to, kept across restarts like where they put it.
|
|
31
|
+
const SIZE_FILE = path.join(HOME, "tray-size.json");
|
|
32
|
+
|
|
33
|
+
function readSize() {
|
|
34
|
+
try {
|
|
35
|
+
const s = JSON.parse(fs.readFileSync(SIZE_FILE, "utf8"));
|
|
36
|
+
return s?.width >= PANEL.minWidth && s?.height >= PANEL.minHeight ? { width: s.width, height: s.height } : null;
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function writeSize(size) {
|
|
43
|
+
try {
|
|
44
|
+
fs.writeFileSync(SIZE_FILE, JSON.stringify(size) + "\n", "utf8");
|
|
45
|
+
} catch (e) {
|
|
46
|
+
log(`could not remember the panel's size: ${e.message}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Load the native library from wherever it was installed, not from this repo. */
|
|
51
|
+
async function loadWebview() {
|
|
52
|
+
const require = createRequire(path.join(HOME, "package.json"));
|
|
53
|
+
const entry = require.resolve("@webviewjs/webview");
|
|
54
|
+
return import(pathToFileURL(entry).href);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The self-contained panel. Nothing is fetched at runtime. */
|
|
58
|
+
function panelHtml() {
|
|
59
|
+
return fs.readFileSync(path.join(HERE, "panel.html"), "utf8");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The environment a command from the tray runs in. The tray is often started from inside an agent's
|
|
64
|
+
* terminal and inherits its session variables, and a snooze pressed by hand must not be filed under
|
|
65
|
+
* whichever agent happened to launch the tray. So those go, and the command is told who is asking.
|
|
66
|
+
*/
|
|
67
|
+
function childEnv() {
|
|
68
|
+
const env = {};
|
|
69
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
70
|
+
if (/_SESSION_ID$/.test(key)) continue;
|
|
71
|
+
if (/^(?:CLAUDE_CODE|CODEX|OPENCODE)_/.test(key)) continue;
|
|
72
|
+
if (key === "CLAUDE_PID" || key === "CLAUDECODE" || key === "AI_AGENT" || key === "ROGER_ROGER_AGENT_PID") continue;
|
|
73
|
+
env[key] = value;
|
|
74
|
+
}
|
|
75
|
+
env.ROGER_ROGER_CALLER = "tray";
|
|
76
|
+
return env;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Run from HOME so a long-lived child never pins whatever directory the tray was started in.
|
|
80
|
+
const childOptions = () => ({ cwd: HOME, env: childEnv(), windowsHide: true });
|
|
81
|
+
|
|
82
|
+
/** Run an `roger-roger` command and forget about it: the daemon owns the outcome, not this process. */
|
|
83
|
+
function run(...args) {
|
|
84
|
+
spawn(process.execPath, [CLI, ...args], { ...childOptions(), detached: true, stdio: "ignore" }).unref();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Run a command and say how it went: null when it worked, otherwise what went wrong. For settings,
|
|
89
|
+
* where a value `setup` refuses would otherwise vanish without a word and the control would just
|
|
90
|
+
* flick back on the next redraw.
|
|
91
|
+
*/
|
|
92
|
+
function runChecked(...args) {
|
|
93
|
+
return new Promise((resolve) => {
|
|
94
|
+
let child;
|
|
95
|
+
try {
|
|
96
|
+
child = spawn(process.execPath, [CLI, ...args], { ...childOptions(), stdio: ["ignore", "pipe", "pipe"] });
|
|
97
|
+
} catch (e) {
|
|
98
|
+
return resolve(e.message);
|
|
99
|
+
}
|
|
100
|
+
let stdout = "";
|
|
101
|
+
let stderr = "";
|
|
102
|
+
child.stdout.on("data", (chunk) => (stdout += chunk));
|
|
103
|
+
child.stderr.on("data", (chunk) => (stderr += chunk));
|
|
104
|
+
child.on("error", (e) => resolve(e.message));
|
|
105
|
+
child.on("close", (code) => {
|
|
106
|
+
let answer = null;
|
|
107
|
+
try {
|
|
108
|
+
answer = JSON.parse(stdout);
|
|
109
|
+
} catch {
|
|
110
|
+
// Not JSON; the exit code is all there is to go on.
|
|
111
|
+
}
|
|
112
|
+
if (code === 0 && answer?.ok !== false) return resolve(null);
|
|
113
|
+
const lastLine = (text) => text.trim().split(/\r?\n/).pop() || "";
|
|
114
|
+
resolve(answer?.error || lastLine(stderr) || lastLine(stdout) || `exited with code ${code}`);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const LOG = path.join(HOME, "tray.log");
|
|
120
|
+
/** Nothing reads this process's output once it is detached, so anything worth knowing goes here. */
|
|
121
|
+
function log(line) {
|
|
122
|
+
try {
|
|
123
|
+
fs.appendFileSync(LOG, `${new Date().toISOString()} ${line}\n`);
|
|
124
|
+
} catch {
|
|
125
|
+
// Logging must never be the thing that breaks the tray.
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const read = () => {
|
|
130
|
+
try {
|
|
131
|
+
return JSON.parse(fs.readFileSync(SNAPSHOT, "utf8"));
|
|
132
|
+
} catch {
|
|
133
|
+
return { agents: [], questions: [], working: [], muted: {} };
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* A snapshot nobody is keeping up to date. The file outlives the daemon that wrote it, so without
|
|
139
|
+
* this a question answered an hour ago would keep the icon lit and the panel full of ghosts.
|
|
140
|
+
*/
|
|
141
|
+
function isStale(state, now = Date.now()) {
|
|
142
|
+
if (!state.daemon) return true;
|
|
143
|
+
const at = Date.parse(state.updatedAt);
|
|
144
|
+
return !Number.isFinite(at) || now - at > STALE_MS;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* A 16×16 icon as raw RGBA, so there is no image file to ship or decode. A thick ring normally, a
|
|
149
|
+
* solid disc when somebody is waiting on an answer — both drawn opaque, because a tasteful hairline
|
|
150
|
+
* at this size is a smudge you cannot find. White, and a template on macOS so the menu bar recolours
|
|
151
|
+
* it to suit itself.
|
|
152
|
+
*/
|
|
153
|
+
function icon(alert) {
|
|
154
|
+
const size = 32; // Drawn at 32 and let the tray scale down: a 16px source on a scaled display
|
|
155
|
+
const data = Buffer.alloc(size * size * 4); // arrives upscaled and blurry, which reads as faint.
|
|
156
|
+
const c = (size - 1) / 2;
|
|
157
|
+
const outer = 14;
|
|
158
|
+
const inner = 7.2;
|
|
159
|
+
for (let y = 0; y < size; y++) {
|
|
160
|
+
for (let x = 0; x < size; x++) {
|
|
161
|
+
const d = Math.hypot(x - c, y - c);
|
|
162
|
+
// Solid to within half a pixel of each edge, then one pixel of softening so it reads as round.
|
|
163
|
+
const outside = Math.min(1, Math.max(0, outer - d));
|
|
164
|
+
const hole = alert ? 0 : Math.min(1, Math.max(0, inner - d));
|
|
165
|
+
const a = Math.max(0, outside - hole);
|
|
166
|
+
const i = (y * size + x) * 4;
|
|
167
|
+
data[i] = data[i + 1] = data[i + 2] = 255;
|
|
168
|
+
data[i + 3] = Math.round(a * 255);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return data;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
175
|
+
|
|
176
|
+
function tooltip(state, stale) {
|
|
177
|
+
if (stale) return "roger-roger — the daemon isn't running";
|
|
178
|
+
const agents = state.agents ?? [];
|
|
179
|
+
if (!agents.length) return "roger-roger — nothing running";
|
|
180
|
+
const waiting = (state.questions ?? []).length;
|
|
181
|
+
const parts = [plural(agents.length, "agent", "agents")];
|
|
182
|
+
if (waiting) parts.push(plural(waiting, "question", "questions"));
|
|
183
|
+
if (state.muted?.snoozeUntil) parts.push("snoozed");
|
|
184
|
+
return `roger-roger — ${parts.join(", ")}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The right-click menu is a menu: a few things you can do. Who is running belongs in the panel,
|
|
189
|
+
* where there is room to say what they are doing. Kept short and rebuilt only when it changes,
|
|
190
|
+
* because replacing a menu while it is open is how a menu ends up refusing to close.
|
|
191
|
+
*/
|
|
192
|
+
function menu(state, spot) {
|
|
193
|
+
const items = [];
|
|
194
|
+
if (state.muted?.snoozeUntil) {
|
|
195
|
+
items.push({ id: "snooze:off", label: "Stop snoozing" });
|
|
196
|
+
} else {
|
|
197
|
+
items.push({ id: "snooze:15m", label: "Snooze for 15 minutes" });
|
|
198
|
+
items.push({ id: "snooze:1h", label: "Snooze for an hour" });
|
|
199
|
+
}
|
|
200
|
+
// Dragged somewhere, the panel stays there; this is the way back.
|
|
201
|
+
if (spot) items.push({ id: "forget-spot", label: "Open the panel under the icon again" });
|
|
202
|
+
items.push({ id: "sep1", label: "-" }, { id: "quit", label: "Quit" });
|
|
203
|
+
return { items };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const menuKey = (state, spot) => `${state.muted?.snoozeUntil ? "snoozed" : "awake"}:${spot ? "spot" : "free"}`;
|
|
207
|
+
|
|
208
|
+
function onMenu(id) {
|
|
209
|
+
if (id === "snooze:off") return run("snooze", "off");
|
|
210
|
+
if (id?.startsWith("snooze:")) return run("snooze", id.slice("snooze:".length));
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Put the panel under the icon, and keep it on the screen the icon is on. That screen is found from
|
|
216
|
+
* the icon itself: the panel is parked off-screen between uses, so asking the panel which monitor it
|
|
217
|
+
* is on gets whichever one the operating system thinks is nearest to nowhere.
|
|
218
|
+
*/
|
|
219
|
+
// Where the user dragged the panel to, if they did. It opens there from then on, instead of under
|
|
220
|
+
// the icon, until they choose "under the icon" on the right-click menu.
|
|
221
|
+
const POSITION_FILE = path.join(HOME, "tray-position.json");
|
|
222
|
+
|
|
223
|
+
function readSpot() {
|
|
224
|
+
try {
|
|
225
|
+
const p = JSON.parse(fs.readFileSync(POSITION_FILE, "utf8"));
|
|
226
|
+
return Number.isFinite(p?.x) && Number.isFinite(p?.y) ? { x: p.x, y: p.y } : null;
|
|
227
|
+
} catch {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function writeSpot(pos) {
|
|
233
|
+
try {
|
|
234
|
+
if (pos) fs.writeFileSync(POSITION_FILE, JSON.stringify(pos) + "\n", "utf8");
|
|
235
|
+
else fs.rmSync(POSITION_FILE, { force: true });
|
|
236
|
+
} catch (e) {
|
|
237
|
+
log(`could not remember where the panel was put: ${e.message}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Put the panel where it was dragged to, if that is still on a screen. A monitor unplugged since
|
|
243
|
+
* would otherwise open it somewhere nobody can see, so then it goes back under the icon.
|
|
244
|
+
*/
|
|
245
|
+
function placeAtSpot(window, pos) {
|
|
246
|
+
if (!pos || typeof window.getMonitorFromPoint !== "function") return false;
|
|
247
|
+
let monitor = null;
|
|
248
|
+
try {
|
|
249
|
+
monitor = window.getMonitorFromPoint(pos.x + 40, pos.y + 20);
|
|
250
|
+
} catch {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
if (!monitor) return false;
|
|
254
|
+
window.setPosition(pos.x, pos.y);
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function placePanel(window, rect) {
|
|
259
|
+
if (!rect) return;
|
|
260
|
+
// Whatever size it is now, which is not the size it started at once the user has resized it.
|
|
261
|
+
let size = { width: PANEL.width, height: PANEL.height };
|
|
262
|
+
try {
|
|
263
|
+
size = window.getOuterSize();
|
|
264
|
+
} catch {
|
|
265
|
+
// The starting size is a fair guess.
|
|
266
|
+
}
|
|
267
|
+
let monitor = null;
|
|
268
|
+
try {
|
|
269
|
+
if (typeof window.getMonitorFromPoint === "function") {
|
|
270
|
+
monitor = window.getMonitorFromPoint(Math.round(rect.x + rect.width / 2), Math.round(rect.y + rect.height / 2));
|
|
271
|
+
}
|
|
272
|
+
} catch {
|
|
273
|
+
// Fall through to the older, vaguer answer.
|
|
274
|
+
}
|
|
275
|
+
if (!monitor && typeof window.getCurrentMonitor === "function") monitor = window.getCurrentMonitor();
|
|
276
|
+
const screen = monitor ? { x: monitor.position.x, y: monitor.position.y, w: monitor.size.width, h: monitor.size.height } : null;
|
|
277
|
+
const below = screen ? rect.y < screen.y + screen.h / 2 : true;
|
|
278
|
+
let x = Math.round(rect.x + rect.width / 2 - size.width / 2);
|
|
279
|
+
let y = below ? Math.round(rect.y + rect.height + PANEL.gap) : Math.round(rect.y - size.height - PANEL.gap);
|
|
280
|
+
if (screen) {
|
|
281
|
+
x = Math.min(Math.max(x, screen.x + PANEL.gap), screen.x + screen.w - size.width - PANEL.gap);
|
|
282
|
+
y = Math.min(Math.max(y, screen.y + PANEL.gap), screen.y + screen.h - size.height - PANEL.gap);
|
|
283
|
+
}
|
|
284
|
+
window.setPosition(x, y);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ---------------------------------------------------------------- one tray at a time
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Whether `pid` is a tray running this very file. A live pid is not enough: Windows hands pids out
|
|
291
|
+
* again quickly, and a pid file left behind by a killed tray would otherwise make every new tray
|
|
292
|
+
* find some unrelated process behind it and quietly leave. So the process's command line is checked
|
|
293
|
+
* for this file's path. If the command line cannot be read at all, the pid is believed: two trays
|
|
294
|
+
* is the worse mistake.
|
|
295
|
+
*/
|
|
296
|
+
function isTray(pid) {
|
|
297
|
+
if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid) return false;
|
|
298
|
+
try {
|
|
299
|
+
process.kill(pid, 0); // throws when nothing is there any more
|
|
300
|
+
} catch (e) {
|
|
301
|
+
if (e.code !== "EPERM") return false;
|
|
302
|
+
}
|
|
303
|
+
let args;
|
|
304
|
+
try {
|
|
305
|
+
args =
|
|
306
|
+
process.platform === "win32"
|
|
307
|
+
? execFileSync(
|
|
308
|
+
"powershell.exe",
|
|
309
|
+
["-NoProfile", "-NonInteractive", "-Command", `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine`],
|
|
310
|
+
{ encoding: "utf8", windowsHide: true, timeout: 15_000 },
|
|
311
|
+
)
|
|
312
|
+
: execFileSync("ps", ["-o", "args=", "-p", String(pid)], { encoding: "utf8", timeout: 15_000 });
|
|
313
|
+
} catch (e) {
|
|
314
|
+
if (process.platform !== "win32" && e.status === 1) return false; // ps: no such process
|
|
315
|
+
log(`could not read the command line of pid ${pid}, so trusting the pid file: ${e.message}`);
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
// Slashes and, on Windows, case can differ between how it was started and how it is spelled here.
|
|
319
|
+
const norm = (s) => {
|
|
320
|
+
const flat = s.trim().replace(/[\\/]+/g, "/");
|
|
321
|
+
return process.platform === "win32" ? flat.toLowerCase() : flat;
|
|
322
|
+
};
|
|
323
|
+
return norm(args).includes(norm(MAIN));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** What the pid file says: `{ pid, startedAt }`, or an older file with just the number in it. */
|
|
327
|
+
function readPidFile() {
|
|
328
|
+
let raw;
|
|
329
|
+
try {
|
|
330
|
+
raw = fs.readFileSync(PID_FILE, "utf8");
|
|
331
|
+
} catch (e) {
|
|
332
|
+
return e.code === "ENOENT" ? { missing: true } : { raw: "" };
|
|
333
|
+
}
|
|
334
|
+
try {
|
|
335
|
+
const parsed = JSON.parse(raw);
|
|
336
|
+
if (parsed && typeof parsed === "object") return { ...parsed, raw };
|
|
337
|
+
return { pid: Number(parsed) || null, raw };
|
|
338
|
+
} catch {
|
|
339
|
+
return { pid: null, raw };
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Take the pid file before doing anything slow, so two trays started together cannot both look,
|
|
345
|
+
* both find nobody, and both stay. `wx` makes the creation itself the decision: exactly one of them
|
|
346
|
+
* gets to make the file. A file that is already there is honoured only if a tray is really behind
|
|
347
|
+
* it; otherwise it is left over, and replaced.
|
|
348
|
+
*
|
|
349
|
+
* Returns what is already running (a pid, or "another tray" when it is too early to say which),
|
|
350
|
+
* or null once the file is ours.
|
|
351
|
+
*/
|
|
352
|
+
function claimPidFile() {
|
|
353
|
+
fs.mkdirSync(HOME, { recursive: true });
|
|
354
|
+
const mine = JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() });
|
|
355
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
356
|
+
try {
|
|
357
|
+
fs.writeFileSync(PID_FILE, mine, { encoding: "utf8", flag: "wx" });
|
|
358
|
+
return null;
|
|
359
|
+
} catch (e) {
|
|
360
|
+
if (e.code !== "EEXIST") throw e;
|
|
361
|
+
}
|
|
362
|
+
const other = readPidFile();
|
|
363
|
+
if (other.missing) continue;
|
|
364
|
+
if (!other.pid) {
|
|
365
|
+
// Empty or half-written: most likely a tray that created the file a moment ago and has not
|
|
366
|
+
// filled it in yet. Only an old one is junk.
|
|
367
|
+
let age = Infinity;
|
|
368
|
+
try {
|
|
369
|
+
age = Date.now() - fs.statSync(PID_FILE).mtimeMs;
|
|
370
|
+
} catch {}
|
|
371
|
+
if (age < 5000) return "another tray";
|
|
372
|
+
} else if (isTray(other.pid)) {
|
|
373
|
+
return other.pid;
|
|
374
|
+
}
|
|
375
|
+
// Stale. Removed only if it still says what was judged stale, so a claim another tray made in
|
|
376
|
+
// the meantime is not the thing thrown away.
|
|
377
|
+
if (readPidFile().raw === other.raw) {
|
|
378
|
+
try {
|
|
379
|
+
fs.rmSync(PID_FILE, { force: true });
|
|
380
|
+
} catch {}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
throw new Error(`could not claim ${PID_FILE}`);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Remove the pid file, but only while it is still ours: a newer tray may have claimed it since. */
|
|
387
|
+
function releasePidFile() {
|
|
388
|
+
try {
|
|
389
|
+
if (readPidFile().pid === process.pid) fs.rmSync(PID_FILE, { force: true });
|
|
390
|
+
} catch {}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function main() {
|
|
394
|
+
const running = claimPidFile();
|
|
395
|
+
if (running) {
|
|
396
|
+
process.stderr.write(`a tray is already running (pid ${running})\n`);
|
|
397
|
+
process.exit(0);
|
|
398
|
+
}
|
|
399
|
+
process.on("exit", releasePidFile);
|
|
400
|
+
const { Application } = await loadWebview();
|
|
401
|
+
const app = new Application();
|
|
402
|
+
await app.whenReady({ interval: 16, ref: true });
|
|
403
|
+
|
|
404
|
+
let state = read();
|
|
405
|
+
let stale = isStale(state);
|
|
406
|
+
let shown = false;
|
|
407
|
+
let spot = readSpot(); // where the user dragged the panel to, or null for under the icon
|
|
408
|
+
// Pinned with the button in its header: the panel stays up when you click elsewhere, and goes
|
|
409
|
+
// away only when you click the icon (or unpin it).
|
|
410
|
+
let keepOpen = false;
|
|
411
|
+
// Nobody is waiting on a question the daemon is not there to deliver.
|
|
412
|
+
const alerting = () => !stale && (state.questions ?? []).length > 0;
|
|
413
|
+
const renderScript = () => `window.render(${JSON.stringify({ ...state, stale })})`;
|
|
414
|
+
|
|
415
|
+
const tray = app.createTrayIcon({
|
|
416
|
+
id: "roger-roger",
|
|
417
|
+
icon: { data: icon(alerting()), width: 32, height: 32 },
|
|
418
|
+
tooltip: tooltip(state, stale),
|
|
419
|
+
menu: menu(state),
|
|
420
|
+
menuOnLeftClick: false,
|
|
421
|
+
menuOnRightClick: true,
|
|
422
|
+
});
|
|
423
|
+
if (process.platform === "darwin") tray.setIconAsTemplate(true);
|
|
424
|
+
// Belt and braces over the constructor options: left click belongs to the panel.
|
|
425
|
+
tray.setShowMenuOnLeftClick(false);
|
|
426
|
+
tray.setShowMenuOnRightClick(true);
|
|
427
|
+
|
|
428
|
+
// Built once, here, and never from inside an event handler: creating a window while the event
|
|
429
|
+
// loop is dispatching a click asks the native side to borrow the application twice, which it
|
|
430
|
+
// refuses. So the panel exists from the start, hidden, and a click only shows it.
|
|
431
|
+
let panel = null;
|
|
432
|
+
function buildPanel() {
|
|
433
|
+
const window = app.createBrowserWindow({
|
|
434
|
+
title: "roger-roger",
|
|
435
|
+
width: PANEL.width,
|
|
436
|
+
height: PANEL.height,
|
|
437
|
+
decorations: false,
|
|
438
|
+
transparent: true,
|
|
439
|
+
alwaysOnTop: true,
|
|
440
|
+
// Frameless, but its edges still resize it: the library leaves that to Windows.
|
|
441
|
+
resizable: true,
|
|
442
|
+
// The header is a title bar as far as Windows knows, and a double-click on one maximizes.
|
|
443
|
+
maximizable: false,
|
|
444
|
+
visible: false,
|
|
445
|
+
// The library's name for it. Plain `skipTaskbar` is silently ignored, which left the panel on
|
|
446
|
+
// the taskbar with Node's icon — and closing that button took the whole tray down with it.
|
|
447
|
+
windowsSkipTaskbar: true,
|
|
448
|
+
// What actually makes it see-through on Windows. Without it the window keeps an opaque
|
|
449
|
+
// backing surface and the translucent page is drawn over light grey: milky, not glass.
|
|
450
|
+
windowsNoRedirectionBitmap: true,
|
|
451
|
+
windowsUndecoratedShadow: true,
|
|
452
|
+
});
|
|
453
|
+
// Its own data directory, so it never argues with another webview on this machine over a lock.
|
|
454
|
+
const context = app.createWebContext({ dataDirectory: path.join(HOME, "webview") });
|
|
455
|
+
const webview = window.createWebview({ html: panelHtml(), transparent: true }, context);
|
|
456
|
+
// What the panel's buttons do. A link to a made-up scheme never reached us — the webview
|
|
457
|
+
// swallowed it — so the page asks over IPC instead, which is what it is for.
|
|
458
|
+
webview.onIpcMessage((message) => {
|
|
459
|
+
let ask;
|
|
460
|
+
try {
|
|
461
|
+
ask = JSON.parse(String(message?.body ?? message ?? ""));
|
|
462
|
+
} catch {
|
|
463
|
+
return log(`the panel said something I could not read: ${message?.body ?? message}`);
|
|
464
|
+
}
|
|
465
|
+
if (ask.do === "snooze") return run("snooze", ask.value || "1h");
|
|
466
|
+
if (ask.do === "hide") return hidePanel();
|
|
467
|
+
if (ask.do === "keep-open") {
|
|
468
|
+
keepOpen = Boolean(ask.value);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
// Anything `setup` understands, without this file having to know what any of it means.
|
|
472
|
+
if (ask.do === "set" && ask.flag) return applySetting(String(ask.flag), String(ask.value ?? ""));
|
|
473
|
+
if (ask.do === "test-voice") return testVoice();
|
|
474
|
+
if (ask.do === "refresh-voices") return refreshVoices();
|
|
475
|
+
log(`the panel asked for something I don't do: ${ask.do}`);
|
|
476
|
+
});
|
|
477
|
+
// Clicking away puts it away, like anything else hanging off a tray icon. Registered here
|
|
478
|
+
// because the window has to exist first — asking a null for its events does nothing, quietly.
|
|
479
|
+
window.on("blur", onBlur);
|
|
480
|
+
window.on("move", onMoved);
|
|
481
|
+
window.on("resize", onResized);
|
|
482
|
+
try {
|
|
483
|
+
window.setMinSize(PANEL.minWidth, PANEL.minHeight);
|
|
484
|
+
const saved = readSize();
|
|
485
|
+
if (saved) window.setSize(saved.width, saved.height);
|
|
486
|
+
} catch (e) {
|
|
487
|
+
log(`could not size the panel: ${e.message}`);
|
|
488
|
+
}
|
|
489
|
+
// The panel is never really closed, only put away. Alt+F4, or a close from anywhere else,
|
|
490
|
+
// would otherwise end the window and with it the tray the user never meant to quit.
|
|
491
|
+
window.on("close", (event) => {
|
|
492
|
+
event.preventDefault();
|
|
493
|
+
hidePanel();
|
|
494
|
+
});
|
|
495
|
+
return { window, webview };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* One setting, as `--flag=value` in a single argument: a value that happens to start with `--`
|
|
500
|
+
* would otherwise be read as the next flag. A refusal is logged and shown in the panel. Success
|
|
501
|
+
* is left for the daemon to pick up, and the panel redrawn once it has had a moment to.
|
|
502
|
+
*/
|
|
503
|
+
async function applySetting(flag, value) {
|
|
504
|
+
const problem = await runChecked("setup", `--${flag}=${value}`);
|
|
505
|
+
if (problem) {
|
|
506
|
+
log(`setup --${flag} was refused: ${problem}`);
|
|
507
|
+
const message = `Could not change ${flag}: ${problem}`;
|
|
508
|
+
if (panel) panel.webview.evaluateScript(`window.flash && window.flash(${JSON.stringify(message)})`);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
// A new provider, or a new place for its key: ask it what voices and models it has.
|
|
512
|
+
if (flag === "speech-provider" || flag === "speech-key-var") refreshVoices();
|
|
513
|
+
setTimeout(refresh, 500);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Ask the provider for its voices and models (the daemon keeps the answer, and the snapshot
|
|
518
|
+
* carries it). Only when there is a key to ask with; a missing key is already on screen.
|
|
519
|
+
*/
|
|
520
|
+
let refreshingVoices = false;
|
|
521
|
+
async function refreshVoices() {
|
|
522
|
+
if (refreshingVoices) return;
|
|
523
|
+
refreshingVoices = true;
|
|
524
|
+
try {
|
|
525
|
+
// The setting may only just have been saved; let the snapshot say whether the key is there.
|
|
526
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
527
|
+
const problem = await runChecked("voices", "--refresh");
|
|
528
|
+
if (problem && !/not set|empty/.test(problem)) {
|
|
529
|
+
log(`could not load voices: ${problem}`);
|
|
530
|
+
panel?.webview.evaluateScript(`window.flash && window.flash(${JSON.stringify(`Could not load voices: ${problem}`)})`);
|
|
531
|
+
}
|
|
532
|
+
} finally {
|
|
533
|
+
refreshingVoices = false;
|
|
534
|
+
setTimeout(refresh, 500);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Say something with the chosen provider, voice and key, and nothing else: `say --test` never
|
|
540
|
+
* falls back to the computer's own voice, so hearing it means it all works, and silence comes
|
|
541
|
+
* with a reason.
|
|
542
|
+
*/
|
|
543
|
+
async function testVoice() {
|
|
544
|
+
const flash = (message, kind) => panel?.webview.evaluateScript(`window.flash && window.flash(${JSON.stringify(message)}, ${JSON.stringify(kind)})`);
|
|
545
|
+
const problem = await runChecked("say", "Roger roger. [short pause] This is how I sound.", "--test");
|
|
546
|
+
if (problem) {
|
|
547
|
+
log(`voice test failed: ${problem}`);
|
|
548
|
+
return flash(`Voice test failed: ${problem}`, "error");
|
|
549
|
+
}
|
|
550
|
+
const provider = (state.options?.speechProviders ?? []).find((p) => p.id === state.settings?.speechProvider)?.label ?? "The provider";
|
|
551
|
+
const used = state.settings?.speechKeyUsed;
|
|
552
|
+
flash(`${provider} spoke${used ? ` using ${used}` : ""}. It works.`, "ok");
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
let drawnAlert = null;
|
|
556
|
+
let drawnMenu = null;
|
|
557
|
+
const draw = () => {
|
|
558
|
+
const alert = alerting();
|
|
559
|
+
if (alert !== drawnAlert) {
|
|
560
|
+
tray.setIcon(icon(alert), 32, 32);
|
|
561
|
+
drawnAlert = alert;
|
|
562
|
+
}
|
|
563
|
+
tray.setTooltip(tooltip(state, stale));
|
|
564
|
+
const key = menuKey(state, spot);
|
|
565
|
+
if (key !== drawnMenu) {
|
|
566
|
+
tray.setMenu(menu(state, spot));
|
|
567
|
+
drawnMenu = key;
|
|
568
|
+
}
|
|
569
|
+
if (shown && panel) panel.webview.evaluateScript(renderScript());
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
// Staleness is worked out on every refresh, not only when the file changes: a daemon that has
|
|
573
|
+
// died is precisely one that has stopped changing it.
|
|
574
|
+
const refresh = () => {
|
|
575
|
+
state = read();
|
|
576
|
+
stale = isStale(state);
|
|
577
|
+
draw();
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
// fs.watch is the fast path and is unreliable on some platforms, so a slow poll backs it up.
|
|
581
|
+
try {
|
|
582
|
+
fs.watch(SNAPSHOT, { persistent: false }, refresh);
|
|
583
|
+
} catch {
|
|
584
|
+
// The file may not exist yet; the poll below will pick it up when it does.
|
|
585
|
+
}
|
|
586
|
+
const poll = setInterval(refresh, 2000);
|
|
587
|
+
poll.unref?.();
|
|
588
|
+
|
|
589
|
+
function quit() {
|
|
590
|
+
// Ending the event loop from inside one of its own callbacks can throw, and an exception here
|
|
591
|
+
// used to mean the process never got as far as leaving. Leaving is the part that matters.
|
|
592
|
+
log("quitting");
|
|
593
|
+
releasePidFile();
|
|
594
|
+
try {
|
|
595
|
+
app.exit();
|
|
596
|
+
} catch (e) {
|
|
597
|
+
log(`the event loop would not stop: ${e.message}`);
|
|
598
|
+
}
|
|
599
|
+
process.exit(0);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Parked off-screen rather than hidden. Hiding a transparent window and showing it again loses
|
|
603
|
+
// the transparency on Windows — the composition is set up once, when the window appears — so it
|
|
604
|
+
// never disappears, it just goes somewhere nobody is looking.
|
|
605
|
+
function hidePanel() {
|
|
606
|
+
if (!shown || !panel) return;
|
|
607
|
+
shown = false;
|
|
608
|
+
panel.window.setPosition(-32000, -32000);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// A window that has just been told to show has not been given focus yet, and the blur that
|
|
612
|
+
// arrives in that moment is not the user clicking away — it is the window being born. Hiding on
|
|
613
|
+
// it is the flicker. Ignoring it outright, though, can strand the panel open when that really
|
|
614
|
+
// was the last blur it will get; so it is looked at again once the window has settled, and the
|
|
615
|
+
// panel put away then if it still does not have focus.
|
|
616
|
+
const BORN_MS = 500;
|
|
617
|
+
let shownAt = 0;
|
|
618
|
+
let blurCheck = null;
|
|
619
|
+
// When a blur last put the panel away. Clicking the icon of an open panel blurs the panel first,
|
|
620
|
+
// and without this the click that follows would open it straight back up.
|
|
621
|
+
let blurHiddenAt = 0;
|
|
622
|
+
function hideOnBlur() {
|
|
623
|
+
blurHiddenAt = Date.now();
|
|
624
|
+
hidePanel();
|
|
625
|
+
}
|
|
626
|
+
function onBlur() {
|
|
627
|
+
if (!shown || !panel || keepOpen) return;
|
|
628
|
+
const settling = BORN_MS - (Date.now() - shownAt);
|
|
629
|
+
if (settling <= 0) return hideOnBlur();
|
|
630
|
+
clearTimeout(blurCheck);
|
|
631
|
+
blurCheck = setTimeout(() => {
|
|
632
|
+
blurCheck = null;
|
|
633
|
+
let focused = false;
|
|
634
|
+
try {
|
|
635
|
+
focused = panel.window.isFocused();
|
|
636
|
+
} catch {}
|
|
637
|
+
if (shown && !focused) hideOnBlur();
|
|
638
|
+
}, settling + 50);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
let lastClick = 0;
|
|
642
|
+
/**
|
|
643
|
+
* Where the panel lives once the user has dragged it. Windows moves the window itself (the header
|
|
644
|
+
* is its title bar), so all there is to do here is notice. Moves the tray makes — placing it when
|
|
645
|
+
* it opens, parking it when it closes — happen while it is closed or in the moment it opens, so
|
|
646
|
+
* a move while it is open and settled is the user's. Saved once the dragging stops.
|
|
647
|
+
*/
|
|
648
|
+
let saveTimer = null;
|
|
649
|
+
function onMoved() {
|
|
650
|
+
if (!shown || !panel || Date.now() - shownAt < BORN_MS) return;
|
|
651
|
+
clearTimeout(saveTimer);
|
|
652
|
+
saveTimer = setTimeout(() => {
|
|
653
|
+
if (!shown) return;
|
|
654
|
+
const at = panel.window.getPosition();
|
|
655
|
+
spot = { x: at.x, y: at.y };
|
|
656
|
+
writeSpot(spot);
|
|
657
|
+
draw(); // the menu grows its way back under the icon
|
|
658
|
+
}, 400);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** Resized by its edges: remember the size once the user lets go. Only the user resizes it. */
|
|
662
|
+
let sizeTimer = null;
|
|
663
|
+
function onResized() {
|
|
664
|
+
if (!panel) return;
|
|
665
|
+
clearTimeout(sizeTimer);
|
|
666
|
+
sizeTimer = setTimeout(() => {
|
|
667
|
+
try {
|
|
668
|
+
const { width, height } = panel.window.getOuterSize();
|
|
669
|
+
if (width >= PANEL.minWidth && height >= PANEL.minHeight) writeSize({ width, height });
|
|
670
|
+
} catch (e) {
|
|
671
|
+
log(`could not read the panel's size: ${e.message}`);
|
|
672
|
+
}
|
|
673
|
+
}, 400);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function forgetSpot() {
|
|
677
|
+
spot = null;
|
|
678
|
+
writeSpot(null);
|
|
679
|
+
draw();
|
|
680
|
+
if (!shown || !panel) return;
|
|
681
|
+
try {
|
|
682
|
+
placePanel(panel.window, tray.rect());
|
|
683
|
+
} catch (e) {
|
|
684
|
+
log(`could not place the panel: ${e.message}`);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function showPanel() {
|
|
689
|
+
if (!panel) return log("there is no panel to show; see the error above");
|
|
690
|
+
const now = Date.now();
|
|
691
|
+
if (now - lastClick < 350) return;
|
|
692
|
+
lastClick = now;
|
|
693
|
+
if (shown) return hidePanel();
|
|
694
|
+
// The blur from this same click has already put it away. The click meant "close", and it is.
|
|
695
|
+
if (now - blurHiddenAt < 300) return;
|
|
696
|
+
shown = true;
|
|
697
|
+
shownAt = Date.now();
|
|
698
|
+
refresh();
|
|
699
|
+
// A key that is there but whose provider hasn't been asked yet: ask now, while the panel is up.
|
|
700
|
+
if (state.settings?.speechKeyFound && !state.options?.speechCatalog?.fetchedAt) refreshVoices();
|
|
701
|
+
try {
|
|
702
|
+
if (!placeAtSpot(panel.window, spot)) placePanel(panel.window, tray.rect());
|
|
703
|
+
} catch (e) {
|
|
704
|
+
// A panel in the wrong place still beats no panel.
|
|
705
|
+
log(`could not place the panel: ${e.message}`);
|
|
706
|
+
}
|
|
707
|
+
panel.webview.evaluateScript(renderScript());
|
|
708
|
+
panel.window.show();
|
|
709
|
+
panel.window.focus();
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// The native side reports a click for the press and again for the release, for every button.
|
|
713
|
+
// Only the left button's release opens the panel; the right one belongs to the menu. A field that
|
|
714
|
+
// is missing altogether counts as a yes, so a build that does not report them still works.
|
|
715
|
+
tray.on("click", (event) => {
|
|
716
|
+
const button = String(event?.button ?? "left").toLowerCase();
|
|
717
|
+
const buttonState = String(event?.buttonState ?? "up").toLowerCase();
|
|
718
|
+
if (button !== "left") return;
|
|
719
|
+
if (buttonState !== "up" && buttonState !== "released") return;
|
|
720
|
+
showPanel();
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
app.on("custom-menu-click", (event) => {
|
|
724
|
+
const id = event?.customMenuEvent?.id;
|
|
725
|
+
log(`menu: ${id ?? JSON.stringify(event)}`);
|
|
726
|
+
if (id === "quit") return quit();
|
|
727
|
+
if (id === "forget-spot") return forgetSpot();
|
|
728
|
+
onMenu(id);
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
try {
|
|
732
|
+
panel = buildPanel();
|
|
733
|
+
// Shown once, out of sight. A transparent window gets its composition when it first appears,
|
|
734
|
+
// and on Windows hiding it and showing it again does not get it back.
|
|
735
|
+
panel.window.setPosition(-32000, -32000);
|
|
736
|
+
panel.window.show();
|
|
737
|
+
// Again after showing: on Windows the taskbar button is created when the window first appears.
|
|
738
|
+
panel.window.setSkipTaskbar?.(true);
|
|
739
|
+
} catch (e) {
|
|
740
|
+
// The icon, its tooltip and its menu are all still worth having without it.
|
|
741
|
+
log(`the panel could not be built: ${e.stack ?? e.message}`);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
main().catch((e) => {
|
|
746
|
+
log(`the tray could not start: ${e.stack ?? e.message}`);
|
|
747
|
+
process.stderr.write(`the tray could not start: ${e.stack ?? e.message}\n`);
|
|
748
|
+
process.exit(1);
|
|
749
|
+
});
|