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,16 @@
|
|
|
1
|
+
import type { ProjectPreference, QuietHours, Settings, SettingsNotice } from '../shared/types.ts';
|
|
2
|
+
export declare function defaultSettings(): Settings;
|
|
3
|
+
export declare function validateConfigDir(value: unknown, platform?: NodeJS.Platform): string;
|
|
4
|
+
export declare const projectPreference: (settings: Pick<Settings, "projectPreferences">, projectDir: string) => ProjectPreference | null;
|
|
5
|
+
export declare function inQuietHours(quietHours: QuietHours | undefined, date: Date): boolean;
|
|
6
|
+
export declare function validateSettings(document: unknown): Settings;
|
|
7
|
+
export declare function appDataDir(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): string;
|
|
8
|
+
export declare const settingsPath: (dataDir: string) => string;
|
|
9
|
+
export declare const backupPath: (file: string) => string;
|
|
10
|
+
export declare function loadSettings(file: string): Promise<Settings | null>;
|
|
11
|
+
export interface LoadedSettings {
|
|
12
|
+
settings: Settings;
|
|
13
|
+
notice?: SettingsNotice;
|
|
14
|
+
}
|
|
15
|
+
export declare function loadSettingsWithRecovery(file: string): Promise<LoadedSettings>;
|
|
16
|
+
export declare function saveSettings(file: string, input: unknown): Promise<Settings>;
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import fsp from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { ACCOUNT_ID_PATTERN, ACCOUNT_LABEL_KEYS, ACCOUNT_ORIGINS, APP_NAME, AUTO_CONTINUE, AUTOMATION_MODES, INHERIT, isOneOf, isProviderId, LOW_QUOTA_PERCENT, LOW_QUOTA_RANGE, MAX_DISMISSED_ACCOUNTS, MAX_LABEL_LENGTH, MODEL_ID_PATTERN, PROVIDER_IDS, providerById, REFRESH_INTERVAL_SECONDS } from "../shared/catalog.js";
|
|
5
|
+
import { DEFAULT_LANGUAGE, isLanguage } from "../shared/i18n/index.js";
|
|
6
|
+
import { AppError, InvalidSettingsError, systemErrorCode } from "./errors.js";
|
|
7
|
+
import { isObject, parseJson, pathKey, samePath, writeFileAtomic } from "./files.js";
|
|
8
|
+
import { hasControlChars, normalizeEmail } from "./model.js";
|
|
9
|
+
const SETTINGS_SCHEMA_VERSION = 1;
|
|
10
|
+
const SETTINGS_FILE = 'settings.json';
|
|
11
|
+
// The last document that validated before it was replaced.
|
|
12
|
+
const BACKUP_SUFFIX = '.bak';
|
|
13
|
+
// An unreadable document is kept beside the new one under this suffix and a timestamp.
|
|
14
|
+
const DAMAGED_SUFFIX = '.damaged-';
|
|
15
|
+
const MAX_PROJECT_PREFERENCES = 100;
|
|
16
|
+
const TIME_OF_DAY = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
|
|
17
|
+
const MAX_ACCOUNTS = 40;
|
|
18
|
+
const MAX_EMAIL_LENGTH = 256;
|
|
19
|
+
const MAX_PATH_LENGTH = 1024;
|
|
20
|
+
const WINDOW_BOUNDS = { maxOffset: 100000, minWidth: 280, maxWidth: 3000, minHeight: 300, maxHeight: 3000 };
|
|
21
|
+
const SETTINGS_KEYS = [
|
|
22
|
+
'schemaVersion',
|
|
23
|
+
'pinned',
|
|
24
|
+
'intervalSeconds',
|
|
25
|
+
'accounts',
|
|
26
|
+
'window',
|
|
27
|
+
'dismissedAccountIds',
|
|
28
|
+
'providerOrder',
|
|
29
|
+
'homeOrder',
|
|
30
|
+
'preferredModels',
|
|
31
|
+
'language',
|
|
32
|
+
'notifications',
|
|
33
|
+
'compareAccounts',
|
|
34
|
+
'autoContinue',
|
|
35
|
+
'returnToPrimary',
|
|
36
|
+
'reopenAfterRestart',
|
|
37
|
+
'compact',
|
|
38
|
+
'checkUpdates',
|
|
39
|
+
'projectPreferences',
|
|
40
|
+
'lowQuotaPercent',
|
|
41
|
+
'quietHours'
|
|
42
|
+
];
|
|
43
|
+
const PREFERENCE_KEYS = ['projectDir', 'tool', 'model', 'accountId', 'autoContinue'];
|
|
44
|
+
const QUIET_HOURS_KEYS = ['start', 'end'];
|
|
45
|
+
const ACCOUNT_KEYS = [
|
|
46
|
+
'id',
|
|
47
|
+
'providerId',
|
|
48
|
+
'label',
|
|
49
|
+
'labelKey',
|
|
50
|
+
'configDir',
|
|
51
|
+
'primary',
|
|
52
|
+
'enabled',
|
|
53
|
+
'configDirExplicit',
|
|
54
|
+
'origin',
|
|
55
|
+
'signInUntil',
|
|
56
|
+
'email'
|
|
57
|
+
];
|
|
58
|
+
const WINDOW_KEYS = ['x', 'y', 'width', 'height'];
|
|
59
|
+
export function defaultSettings() {
|
|
60
|
+
return {
|
|
61
|
+
schemaVersion: SETTINGS_SCHEMA_VERSION,
|
|
62
|
+
pinned: false,
|
|
63
|
+
intervalSeconds: REFRESH_INTERVAL_SECONDS.default,
|
|
64
|
+
accounts: [],
|
|
65
|
+
dismissedAccountIds: [],
|
|
66
|
+
providerOrder: [...PROVIDER_IDS],
|
|
67
|
+
preferredModels: {},
|
|
68
|
+
language: DEFAULT_LANGUAGE,
|
|
69
|
+
notifications: false,
|
|
70
|
+
compareAccounts: false,
|
|
71
|
+
autoContinue: 'off',
|
|
72
|
+
returnToPrimary: 'ask',
|
|
73
|
+
reopenAfterRestart: 'ask',
|
|
74
|
+
compact: false,
|
|
75
|
+
checkUpdates: true,
|
|
76
|
+
projectPreferences: [],
|
|
77
|
+
lowQuotaPercent: LOW_QUOTA_PERCENT
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function assertShape(value, allowed, reason) {
|
|
81
|
+
if (!isObject(value) || Object.keys(value).some(key => !allowed.includes(key)))
|
|
82
|
+
throw new InvalidSettingsError(reason);
|
|
83
|
+
}
|
|
84
|
+
function check(condition, reason) {
|
|
85
|
+
if (!condition)
|
|
86
|
+
throw new InvalidSettingsError(reason);
|
|
87
|
+
}
|
|
88
|
+
// An absolute, local (no UNC share) directory path without parent traversal. On Windows it
|
|
89
|
+
// names its drive: `\Repo` would depend on the current one.
|
|
90
|
+
export function validateConfigDir(value, platform = process.platform) {
|
|
91
|
+
if (typeof value !== 'string' ||
|
|
92
|
+
value.length > MAX_PATH_LENGTH ||
|
|
93
|
+
hasControlChars(value) ||
|
|
94
|
+
/^[/\\]{2}/.test(value) ||
|
|
95
|
+
value.split(/[\\/]/).includes('..') ||
|
|
96
|
+
!path.isAbsolute(value) ||
|
|
97
|
+
(platform === 'win32' && !/^[A-Za-z]:[\\/]/.test(value)))
|
|
98
|
+
throw new AppError('invalid_path');
|
|
99
|
+
return path.normalize(value);
|
|
100
|
+
}
|
|
101
|
+
const isOptionalBoolean = (value) => value === undefined || typeof value === 'boolean';
|
|
102
|
+
function validateAccount(input, ids, primaries) {
|
|
103
|
+
assertShape(input, ACCOUNT_KEYS, 'unknown_account_setting');
|
|
104
|
+
const { id, providerId, labelKey, configDir, configDirExplicit, origin, signInUntil, email, primary, enabled } = input;
|
|
105
|
+
check(typeof id === 'string' && ACCOUNT_ID_PATTERN.test(id) && !ids.has(id) && isProviderId(providerId), 'invalid_account');
|
|
106
|
+
ids.add(id);
|
|
107
|
+
const label = typeof input.label === 'string' ? input.label.slice(0, MAX_LABEL_LENGTH).trim() : '';
|
|
108
|
+
// A typed name is shown instead of the app's key, which stays: clearing the name brings it
|
|
109
|
+
// back. Without either, the account is shown by its sign-in.
|
|
110
|
+
check(labelKey === undefined || isOneOf(ACCOUNT_LABEL_KEYS, labelKey), 'invalid_account_label');
|
|
111
|
+
check(!hasControlChars(label), 'invalid_account_label');
|
|
112
|
+
check(typeof primary === 'boolean' && typeof enabled === 'boolean' && isOptionalBoolean(configDirExplicit), 'invalid_account_flags');
|
|
113
|
+
check(origin === undefined || isOneOf(ACCOUNT_ORIGINS, origin), 'invalid_account_flags');
|
|
114
|
+
check(signInUntil === undefined || (typeof signInUntil === 'number' && Number.isFinite(signInUntil)), 'invalid_account_flags');
|
|
115
|
+
check(email === undefined || (typeof email === 'string' && email.length <= MAX_EMAIL_LENGTH && !hasControlChars(email)), 'invalid_account_flags');
|
|
116
|
+
check(!(primary && primaries.has(providerId)), 'duplicate_primary');
|
|
117
|
+
if (primary)
|
|
118
|
+
primaries.add(providerId);
|
|
119
|
+
return {
|
|
120
|
+
id,
|
|
121
|
+
providerId,
|
|
122
|
+
...(label ? { label } : {}),
|
|
123
|
+
...(isOneOf(ACCOUNT_LABEL_KEYS, labelKey) ? { labelKey } : {}),
|
|
124
|
+
...(configDir === undefined ? {} : { configDir: validateConfigDir(configDir) }),
|
|
125
|
+
...(configDirExplicit === undefined ? {} : { configDirExplicit }),
|
|
126
|
+
...(origin === undefined ? {} : { origin }),
|
|
127
|
+
// Only a pending account waits for a sign-in; without a time, its sign-in is closed.
|
|
128
|
+
...(origin === 'pending' && typeof signInUntil === 'number' ? { signInUntil } : {}),
|
|
129
|
+
...(email === undefined ? {} : { email: normalizeEmail(email) }),
|
|
130
|
+
primary,
|
|
131
|
+
enabled
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
function validateWindow(value) {
|
|
135
|
+
assertShape(value, WINDOW_KEYS, 'invalid_window');
|
|
136
|
+
const { x, y, width, height } = value;
|
|
137
|
+
const b = WINDOW_BOUNDS;
|
|
138
|
+
const isInteger = (n) => Number.isInteger(n);
|
|
139
|
+
check(isInteger(x) &&
|
|
140
|
+
isInteger(y) &&
|
|
141
|
+
isInteger(width) &&
|
|
142
|
+
isInteger(height) &&
|
|
143
|
+
Math.abs(x) <= b.maxOffset &&
|
|
144
|
+
Math.abs(y) <= b.maxOffset &&
|
|
145
|
+
width >= b.minWidth &&
|
|
146
|
+
width <= b.maxWidth &&
|
|
147
|
+
height >= b.minHeight &&
|
|
148
|
+
height <= b.maxHeight, 'invalid_window');
|
|
149
|
+
return { x, y, width, height };
|
|
150
|
+
}
|
|
151
|
+
function validateProviderOrder(value) {
|
|
152
|
+
check(Array.isArray(value) && value.length <= PROVIDER_IDS.length && new Set(value).size === value.length && value.every(isProviderId), 'invalid_provider_order');
|
|
153
|
+
return value;
|
|
154
|
+
}
|
|
155
|
+
function validatePreferredModels(value) {
|
|
156
|
+
assertShape(value, PROVIDER_IDS, 'invalid_preferred_models');
|
|
157
|
+
const models = {};
|
|
158
|
+
for (const [providerId, model] of Object.entries(value)) {
|
|
159
|
+
// Tools opened as apps take no model argument.
|
|
160
|
+
check(isProviderId(providerId) && providerById(providerId).acceptsModel && typeof model === 'string' && MODEL_ID_PATTERN.test(model), 'invalid_preferred_models');
|
|
161
|
+
models[providerId] = model;
|
|
162
|
+
}
|
|
163
|
+
return models;
|
|
164
|
+
}
|
|
165
|
+
function validateDismissed(value) {
|
|
166
|
+
const isAccountId = (id) => typeof id === 'string' && ACCOUNT_ID_PATTERN.test(id);
|
|
167
|
+
check(Array.isArray(value) && value.every(isAccountId), 'invalid_dismissed_accounts');
|
|
168
|
+
return [...new Set(value)].slice(-MAX_DISMISSED_ACCOUNTS);
|
|
169
|
+
}
|
|
170
|
+
// Folders compare the way the file systems name them by default: case-insensitively on Windows
|
|
171
|
+
// and macOS, as session folders are matched.
|
|
172
|
+
export const projectPreference = (settings, projectDir) => settings.projectPreferences.find(p => samePath(p.projectDir, projectDir)) ?? null;
|
|
173
|
+
// A preferred account may since have been removed: it is checked where it is used, so that
|
|
174
|
+
// removing an account never makes the settings invalid.
|
|
175
|
+
function validateProjectPreference(input, seen) {
|
|
176
|
+
assertShape(input, PREFERENCE_KEYS, 'invalid_project_preference');
|
|
177
|
+
const { tool, model, accountId, autoContinue } = input;
|
|
178
|
+
const projectDir = validateConfigDir(input.projectDir);
|
|
179
|
+
check(!seen.has(pathKey(projectDir)), 'duplicate_project_preference');
|
|
180
|
+
seen.add(pathKey(projectDir));
|
|
181
|
+
check(tool === undefined || isProviderId(tool), 'invalid_project_preference');
|
|
182
|
+
check(model === undefined ||
|
|
183
|
+
(tool !== undefined && providerById(tool).acceptsModel && typeof model === 'string' && MODEL_ID_PATTERN.test(model)), 'invalid_project_preference');
|
|
184
|
+
check(accountId === undefined || (typeof accountId === 'string' && ACCOUNT_ID_PATTERN.test(accountId)), 'invalid_project_preference');
|
|
185
|
+
check(autoContinue === undefined || autoContinue === INHERIT || isOneOf(AUTO_CONTINUE, autoContinue), 'invalid_project_preference');
|
|
186
|
+
return {
|
|
187
|
+
projectDir,
|
|
188
|
+
...(tool === undefined ? {} : { tool }),
|
|
189
|
+
...(model === undefined ? {} : { model }),
|
|
190
|
+
...(accountId === undefined ? {} : { accountId }),
|
|
191
|
+
autoContinue: autoContinue ?? INHERIT
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
function validateProjectPreferences(value) {
|
|
195
|
+
check(Array.isArray(value) && value.length <= MAX_PROJECT_PREFERENCES, 'invalid_project_preference');
|
|
196
|
+
const seen = new Set();
|
|
197
|
+
return value.map(entry => validateProjectPreference(entry, seen));
|
|
198
|
+
}
|
|
199
|
+
// Equal times would be an empty or a whole-day span; neither is a useful setting.
|
|
200
|
+
function validateQuietHours(value) {
|
|
201
|
+
assertShape(value, QUIET_HOURS_KEYS, 'invalid_quiet_hours');
|
|
202
|
+
const { start, end } = value;
|
|
203
|
+
check(typeof start === 'string' && typeof end === 'string' && TIME_OF_DAY.test(start) && TIME_OF_DAY.test(end), 'invalid_quiet_hours');
|
|
204
|
+
check(start !== end, 'invalid_quiet_hours');
|
|
205
|
+
return { start, end };
|
|
206
|
+
}
|
|
207
|
+
const minutesOfDay = (time) => Number(time.slice(0, 2)) * 60 + Number(time.slice(3));
|
|
208
|
+
// Whether `date` (local time) falls in the quiet hours; the start is included, the end is not.
|
|
209
|
+
export function inQuietHours(quietHours, date) {
|
|
210
|
+
if (!quietHours)
|
|
211
|
+
return false;
|
|
212
|
+
const now = date.getHours() * 60 + date.getMinutes();
|
|
213
|
+
const start = minutesOfDay(quietHours.start);
|
|
214
|
+
const end = minutesOfDay(quietHours.end);
|
|
215
|
+
return start < end ? now >= start && now < end : now >= start || now < end;
|
|
216
|
+
}
|
|
217
|
+
const isFutureDocument = (document) => isObject(document) && typeof document.schemaVersion === 'number' && document.schemaVersion > SETTINGS_SCHEMA_VERSION;
|
|
218
|
+
// Complete settings from a stored or patched document; absent optional fields get defaults.
|
|
219
|
+
// A document without a version predates versioning and reads as version 1 unchanged.
|
|
220
|
+
// Settings an earlier version wrote that no longer exist: a file that has them is read without
|
|
221
|
+
// them (and saved without them next time), rather than refused as unknown.
|
|
222
|
+
const RETIRED_SETTINGS = ['globalShortcut'];
|
|
223
|
+
export function validateSettings(document) {
|
|
224
|
+
const input = isObject(document)
|
|
225
|
+
? Object.fromEntries(Object.entries(document).filter(([key]) => !RETIRED_SETTINGS.includes(key)))
|
|
226
|
+
: document;
|
|
227
|
+
assertShape(input, SETTINGS_KEYS, 'unknown_setting');
|
|
228
|
+
check(!isFutureDocument(input), 'unsupported_schema');
|
|
229
|
+
check(input.schemaVersion === undefined || input.schemaVersion === SETTINGS_SCHEMA_VERSION, 'invalid_schema_version');
|
|
230
|
+
const { pinned, intervalSeconds, accounts, language, notifications, compareAccounts, autoContinue, returnToPrimary, reopenAfterRestart, compact, checkUpdates } = input;
|
|
231
|
+
check(typeof pinned === 'boolean', 'invalid_pinned');
|
|
232
|
+
check(isOptionalBoolean(notifications), 'invalid_notifications');
|
|
233
|
+
check(isOptionalBoolean(compareAccounts), 'invalid_compare_accounts');
|
|
234
|
+
check(autoContinue === undefined || isOneOf(AUTO_CONTINUE, autoContinue), 'invalid_auto_continue');
|
|
235
|
+
check(returnToPrimary === undefined || isOneOf(AUTOMATION_MODES, returnToPrimary), 'invalid_settings');
|
|
236
|
+
check(reopenAfterRestart === undefined || isOneOf(AUTOMATION_MODES, reopenAfterRestart), 'invalid_settings');
|
|
237
|
+
check(isOptionalBoolean(compact) && isOptionalBoolean(checkUpdates), 'invalid_settings');
|
|
238
|
+
check(typeof intervalSeconds === 'number' &&
|
|
239
|
+
Number.isInteger(intervalSeconds) &&
|
|
240
|
+
intervalSeconds >= REFRESH_INTERVAL_SECONDS.min &&
|
|
241
|
+
intervalSeconds <= REFRESH_INTERVAL_SECONDS.max, 'invalid_interval');
|
|
242
|
+
check(Array.isArray(accounts) && accounts.length <= MAX_ACCOUNTS, 'invalid_accounts');
|
|
243
|
+
check(language === undefined || isLanguage(language), 'invalid_language');
|
|
244
|
+
const { lowQuotaPercent } = input;
|
|
245
|
+
check(lowQuotaPercent === undefined ||
|
|
246
|
+
(Number.isInteger(lowQuotaPercent) &&
|
|
247
|
+
typeof lowQuotaPercent === 'number' &&
|
|
248
|
+
lowQuotaPercent >= LOW_QUOTA_RANGE.min &&
|
|
249
|
+
lowQuotaPercent <= LOW_QUOTA_RANGE.max), 'invalid_low_quota_percent');
|
|
250
|
+
const ids = new Set();
|
|
251
|
+
const primaries = new Set();
|
|
252
|
+
const defaults = defaultSettings();
|
|
253
|
+
return {
|
|
254
|
+
schemaVersion: SETTINGS_SCHEMA_VERSION,
|
|
255
|
+
pinned,
|
|
256
|
+
intervalSeconds,
|
|
257
|
+
accounts: accounts.map(account => validateAccount(account, ids, primaries)),
|
|
258
|
+
dismissedAccountIds: input.dismissedAccountIds === undefined ? defaults.dismissedAccountIds : validateDismissed(input.dismissedAccountIds),
|
|
259
|
+
providerOrder: input.providerOrder === undefined ? defaults.providerOrder : validateProviderOrder(input.providerOrder),
|
|
260
|
+
preferredModels: input.preferredModels === undefined ? defaults.preferredModels : validatePreferredModels(input.preferredModels),
|
|
261
|
+
language: language ?? defaults.language,
|
|
262
|
+
notifications: notifications ?? defaults.notifications,
|
|
263
|
+
compareAccounts: compareAccounts ?? defaults.compareAccounts,
|
|
264
|
+
autoContinue: autoContinue ?? defaults.autoContinue,
|
|
265
|
+
returnToPrimary: returnToPrimary ?? defaults.returnToPrimary,
|
|
266
|
+
reopenAfterRestart: reopenAfterRestart ?? defaults.reopenAfterRestart,
|
|
267
|
+
compact: compact ?? defaults.compact,
|
|
268
|
+
checkUpdates: checkUpdates ?? defaults.checkUpdates,
|
|
269
|
+
projectPreferences: input.projectPreferences === undefined ? defaults.projectPreferences : validateProjectPreferences(input.projectPreferences),
|
|
270
|
+
lowQuotaPercent: lowQuotaPercent ?? defaults.lowQuotaPercent,
|
|
271
|
+
// null clears the quiet hours: an absent field would keep the current ones in a patch.
|
|
272
|
+
...(input.quietHours === undefined || input.quietHours === null ? {} : { quietHours: validateQuietHours(input.quietHours) }),
|
|
273
|
+
...(input.homeOrder === undefined ? {} : { homeOrder: validateProviderOrder(input.homeOrder) }),
|
|
274
|
+
...(input.window === undefined ? {} : { window: validateWindow(input.window) })
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
// Where the app keeps its data: VIBE_DATA_DIR, else the platform's per-user app data folder
|
|
278
|
+
// (the same folder Electron's `userData` resolves to).
|
|
279
|
+
export function appDataDir(env = process.env, platform = process.platform) {
|
|
280
|
+
if (env.VIBE_DATA_DIR)
|
|
281
|
+
return path.resolve(env.VIBE_DATA_DIR);
|
|
282
|
+
const root = platform === 'win32'
|
|
283
|
+
? (env.APPDATA ?? path.join(os.homedir(), 'AppData/Roaming'))
|
|
284
|
+
: platform === 'darwin'
|
|
285
|
+
? path.join(os.homedir(), 'Library/Application Support')
|
|
286
|
+
: (env.XDG_CONFIG_HOME ?? path.join(os.homedir(), '.config'));
|
|
287
|
+
return path.join(root, APP_NAME);
|
|
288
|
+
}
|
|
289
|
+
export const settingsPath = (dataDir) => path.join(dataDir, SETTINGS_FILE);
|
|
290
|
+
export const backupPath = (file) => `${file}${BACKUP_SUFFIX}`;
|
|
291
|
+
async function readText(file) {
|
|
292
|
+
try {
|
|
293
|
+
return await fsp.readFile(file, 'utf8');
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
if (systemErrorCode(error) === 'ENOENT')
|
|
297
|
+
return null;
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
// A file that is there but cannot be read (no permission, a folder in its place) counts as
|
|
302
|
+
// damaged; only a missing one is absent.
|
|
303
|
+
const UNREADABLE = Symbol('unreadable');
|
|
304
|
+
const readStored = (file) => readText(file).catch(() => UNREADABLE);
|
|
305
|
+
function parseSettings(text) {
|
|
306
|
+
let document;
|
|
307
|
+
try {
|
|
308
|
+
document = parseJson(text);
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
throw new InvalidSettingsError('malformed_file');
|
|
312
|
+
}
|
|
313
|
+
return validateSettings(document);
|
|
314
|
+
}
|
|
315
|
+
// The stored settings, or null when none were saved yet.
|
|
316
|
+
export async function loadSettings(file) {
|
|
317
|
+
const text = await readText(file);
|
|
318
|
+
return text === null ? null : parseSettings(text);
|
|
319
|
+
}
|
|
320
|
+
// The settings to start with. A damaged or unreadable file falls back to the backup, else to
|
|
321
|
+
// defaults; a newer version's file (or backup) to defaults, locked. Nothing is written here:
|
|
322
|
+
// the evidence stays as it is.
|
|
323
|
+
export async function loadSettingsWithRecovery(file) {
|
|
324
|
+
const text = await readStored(file);
|
|
325
|
+
if (text === null)
|
|
326
|
+
return { settings: defaultSettings() };
|
|
327
|
+
if (text !== UNREADABLE) {
|
|
328
|
+
if (isFutureDocument(attemptParse(text)))
|
|
329
|
+
return { settings: defaultSettings(), notice: 'future' };
|
|
330
|
+
const settings = attemptValidate(text);
|
|
331
|
+
if (settings)
|
|
332
|
+
return { settings };
|
|
333
|
+
}
|
|
334
|
+
const backup = await readStored(backupPath(file));
|
|
335
|
+
if (typeof backup === 'string' && isFutureDocument(attemptParse(backup)))
|
|
336
|
+
return { settings: defaultSettings(), notice: 'future' };
|
|
337
|
+
const restored = typeof backup === 'string' ? attemptValidate(backup) : null;
|
|
338
|
+
return restored ? { settings: restored, notice: 'recovered' } : { settings: defaultSettings(), notice: 'unrecoverable' };
|
|
339
|
+
}
|
|
340
|
+
function attemptParse(text) {
|
|
341
|
+
try {
|
|
342
|
+
return parseJson(text);
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
function attemptValidate(text) {
|
|
349
|
+
try {
|
|
350
|
+
return parseSettings(text);
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
// Validates and writes atomically: a crash mid-write never leaves a truncated file. The
|
|
357
|
+
// document it replaces becomes the backup when it was valid, or is kept aside when it was
|
|
358
|
+
// not; a newer version's document is never replaced.
|
|
359
|
+
export async function saveSettings(file, input) {
|
|
360
|
+
const settings = validateSettings(input);
|
|
361
|
+
const previous = await readStored(file);
|
|
362
|
+
if (typeof previous === 'string' && isFutureDocument(attemptParse(previous)))
|
|
363
|
+
throw new AppError('settings_locked');
|
|
364
|
+
if (typeof previous === 'string' && attemptValidate(previous))
|
|
365
|
+
await keepBackup(file, previous);
|
|
366
|
+
else if (previous !== null)
|
|
367
|
+
await fsp.rename(file, `${file}${DAMAGED_SUFFIX}${new Date().toISOString().replace(/[:.]/g, '-')}`);
|
|
368
|
+
await writeFileAtomic(file, `${JSON.stringify(settings, null, 2)}\n`);
|
|
369
|
+
return settings;
|
|
370
|
+
}
|
|
371
|
+
// A newer version's backup is its last good document: it is kept rather than replaced.
|
|
372
|
+
async function keepBackup(file, previous) {
|
|
373
|
+
const backup = await readStored(backupPath(file));
|
|
374
|
+
if (typeof backup === 'string' && isFutureDocument(attemptParse(backup)))
|
|
375
|
+
return;
|
|
376
|
+
await writeFileAtomic(backupPath(file), previous);
|
|
377
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
type Rgba = readonly [number, number, number, number];
|
|
2
|
+
export declare const quotaColor: (percent: number, lowPercent?: number) => Rgba;
|
|
3
|
+
export declare function ringPixels(size: number, percent: number | null, lowPercent?: number): Buffer;
|
|
4
|
+
export declare const ringIcon: (size: number, percent: number | null, lowPercent?: number) => Buffer;
|
|
5
|
+
export {};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// The tray icon as a ring that fills with the quota left, drawn here and encoded as PNG so
|
|
2
|
+
// that it needs no image library and looks the same on every platform.
|
|
3
|
+
import zlib from 'node:zlib';
|
|
4
|
+
import { LOW_QUOTA_PERCENT } from "../shared/catalog.js";
|
|
5
|
+
const AMPLE_PERCENT = 50;
|
|
6
|
+
const COLORS = {
|
|
7
|
+
ample: [34, 197, 94, 255],
|
|
8
|
+
some: [245, 158, 11, 255],
|
|
9
|
+
low: [239, 68, 68, 255],
|
|
10
|
+
track: [128, 128, 128, 150],
|
|
11
|
+
unknown: [148, 163, 184, 255]
|
|
12
|
+
};
|
|
13
|
+
// Ring geometry as fractions of the icon size.
|
|
14
|
+
const OUTER_RADIUS = 0.47;
|
|
15
|
+
const RING_WIDTH = 0.2;
|
|
16
|
+
// The unknown state is a small dot in the middle of an empty ring.
|
|
17
|
+
const DOT_RADIUS = 0.12;
|
|
18
|
+
// Each pixel is sampled SUPERSAMPLE² times for smooth edges.
|
|
19
|
+
const SUPERSAMPLE = 4;
|
|
20
|
+
// Red at or below the user's low line, green well above it, amber between.
|
|
21
|
+
export const quotaColor = (percent, lowPercent = LOW_QUOTA_PERCENT) => percent <= lowPercent ? COLORS.low : percent > Math.max(AMPLE_PERCENT, lowPercent) ? COLORS.ample : COLORS.some;
|
|
22
|
+
// What covers the point (x, y) in a unit square: the filled arc, the track, the dot or nothing.
|
|
23
|
+
function paint(x, y, percent, fill) {
|
|
24
|
+
const dx = x - 0.5;
|
|
25
|
+
const dy = y - 0.5;
|
|
26
|
+
const distance = Math.hypot(dx, dy);
|
|
27
|
+
if (percent === null && distance <= DOT_RADIUS)
|
|
28
|
+
return COLORS.unknown;
|
|
29
|
+
if (distance > OUTER_RADIUS || distance < OUTER_RADIUS - RING_WIDTH)
|
|
30
|
+
return null;
|
|
31
|
+
if (percent === null)
|
|
32
|
+
return COLORS.track;
|
|
33
|
+
// Clockwise from twelve o'clock.
|
|
34
|
+
const turn = (Math.atan2(dx, -dy) / (2 * Math.PI) + 1) % 1;
|
|
35
|
+
return turn * 100 < percent ? fill : COLORS.track;
|
|
36
|
+
}
|
|
37
|
+
// Straight (non-premultiplied) RGBA pixels, row by row.
|
|
38
|
+
export function ringPixels(size, percent, lowPercent = LOW_QUOTA_PERCENT) {
|
|
39
|
+
const clamped = percent === null ? null : Math.min(Math.max(percent, 0), 100);
|
|
40
|
+
const fill = quotaColor(clamped ?? 0, lowPercent);
|
|
41
|
+
const pixels = Buffer.alloc(size * size * 4);
|
|
42
|
+
const samples = SUPERSAMPLE * SUPERSAMPLE;
|
|
43
|
+
for (let py = 0; py < size; py++) {
|
|
44
|
+
for (let px = 0; px < size; px++) {
|
|
45
|
+
let [r, g, b, a] = [0, 0, 0, 0];
|
|
46
|
+
for (let sy = 0; sy < SUPERSAMPLE; sy++) {
|
|
47
|
+
for (let sx = 0; sx < SUPERSAMPLE; sx++) {
|
|
48
|
+
const color = paint((px + (sx + 0.5) / SUPERSAMPLE) / size, (py + (sy + 0.5) / SUPERSAMPLE) / size, clamped, fill);
|
|
49
|
+
if (!color)
|
|
50
|
+
continue;
|
|
51
|
+
// Colors are averaged weighted by their alpha, so edges do not darken.
|
|
52
|
+
r += color[0] * color[3];
|
|
53
|
+
g += color[1] * color[3];
|
|
54
|
+
b += color[2] * color[3];
|
|
55
|
+
a += color[3];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const offset = (py * size + px) * 4;
|
|
59
|
+
if (a)
|
|
60
|
+
pixels.set([Math.round(r / a), Math.round(g / a), Math.round(b / a), Math.round(a / samples)], offset);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return pixels;
|
|
64
|
+
}
|
|
65
|
+
function chunk(type, data) {
|
|
66
|
+
const length = Buffer.alloc(4);
|
|
67
|
+
length.writeUInt32BE(data.length);
|
|
68
|
+
const body = Buffer.concat([Buffer.from(type, 'latin1'), data]);
|
|
69
|
+
const crc = Buffer.alloc(4);
|
|
70
|
+
crc.writeUInt32BE(zlib.crc32(body));
|
|
71
|
+
return Buffer.concat([length, body, crc]);
|
|
72
|
+
}
|
|
73
|
+
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
74
|
+
const BIT_DEPTH = 8;
|
|
75
|
+
const COLOR_TYPE_RGBA = 6;
|
|
76
|
+
const FILTER_NONE = 0;
|
|
77
|
+
function encodePng(size, pixels) {
|
|
78
|
+
const header = Buffer.alloc(13);
|
|
79
|
+
header.writeUInt32BE(size, 0);
|
|
80
|
+
header.writeUInt32BE(size, 4);
|
|
81
|
+
header.set([BIT_DEPTH, COLOR_TYPE_RGBA, 0, 0, 0], 8);
|
|
82
|
+
// Every row starts with its filter type.
|
|
83
|
+
const rows = Buffer.alloc(size * (size * 4 + 1));
|
|
84
|
+
for (let y = 0; y < size; y++) {
|
|
85
|
+
rows[y * (size * 4 + 1)] = FILTER_NONE;
|
|
86
|
+
pixels.copy(rows, y * (size * 4 + 1) + 1, y * size * 4, (y + 1) * size * 4);
|
|
87
|
+
}
|
|
88
|
+
return Buffer.concat([PNG_SIGNATURE, chunk('IHDR', header), chunk('IDAT', zlib.deflateSync(rows)), chunk('IEND', Buffer.alloc(0))]);
|
|
89
|
+
}
|
|
90
|
+
// The icon at each pixel density the tray may ask for.
|
|
91
|
+
export const ringIcon = (size, percent, lowPercent = LOW_QUOTA_PERCENT) => encodePng(size, ringPixels(size, percent, lowPercent));
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { UpdateInstall, UpdateUnsupportedReason } from '../shared/types.ts';
|
|
2
|
+
export interface UpdaterBackend {
|
|
3
|
+
checkForUpdates(): Promise<{
|
|
4
|
+
isUpdateAvailable: boolean;
|
|
5
|
+
updateInfo: {
|
|
6
|
+
version: string;
|
|
7
|
+
};
|
|
8
|
+
} | null>;
|
|
9
|
+
downloadUpdate(): Promise<unknown>;
|
|
10
|
+
quitAndInstall(isSilent?: boolean, isForceRunAfter?: boolean): void;
|
|
11
|
+
on(event: 'download-progress', listener: (progress: {
|
|
12
|
+
percent: number;
|
|
13
|
+
}) => void): unknown;
|
|
14
|
+
}
|
|
15
|
+
export interface Installer {
|
|
16
|
+
state(): UpdateInstall;
|
|
17
|
+
download(): Promise<void>;
|
|
18
|
+
install(): void;
|
|
19
|
+
}
|
|
20
|
+
export declare function createInstaller(backend: UpdaterBackend | UpdateUnsupportedReason, onChange: () => void): Installer;
|
|
21
|
+
export interface SupportProbe {
|
|
22
|
+
packaged: boolean;
|
|
23
|
+
platform: NodeJS.Platform;
|
|
24
|
+
execPath: string;
|
|
25
|
+
exists?: (file: string) => boolean;
|
|
26
|
+
readText?: (file: string) => string;
|
|
27
|
+
describeSignature?: (bundle: string) => Promise<string>;
|
|
28
|
+
}
|
|
29
|
+
export declare function updateSupport({ packaged, platform, execPath, exists, readText, describeSignature: describe }: SupportProbe): Promise<UpdateUnsupportedReason | null>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Installing a newer release from inside the app, step by step at the user's request: look for
|
|
2
|
+
// it, download it (verified against the release's checksums by the updater), then restart into
|
|
3
|
+
// it. Nothing downloads or installs by itself. A copy that cannot replace itself (a portable zip,
|
|
4
|
+
// an unsigned macOS app, a development build) keeps the link to the release page.
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { promisify } from 'node:util';
|
|
9
|
+
import { APP_NAME } from "../shared/catalog.js";
|
|
10
|
+
export function createInstaller(backend, onChange) {
|
|
11
|
+
if (typeof backend === 'string') {
|
|
12
|
+
const unsupported = { state: 'unsupported', reason: backend };
|
|
13
|
+
return { state: () => unsupported, download: () => Promise.resolve(), install: () => undefined };
|
|
14
|
+
}
|
|
15
|
+
const updater = backend;
|
|
16
|
+
let current = { state: 'ready' };
|
|
17
|
+
let running = null;
|
|
18
|
+
const set = (next) => {
|
|
19
|
+
current = next;
|
|
20
|
+
onChange();
|
|
21
|
+
};
|
|
22
|
+
updater.on('download-progress', ({ percent }) => {
|
|
23
|
+
if (current.state === 'downloading' && Number.isFinite(percent))
|
|
24
|
+
set({ state: 'downloading', percent: Math.max(0, Math.min(100, Math.round(percent))) });
|
|
25
|
+
});
|
|
26
|
+
async function run() {
|
|
27
|
+
set({ state: 'downloading', percent: 0 });
|
|
28
|
+
try {
|
|
29
|
+
const result = await updater.checkForUpdates();
|
|
30
|
+
if (!result?.isUpdateAvailable) {
|
|
31
|
+
set({ state: 'ready' });
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
await updater.downloadUpdate();
|
|
35
|
+
set({ state: 'downloaded', version: result.updateInfo.version });
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// The updater's own message can carry URLs and paths; the user is told it failed.
|
|
39
|
+
set({ state: 'failed' });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
state: () => structuredClone(current),
|
|
44
|
+
download() {
|
|
45
|
+
if (current.state === 'downloaded')
|
|
46
|
+
return Promise.resolve();
|
|
47
|
+
running ??= run().finally(() => {
|
|
48
|
+
running = null;
|
|
49
|
+
});
|
|
50
|
+
return running;
|
|
51
|
+
},
|
|
52
|
+
install() {
|
|
53
|
+
if (current.state === 'downloaded')
|
|
54
|
+
updater.quitAndInstall(false, true);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const execFileAsync = promisify(execFile);
|
|
59
|
+
const describeSignature = async (bundle) => (await execFileAsync('/usr/bin/codesign', ['-dv', '--verbose=2', bundle], { encoding: 'utf8', timeout: 5000 })).stderr;
|
|
60
|
+
// Why this copy cannot update itself, or null when it can. The Windows installer puts its
|
|
61
|
+
// uninstaller beside the app; a portable zip has none. Only a signed build records its publisher
|
|
62
|
+
// in app-update.yml, and only then does the updater check an installer's signature as well as
|
|
63
|
+
// its checksum, so an unsigned copy does not install downloads. macOS only accepts an update to
|
|
64
|
+
// an app signed with a Developer ID.
|
|
65
|
+
export async function updateSupport({ packaged, platform, execPath, exists = fs.existsSync, readText = file => fs.readFileSync(file, 'utf8'), describeSignature: describe = describeSignature }) {
|
|
66
|
+
if (!packaged)
|
|
67
|
+
return 'unpackaged';
|
|
68
|
+
if (platform === 'win32') {
|
|
69
|
+
const dir = path.dirname(execPath);
|
|
70
|
+
if (!exists(path.join(dir, `Uninstall ${APP_NAME}.exe`)))
|
|
71
|
+
return 'portable';
|
|
72
|
+
let config = '';
|
|
73
|
+
try {
|
|
74
|
+
config = readText(path.join(dir, 'resources', 'app-update.yml'));
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// No updater configuration: treated like an unsigned copy.
|
|
78
|
+
}
|
|
79
|
+
return /^publisherName:/m.test(config) ? null : 'unsigned';
|
|
80
|
+
}
|
|
81
|
+
if (platform === 'darwin') {
|
|
82
|
+
// …/Vibe Usage.app/Contents/MacOS/Vibe Usage
|
|
83
|
+
const bundle = path.resolve(execPath, '../../..');
|
|
84
|
+
const signature = await describe(bundle).catch(() => '');
|
|
85
|
+
return /^Authority=Developer ID Application:/m.test(signature) ? null : 'unsigned';
|
|
86
|
+
}
|
|
87
|
+
return 'platform';
|
|
88
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { UpdateInfo } from '../shared/types.ts';
|
|
2
|
+
export declare const LATEST_RELEASE_URL = "https://api.github.com/repos/mustafacagri/vibe-usage/releases/latest";
|
|
3
|
+
export declare function isNewer(candidate: string, current: string): boolean;
|
|
4
|
+
export declare function checkForUpdate(currentVersion: string, fetchJson: (url: string) => Promise<unknown>): Promise<UpdateInfo | null>;
|
|
5
|
+
export declare const isReleasePage: (url: unknown) => url is string;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Whether a newer release is out: the latest GitHub release of the app's repository against
|
|
2
|
+
// the running version. Nothing is downloaded; the user opens the release page.
|
|
3
|
+
import { APP_REPOSITORY } from "../shared/catalog.js";
|
|
4
|
+
import { isObject } from "./files.js";
|
|
5
|
+
export const LATEST_RELEASE_URL = `https://api.github.com/repos/${APP_REPOSITORY}/releases/latest`;
|
|
6
|
+
const RELEASE_PAGE = `https://github.com/${APP_REPOSITORY}/releases/`;
|
|
7
|
+
const VERSION = /^v?(\d+)\.(\d+)\.(\d+)$/;
|
|
8
|
+
const parts = (version) => VERSION.exec(version)?.slice(1).map(Number) ?? null;
|
|
9
|
+
// Whether `candidate` is a later x.y.z than `current`; false for anything unparsable.
|
|
10
|
+
export function isNewer(candidate, current) {
|
|
11
|
+
const next = parts(candidate);
|
|
12
|
+
const now = parts(current);
|
|
13
|
+
if (!next || !now)
|
|
14
|
+
return false;
|
|
15
|
+
const index = next.findIndex((n, i) => n !== now[i]);
|
|
16
|
+
return index !== -1 && (next[index] ?? 0) > (now[index] ?? 0);
|
|
17
|
+
}
|
|
18
|
+
// The release to tell about, or null when there is none newer. The page must belong to the
|
|
19
|
+
// app's repository: the link is opened in the user's browser.
|
|
20
|
+
export async function checkForUpdate(currentVersion, fetchJson) {
|
|
21
|
+
const release = await fetchJson(LATEST_RELEASE_URL);
|
|
22
|
+
if (!isObject(release) || release.draft === true || release.prerelease === true)
|
|
23
|
+
return null;
|
|
24
|
+
const { tag_name: tag, html_url: url } = release;
|
|
25
|
+
if (typeof tag !== 'string' || !isReleasePage(url))
|
|
26
|
+
return null;
|
|
27
|
+
return isNewer(tag, currentVersion) ? { version: tag.replace(/^v/, ''), url } : null;
|
|
28
|
+
}
|
|
29
|
+
export const isReleasePage = (url) => typeof url === 'string' && url.startsWith(RELEASE_PAGE);
|