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/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 = 'v4.22.0';
8
- const RELEASE_BASE = `https://github.com/zexadev/gemini-web2api-go/releases/download/${BACKEND_VERSION}/`;
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
- // Upstream release assets by `${process.platform}-${process.arch}`, SHA-256 from the release's SHA256SUMS.txt.
11
- export const PINNED_BINARIES = {
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
- const pin = PINNED_BINARIES[`${platform}-${arch}`];
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 ${BACKEND_VERSION} build for ${platform}/${arch}. Download one manually from https://github.com/zexadev/gemini-web2api-go/releases/tag/${BACKEND_VERSION}`);
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 pin = options.expectedHash && options.url ? null : pinnedBinary(platform, arch);
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
- return { binPath, ...ensureDataFiles(dataDir) };
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 is what a backend binary in dataDir may hash to: the pinned upstream build for
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 pinned upstream release, config and secrets are
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
- async function adminCall(setup, fetchFn, method, path, body) {
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(60000)
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 new Error(`这个 Cookie 无法登录 Gemini:${check.detail || '检测未通过'}`);
149
+ throw checkFailure(setup, check.detail || '');
127
150
  }
128
151
  return { id: added.id, detail: check.detail || '' };
129
152
  }