magpie-gemini-web 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +90 -22
- package/README.md +90 -22
- package/docs/backend-release.md +31 -0
- package/index.mjs +6 -1
- package/lib/backend-release.json +36 -0
- package/lib/backend.mjs +53 -7
- package/lib/native.mjs +50 -18
- package/lib/setup.mjs +27 -4
- package/lib/studio-native.mjs +1137 -0
- package/lib/studio-release.json +27 -0
- package/lib/studio.mjs +641 -0
- package/native-task/README.md +3 -1
- package/package.json +16 -3
- package/scripts/backend-release.mjs +111 -0
- package/scripts/backend.mjs +4 -1
- package/scripts/studio-smoke.mjs +195 -0
- package/scripts/studio.mjs +377 -0
package/lib/native.mjs
CHANGED
|
@@ -3,12 +3,16 @@ import path from 'node:path';
|
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
5
|
import { spawn } from 'node:child_process';
|
|
6
|
+
import release from './backend-release.json' with { type: 'json' };
|
|
6
7
|
|
|
7
|
-
export const BACKEND_VERSION =
|
|
8
|
-
const RELEASE_BASE = `https://github.com/
|
|
8
|
+
export const BACKEND_VERSION = `v${release.version}`;
|
|
9
|
+
const RELEASE_BASE = `https://github.com/${release.repository}/releases/download/${release.tag}/`;
|
|
10
|
+
export const PINNED_BINARIES = release.binaries;
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
+
const UPSTREAM_VERSION = 'v4.22.0';
|
|
13
|
+
const UPSTREAM_BASE = `https://github.com/zexadev/gemini-web2api-go/releases/download/${UPSTREAM_VERSION}/`;
|
|
14
|
+
// Retained for an explicit, checksum-verified rollback to the upstream release.
|
|
15
|
+
export const UPSTREAM_BINARIES = {
|
|
12
16
|
'darwin-arm64': { asset: 'gemini-web2api-go_v4.22.0_darwin_arm64', sha256: 'c69ca75cf43017cf2a62dbf5c12b07f6bde66f403f714c32209b1ad350ed24c9' },
|
|
13
17
|
'darwin-x64': { asset: 'gemini-web2api-go_v4.22.0_darwin_amd64', sha256: '384e617bcc30aacac6b7c4e4d0490e88d282b13b29f7a0a20d645b69514cd405' },
|
|
14
18
|
'linux-x64': { asset: 'gemini-web2api-go_v4.22.0_linux_amd64', sha256: '21fcc81e0ad8ce0921e6b6c2cafce1089852530142b37e0289355c6e72df5299' },
|
|
@@ -17,12 +21,38 @@ export const PINNED_BINARIES = {
|
|
|
17
21
|
'win32-arm64': { asset: 'gemini-web2api-go_v4.22.0_windows_arm64.exe', sha256: 'b42e951bfc08d7fe8bdb436dca87fc6a1b7948bb8d10e110653bcf13a790756c' }
|
|
18
22
|
};
|
|
19
23
|
|
|
20
|
-
export function pinnedBinary(platform = process.platform, arch = process.arch) {
|
|
21
|
-
|
|
24
|
+
export function pinnedBinary(platform = process.platform, arch = process.arch, channel = 'optimized') {
|
|
25
|
+
if (channel !== 'optimized' && channel !== 'upstream') throw new Error(`Invalid backend channel: ${channel}`);
|
|
26
|
+
const pins = channel === 'upstream' ? UPSTREAM_BINARIES : PINNED_BINARIES;
|
|
27
|
+
const base = channel === 'upstream' ? UPSTREAM_BASE : RELEASE_BASE;
|
|
28
|
+
const version = channel === 'upstream' ? UPSTREAM_VERSION : BACKEND_VERSION;
|
|
29
|
+
const pin = pins[`${platform}-${arch}`];
|
|
22
30
|
if (!pin) {
|
|
23
|
-
throw new Error(`No gemini-web2api-go ${
|
|
31
|
+
throw new Error(`No gemini-web2api-go ${version} build for ${platform}/${arch}. See ${base.slice(0, -1).replace('/download/', '/tag/')}`);
|
|
32
|
+
}
|
|
33
|
+
return { url: base + pin.asset, sha256: pin.sha256 };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function backendChannel(dataDir = getDataDir()) {
|
|
37
|
+
try {
|
|
38
|
+
const { channel } = JSON.parse(fs.readFileSync(path.join(dataDir, 'backend-channel.json'), 'utf8'));
|
|
39
|
+
if (channel !== 'optimized' && channel !== 'upstream') throw new Error('Expected optimized or upstream');
|
|
40
|
+
return channel;
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (error.code === 'ENOENT') return 'optimized';
|
|
43
|
+
throw new Error(`Invalid backend-channel.json: ${error.message}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function saveBackendChannel(dataDir, channel) {
|
|
48
|
+
const file = path.join(dataDir, 'backend-channel.json');
|
|
49
|
+
const temp = `${file}.${crypto.randomBytes(4).toString('hex')}.tmp`;
|
|
50
|
+
try {
|
|
51
|
+
fs.writeFileSync(temp, JSON.stringify({ channel }) + '\n', { mode: 0o600 });
|
|
52
|
+
fs.renameSync(temp, file);
|
|
53
|
+
} finally {
|
|
54
|
+
fs.rmSync(temp, { force: true });
|
|
24
55
|
}
|
|
25
|
-
return { url: RELEASE_BASE + pin.asset, sha256: pin.sha256 };
|
|
26
56
|
}
|
|
27
57
|
|
|
28
58
|
export function getDataDir(env = process.env) {
|
|
@@ -41,7 +71,8 @@ export function computeFileHash(filePath) {
|
|
|
41
71
|
export async function installBackend(options = {}) {
|
|
42
72
|
const platform = options.platform || process.platform;
|
|
43
73
|
const arch = options.arch || process.arch;
|
|
44
|
-
const
|
|
74
|
+
const channel = options.channel ?? 'optimized';
|
|
75
|
+
const pin = options.expectedHash && options.url ? null : pinnedBinary(platform, arch, channel);
|
|
45
76
|
const expectedHash = options.expectedHash || pin.sha256;
|
|
46
77
|
const downloadUrl = options.url || pin.url;
|
|
47
78
|
const fetchFn = options.fetchFn || globalThis.fetch;
|
|
@@ -62,8 +93,6 @@ export async function installBackend(options = {}) {
|
|
|
62
93
|
if (existingHash === expectedHash) {
|
|
63
94
|
binaryReady = true;
|
|
64
95
|
console.log(`Backend binary verified: ${binPath}`);
|
|
65
|
-
} else {
|
|
66
|
-
fs.unlinkSync(binPath);
|
|
67
96
|
}
|
|
68
97
|
}
|
|
69
98
|
|
|
@@ -97,7 +126,11 @@ export async function installBackend(options = {}) {
|
|
|
97
126
|
}
|
|
98
127
|
}
|
|
99
128
|
|
|
100
|
-
|
|
129
|
+
const files = ensureDataFiles(dataDir);
|
|
130
|
+
// A local custom build keeps the user's channel. Official installs record their
|
|
131
|
+
// selection only after verification and replacement succeeded.
|
|
132
|
+
if ((!options.expectedHash && !options.url) || options.channel !== undefined) saveBackendChannel(dataDir, channel);
|
|
133
|
+
return { binPath, ...files };
|
|
101
134
|
}
|
|
102
135
|
|
|
103
136
|
// ensureDataFiles creates config.json and secrets.json when missing; existing ones are kept.
|
|
@@ -136,7 +169,7 @@ function ensureDataFiles(dataDir) {
|
|
|
136
169
|
|
|
137
170
|
export function startBackend(options = {}) {
|
|
138
171
|
const dataDir = options.dataDir || getDataDir();
|
|
139
|
-
const expectedHash = options.expectedHash || pinnedBinary().sha256;
|
|
172
|
+
const expectedHash = options.expectedHash || pinnedBinary(process.platform, process.arch, backendChannel(dataDir)).sha256;
|
|
140
173
|
const spawnFn = options.spawnFn || spawn;
|
|
141
174
|
const { binPath, args, env } = prepareLaunch(dataDir, [expectedHash]);
|
|
142
175
|
|
|
@@ -236,12 +269,11 @@ function prepareLaunch(dataDir, trustedHashes, platform = process.platform) {
|
|
|
236
269
|
return { binPath, args, env, port: config.port, secrets };
|
|
237
270
|
}
|
|
238
271
|
|
|
239
|
-
// trustedHashes
|
|
240
|
-
// this platform, plus a local Argon build installed by `backend:install:argon`.
|
|
272
|
+
// trustedHashes accepts the selected release channel and any explicitly installed local build.
|
|
241
273
|
function trustedHashes(dataDir, platform, arch) {
|
|
242
274
|
const hashes = [];
|
|
243
275
|
try {
|
|
244
|
-
hashes.push(pinnedBinary(platform, arch).sha256);
|
|
276
|
+
hashes.push(pinnedBinary(platform, arch, backendChannel(dataDir)).sha256);
|
|
245
277
|
} catch {}
|
|
246
278
|
try {
|
|
247
279
|
const argon = JSON.parse(fs.readFileSync(path.join(dataDir, 'argon-build.json'), 'utf8'));
|
|
@@ -318,7 +350,7 @@ async function withLock(dataDir, fn, timeoutMs) {
|
|
|
318
350
|
|
|
319
351
|
// ensureBackend makes sure the local backend is installed and running, and returns how to reach
|
|
320
352
|
// it. A backend already answering with this setup's key is left alone. Otherwise a missing or
|
|
321
|
-
// untrusted binary is (re)installed from the
|
|
353
|
+
// untrusted binary is (re)installed from the selected release channel, config and secrets are
|
|
322
354
|
// created when missing, and the backend is started in the background (logging to backend.log)
|
|
323
355
|
// so it outlives the caller.
|
|
324
356
|
export async function ensureBackend(options = {}) {
|
|
@@ -344,7 +376,7 @@ export async function ensureBackend(options = {}) {
|
|
|
344
376
|
const trusted = trustedHashes(dataDir, platform, arch);
|
|
345
377
|
const binPath = binaryPath(dataDir, platform);
|
|
346
378
|
if (!fs.existsSync(binPath) || !trusted.includes(computeFileHash(binPath))) {
|
|
347
|
-
await installBackend({ dataDir, platform, arch, fetchFn });
|
|
379
|
+
await installBackend({ dataDir, platform, arch, fetchFn, channel: backendChannel(dataDir) });
|
|
348
380
|
} else {
|
|
349
381
|
ensureDataFiles(dataDir);
|
|
350
382
|
}
|
package/lib/setup.mjs
CHANGED
|
@@ -78,7 +78,12 @@ export function parseCookieInput(input) {
|
|
|
78
78
|
return [...cookies].map(([n, v]) => `${n}=${v}`).join('; ');
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
|
|
81
|
+
const ADMIN_TIMEOUT_MS = 60000;
|
|
82
|
+
|
|
83
|
+
// adminCall is one request to the backend's admin API. A request that gets no answer within
|
|
84
|
+
// ADMIN_TIMEOUT_MS fails with timeoutMessage when one is given: the backend was reached, but what
|
|
85
|
+
// it does for the request took too long.
|
|
86
|
+
async function adminCall(setup, fetchFn, method, path, body, timeoutMessage) {
|
|
82
87
|
let res;
|
|
83
88
|
try {
|
|
84
89
|
res = await fetchFn(`${setup.origin}/admin/api/${path}`, {
|
|
@@ -89,9 +94,10 @@ async function adminCall(setup, fetchFn, method, path, body) {
|
|
|
89
94
|
},
|
|
90
95
|
body: body ? JSON.stringify(body) : undefined,
|
|
91
96
|
redirect: 'error',
|
|
92
|
-
signal: AbortSignal.timeout(
|
|
97
|
+
signal: AbortSignal.timeout(ADMIN_TIMEOUT_MS)
|
|
93
98
|
});
|
|
94
99
|
} catch (err) {
|
|
100
|
+
if (timeoutMessage && err?.name === 'TimeoutError') throw new Error(timeoutMessage);
|
|
95
101
|
throw new Error(`连不上本地 Gemini Web 后端(${setup.origin}):${err?.message || err}`);
|
|
96
102
|
}
|
|
97
103
|
let data = {};
|
|
@@ -102,6 +108,22 @@ async function adminCall(setup, fetchFn, method, path, body) {
|
|
|
102
108
|
return data;
|
|
103
109
|
}
|
|
104
110
|
|
|
111
|
+
// The check is the backend fetching gemini.google.com with the cookie, directly or through the
|
|
112
|
+
// backend's own proxy pool: neither Magpie's proxy nor HTTP_PROXY reach it.
|
|
113
|
+
function proxyHint(setup) {
|
|
114
|
+
return `后端不走 Magpie 的代理,也不读系统代理(HTTP_PROXY / HTTPS_PROXY):需要代理才能访问 Google 的话,请在管理面板 ${setup.origin}/admin 的代理池里添加(登录口令是数据目录 secrets.json 里的 adminToken),然后重试。`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// checkFailure is the error for a check that came back not ok. The backend words the cookie's
|
|
118
|
+
// own verdicts (signed out, redirected to sign-in, rate limited) itself and prefixes everything
|
|
119
|
+
// else, a failed fetch of gemini.google.com among it, with "检测失败" (explainCookieFailure).
|
|
120
|
+
function checkFailure(setup, detail) {
|
|
121
|
+
if (detail.startsWith('检测失败')) {
|
|
122
|
+
return new Error(`后端没能访问 gemini.google.com 来检测这个 Cookie(${detail})。${proxyHint(setup)}`);
|
|
123
|
+
}
|
|
124
|
+
return new Error(`这个 Cookie 无法登录 Gemini:${detail || '检测未通过'}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
105
127
|
// importCookie adds the cookie to the backend's account pool and checks it signs in. An
|
|
106
128
|
// account that fails the check is removed again, and the error says why.
|
|
107
129
|
export async function importCookie(setup, cookie, options = {}) {
|
|
@@ -116,14 +138,15 @@ export async function importCookie(setup, cookie, options = {}) {
|
|
|
116
138
|
|
|
117
139
|
let check;
|
|
118
140
|
try {
|
|
119
|
-
check = await adminCall(setup, fetchFn, 'POST', `cookies/${added.id}/check
|
|
141
|
+
check = await adminCall(setup, fetchFn, 'POST', `cookies/${added.id}/check`, undefined,
|
|
142
|
+
`后端 ${ADMIN_TIMEOUT_MS / 1000} 秒内没检测完这个 Cookie:检测要由后端访问 gemini.google.com,这一步一直没有返回,多半是后端连不上 Google。${proxyHint(setup)}`);
|
|
120
143
|
} catch (err) {
|
|
121
144
|
await adminCall(setup, fetchFn, 'DELETE', `cookies/${added.id}`).catch(() => {});
|
|
122
145
|
throw err;
|
|
123
146
|
}
|
|
124
147
|
if (!check.ok) {
|
|
125
148
|
await adminCall(setup, fetchFn, 'DELETE', `cookies/${added.id}`).catch(() => {});
|
|
126
|
-
throw
|
|
149
|
+
throw checkFailure(setup, check.detail || '');
|
|
127
150
|
}
|
|
128
151
|
return { id: added.id, detail: check.detail || '' };
|
|
129
152
|
}
|