@materialwebunofficial/antigravity-themes 0.0.0-stage → 1.0.4
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 +201 -0
- package/NOTICE +30 -0
- package/README.md +294 -2
- package/bin/cli.mjs +324 -0
- package/lib/autostart.mjs +90 -0
- package/lib/bundle.mjs +73 -0
- package/lib/injector.mjs +176 -0
- package/lib/paths.mjs +35 -0
- package/package.json +40 -4
- package/src/code-tools.js +137 -0
- package/src/context-card.js +542 -0
- package/src/loading-indicator.js +74 -0
- package/src/progress-indicators.js +98 -0
- package/src/theme-host.js +904 -0
- package/src/turn-tokens.js +115 -0
- package/themes/index.json +27 -0
- package/themes/matte.css +588 -0
- package/themes/md3e-icons.css +244 -0
- package/themes/md3e-tokens.css +834 -0
- package/themes/md3e.css +1400 -0
- package/themes/nordic.css +370 -0
- package/themes/paper.css +414 -0
- package/vendor/md3e/LICENSE +176 -0
- package/vendor/md3e/NOTICE +686 -0
- package/vendor/md3e/Roboto-OFL.txt +93 -0
- package/vendor/md3e/SOURCE.txt +10 -0
- package/vendor/md3e/components.js +4682 -0
- package/vendor/md3e/roboto-variable.woff2 +0 -0
package/bin/cli.mjs
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// antigravity-themes: installs, runs and removes the themes. See README.md.
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { appendFileSync, cpSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, watch as watchFiles, writeFileSync } from 'node:fs';
|
|
5
|
+
import http from 'node:http';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { ROOT, pageScript, REMOVE_SCRIPT, PROBE_SCRIPT, selectScript } from '../lib/bundle.mjs';
|
|
8
|
+
import { readPort, evaluateEverywhere, watch } from '../lib/injector.mjs';
|
|
9
|
+
import { installDir, stateFile, logFile, portFile, appResourcesDir } from '../lib/paths.mjs';
|
|
10
|
+
import { launch, writeAutostart, removeAutostart, hasAutostart, autostartFile } from '../lib/autostart.mjs';
|
|
11
|
+
|
|
12
|
+
const NAME = 'antigravity-themes';
|
|
13
|
+
const pkg = JSON.parse(readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
|
|
14
|
+
// npx.cmd: PowerShell's default script policy on Windows blocks plain npx (npx.ps1)
|
|
15
|
+
const NPX = `${process.platform === 'win32' ? 'npx.cmd' : 'npx'} -y ${pkg.name}`;
|
|
16
|
+
const [command = 'help', ...args] = process.argv.slice(2);
|
|
17
|
+
const has = (flag) => args.includes(flag);
|
|
18
|
+
const say = (line = '') => console.log(line);
|
|
19
|
+
function fail(message) {
|
|
20
|
+
console.error(`${NAME}: ${message}`);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (typeof WebSocket !== 'function') fail(`Node.js 22 or newer is needed (this is ${process.version}). Get it from https://nodejs.org`);
|
|
25
|
+
|
|
26
|
+
// ---- Background process ------------------------------------------------------
|
|
27
|
+
// It answers on 127.0.0.1 with a random token, written to run.json: other
|
|
28
|
+
// commands find and stop it through that, never by process id (an id can be
|
|
29
|
+
// reused by an unrelated process after a reboot).
|
|
30
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
31
|
+
function readState() {
|
|
32
|
+
try { return JSON.parse(readFileSync(stateFile(), 'utf8')); } catch { return null; }
|
|
33
|
+
}
|
|
34
|
+
async function running() {
|
|
35
|
+
const state = readState();
|
|
36
|
+
if (!state) return null;
|
|
37
|
+
try {
|
|
38
|
+
const res = await fetch(`http://127.0.0.1:${state.port}/ping?token=${state.token}`, { signal: AbortSignal.timeout(1000) });
|
|
39
|
+
if (res.ok) return state;
|
|
40
|
+
} catch { /* not answering */ }
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function stopBackground() {
|
|
45
|
+
const state = await running();
|
|
46
|
+
if (!state) return false;
|
|
47
|
+
try {
|
|
48
|
+
await fetch(`http://127.0.0.1:${state.port}/stop?token=${state.token}`, { method: 'POST', signal: AbortSignal.timeout(2000) });
|
|
49
|
+
} catch { /* it exits while answering */ }
|
|
50
|
+
for (let i = 0; i < 30 && await running(); i++) await sleep(100);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function startBackground(cli) {
|
|
55
|
+
mkdirSync(installDir(), { recursive: true });
|
|
56
|
+
launch(process.execPath, cli);
|
|
57
|
+
for (let i = 0; i < 100; i++) {
|
|
58
|
+
const state = await running();
|
|
59
|
+
if (state) return state;
|
|
60
|
+
await sleep(100);
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function run() {
|
|
66
|
+
const other = await running();
|
|
67
|
+
if (other) {
|
|
68
|
+
say(`Already running (process ${other.pid}).`);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const token = randomUUID();
|
|
72
|
+
const control = http.createServer((req, res) => {
|
|
73
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
74
|
+
if (url.searchParams.get('token') !== token) { res.writeHead(403).end(); return; }
|
|
75
|
+
if (url.pathname === '/ping') { res.end(pkg.version); return; }
|
|
76
|
+
if (url.pathname === '/stop' && req.method === 'POST') { res.end('stopping'); setTimeout(() => process.exit(0), 50); return; }
|
|
77
|
+
res.writeHead(404).end();
|
|
78
|
+
});
|
|
79
|
+
await new Promise((resolve) => control.listen(0, '127.0.0.1', resolve));
|
|
80
|
+
mkdirSync(installDir(), { recursive: true });
|
|
81
|
+
writeFileSync(stateFile(), JSON.stringify({ pid: process.pid, port: control.address().port, token, version: pkg.version }));
|
|
82
|
+
process.on('exit', () => {
|
|
83
|
+
if (readState()?.token === token) rmSync(stateFile(), { force: true });
|
|
84
|
+
});
|
|
85
|
+
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => process.exit(0));
|
|
86
|
+
// Never two processes theming the same windows: when another one has
|
|
87
|
+
// written run.json since (the last one started wins), or the file is gone
|
|
88
|
+
// (uninstall), this one ends.
|
|
89
|
+
setInterval(() => {
|
|
90
|
+
if (readState()?.token !== token) process.exit(0);
|
|
91
|
+
}, 2000);
|
|
92
|
+
// keep the log short: it is only for troubleshooting
|
|
93
|
+
try { if (statSync(logFile()).size > 512 * 1024) rmSync(logFile()); } catch { /* none yet */ }
|
|
94
|
+
const log = (line) => {
|
|
95
|
+
const entry = `${new Date().toISOString()} ${line}`;
|
|
96
|
+
say(entry);
|
|
97
|
+
try { appendFileSync(logFile(), `${entry}\n`); } catch { /* uninstalling */ }
|
|
98
|
+
};
|
|
99
|
+
process.on('uncaughtException', (err) => {
|
|
100
|
+
log(err.stack);
|
|
101
|
+
process.exit(1);
|
|
102
|
+
});
|
|
103
|
+
log(`${NAME} ${pkg.version} running from ${ROOT}`);
|
|
104
|
+
const watcher = watch({ getSource: () => pageScript(ROOT), log });
|
|
105
|
+
if (has('--dev')) {
|
|
106
|
+
let timer;
|
|
107
|
+
for (const dir of ['src', 'themes', 'vendor']) {
|
|
108
|
+
watchFiles(path.join(ROOT, dir), { recursive: true }, () => {
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
timer = setTimeout(() => watcher.reload().catch((err) => log(err.message)), 150);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
log('--dev: reloading on changes to src/, themes/ and vendor/');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---- Commands -----------------------------------------------------------------
|
|
118
|
+
const RUNTIME = ['bin', 'lib', 'src', 'themes', 'vendor', 'package.json', 'README.md', 'LICENSE', 'NOTICE'];
|
|
119
|
+
|
|
120
|
+
const isThemed = (result) => Boolean(result.value && JSON.parse(result.value));
|
|
121
|
+
|
|
122
|
+
// Waits for the background process to reach the open windows, so that install
|
|
123
|
+
// can report the result itself. Throws when Antigravity is not open.
|
|
124
|
+
async function themedWindows() {
|
|
125
|
+
let results = [];
|
|
126
|
+
for (let i = 0; i < 40; i++) {
|
|
127
|
+
results = await evaluateEverywhere(PROBE_SCRIPT);
|
|
128
|
+
if (results.length && results.every(isThemed)) break;
|
|
129
|
+
await sleep(200);
|
|
130
|
+
}
|
|
131
|
+
return { themed: results.filter(isThemed).length, total: results.length };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function installNative() {
|
|
135
|
+
const { execSync } = await import('node:child_process');
|
|
136
|
+
const resDir = appResourcesDir();
|
|
137
|
+
const asarPath = path.join(resDir, 'app.asar');
|
|
138
|
+
const appDir = path.join(resDir, 'app');
|
|
139
|
+
if (!existsSync(asarPath) && !existsSync(appDir)) {
|
|
140
|
+
fail(`Could not find Antigravity resources at ${resDir}. Is Antigravity installed?`);
|
|
141
|
+
}
|
|
142
|
+
await stopBackground();
|
|
143
|
+
removeAutostart();
|
|
144
|
+
|
|
145
|
+
if (!existsSync(appDir)) {
|
|
146
|
+
say('Extracting Antigravity app.asar into resources/app...');
|
|
147
|
+
execSync(`npx -y @electron/asar extract "${asarPath}" "${appDir}"`, { stdio: 'inherit' });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const bundleCode = pageScript(ROOT);
|
|
151
|
+
const bundleDest = path.join(appDir, 'dist', 'theme-bundle.js');
|
|
152
|
+
writeFileSync(bundleDest, bundleCode, 'utf8');
|
|
153
|
+
|
|
154
|
+
const preloadPath = path.join(appDir, 'dist', 'preload.js');
|
|
155
|
+
let preloadContent = readFileSync(preloadPath, 'utf8');
|
|
156
|
+
const marker = '// Antigravity Native Theme Injection (Zero background processes)';
|
|
157
|
+
if (!preloadContent.includes(marker)) {
|
|
158
|
+
const patch = `\n\n${marker}\n(function() {\n try {\n const electron = require('electron');\n const path = require('path');\n const fs = require('fs');\n const b = path.join(__dirname, 'theme-bundle.js');\n if (fs.existsSync(b)) {\n const c = fs.readFileSync(b, 'utf8');\n if (electron.webFrame) electron.webFrame.executeJavaScript(c);\n }\n } catch (e) {}\n})();\n`;
|
|
159
|
+
appendFileSync(preloadPath, patch, 'utf8');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
say(`Installed natively into Antigravity (${resDir}).`);
|
|
163
|
+
say('No background process, no host server, and no startup entry.');
|
|
164
|
+
say('Done. The themes load permanently whenever Antigravity starts.');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function install() {
|
|
168
|
+
if (has('--native')) return installNative();
|
|
169
|
+
const dir = installDir();
|
|
170
|
+
const same = path.resolve(dir) === path.resolve(ROOT);
|
|
171
|
+
const wasRunning = await stopBackground();
|
|
172
|
+
if (!same) {
|
|
173
|
+
mkdirSync(dir, { recursive: true });
|
|
174
|
+
for (const entry of RUNTIME) {
|
|
175
|
+
const from = path.join(ROOT, entry);
|
|
176
|
+
if (!existsSync(from)) continue;
|
|
177
|
+
rmSync(path.join(dir, entry), { recursive: true, force: true });
|
|
178
|
+
cpSync(from, path.join(dir, entry), { recursive: true });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const cli = path.join(dir, 'bin', 'cli.mjs');
|
|
182
|
+
say(`Installed ${NAME} ${pkg.version} in ${dir}`);
|
|
183
|
+
const permanent = !has('--no-autostart');
|
|
184
|
+
if (permanent) {
|
|
185
|
+
say(`Starts at logon: ${writeAutostart(process.execPath, cli)}`);
|
|
186
|
+
} else {
|
|
187
|
+
removeAutostart();
|
|
188
|
+
say('Not started at logon (--no-autostart).');
|
|
189
|
+
}
|
|
190
|
+
const state = await startBackground(cli);
|
|
191
|
+
if (!state) fail(`could not start the background process, see ${logFile()}. On Windows it needs Windows Script Host (wscript.exe), and so does the logon entry.`);
|
|
192
|
+
say(`${wasRunning ? 'Restarted' : 'Started'} in the background (process ${state.pid}).`);
|
|
193
|
+
let windows = null;
|
|
194
|
+
try { windows = await themedWindows(); } catch { /* not open */ }
|
|
195
|
+
if (!windows?.total) say('Antigravity is not open: the theme appears when it starts.');
|
|
196
|
+
else if (!windows.themed) fail(`Antigravity is open, but no window got the theme; see ${logFile()}`);
|
|
197
|
+
else say(`Theme applied to ${windows.themed} of ${windows.total} Antigravity window(s).`);
|
|
198
|
+
say();
|
|
199
|
+
say('Choose a theme in Antigravity: the "Theme" menu in the title bar (Matte), or the menu button at the');
|
|
200
|
+
say('top left, then Theme (Material 3 Expressive). Ctrl+Shift+F12 cycles through the themes.');
|
|
201
|
+
say(`Remove everything with: ${NPX} uninstall`);
|
|
202
|
+
say();
|
|
203
|
+
say(permanent
|
|
204
|
+
? 'Done. This is permanent: the themes return at every logon and every Antigravity start. Nothing else to do.'
|
|
205
|
+
: `Done. Not permanent: after the next logon, run "${NPX} start".`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function uninstall() {
|
|
209
|
+
if (await stopBackground()) say('Stopped the background process.');
|
|
210
|
+
try {
|
|
211
|
+
const results = await evaluateEverywhere(REMOVE_SCRIPT);
|
|
212
|
+
say(`Removed the theme from ${results.filter((r) => !r.error).length} open window(s).`);
|
|
213
|
+
} catch {
|
|
214
|
+
say('Antigravity is not running: nothing to remove from its windows.');
|
|
215
|
+
}
|
|
216
|
+
const entry = removeAutostart();
|
|
217
|
+
if (entry) say(`Removed the logon entry: ${entry}`);
|
|
218
|
+
const dir = installDir();
|
|
219
|
+
if (path.basename(dir) === NAME && existsSync(dir)) {
|
|
220
|
+
rmSync(dir, { recursive: true, force: true });
|
|
221
|
+
say(`Deleted ${dir}`);
|
|
222
|
+
}
|
|
223
|
+
const appDir = path.join(appResourcesDir(), 'app');
|
|
224
|
+
if (existsSync(appDir)) {
|
|
225
|
+
rmSync(appDir, { recursive: true, force: true });
|
|
226
|
+
say(`Removed native installation at ${appDir}. Restored stock app.asar.`);
|
|
227
|
+
}
|
|
228
|
+
say('Done. Antigravity keeps your last theme choice in its own storage; it has no effect without this tool.');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function on() {
|
|
232
|
+
const results = await evaluateEverywhere(pageScript(ROOT));
|
|
233
|
+
const ok = results.filter((r) => !r.error).length;
|
|
234
|
+
say(`Applied to ${ok} window(s).${await running() ? '' : ' Without the background process it lasts until a window reloads; "install" makes it permanent.'}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function off() {
|
|
238
|
+
if (await stopBackground()) say('Stopped the background process.');
|
|
239
|
+
const results = await evaluateEverywhere(REMOVE_SCRIPT);
|
|
240
|
+
say(`Removed from ${results.filter((r) => !r.error).length} window(s).${hasAutostart() ? ' It comes back at the next logon; "uninstall" removes it for good.' : ''}`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function theme() {
|
|
244
|
+
const index = JSON.parse(readFileSync(path.join(ROOT, 'themes', 'index.json'), 'utf8'));
|
|
245
|
+
const key = args.find((a) => !a.startsWith('-'));
|
|
246
|
+
if (!key) {
|
|
247
|
+
say('Themes:');
|
|
248
|
+
for (const o of index.options) say(` ${o.key.padEnd(14)} ${o.name}`);
|
|
249
|
+
say(`\nSwitch with: ${NPX} theme <key>`);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (!index.options.some((o) => o.key === key)) fail(`no theme "${key}". Run "theme" to list them.`);
|
|
253
|
+
const results = await evaluateEverywhere(selectScript(key));
|
|
254
|
+
const ok = results.filter((r) => r.value === 'ok').length;
|
|
255
|
+
if (!ok) fail('no themed window found. Is the background process running? Try "status".');
|
|
256
|
+
say(`Switched ${ok} window(s) to ${key}.`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function status() {
|
|
260
|
+
const dir = installDir();
|
|
261
|
+
let installed = null;
|
|
262
|
+
try { installed = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8')).version; } catch { /* not installed */ }
|
|
263
|
+
say(`Installed: ${installed ? `${installed} in ${dir}` : 'no'}`);
|
|
264
|
+
say(`At logon: ${hasAutostart() ? autostartFile() : 'no'}`);
|
|
265
|
+
const state = await running();
|
|
266
|
+
say(`Background: ${state ? `running (process ${state.pid}, version ${state.version})` : 'not running'}`);
|
|
267
|
+
try {
|
|
268
|
+
const port = readPort();
|
|
269
|
+
const results = await evaluateEverywhere(PROBE_SCRIPT, port);
|
|
270
|
+
say(`Antigravity: running, port ${port}, ${results.length} window(s)`);
|
|
271
|
+
for (const r of results) {
|
|
272
|
+
const host = r.value ? JSON.parse(r.value) : null;
|
|
273
|
+
say(` ${host ? `themed (${host.theme})` : 'not themed'} ${r.url}`);
|
|
274
|
+
}
|
|
275
|
+
} catch (err) {
|
|
276
|
+
say(`Antigravity: ${existsSync(portFile()) ? err.message : `not running (no ${portFile()})`}`);
|
|
277
|
+
}
|
|
278
|
+
say();
|
|
279
|
+
if (installed && hasAutostart() && state) {
|
|
280
|
+
say('OK: installed and permanent.');
|
|
281
|
+
} else {
|
|
282
|
+
say(`Not permanent. Fix it with: ${NPX} install`);
|
|
283
|
+
process.exitCode = 1;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function help() {
|
|
288
|
+
say(`${NAME} ${pkg.version}: Matte, Paper, Nordic, and Material 3 Expressive themes for Antigravity Desktop
|
|
289
|
+
|
|
290
|
+
Usage: ${NPX} <command>
|
|
291
|
+
|
|
292
|
+
install [--native] Install the themes permanently.
|
|
293
|
+
With --native: installs directly into Antigravity with ZERO background processes or servers.
|
|
294
|
+
Default: copies themes to ${installDir()} and keeps them synced.
|
|
295
|
+
uninstall Remove the themes from Antigravity, the logon entry, and native app directory
|
|
296
|
+
start Start the background process that themes every window (not permanent)
|
|
297
|
+
stop Stop it (open windows keep the theme until they reload)
|
|
298
|
+
on Apply the themes to the open windows once (not permanent)
|
|
299
|
+
off Stop the background process and remove the themes from the open windows
|
|
300
|
+
theme [key] List the themes, or switch every window to one
|
|
301
|
+
status Show what is installed and running; exit code 1 if not permanent
|
|
302
|
+
run [--dev] Run the background process in this terminal (--dev reloads on file changes)`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const commands = {
|
|
306
|
+
install, uninstall, on, off, theme, status, run, help,
|
|
307
|
+
start: async () => {
|
|
308
|
+
const state = await running() ?? await startBackground(path.join(ROOT, 'bin', 'cli.mjs'));
|
|
309
|
+
if (!state) fail(`could not start; see ${logFile()}`);
|
|
310
|
+
say(`Running in the background (process ${state.pid}).${hasAutostart() ? '' : ' Until the next logon; "install" makes it permanent.'}`);
|
|
311
|
+
},
|
|
312
|
+
stop: async () => say(await stopBackground() ? 'Stopped.' : 'Not running.'),
|
|
313
|
+
'--version': () => say(pkg.version),
|
|
314
|
+
'-v': () => say(pkg.version),
|
|
315
|
+
'--help': help,
|
|
316
|
+
'-h': help,
|
|
317
|
+
};
|
|
318
|
+
const handler = commands[command];
|
|
319
|
+
if (!handler) fail(`unknown command "${command}". Run "help" for the list.`);
|
|
320
|
+
try {
|
|
321
|
+
await handler();
|
|
322
|
+
} catch (err) {
|
|
323
|
+
fail(err.message);
|
|
324
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Starts the background process when the user logs in, with no window:
|
|
2
|
+
// Windows a .vbs launcher in the Startup folder (no admin rights needed)
|
|
3
|
+
// macOS a LaunchAgent (untested)
|
|
4
|
+
// Linux an XDG autostart entry (untested)
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { ROOT } from './bundle.mjs';
|
|
10
|
+
import { installDir } from './paths.mjs';
|
|
11
|
+
|
|
12
|
+
const NAME = 'antigravity-themes';
|
|
13
|
+
const PACKAGE = JSON.parse(readFileSync(path.join(ROOT, 'package.json'), 'utf8')).name;
|
|
14
|
+
|
|
15
|
+
function vbsRun(node, cli) {
|
|
16
|
+
const q = (s) => s.replace(/"/g, '""');
|
|
17
|
+
return `CreateObject("WScript.Shell").Run """${q(node)}"" ""${q(cli)}"" run", 0, False`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Starts the background process now, with no window. On Windows it goes through
|
|
21
|
+
// the script host, as at logon: a process started directly inherits the output
|
|
22
|
+
// pipes of a shell like PowerShell, and whatever reads those pipes would then
|
|
23
|
+
// wait for the background process to end.
|
|
24
|
+
export function launch(node, cli) {
|
|
25
|
+
if (process.platform !== 'win32') {
|
|
26
|
+
spawn(node, [cli, 'run'], { detached: true, stdio: 'ignore' }).unref();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const file = path.join(installDir(), 'start.vbs');
|
|
30
|
+
writeFileSync(file, `${vbsRun(node, cli)}\r\n`);
|
|
31
|
+
spawn('wscript.exe', ['//B', '//Nologo', file], { stdio: 'ignore', windowsHide: true }).on('error', () => {});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function autostartFile() {
|
|
35
|
+
const home = os.homedir();
|
|
36
|
+
if (process.platform === 'win32') {
|
|
37
|
+
const roaming = process.env.APPDATA ?? path.join(home, 'AppData', 'Roaming');
|
|
38
|
+
return path.join(roaming, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup', `${NAME}.vbs`);
|
|
39
|
+
}
|
|
40
|
+
if (process.platform === 'darwin') return path.join(home, 'Library', 'LaunchAgents', `dev.${NAME}.plist`);
|
|
41
|
+
return path.join(process.env.XDG_CONFIG_HOME ?? path.join(home, '.config'), 'autostart', `${NAME}.desktop`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// node and the installed cli.mjs, as absolute paths: the entry keeps working
|
|
45
|
+
// after the npx cache is gone (and stops if Node moves: install again then)
|
|
46
|
+
export function writeAutostart(node, cli) {
|
|
47
|
+
const file = autostartFile();
|
|
48
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
49
|
+
if (process.platform === 'win32') {
|
|
50
|
+
writeFileSync(file, [
|
|
51
|
+
`' ${NAME}: applies the themes to Antigravity at logon, with no window.`,
|
|
52
|
+
`' Remove it with: npx ${PACKAGE} uninstall`,
|
|
53
|
+
vbsRun(node, cli),
|
|
54
|
+
'',
|
|
55
|
+
].join('\r\n'));
|
|
56
|
+
} else if (process.platform === 'darwin') {
|
|
57
|
+
const x = (s) => s.replace(/&/g, '&').replace(/</g, '<');
|
|
58
|
+
writeFileSync(file, `<?xml version="1.0" encoding="UTF-8"?>
|
|
59
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
60
|
+
<plist version="1.0">
|
|
61
|
+
<dict>
|
|
62
|
+
<key>Label</key><string>dev.${NAME}</string>
|
|
63
|
+
<key>ProgramArguments</key>
|
|
64
|
+
<array><string>${x(node)}</string><string>${x(cli)}</string><string>run</string></array>
|
|
65
|
+
<key>RunAtLoad</key><true/>
|
|
66
|
+
</dict>
|
|
67
|
+
</plist>
|
|
68
|
+
`);
|
|
69
|
+
} else {
|
|
70
|
+
const q = (s) => `"${s.replace(/(["\\`$])/g, '\\$1')}"`;
|
|
71
|
+
writeFileSync(file, `[Desktop Entry]
|
|
72
|
+
Type=Application
|
|
73
|
+
Name=${NAME}
|
|
74
|
+
Comment=Applies the themes to Antigravity
|
|
75
|
+
Exec=${q(node)} ${q(cli)} run
|
|
76
|
+
X-GNOME-Autostart-enabled=true
|
|
77
|
+
NoDisplay=true
|
|
78
|
+
`);
|
|
79
|
+
}
|
|
80
|
+
return file;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function removeAutostart() {
|
|
84
|
+
const file = autostartFile();
|
|
85
|
+
if (!existsSync(file)) return null;
|
|
86
|
+
rmSync(file);
|
|
87
|
+
return file;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const hasAutostart = () => existsSync(autostartFile());
|
package/lib/bundle.mjs
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Builds the script that runs inside Antigravity: the vendored md3e-web
|
|
2
|
+
// components, the theme modules and the theme host, started with a bundle
|
|
3
|
+
// read from themes/index.json (each theme's CSS, its fonts as base64, and
|
|
4
|
+
// the choices in the Theme menu).
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
10
|
+
|
|
11
|
+
// The components come first: the indicator modules need them defined.
|
|
12
|
+
const MODULES = [
|
|
13
|
+
'vendor/md3e/components.js',
|
|
14
|
+
'src/context-card.js',
|
|
15
|
+
'src/turn-tokens.js',
|
|
16
|
+
'src/loading-indicator.js',
|
|
17
|
+
'src/progress-indicators.js',
|
|
18
|
+
'src/code-tools.js',
|
|
19
|
+
'src/theme-host.js',
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
export function loadBundle(root = ROOT) {
|
|
23
|
+
const themesDir = path.join(root, 'themes');
|
|
24
|
+
const index = JSON.parse(readFileSync(path.join(themesDir, 'index.json'), 'utf8'));
|
|
25
|
+
const themes = {};
|
|
26
|
+
for (const [id, files] of Object.entries(index.themes)) {
|
|
27
|
+
themes[id] = [].concat(files).map((file) => readFileSync(path.join(themesDir, file), 'utf8')).join('\n');
|
|
28
|
+
}
|
|
29
|
+
const fonts = {};
|
|
30
|
+
for (const [id, list] of Object.entries(index.fonts ?? {})) {
|
|
31
|
+
fonts[id] = list.map(({ file, ...descriptors }) => ({
|
|
32
|
+
...descriptors,
|
|
33
|
+
data: readFileSync(path.join(themesDir, file)).toString('base64'),
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
return { default: index.default, themes, fonts, ripple: index.ripple ?? [], options: index.options };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// On a new document the script runs before the parser has created <html>,
|
|
40
|
+
// so it waits for that element; the host then waits for the rest itself.
|
|
41
|
+
export function pageScript(root = ROOT) {
|
|
42
|
+
const parts = MODULES.map((file) => readFileSync(path.join(root, file), 'utf8'));
|
|
43
|
+
return `(() => {
|
|
44
|
+
const start = () => {
|
|
45
|
+
${parts.join('\n;')}
|
|
46
|
+
;agyThemeHost(${JSON.stringify(loadBundle(root))});
|
|
47
|
+
};
|
|
48
|
+
if (document.documentElement) start();
|
|
49
|
+
else new MutationObserver((_, observer) => {
|
|
50
|
+
if (!document.documentElement) return;
|
|
51
|
+
observer.disconnect();
|
|
52
|
+
start();
|
|
53
|
+
}).observe(document, { childList: true });
|
|
54
|
+
})();`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Undoes everything the page script did in that window
|
|
58
|
+
export const REMOVE_SCRIPT = `(() => {
|
|
59
|
+
window.__agyThemeHost?.dispose();
|
|
60
|
+
document.getElementById('agy-theme-css')?.remove();
|
|
61
|
+
document.documentElement?.removeAttribute('data-agy-theme');
|
|
62
|
+
})();`;
|
|
63
|
+
|
|
64
|
+
// The theme host's version and theme in a window, or null without one
|
|
65
|
+
export const PROBE_SCRIPT = `JSON.stringify(window.__agyThemeHost ? { version: window.__agyThemeHost.version, theme: window.__agyThemeHost.get() } : null)`;
|
|
66
|
+
|
|
67
|
+
export const selectScript = (key) => `(() => {
|
|
68
|
+
const host = window.__agyThemeHost;
|
|
69
|
+
if (!host) return 'no-host';
|
|
70
|
+
if (!host.options().some((o) => o.key === ${JSON.stringify(key)})) return 'unknown';
|
|
71
|
+
host.select(${JSON.stringify(key)});
|
|
72
|
+
return 'ok';
|
|
73
|
+
})()`;
|
package/lib/injector.mjs
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Talks to a running Antigravity over the Chrome DevTools Protocol on
|
|
2
|
+
// 127.0.0.1: Antigravity opens that port itself on every launch. Nothing is
|
|
3
|
+
// sent anywhere else, and no app file is changed: the theme lives in the
|
|
4
|
+
// open windows, and a script registered for each window's next document
|
|
5
|
+
// brings it back after a reload while the connection lasts.
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
import { portFile } from './paths.mjs';
|
|
8
|
+
|
|
9
|
+
const TARGET_TYPES = new Set(['page', 'webview', 'iframe']);
|
|
10
|
+
|
|
11
|
+
export function readPort() {
|
|
12
|
+
const file = portFile();
|
|
13
|
+
let text;
|
|
14
|
+
try {
|
|
15
|
+
text = readFileSync(file, 'utf8');
|
|
16
|
+
} catch {
|
|
17
|
+
throw new Error(`${file} was not found. Is Antigravity running?`);
|
|
18
|
+
}
|
|
19
|
+
const port = Number(text.split(/\r?\n/)[0]);
|
|
20
|
+
if (!Number.isInteger(port) || port <= 0) throw new Error(`${file} holds no port.`);
|
|
21
|
+
return port;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function listTargets(port) {
|
|
25
|
+
let res;
|
|
26
|
+
try {
|
|
27
|
+
res = await fetch(`http://127.0.0.1:${port}/json/list`);
|
|
28
|
+
} catch {
|
|
29
|
+
throw new Error(`Antigravity does not answer on port ${port}. Is it running?`);
|
|
30
|
+
}
|
|
31
|
+
const targets = await res.json();
|
|
32
|
+
return targets.filter((t) => TARGET_TYPES.has(t.type) && t.webSocketDebuggerUrl && !t.url.startsWith('devtools://'));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class Session {
|
|
36
|
+
constructor(target) {
|
|
37
|
+
this.target = target;
|
|
38
|
+
this.nextId = 0;
|
|
39
|
+
this.pending = new Map();
|
|
40
|
+
this.scriptId = null;
|
|
41
|
+
this.pageEnabled = false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
open() {
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
this.ws = new WebSocket(this.target.webSocketDebuggerUrl);
|
|
47
|
+
this.ws.onopen = () => resolve(this);
|
|
48
|
+
this.ws.onerror = () => reject(new Error(`cannot connect to ${this.target.url}`));
|
|
49
|
+
this.ws.onclose = () => {
|
|
50
|
+
for (const { reject: fail } of this.pending.values()) fail(new Error('connection closed'));
|
|
51
|
+
this.pending.clear();
|
|
52
|
+
this.onclose?.();
|
|
53
|
+
};
|
|
54
|
+
this.ws.onmessage = (event) => {
|
|
55
|
+
const msg = JSON.parse(event.data);
|
|
56
|
+
const call = msg.id && this.pending.get(msg.id);
|
|
57
|
+
if (!call) return;
|
|
58
|
+
this.pending.delete(msg.id);
|
|
59
|
+
if (msg.error) call.reject(new Error(msg.error.message));
|
|
60
|
+
else call.resolve(msg.result);
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
send(method, params = {}) {
|
|
66
|
+
const id = ++this.nextId;
|
|
67
|
+
return new Promise((resolve, reject) => {
|
|
68
|
+
this.pending.set(id, { resolve, reject });
|
|
69
|
+
this.ws.send(JSON.stringify({ id, method, params }));
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Now, and on every new document in this window while connected. Chromium
|
|
74
|
+
// runs scripts registered for new documents only once the Page domain is
|
|
75
|
+
// enabled on this connection.
|
|
76
|
+
async apply(source) {
|
|
77
|
+
if (!this.pageEnabled) {
|
|
78
|
+
await this.send('Page.enable');
|
|
79
|
+
this.pageEnabled = true;
|
|
80
|
+
}
|
|
81
|
+
if (this.scriptId) await this.send('Page.removeScriptToEvaluateOnNewDocument', { identifier: this.scriptId });
|
|
82
|
+
({ identifier: this.scriptId } = await this.send('Page.addScriptToEvaluateOnNewDocument', { source }));
|
|
83
|
+
await this.evaluate(source);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async evaluate(expression) {
|
|
87
|
+
const { result, exceptionDetails } = await this.send('Runtime.evaluate', { expression, returnByValue: true });
|
|
88
|
+
if (exceptionDetails) throw new Error(exceptionDetails.exception?.description ?? exceptionDetails.text);
|
|
89
|
+
return result?.value;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
close() {
|
|
93
|
+
this.ws.close();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Runs one script in every open window and closes again
|
|
98
|
+
export async function evaluateEverywhere(script, port = readPort()) {
|
|
99
|
+
const results = [];
|
|
100
|
+
for (const target of await listTargets(port)) {
|
|
101
|
+
const session = new Session(target);
|
|
102
|
+
try {
|
|
103
|
+
await session.open();
|
|
104
|
+
results.push({ url: target.url, value: await session.evaluate(script) });
|
|
105
|
+
} catch (err) {
|
|
106
|
+
results.push({ url: target.url, error: err.message });
|
|
107
|
+
} finally {
|
|
108
|
+
session.close();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return results;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// The long-running part: themes every window Antigravity opens, keeps the
|
|
115
|
+
// theme across reloads, follows the port when the app restarts, and waits
|
|
116
|
+
// while it is closed. getSource() is read again when reload() is called.
|
|
117
|
+
export function watch({ getSource, log = () => {} }) {
|
|
118
|
+
const sessions = new Map(); // target id -> Session
|
|
119
|
+
let port = null;
|
|
120
|
+
let source = getSource();
|
|
121
|
+
let waiting = false;
|
|
122
|
+
|
|
123
|
+
function followPort() {
|
|
124
|
+
const next = readPort();
|
|
125
|
+
if (next === port) return;
|
|
126
|
+
for (const session of sessions.values()) session.close();
|
|
127
|
+
sessions.clear();
|
|
128
|
+
if (port !== null) log(`Antigravity restarted (port ${next})`);
|
|
129
|
+
port = next;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function attachNew() {
|
|
133
|
+
for (const target of await listTargets(port)) {
|
|
134
|
+
if (sessions.has(target.id)) continue;
|
|
135
|
+
const session = new Session(target);
|
|
136
|
+
sessions.set(target.id, session);
|
|
137
|
+
session.onclose = () => sessions.delete(target.id);
|
|
138
|
+
try {
|
|
139
|
+
await session.open();
|
|
140
|
+
await session.apply(source);
|
|
141
|
+
log(`themed ${target.type} ${target.url}`);
|
|
142
|
+
} catch (err) {
|
|
143
|
+
log(`${target.url}: ${err.message}`);
|
|
144
|
+
sessions.delete(target.id);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function tick() {
|
|
150
|
+
try {
|
|
151
|
+
followPort();
|
|
152
|
+
await attachNew();
|
|
153
|
+
if (waiting) log(`Antigravity found on port ${port}`);
|
|
154
|
+
waiting = false;
|
|
155
|
+
} catch (err) {
|
|
156
|
+
if (!waiting) log(`waiting for Antigravity (${err.message})`);
|
|
157
|
+
waiting = true;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const timer = setInterval(tick, 2000);
|
|
162
|
+
tick();
|
|
163
|
+
return {
|
|
164
|
+
async reload() {
|
|
165
|
+
source = getSource();
|
|
166
|
+
for (const session of sessions.values()) {
|
|
167
|
+
try { await session.apply(source); } catch (err) { log(`reload failed for ${session.target.url}: ${err.message}`); }
|
|
168
|
+
}
|
|
169
|
+
log(`reloaded into ${sessions.size} window(s)`);
|
|
170
|
+
},
|
|
171
|
+
stop() {
|
|
172
|
+
clearInterval(timer);
|
|
173
|
+
for (const session of sessions.values()) session.close();
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|