magpie-gemini-web 0.5.0 → 0.5.2

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.
@@ -30,9 +30,14 @@ export function computeFileHash(filePath) {
30
30
  // studioCommand is a command line for this plugin's own management CLI, to quote in messages.
31
31
  // Magpie installs the plugin where its user doesn't look, so `npm run studio:*` from a checkout
32
32
  // is no help to them; the CLI's full path also keeps its pinned version the plugin's own.
33
- export function studioCommand(action) {
33
+ export function studioCommand(action, { dataDir } = {}) {
34
34
  const script = fileURLToPath(new URL('../scripts/studio.mjs', import.meta.url));
35
- return `node "${script}" ${action}`;
35
+ const command = `node "${script}" ${action}`;
36
+ if (!dataDir) return command;
37
+ const quoted = process.platform === 'win32'
38
+ ? `"${dataDir.replaceAll('"', '""')}"`
39
+ : `'${dataDir.replaceAll("'", "'\\''")}'`;
40
+ return `${command} --data-dir ${quoted}`;
36
41
  }
37
42
 
38
43
  // pickStudioPort is the port for a new setup: preferred when nothing uses it, else one the system
@@ -169,7 +174,7 @@ function serviceEnv(dataDir, config, options = {}) {
169
174
  MAX_ACTIVE_WORKERS: '1',
170
175
  WARM_STARTUP_CONCURRENCY: '1',
171
176
  ROUTING_STRATEGY: 'fill-first',
172
- REQUEST_TIMEOUT: '5m',
177
+ REQUEST_TIMEOUT: '30m',
173
178
  INIT_TIMEOUT: '2m',
174
179
  PROXY: effectiveProxy(options.proxy, config.proxy)
175
180
  };
@@ -438,7 +443,8 @@ export function isConnectRefused(err) {
438
443
  }
439
444
 
440
445
  // checkStudioHealth says what answers on the setup's port: 'down' (nothing), 'up' (this setup's
441
- // service, at the pinned version), 'outdated' (this setup's service, at another version),
446
+ // service, at the pinned version), 'outdated' (an older official version),
447
+ // 'version_conflict' (a newer or custom version installed outside this plugin),
442
448
  // 'foreign' (something else) or 'unresponsive' (something that times out or fails, as the
443
449
  // service can while it starts or when it hangs).
444
450
  export async function checkStudioHealth(local, fetchFn) {
@@ -481,10 +487,15 @@ export async function checkStudioHealth(local, fetchFn) {
481
487
  }
482
488
  }
483
489
 
484
- // The service took this setup's own admin credentials, so this plugin installed it. Another
485
- // version is an earlier install still running, as after a plugin update pinned a new one.
486
- if (String(status.version).replace(/^v/, '') !== release.version) {
487
- return { state: 'outdated', status, reason: `Running version ${status.version}, expected ${STUDIO_VERSION}` };
490
+ // Official releases at or below our upstream base can move to the managed
491
+ // build. Newer/custom installations require an explicit recovery decision.
492
+ const runningVersion = String(status.version).replace(/^v/, '');
493
+ if (runningVersion !== release.version) {
494
+ const parts = /^\d+\.\d+\.\d+$/.test(runningVersion) ? runningVersion.split('.').map(Number) : null;
495
+ const expected = release.upstream.version.split('.').map(Number);
496
+ const different = parts?.findIndex((n, i) => n !== expected[i]);
497
+ const older = parts && (different === -1 || parts[different] < expected[different]);
498
+ return { state: older ? 'outdated' : 'version_conflict', status, reason: `Running version ${status.version}, expected ${STUDIO_VERSION}` };
488
499
  }
489
500
 
490
501
  return { state: 'up', status };
@@ -504,8 +515,9 @@ export async function studioStatus(options = {}) {
504
515
  if (health.state === 'down') {
505
516
  return { status: 'stopped', port: local.port, dataDir };
506
517
  }
507
- if (health.state === 'outdated') {
508
- return { ...health.status, status: 'outdated', expected: STUDIO_VERSION, port: local.port, dataDir };
518
+ if (health.state === 'outdated' || health.state === 'version_conflict') {
519
+ return { ...health.status, status: health.state, expected: STUDIO_VERSION, port: local.port, dataDir,
520
+ ...(health.state === 'version_conflict' ? { reason: studioVersionConflict(local, health).message } : {}) };
509
521
  }
510
522
  return { status: health.state, reason: health.reason, port: local.port, dataDir };
511
523
  }
@@ -526,6 +538,7 @@ export function verifyProcessIdentity(pid, expectedBinaryPath, platform = proces
526
538
  ], {
527
539
  env: { ...process.env, TARGET_PID: String(pid) },
528
540
  encoding: 'utf8',
541
+ timeout: 20000,
529
542
  windowsHide: true
530
543
  }).trim();
531
544
  if (!out) return false;
@@ -563,6 +576,102 @@ export function verifyProcessIdentity(pid, expectedBinaryPath, platform = proces
563
576
  return false;
564
577
  }
565
578
 
579
+ function processAlive(pid) {
580
+ if (!Number.isInteger(pid) || pid <= 0) return false;
581
+ try { process.kill(pid, 0); return true; } catch (err) { return err.code === 'EPERM'; }
582
+ }
583
+
584
+ function recordedPid(dataDir) {
585
+ try { return Number(fs.readFileSync(path.join(dataDir, 'backend.pid'), 'utf8').trim()); } catch { return NaN; }
586
+ }
587
+
588
+ // A self-update can close HTTP before exec (Unix), or spawn a replacement PID
589
+ // (Windows). Inspect processes as well as the port before replacing or launching.
590
+ export function studioProcessPids(bin, platform = process.platform, run = execFileSync) {
591
+ const opts = { encoding: 'utf8', timeout: platform === 'win32' ? 20000 : 5000, windowsHide: true };
592
+ let output;
593
+ if (platform === 'win32') {
594
+ try {
595
+ output = run('powershell', ['-NoProfile', '-NonInteractive', '-Command',
596
+ '$ErrorActionPreference = "Stop"; Get-CimInstance Win32_Process -Filter "Name=\'aistudio2api.exe\'" | Where-Object { $_.ExecutablePath -eq $env:TARGET_BINARY } | Select-Object -ExpandProperty ProcessId'],
597
+ { ...opts, env: { ...process.env, TARGET_BINARY: path.resolve(bin) } });
598
+ } catch (err) {
599
+ const dataDir = path.dirname(path.dirname(bin));
600
+ throw new Error(`Cannot check for remaining AIStudio2API processes. Check that PowerShell/CIM is available and permitted, then run: ${studioCommand('stop', { dataDir })}. No binary was replaced or new process started.`, { cause: err });
601
+ }
602
+ return [...new Set(String(output).trim().split(/\s+/).map(Number).filter(n => Number.isInteger(n) && n > 0))];
603
+ }
604
+ if (platform === 'linux' && run === execFileSync) {
605
+ // Linux ps comm is often only a basename. /proc retains the launch path
606
+ // even while the updater has renamed/unlinked the running executable.
607
+ return fs.readdirSync('/proc').filter(n => /^\d+$/.test(n)).flatMap(n => {
608
+ try {
609
+ const command = fs.readFileSync(`/proc/${n}/cmdline`, 'utf8').split('\0')[0];
610
+ return path.isAbsolute(command) && path.resolve(command) === path.resolve(bin) ? [Number(n)] : [];
611
+ } catch { return []; }
612
+ });
613
+ }
614
+ output = run('ps', ['-axo', 'pid=,comm='], opts);
615
+ return String(output).split('\n').flatMap(line => {
616
+ const match = line.trim().match(/^(\d+)\s+(.+)$/);
617
+ if (!match) return [];
618
+ const command = match[2].replace(/ \(deleted\)$/, '');
619
+ return path.isAbsolute(command) && path.resolve(command) === path.resolve(bin) ? [Number(match[1])] : [];
620
+ });
621
+ }
622
+
623
+ export function studioListenerPids(port, platform = process.platform, run = execFileSync) {
624
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('Invalid listener port');
625
+ const opts = { encoding: 'utf8', timeout: platform === 'win32' ? 20000 : 5000, windowsHide: true };
626
+ let output;
627
+ if (platform === 'win32') {
628
+ output = run('powershell', ['-NoProfile', '-NonInteractive', '-Command',
629
+ '$ErrorActionPreference = "Stop"; Get-NetTCPConnection -State Listen -LocalPort $env:TARGET_PORT | Select-Object -ExpandProperty OwningProcess -Unique'],
630
+ { ...opts, env: { ...process.env, TARGET_PORT: String(port) } });
631
+ } else {
632
+ try {
633
+ output = run('lsof', ['-nP', '-t', `-iTCP:${port}`, '-sTCP:LISTEN'], opts);
634
+ } catch (err) {
635
+ if (platform !== 'linux') throw err;
636
+ const rows = run('ss', ['-H', '-ltnp', `sport = :${port}`], opts);
637
+ output = [...String(rows).matchAll(/pid=(\d+)/g)].map(m => m[1]).join('\n');
638
+ }
639
+ }
640
+ return [...new Set(String(output).trim().split(/\s+/).map(Number).filter(n => Number.isInteger(n) && n > 0))];
641
+ }
642
+
643
+ function assertStudioExited(dataDir, platform) {
644
+ const tracked = recordedPid(dataDir);
645
+ const bin = binaryPath(dataDir, platform);
646
+ let pids;
647
+ if (processAlive(tracked) && verifyProcessIdentity(tracked, bin, platform)) {
648
+ pids = [tracked];
649
+ } else {
650
+ // A live PID can belong to an unrelated process after a crash or reboot.
651
+ fs.rmSync(path.join(dataDir, 'backend.pid'), { force: true });
652
+ pids = studioProcessPids(bin, platform);
653
+ }
654
+ if (pids.length) {
655
+ throw new Error(`AI Studio may still be running or restarting (PID ${pids.join(', ')}), even though its HTTP port is closed. Wait for it to finish restarting, or run: ${studioCommand('stop', { dataDir })}. No binary was replaced or new process started.`);
656
+ }
657
+ }
658
+
659
+ function protectPackage(dataDir, platform) {
660
+ if (platform === 'win32') return;
661
+ // Best-effort package hardening. Online updates are disabled in the managed
662
+ // backend itself; this permission is not the mechanism enforcing that policy.
663
+ try {
664
+ const pkg = path.join(dataDir, 'package');
665
+ if ((fs.statSync(pkg).mode & 0o777) !== 0o555) fs.chmodSync(pkg, 0o555);
666
+ } catch {}
667
+ }
668
+
669
+ function removePackage(dir, platform) {
670
+ if (!fs.existsSync(dir)) return;
671
+ if (platform !== 'win32') fs.chmodSync(dir, 0o755);
672
+ fs.rmSync(dir, { recursive: true, force: true });
673
+ }
674
+
566
675
  const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
567
676
 
568
677
  async function withLock(dataDir, fn, timeoutMs = 600000) {
@@ -654,6 +763,7 @@ export async function installStudio(options = {}) {
654
763
  if (health.state === 'up') {
655
764
  throw new Error('Cannot install or update AIStudio2API while service is running. Stop the service first.');
656
765
  }
766
+ if (health.state === 'version_conflict') throw studioVersionConflict(localExisting, health);
657
767
  if (health.state === 'outdated') {
658
768
  throw new Error(`Cannot install AIStudio2API ${STUDIO_VERSION} while ${health.status.version} from an earlier install is running. Stop the service first.`);
659
769
  }
@@ -665,6 +775,7 @@ export async function installStudio(options = {}) {
665
775
  }
666
776
  }
667
777
 
778
+ assertStudioExited(dataDir, platform);
668
779
  const expectedHash = pin.sha256;
669
780
  let archivePath = options.archivePath;
670
781
  // The archive kept from an earlier install sets a damaged binary right again without a download.
@@ -785,16 +896,23 @@ export async function installStudio(options = {}) {
785
896
  const stagePackageDir = path.join(dataDir, `.package.stage.${crypto.randomBytes(4).toString('hex')}`);
786
897
  const backupPackageDir = path.join(dataDir, `.package.backup.${crypto.randomBytes(4).toString('hex')}`);
787
898
 
899
+ // Download/extraction can take minutes: recheck before the actual swap too.
900
+ assertStudioExited(dataDir, platform);
788
901
  fs.renameSync(topDir, stagePackageDir);
789
902
 
790
903
  let hasBackup = false;
791
904
  let installedNewFinal = false;
792
- if (fs.existsSync(finalPackageDir)) {
793
- fs.renameSync(finalPackageDir, backupPackageDir);
794
- hasBackup = true;
795
- }
905
+ const oldPackageMode = platform !== 'win32' && fs.existsSync(finalPackageDir)
906
+ ? fs.statSync(finalPackageDir).mode & 0o777 : null;
796
907
 
797
908
  try {
909
+ if (fs.existsSync(finalPackageDir)) {
910
+ // macOS can reject renaming a read-only directory even when its parent
911
+ // is writable. Unlock only for this controlled swap and restore on failure.
912
+ if (oldPackageMode !== null) fs.chmodSync(finalPackageDir, oldPackageMode | 0o700);
913
+ fs.renameSync(finalPackageDir, backupPackageDir);
914
+ hasBackup = true;
915
+ }
798
916
  fs.renameSync(stagePackageDir, finalPackageDir);
799
917
  installedNewFinal = true;
800
918
 
@@ -811,18 +929,23 @@ export async function installStudio(options = {}) {
811
929
  fs.chmodSync(manifestPath, 0o600);
812
930
 
813
931
  ensureStudioConfig(dataDir, options.proxy, { port });
932
+ protectPackage(dataDir, platform);
814
933
 
815
934
  if (hasBackup) {
816
- fs.rmSync(backupPackageDir, { recursive: true, force: true });
935
+ removePackage(backupPackageDir, platform);
817
936
  }
818
937
  removeOtherArchives(dataDir, pin.asset);
819
938
  } catch (swapErr) {
820
939
  if (installedNewFinal) {
821
- try { fs.rmSync(finalPackageDir, { recursive: true, force: true }); } catch {}
940
+ try { removePackage(finalPackageDir, platform); } catch {}
822
941
  }
823
942
  if (hasBackup) {
824
943
  try { fs.renameSync(backupPackageDir, finalPackageDir); } catch {}
825
944
  }
945
+ if (oldPackageMode !== null && fs.existsSync(finalPackageDir)) {
946
+ try { fs.chmodSync(finalPackageDir, oldPackageMode); } catch {}
947
+ }
948
+ try { removePackage(stagePackageDir, platform); } catch {}
826
949
  if (oldManifestExists && oldManifestBytes) {
827
950
  try { fs.writeFileSync(manifestPath, oldManifestBytes, { mode: 0o600 }); } catch {}
828
951
  } else {
@@ -860,7 +983,7 @@ async function readyStudio(local, initial, options = {}) {
860
983
  for (;;) {
861
984
  if (status.running) return local;
862
985
  if (needsSignIn(status.accounts)) {
863
- throw new Error(`AI Studio cannot start: its Google accounts need to sign in again (${status.accounts.auth_required} of ${status.accounts.total}). In Magpie, sign in to AI Studio with "重新登录失效的 AI Studio 账号", or run: ${studioCommand('relogin')}`);
986
+ throw new Error(`AI Studio cannot start: its Google accounts need to sign in again (${status.accounts.auth_required} of ${status.accounts.total}). In Magpie, sign in to Google AI Studio with "用 Google 账号登录(推荐)" and choose the account, or run: ${studioCommand('relogin', { dataDir: local.dataDir })}`);
864
987
  }
865
988
  if (Date.now() >= deadline) {
866
989
  throw new Error(`AI Studio generation did not become ready (state ${status.state}, ${status.accounts.ready ?? 0}/${status.accounts.total} accounts ready); see ${path.join(local.dataDir, 'backend.log')}`);
@@ -896,7 +1019,7 @@ function portTaken(local) {
896
1019
  }
897
1020
 
898
1021
  function notResponding(local, health) {
899
- return new Error(`The AI Studio service on port ${local.port} is not responding (${health.reason}). If it is stuck, run: ${studioCommand('stop')}; if another program uses the port, change "port" in ${path.join(local.dataDir, 'config.json')}`);
1022
+ return new Error(`The AI Studio service on port ${local.port} is not responding (${health.reason}). If it is stuck, run: ${studioCommand('stop', { dataDir: local.dataDir })}; if another program uses the port, change "port" in ${path.join(local.dataDir, 'config.json')}`);
900
1023
  }
901
1024
 
902
1025
  // rotateLog sets a log past LOG_ROTATE_BYTES aside as <log>.1, replacing the one before.
@@ -935,7 +1058,11 @@ async function launchStudio(options) {
935
1058
  let local = localStudio({ dataDir, platform });
936
1059
  if (local) {
937
1060
  const health = await checkStudioHealth(local, fetchFn);
938
- if (health.state === 'up') return { local, status: health.status };
1061
+ if (health.state === 'up') {
1062
+ protectPackage(dataDir, platform);
1063
+ return { local, status: health.status };
1064
+ }
1065
+ if (health.state === 'version_conflict') throw studioVersionConflict(local, health);
939
1066
  if (health.state === 'foreign') throw portTaken(local);
940
1067
  }
941
1068
 
@@ -944,10 +1071,16 @@ async function launchStudio(options) {
944
1071
  local = localStudio({ dataDir, platform });
945
1072
  if (local) {
946
1073
  const health = await settle(local, await checkStudioHealth(local, fetchFn), fetchFn, options.unresponsiveTimeoutMs ?? 10000);
947
- if (health.state === 'up') return { local, status: health.status };
1074
+ if (health.state === 'up') {
1075
+ protectPackage(dataDir, platform);
1076
+ return { local, status: health.status };
1077
+ }
1078
+ if (health.state === 'version_conflict') throw studioVersionConflict(local, health);
1079
+ if (health.state === 'foreign') throw portTaken(local);
948
1080
  if (health.state === 'unresponsive') throw notResponding(local, health);
949
1081
  if (health.state === 'outdated') await stopOutdated(local, health, { dataDir, platform, fetchFn });
950
1082
  }
1083
+ assertStudioExited(dataDir, platform);
951
1084
 
952
1085
  // Check config validity before touching binaries
953
1086
  const configPath = path.join(dataDir, 'config.json');
@@ -970,14 +1103,12 @@ async function launchStudio(options) {
970
1103
  manifest.arch === arch &&
971
1104
  manifest.archiveSha256 === pin.sha256
972
1105
  ) {
973
- if (currentHash === manifest.binarySha256) {
974
- manifestValid = true;
975
- } else {
976
- throw new Error('Binary checksum verification failed (mutated binary refused on launch)');
977
- }
1106
+ // A stopped service may have self-updated. Restore the pinned archive below;
1107
+ // never bless the replacement by rewriting its hash into the manifest.
1108
+ manifestValid = currentHash === manifest.binarySha256;
978
1109
  }
979
- } catch (err) {
980
- if (err.message.includes('mutated binary')) throw err;
1110
+ } catch {
1111
+ // Missing/invalid installation metadata also requires a verified reinstall.
981
1112
  }
982
1113
  }
983
1114
 
@@ -991,8 +1122,11 @@ async function launchStudio(options) {
991
1122
 
992
1123
  const health = await checkStudioHealth(local, fetchFn);
993
1124
  if (health.state === 'up') return { local, status: health.status };
1125
+ if (health.state === 'version_conflict') throw studioVersionConflict(local, health);
994
1126
  if (health.state === 'foreign') throw portTaken(local);
995
1127
  if (health.state !== 'down') throw notResponding(local, health);
1128
+ assertStudioExited(dataDir, platform);
1129
+ protectPackage(dataDir, platform);
996
1130
 
997
1131
  // Clean environment: no arbitrary host AISTUDIO_* or WAA_* vars
998
1132
  const cleanEnv = {};
@@ -1006,6 +1140,7 @@ async function launchStudio(options) {
1006
1140
  const logPath = path.join(dataDir, 'backend.log');
1007
1141
  rotateLog(logPath);
1008
1142
  const logFd = fs.openSync(logPath, 'a', 0o600);
1143
+ let launchError;
1009
1144
  try {
1010
1145
  const child = spawnFn(binPath, ['-open-ui=false'], {
1011
1146
  cwd: dataDir,
@@ -1014,7 +1149,16 @@ async function launchStudio(options) {
1014
1149
  stdio: ['ignore', logFd, logFd],
1015
1150
  windowsHide: true
1016
1151
  });
1017
- child.on?.('error', () => {});
1152
+ const clearChildPid = () => {
1153
+ if (child.pid && recordedPid(dataDir) === child.pid) {
1154
+ try { fs.rmSync(path.join(dataDir, 'backend.pid'), { force: true }); } catch {}
1155
+ }
1156
+ };
1157
+ child.on?.('error', err => { launchError = err; clearChildPid(); });
1158
+ child.on?.('exit', (code, signal) => {
1159
+ launchError = new Error(`AIStudio2API exited during startup (${signal || code}); see ${logPath}`);
1160
+ clearChildPid();
1161
+ });
1018
1162
  if (child.pid) {
1019
1163
  fs.writeFileSync(path.join(dataDir, 'backend.pid'), `${child.pid}\n`, { mode: 0o600 });
1020
1164
  }
@@ -1029,15 +1173,26 @@ async function launchStudio(options) {
1029
1173
  const h = await checkStudioHealth(local, fetchFn);
1030
1174
  if (h.state === 'up') return { local, status: h.status };
1031
1175
  if (h.state === 'foreign') throw portTaken(local);
1176
+ if (h.state === 'version_conflict') throw studioVersionConflict(local, h);
1032
1177
  if (h.state === 'outdated') {
1033
1178
  throw new Error(`AIStudio2API on port ${local.port} reports version ${h.status.version}, expected ${STUDIO_VERSION}`);
1034
1179
  }
1180
+ if (launchError) throw launchError;
1035
1181
  await sleep(250);
1036
1182
  }
1037
1183
  throw new Error(`AI Studio service did not start within ${Math.round(startTimeoutMs / 1000)}s; see ${logPath}`);
1038
1184
  });
1039
1185
  }
1040
1186
 
1187
+ function studioVersionConflict(local, health) {
1188
+ return new Error(`AI Studio is running ${health.status.version}, but this plugin manages ${STUDIO_VERSION}. ` +
1189
+ `Update magpie-gemini-web instead of using the backend admin's online updater. ` +
1190
+ `To restore ${STUDIO_VERSION}, run: ${studioCommand('stop', { dataDir: local.dataDir })}; ` +
1191
+ `after it stops, back up ${local.dataDir}, then run: ${studioCommand('install', { dataDir: local.dataDir })}; ` +
1192
+ `then ${studioCommand('start', { dataDir: local.dataDir })}. ` +
1193
+ `Existing data files are kept, but an older backend may not read data migrated by a newer release.`);
1194
+ }
1195
+
1041
1196
  // stopOutdated stops an earlier install of the service that is still running, so the pinned
1042
1197
  // version can be installed and started in its place.
1043
1198
  async function stopOutdated(local, health, options) {
@@ -1071,29 +1226,35 @@ export async function stopStudio(options = {}) {
1071
1226
  throw new Error(`Cannot stop unknown or foreign process listening on port ${local.port}`);
1072
1227
  }
1073
1228
 
1074
- let pid = NaN;
1075
- try {
1076
- pid = parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10);
1077
- } catch {}
1078
-
1079
- let alive = Number.isInteger(pid) && pid > 0;
1080
- if (alive) {
1229
+ let pid = recordedPid(dataDir);
1230
+ const bin = binaryPath(dataDir, platform);
1231
+ let alive = processAlive(pid) && verifyProcessIdentity(pid, bin, platform);
1232
+ let discoveryError;
1233
+ let discovered = [];
1234
+ if (!alive) {
1235
+ fs.rmSync(pidFile, { force: true });
1236
+ // Upstream's Windows updater starts a new PID. Recover only an executable
1237
+ // at this installation's exact path; never signal a port owner by name alone.
1081
1238
  try {
1082
- process.kill(pid, 0);
1083
- } catch {
1084
- alive = false;
1085
- }
1239
+ const candidates = health.state === 'down'
1240
+ ? studioProcessPids(bin, platform)
1241
+ : studioListenerPids(local.port, platform);
1242
+ discovered = candidates;
1243
+ const verified = candidates.filter(p => processAlive(p) && verifyProcessIdentity(p, bin, platform));
1244
+ if (verified.length === 1) {
1245
+ pid = verified[0];
1246
+ alive = true;
1247
+ fs.writeFileSync(pidFile, `${pid}\n`, { mode: 0o600 });
1248
+ }
1249
+ } catch (err) { discoveryError = err.message; }
1086
1250
  }
1087
1251
 
1088
- // Without a live process in backend.pid there is nothing this setup can signal. That only
1089
- // means stopped when nothing answers on the port either.
1090
1252
  if (!alive) {
1091
1253
  fs.rmSync(pidFile, { force: true });
1092
- if (health.state === 'down') return { stopped: true };
1093
- return { stopped: false, reported: `AIStudio2API still answers on port ${local.port}, but ${pidFile} names no running process; end it yourself` };
1254
+ if (health.state === 'down' && !discoveryError && discovered.length === 0) return { stopped: true };
1255
+ return { stopped: false, reported: `Cannot identify a verified AIStudio2API process for port ${local.port}. Wait for any update/restart to finish, then retry: ${studioCommand('stop', { dataDir })}. ${discoveryError ? `Process inspection failed: ${discoveryError}` : 'Check the listener PID and its executable path if this persists.'}` };
1094
1256
  }
1095
1257
 
1096
- const bin = binaryPath(dataDir, platform);
1097
1258
  if (!verifyProcessIdentity(pid, bin, platform)) {
1098
1259
  throw new Error(`Process ${pid} does not match expected binary path ${bin}; refusal to signal unverified process`);
1099
1260
  }
@@ -1103,7 +1264,7 @@ export async function stopStudio(options = {}) {
1103
1264
  throw new Error(`Cannot stop unknown or foreign process listening on port ${local.port}`);
1104
1265
  }
1105
1266
 
1106
- if (health.state === 'up' || health.state === 'outdated') {
1267
+ if (health.state === 'up' || health.state === 'outdated' || health.state === 'version_conflict') {
1107
1268
  try {
1108
1269
  await studioAdmin(local, 'POST', '/api/control/stop', null, { fetchFn, timeoutMs: 5000 });
1109
1270
  } catch {}
@@ -1133,5 +1294,17 @@ export async function stopStudio(options = {}) {
1133
1294
  }
1134
1295
 
1135
1296
  fs.rmSync(pidFile, { force: true });
1297
+ // A pooled HTTP connection may reset just after the process exits (especially
1298
+ // on Windows). Wait for a confirmed refusal, without treating a reset as proof
1299
+ // that the port is free or overlooking a replacement listener.
1300
+ let after = await checkStudioHealth(local, fetchFn);
1301
+ const closeDeadline = Date.now() + 5000;
1302
+ while (after.state === 'unresponsive' && Date.now() < closeDeadline) {
1303
+ await sleep(150);
1304
+ after = await checkStudioHealth(local, fetchFn);
1305
+ }
1306
+ if (after.state !== 'down') {
1307
+ return { stopped: false, reported: `Port ${local.port} is not confirmed closed (${after.state}: ${after.reason || 'service responds'}); retry: ${studioCommand('stop', { dataDir })}` };
1308
+ }
1136
1309
  return { stopped: true };
1137
1310
  }
@@ -1,27 +1,38 @@
1
1
  {
2
- "version": "0.2.12",
3
- "repository": "Mag1cFall/AIStudio2API",
4
- "tag": "v0.2.12",
2
+ "version": "0.2.13-magpie.1",
3
+ "repository": "BUKOWSKIREAL/magpie-gemini-web",
4
+ "tag": "studio-v0.2.13-magpie.1",
5
+ "goVersion": "1.27.1",
6
+ "upstream": {
7
+ "version": "0.2.13",
8
+ "repository": "https://github.com/Mag1cFall/AIStudio2API.git",
9
+ "commit": "a784c5193b3ba5c4894d536b2f4cd73e87f99475"
10
+ },
5
11
  "binaries": {
6
- "darwin-x64": {
7
- "asset": "aistudio2api-v0.2.12-darwin-amd64.tar.gz",
8
- "sha256": "d7700be498293539c551671c582c4112997873593dcea6dee5cf9f23562f0331"
9
- },
10
12
  "darwin-arm64": {
11
- "asset": "aistudio2api-v0.2.12-darwin-arm64.tar.gz",
12
- "sha256": "a24bdc093e024e6c5d737ebdaed719c9cf5d6418c4f2edbf907f3c2979a9bb63"
13
+ "asset": "aistudio2api-v0.2.13-magpie.1-darwin-arm64.tar.gz",
14
+ "sha256": "3145f1fc3e88dafc1c43ecfa4ed7c6f82d1126a2d8add02605d05f1c5c404144",
15
+ "binarySha256": "7d727bf1cc5a86d9c1a2a99d73d645f38a0597489ef62e2bf20b0d33dd87369d"
16
+ },
17
+ "darwin-x64": {
18
+ "asset": "aistudio2api-v0.2.13-magpie.1-darwin-amd64.tar.gz",
19
+ "sha256": "96994351570105a6de4e1c95800231ad20d762ee010cfa238bfef11101124c27",
20
+ "binarySha256": "428963b5bd3c9c35b88c7a7f29923c5a5a6bef5d0fcd511274daa8a8ebc9f686"
13
21
  },
14
22
  "linux-x64": {
15
- "asset": "aistudio2api-v0.2.12-linux-amd64.tar.gz",
16
- "sha256": "fa2de6053b2bf8c8188cef39751a31bd0f88a07c67f34ad59a8bce29b0069b6a"
23
+ "asset": "aistudio2api-v0.2.13-magpie.1-linux-amd64.tar.gz",
24
+ "sha256": "9e24fb139abb0bcd8f6cbab23ac1875136f3ecacd30919fbcc25fe342feb369c",
25
+ "binarySha256": "de8c25f07870d83dd2ebc03b9be1f6e15b4f93f454dfe8f5c4182c72261bcbfa"
17
26
  },
18
27
  "linux-arm64": {
19
- "asset": "aistudio2api-v0.2.12-linux-arm64.tar.gz",
20
- "sha256": "32c9cbe63fa0b864f201e917aaa2c9b3b5a2be6c3f7734ad4f2328df7ed5e19c"
28
+ "asset": "aistudio2api-v0.2.13-magpie.1-linux-arm64.tar.gz",
29
+ "sha256": "7b7057abd8e298685f4ca835e014a48c9b1904a253ebfbc053e4b1d7712c532c",
30
+ "binarySha256": "151fd830cf0da8801d49e448021d73cde0adb51e982b3a8604cdcbc5babd21c4"
21
31
  },
22
32
  "win32-x64": {
23
- "asset": "aistudio2api-v0.2.12-windows-amd64.zip",
24
- "sha256": "e03235bc5aecc6e81c2c609ca4cc010afb6b88ce98ac87934603a59fe625b493"
33
+ "asset": "aistudio2api-v0.2.13-magpie.1-windows-amd64.zip",
34
+ "sha256": "aa337556362ce9c16cae71117a9c12e20b623b12665dd5f36cf01bf152d72fbb",
35
+ "binarySha256": "4a0c32be722dcc27c9046fe410d6af7efd576c4192cb1158c84468cada4fbd30"
25
36
  }
26
37
  }
27
38
  }