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.
- package/LICENSE-AISTUDIO +21 -0
- package/README.en.md +108 -125
- package/README.md +118 -135
- package/docs/studio-release.md +49 -0
- package/lib/studio-native.mjs +218 -45
- package/lib/studio-release.json +26 -15
- package/lib/studio.mjs +135 -94
- package/package.json +9 -2
- package/patches/studio-managed.patch +625 -0
- package/scripts/studio-archive.go +92 -0
- package/scripts/studio-managed-smoke.mjs +66 -0
- package/scripts/studio-release.mjs +85 -0
- package/scripts/studio.mjs +8 -4
package/lib/studio-native.mjs
CHANGED
|
@@ -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
|
-
|
|
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: '
|
|
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' (
|
|
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
|
-
//
|
|
485
|
-
//
|
|
486
|
-
|
|
487
|
-
|
|
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:
|
|
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
|
-
|
|
793
|
-
fs.
|
|
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
|
-
|
|
935
|
+
removePackage(backupPackageDir, platform);
|
|
817
936
|
}
|
|
818
937
|
removeOtherArchives(dataDir, pin.asset);
|
|
819
938
|
} catch (swapErr) {
|
|
820
939
|
if (installedNewFinal) {
|
|
821
|
-
try {
|
|
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 "
|
|
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')
|
|
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')
|
|
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
|
-
|
|
974
|
-
|
|
975
|
-
|
|
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
|
|
980
|
-
|
|
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
|
-
|
|
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 =
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
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
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
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
|
|
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
|
}
|
package/lib/studio-release.json
CHANGED
|
@@ -1,27 +1,38 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.2.
|
|
3
|
-
"repository": "
|
|
4
|
-
"tag": "v0.2.
|
|
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
|
-
"sha256": "
|
|
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.
|
|
16
|
-
"sha256": "
|
|
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.
|
|
20
|
-
"sha256": "
|
|
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.
|
|
24
|
-
"sha256": "
|
|
33
|
+
"asset": "aistudio2api-v0.2.13-magpie.1-windows-amd64.zip",
|
|
34
|
+
"sha256": "aa337556362ce9c16cae71117a9c12e20b623b12665dd5f36cf01bf152d72fbb",
|
|
35
|
+
"binarySha256": "4a0c32be722dcc27c9046fe410d6af7efd576c4192cb1158c84468cada4fbd30"
|
|
25
36
|
}
|
|
26
37
|
}
|
|
27
38
|
}
|