vibe-usage-cli 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +275 -2
- package/build/cli.js +197 -0
- package/build/core/claude-processes.d.ts +51 -0
- package/build/core/claude-processes.js +288 -0
- package/build/core/claude-profile.d.ts +2 -0
- package/build/core/claude-profile.js +146 -0
- package/build/core/claude-renew.d.ts +12 -0
- package/build/core/claude-renew.js +79 -0
- package/build/core/continuity.d.ts +90 -0
- package/build/core/continuity.js +1244 -0
- package/build/core/diagnostics.d.ts +25 -0
- package/build/core/diagnostics.js +229 -0
- package/build/core/environment.d.ts +12 -0
- package/build/core/environment.js +25 -0
- package/build/core/errors.d.ts +23 -0
- package/build/core/errors.js +48 -0
- package/build/core/files.d.ts +20 -0
- package/build/core/files.js +136 -0
- package/build/core/glance.d.ts +25 -0
- package/build/core/glance.js +107 -0
- package/build/core/handover.d.ts +61 -0
- package/build/core/handover.js +333 -0
- package/build/core/history.d.ts +19 -0
- package/build/core/history.js +134 -0
- package/build/core/launch.d.ts +78 -0
- package/build/core/launch.js +256 -0
- package/build/core/measurements.d.ts +11 -0
- package/build/core/measurements.js +75 -0
- package/build/core/model-families.d.ts +12 -0
- package/build/core/model-families.js +61 -0
- package/build/core/model.d.ts +32 -0
- package/build/core/model.js +86 -0
- package/build/core/models.d.ts +25 -0
- package/build/core/models.js +164 -0
- package/build/core/redact.d.ts +2 -0
- package/build/core/redact.js +64 -0
- package/build/core/selection.d.ts +65 -0
- package/build/core/selection.js +173 -0
- package/build/core/service.d.ts +35 -0
- package/build/core/service.js +484 -0
- package/build/core/sessions.d.ts +87 -0
- package/build/core/sessions.js +868 -0
- package/build/core/settings.d.ts +16 -0
- package/build/core/settings.js +377 -0
- package/build/core/tray-icon.d.ts +5 -0
- package/build/core/tray-icon.js +91 -0
- package/build/core/updater.d.ts +29 -0
- package/build/core/updater.js +88 -0
- package/build/core/updates.d.ts +5 -0
- package/build/core/updates.js +29 -0
- package/build/launchers/discover-antigravity.ps1 +68 -0
- package/build/launchers/terminal.ps1 +78 -0
- package/build/main/main.js +587 -0
- package/build/providers/antigravity.d.ts +48 -0
- package/build/providers/antigravity.js +463 -0
- package/build/providers/claude-keychain.d.ts +13 -0
- package/build/providers/claude-keychain.js +42 -0
- package/build/providers/claude.d.ts +33 -0
- package/build/providers/claude.js +218 -0
- package/build/providers/codex.d.ts +21 -0
- package/build/providers/codex.js +221 -0
- package/build/providers/common.d.ts +63 -0
- package/build/providers/common.js +294 -0
- package/build/providers/cursor.d.ts +14 -0
- package/build/providers/cursor.js +141 -0
- package/build/providers/profiles.d.ts +10 -0
- package/build/providers/profiles.js +69 -0
- package/build/providers/tls-trust.d.ts +7 -0
- package/build/providers/tls-trust.js +29 -0
- package/build/providers/types.d.ts +19 -0
- package/build/providers/types.js +1 -0
- package/build/shared/account-state.d.ts +19 -0
- package/build/shared/account-state.js +75 -0
- package/build/shared/catalog.d.ts +85 -0
- package/build/shared/catalog.js +125 -0
- package/build/shared/format.d.ts +40 -0
- package/build/shared/format.js +134 -0
- package/build/shared/i18n/index.d.ts +12 -0
- package/build/shared/i18n/index.js +40 -0
- package/build/shared/i18n/locales/de.d.ts +2 -0
- package/build/shared/i18n/locales/de.js +328 -0
- package/build/shared/i18n/locales/en.d.ts +328 -0
- package/build/shared/i18n/locales/en.js +330 -0
- package/build/shared/i18n/locales/es.d.ts +2 -0
- package/build/shared/i18n/locales/es.js +328 -0
- package/build/shared/i18n/locales/fr.d.ts +2 -0
- package/build/shared/i18n/locales/fr.js +328 -0
- package/build/shared/i18n/locales/it.d.ts +2 -0
- package/build/shared/i18n/locales/it.js +327 -0
- package/build/shared/i18n/locales/ja.d.ts +2 -0
- package/build/shared/i18n/locales/ja.js +322 -0
- package/build/shared/i18n/locales/ko.d.ts +2 -0
- package/build/shared/i18n/locales/ko.js +322 -0
- package/build/shared/i18n/locales/nl.d.ts +2 -0
- package/build/shared/i18n/locales/nl.js +327 -0
- package/build/shared/i18n/locales/pl.d.ts +2 -0
- package/build/shared/i18n/locales/pl.js +332 -0
- package/build/shared/i18n/locales/pt.d.ts +2 -0
- package/build/shared/i18n/locales/pt.js +328 -0
- package/build/shared/i18n/locales/ru.d.ts +2 -0
- package/build/shared/i18n/locales/ru.js +331 -0
- package/build/shared/i18n/locales/tr.d.ts +2 -0
- package/build/shared/i18n/locales/tr.js +322 -0
- package/build/shared/i18n/locales/zh.d.ts +2 -0
- package/build/shared/i18n/locales/zh.js +322 -0
- package/build/shared/i18n/messages.d.ts +10 -0
- package/build/shared/i18n/messages.js +1 -0
- package/build/shared/ipc.d.ts +138 -0
- package/build/shared/ipc.js +2 -0
- package/build/shared/tsconfig.tsbuildinfo +1 -0
- package/build/shared/types.d.ts +205 -0
- package/build/shared/types.js +1 -0
- package/package.json +144 -4
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
// Electron entry point: the tray, the window and the IPC wiring around the quota service.
|
|
2
|
+
import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, nativeImage, net, Notification, powerMonitor, screen, session, shell, Tray } from 'electron';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { pathToFileURL } from 'node:url';
|
|
7
|
+
import electronUpdater from 'electron-updater';
|
|
8
|
+
import { createContinuity } from "../core/continuity.js";
|
|
9
|
+
import { createToolProbe } from "../core/diagnostics.js";
|
|
10
|
+
import { AppError } from "../core/errors.js";
|
|
11
|
+
import { createQuotaWatch, describeAlert, describeNotice, trayTool, trayTooltip } from "../core/glance.js";
|
|
12
|
+
import { createHistory } from "../core/history.js";
|
|
13
|
+
import { renewClaudeSignIn } from "../core/claude-renew.js";
|
|
14
|
+
import { MEASUREMENTS_FILE } from "../core/measurements.js";
|
|
15
|
+
import { createService } from "../core/service.js";
|
|
16
|
+
import { appDataDir, inQuietHours, settingsPath } from "../core/settings.js";
|
|
17
|
+
import { ringIcon } from "../core/tray-icon.js";
|
|
18
|
+
import { createInstaller, updateSupport } from "../core/updater.js";
|
|
19
|
+
import { checkForUpdate, isReleasePage } from "../core/updates.js";
|
|
20
|
+
import { APP_NAME, APP_REPOSITORY, DURATION_MS } from "../shared/catalog.js";
|
|
21
|
+
import { DEFAULT_LANGUAGE, translator } from "../shared/i18n/index.js";
|
|
22
|
+
import { SUGGESTION_BUTTONS } from "../shared/format.js";
|
|
23
|
+
import { IPC_PREFIX, SNAPSHOT_EVENT } from "../shared/ipc.js";
|
|
24
|
+
import { createHandlers } from "./handlers.js";
|
|
25
|
+
import { assertSender, envelope } from "./ipc-policy.js";
|
|
26
|
+
const WINDOW = {
|
|
27
|
+
defaultWidth: 430,
|
|
28
|
+
minWidth: 340,
|
|
29
|
+
maxWidth: 620,
|
|
30
|
+
defaultHeight: 900,
|
|
31
|
+
minHeight: 420,
|
|
32
|
+
// The compact view is as tall as its content, within these limits.
|
|
33
|
+
compactMinHeight: 120,
|
|
34
|
+
compactMaxHeight: 600,
|
|
35
|
+
screenMargin: 30,
|
|
36
|
+
background: '#171a1e'
|
|
37
|
+
};
|
|
38
|
+
// Tray icon sizes for 100%, 150% and 200% display scaling.
|
|
39
|
+
const TRAY_ICON_SCALES = [1, 1.5, 2];
|
|
40
|
+
const TRAY_ICON_SIZE = 16;
|
|
41
|
+
const UPDATE_FIRST_CHECK_DELAY_MS = 15 * 1000;
|
|
42
|
+
const UPDATE_CHECK_INTERVAL_MS = DURATION_MS.DAY;
|
|
43
|
+
// A check that gets no answer ends, so that the next one is still scheduled.
|
|
44
|
+
const UPDATE_REQUEST_TIMEOUT_MS = 15 * 1000;
|
|
45
|
+
const BOUNDS_SAVE_DELAY_MS = 500;
|
|
46
|
+
// A tool writes several files while signing in; it is read once they settle.
|
|
47
|
+
const FOLDER_CHANGE_DEBOUNCE_MS = 1000;
|
|
48
|
+
// The network is often not back yet when the system reports that it woke up.
|
|
49
|
+
const RESUME_REFRESH_DELAY_MS = 5000;
|
|
50
|
+
// Returning to the window (after a sign-in in the browser, say) reads quota again unless it
|
|
51
|
+
// was read this recently.
|
|
52
|
+
const FOCUS_REFRESH_MIN_AGE_MS = 30 * 1000;
|
|
53
|
+
// Marks a start by the login item, which opens the app to the tray only. Windows passes it
|
|
54
|
+
// to the registered command; macOS reports such a start through `wasOpenedAtLogin`.
|
|
55
|
+
const OPENED_AT_LOGIN_ARG = '--opened-at-login';
|
|
56
|
+
const LOGIN_ITEM = { args: [OPENED_AT_LOGIN_ARG] };
|
|
57
|
+
// An unpackaged build would register the bare Electron binary.
|
|
58
|
+
const LOGIN_ITEM_SUPPORTED = app.isPackaged && (process.platform === 'darwin' || process.platform === 'win32');
|
|
59
|
+
const HTML = path.join(import.meta.dirname, '../ui/index.html');
|
|
60
|
+
const PRELOAD = path.join(import.meta.dirname, 'preload.cjs');
|
|
61
|
+
const ASSETS = path.join(import.meta.dirname, '../../assets');
|
|
62
|
+
const EXPECTED_URL = pathToFileURL(HTML).href;
|
|
63
|
+
app.setName(APP_NAME);
|
|
64
|
+
// Every renderer is sandboxed, regardless of per-window options.
|
|
65
|
+
app.enableSandbox();
|
|
66
|
+
lockDownWebContents();
|
|
67
|
+
// One owner for where the data lives, shared with the CLI.
|
|
68
|
+
app.setPath('userData', appDataDir());
|
|
69
|
+
const openedAtLogin = () => process.argv.includes(OPENED_AT_LOGIN_ARG) || (process.platform === 'darwin' && app.getLoginItemSettings().wasOpenedAtLogin);
|
|
70
|
+
let quitting = false;
|
|
71
|
+
// Wording for a failed start, in the user's language once the settings are loaded.
|
|
72
|
+
let startupT = translator(DEFAULT_LANGUAGE);
|
|
73
|
+
let poll;
|
|
74
|
+
let boundsTimer;
|
|
75
|
+
let resumeTimer;
|
|
76
|
+
let updateTimer;
|
|
77
|
+
// Records a deliberate quit, so sessions closed after it are not taken for ones the system ended.
|
|
78
|
+
let quitContinuity;
|
|
79
|
+
// Held so that its click handler lives as long as the notification is shown.
|
|
80
|
+
let notification;
|
|
81
|
+
const contains = (area, bounds) => bounds.x >= area.x &&
|
|
82
|
+
bounds.x + bounds.width <= area.x + area.width &&
|
|
83
|
+
bounds.y >= area.y &&
|
|
84
|
+
bounds.y + bounds.height <= area.y + area.height;
|
|
85
|
+
// The saved size within the screen's limits; the saved position only while it is on a screen.
|
|
86
|
+
function initialBounds(saved) {
|
|
87
|
+
const area = screen.getPrimaryDisplay().workArea;
|
|
88
|
+
const width = Math.min(Math.max(saved?.width ?? WINDOW.defaultWidth, WINDOW.minWidth), Math.min(area.width, WINDOW.maxWidth));
|
|
89
|
+
const height = Math.min(Math.max(saved?.height ?? WINDOW.defaultHeight, WINDOW.minHeight), area.height - WINDOW.screenMargin);
|
|
90
|
+
const placed = saved && { x: saved.x, y: saved.y, width, height };
|
|
91
|
+
return placed && screen.getAllDisplays().some(display => contains(display.workArea, placed)) ? placed : { width, height };
|
|
92
|
+
}
|
|
93
|
+
function createWindow(settings, service) {
|
|
94
|
+
const win = new BrowserWindow({
|
|
95
|
+
...initialBounds(settings.window),
|
|
96
|
+
minWidth: WINDOW.minWidth,
|
|
97
|
+
minHeight: settings.compact ? WINDOW.compactMinHeight : WINDOW.minHeight,
|
|
98
|
+
title: APP_NAME,
|
|
99
|
+
frame: false,
|
|
100
|
+
show: false,
|
|
101
|
+
alwaysOnTop: settings.pinned,
|
|
102
|
+
backgroundColor: WINDOW.background,
|
|
103
|
+
icon: path.join(ASSETS, 'icon.png'),
|
|
104
|
+
webPreferences: { preload: PRELOAD, nodeIntegration: false, contextIsolation: true, sandbox: true }
|
|
105
|
+
});
|
|
106
|
+
win.on('close', event => {
|
|
107
|
+
if (quitting)
|
|
108
|
+
return;
|
|
109
|
+
event.preventDefault();
|
|
110
|
+
win.hide();
|
|
111
|
+
});
|
|
112
|
+
// The compact view sizes itself to its content: only its position is kept, so the full
|
|
113
|
+
// window opens again at the size the user gave it.
|
|
114
|
+
const saveBounds = () => {
|
|
115
|
+
clearTimeout(boundsTimer);
|
|
116
|
+
boundsTimer = setTimeout(() => {
|
|
117
|
+
if (win.isDestroyed())
|
|
118
|
+
return;
|
|
119
|
+
const bounds = win.getBounds();
|
|
120
|
+
const { compact, window: saved } = service.snapshot().settings;
|
|
121
|
+
const window = compact && saved ? { ...saved, x: bounds.x, y: bounds.y } : compact ? undefined : bounds;
|
|
122
|
+
if (window)
|
|
123
|
+
service.updateSettings({ window }).catch(() => undefined);
|
|
124
|
+
}, BOUNDS_SAVE_DELAY_MS);
|
|
125
|
+
};
|
|
126
|
+
win.on('resize', saveBounds);
|
|
127
|
+
win.on('move', saveBounds);
|
|
128
|
+
return win;
|
|
129
|
+
}
|
|
130
|
+
function show(win) {
|
|
131
|
+
if (win.isDestroyed())
|
|
132
|
+
return;
|
|
133
|
+
if (win.isMinimized())
|
|
134
|
+
win.restore();
|
|
135
|
+
win.show();
|
|
136
|
+
win.focus();
|
|
137
|
+
}
|
|
138
|
+
function quit() {
|
|
139
|
+
quitting = true;
|
|
140
|
+
app.quit();
|
|
141
|
+
}
|
|
142
|
+
function trayMenu(t, win, refresh) {
|
|
143
|
+
return Menu.buildFromTemplate([
|
|
144
|
+
{
|
|
145
|
+
label: t('trayOpen'),
|
|
146
|
+
click: () => {
|
|
147
|
+
show(win);
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
{ label: t('trayRefresh'), click: refresh },
|
|
151
|
+
{ type: 'separator' },
|
|
152
|
+
{ label: t('trayQuit'), click: quit }
|
|
153
|
+
]);
|
|
154
|
+
}
|
|
155
|
+
// The app shows one local page: no web contents may navigate, open windows or embed a
|
|
156
|
+
// webview. Applied to every web contents the app creates, not only to the known window.
|
|
157
|
+
function lockDownWebContents() {
|
|
158
|
+
app.on('web-contents-created', (_event, contents) => {
|
|
159
|
+
contents.setWindowOpenHandler(() => ({ action: 'deny' }));
|
|
160
|
+
contents.on('will-navigate', event => {
|
|
161
|
+
event.preventDefault();
|
|
162
|
+
});
|
|
163
|
+
contents.on('will-attach-webview', event => {
|
|
164
|
+
event.preventDefault();
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
function watchFolder(dir, onChange) {
|
|
169
|
+
let timer;
|
|
170
|
+
let watcher;
|
|
171
|
+
try {
|
|
172
|
+
watcher = fs.watch(dir, () => {
|
|
173
|
+
clearTimeout(timer);
|
|
174
|
+
timer = setTimeout(onChange, FOLDER_CHANGE_DEBOUNCE_MS);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
// A folder that cannot be watched leaves the sign-in to the regular refresh.
|
|
179
|
+
return () => undefined;
|
|
180
|
+
}
|
|
181
|
+
watcher.on('error', () => {
|
|
182
|
+
watcher.close();
|
|
183
|
+
});
|
|
184
|
+
return () => {
|
|
185
|
+
clearTimeout(timer);
|
|
186
|
+
watcher.close();
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
function loginItem() {
|
|
190
|
+
return { supported: LOGIN_ITEM_SUPPORTED, enabled: LOGIN_ITEM_SUPPORTED && app.getLoginItemSettings(LOGIN_ITEM).openAtLogin };
|
|
191
|
+
}
|
|
192
|
+
function setLoginItem(enabled) {
|
|
193
|
+
if (LOGIN_ITEM_SUPPORTED)
|
|
194
|
+
app.setLoginItemSettings({ ...LOGIN_ITEM, openAtLogin: enabled });
|
|
195
|
+
return loginItem();
|
|
196
|
+
}
|
|
197
|
+
// Windows shows tray balloons as system notifications without the Start menu shortcut that
|
|
198
|
+
// its toast notifications require, which a portable zip does not install. A balloon has no
|
|
199
|
+
// buttons: clicking it opens the window, where the same action waits as a suggestion.
|
|
200
|
+
function notify(tray, win, { title, body }, action) {
|
|
201
|
+
if (process.platform === 'win32') {
|
|
202
|
+
tray.displayBalloon({ title, content: body, iconType: 'info' });
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (!Notification.isSupported())
|
|
206
|
+
return;
|
|
207
|
+
notification = new Notification({ title, body, actions: action ? [{ type: 'button', text: action.text }] : [] });
|
|
208
|
+
notification.on('click', () => {
|
|
209
|
+
show(win);
|
|
210
|
+
});
|
|
211
|
+
notification.on('action', () => {
|
|
212
|
+
action?.run();
|
|
213
|
+
});
|
|
214
|
+
notification.show();
|
|
215
|
+
}
|
|
216
|
+
// The ring at each display scale, built from one percentage.
|
|
217
|
+
function trayImage(percent, lowPercent) {
|
|
218
|
+
const image = nativeImage.createEmpty();
|
|
219
|
+
for (const scaleFactor of TRAY_ICON_SCALES) {
|
|
220
|
+
const size = Math.round(TRAY_ICON_SIZE * scaleFactor);
|
|
221
|
+
image.addRepresentation({ scaleFactor, width: size, height: size, buffer: ringIcon(size, percent, lowPercent) });
|
|
222
|
+
}
|
|
223
|
+
return image;
|
|
224
|
+
}
|
|
225
|
+
const fetchJson = async (url) => {
|
|
226
|
+
const response = await net.fetch(url, {
|
|
227
|
+
headers: { Accept: 'application/vnd.github+json', 'User-Agent': APP_NAME },
|
|
228
|
+
signal: AbortSignal.timeout(UPDATE_REQUEST_TIMEOUT_MS)
|
|
229
|
+
});
|
|
230
|
+
// No answer (GitHub's rate limit, an outage) is not "no release": the check failed.
|
|
231
|
+
if (!response.ok)
|
|
232
|
+
throw new Error(`release check answered ${response.status}`);
|
|
233
|
+
return response.json();
|
|
234
|
+
};
|
|
235
|
+
// The feed is fixed to the app's repository and nothing happens without the user: no automatic
|
|
236
|
+
// download, no install on quit, no prerelease or older version.
|
|
237
|
+
function releaseUpdater() {
|
|
238
|
+
const { autoUpdater } = electronUpdater;
|
|
239
|
+
autoUpdater.autoDownload = false;
|
|
240
|
+
autoUpdater.autoInstallOnAppQuit = false;
|
|
241
|
+
autoUpdater.allowPrerelease = false;
|
|
242
|
+
autoUpdater.allowDowngrade = false;
|
|
243
|
+
autoUpdater.logger = null;
|
|
244
|
+
const [owner = '', repo = ''] = APP_REPOSITORY.split('/');
|
|
245
|
+
autoUpdater.setFeedURL({ provider: 'github', owner, repo });
|
|
246
|
+
return autoUpdater;
|
|
247
|
+
}
|
|
248
|
+
// The page needs no browser permission (camera, web notifications, …): deny both the prompt and
|
|
249
|
+
// the silent check.
|
|
250
|
+
function denyPermissions() {
|
|
251
|
+
session.defaultSession.setPermissionRequestHandler((_webContents, _permission, callback) => {
|
|
252
|
+
callback(false);
|
|
253
|
+
});
|
|
254
|
+
session.defaultSession.setPermissionCheckHandler(() => false);
|
|
255
|
+
}
|
|
256
|
+
async function start() {
|
|
257
|
+
denyPermissions();
|
|
258
|
+
const dataDir = app.getPath('userData');
|
|
259
|
+
const homeDir = os.homedir();
|
|
260
|
+
// Set once the window can show a change; until then there is nothing to tell.
|
|
261
|
+
let changed = () => undefined;
|
|
262
|
+
const service = await createService({
|
|
263
|
+
onChange: () => {
|
|
264
|
+
changed();
|
|
265
|
+
},
|
|
266
|
+
settingsPath: settingsPath(dataDir),
|
|
267
|
+
measurementsPath: path.join(dataDir, MEASUREMENTS_FILE),
|
|
268
|
+
renewSignIn: { claude: account => renewClaudeSignIn(account, { dataDir, home: homeDir }) }
|
|
269
|
+
});
|
|
270
|
+
const history = await createHistory({ dataDir });
|
|
271
|
+
const continuity = await createContinuity({ homeDir, dataDir, launch: { dataDir, homeDir, platform: process.platform } });
|
|
272
|
+
quitContinuity = () => {
|
|
273
|
+
continuity.quit();
|
|
274
|
+
};
|
|
275
|
+
// macOS and Linux quit the app as the system shuts down: that is the system ending it, not the user.
|
|
276
|
+
powerMonitor.on('shutdown', () => {
|
|
277
|
+
quitContinuity = undefined;
|
|
278
|
+
});
|
|
279
|
+
const t = (key, params) => translator(service.snapshot().settings.language)(key, params);
|
|
280
|
+
const format = () => ({ t, locale: service.snapshot().settings.language });
|
|
281
|
+
startupT = t;
|
|
282
|
+
const win = createWindow(service.snapshot().settings, service);
|
|
283
|
+
const tray = new Tray(nativeImage.createFromPath(path.join(ASSETS, 'tray.png')));
|
|
284
|
+
// Replaced by each tool's quota once the first snapshot is published.
|
|
285
|
+
tray.setToolTip(APP_NAME);
|
|
286
|
+
// The ring last drawn, by its percentage and low line.
|
|
287
|
+
let trayDrawn;
|
|
288
|
+
let update;
|
|
289
|
+
const unsupported = await updateSupport({ packaged: app.isPackaged, platform: process.platform, execPath: process.execPath });
|
|
290
|
+
const installer = createInstaller(unsupported ?? releaseUpdater(), () => {
|
|
291
|
+
send(view());
|
|
292
|
+
});
|
|
293
|
+
const watchLow = createQuotaWatch();
|
|
294
|
+
// Quiet hours hold back every notification; the window still shows what happened.
|
|
295
|
+
const quiet = () => inQuietHours(service.snapshot().settings.quietHours, new Date());
|
|
296
|
+
const showAlert = (quotaAlert) => {
|
|
297
|
+
notify(tray, win, describeAlert(quotaAlert, format()));
|
|
298
|
+
};
|
|
299
|
+
// The snapshot as the window shows it: with forecasts, suggestions and a newer release.
|
|
300
|
+
const view = () => ({
|
|
301
|
+
...history.annotate(service.snapshot()),
|
|
302
|
+
suggestions: continuity.suggestions(),
|
|
303
|
+
...(update ? { update, updateInstall: installer.state() } : {})
|
|
304
|
+
});
|
|
305
|
+
const send = (snapshot) => {
|
|
306
|
+
if (!win.isDestroyed())
|
|
307
|
+
win.webContents.send(SNAPSHOT_EVENT, snapshot);
|
|
308
|
+
};
|
|
309
|
+
// The first tool of the window's order, as a ring that fills with its best quota left.
|
|
310
|
+
const updateTray = (snapshot) => {
|
|
311
|
+
tray.setToolTip(trayTooltip(snapshot, format()));
|
|
312
|
+
const tool = trayTool(snapshot);
|
|
313
|
+
if (!tool)
|
|
314
|
+
return;
|
|
315
|
+
const percent = tool.percent === null ? null : Math.round(tool.percent);
|
|
316
|
+
const { lowQuotaPercent } = snapshot.settings;
|
|
317
|
+
// Drawn again when its percentage or the low line that colours it changed.
|
|
318
|
+
const drawn = `${percent}:${lowQuotaPercent}`;
|
|
319
|
+
if (drawn === trayDrawn)
|
|
320
|
+
return;
|
|
321
|
+
trayDrawn = drawn;
|
|
322
|
+
tray.setImage(trayImage(percent, lowQuotaPercent));
|
|
323
|
+
if (process.platform === 'darwin')
|
|
324
|
+
tray.setTitle(percent === null ? '' : `${percent}%`);
|
|
325
|
+
};
|
|
326
|
+
const showNotice = (notice, notifySuggestions) => {
|
|
327
|
+
const described = describeNotice(notice, format(), service.snapshot().settings.accounts);
|
|
328
|
+
if (notice.kind !== 'suggested') {
|
|
329
|
+
notify(tray, win, described);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (!notifySuggestions)
|
|
333
|
+
return;
|
|
334
|
+
const { id, kind, accountId } = notice.suggestion;
|
|
335
|
+
notify(tray, win, described, {
|
|
336
|
+
text: t(SUGGESTION_BUTTONS[kind]),
|
|
337
|
+
// Started from the notification, a failure is told the same way, as nothing else shows it.
|
|
338
|
+
run: () => {
|
|
339
|
+
handlers
|
|
340
|
+
.continueWork(id)
|
|
341
|
+
.catch((error) => {
|
|
342
|
+
const from = service
|
|
343
|
+
.snapshot()
|
|
344
|
+
.providers.flatMap(p => p.accounts)
|
|
345
|
+
.find(a => a.id === accountId);
|
|
346
|
+
const code = error instanceof AppError ? error.code : 'unexpected';
|
|
347
|
+
if (from)
|
|
348
|
+
showNotice({ kind: 'failed', from, code, ...(kind === 'low' ? {} : { attempt: kind }) }, notifySuggestions);
|
|
349
|
+
})
|
|
350
|
+
.finally(publish);
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
};
|
|
354
|
+
// Every change of state passes here: the window, the tray and the notifications follow.
|
|
355
|
+
const publish = () => {
|
|
356
|
+
const current = service.snapshot();
|
|
357
|
+
history.record(current);
|
|
358
|
+
const snapshot = view();
|
|
359
|
+
send(snapshot);
|
|
360
|
+
updateTray(snapshot);
|
|
361
|
+
const low = watchLow(current);
|
|
362
|
+
// A suggestion replaces the plain low-quota notification for its account.
|
|
363
|
+
void continuity
|
|
364
|
+
.observe(current, { low })
|
|
365
|
+
.then(notices => {
|
|
366
|
+
if (notices.length)
|
|
367
|
+
send(view());
|
|
368
|
+
if (quiet())
|
|
369
|
+
return;
|
|
370
|
+
const { notifications } = service.snapshot().settings;
|
|
371
|
+
const suggested = new Set(notices.flatMap(n => (n.kind === 'suggested' ? [n.suggestion.accountId] : [])));
|
|
372
|
+
if (notifications)
|
|
373
|
+
low.filter(a => !suggested.has(a.account.id)).forEach(showAlert);
|
|
374
|
+
for (const notice of notices)
|
|
375
|
+
showNotice(notice, notifications);
|
|
376
|
+
})
|
|
377
|
+
// A look that failed (its state could not be written) is taken again with the next snapshot.
|
|
378
|
+
.catch(() => undefined);
|
|
379
|
+
return snapshot;
|
|
380
|
+
};
|
|
381
|
+
changed = () => {
|
|
382
|
+
publish();
|
|
383
|
+
};
|
|
384
|
+
let lastRefreshAt = 0;
|
|
385
|
+
const refresh = async (providerId, options) => {
|
|
386
|
+
lastRefreshAt = Date.now();
|
|
387
|
+
const pending = service.refresh(providerId, options);
|
|
388
|
+
publish();
|
|
389
|
+
try {
|
|
390
|
+
await pending;
|
|
391
|
+
}
|
|
392
|
+
finally {
|
|
393
|
+
publish();
|
|
394
|
+
}
|
|
395
|
+
return view();
|
|
396
|
+
};
|
|
397
|
+
const refreshInBackground = () => {
|
|
398
|
+
refresh().catch(() => undefined);
|
|
399
|
+
};
|
|
400
|
+
const schedule = () => {
|
|
401
|
+
clearInterval(poll);
|
|
402
|
+
poll = setInterval(refreshInBackground, service.snapshot().settings.intervalSeconds * 1000);
|
|
403
|
+
};
|
|
404
|
+
const buildTrayMenu = () => {
|
|
405
|
+
tray.setContextMenu(trayMenu(t, win, refreshInBackground));
|
|
406
|
+
};
|
|
407
|
+
const applyCompact = (compact) => {
|
|
408
|
+
win.setMinimumSize(WINDOW.minWidth, compact ? WINDOW.compactMinHeight : WINDOW.minHeight);
|
|
409
|
+
if (compact)
|
|
410
|
+
return;
|
|
411
|
+
const [width = WINDOW.defaultWidth] = win.getContentSize();
|
|
412
|
+
win.setContentSize(width, Math.max(service.snapshot().settings.window?.height ?? WINDOW.defaultHeight, WINDOW.minHeight));
|
|
413
|
+
};
|
|
414
|
+
const checkUpdates = (delayMs = UPDATE_FIRST_CHECK_DELAY_MS) => {
|
|
415
|
+
clearTimeout(updateTimer);
|
|
416
|
+
if (!service.snapshot().settings.checkUpdates) {
|
|
417
|
+
update = undefined;
|
|
418
|
+
send(view());
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
updateTimer = setTimeout(() => {
|
|
422
|
+
checkForUpdate(app.getVersion(), fetchJson)
|
|
423
|
+
.then(found => {
|
|
424
|
+
if (!service.snapshot().settings.checkUpdates)
|
|
425
|
+
return;
|
|
426
|
+
update = found ?? undefined;
|
|
427
|
+
send(view());
|
|
428
|
+
})
|
|
429
|
+
.catch(() => undefined)
|
|
430
|
+
.finally(() => {
|
|
431
|
+
checkUpdates(UPDATE_CHECK_INTERVAL_MS);
|
|
432
|
+
});
|
|
433
|
+
}, delayMs);
|
|
434
|
+
};
|
|
435
|
+
const handlers = createHandlers({
|
|
436
|
+
service,
|
|
437
|
+
dataDir,
|
|
438
|
+
homeDir,
|
|
439
|
+
platform: process.platform,
|
|
440
|
+
publish,
|
|
441
|
+
view,
|
|
442
|
+
refresh,
|
|
443
|
+
continuity,
|
|
444
|
+
history,
|
|
445
|
+
installer,
|
|
446
|
+
openUpdate() {
|
|
447
|
+
if (update && isReleasePage(update.url))
|
|
448
|
+
void shell.openExternal(update.url);
|
|
449
|
+
},
|
|
450
|
+
fitHeight(height) {
|
|
451
|
+
if (!service.snapshot().settings.compact)
|
|
452
|
+
return;
|
|
453
|
+
const [width = WINDOW.defaultWidth] = win.getContentSize();
|
|
454
|
+
win.setContentSize(width, Math.round(Math.min(Math.max(height, WINDOW.compactMinHeight), WINDOW.compactMaxHeight)));
|
|
455
|
+
},
|
|
456
|
+
settingsChanged(before, after) {
|
|
457
|
+
if (after.pinned !== before.pinned)
|
|
458
|
+
win.setAlwaysOnTop(after.pinned);
|
|
459
|
+
if (after.language !== before.language)
|
|
460
|
+
buildTrayMenu();
|
|
461
|
+
if (after.intervalSeconds !== before.intervalSeconds)
|
|
462
|
+
schedule();
|
|
463
|
+
if (after.compact !== before.compact)
|
|
464
|
+
applyCompact(after.compact);
|
|
465
|
+
if (after.checkUpdates !== before.checkUpdates)
|
|
466
|
+
checkUpdates();
|
|
467
|
+
},
|
|
468
|
+
hideWindow: () => {
|
|
469
|
+
win.hide();
|
|
470
|
+
},
|
|
471
|
+
loginItem: { get: loginItem, set: setLoginItem },
|
|
472
|
+
async chooseDirectory(titleKey, defaultPath) {
|
|
473
|
+
const choice = await dialog.showOpenDialog(win, {
|
|
474
|
+
title: t(titleKey),
|
|
475
|
+
properties: ['openDirectory', 'createDirectory'],
|
|
476
|
+
...(defaultPath ? { defaultPath } : {})
|
|
477
|
+
});
|
|
478
|
+
return choice.canceled ? null : (choice.filePaths[0] ?? null);
|
|
479
|
+
},
|
|
480
|
+
probe: createToolProbe({ homeDir }),
|
|
481
|
+
appVersion: app.getVersion(),
|
|
482
|
+
async copyText(text) {
|
|
483
|
+
await clipboard.writeText(text);
|
|
484
|
+
},
|
|
485
|
+
async chooseFile(mode, titleKey, defaultName) {
|
|
486
|
+
const extension = defaultName ? path.extname(defaultName).slice(1) : 'json';
|
|
487
|
+
const filters = [{ name: extension.toUpperCase(), extensions: [extension] }];
|
|
488
|
+
if (mode === 'save') {
|
|
489
|
+
const choice = await dialog.showSaveDialog(win, {
|
|
490
|
+
title: t(titleKey),
|
|
491
|
+
filters,
|
|
492
|
+
...(defaultName ? { defaultPath: defaultName } : {})
|
|
493
|
+
});
|
|
494
|
+
return choice.canceled ? null : choice.filePath;
|
|
495
|
+
}
|
|
496
|
+
const choice = await dialog.showOpenDialog(win, { title: t(titleKey), filters, properties: ['openFile'] });
|
|
497
|
+
return choice.canceled ? null : (choice.filePaths[0] ?? null);
|
|
498
|
+
},
|
|
499
|
+
async confirm(messageKey, actionKey) {
|
|
500
|
+
const { response } = await dialog.showMessageBox(win, {
|
|
501
|
+
type: 'warning',
|
|
502
|
+
buttons: [t(actionKey), t('cancel')],
|
|
503
|
+
defaultId: 1,
|
|
504
|
+
cancelId: 1,
|
|
505
|
+
message: t(messageKey)
|
|
506
|
+
});
|
|
507
|
+
return response === 0;
|
|
508
|
+
},
|
|
509
|
+
async confirmWarnings(warnings) {
|
|
510
|
+
if (!warnings.length)
|
|
511
|
+
return true;
|
|
512
|
+
const { response } = await dialog.showMessageBox(win, {
|
|
513
|
+
type: 'warning',
|
|
514
|
+
buttons: [t('launchAnyway'), t('cancel')],
|
|
515
|
+
defaultId: 0,
|
|
516
|
+
cancelId: 1,
|
|
517
|
+
title: t('launchWarningTitle'),
|
|
518
|
+
message: warnings.map(w => t(w.key, w.params)).join('\n\n')
|
|
519
|
+
});
|
|
520
|
+
return response === 0;
|
|
521
|
+
},
|
|
522
|
+
setTimeout,
|
|
523
|
+
watchFolder
|
|
524
|
+
});
|
|
525
|
+
for (const [channel, handler] of Object.entries(handlers)) {
|
|
526
|
+
ipcMain.handle(`${IPC_PREFIX}${channel}`, (event, ...args) => envelope(() => {
|
|
527
|
+
assertSender(event, EXPECTED_URL);
|
|
528
|
+
return handler(...args);
|
|
529
|
+
}));
|
|
530
|
+
}
|
|
531
|
+
tray.on('click', () => {
|
|
532
|
+
if (win.isVisible())
|
|
533
|
+
win.hide();
|
|
534
|
+
else
|
|
535
|
+
show(win);
|
|
536
|
+
});
|
|
537
|
+
win.on('focus', () => {
|
|
538
|
+
if (Date.now() - lastRefreshAt > FOCUS_REFRESH_MIN_AGE_MS)
|
|
539
|
+
refreshInBackground();
|
|
540
|
+
});
|
|
541
|
+
tray.on('balloon-click', () => {
|
|
542
|
+
show(win);
|
|
543
|
+
});
|
|
544
|
+
buildTrayMenu();
|
|
545
|
+
app.on('second-instance', () => {
|
|
546
|
+
show(win);
|
|
547
|
+
});
|
|
548
|
+
// Timers do not run while the system sleeps: read fresh quota once it is awake again.
|
|
549
|
+
powerMonitor.on('resume', () => {
|
|
550
|
+
schedule();
|
|
551
|
+
clearTimeout(resumeTimer);
|
|
552
|
+
resumeTimer = setTimeout(refreshInBackground, RESUME_REFRESH_DELAY_MS);
|
|
553
|
+
});
|
|
554
|
+
app.on('will-quit', () => {
|
|
555
|
+
void history.flush();
|
|
556
|
+
});
|
|
557
|
+
await win.loadFile(HTML);
|
|
558
|
+
if (!openedAtLogin())
|
|
559
|
+
show(win);
|
|
560
|
+
checkUpdates();
|
|
561
|
+
schedule();
|
|
562
|
+
await refresh();
|
|
563
|
+
}
|
|
564
|
+
if (!app.requestSingleInstanceLock()) {
|
|
565
|
+
app.quit();
|
|
566
|
+
}
|
|
567
|
+
else {
|
|
568
|
+
app
|
|
569
|
+
.whenReady()
|
|
570
|
+
.then(start)
|
|
571
|
+
.catch((error) => {
|
|
572
|
+
console.error(error);
|
|
573
|
+
dialog.showErrorBox(APP_NAME, startupT('startupFailed'));
|
|
574
|
+
quit();
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
// Not emitted when Windows shuts down, restarts or signs out: the app then ends with the system.
|
|
578
|
+
app.on('before-quit', () => {
|
|
579
|
+
quitting = true;
|
|
580
|
+
quitContinuity?.();
|
|
581
|
+
clearInterval(poll);
|
|
582
|
+
clearTimeout(boundsTimer);
|
|
583
|
+
clearTimeout(resumeTimer);
|
|
584
|
+
clearTimeout(updateTimer);
|
|
585
|
+
});
|
|
586
|
+
// The app lives in the tray: closing the window never quits it.
|
|
587
|
+
app.on('window-all-closed', () => undefined);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ClientRequest, IncomingMessage } from 'node:http';
|
|
2
|
+
import https from 'node:https';
|
|
3
|
+
import type { Environment } from '../core/environment.ts';
|
|
4
|
+
import type { QuotaWindow } from '../shared/types.ts';
|
|
5
|
+
import { type RunProcess } from './common.ts';
|
|
6
|
+
import type { ProviderAdapter } from './types.ts';
|
|
7
|
+
declare const RPC_METHODS: {
|
|
8
|
+
readonly quota: "RetrieveUserQuotaSummary";
|
|
9
|
+
readonly status: "GetUserStatus";
|
|
10
|
+
};
|
|
11
|
+
type RpcMethod = (typeof RPC_METHODS)[keyof typeof RPC_METHODS];
|
|
12
|
+
export declare const DISCOVERY_OUTCOMES: readonly ["success", "app_closed", "local_service_unavailable"];
|
|
13
|
+
export interface AntigravityReading {
|
|
14
|
+
windows: QuotaWindow[];
|
|
15
|
+
email: string | null;
|
|
16
|
+
}
|
|
17
|
+
export declare function parseAntigravitySummary(raw: unknown): AntigravityReading;
|
|
18
|
+
export declare function parseAntigravityUserStatus(raw: unknown): AntigravityReading;
|
|
19
|
+
export interface LanguageServer {
|
|
20
|
+
pid: number;
|
|
21
|
+
csrfToken: string;
|
|
22
|
+
ports: number[];
|
|
23
|
+
}
|
|
24
|
+
type ProcessEntry = Omit<LanguageServer, 'ports'>;
|
|
25
|
+
export declare function parseMacProcessList(output: string, currentUid: unknown): ProcessEntry[];
|
|
26
|
+
export declare function parseListeningPorts(output: string, expectedPid: unknown): number[];
|
|
27
|
+
export interface DiscoveryOptions {
|
|
28
|
+
runProcess?: RunProcess;
|
|
29
|
+
platform?: NodeJS.Platform;
|
|
30
|
+
env?: Environment;
|
|
31
|
+
currentUid?: number;
|
|
32
|
+
}
|
|
33
|
+
export declare function discoverAntigravityServers({ runProcess, platform, env, currentUid }?: DiscoveryOptions): Promise<LanguageServer[]>;
|
|
34
|
+
type HttpsRequest = (options: https.RequestOptions, callback: (response: IncomingMessage) => void) => ClientRequest;
|
|
35
|
+
export interface LocalRequestOptions {
|
|
36
|
+
timeoutMs?: number;
|
|
37
|
+
maxBytes?: number;
|
|
38
|
+
request?: HttpsRequest;
|
|
39
|
+
}
|
|
40
|
+
export type RequestLocalJson = (server: LanguageServer, port: number, method: RpcMethod, options?: LocalRequestOptions) => Promise<unknown>;
|
|
41
|
+
export declare const requestLocalJson: RequestLocalJson;
|
|
42
|
+
export declare function antigravityAccountId(email: string): string;
|
|
43
|
+
export interface AntigravityProviderOptions extends DiscoveryOptions {
|
|
44
|
+
discoverAntigravityServers?: (options: DiscoveryOptions) => Promise<LanguageServer[]>;
|
|
45
|
+
requestLocalJson?: RequestLocalJson;
|
|
46
|
+
}
|
|
47
|
+
export declare function createAntigravityProvider(options?: AntigravityProviderOptions): ProviderAdapter;
|
|
48
|
+
export {};
|