@materialwebunofficial/antigravity-themes 1.0.4 → 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/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 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).
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
- export function pageScript(root = ROOT) {
42
- const parts = MODULES.map((file) => readFileSync(path.join(root, file), 'utf8'));
43
- return `(() => {
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
- ${parts.join('\n;')}
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
- // Undoes everything the page script did in that window
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, or null without one
65
- export const PROBE_SCRIPT = `JSON.stringify(window.__agyThemeHost ? { version: window.__agyThemeHost.version, theme: window.__agyThemeHost.get() } : null)`;
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(script) });
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
- export function watch({ getSource, log = () => {} }) {
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(source);
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
- tick();
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
- export const portFile = () => path.join(appDataDir(), 'DevToolsActivePort');
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');