conductor-remote 1.9.0 → 1.9.1

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.
@@ -0,0 +1,186 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import net from 'node:net';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ /**
7
+ * Client for Conductor's sidecar IPC — the `conductor-runtime sidecar` process
8
+ * that owns every live `claude`/`codex` agent. The desktop app drives it over a
9
+ * unix socket speaking newline-delimited JSON-RPC 2.0; we speak the same
10
+ * protocol as an additional local client.
11
+ *
12
+ * This is by far the most precise write path: prompts are addressed by
13
+ * `sessionId`, so there is no window focus or AppleScript involved and the app's
14
+ * UI updates correctly because it's the real dispatch path. It is also the most
15
+ * update-fragile surface (a private, versioned IPC), which is why it lives
16
+ * behind the Actuator interface and falls back to AppleScript when the socket
17
+ * can't be reached (see writes.ts).
18
+ *
19
+ * Reverse-engineered from conductor-runtime (Conductor 0.76):
20
+ * - socket: `$TMPDIR/conductor-sidecar-v2-<sidecarPid>.sock`
21
+ * - transport: newline-delimited JSON-RPC 2.0 (`{jsonrpc,id,method,params}`)
22
+ * - local auth: the literal `{ userId: 'local', auth: 'local' }`
23
+ * - send prompt: method `query`, params `{ type: 'sendUserMessageRequest', … }`
24
+ * - safe read: method `contextUsage`, params `{ sessionId, …auth }`
25
+ *
26
+ * Stale socket files from exited sidecars linger in `$TMPDIR`, so discovery is
27
+ * connectivity-based: we try candidates newest-first and use the first that
28
+ * actually accepts a connection.
29
+ */
30
+ const SOCKET_PREFIX = 'conductor-sidecar-v2-';
31
+ const LOCAL_AUTH = { userId: 'local', auth: 'local' };
32
+ /** Candidate sidecar socket paths in `$TMPDIR`, newest mtime first. */
33
+ function listSidecarSockets() {
34
+ const dir = os.tmpdir();
35
+ let names;
36
+ try {
37
+ names = fs.readdirSync(dir);
38
+ }
39
+ catch {
40
+ return [];
41
+ }
42
+ const found = [];
43
+ for (const name of names) {
44
+ if (!(name.startsWith(SOCKET_PREFIX) && name.endsWith('.sock')))
45
+ continue;
46
+ const p = path.join(dir, name);
47
+ try {
48
+ const st = fs.statSync(p);
49
+ if (st.isSocket())
50
+ found.push({ p, m: st.mtimeMs });
51
+ }
52
+ catch {
53
+ // vanished between readdir and stat — skip
54
+ }
55
+ }
56
+ return found.sort((a, b) => b.m - a.m).map(x => x.p);
57
+ }
58
+ function isConnRefused(err) {
59
+ const code = err?.code;
60
+ return code === 'ECONNREFUSED' || code === 'ENOENT';
61
+ }
62
+ /** One JSON-RPC request/response over a fresh connection to a specific socket. */
63
+ function rpcOnSocket(socketPath, method, params, timeoutMs) {
64
+ return new Promise((resolve, reject) => {
65
+ const sock = net.connect(socketPath);
66
+ const id = 1;
67
+ let buf = '';
68
+ let settled = false;
69
+ const finish = (err, val) => {
70
+ if (settled)
71
+ return;
72
+ settled = true;
73
+ clearTimeout(timer);
74
+ sock.destroy();
75
+ if (err)
76
+ reject(err);
77
+ else
78
+ resolve(val);
79
+ };
80
+ const timer = setTimeout(() => finish(new Error(`sidecar RPC "${method}" timed out`)), timeoutMs);
81
+ sock.on('connect', () => {
82
+ sock.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`);
83
+ });
84
+ sock.on('data', chunk => {
85
+ buf += chunk.toString('utf8');
86
+ // The sidecar also pushes unsolicited notifications — ignore anything
87
+ // that isn't the response to our id.
88
+ let nl = buf.indexOf('\n');
89
+ while (nl >= 0) {
90
+ const line = buf.slice(0, nl).trim();
91
+ buf = buf.slice(nl + 1);
92
+ nl = buf.indexOf('\n');
93
+ if (!line)
94
+ continue;
95
+ let msg;
96
+ try {
97
+ msg = JSON.parse(line);
98
+ }
99
+ catch {
100
+ continue;
101
+ }
102
+ if (msg.id === id && ('result' in msg || 'error' in msg)) {
103
+ if (msg.error)
104
+ return finish(new Error(msg.error.message || `sidecar RPC error ${msg.error.code}`));
105
+ return finish(null, msg.result);
106
+ }
107
+ }
108
+ });
109
+ sock.on('error', e => finish(e instanceof Error ? e : new Error(String(e))));
110
+ sock.on('close', () => finish(new Error('sidecar closed the connection before responding')));
111
+ });
112
+ }
113
+ /** Try each candidate socket, skipping stale ones (connection refused). */
114
+ async function rpc(method, params, timeoutMs = 8000) {
115
+ const candidates = listSidecarSockets();
116
+ if (!candidates.length)
117
+ throw new Error('no Conductor sidecar socket found — is Conductor running?');
118
+ let lastErr;
119
+ for (const socketPath of candidates) {
120
+ try {
121
+ return await rpcOnSocket(socketPath, method, params, timeoutMs);
122
+ }
123
+ catch (err) {
124
+ lastErr = err;
125
+ if (isConnRefused(err))
126
+ continue; // stale socket file, try the next
127
+ throw err; // a real RPC/protocol error — surface it, don't mask
128
+ }
129
+ }
130
+ throw lastErr instanceof Error ? lastErr : new Error('sidecar unreachable');
131
+ }
132
+ /** Resolve a connectable sidecar socket, or null. Used to decide write strategy. */
133
+ export function sidecarSocket(timeoutMs = 800) {
134
+ const candidates = listSidecarSockets();
135
+ return (async () => {
136
+ for (const p of candidates) {
137
+ const ok = await canConnect(p, timeoutMs);
138
+ if (ok)
139
+ return p;
140
+ }
141
+ return null;
142
+ })();
143
+ }
144
+ function canConnect(socketPath, timeoutMs) {
145
+ return new Promise(resolve => {
146
+ const sock = net.connect(socketPath);
147
+ let done = false;
148
+ const finish = (ok) => {
149
+ if (done)
150
+ return;
151
+ done = true;
152
+ clearTimeout(t);
153
+ sock.destroy();
154
+ resolve(ok);
155
+ };
156
+ const t = setTimeout(() => finish(false), timeoutMs);
157
+ sock.on('connect', () => finish(true));
158
+ sock.on('error', () => finish(false));
159
+ });
160
+ }
161
+ /** True when a precise (sidecar) send path is currently reachable. */
162
+ export async function sidecarAvailable() {
163
+ return (await sidecarSocket()) !== null;
164
+ }
165
+ /**
166
+ * Deliver a prompt to a specific session — the real send path, precisely
167
+ * targeted. Resolves once the sidecar has accepted (queued/sent) the message.
168
+ */
169
+ export async function sidecarSendUserMessage(sessionId, text) {
170
+ await rpc('query', {
171
+ type: 'sendUserMessageRequest',
172
+ ...LOCAL_AUTH,
173
+ sessionId,
174
+ id: randomUUID(),
175
+ message: text,
176
+ agentMessage: text,
177
+ deliveryMode: 'default'
178
+ });
179
+ }
180
+ /**
181
+ * Read a session's context usage. Pure read — no turn is triggered — so it's the
182
+ * safe way to prove the socket + auth + framing work end to end.
183
+ */
184
+ export function sidecarContextUsage(sessionId) {
185
+ return rpc('contextUsage', { ...LOCAL_AUTH, sessionId }, 5000);
186
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Conductor stores each turn's raw Claude Code SDK stream JSON in
3
+ * `session_messages.content`. User-typed prompts are stored as plain text.
4
+ * This turns a row into a compact, phone-renderable entry.
5
+ */
6
+ function textFromBlocks(blocks) {
7
+ const parts = [];
8
+ let tool;
9
+ for (const b of blocks) {
10
+ if (b.type === 'text' && b.text)
11
+ parts.push(b.text);
12
+ else if (b.type === 'thinking' && typeof b.text === 'string')
13
+ parts.push(b.text);
14
+ else if (b.type === 'tool_use') {
15
+ tool = b.name;
16
+ const inputStr = summarizeToolInput(b.input);
17
+ parts.push(inputStr ? `▸ ${b.name}: ${inputStr}` : `▸ ${b.name}`);
18
+ }
19
+ else if (b.type === 'tool_result') {
20
+ parts.push(summarizeToolResult(b.content));
21
+ }
22
+ }
23
+ return { text: parts.join('\n').trim(), tool };
24
+ }
25
+ function summarizeToolInput(input) {
26
+ if (!input || typeof input !== 'object')
27
+ return '';
28
+ const o = input;
29
+ const key = o.command ?? o.file_path ?? o.path ?? o.pattern ?? o.description ?? o.prompt;
30
+ const s = typeof key === 'string' ? key : JSON.stringify(o);
31
+ return s.length > 140 ? `${s.slice(0, 140)}…` : s;
32
+ }
33
+ function summarizeToolResult(content) {
34
+ let s = '';
35
+ if (typeof content === 'string')
36
+ s = content;
37
+ else if (Array.isArray(content)) {
38
+ s = content
39
+ .map(c => (c && typeof c === 'object' && 'text' in c ? String(c.text) : ''))
40
+ .join('');
41
+ }
42
+ s = s.trim();
43
+ if (!s)
44
+ return '↳ (result)';
45
+ return `↳ ${s.length > 200 ? `${s.slice(0, 200)}…` : s}`;
46
+ }
47
+ export function parseMessage(row) {
48
+ const queued = row.queue_order !== null && row.sent_at === null;
49
+ const base = { id: row.id, rowid: row.rowid, ts: row.created_at, queued };
50
+ const content = row.content ?? '';
51
+ // Plain user prompt (not SDK JSON).
52
+ if (!content.startsWith('{')) {
53
+ if (!content.trim())
54
+ return null;
55
+ return { ...base, role: 'user', text: content };
56
+ }
57
+ let parsed;
58
+ try {
59
+ parsed = JSON.parse(content);
60
+ }
61
+ catch {
62
+ return { ...base, role: 'system', text: content.slice(0, 200) };
63
+ }
64
+ // Skip pure bookkeeping frames (token accounting, etc.).
65
+ if (parsed.type === 'system' && parsed.subtype === 'thinking_tokens')
66
+ return null;
67
+ if (parsed.type === 'result')
68
+ return null;
69
+ const blocks = parsed.message?.content;
70
+ if (Array.isArray(blocks)) {
71
+ const { text, tool } = textFromBlocks(blocks);
72
+ if (!text)
73
+ return null;
74
+ if (tool)
75
+ return { ...base, role: 'tool', text, tool };
76
+ if (parsed.type === 'user')
77
+ return { ...base, role: 'user', text };
78
+ return { ...base, role: 'assistant', text };
79
+ }
80
+ if (parsed.type === 'system')
81
+ return null;
82
+ return { ...base, role: 'system', text: content.slice(0, 200) };
83
+ }
@@ -0,0 +1,144 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { sidecarAvailable, sidecarSendUserMessage } from "./sidecar.js";
4
+ const exec = promisify(execFile);
5
+ /**
6
+ * The sidecar IPC path — the precise, per-session write. Delivers straight to
7
+ * `sessionId` over Conductor's own dispatch socket (see sidecar.ts), so it needs
8
+ * no window focus and the app UI reflects the turn correctly.
9
+ *
10
+ * Opt-in (WRITE_STRATEGY=sidecar) because it speaks a private, versioned IPC and
11
+ * hasn't been validated by an automated live send (that would inject a prompt
12
+ * into a running agent). It is the intended default once you've confirmed it on
13
+ * your setup.
14
+ */
15
+ export class SidecarActuator {
16
+ name = 'sidecar';
17
+ caveat = 'Delivered straight to the target session over Conductor’s dispatch socket — precise per-workspace targeting.';
18
+ precise = true;
19
+ available() {
20
+ return sidecarAvailable();
21
+ }
22
+ async send(target, text) {
23
+ const sessionId = target.sessionId ?? target.workspace.active_session_id;
24
+ if (!sessionId)
25
+ return { ok: false, strategy: this.name, error: 'no session id to target' };
26
+ try {
27
+ await sidecarSendUserMessage(sessionId, text);
28
+ return { ok: true, strategy: this.name };
29
+ }
30
+ catch (err) {
31
+ return { ok: false, strategy: this.name, error: err instanceof Error ? err.message : String(err) };
32
+ }
33
+ }
34
+ }
35
+ // AppleScript steps that focus a workspace via Conductor's command palette
36
+ // (Esc → Cmd+K → branch → Enter). The branch is read from RELAY_WS_QUERY at run
37
+ // time to dodge AppleScript escaping; the timing delays are load-bearing.
38
+ const FOCUS_WORKSPACE_STEPS = `
39
+ key code 53
40
+ delay 0.25
41
+ keystroke "k" using {command down}
42
+ delay 0.7
43
+ keystroke (system attribute "RELAY_WS_QUERY")
44
+ delay 0.9
45
+ key code 36
46
+ delay 1.3`;
47
+ /** Conductor's command palette matches workspaces by branch — its unique key. A
48
+ * looser query (directory name) can match a command like unarchive, so prefer
49
+ * branch and only fall back when it's absent. */
50
+ function focusQuery(ws) {
51
+ return ws.branch || ws.workspace_name || ws.directory_name || '';
52
+ }
53
+ /**
54
+ * Drives Conductor's real send path via macOS Accessibility (AppleScript): focus
55
+ * the target workspace, paste the prompt, press Enter. Uses whatever model /
56
+ * permission mode the session already has (zero risk of altering the agent),
57
+ * which is why it's the default.
58
+ *
59
+ * Precise targeting comes from focusing the intended workspace first through
60
+ * Conductor's command palette (Cmd+K → branch → Enter) before pasting, so the
61
+ * prompt lands in the right session regardless of what was focused — no private
62
+ * IPC and nothing to rebreak on a Conductor update (unlike the sidecar).
63
+ */
64
+ export class AppleScriptActuator {
65
+ name = 'applescript';
66
+ caveat = 'Focuses the target workspace via the command palette (Cmd+K) before sending.';
67
+ precise = true;
68
+ async send(target, text) {
69
+ const navQuery = focusQuery(target.workspace);
70
+ const navigate = navQuery ? FOCUS_WORKSPACE_STEPS : '';
71
+ // Paste beats keystroke for long/multibyte prompts. Stash the clipboard,
72
+ // focus the target workspace, paste, send, and restore.
73
+ const script = `
74
+ set savedClipboard to the clipboard
75
+ tell application "Conductor" to activate
76
+ delay 0.4
77
+ tell application "System Events"${navigate}
78
+ set the clipboard to (do shell script "cat" & " " & quoted form of (system attribute "RELAY_PROMPT_FILE"))
79
+ keystroke "v" using {command down}
80
+ delay 0.15
81
+ key code 36
82
+ end tell
83
+ delay 0.1
84
+ set the clipboard to savedClipboard
85
+ `.trim();
86
+ // Pass the prompt via a temp file + env to avoid AppleScript string escaping.
87
+ const os = await import('node:os');
88
+ const fs = await import('node:fs/promises');
89
+ const path = await import('node:path');
90
+ const tmp = path.join(os.tmpdir(), `relay-prompt-${process.pid}-${Date.now()}.txt`);
91
+ await fs.writeFile(tmp, text, 'utf8');
92
+ try {
93
+ await exec('osascript', ['-e', script], {
94
+ env: { ...process.env, RELAY_PROMPT_FILE: tmp, RELAY_WS_QUERY: navQuery },
95
+ timeout: 15000
96
+ });
97
+ return { ok: true, strategy: this.name };
98
+ }
99
+ catch (err) {
100
+ return {
101
+ ok: false,
102
+ strategy: this.name,
103
+ error: err instanceof Error ? err.message : String(err)
104
+ };
105
+ }
106
+ finally {
107
+ await fs.rm(tmp, { force: true }).catch(() => undefined);
108
+ }
109
+ }
110
+ }
111
+ /**
112
+ * Open a new chat in the target workspace — Conductor's "New chat, same files"
113
+ * (Cmd+T). Focuses the workspace first (command palette → branch), then Cmd+T; the
114
+ * caller detects the freshly-created session id from the DB.
115
+ */
116
+ export async function newChat(workspace) {
117
+ const navQuery = focusQuery(workspace);
118
+ if (!navQuery)
119
+ return { ok: false, strategy: 'applescript', error: 'workspace has no branch to focus' };
120
+ const script = `
121
+ tell application "Conductor" to activate
122
+ delay 0.4
123
+ tell application "System Events"${FOCUS_WORKSPACE_STEPS}
124
+ keystroke "t" using {command down}
125
+ end tell`.trim();
126
+ try {
127
+ await exec('osascript', ['-e', script], {
128
+ env: { ...process.env, RELAY_WS_QUERY: navQuery },
129
+ timeout: 15000
130
+ });
131
+ return { ok: true, strategy: 'applescript' };
132
+ }
133
+ catch (err) {
134
+ return { ok: false, strategy: 'applescript', error: err instanceof Error ? err.message : String(err) };
135
+ }
136
+ }
137
+ export function pickActuator(strategy) {
138
+ return strategy === 'sidecar' ? new SidecarActuator() : new AppleScriptActuator();
139
+ }
140
+ /** Effective actuator description for the UI, factoring in runtime availability. */
141
+ export async function describeActuator(actuator) {
142
+ const available = actuator.available ? await actuator.available().catch(() => false) : true;
143
+ return { name: actuator.name, caveat: actuator.caveat, precise: actuator.precise, available };
144
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conductor-remote",
3
- "version": "1.9.0",
3
+ "version": "1.9.1",
4
4
  "type": "module",
5
5
  "packageManager": "yarn@4.15.0",
6
6
  "description": "Phone control panel for local Conductor agents. Reads ride SQLite + git; prompts ride Conductor's own dispatch path.",
@@ -29,9 +29,8 @@
29
29
  "bin": "bin/cli.js",
30
30
  "files": [
31
31
  "bin",
32
- "src",
33
- "scripts",
34
- "dist"
32
+ "dist",
33
+ "dist-node"
35
34
  ],
36
35
  "publishConfig": {
37
36
  "access": "public",
@@ -43,6 +42,7 @@
43
42
  "dev": "node scripts/dev.ts",
44
43
  "dev:web": "vite",
45
44
  "build": "vite build",
45
+ "build:node": "tsc -p tsconfig.build.json",
46
46
  "preview": "yarn build && yarn start",
47
47
  "gen:icons": "node scripts/gen-icons.ts",
48
48
  "deploy": "yarn build && node bin/cli.js service install",
@@ -52,7 +52,7 @@
52
52
  "fix": "biome check . --fix",
53
53
  "verify": "yarn typecheck && yarn lint",
54
54
  "release": "semantic-release",
55
- "prepack": "yarn build",
55
+ "prepack": "yarn build && yarn build:node",
56
56
  "postinstall": "husky || true"
57
57
  },
58
58
  "devDependencies": {
package/scripts/dev.ts DELETED
@@ -1,75 +0,0 @@
1
- import { spawn } from 'node:child_process'
2
- import net from 'node:net'
3
-
4
- // Run the Vite dev server (HMR PWA) and the relay (API + reads) together.
5
- // Vite proxies /api → the relay so the phone hits a single origin.
6
- //
7
- // Dev-only per-workspace ports: inside a Conductor workspace, $CONDUCTOR_PORT is
8
- // unique per workspace, so several `yarn dev`s can run concurrently. Vite (the
9
- // origin a browser/phone opens) takes that port; the relay gets a free ephemeral
10
- // port and Vite proxies /api to it on loopback. Outside Conductor, fall back to
11
- // the classic 5173 (web) / 8787 (relay) pair. Prod (`yarn start`/`deploy`/the
12
- // LaunchAgent) never runs through here, so its Tailscale bind and RELAY_PORT
13
- // default stay untouched — this clutters neither the prod path nor the build.
14
-
15
- /** Ask the OS for an unused TCP port so a Conductor workspace's relay never collides. */
16
- function freePort(): Promise<number> {
17
- return new Promise((resolve, reject) => {
18
- const srv = net.createServer()
19
- srv.once('error', reject)
20
- srv.listen(0, '127.0.0.1', () => {
21
- const { port } = srv.address() as net.AddressInfo
22
- srv.close(() => resolve(port))
23
- })
24
- })
25
- }
26
-
27
- const conductorPort = Number(process.env.CONDUCTOR_PORT) || 0
28
- const webPort = conductorPort || 5173
29
- // Loopback bind + Vite proxy target must match; only prod auto-binds the Tailscale NIC.
30
- const relayPort = conductorPort ? await freePort() : 8787
31
-
32
- const procs = [
33
- {
34
- name: 'web ',
35
- cmd: 'yarn',
36
- args: ['dev:web'],
37
- color: '\x1b[35m',
38
- env: { ...process.env, WEB_PORT: String(webPort), RELAY_PORT: String(relayPort) }
39
- },
40
- {
41
- name: 'relay',
42
- cmd: 'node',
43
- args: ['--watch', 'bin/cli.js'],
44
- color: '\x1b[36m',
45
- env: { ...process.env, RELAY_PORT: String(relayPort), RELAY_HOST: process.env.RELAY_HOST ?? '127.0.0.1' }
46
- }
47
- ]
48
-
49
- const children = procs.map(p => {
50
- const child = spawn(p.cmd, p.args, { stdio: ['inherit', 'pipe', 'pipe'], env: p.env })
51
- const tag = `${p.color}[${p.name}]\x1b[0m `
52
- const pipe = (stream: NodeJS.ReadableStream) => {
53
- let buf = ''
54
- stream.on('data', (d: Buffer) => {
55
- buf += d.toString()
56
- let nl = buf.indexOf('\n')
57
- while (nl >= 0) {
58
- console.log(tag + buf.slice(0, nl))
59
- buf = buf.slice(nl + 1)
60
- nl = buf.indexOf('\n')
61
- }
62
- })
63
- }
64
- pipe(child.stdout)
65
- pipe(child.stderr)
66
- return child
67
- })
68
-
69
- const shutdown = () => {
70
- for (const c of children) c.kill('SIGTERM')
71
- process.exit(0)
72
- }
73
- process.on('SIGINT', shutdown)
74
- process.on('SIGTERM', shutdown)
75
- for (const c of children) c.on('exit', shutdown)
@@ -1,42 +0,0 @@
1
- // ── Conductor IPC reconnaissance snippet ──────────────────────────────────
2
- //
3
- // PREREQUISITE (read FINDINGS.md first): this only works if Conductor's WKWeb
4
- // view is inspectable. On the shipped build it is NOT — the app is hardened/
5
- // notarized without `get-task-allow`, so Safari's Develop menu won't list it
6
- // and there is no console to paste this into. Injection is therefore blocked;
7
- // the relay drives writes via Accessibility instead. Keep this snippet for the
8
- // case where a future build ships with devtools enabled.
9
- //
10
- // IF a console is available (Safari ▸ Develop ▸ [Conductor] ▸ [webview]):
11
- // paste this, then perform the action you want to reverse-engineer (send a
12
- // prompt, approve a tool, stop a session). Every Tauri IPC call is logged with
13
- // its command name + payload — that is the undocumented write API.
14
-
15
- ;(() => {
16
- const internals = window.__TAURI_INTERNALS__
17
- if (!internals || typeof internals.invoke !== 'function') {
18
- console.warn('[recon] __TAURI_INTERNALS__.invoke not found — not a Tauri webview or not exposed.')
19
- return
20
- }
21
- if (internals.__reconWrapped) {
22
- console.info('[recon] already wrapped.')
23
- return
24
- }
25
- const original = internals.invoke.bind(internals)
26
- const log = []
27
- window.__reconLog = log
28
- internals.invoke = (cmd, payload, options) => {
29
- // Stock Tauri plugin traffic is noise; Conductor's own commands are the signal.
30
- const interesting = !String(cmd).startsWith('plugin:')
31
- const record = { t: new Date().toISOString(), cmd, payload }
32
- if (interesting) {
33
- log.push(record)
34
- console.info('%c[INVOKE]', 'color:#4a9eff', cmd, payload)
35
- }
36
- return original(cmd, payload, options)
37
- }
38
- internals.__reconWrapped = true
39
- console.info(
40
- '[recon] invoke wrapped. Reproduce an action, then run: copy(JSON.stringify(window.__reconLog, null, 2))'
41
- )
42
- })()
@@ -1,22 +0,0 @@
1
- import { readFile, writeFile } from 'node:fs/promises'
2
- import path from 'node:path'
3
- import sharp from 'sharp'
4
-
5
- // Rasterize web/public/icon.svg into the PNG sizes the PWA manifest + iOS need.
6
- const pub = path.join(import.meta.dirname, '..', 'web', 'public')
7
- const svg = await readFile(path.join(pub, 'icon.svg'))
8
-
9
- const targets: { name: string; size: number; background?: string }[] = [
10
- { name: 'icon-192.png', size: 192 },
11
- { name: 'icon-512.png', size: 512 },
12
- { name: 'icon-maskable-512.png', size: 512 },
13
- { name: 'apple-touch-icon.png', size: 180, background: '#0a0b0e' }
14
- ]
15
-
16
- for (const t of targets) {
17
- let pipe = sharp(svg, { density: 384 }).resize(t.size, t.size, { fit: 'contain' })
18
- if (t.background) pipe = pipe.flatten({ background: t.background })
19
- const out = await pipe.png().toBuffer()
20
- await writeFile(path.join(pub, t.name), out)
21
- console.info(`wrote ${t.name} (${t.size}px)`)
22
- }