@materialwebunofficial/antigravity-themes 1.0.5 → 1.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +173 -34
- package/bin/cli.mjs +91 -47
- package/lib/asar.mjs +163 -0
- package/lib/bundle.mjs +70 -17
- package/lib/injector.mjs +39 -6
- package/lib/keeper.mjs +73 -0
- package/lib/native.mjs +165 -0
- package/lib/paths.mjs +3 -2
- package/lib/plugin.mjs +98 -0
- package/package.json +1 -1
- package/src/code-tools.js +1 -1
- package/src/context-card.js +1 -1
- package/src/frame-host.js +282 -0
- package/src/loading-indicator.js +1 -1
- package/src/progress-indicators.js +49 -1
- package/src/theme-host.js +97 -66
- package/src/turn-tokens.js +1 -1
- package/themes/frames/matte.css +211 -0
- package/themes/frames/md3e.css +1410 -0
- package/themes/frames/nordic.css +252 -0
- package/themes/frames/paper.css +298 -0
- package/themes/index.json +18 -12
- package/themes/matte-tokens.css +98 -0
- package/themes/matte.css +465 -135
- package/themes/md3e.css +1124 -164
- package/themes/nordic-tokens.css +60 -0
- package/themes/nordic.css +457 -67
- package/themes/paper-tokens.css +54 -0
- package/themes/paper.css +508 -68
package/lib/asar.mjs
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// Electron asar archives: just enough for the native install, with no package
|
|
2
|
+
// and no network. An archive is a size field and a header (two Chromium
|
|
3
|
+
// Pickles; the header holds JSON), then the files back to back; files marked
|
|
4
|
+
// "unpacked" live beside it in <archive>.unpacked/ instead.
|
|
5
|
+
//
|
|
6
|
+
// Electron's own Node (ELECTRON_RUN_AS_NODE, how Antigravity runs a sidecar)
|
|
7
|
+
// shows an archive as a folder through fs; process.noAsar turns that off so
|
|
8
|
+
// the archive reads as the file it is.
|
|
9
|
+
import { createHash } from 'node:crypto';
|
|
10
|
+
import {
|
|
11
|
+
closeSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, writeSync,
|
|
12
|
+
} from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
|
|
15
|
+
process.noAsar = true;
|
|
16
|
+
|
|
17
|
+
const BLOCK = 4 * 1024 * 1024; // integrity block size, as @electron/asar
|
|
18
|
+
|
|
19
|
+
export function readHeader(archive) {
|
|
20
|
+
const fd = openSync(archive, 'r');
|
|
21
|
+
try {
|
|
22
|
+
const size = Buffer.alloc(8);
|
|
23
|
+
readSync(fd, size, 0, 8, 0);
|
|
24
|
+
const headerSize = size.readUInt32LE(4);
|
|
25
|
+
const pickle = Buffer.alloc(headerSize);
|
|
26
|
+
readSync(fd, pickle, 0, headerSize, 8);
|
|
27
|
+
const length = pickle.readUInt32LE(4);
|
|
28
|
+
return { header: JSON.parse(pickle.toString('utf8', 8, 8 + length)), base: 8 + headerSize };
|
|
29
|
+
} finally {
|
|
30
|
+
closeSync(fd);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The entry for a path inside the archive ('dist/preload.js'), or null
|
|
35
|
+
function entryAt(header, file) {
|
|
36
|
+
let node = header;
|
|
37
|
+
for (const part of file.split(/[\\/]/)) {
|
|
38
|
+
node = node?.files?.[part];
|
|
39
|
+
if (!node) return null;
|
|
40
|
+
}
|
|
41
|
+
return node;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readEntry(fd, base, entry) {
|
|
45
|
+
const buffer = Buffer.alloc(entry.size);
|
|
46
|
+
if (entry.size) readSync(fd, buffer, 0, entry.size, base + Number(entry.offset));
|
|
47
|
+
return buffer;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// One file's bytes, read without unpacking the rest
|
|
51
|
+
export function readFile(archive, file) {
|
|
52
|
+
const { header, base } = readHeader(archive);
|
|
53
|
+
const entry = entryAt(header, file);
|
|
54
|
+
if (!entry || entry.files || entry.link) throw new Error(`${file} is not a file in ${archive}`);
|
|
55
|
+
if (entry.unpacked) return readFileSync(path.join(`${archive}.unpacked`, file));
|
|
56
|
+
const fd = openSync(archive, 'r');
|
|
57
|
+
try { return readEntry(fd, base, entry); } finally { closeSync(fd); }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Every file into dest, unpacked ones copied from <archive>.unpacked.
|
|
61
|
+
// Links become copies of what they point to.
|
|
62
|
+
export function extract(archive, dest) {
|
|
63
|
+
const { header, base } = readHeader(archive);
|
|
64
|
+
const unpacked = `${archive}.unpacked`;
|
|
65
|
+
const links = [];
|
|
66
|
+
const fd = openSync(archive, 'r');
|
|
67
|
+
try {
|
|
68
|
+
const walk = (node, rel) => {
|
|
69
|
+
for (const [name, entry] of Object.entries(node.files)) {
|
|
70
|
+
const file = rel ? `${rel}/${name}` : name;
|
|
71
|
+
const out = path.join(dest, file);
|
|
72
|
+
if (entry.files) {
|
|
73
|
+
mkdirSync(out, { recursive: true });
|
|
74
|
+
walk(entry, file);
|
|
75
|
+
} else if (entry.link) {
|
|
76
|
+
links.push([out, path.join(dest, entry.link)]);
|
|
77
|
+
} else if (entry.unpacked) {
|
|
78
|
+
mkdirSync(path.dirname(out), { recursive: true });
|
|
79
|
+
copyFileSync(path.join(unpacked, file), out);
|
|
80
|
+
} else {
|
|
81
|
+
mkdirSync(path.dirname(out), { recursive: true });
|
|
82
|
+
const out_fd = openSync(out, 'w');
|
|
83
|
+
try { writeSync(out_fd, readEntry(fd, base, entry)); } finally { closeSync(out_fd); }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
mkdirSync(dest, { recursive: true });
|
|
88
|
+
walk(header, '');
|
|
89
|
+
} finally {
|
|
90
|
+
closeSync(fd);
|
|
91
|
+
}
|
|
92
|
+
for (const [out, target] of links) {
|
|
93
|
+
if (existsSync(target)) copyFileSync(target, out);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const sha256 = (buffer) => createHash('sha256').update(buffer).digest('hex');
|
|
98
|
+
function integrity(buffer) {
|
|
99
|
+
const blocks = [];
|
|
100
|
+
for (let i = 0; i < buffer.length; i += BLOCK) blocks.push(sha256(buffer.subarray(i, i + BLOCK)));
|
|
101
|
+
return { algorithm: 'SHA256', hash: sha256(buffer), blockSize: BLOCK, blocks };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The archive again with some files' contents replaced ({ 'dist/main.js':
|
|
105
|
+
// Buffer }): same entries, order and unpacked files; offsets, sizes and
|
|
106
|
+
// integrity follow the new contents. Written beside it, then swapped in.
|
|
107
|
+
export function rewrite(archive, replacements) {
|
|
108
|
+
const { header, base } = readHeader(archive);
|
|
109
|
+
for (const file of Object.keys(replacements)) {
|
|
110
|
+
const entry = entryAt(header, file);
|
|
111
|
+
if (!entry || entry.files || entry.link || entry.unpacked) throw new Error(`cannot replace ${file} in ${archive}`);
|
|
112
|
+
}
|
|
113
|
+
const chunks = [];
|
|
114
|
+
let offset = 0;
|
|
115
|
+
const fd = openSync(archive, 'r');
|
|
116
|
+
try {
|
|
117
|
+
const walk = (node, rel) => {
|
|
118
|
+
for (const [name, entry] of Object.entries(node.files)) {
|
|
119
|
+
const file = rel ? `${rel}/${name}` : name;
|
|
120
|
+
if (entry.files) { walk(entry, file); continue; }
|
|
121
|
+
if (entry.link || entry.unpacked) continue;
|
|
122
|
+
const data = replacements[file] ?? readEntry(fd, base, entry);
|
|
123
|
+
if (replacements[file]) {
|
|
124
|
+
entry.size = data.length;
|
|
125
|
+
entry.integrity = integrity(data);
|
|
126
|
+
}
|
|
127
|
+
entry.offset = String(offset);
|
|
128
|
+
offset += data.length;
|
|
129
|
+
chunks.push(data);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
walk(header, '');
|
|
133
|
+
} finally {
|
|
134
|
+
closeSync(fd);
|
|
135
|
+
}
|
|
136
|
+
const json = Buffer.from(JSON.stringify(header), 'utf8');
|
|
137
|
+
const padded = (json.length + 3) & ~3;
|
|
138
|
+
const headerPickle = Buffer.alloc(8 + padded);
|
|
139
|
+
headerPickle.writeUInt32LE(4 + padded, 0);
|
|
140
|
+
headerPickle.writeUInt32LE(json.length, 4);
|
|
141
|
+
json.copy(headerPickle, 8);
|
|
142
|
+
const sizePickle = Buffer.alloc(8);
|
|
143
|
+
sizePickle.writeUInt32LE(4, 0);
|
|
144
|
+
sizePickle.writeUInt32LE(headerPickle.length, 4);
|
|
145
|
+
|
|
146
|
+
const temp = `${archive}.agy-tmp`;
|
|
147
|
+
const out = openSync(temp, 'w');
|
|
148
|
+
try {
|
|
149
|
+
writeSync(out, sizePickle);
|
|
150
|
+
writeSync(out, headerPickle);
|
|
151
|
+
for (const chunk of chunks) writeSync(out, chunk);
|
|
152
|
+
} finally {
|
|
153
|
+
closeSync(out);
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
renameSync(temp, archive);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
rmSync(temp, { force: true }); // in use (the app is running): leave the archive as it was
|
|
159
|
+
throw err;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export const removeTree = (dir) => rmSync(dir, { recursive: true, force: true, maxRetries: 3 });
|
package/lib/bundle.mjs
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
|
-
// Builds the
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Builds the scripts that run inside Antigravity.
|
|
2
|
+
//
|
|
3
|
+
// pageScript: the window's script. The vendored md3e-web components, the
|
|
4
|
+
// theme modules and the theme host, started with a bundle read from
|
|
5
|
+
// themes/index.json (each theme's CSS, its fonts as base64, and the choices
|
|
6
|
+
// in the Theme menu).
|
|
7
|
+
//
|
|
8
|
+
// frameScript: the script for sidecar frames (Swarm and other plugin panes,
|
|
9
|
+
// cross-origin plugin:// iframes). Only the frame host and each theme's frame
|
|
10
|
+
// stylesheet: a frame never parses the window's CSS, components or fonts.
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
5
12
|
import { readFileSync } from 'node:fs';
|
|
6
13
|
import path from 'node:path';
|
|
7
14
|
import { fileURLToPath } from 'node:url';
|
|
@@ -19,13 +26,21 @@ const MODULES = [
|
|
|
19
26
|
'src/theme-host.js',
|
|
20
27
|
];
|
|
21
28
|
|
|
29
|
+
// What <md-loading-indicator> needs out of vendor/md3e/components.js (its
|
|
30
|
+
// modules sit there in dependency order, one "// ---- name ----" block each)
|
|
31
|
+
const FRAME_COMPONENT = 'components/md-loading-indicator.js';
|
|
32
|
+
|
|
33
|
+
const readThemes = (themesDir, map) => {
|
|
34
|
+
const out = {};
|
|
35
|
+
for (const [id, files] of Object.entries(map ?? {})) {
|
|
36
|
+
out[id] = [].concat(files).map((file) => readFileSync(path.join(themesDir, file), 'utf8')).join('\n');
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
};
|
|
40
|
+
|
|
22
41
|
export function loadBundle(root = ROOT) {
|
|
23
42
|
const themesDir = path.join(root, 'themes');
|
|
24
43
|
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
44
|
const fonts = {};
|
|
30
45
|
for (const [id, list] of Object.entries(index.fonts ?? {})) {
|
|
31
46
|
fonts[id] = list.map(({ file, ...descriptors }) => ({
|
|
@@ -33,17 +48,21 @@ export function loadBundle(root = ROOT) {
|
|
|
33
48
|
data: readFileSync(path.join(themesDir, file)).toString('base64'),
|
|
34
49
|
}));
|
|
35
50
|
}
|
|
36
|
-
return { default: index.default, themes, fonts, ripple: index.ripple ?? [], options: index.options };
|
|
51
|
+
return { default: index.default, themes: readThemes(themesDir, index.themes), fonts, ripple: index.ripple ?? [], options: index.options };
|
|
37
52
|
}
|
|
38
53
|
|
|
39
54
|
// On a new document the script runs before the parser has created <html>,
|
|
40
55
|
// so it waits for that element; the host then waits for the rest itself.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
56
|
+
// The same script can reach a document twice (the native install and a
|
|
57
|
+
// background process on the same version, or a reconnect): the second run
|
|
58
|
+
// stops at the flag instead of evaluating it all again. A host clears the
|
|
59
|
+
// flag when it is disposed.
|
|
60
|
+
const hashOf = (text) => JSON.stringify(createHash('sha1').update(text).digest('hex').slice(0, 12));
|
|
61
|
+
const whenRooted = (body, flag, hash = hashOf(body)) => `(() => {
|
|
62
|
+
if (window.${flag} === ${hash}) return;
|
|
63
|
+
window.${flag} = ${hash};
|
|
44
64
|
const start = () => {
|
|
45
|
-
${
|
|
46
|
-
;agyThemeHost(${JSON.stringify(loadBundle(root))});
|
|
65
|
+
${body}
|
|
47
66
|
};
|
|
48
67
|
if (document.documentElement) start();
|
|
49
68
|
else new MutationObserver((_, observer) => {
|
|
@@ -52,17 +71,51 @@ else new MutationObserver((_, observer) => {
|
|
|
52
71
|
start();
|
|
53
72
|
}).observe(document, { childList: true });
|
|
54
73
|
})();`;
|
|
74
|
+
|
|
75
|
+
export function pageScript(root = ROOT) {
|
|
76
|
+
const parts = MODULES.map((file) => readFileSync(path.join(root, file), 'utf8'));
|
|
77
|
+
return whenRooted(`${parts.join('\n;')}
|
|
78
|
+
;agyThemeHost(${JSON.stringify(loadBundle(root))});`, '__agyPageScript');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The component modules up to and including the one asked for, as a function
|
|
82
|
+
// body that defines the element. Run only when a theme needs it.
|
|
83
|
+
function componentSlice(root, last) {
|
|
84
|
+
const source = readFileSync(path.join(root, 'vendor/md3e/components.js'), 'utf8');
|
|
85
|
+
const first = source.indexOf('\n// ---- ');
|
|
86
|
+
const marker = `\n// ---- ${last} ----`;
|
|
87
|
+
const at = source.indexOf(marker);
|
|
88
|
+
if (first < 0 || at < 0) throw new Error(`vendor/md3e/components.js has no module ${last}`);
|
|
89
|
+
const next = source.indexOf('\n// ---- ', at + marker.length);
|
|
90
|
+
return `'use strict';\nconst __md3e = {};${source.slice(first, next < 0 ? source.lastIndexOf('})();') : next)}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function frameScript(root = ROOT) {
|
|
94
|
+
const themesDir = path.join(root, 'themes');
|
|
95
|
+
const index = JSON.parse(readFileSync(path.join(themesDir, 'index.json'), 'utf8'));
|
|
96
|
+
const host = readFileSync(path.join(root, 'src/frame-host.js'), 'utf8');
|
|
97
|
+
const bundle = JSON.stringify({ frames: readThemes(themesDir, index.frames), fonts: Object.keys(index.fonts ?? {}) });
|
|
98
|
+
return whenRooted(`${host}
|
|
99
|
+
;agyFrameHost(Object.assign(${bundle}, { md3e: () => {
|
|
100
|
+
${componentSlice(root, FRAME_COMPONENT)}
|
|
101
|
+
} }));`, '__agyFrameScript');
|
|
55
102
|
}
|
|
56
103
|
|
|
57
|
-
//
|
|
104
|
+
// Sidecar frames are served from this scheme (the app's customScheme.js)
|
|
105
|
+
export const FRAME_SCHEME = 'plugin://';
|
|
106
|
+
export const isFrameUrl = (url) => typeof url === 'string' && url.startsWith(FRAME_SCHEME);
|
|
107
|
+
|
|
108
|
+
// Undoes everything the scripts did in that window or frame
|
|
58
109
|
export const REMOVE_SCRIPT = `(() => {
|
|
59
110
|
window.__agyThemeHost?.dispose();
|
|
111
|
+
window.__agyFrameHost?.dispose();
|
|
60
112
|
document.getElementById('agy-theme-css')?.remove();
|
|
61
113
|
document.documentElement?.removeAttribute('data-agy-theme');
|
|
62
114
|
})();`;
|
|
63
115
|
|
|
64
|
-
// The theme host's version and theme in a window
|
|
65
|
-
|
|
116
|
+
// The theme host's version and theme in a window (or the frame host's in a
|
|
117
|
+
// sidecar frame), or null without one
|
|
118
|
+
export const PROBE_SCRIPT = `JSON.stringify(window.__agyThemeHost ? { version: window.__agyThemeHost.version, theme: window.__agyThemeHost.get() } : window.__agyFrameHost ? { version: window.__agyFrameHost.version, theme: window.__agyFrameHost.get(), frame: true } : null)`;
|
|
66
119
|
|
|
67
120
|
export const selectScript = (key) => `(() => {
|
|
68
121
|
const host = window.__agyThemeHost;
|
package/lib/injector.mjs
CHANGED
|
@@ -3,11 +3,28 @@
|
|
|
3
3
|
// sent anywhere else, and no app file is changed: the theme lives in the
|
|
4
4
|
// open windows, and a script registered for each window's next document
|
|
5
5
|
// brings it back after a reload while the connection lasts.
|
|
6
|
+
//
|
|
7
|
+
// A script is either one source for every target or a function of the
|
|
8
|
+
// target: the app's own windows take the page script, sidecar frames
|
|
9
|
+
// (plugin://) the frame script, and nothing else is touched: not the web pages
|
|
10
|
+
// of the agent's browser, not a sandboxed srcdoc inside a sidecar.
|
|
6
11
|
import { readFileSync } from 'node:fs';
|
|
7
12
|
import { portFile } from './paths.mjs';
|
|
13
|
+
import { isFrameUrl } from './bundle.mjs';
|
|
8
14
|
|
|
9
15
|
const TARGET_TYPES = new Set(['page', 'webview', 'iframe']);
|
|
10
16
|
|
|
17
|
+
// The app's windows are served by its language server on 127.0.0.1 (https)
|
|
18
|
+
const APP_URL = new RegExp(String.raw`^https://(127\.0\.0\.1|localhost):\d+/`);
|
|
19
|
+
const isAppUrl = (url) => typeof url === 'string' && APP_URL.test(url);
|
|
20
|
+
|
|
21
|
+
// { page, frame } -> the source for one target, or null to leave it alone
|
|
22
|
+
export const scriptFor = ({ page, frame }) => (target) => {
|
|
23
|
+
if (target.type === 'iframe') return isFrameUrl(target.url) ? frame : null;
|
|
24
|
+
return target.type === 'page' && isAppUrl(target.url) ? page : null;
|
|
25
|
+
};
|
|
26
|
+
const sourceFor = (script, target) => (typeof script === 'function' ? script(target) : script);
|
|
27
|
+
|
|
11
28
|
export function readPort() {
|
|
12
29
|
const file = portFile();
|
|
13
30
|
let text;
|
|
@@ -98,10 +115,12 @@ export class Session {
|
|
|
98
115
|
export async function evaluateEverywhere(script, port = readPort()) {
|
|
99
116
|
const results = [];
|
|
100
117
|
for (const target of await listTargets(port)) {
|
|
118
|
+
const source = sourceFor(script, target);
|
|
119
|
+
if (source == null) continue;
|
|
101
120
|
const session = new Session(target);
|
|
102
121
|
try {
|
|
103
122
|
await session.open();
|
|
104
|
-
results.push({ url: target.url, value: await session.evaluate(
|
|
123
|
+
results.push({ url: target.url, value: await session.evaluate(source) });
|
|
105
124
|
} catch (err) {
|
|
106
125
|
results.push({ url: target.url, error: err.message });
|
|
107
126
|
} finally {
|
|
@@ -114,11 +133,14 @@ export async function evaluateEverywhere(script, port = readPort()) {
|
|
|
114
133
|
// The long-running part: themes every window Antigravity opens, keeps the
|
|
115
134
|
// theme across reloads, follows the port when the app restarts, and waits
|
|
116
135
|
// while it is closed. getSource() is read again when reload() is called.
|
|
117
|
-
|
|
136
|
+
// With onGone it does not wait: once the app is closed (or never answers) it
|
|
137
|
+
// stops and calls onGone, for a caller that lives only as long as the app.
|
|
138
|
+
export function watch({ getSource, log = () => {}, onGone = null }) {
|
|
118
139
|
const sessions = new Map(); // target id -> Session
|
|
119
140
|
let port = null;
|
|
120
141
|
let source = getSource();
|
|
121
142
|
let waiting = false;
|
|
143
|
+
let found = false, misses = 0;
|
|
122
144
|
|
|
123
145
|
function followPort() {
|
|
124
146
|
const next = readPort();
|
|
@@ -132,12 +154,14 @@ export function watch({ getSource, log = () => {} }) {
|
|
|
132
154
|
async function attachNew() {
|
|
133
155
|
for (const target of await listTargets(port)) {
|
|
134
156
|
if (sessions.has(target.id)) continue;
|
|
157
|
+
const script = sourceFor(source, target);
|
|
158
|
+
if (script == null) continue;
|
|
135
159
|
const session = new Session(target);
|
|
136
160
|
sessions.set(target.id, session);
|
|
137
161
|
session.onclose = () => sessions.delete(target.id);
|
|
138
162
|
try {
|
|
139
163
|
await session.open();
|
|
140
|
-
await session.apply(
|
|
164
|
+
await session.apply(script);
|
|
141
165
|
log(`themed ${target.type} ${target.url}`);
|
|
142
166
|
} catch (err) {
|
|
143
167
|
log(`${target.url}: ${err.message}`);
|
|
@@ -152,19 +176,26 @@ export function watch({ getSource, log = () => {} }) {
|
|
|
152
176
|
await attachNew();
|
|
153
177
|
if (waiting) log(`Antigravity found on port ${port}`);
|
|
154
178
|
waiting = false;
|
|
179
|
+
found = true;
|
|
180
|
+
misses = 0;
|
|
155
181
|
} catch (err) {
|
|
182
|
+
if (onGone && (found || ++misses >= 15)) {
|
|
183
|
+
log(`Antigravity is gone (${err.message})`);
|
|
184
|
+
api.stop();
|
|
185
|
+
onGone();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
156
188
|
if (!waiting) log(`waiting for Antigravity (${err.message})`);
|
|
157
189
|
waiting = true;
|
|
158
190
|
}
|
|
159
191
|
}
|
|
160
192
|
|
|
161
193
|
const timer = setInterval(tick, 2000);
|
|
162
|
-
|
|
163
|
-
return {
|
|
194
|
+
const api = {
|
|
164
195
|
async reload() {
|
|
165
196
|
source = getSource();
|
|
166
197
|
for (const session of sessions.values()) {
|
|
167
|
-
try { await session.apply(source); } catch (err) { log(`reload failed for ${session.target.url}: ${err.message}`); }
|
|
198
|
+
try { await session.apply(sourceFor(source, session.target)); } catch (err) { log(`reload failed for ${session.target.url}: ${err.message}`); }
|
|
168
199
|
}
|
|
169
200
|
log(`reloaded into ${sessions.size} window(s)`);
|
|
170
201
|
},
|
|
@@ -173,4 +204,6 @@ export function watch({ getSource, log = () => {} }) {
|
|
|
173
204
|
for (const session of sessions.values()) session.close();
|
|
174
205
|
},
|
|
175
206
|
};
|
|
207
|
+
tick();
|
|
208
|
+
return api;
|
|
176
209
|
}
|
package/lib/keeper.mjs
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// The keeper: a one-shot sidecar that Antigravity starts with every launch,
|
|
2
|
+
// from the plugin this tool installs (~/.gemini/config/plugins/
|
|
3
|
+
// antigravity-themes, outside the app's install folder, so an update leaves it
|
|
4
|
+
// alone). It keeps the native install in place:
|
|
5
|
+
//
|
|
6
|
+
// - everything current (the usual start): it exits at once;
|
|
7
|
+
// - an Antigravity update replaced resources/ and resources/app with it: it
|
|
8
|
+
// extracts the new app.asar and patches it for the next start, and, since
|
|
9
|
+
// this start ran without the patch, themes the open windows over the app's
|
|
10
|
+
// DevTools port and keeps them themed until the app closes;
|
|
11
|
+
// - the patch holds an older build of the scripts: it patches again and
|
|
12
|
+
// applies the new scripts to the open windows once.
|
|
13
|
+
//
|
|
14
|
+
// Antigravity runs it with its own Node (ELECTRON_RUN_AS_NODE). Its log is
|
|
15
|
+
// keeper.log in the sidecar's data folder.
|
|
16
|
+
import { appendFileSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { ROOT, pageScript, frameScript } from './bundle.mjs';
|
|
19
|
+
import { appState, ensureApp, patchApp } from './native.mjs';
|
|
20
|
+
import { evaluateEverywhere, scriptFor, watch } from './injector.mjs';
|
|
21
|
+
import { appResourcesDir, installDir } from './paths.mjs';
|
|
22
|
+
|
|
23
|
+
// Run by the app, the keeper is the app's own executable in Node mode.
|
|
24
|
+
// AGY_THEMES_RESOURCES points elsewhere (tests).
|
|
25
|
+
export function resourcesDir() {
|
|
26
|
+
if (process.env.AGY_THEMES_RESOURCES) return process.env.AGY_THEMES_RESOURCES;
|
|
27
|
+
const exe = process.execPath;
|
|
28
|
+
if (/^antigravity(\.exe)?$/i.test(path.basename(exe))) return path.join(path.dirname(exe), 'resources');
|
|
29
|
+
return appResourcesDir();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function logger() {
|
|
33
|
+
const dir = process.env.ANTIGRAVITY_EXECUTABLE_DATA_DIR || installDir();
|
|
34
|
+
const file = path.join(dir, 'keeper.log');
|
|
35
|
+
try {
|
|
36
|
+
mkdirSync(dir, { recursive: true });
|
|
37
|
+
if (statSync(file).size > 256 * 1024) writeFileSync(file, '');
|
|
38
|
+
} catch { /* no log yet */ }
|
|
39
|
+
return (line) => {
|
|
40
|
+
const entry = `${new Date().toISOString()} ${line}`;
|
|
41
|
+
console.log(entry);
|
|
42
|
+
try { appendFileSync(file, `${entry}\n`); } catch { /* read-only data folder: stdout only */ }
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function runKeeper(root = ROOT) {
|
|
47
|
+
const log = logger();
|
|
48
|
+
try {
|
|
49
|
+
const { build } = JSON.parse(readFileSync(path.join(root, 'build.json'), 'utf8'));
|
|
50
|
+
const resources = resourcesDir();
|
|
51
|
+
const state = appState(resources);
|
|
52
|
+
if (state.extractedVersion && state.extractedVersion === state.appVersion && state.stamp?.build === build) {
|
|
53
|
+
log(`current: app ${state.appVersion}, build ${build}`);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const page = pageScript(root), frame = frameScript(root);
|
|
57
|
+
const extracted = ensureApp(resources);
|
|
58
|
+
patchApp(state.appDir, { page, frame, build });
|
|
59
|
+
log(`${extracted ? 'extracted and patched' : 'patched'} app ${appState(resources).appVersion} with build ${build}`);
|
|
60
|
+
const scripts = scriptFor({ page, frame });
|
|
61
|
+
if (!extracted) {
|
|
62
|
+
const results = await evaluateEverywhere(scripts).catch((err) => { log(`not applied to the open windows: ${err.message}`); return []; });
|
|
63
|
+
if (results.length) log(`applied to ${results.filter((r) => !r.error).length} open window(s)`);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
// This start loaded the app without the patch
|
|
67
|
+
await new Promise((resolve) => watch({ getSource: () => scripts, log, onGone: resolve }));
|
|
68
|
+
log('app closed');
|
|
69
|
+
} catch (err) {
|
|
70
|
+
log(`failed: ${err.stack || err.message}`);
|
|
71
|
+
process.exitCode = 1;
|
|
72
|
+
}
|
|
73
|
+
}
|
package/lib/native.mjs
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// The native install: the scripts are written into Antigravity's own files,
|
|
2
|
+
// so no process has to stay running.
|
|
3
|
+
//
|
|
4
|
+
// - resources/app is the app extracted from resources/app.asar. Electron loads
|
|
5
|
+
// that folder ahead of the archive, so the archive itself stays the app's
|
|
6
|
+
// own and an update can replace it.
|
|
7
|
+
// - dist/preload.js runs in every window: it evaluates the page script in the
|
|
8
|
+
// window's main world.
|
|
9
|
+
// - dist/main.js gets a listener for sidecar frames. A preload does not run in
|
|
10
|
+
// subframes, and a sidecar pane (Swarm) is a cross-origin plugin:// iframe,
|
|
11
|
+
// so the main process evaluates the frame script in it as soon as its
|
|
12
|
+
// document commits.
|
|
13
|
+
// - agy-themes.json in resources/app records which build of the scripts is in
|
|
14
|
+
// it and which app version it came from, for the keeper (keeper.mjs).
|
|
15
|
+
//
|
|
16
|
+
// Each patch is everything after its marker line: patching again replaces it,
|
|
17
|
+
// and cutting the file at the marker restores the original.
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { FRAME_SCHEME } from './bundle.mjs';
|
|
22
|
+
import { extract, readFile, removeTree, rewrite } from './asar.mjs';
|
|
23
|
+
|
|
24
|
+
export const PRELOAD_MARKER = '// Antigravity Native Theme Injection (Zero background processes)';
|
|
25
|
+
export const MAIN_MARKER = '// Antigravity Native Theme Injection: sidecar frames';
|
|
26
|
+
const STAMP = 'agy-themes.json';
|
|
27
|
+
const PATCHED = { 'dist/preload.js': PRELOAD_MARKER, 'dist/main.js': MAIN_MARKER };
|
|
28
|
+
|
|
29
|
+
export const buildId = (page, frame) => createHash('sha1').update(page).update('\0').update(frame).digest('hex').slice(0, 16);
|
|
30
|
+
|
|
31
|
+
// The text before the marker: the file as the app shipped it
|
|
32
|
+
export function cut(text, marker) {
|
|
33
|
+
const at = text.indexOf(marker);
|
|
34
|
+
return at === -1 ? text : `${text.slice(0, at).trimEnd()}\n`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function patchFile(file, marker, block) {
|
|
38
|
+
writeFileSync(file, `${cut(readFileSync(file, 'utf8'), marker)}\n\n${marker}\n${block}\n`, 'utf8');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const preloadBlock = (page) => `(function() {
|
|
42
|
+
try {
|
|
43
|
+
const electron = require('electron');
|
|
44
|
+
if (electron && electron.webFrame && typeof electron.webFrame.executeJavaScript === 'function') {
|
|
45
|
+
electron.webFrame.executeJavaScript(${JSON.stringify(page)});
|
|
46
|
+
}
|
|
47
|
+
} catch (err) {
|
|
48
|
+
console.error('[antigravity-themes] Injection error:', err);
|
|
49
|
+
}
|
|
50
|
+
})();`;
|
|
51
|
+
|
|
52
|
+
const mainBlock = (frame) => `(function() {
|
|
53
|
+
try {
|
|
54
|
+
const { app, webFrameMain } = require('electron');
|
|
55
|
+
const source = ${JSON.stringify(frame)};
|
|
56
|
+
app.on('web-contents-created', (_event, contents) => {
|
|
57
|
+
contents.on('did-frame-navigate', (_e, url, _code, _status, isMainFrame, processId, routingId) => {
|
|
58
|
+
if (isMainFrame || typeof url !== 'string' || !url.startsWith(${JSON.stringify(FRAME_SCHEME)})) return;
|
|
59
|
+
try {
|
|
60
|
+
const frame = webFrameMain.fromId(processId, routingId);
|
|
61
|
+
if (frame) frame.executeJavaScript(source).catch(() => {});
|
|
62
|
+
} catch (err) {
|
|
63
|
+
// the frame went away while its document was committing
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
} catch (err) {
|
|
68
|
+
console.error('[antigravity-themes] Frame injection error:', err);
|
|
69
|
+
}
|
|
70
|
+
})();`;
|
|
71
|
+
|
|
72
|
+
const readJson = (file) => {
|
|
73
|
+
try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return null; }
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// appDir: the extracted app (resources/app)
|
|
77
|
+
export function patchApp(appDir, { page, frame, build = buildId(page, frame) }) {
|
|
78
|
+
const preload = path.join(appDir, 'dist', 'preload.js');
|
|
79
|
+
const main = path.join(appDir, 'dist', 'main.js');
|
|
80
|
+
patchFile(preload, PRELOAD_MARKER, preloadBlock(page));
|
|
81
|
+
patchFile(main, MAIN_MARKER, mainBlock(frame));
|
|
82
|
+
const appVersion = readJson(path.join(appDir, 'package.json'))?.version ?? null;
|
|
83
|
+
writeFileSync(path.join(appDir, STAMP), `${JSON.stringify({ build, appVersion }, null, 2)}\n`);
|
|
84
|
+
return { preload, main, build, appVersion };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// What is installed: the app's version (in its archive), the extracted copy's
|
|
88
|
+
// version, and the stamp of the scripts in it
|
|
89
|
+
export function appState(resources) {
|
|
90
|
+
const archive = path.join(resources, 'app.asar');
|
|
91
|
+
const appDir = path.join(resources, 'app');
|
|
92
|
+
let appVersion = null;
|
|
93
|
+
try { appVersion = JSON.parse(readFile(archive, 'package.json')).version; } catch { /* no archive */ }
|
|
94
|
+
return {
|
|
95
|
+
archive,
|
|
96
|
+
appDir,
|
|
97
|
+
appVersion,
|
|
98
|
+
extractedVersion: readJson(path.join(appDir, 'package.json'))?.version ?? null,
|
|
99
|
+
stamp: readJson(path.join(appDir, STAMP)),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// resources/app from the app's archive, unless it already holds this version.
|
|
104
|
+
// Extracted beside it and swapped in, so the app never finds half a folder.
|
|
105
|
+
// Returns true when it was (re)extracted.
|
|
106
|
+
export function ensureApp(resources) {
|
|
107
|
+
const state = appState(resources);
|
|
108
|
+
if (!state.appVersion) throw new Error(`${state.archive} was not found or is not an app archive`);
|
|
109
|
+
if (state.extractedVersion === state.appVersion) return false;
|
|
110
|
+
const fresh = `${state.appDir}.agy-new`;
|
|
111
|
+
removeTree(fresh);
|
|
112
|
+
extract(state.archive, fresh);
|
|
113
|
+
for (const [file, marker] of Object.entries(PATCHED)) { // an archive patched by an earlier install
|
|
114
|
+
const full = path.join(fresh, file);
|
|
115
|
+
if (existsSync(full)) writeFileSync(full, cut(readFileSync(full, 'utf8'), marker), 'utf8');
|
|
116
|
+
}
|
|
117
|
+
if (existsSync(state.appDir)) {
|
|
118
|
+
const old = `${state.appDir}.agy-old`;
|
|
119
|
+
removeTree(old);
|
|
120
|
+
renameSync(state.appDir, old);
|
|
121
|
+
renameSync(fresh, state.appDir);
|
|
122
|
+
removeTree(old);
|
|
123
|
+
} else {
|
|
124
|
+
renameSync(fresh, state.appDir);
|
|
125
|
+
}
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Earlier builds of this tool (before 1.0.13) also patched app.asar itself:
|
|
130
|
+
// put the app's own preload and main back in it. Returns true when it did.
|
|
131
|
+
export function restoreArchive(resources) {
|
|
132
|
+
const archive = path.join(resources, 'app.asar');
|
|
133
|
+
if (!existsSync(archive)) return false;
|
|
134
|
+
const replacements = {};
|
|
135
|
+
for (const [file, marker] of Object.entries(PATCHED)) {
|
|
136
|
+
const text = readFile(archive, file).toString('utf8');
|
|
137
|
+
if (text.includes(marker)) replacements[file] = Buffer.from(cut(text, marker), 'utf8');
|
|
138
|
+
}
|
|
139
|
+
if (!Object.keys(replacements).length) return false;
|
|
140
|
+
rewrite(archive, replacements);
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// The app's preload and main without the patches, in place (uninstalling
|
|
145
|
+
// while the app runs: it keeps loading this folder until it restarts)
|
|
146
|
+
export function unpatchApp(appDir) {
|
|
147
|
+
for (const [file, marker] of Object.entries(PATCHED)) {
|
|
148
|
+
const full = path.join(appDir, file);
|
|
149
|
+
if (existsSync(full)) writeFileSync(full, cut(readFileSync(full, 'utf8'), marker), 'utf8');
|
|
150
|
+
}
|
|
151
|
+
removeTree(path.join(appDir, STAMP));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// app.asar.patched (and its .unpacked folder) from the hand-made builds that
|
|
155
|
+
// preceded the keeper: unused, and holding an old copy of the app
|
|
156
|
+
export function removeLegacyArchive(resources) {
|
|
157
|
+
const legacy = path.join(resources, 'app.asar.patched');
|
|
158
|
+
if (!existsSync(legacy)) return false;
|
|
159
|
+
let ours = false;
|
|
160
|
+
try { ours = readFile(legacy, 'dist/preload.js').toString('utf8').includes(PRELOAD_MARKER); } catch { /* not an archive */ }
|
|
161
|
+
if (!ours) return false;
|
|
162
|
+
removeTree(legacy);
|
|
163
|
+
removeTree(`${legacy}.unpacked`);
|
|
164
|
+
return true;
|
|
165
|
+
}
|
package/lib/paths.mjs
CHANGED
|
@@ -14,8 +14,9 @@ export function appDataDir() {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
// Antigravity starts with --remote-debugging-port=0 and writes the port it
|
|
17
|
-
// got here (first line), anew on every launch
|
|
18
|
-
|
|
17
|
+
// got here (first line), anew on every launch. AGY_THEMES_PORT_FILE points
|
|
18
|
+
// elsewhere (tests).
|
|
19
|
+
export const portFile = () => process.env.AGY_THEMES_PORT_FILE || path.join(appDataDir(), 'DevToolsActivePort');
|
|
19
20
|
|
|
20
21
|
export function installDir() {
|
|
21
22
|
if (process.platform === 'win32') return path.join(process.env.LOCALAPPDATA ?? path.join(home, 'AppData', 'Local'), 'antigravity-themes');
|