@hraness/ghostget 0.18.12 → 0.18.14
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/CHANGELOG.md +29 -0
- package/README.md +17 -8
- package/dist/apple-photos-client.js +1 -1
- package/dist/beeper-client.js +42 -6
- package/dist/{imessage-direct-install-2gm3kge7.js → imessage-direct-install-5nftwc6p.js} +1 -1
- package/dist/{index-9ayntqrw.js → index-g6na3p6k.js} +1 -1
- package/dist/{index-en5hycxp.js → index-kaav16fd.js} +4 -2
- package/dist/{index-2ymnp8xv.js → index-qcf2f6wm.js} +5 -1
- package/dist/{messaging-automation-j4274hvc.js → messaging-automation-58s1366z.js} +1 -1
- package/dist/messaging-automation-api.js +2 -2
- package/dist/{messaging-native-install-8w1j36ah.js → messaging-native-install-c90nv501.js} +1 -1
- package/dist/{whatsapp-automation-runtime-hgh1e9rm.js → whatsapp-automation-runtime-ke1dx91p.js} +1 -1
- package/docs/messaging-automation.md +14 -7
- package/package.json +12 -7
- package/skills/ghostget/SKILL.md +1 -1
- package/skills/ghostget/references/install.md +5 -5
- package/src/assets/adapters/beeper/wrench-web-adapter.json +37 -1
- package/src/assets/adapters/beeper/wrench-web-adapter.v2.4.0.json +1500 -0
- package/src/beeper-client-types.ts +1 -1
- package/src/control/menubar-cli.ts +570 -0
- package/src/control/menubar-icon.ts +4 -0
- package/src/messaging-automation-descriptors.ts +9 -1
- package/src/messaging-automation-factory.ts +7 -5
- package/src/messaging-automation-server.ts +2 -2
- package/src/messaging-automation-types.ts +3 -2
- package/src/messaging-automation-validation.ts +5 -1
- package/src/plugins/beeper-linked-device/plugin.ts +3 -1
- package/src/provider-plugin-contract-identity.ts +11 -2
- package/src/providers/beeper-automation.ts +447 -0
- package/src/providers/beeper-local-runtime.ts +61 -21
- package/src/providers/beeper-local.ts +4 -4
- package/src/providers/beeper-omni.ts +9 -2
- package/src/providers/imessage-direct-runtime.ts +28 -2
- package/src/providers/messaging-native-install.ts +2 -1
- package/src/support-profile.ts +7 -0
- package/src/support.ts +1 -7
- package/src/version.ts +1 -1
|
@@ -96,7 +96,7 @@ export type BeeperContactInteractionExportReceipt = Readonly<{
|
|
|
96
96
|
implementation: Readonly<{
|
|
97
97
|
producer: Readonly<{
|
|
98
98
|
package: "@hraness/ghostget";
|
|
99
|
-
version: "0.18.
|
|
99
|
+
version: "0.18.14";
|
|
100
100
|
}>;
|
|
101
101
|
officialCli: Readonly<{
|
|
102
102
|
implementation: "github.com/beeper/cli";
|
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { lstatSync, readdirSync, type BigIntStats } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { handleCompanionCommand, openBrowser, type CompanionOptions, type MenuItem } from "@hraness/desktop-foundation";
|
|
6
|
+
import { createSupportOffer } from "@hraness/support-foundation";
|
|
7
|
+
import { ghostgetSupportProfile } from "../support-profile";
|
|
8
|
+
import { TRAY_ICON } from "./menubar-icon";
|
|
9
|
+
import { ghostgetStateHome } from "../storage";
|
|
10
|
+
import { CONTROL_PROTOCOL, type ActivityRow, type ApprovalView, type CapabilityView, type ControlRequest, type ControlResponse, type ControlSnapshot } from "./protocol";
|
|
11
|
+
import type { ControlEnvironment } from "./web-policy";
|
|
12
|
+
|
|
13
|
+
const WEBSITE = "https://ghostget.com/getting-started";
|
|
14
|
+
const SUPPORT_ACTIONS = createSupportOffer(ghostgetSupportProfile, "desktop").actions;
|
|
15
|
+
const MAX_FRAME = 4_194_304;
|
|
16
|
+
const HELPER_TIMEOUT_MS = 15_000;
|
|
17
|
+
const OUTPUTS_LIMIT = 12;
|
|
18
|
+
const OUTPUTS_SCAN_BOUND = 512;
|
|
19
|
+
const OPENABLE_EXTENSIONS = new Set(["pdf", "txt", "md", "csv", "json", "png", "jpg", "jpeg", "gif", "webp", "tiff"]);
|
|
20
|
+
const BROWSERS = [
|
|
21
|
+
{ key: "safari", label: "Safari", browser: "safari", profile: null },
|
|
22
|
+
{ key: "chrome-default", label: "Chrome · Default", browser: "chrome", profile: "Default" },
|
|
23
|
+
{ key: "chrome-profile-1", label: "Chrome · Profile 1", browser: "chrome", profile: "Profile 1" },
|
|
24
|
+
{ key: "chrome-profile-2", label: "Chrome · Profile 2", browser: "chrome", profile: "Profile 2" },
|
|
25
|
+
] as const;
|
|
26
|
+
type Output = { readonly stdout: (text: string) => unknown; readonly stderr: (text: string) => unknown };
|
|
27
|
+
|
|
28
|
+
/** Presentation never lets control data add lines, bidi overrides or unbounded menus. */
|
|
29
|
+
export function menuLabel(text: string, limit = 72): string {
|
|
30
|
+
const clean = [...text]
|
|
31
|
+
.map((character) => (/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test(character) ? " " : character))
|
|
32
|
+
.join("").split(/\s+/u).filter((part) => part.length > 0).join(" ");
|
|
33
|
+
const scalars = [...clean];
|
|
34
|
+
return scalars.length > limit ? `${scalars.slice(0, Math.max(0, limit - 1)).join("")}…` : clean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function detailItems(detail: string): MenuItem[] {
|
|
38
|
+
const words = menuLabel(detail, 216).split(" ");
|
|
39
|
+
const rows: string[] = [];
|
|
40
|
+
let current = "";
|
|
41
|
+
for (const word of words) {
|
|
42
|
+
const next = current ? `${current} ${word}` : word;
|
|
43
|
+
if ([...next].length <= 72) current = next;
|
|
44
|
+
else { if (current !== "") rows.push(current); current = word; }
|
|
45
|
+
}
|
|
46
|
+
if (current !== "") rows.push(current);
|
|
47
|
+
const bounded = rows.slice(0, 3);
|
|
48
|
+
return bounded.length > 0 ? bounded.map((line) => ({ kind: "label" as const, label: menuLabel(line, 72) })) : [{ kind: "label" as const, label: "No additional detail." }];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** One output file that passed ownership, type and permission checks. The
|
|
52
|
+
* identity fields revalidate the same file at dispatch time. */
|
|
53
|
+
export interface OutputEntry {
|
|
54
|
+
readonly name: string;
|
|
55
|
+
readonly size: number;
|
|
56
|
+
readonly modifiedMs: number;
|
|
57
|
+
readonly identity: { readonly dev: bigint; readonly ino: bigint; readonly size: bigint; readonly mtimeNs: bigint; readonly mode: bigint };
|
|
58
|
+
}
|
|
59
|
+
export interface OutputsView {
|
|
60
|
+
readonly directory: { readonly dev: bigint; readonly ino: bigint } | null;
|
|
61
|
+
readonly entries: readonly OutputEntry[];
|
|
62
|
+
readonly message: string | null;
|
|
63
|
+
readonly truncated: boolean;
|
|
64
|
+
}
|
|
65
|
+
const EMPTY_OUTPUTS: OutputsView = { directory: null, entries: [], message: null, truncated: false };
|
|
66
|
+
const UNAVAILABLE_OUTPUTS: OutputsView = { directory: null, entries: [], message: "Outputs unavailable", truncated: false };
|
|
67
|
+
|
|
68
|
+
/** Bounded newest-first listing of the product outputs directory. Reads never
|
|
69
|
+
* create the directory and never follow symlinks; entries must be regular
|
|
70
|
+
* files owned by the current user without group or other write bits. */
|
|
71
|
+
export function readOutputs(directory: string): OutputsView {
|
|
72
|
+
const uid = process.getuid?.();
|
|
73
|
+
const dirInfo = (() => { try { return lstatSync(directory, { bigint: true }); } catch { return null; } })();
|
|
74
|
+
if (dirInfo === null || uid === undefined || !dirInfo.isDirectory() || dirInfo.isSymbolicLink() || dirInfo.uid !== BigInt(uid) || (dirInfo.mode & 0o777n) !== 0o700n) return UNAVAILABLE_OUTPUTS;
|
|
75
|
+
const entry = (name: string): OutputEntry | null => {
|
|
76
|
+
if (name.startsWith(".") || name.includes("/") || name.includes("\u0000")) return null;
|
|
77
|
+
let info: BigIntStats;
|
|
78
|
+
try { info = lstatSync(join(directory, name), { bigint: true }); } catch { return null; }
|
|
79
|
+
if (!info.isFile() || info.isSymbolicLink() || info.uid !== BigInt(uid) || (info.mode & 0o022n) !== 0n) return null;
|
|
80
|
+
return { name, size: Number(info.size), modifiedMs: Number(info.mtimeMs), identity: { dev: info.dev, ino: info.ino, size: info.size, mtimeNs: info.mtimeNs, mode: info.mode } };
|
|
81
|
+
};
|
|
82
|
+
let names: readonly string[];
|
|
83
|
+
try { names = readdirSync(directory).slice(0, OUTPUTS_SCAN_BOUND); } catch { return UNAVAILABLE_OUTPUTS; }
|
|
84
|
+
const entries = names.flatMap((name) => { const found = entry(name); return found === null ? [] : [found]; });
|
|
85
|
+
const truncated = names.length === OUTPUTS_SCAN_BOUND || entries.length > OUTPUTS_LIMIT;
|
|
86
|
+
entries.sort((a, b) => b.identity.mtimeNs === a.identity.mtimeNs ? a.name.localeCompare(b.name) : b.identity.mtimeNs > a.identity.mtimeNs ? 1 : -1);
|
|
87
|
+
return { directory: { dev: dirInfo.dev, ino: dirInfo.ino }, entries: entries.slice(0, OUTPUTS_LIMIT), message: entries.length === 0 ? "No output files" : null, truncated };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Wire action ids carry only entry indices; file names never enter the wire
|
|
91
|
+
* contract, and dispatch revalidates identity against the stored listing. */
|
|
92
|
+
|
|
93
|
+
/** Revalidate directory and entry identity at dispatch. An OS open remains a
|
|
94
|
+
* handoff; this refuses stale or swapped targets. */
|
|
95
|
+
function validatedOutputPath(directory: string, expected: OutputsView, name: string): string | null {
|
|
96
|
+
const uid = process.getuid?.();
|
|
97
|
+
if (expected.directory === null || uid === undefined) return null;
|
|
98
|
+
const dirInfo = (() => { try { return lstatSync(directory, { bigint: true }); } catch { return null; } })();
|
|
99
|
+
if (dirInfo === null || !dirInfo.isDirectory() || dirInfo.isSymbolicLink() || dirInfo.dev !== expected.directory.dev || dirInfo.ino !== expected.directory.ino) return null;
|
|
100
|
+
const expectedEntry = expected.entries.find((item) => item.name === name);
|
|
101
|
+
if (expectedEntry === undefined) return null;
|
|
102
|
+
const fresh = (() => { try { return lstatSync(join(directory, name), { bigint: true }); } catch { return null; } })();
|
|
103
|
+
if (fresh === null || !fresh.isFile() || fresh.isSymbolicLink() || fresh.uid !== BigInt(uid) || (fresh.mode & 0o022n) !== 0n
|
|
104
|
+
|| fresh.dev !== expectedEntry.identity.dev || fresh.ino !== expectedEntry.identity.ino
|
|
105
|
+
|| fresh.size !== expectedEntry.identity.size || fresh.mtimeNs !== expectedEntry.identity.mtimeNs || fresh.mode !== expectedEntry.identity.mode) return null;
|
|
106
|
+
return join(directory, name);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function outputSize(size: number): string {
|
|
110
|
+
if (size < 1024) return `${size} B`;
|
|
111
|
+
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
|
112
|
+
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
|
113
|
+
}
|
|
114
|
+
function outputItems(outputs: OutputsView): MenuItem[] {
|
|
115
|
+
const items: MenuItem[] = [];
|
|
116
|
+
if (outputs.message !== null) items.push({ kind: "label", label: menuLabel(outputs.message) });
|
|
117
|
+
outputs.entries.forEach((entry, index) => {
|
|
118
|
+
const detail: MenuItem[] = [{ kind: "label", label: menuLabel(`${outputSize(entry.size)} · ${new Date(entry.modifiedMs).toLocaleString()}`) }];
|
|
119
|
+
const extension = entry.name.includes(".") ? entry.name.slice(entry.name.lastIndexOf(".") + 1).toLowerCase() : "";
|
|
120
|
+
if (OPENABLE_EXTENSIONS.has(extension)) detail.push({ kind: "action", id: `output:open:${index}`, label: "Open file" });
|
|
121
|
+
detail.push({ kind: "action", id: `output:reveal:${index}`, label: "Reveal in file manager" });
|
|
122
|
+
detail.push({ kind: "action", id: `output:copy:${index}`, label: "Copy file path" });
|
|
123
|
+
items.push({ kind: "submenu", label: menuLabel(entry.name), items: detail });
|
|
124
|
+
});
|
|
125
|
+
if (outputs.truncated) items.push({ kind: "label", label: `Showing up to ${OUTPUTS_LIMIT} files from a bounded scan` });
|
|
126
|
+
if (outputs.directory !== null && outputs.entries.length > 0) items.push({ kind: "action", id: "output:folder", label: "Reveal outputs folder" });
|
|
127
|
+
if (items.length === 0) items.push({ kind: "label", label: "No output files" });
|
|
128
|
+
return items;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const CLI_COMMANDS = ["ghostget --help", "ghostget menubar --help", "ghostget menubar status"] as const;
|
|
132
|
+
function cliHelpItems(): MenuItem[] {
|
|
133
|
+
return [
|
|
134
|
+
{ kind: "action", id: "clip:0", label: "CLI help" },
|
|
135
|
+
{ kind: "action", id: "clip:1", label: "Menu-bar help" },
|
|
136
|
+
{ kind: "action", id: "clip:2", label: "Menu-bar installation status" },
|
|
137
|
+
];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** OS open/reveal handoff for one validated path. Explorer exit codes are not
|
|
141
|
+
* meaningful, so only spawn failure degrades the menu. */
|
|
142
|
+
function openPath(path: string, reveal: boolean): void {
|
|
143
|
+
const command = process.platform === "darwin"
|
|
144
|
+
? (reveal ? ["open", "-R", path] : ["open", path])
|
|
145
|
+
: process.platform === "win32"
|
|
146
|
+
? (reveal ? ["explorer", `/select,${path}`] : ["explorer", path])
|
|
147
|
+
: (reveal ? ["xdg-open", dirname(path)] : ["xdg-open", path]);
|
|
148
|
+
try { const child = Bun.spawnSync(command, { stdout: "ignore", stderr: "ignore" }); if (process.platform !== "win32" && child.exitCode !== 0) throw new Error("exit"); }
|
|
149
|
+
catch { throw new Error("ghostget-open-unavailable"); }
|
|
150
|
+
}
|
|
151
|
+
function copyText(text: string): void {
|
|
152
|
+
const command = process.platform === "darwin" ? ["pbcopy"] : process.platform === "win32" ? ["clip"] : ["xclip", "-selection", "clipboard"];
|
|
153
|
+
try { const child = Bun.spawnSync(command, { stdin: Buffer.from(text, "utf8"), stdout: "ignore", stderr: "ignore" }); if (child.exitCode !== 0) throw new Error("exit"); }
|
|
154
|
+
catch { throw new Error("ghostget-clipboard-unavailable"); }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Bounded administrative client over the helper's private stdio channel. The
|
|
158
|
+
* menu companion owns the helper process; agent requests stay on the socket. */
|
|
159
|
+
interface HelperClient { request(request: ControlRequest, timeoutMs?: number): Promise<ControlResponse>; close(): void }
|
|
160
|
+
function spawnHelper(environment: ControlEnvironment): HelperClient {
|
|
161
|
+
const script = fileURLToPath(new URL("./helper.ts", import.meta.url));
|
|
162
|
+
const env: Record<string, string> = { ...process.env } as Record<string, string>;
|
|
163
|
+
for (const [key, value] of Object.entries(environment)) if (value !== undefined) env[key] = value;
|
|
164
|
+
const child = Bun.spawn([process.execPath, "--no-env-file", "--no-install", script], { cwd: dirname(script), env, stdin: "pipe", stdout: "pipe", stderr: "pipe" });
|
|
165
|
+
const pending = new Map<string, { resolve: (response: ControlResponse) => void; timer: ReturnType<typeof setTimeout> }>();
|
|
166
|
+
let buffer = Buffer.alloc(0);
|
|
167
|
+
let closed = false;
|
|
168
|
+
void new Response(child.stderr).text();
|
|
169
|
+
const settle = (response: ControlResponse | null): void => {
|
|
170
|
+
const slots = [...pending.values()]; pending.clear();
|
|
171
|
+
for (const slot of slots) {
|
|
172
|
+
clearTimeout(slot.timer);
|
|
173
|
+
slot.resolve(response ?? { ok: false, code: "CONTROL_DISCONNECTED", message: "The Ghostget control helper disconnected." });
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
const read = (async () => {
|
|
177
|
+
const reader = child.stdout.getReader();
|
|
178
|
+
try {
|
|
179
|
+
for (;;) {
|
|
180
|
+
const next = await reader.read(); if (next.done) break;
|
|
181
|
+
buffer = Buffer.concat([buffer, next.value]);
|
|
182
|
+
if (buffer.length > MAX_FRAME) throw new Error("oversized helper response");
|
|
183
|
+
let end: number;
|
|
184
|
+
while ((end = buffer.indexOf(10)) >= 0) {
|
|
185
|
+
const line = buffer.subarray(0, end); buffer = buffer.subarray(end + 1);
|
|
186
|
+
let frame: Record<string, unknown>;
|
|
187
|
+
try { frame = JSON.parse(line.toString("utf8")) as Record<string, unknown>; } catch { continue; }
|
|
188
|
+
const id = typeof frame.id === "string" ? frame.id : null;
|
|
189
|
+
if (id === null || frame.protocol !== CONTROL_PROTOCOL) continue;
|
|
190
|
+
const slot = pending.get(id); if (slot === undefined) continue;
|
|
191
|
+
pending.delete(id); clearTimeout(slot.timer);
|
|
192
|
+
slot.resolve(frame as unknown as ControlResponse);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
} catch { /* a dead or oversized channel degrades the menu, it never retries */ }
|
|
196
|
+
finally { closed = true; settle(null); }
|
|
197
|
+
})();
|
|
198
|
+
void read;
|
|
199
|
+
void child.exited.then(() => { closed = true; settle(null); });
|
|
200
|
+
return {
|
|
201
|
+
request: (request, timeoutMs = HELPER_TIMEOUT_MS) => new Promise<ControlResponse>((resolve) => {
|
|
202
|
+
if (closed) { resolve({ ok: false, code: "CONTROL_DISCONNECTED", message: "The Ghostget control helper is not running." }); return; }
|
|
203
|
+
const id = randomUUID();
|
|
204
|
+
const timer = setTimeout(() => { pending.delete(id); resolve({ ok: false, code: "CONTROL_TIMEOUT", message: "The Ghostget control helper did not answer in time." }); }, timeoutMs);
|
|
205
|
+
pending.set(id, { resolve, timer });
|
|
206
|
+
try { child.stdin.write(`${JSON.stringify({ id, protocol: CONTROL_PROTOCOL, request })}\n`); }
|
|
207
|
+
catch { pending.delete(id); clearTimeout(timer); resolve({ ok: false, code: "CONTROL_DISCONNECTED", message: "The Ghostget control helper disconnected." }); }
|
|
208
|
+
}),
|
|
209
|
+
close: () => { try { child.stdin.end(); } catch { /* already closed */ } },
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function accountItems(snapshot: ControlSnapshot, enabled: boolean): MenuItem[] {
|
|
214
|
+
const accounts = snapshot.accounts;
|
|
215
|
+
const rows: MenuItem[] = accounts.slice(0, 20).map((account) => {
|
|
216
|
+
const status = account.status === "verified" ? "Verified" : account.status === "configured" ? "Configured" : "Reconnect required";
|
|
217
|
+
const items: MenuItem[] = [
|
|
218
|
+
{ kind: "label", label: status },
|
|
219
|
+
...detailItems([account.provider, account.kind, account.subject].filter((part): part is string => part !== null).join(" · ") || "No subject recorded"),
|
|
220
|
+
];
|
|
221
|
+
if (enabled && account.status === "reconnect-required") {
|
|
222
|
+
items.push({
|
|
223
|
+
kind: "submenu",
|
|
224
|
+
label: "Reconnect",
|
|
225
|
+
items: snapshot.connectionProviders.map((provider) => ({
|
|
226
|
+
kind: "submenu" as const,
|
|
227
|
+
label: menuLabel(provider.title) || "Provider",
|
|
228
|
+
items: BROWSERS.map((choice) => ({ kind: "action" as const, id: `reconnect:${account.id}:${provider.id}:${choice.key}`, label: choice.label })),
|
|
229
|
+
})),
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
if (enabled) items.push({ kind: "action", id: `disconnect:${account.id}`, label: "Disconnect this account" });
|
|
233
|
+
return { kind: "submenu" as const, label: menuLabel(`${account.id} · ${status}`) || "Account", items };
|
|
234
|
+
});
|
|
235
|
+
if (accounts.length === 0) rows.push({ kind: "label", label: "No accounts connected" });
|
|
236
|
+
if (accounts.length > 20) rows.push({ kind: "label", label: `${accounts.length - 20} more accounts` });
|
|
237
|
+
return rows;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function approvalItems(approvals: readonly ApprovalView[], enabled: boolean): MenuItem[] {
|
|
241
|
+
const rows: MenuItem[] = approvals.slice(0, 10).map((approval) => ({
|
|
242
|
+
kind: "submenu" as const,
|
|
243
|
+
label: menuLabel(approval.title) || "Approval request",
|
|
244
|
+
items: [
|
|
245
|
+
...detailItems([approval.kind, approval.account ?? "no account", approval.effect, approval.preview].join(" · ")),
|
|
246
|
+
{ kind: "label", label: menuLabel(`Expires ${approval.expiresAt}`) },
|
|
247
|
+
{ kind: "action", id: `approval:allow:${approval.id}`, label: "Allow once", enabled },
|
|
248
|
+
{ kind: "action", id: `approval:deny:${approval.id}`, label: "Deny", enabled },
|
|
249
|
+
],
|
|
250
|
+
}));
|
|
251
|
+
if (approvals.length === 0) rows.push({ kind: "label", label: "No pending approvals" });
|
|
252
|
+
if (approvals.length > 10) rows.push({ kind: "label", label: `${approvals.length - 10} more approvals` });
|
|
253
|
+
return rows;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export interface Attempt { readonly attemptId: string; readonly title: string; status: "awaiting-sign-in" | "verified"; subject: string | null }
|
|
257
|
+
function connectItems(snapshot: ControlSnapshot, attempts: ReadonlyMap<string, Attempt>, enabled: boolean): MenuItem[] {
|
|
258
|
+
const rows: MenuItem[] = [];
|
|
259
|
+
for (const attempt of [...attempts.values()].slice(0, 8)) {
|
|
260
|
+
const items: MenuItem[] = attempt.status === "verified"
|
|
261
|
+
? [
|
|
262
|
+
{ kind: "label", label: menuLabel(`Signed in as ${attempt.subject ?? "unknown"}`) },
|
|
263
|
+
{ kind: "action", id: `attempt:commit:${attempt.attemptId}`, label: `Connect ${menuLabel(attempt.subject ?? "this account", 40)}`, enabled },
|
|
264
|
+
]
|
|
265
|
+
: [
|
|
266
|
+
{ kind: "label", label: "Finish sign-in in the opened browser, then verify." },
|
|
267
|
+
{ kind: "action", id: `attempt:verify:${attempt.attemptId}`, label: "Verify sign-in", enabled },
|
|
268
|
+
];
|
|
269
|
+
items.push({ kind: "action", id: `attempt:cancel:${attempt.attemptId}`, label: "Cancel", enabled });
|
|
270
|
+
rows.push({ kind: "submenu", label: menuLabel(`${attempt.title} · ${attempt.status === "verified" ? "verified" : "awaiting sign-in"}`), items });
|
|
271
|
+
}
|
|
272
|
+
for (const provider of snapshot.connectionProviders.slice(0, 12)) {
|
|
273
|
+
rows.push({
|
|
274
|
+
kind: "submenu",
|
|
275
|
+
label: menuLabel(provider.title) || "Provider",
|
|
276
|
+
items: BROWSERS.map((choice) => ({ kind: "action" as const, id: `connect:${provider.id}:${choice.key}`, label: choice.label, enabled })),
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
if (rows.length === 0) rows.push({ kind: "label", label: "No connection providers" });
|
|
280
|
+
return rows;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function capabilityItems(capabilities: readonly CapabilityView[]): MenuItem[] {
|
|
284
|
+
if (capabilities.length === 0) return [{ kind: "label", label: "No capabilities discovered" }];
|
|
285
|
+
const counts = new Map<string, number>();
|
|
286
|
+
for (const capability of capabilities) counts.set(capability.state, (counts.get(capability.state) ?? 0) + 1);
|
|
287
|
+
const rows: MenuItem[] = [...counts.entries()].map(([state, count]) => ({ kind: "label" as const, label: `${count} ${state}` }));
|
|
288
|
+
for (const capability of capabilities.slice(0, 15)) {
|
|
289
|
+
rows.push({ kind: "label", label: menuLabel(`${capability.adapterId} · ${capability.operationId} · ${capability.permission}`) });
|
|
290
|
+
}
|
|
291
|
+
if (capabilities.length > 15) rows.push({ kind: "label", label: `${capabilities.length - 15} more capabilities` });
|
|
292
|
+
return rows;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function permissionItems(snapshot: ControlSnapshot, enabled: boolean): MenuItem[] {
|
|
296
|
+
const rows: MenuItem[] = [];
|
|
297
|
+
if (!snapshot.policy.managed) rows.push({ kind: "action", id: "permission:enable", label: "Enable operation permissions", enabled });
|
|
298
|
+
else rows.push({ kind: "label", label: "Operation permissions are managed per account." });
|
|
299
|
+
const unmanaged = snapshot.capabilities.filter((capability) => capability.permission === "unmanaged");
|
|
300
|
+
for (const capability of unmanaged.slice(0, 15)) {
|
|
301
|
+
rows.push({
|
|
302
|
+
kind: "submenu",
|
|
303
|
+
label: menuLabel(`${capability.adapterId} · ${capability.operationId}`),
|
|
304
|
+
items: [
|
|
305
|
+
...detailItems(`${capability.surface} · ${capability.effect} · risk ${capability.risk}`),
|
|
306
|
+
{ kind: "action", id: `permission:allow:${capability.adapterId}:${capability.operationId}`, label: "Allow", enabled },
|
|
307
|
+
{ kind: "action", id: `permission:ask:${capability.adapterId}:${capability.operationId}`, label: "Ask each time", enabled },
|
|
308
|
+
{ kind: "action", id: `permission:deny:${capability.adapterId}:${capability.operationId}`, label: "Deny", enabled },
|
|
309
|
+
],
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
if (unmanaged.length > 15) rows.push({ kind: "label", label: `${unmanaged.length - 15} more operations need review` });
|
|
313
|
+
if (!snapshot.policy.managed && unmanaged.length === 0) rows.push({ kind: "label", label: "All operations already have a decision." });
|
|
314
|
+
return rows;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function webItems(snapshot: ControlSnapshot): MenuItem[] {
|
|
318
|
+
const rules = snapshot.web.rules;
|
|
319
|
+
const rows: MenuItem[] = [{ kind: "label", label: snapshot.web.gatewayOnly ? "Gateway-only mode" : "Direct retrieval allowed" }];
|
|
320
|
+
for (const rule of rules.slice(0, 10)) {
|
|
321
|
+
rows.push({ kind: "label", label: menuLabel(`${rule.origin}${rule.path.value} · ${rule.decision}`) });
|
|
322
|
+
}
|
|
323
|
+
if (rules.length > 10) rows.push({ kind: "label", label: `${rules.length - 10} more rules` });
|
|
324
|
+
return rows;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function interfaceItems(snapshot: ControlSnapshot): MenuItem[] {
|
|
328
|
+
const interfaces = snapshot.interfaces;
|
|
329
|
+
if (interfaces.length === 0) return [{ kind: "label", label: "No interfaces installed" }];
|
|
330
|
+
const rows: MenuItem[] = interfaces.slice(0, 10).map((entry) => ({
|
|
331
|
+
kind: "submenu" as const,
|
|
332
|
+
label: menuLabel(`${entry.title} · ${entry.state}`),
|
|
333
|
+
items: detailItems(`${entry.operationCount} operations · ${entry.adapterIds.join(", ") || "no adapters"}${entry.issues.length > 0 ? ` · ${entry.issues.length} issue(s)` : ""}`),
|
|
334
|
+
}));
|
|
335
|
+
if (interfaces.length > 10) rows.push({ kind: "label", label: `${interfaces.length - 10} more interfaces` });
|
|
336
|
+
return rows;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function activityItems(rows: readonly ActivityRow[]): MenuItem[] {
|
|
340
|
+
if (rows.length === 0) return [{ kind: "label", label: "No recent gateway activity" }];
|
|
341
|
+
return rows.slice(0, 8).map((row) => ({
|
|
342
|
+
kind: "label" as const,
|
|
343
|
+
label: menuLabel(`${row.method} ${row.origin ?? row.endpoint ?? "request"} · ${row.outcome}${row.httpStatus === null ? "" : ` · ${row.httpStatus}`}`),
|
|
344
|
+
}));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Map one control snapshot onto the shared menu contract. The helper stays
|
|
348
|
+
* the authority; rows are display-only except the bounded actions below. */
|
|
349
|
+
export function snapshotItems(
|
|
350
|
+
snapshot: ControlSnapshot | null,
|
|
351
|
+
attempts: ReadonlyMap<string, Attempt>,
|
|
352
|
+
status: { confirmedAgeSeconds: number | null; fresh: boolean; detail?: string | undefined },
|
|
353
|
+
activity: readonly ActivityRow[] = [],
|
|
354
|
+
outputs: OutputsView = EMPTY_OUTPUTS,
|
|
355
|
+
notice: string | null = null,
|
|
356
|
+
): MenuItem[] {
|
|
357
|
+
const confirmed = snapshot !== null;
|
|
358
|
+
const age = status.confirmedAgeSeconds;
|
|
359
|
+
const ageText = age !== null && age < 60 ? `${age}s ago` : `${Math.floor((age ?? 0) / 60)}m ago`;
|
|
360
|
+
const updated = age === null ? "Control status not confirmed" : status.fresh ? `Updated ${ageText}` : `Last confirmed ${ageText}`;
|
|
361
|
+
const tail: MenuItem[] = [
|
|
362
|
+
{ kind: "separator" },
|
|
363
|
+
{ kind: "submenu", label: `Outputs · ${outputs.entries.length}`, items: outputItems(outputs) },
|
|
364
|
+
...(notice === null ? [] : [{ kind: "label" as const, label: menuLabel(notice) }]),
|
|
365
|
+
{ kind: "label", label: menuLabel(updated) },
|
|
366
|
+
{ kind: "action", id: "refresh", label: "Refresh status" },
|
|
367
|
+
{ kind: "action", id: "open-website", label: "Open Ghostget…" },
|
|
368
|
+
{ kind: "action", id: "support:updates", label: "Get Ghostget updates (free)…" },
|
|
369
|
+
{ kind: "action", id: "support:paid", label: "Support Ghostget development (optional paid)…" },
|
|
370
|
+
{ kind: "submenu", label: "Copy CLI command", items: cliHelpItems() },
|
|
371
|
+
{ kind: "separator" },
|
|
372
|
+
{ kind: "quit", label: "Quit Ghostget" },
|
|
373
|
+
];
|
|
374
|
+
if (!confirmed) {
|
|
375
|
+
return [
|
|
376
|
+
{ kind: "label", label: "Ghostget control unavailable" },
|
|
377
|
+
{ kind: "label", label: menuLabel(status.detail ?? "The control helper is not running. Requests need the companion open.") },
|
|
378
|
+
...tail,
|
|
379
|
+
];
|
|
380
|
+
}
|
|
381
|
+
const pending = snapshot.approvals.length;
|
|
382
|
+
const reconnect = snapshot.accounts.filter((account) => account.status === "reconnect-required").length;
|
|
383
|
+
return [
|
|
384
|
+
{ kind: "label", label: menuLabel(`Ghostget ${snapshot.version}`) },
|
|
385
|
+
{ kind: "label", label: menuLabel(`${snapshot.accounts.length} account${snapshot.accounts.length === 1 ? "" : "s"}${reconnect > 0 ? ` · ${reconnect} need reconnect` : ""} · ${pending} pending approval${pending === 1 ? "" : "s"}`) },
|
|
386
|
+
{ kind: "separator" },
|
|
387
|
+
{ kind: "submenu", label: `Approvals · ${pending}`, items: approvalItems(snapshot.approvals, confirmed) },
|
|
388
|
+
{ kind: "submenu", label: `Accounts · ${snapshot.accounts.length}`, items: accountItems(snapshot, confirmed) },
|
|
389
|
+
{ kind: "submenu", label: "Connect account", items: connectItems(snapshot, attempts, confirmed) },
|
|
390
|
+
{ kind: "submenu", label: "Permissions", items: permissionItems(snapshot, confirmed) },
|
|
391
|
+
{ kind: "submenu", label: `Capabilities · ${snapshot.capabilities.length}`, items: capabilityItems(snapshot.capabilities) },
|
|
392
|
+
{ kind: "submenu", label: `Web rules · ${snapshot.web.rules.length}`, items: webItems(snapshot) },
|
|
393
|
+
{ kind: "submenu", label: `Interfaces · ${snapshot.interfaces.length}`, items: interfaceItems(snapshot) },
|
|
394
|
+
{ kind: "submenu", label: "Recent activity", items: activityItems(activity) },
|
|
395
|
+
{ kind: "label", label: menuLabel(`Vault · 1Password ${snapshot.vault.available ? "available" : "unavailable"}`) },
|
|
396
|
+
...tail,
|
|
397
|
+
];
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** The Ghostget menu companion owns the control helper's stdio channel and is
|
|
401
|
+
* a disposable client of it. All reads and mutations use bounded control
|
|
402
|
+
* requests; the shared runner renders state and enforces revision-checked
|
|
403
|
+
* dispatch. Failed or indeterminate mutations are never retried. */
|
|
404
|
+
export function companionOptions(
|
|
405
|
+
environment: ControlEnvironment,
|
|
406
|
+
openAccountPage: (url: string) => Promise<void> = openBrowser,
|
|
407
|
+
): CompanionOptions {
|
|
408
|
+
let helper: HelperClient | null = null;
|
|
409
|
+
let lastSnapshot: ControlSnapshot | null = null;
|
|
410
|
+
let confirmedAt: number | null = null;
|
|
411
|
+
let lastOutputs: OutputsView = EMPTY_OUTPUTS;
|
|
412
|
+
let notice: string | null = null;
|
|
413
|
+
let openingAccountPage = false;
|
|
414
|
+
const attempts = new Map<string, Attempt>();
|
|
415
|
+
const outputsDirectory = join(ghostgetStateHome(environment), "outputs");
|
|
416
|
+
const client = (): HelperClient => {
|
|
417
|
+
if (helper === null) helper = spawnHelper(environment);
|
|
418
|
+
return helper;
|
|
419
|
+
};
|
|
420
|
+
const drop = (): void => { helper?.close(); helper = null; };
|
|
421
|
+
process.once("exit", drop);
|
|
422
|
+
const request = async (body: ControlRequest, timeoutMs?: number): Promise<ControlResponse> => {
|
|
423
|
+
const response = await client().request(body, timeoutMs);
|
|
424
|
+
if (!response.ok && (response.code === "CONTROL_DISCONNECTED" || response.code === "CONTROL_TIMEOUT")) drop();
|
|
425
|
+
return response;
|
|
426
|
+
};
|
|
427
|
+
return {
|
|
428
|
+
appId: "ghostget",
|
|
429
|
+
name: "Ghostget",
|
|
430
|
+
title: "\u{1f47b}",
|
|
431
|
+
icon: TRAY_ICON,
|
|
432
|
+
tooltip: "Ghostget · control, outputs and CLI help",
|
|
433
|
+
stateDir: join(ghostgetStateHome(environment), "menubar"),
|
|
434
|
+
refreshMs: 5_000,
|
|
435
|
+
timeoutMs: 90_000,
|
|
436
|
+
snapshot: async () => {
|
|
437
|
+
let fresh = false;
|
|
438
|
+
const response = await request({ action: "snapshot", accountId: null });
|
|
439
|
+
if (response.ok && response.data.kind === "snapshot") {
|
|
440
|
+
if (lastSnapshot !== null && response.data.snapshot.policy.revision < lastSnapshot.policy.revision) {
|
|
441
|
+
// A late or replayed response must never undo newer owner state.
|
|
442
|
+
} else {
|
|
443
|
+
lastSnapshot = response.data.snapshot;
|
|
444
|
+
confirmedAt = Date.now();
|
|
445
|
+
fresh = true;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
let activity: readonly ActivityRow[] = [];
|
|
449
|
+
if (fresh) {
|
|
450
|
+
const page = await request({ action: "activity.query", query: { search: "", method: "all", outcome: "all", origin: null, since: null, order: "newest", cursor: null, limit: 8 } });
|
|
451
|
+
if (page.ok && page.data.kind === "activity") activity = page.data.page.rows;
|
|
452
|
+
}
|
|
453
|
+
lastOutputs = readOutputs(outputsDirectory);
|
|
454
|
+
return snapshotItems(lastSnapshot, attempts, { confirmedAgeSeconds: confirmedAt === null ? null : Math.max(0, Math.floor((Date.now() - confirmedAt) / 1000)), fresh, detail: response.ok ? undefined : response.message }, activity, lastOutputs, notice);
|
|
455
|
+
},
|
|
456
|
+
onAction: async (id) => {
|
|
457
|
+
if (id === "refresh") { notice = null; return; } // the runner re-reads state after every action
|
|
458
|
+
if (id === "open-website") { await openBrowser(WEBSITE); return; }
|
|
459
|
+
if (id === "support:updates" || id === "support:paid") {
|
|
460
|
+
if (openingAccountPage) return;
|
|
461
|
+
const action = SUPPORT_ACTIONS.find((item) => item.kind === (id === "support:updates" ? "updates" : "support"));
|
|
462
|
+
if (action === undefined) return;
|
|
463
|
+
openingAccountPage = true;
|
|
464
|
+
notice = null;
|
|
465
|
+
try { await openAccountPage(action.url); }
|
|
466
|
+
catch { notice = "Could not open Accounts. Try again from the menu."; }
|
|
467
|
+
finally { openingAccountPage = false; }
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
if (id === "output:folder") {
|
|
471
|
+
const expected = lastOutputs.directory;
|
|
472
|
+
const fresh = (() => { try { return lstatSync(outputsDirectory, { bigint: true }); } catch { return null; } })();
|
|
473
|
+
if (expected !== null && fresh !== null && fresh.isDirectory() && !fresh.isSymbolicLink() && fresh.dev === expected.dev && fresh.ino === expected.ino) openPath(outputsDirectory, true);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
if (id.startsWith("output:open:") || id.startsWith("output:reveal:") || id.startsWith("output:copy:")) {
|
|
477
|
+
const entry = lastOutputs.entries[Number(id.slice(id.indexOf(":", 7) + 1))];
|
|
478
|
+
const path = entry === undefined ? null : validatedOutputPath(outputsDirectory, lastOutputs, entry.name);
|
|
479
|
+
if (path === null) { notice = "File changed or unavailable; refresh outputs"; return; }
|
|
480
|
+
notice = null;
|
|
481
|
+
if (id.startsWith("output:copy:")) copyText(path);
|
|
482
|
+
else openPath(path, id.startsWith("output:reveal:"));
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
if (id.startsWith("clip:")) { const command = CLI_COMMANDS[Number(id.slice(5))]; if (command !== undefined) copyText(command); return; }
|
|
486
|
+
const snapshot = lastSnapshot;
|
|
487
|
+
if (snapshot === null) return;
|
|
488
|
+
const parts = id.split(":");
|
|
489
|
+
const fail = (response: ControlResponse): void => { if (!response.ok) throw new Error(`ghostget-${response.code}`); };
|
|
490
|
+
if (id === "permission:enable") { fail(await request({ action: "permission.enable", expectedRevision: snapshot.policy.revision })); return; }
|
|
491
|
+
if (parts[0] === "approval" && parts.length === 3 && (parts[1] === "allow" || parts[1] === "deny")) {
|
|
492
|
+
const approval = snapshot.approvals.find((item) => item.id === parts[2]);
|
|
493
|
+
if (approval === undefined) return;
|
|
494
|
+
fail(await request({ action: "approval.decide", id: approval.id, digest: approval.digest, decision: parts[1] === "allow" ? "allow-once" : "deny" }));
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
if (parts[0] === "disconnect" && parts.length === 2) {
|
|
498
|
+
const account = snapshot.accounts.find((item) => item.id === parts[1]);
|
|
499
|
+
if (account === undefined) return;
|
|
500
|
+
fail(await request({ action: "connection.disconnect", id: account.id, expectedRevision: account.revision }));
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
if (parts[0] === "connect" && parts.length === 3) {
|
|
504
|
+
const provider = snapshot.connectionProviders.find((item) => item.id === parts[1]);
|
|
505
|
+
const choice = BROWSERS.find((item) => item.key === parts[2]);
|
|
506
|
+
if (provider === undefined || choice === undefined) return;
|
|
507
|
+
const response = await request({ action: "connection.begin", id: `${provider.id}-${randomUUID().slice(0, 8)}`, provider: provider.id, browser: choice.browser, profile: choice.profile, expectedRevision: null });
|
|
508
|
+
fail(response);
|
|
509
|
+
if (response.ok && response.data.kind === "connection") attempts.set(response.data.attemptId, { attemptId: response.data.attemptId, title: provider.title, status: "awaiting-sign-in", subject: null });
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
if (parts[0] === "reconnect" && parts.length === 4) {
|
|
513
|
+
const account = snapshot.accounts.find((item) => item.id === parts[1]);
|
|
514
|
+
const provider = snapshot.connectionProviders.find((item) => item.id === parts[2]);
|
|
515
|
+
const choice = BROWSERS.find((item) => item.key === parts[3]);
|
|
516
|
+
if (account === undefined || provider === undefined || choice === undefined) return;
|
|
517
|
+
const response = await request({ action: "connection.begin", id: account.id, provider: provider.id, browser: choice.browser, profile: choice.profile, expectedRevision: account.revision });
|
|
518
|
+
fail(response);
|
|
519
|
+
if (response.ok && response.data.kind === "connection") attempts.set(response.data.attemptId, { attemptId: response.data.attemptId, title: provider.title, status: "awaiting-sign-in", subject: null });
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
if (parts[0] === "attempt" && parts.length === 3) {
|
|
523
|
+
const attempt = attempts.get(parts[2]!);
|
|
524
|
+
if (attempt === undefined) return;
|
|
525
|
+
if (parts[1] === "cancel") { await request({ action: "connection.cancel", attemptId: attempt.attemptId }); attempts.delete(attempt.attemptId); return; }
|
|
526
|
+
if (parts[1] === "verify") {
|
|
527
|
+
const response = await request({ action: "connection.verify", attemptId: attempt.attemptId }, 75_000);
|
|
528
|
+
if (!response.ok) { attempts.delete(attempt.attemptId); throw new Error(`ghostget-${response.code}`); }
|
|
529
|
+
if (response.data.kind === "connection" && response.data.status === "verified") attempts.set(attempt.attemptId, { ...attempt, status: "verified", subject: response.data.subject });
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
if (parts[1] === "commit") {
|
|
533
|
+
if (attempt.status !== "verified" || attempt.subject === null) return;
|
|
534
|
+
const response = await request({ action: "connection.commit", attemptId: attempt.attemptId, expectedSubject: attempt.subject });
|
|
535
|
+
attempts.delete(attempt.attemptId);
|
|
536
|
+
fail(response);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
if (parts[0] === "permission" && parts.length === 4 && (parts[1] === "allow" || parts[1] === "ask" || parts[1] === "deny")) {
|
|
542
|
+
const capability = snapshot.capabilities.find((item) => item.adapterId === parts[2] && item.operationId === parts[3]);
|
|
543
|
+
if (capability === undefined) return;
|
|
544
|
+
fail(await request({ action: "permission.set", adapterId: capability.adapterId, operationId: capability.operationId, accountId: snapshot.accountId, decision: parts[1], expectedRevision: snapshot.policy.revision, expectedCapabilityDigest: capability.digest }));
|
|
545
|
+
}
|
|
546
|
+
},
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** Delegate the product `menubar` command family to the shared lifecycle. */
|
|
551
|
+
export async function runMenubarCommand(args: readonly string[], environment: ControlEnvironment = process.env, output: Output): Promise<number> {
|
|
552
|
+
const rest = args.slice(1);
|
|
553
|
+
if (rest[0] === "--help" || rest[0] === "help" || rest[0] === "-h") {
|
|
554
|
+
output.stdout("Usage: ghostget menubar [start|stop|status|doctor|install|uninstall]\nRuns the shared menu-bar companion; install registers login startup.\n");
|
|
555
|
+
return 0;
|
|
556
|
+
}
|
|
557
|
+
if (rest.length > 1 || (rest[0] !== undefined && !["start", "stop", "status", "doctor", "install", "uninstall", "--foreground", "--background"].includes(rest[0]))) {
|
|
558
|
+
output.stderr("Usage: ghostget menubar [start|stop|status|doctor|install|uninstall]\n");
|
|
559
|
+
return 1;
|
|
560
|
+
}
|
|
561
|
+
const mapped = rest[0] === "--background" ? [] : rest;
|
|
562
|
+
const cli = fileURLToPath(new URL("../cli.ts", import.meta.url));
|
|
563
|
+
const binary = environment.GHOSTGET_MENUBAR;
|
|
564
|
+
return await handleCompanionCommand(companionOptions(environment), {
|
|
565
|
+
args: mapped,
|
|
566
|
+
...(binary === undefined || binary === "" ? {} : { binary }),
|
|
567
|
+
foreground: { executable: process.execPath, args: [cli, "menubar", "--foreground"] },
|
|
568
|
+
write: (result) => output.stdout(`${JSON.stringify(result)}\n`),
|
|
569
|
+
});
|
|
570
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Ghost status mark (U+1F47B), pre-rendered at 32px from Twemoji
|
|
2
|
+
* (CC-BY 4.0 — https://twemoji.twitter.com). macOS renders the emoji title
|
|
3
|
+
* natively; icon-only tray surfaces use this art. */
|
|
4
|
+
export const TRAY_ICON = { width: 32, height: 32, rgba: "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" } as const;
|
|
@@ -10,8 +10,15 @@ export function automationPermissionOperation(operation: string): string {
|
|
|
10
10
|
throw new Error("This messaging action has no admitted host permission.");
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
const AUTOMATION_SEND_KINDS: Readonly<Record<AutomationProviderId, readonly string[]>> = Object.freeze({
|
|
14
|
+
imessage: Object.freeze(["text", "attachment", "reaction", "sticker", "link", "poll"]),
|
|
15
|
+
whatsapp: Object.freeze(["text", "attachment", "reaction", "sticker", "link", "poll"]),
|
|
16
|
+
// The pinned Beeper local transport admits exact text sends only.
|
|
17
|
+
beeper: Object.freeze(["text"]),
|
|
18
|
+
});
|
|
19
|
+
|
|
13
20
|
export function automationOperationDefinitions(provider: AutomationProviderId): readonly LocalCliPluginOperationDefinitionV1[] {
|
|
14
|
-
const names = ["messaging.automation.read", ...(provider === "whatsapp" ? ["messaging.automation.sync"] : []), ...[
|
|
21
|
+
const names = ["messaging.automation.read", ...(provider === "whatsapp" ? ["messaging.automation.sync"] : []), ...AUTOMATION_SEND_KINDS[provider].map(kind => `messaging.automation.send.${kind}`)];
|
|
15
22
|
return names.map(name => ({
|
|
16
23
|
name, contractVersion: 1, risk: name.endsWith(".read") ? "R1" : name.endsWith(".sync") ? "R2" : "R3",
|
|
17
24
|
input: { properties: {}, required: [] },
|
|
@@ -27,3 +34,4 @@ export function automationOperationDefinitions(provider: AutomationProviderId):
|
|
|
27
34
|
// pinned runtime. The owner permission kernel stays outside provider plugins.
|
|
28
35
|
export function loadImsgAutomationRuntime() { return import("./providers/imessage-automation"); }
|
|
29
36
|
export function loadWhatsAppAutomationRuntime() { return import("./providers/whatsapp-automation"); }
|
|
37
|
+
export function loadBeeperAutomationRuntime() { return import("./providers/beeper-automation"); }
|