magpie-gemini-web 0.5.3 → 0.5.4
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 +3 -3
- package/README.md +3 -3
- package/docs/studio-release.md +12 -12
- package/lib/studio-native.mjs +18 -7
- package/lib/studio-release.json +19 -19
- package/package.json +1 -1
- package/scripts/studio-managed-smoke.mjs +7 -5
- package/scripts/studio-release.mjs +6 -6
- package/patches/studio-managed.patch +0 -625
package/README.en.md
CHANGED
|
@@ -17,7 +17,7 @@ The plugin provides two independent providers that can be used individually or t
|
|
|
17
17
|
| **Thinking levels** | `minimal` / `low` / `medium` / `high` (`minimal` is fastest & cheapest) | `low` (base) / `high` (`-thinking` extended thinking) |
|
|
18
18
|
| **Sign-in method** | Sign in directly in an isolated popup browser window | Copy `cookie` from a private/incognito window |
|
|
19
19
|
| **Usage cards** | Shows models currently on cooldown and recovery times | Syncs **5-hour** & **weekly** usage and reset times from the web |
|
|
20
|
-
| **Local backend** | AIStudio2API (`v0.2.
|
|
20
|
+
| **Local backend** | AIStudio2API (`v0.2.14`) | gemini-web2api-go (`4.22.0-magpie.1` optimized build) |
|
|
21
21
|
|
|
22
22
|
---
|
|
23
23
|
|
|
@@ -135,8 +135,8 @@ When a plugin error asks you to run a command, it includes the full ready-to-cop
|
|
|
135
135
|
- Concurrency is capped at 1, but Google account rate-limiting or flagging cannot be completely ruled out—use at your own risk.
|
|
136
136
|
|
|
137
137
|
**AI Studio upgrades and model selection:**
|
|
138
|
-
- The plugin pins SHA-256-verified `v0.2.
|
|
139
|
-
- **Online updates are disabled in the managed backend on macOS, Linux, and Windows.** The admin page
|
|
138
|
+
- The plugin pins SHA-256-verified `v0.2.14-magpie.1`, based on upstream `v0.2.14`. Upgrade the plugin to update the backend. Official releases at or below that upstream version, and managed backends an older plugin installed, are upgraded on the next managed startup, preserving accounts, port and sign-in credentials.
|
|
139
|
+
- **Online updates are disabled in the managed backend on macOS, Linux, and Windows.** The admin page says updates are managed by magpie-gemini-web; the update endpoint returns HTTP 409 and update checks do not contact GitHub. Independently managed external servers are unaffected. See [docs/studio-release.md](docs/studio-release.md) for how the managed build is made and reproduced.
|
|
140
140
|
- Managed Unix installs still attempt to set `package/` to `0555` as additional file protection. Permission failures do not block service, and disabling online updates does not depend on permissions. To reset by manually deleting the data directory, stop the service first, then run `chmod -R u+w "<data-dir>/package"` before deleting it; replace `<data-dir>` with the actual path.
|
|
141
141
|
- If a newer or custom version is installed, the plugin reports a conflict with `stop`, `install`, and `start` commands carrying the correct `--data-dir`. Stop can recover a changed listener PID and verifies its executable path before signaling it. If HTTP is down but the process is still alive, the plugin refuses to reinstall or launch a second process.
|
|
142
142
|
- When stopped, a replaced executable is restored from the verified pinned archive; failed verification never launches the replacement. Existing account and runtime data files are kept, but **an older backend may not read data migrated by a newer version**. Before manually downgrading, stop the service and back up the entire data directory, including `auth/` and `runtime/requests.db`. If data migration is known to have occurred, update the plugin to match the newer backend.
|
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
| **思考档位** | `minimal` / `low` / `medium` / `high` 四档(`minimal` 最快最省) | `low`(普通)/ `high`(`-thinking` 扩展思考)两档 |
|
|
18
18
|
| **登录方式** | 弹出独立浏览器窗口直接登录 Google | 用无痕窗口登录并复制 `cookie` |
|
|
19
19
|
| **额度卡片** | 显示冷却中的模型与恢复时间 | 同步网页版 **5 小时** 与 **每周** 用量及重置时间 |
|
|
20
|
-
| **本地后端** | AIStudio2API (`v0.2.
|
|
20
|
+
| **本地后端** | AIStudio2API (`v0.2.14`) | gemini-web2api-go (`4.22.0-magpie.1` 优化版) |
|
|
21
21
|
|
|
22
22
|
---
|
|
23
23
|
|
|
@@ -135,8 +135,8 @@ magpie plugin options magpie-gemini-web '{"studio":{"proxy":"http://127.0.0.1:78
|
|
|
135
135
|
- 插件将请求并发限制为 1,但仍无法完全排除 Google 账号风控风险,请自行评估使用。
|
|
136
136
|
|
|
137
137
|
**AI Studio 升级与模型范围:**
|
|
138
|
-
- 插件固定使用经过 SHA-256 校验的 `v0.2.
|
|
139
|
-
- **托管后端已在 macOS、Linux、Windows
|
|
138
|
+
- 插件固定使用经过 SHA-256 校验的 `v0.2.14-magpie.1`(基于上游 `v0.2.14`),通过升级插件更新后端。同基线或更旧的官方版、以及旧版插件装的托管后端,在下次管理启动时自动升级,保留账号、端口与登录凭据。
|
|
139
|
+
- **托管后端已在 macOS、Linux、Windows 统一禁用在线更新**:管理页提示“更新由 magpie-gemini-web 管理”,更新接口返回 HTTP 409,检查更新也不会访问 GitHub。用户自行连接的外部服务不受影响。托管构建方式和可复现构建流程见 [docs/studio-release.md](docs/studio-release.md)。
|
|
140
140
|
- Unix 的 `package/` 仍尽力设置为 `0555`,作为额外的文件保护;权限设置失败不阻断服务,在线更新的禁用也不依赖该权限。若要手动删除数据目录来重置,请先停止服务,再运行 `chmod -R u+w "<数据目录>/package"` 恢复可写权限,然后删除;将 `<数据目录>` 替换为实际路径。
|
|
141
141
|
- 若自行更新到更新版或定制版,插件会提示版本冲突,并提供带正确 `--data-dir` 的 `stop`、`install`、`start` 命令。停止时会找回更新后的监听进程 PID,并核对可执行文件路径;无法核实身份就不会发送终止信号。HTTP 端口关闭但进程尚未退出时,插件不会重装或启动第二个进程。
|
|
142
142
|
- 已停止但文件被替换的后端会从校验通过的固定版本安装包恢复;校验失败不会启动替换文件。安装会保留账号和运行数据文件,但**不保证旧版本能读取新版迁移过的数据**。手动降级前先停止服务并备份整个数据目录(包括 `auth/`、`runtime/requests.db` 等);已知发生数据迁移时应升级插件以匹配新版后端。
|
package/docs/studio-release.md
CHANGED
|
@@ -1,29 +1,29 @@
|
|
|
1
1
|
# AI Studio 托管后端构建
|
|
2
2
|
|
|
3
|
-
插件使用 `0.2.
|
|
3
|
+
插件使用 `0.2.14-magpie.1`,基于 AIStudio2API `v0.2.14` 的固定 commit。上游版本、commit、Go 工具链、安装包及二进制 SHA-256 均记录在 `lib/studio-release.json`。MIT 许可见 `LICENSE-AISTUDIO`。
|
|
4
4
|
|
|
5
5
|
## 在线更新策略
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
上游从 v0.2.14 起支持由分发方管理更新,不再需要补丁。构建时通过 `-ldflags` 写入两个值:
|
|
8
8
|
|
|
9
|
-
-
|
|
10
|
-
- `POST /api/update` 返回 HTTP
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
- 普通插件升级、账户登录、模型调用及管理功能继续保留。用户自行连接的外部 AIStudio2API
|
|
9
|
+
- `internal/app.version=v0.2.14-magpie.1`:明确的版本标识。插件把同基线或更旧的官方版、以及更旧的托管版(`-magpie.N` 的基线更低,或同基线但 N 更小)视为需要升级;更新的官方版、更新的托管版或不认识的定制版仍提示冲突。
|
|
10
|
+
- `internal/app.managedBy=magpie-gemini-web`:更新模式为 `managed`。`GET /api/update`(包括 `?refresh=1`)只返回本地版本、`mode: managed` 和 `managed_by`,不访问 GitHub;`POST /api/update` 返回 HTTP 409,说明更新由 magpie-gemini-web 管理,不下载、不替换文件、不重启。这两个值在链接时写死,没有环境变量或管理页开关,在所有构建平台相同。
|
|
11
|
+
- 管理页版本面板显示当前版本和"更新由 magpie-gemini-web 管理",不显示检查更新或安装按钮。上游的在线更新代码仍然编译在内,但托管模式下走不到;`studio:managed:smoke` 用真实二进制验证这一点。
|
|
12
|
+
- 构建不带 `desktop` 标签,即上游的命令行版,不包含桌面窗口。
|
|
13
|
+
- 普通插件升级、账户登录、模型调用及管理功能继续保留。用户自行连接的外部 AIStudio2API 服务不受影响。
|
|
14
14
|
|
|
15
15
|
## 复现构建
|
|
16
16
|
|
|
17
17
|
维护者需要 Git、tar、Node.js 26.8.2(构建页面用)、Go 1.27.1。Windows 使用 Git Bash 执行以下命令,安装包运行和解压使用 Windows 自带 PowerShell。最终用户不需要这些构建工具。
|
|
18
18
|
|
|
19
19
|
```bash
|
|
20
|
-
#
|
|
20
|
+
# 按固定源代码构建所有五个平台,并核对现有 pin。
|
|
21
21
|
npm run studio:release:build
|
|
22
22
|
|
|
23
23
|
# 仅构建当前平台。
|
|
24
24
|
node scripts/studio-release.mjs --target=host --verify
|
|
25
25
|
|
|
26
|
-
#
|
|
26
|
+
# 制作新版本:审查上游源码后重新生成所有平台 pin。
|
|
27
27
|
node scripts/studio-release.mjs --write-pins
|
|
28
28
|
|
|
29
29
|
# 可复用本地上游 git 仓库,但始终导出固定 commit,不使用工作区修改。
|
|
@@ -42,8 +42,8 @@ npm run studio:managed:smoke
|
|
|
42
42
|
AI_STUDIO_TEST_ARCHIVE=/absolute/path/to/archive.tar.gz npm test
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
-
真实安装验证检查启动、版本、GET 更新状态、POST 更新返回
|
|
45
|
+
真实安装验证检查启动、版本、GET 更新状态、POST 更新返回 409、二进制及 PID 不变、管理页面显示托管提示、停止与状态。`.github/workflows/studio.yml` 在五种系统/架构上重新构建、对比 SHA-256 并运行该验证;本机交叉编译不代替 Windows 真机验证。
|
|
46
46
|
|
|
47
|
-
发布时先审查构建记录和各平台 CI 结果,再将安装包及许可证上传到本仓库 `studio-v0.2.
|
|
47
|
+
发布时先审查构建记录和各平台 CI 结果,再将安装包及许可证上传到本仓库 `studio-v0.2.14-magpie.1` release。该 release 可公开下载后,才能发布引用它的插件版本,否则新安装会遇到 404。构建脚本及 CI 不自动发布 release,也不发布 npm 包。
|
|
48
48
|
|
|
49
|
-
本地试用无需发布:先停止当前托管后端,再执行 `node scripts/studio.mjs install --archive /absolute/path/to/archive` 和 `node scripts/studio.mjs start`。可用 `--data-dir`
|
|
49
|
+
本地试用无需发布:先停止当前托管后端,再执行 `node scripts/studio.mjs install --archive /absolute/path/to/archive` 和 `node scripts/studio.mjs start`。可用 `--data-dir` 指定目录。修改前备份账号和配置;该版本与上游 v0.2.14 使用相同的数据格式。v0.2.14 对数据的改动都是新增的(账号记录的可选字段、新的 `runtime/prompts.db`、`.env` 的新设置),用真实账号验证过:`v0.2.13-magpie.1` 的数据可以直接被它读取,用它运行过之后退回 `v0.2.13-magpie.1` 也能正常使用。
|
package/lib/studio-native.mjs
CHANGED
|
@@ -178,7 +178,7 @@ const ENV_KEYS = [
|
|
|
178
178
|
'WARM_WORKER_LIMIT', 'MAX_ACTIVE_WORKERS', 'WARM_STARTUP_CONCURRENCY', 'PER_ACCOUNT_CONCURRENCY',
|
|
179
179
|
'ROUTING_STRATEGY', 'UPSTREAM_CHANNELS', 'MODEL_MAPPING', 'TEMPORARY_CHAT', 'WAA_BACKEND', 'AUTO_START',
|
|
180
180
|
'REQUEST_BODY_LOG', 'LOG_FILE', 'LOG_MAX_TOTAL_MB', 'ADMIN_AUTH_ENABLED', 'ADMIN_USERNAME', 'ADMIN_PASSWORD',
|
|
181
|
-
'BUILD_NATIVE_NONSTREAM'
|
|
181
|
+
'BUILD_NATIVE_NONSTREAM', 'MODEL_CHANNELS', 'MEDIA_OUTPUT', 'MEDIA_BASE_URL'
|
|
182
182
|
];
|
|
183
183
|
|
|
184
184
|
// SETTING_DEFAULTS are the settings the admin page owns, as a new setup has them. They are written
|
|
@@ -668,6 +668,20 @@ export function isConnectRefused(err) {
|
|
|
668
668
|
return false;
|
|
669
669
|
}
|
|
670
670
|
|
|
671
|
+
// olderStudioVersion says whether a version (without its v) is one this plugin may replace: an
|
|
672
|
+
// official X.Y.Z at or below the upstream base, or a managed X.Y.Z-magpie.N on an older base, or
|
|
673
|
+
// on the same base with a lower N.
|
|
674
|
+
function olderStudioVersion(version) {
|
|
675
|
+
const running = /^(\d+)\.(\d+)\.(\d+)(?:-magpie\.(\d+))?$/.exec(version);
|
|
676
|
+
if (!running) return false;
|
|
677
|
+
const ours = /-magpie\.(\d+)$/.exec(release.version);
|
|
678
|
+
const base = running.slice(1, 4).map(Number);
|
|
679
|
+
const expected = release.upstream.version.split('.').map(Number);
|
|
680
|
+
const different = base.findIndex((n, i) => n !== expected[i]);
|
|
681
|
+
if (different !== -1) return base[different] < expected[different];
|
|
682
|
+
return running[4] === undefined || Number(running[4]) < Number(ours[1]);
|
|
683
|
+
}
|
|
684
|
+
|
|
671
685
|
// checkStudioHealth says what answers on the setup's port: 'down' (nothing), 'up' (this setup's
|
|
672
686
|
// service, at the pinned version), 'outdated' (an older official version),
|
|
673
687
|
// 'version_conflict' (a newer or custom version installed outside this plugin),
|
|
@@ -713,14 +727,11 @@ export async function checkStudioHealth(local, fetchFn) {
|
|
|
713
727
|
}
|
|
714
728
|
}
|
|
715
729
|
|
|
716
|
-
// Official releases at or below our upstream base
|
|
717
|
-
// build. Newer/custom installations require an explicit recovery decision.
|
|
730
|
+
// Official releases at or below our upstream base, and managed builds older than this one, can
|
|
731
|
+
// move to this managed build. Newer/custom installations require an explicit recovery decision.
|
|
718
732
|
const runningVersion = String(status.version).replace(/^v/, '');
|
|
719
733
|
if (runningVersion !== release.version) {
|
|
720
|
-
const
|
|
721
|
-
const expected = release.upstream.version.split('.').map(Number);
|
|
722
|
-
const different = parts?.findIndex((n, i) => n !== expected[i]);
|
|
723
|
-
const older = parts && (different === -1 || parts[different] < expected[different]);
|
|
734
|
+
const older = olderStudioVersion(runningVersion);
|
|
724
735
|
return { state: older ? 'outdated' : 'version_conflict', status, reason: `Running version ${status.version}, expected ${STUDIO_VERSION}` };
|
|
725
736
|
}
|
|
726
737
|
|
package/lib/studio-release.json
CHANGED
|
@@ -1,38 +1,38 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.2.
|
|
2
|
+
"version": "0.2.14-magpie.1",
|
|
3
3
|
"repository": "BUKOWSKIREAL/magpie-gemini-web",
|
|
4
|
-
"tag": "studio-v0.2.
|
|
4
|
+
"tag": "studio-v0.2.14-magpie.1",
|
|
5
5
|
"goVersion": "1.27.1",
|
|
6
6
|
"upstream": {
|
|
7
|
-
"version": "0.2.
|
|
7
|
+
"version": "0.2.14",
|
|
8
8
|
"repository": "https://github.com/Mag1cFall/AIStudio2API.git",
|
|
9
|
-
"commit": "
|
|
9
|
+
"commit": "370a313814099b508881807effa2a9d72f2d7e66"
|
|
10
10
|
},
|
|
11
11
|
"binaries": {
|
|
12
12
|
"darwin-arm64": {
|
|
13
|
-
"asset": "aistudio2api-v0.2.
|
|
14
|
-
"sha256": "
|
|
15
|
-
"binarySha256": "
|
|
13
|
+
"asset": "aistudio2api-v0.2.14-magpie.1-darwin-arm64.tar.gz",
|
|
14
|
+
"sha256": "f649c21837741bdb448407f79a3bc998429b446408ef3117a897103b9a47ee72",
|
|
15
|
+
"binarySha256": "999e49572dede3a63f538e3f62ceed5f03349459bdf391fd49fd57784985e13b"
|
|
16
16
|
},
|
|
17
17
|
"darwin-x64": {
|
|
18
|
-
"asset": "aistudio2api-v0.2.
|
|
19
|
-
"sha256": "
|
|
20
|
-
"binarySha256": "
|
|
18
|
+
"asset": "aistudio2api-v0.2.14-magpie.1-darwin-amd64.tar.gz",
|
|
19
|
+
"sha256": "ece680b5dfef57b0b0e96d639b618b74570e6e194f58808d762da85fd41d9b4a",
|
|
20
|
+
"binarySha256": "970df3ac51719721d908162d0233d80711f6c3ee007e193d0a73b6e4e596ca9a"
|
|
21
21
|
},
|
|
22
22
|
"linux-x64": {
|
|
23
|
-
"asset": "aistudio2api-v0.2.
|
|
24
|
-
"sha256": "
|
|
25
|
-
"binarySha256": "
|
|
23
|
+
"asset": "aistudio2api-v0.2.14-magpie.1-linux-amd64.tar.gz",
|
|
24
|
+
"sha256": "f6c7b52a739e4534236f055280fcddd48e64b56fdb954a7844eb453226d5b67f",
|
|
25
|
+
"binarySha256": "316306225bcc343188d6d81635d4bd79e7e82bd5c51b1009cd25361deb3b5e61"
|
|
26
26
|
},
|
|
27
27
|
"linux-arm64": {
|
|
28
|
-
"asset": "aistudio2api-v0.2.
|
|
29
|
-
"sha256": "
|
|
30
|
-
"binarySha256": "
|
|
28
|
+
"asset": "aistudio2api-v0.2.14-magpie.1-linux-arm64.tar.gz",
|
|
29
|
+
"sha256": "a5b838b9b64e78d402372cae6b88e1f3faba406747e598e976728c71da3a75ef",
|
|
30
|
+
"binarySha256": "a5e9573b9edad7fb246178fb4c3d21a0d4905789fd59abf96656d57f26b7526e"
|
|
31
31
|
},
|
|
32
32
|
"win32-x64": {
|
|
33
|
-
"asset": "aistudio2api-v0.2.
|
|
34
|
-
"sha256": "
|
|
35
|
-
"binarySha256": "
|
|
33
|
+
"asset": "aistudio2api-v0.2.14-magpie.1-windows-amd64.zip",
|
|
34
|
+
"sha256": "ccd5b96dabebbc0f1d510959c698beb9d9af09bb65d8b0e021327970b3173384",
|
|
35
|
+
"binarySha256": "ce784c98f1558395a670421ef6cc7d36dde5313f74788f7df5772851c241ae06"
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
}
|
package/package.json
CHANGED
|
@@ -25,6 +25,7 @@ try {
|
|
|
25
25
|
for (const query of ['', '?refresh=1']) {
|
|
26
26
|
const update = await studioAdmin(local, 'GET', `/api/update${query}`);
|
|
27
27
|
assert.equal(update.mode, 'managed');
|
|
28
|
+
assert.equal(update.managed_by, 'magpie-gemini-web');
|
|
28
29
|
assert.equal(update.available, false);
|
|
29
30
|
assert.equal(update.updating, false);
|
|
30
31
|
assert.equal(update.current, STUDIO_VERSION);
|
|
@@ -36,8 +37,8 @@ try {
|
|
|
36
37
|
if (String(args[0]).endsWith('/api/update')) updateHTTP = response.status;
|
|
37
38
|
return response;
|
|
38
39
|
}
|
|
39
|
-
}), /
|
|
40
|
-
assert.equal(updateHTTP,
|
|
40
|
+
}), /magpie-gemini-web/);
|
|
41
|
+
assert.equal(updateHTTP, 409);
|
|
41
42
|
assert.equal(computeFileHash(bin), pin.binarySha256, 'update must not replace the binary');
|
|
42
43
|
assert.equal(fs.readFileSync(path.join(dataDir, 'backend.pid'), 'utf8'), pidBefore, 'update must not restart');
|
|
43
44
|
const html = await (await fetch(local.origin)).text();
|
|
@@ -47,11 +48,12 @@ try {
|
|
|
47
48
|
const versionPanel = /(?:\.\/|\/assets\/)?(VersionPanel-[\w-]+\.js)/.exec(js)?.[1];
|
|
48
49
|
assert.ok(versionPanel, 'built admin version panel must exist');
|
|
49
50
|
const panel = await (await fetch(`${local.origin}/assets/${versionPanel}`)).text();
|
|
50
|
-
|
|
51
|
-
|
|
51
|
+
// Upstream's panel keeps its install button for other builds and shows a managed build's note
|
|
52
|
+
// in its place; the refusal above is what keeps the binary
|
|
53
|
+
assert.match(panel, /version\.managed/);
|
|
52
54
|
assert.deepEqual(await stopStudio({ dataDir }), { stopped: true });
|
|
53
55
|
assert.equal((await studioStatus({ dataDir })).status, 'stopped');
|
|
54
|
-
console.log(`PASS ${key}: ${STUDIO_VERSION}, managed status, update HTTP
|
|
56
|
+
console.log(`PASS ${key}: ${STUDIO_VERSION}, managed status, update HTTP 409, unchanged executable/PID, managed UI, clean stop`);
|
|
55
57
|
} finally {
|
|
56
58
|
let stopped = !local;
|
|
57
59
|
if (local) stopped = (await stopStudio({ dataDir }).catch(() => ({ stopped: false }))).stopped;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Clean upstream commit
|
|
2
|
+
// Clean upstream commit -> managed archives, never a developer's dirty tree. Upstream's own
|
|
3
|
+
// distributor settings make the build managed: it reports this version, and its online updater
|
|
4
|
+
// neither checks GitHub nor installs, saying the plugin manages updates.
|
|
3
5
|
import fs from 'node:fs';
|
|
4
6
|
import os from 'node:os';
|
|
5
7
|
import path from 'node:path';
|
|
@@ -34,9 +36,9 @@ const hash = file => crypto.createHash('sha256').update(fs.readFileSync(file)).d
|
|
|
34
36
|
const go = (argv, extra = {}) => execFileSync('go', argv, { cwd: source, env: { ...env, ...extra }, stdio: 'inherit' });
|
|
35
37
|
const npm = argv => execFileSync(process.platform === 'win32' ? 'npm.cmd' : 'npm', argv,
|
|
36
38
|
{ cwd: path.join(source, 'web'), stdio: 'inherit', shell: process.platform === 'win32' });
|
|
37
|
-
const
|
|
39
|
+
const MANAGED_BY = 'magpie-gemini-web';
|
|
38
40
|
const flags = ['-mod=readonly', '-trimpath', '-buildvcs=false',
|
|
39
|
-
`-ldflags=-s -w -X github.com/Mag1cFall/AIStudio2API/internal/app.
|
|
41
|
+
`-ldflags=-s -w -X github.com/Mag1cFall/AIStudio2API/internal/app.version=v${release.version} -X github.com/Mag1cFall/AIStudio2API/internal/app.managedBy=${MANAGED_BY}`];
|
|
40
42
|
|
|
41
43
|
try {
|
|
42
44
|
let upstream = args.find(arg => arg.startsWith('--source='))?.slice(9);
|
|
@@ -50,8 +52,6 @@ try {
|
|
|
50
52
|
}
|
|
51
53
|
execFileSync('git', ['-C', upstream, 'archive', '--format=tar', `--output=${path.join(scratch, 'upstream.tar')}`, release.upstream.commit]);
|
|
52
54
|
execFileSync('tar', ['-xf', 'upstream.tar', '-C', 'source'], { cwd: scratch });
|
|
53
|
-
execFileSync('git', ['apply', '--check', patch], { cwd: source, stdio: 'inherit' });
|
|
54
|
-
execFileSync('git', ['apply', patch], { cwd: source, stdio: 'inherit' });
|
|
55
55
|
npm(['ci', '--no-audit', '--no-fund']);
|
|
56
56
|
npm(['run', 'lint']);
|
|
57
57
|
npm(['run', 'build']);
|
|
@@ -74,7 +74,7 @@ try {
|
|
|
74
74
|
fs.copyFileSync(path.join(source, 'LICENSE'), path.join(output, 'LICENSE-AISTUDIO'));
|
|
75
75
|
fs.writeFileSync(path.join(output, target ? `build-${target}.json` : 'build.json'), JSON.stringify({
|
|
76
76
|
version: release.version, upstream: release.upstream, goVersion: release.goVersion,
|
|
77
|
-
nodeVersion: process.version,
|
|
77
|
+
nodeVersion: process.version, managedBy: MANAGED_BY, flags, binaries
|
|
78
78
|
}, null, 2) + '\n');
|
|
79
79
|
fs.writeFileSync(path.join(output, target ? `SHA256SUMS-${target}.txt` : 'SHA256SUMS.txt'),
|
|
80
80
|
Object.values(binaries).map(pin => `${pin.sha256} ${pin.asset}\n`).join(''));
|
|
@@ -1,625 +0,0 @@
|
|
|
1
|
-
diff --git a/internal/app/admin.go b/internal/app/admin.go
|
|
2
|
-
index 2f3de19..7f84cd5 100644
|
|
3
|
-
--- a/internal/app/admin.go
|
|
4
|
-
+++ b/internal/app/admin.go
|
|
5
|
-
@@ -8,7 +8,6 @@ import (
|
|
6
|
-
"net/http"
|
|
7
|
-
"os"
|
|
8
|
-
"path/filepath"
|
|
9
|
-
- "runtime/debug"
|
|
10
|
-
"sort"
|
|
11
|
-
"strings"
|
|
12
|
-
"sync"
|
|
13
|
-
@@ -1390,13 +1389,10 @@ func (registry *requestRegistry) publish(event api.AdminEvent) {
|
|
14
|
-
registry.mu.Unlock()
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
-func buildVersion() string {
|
|
18
|
-
- info, ok := debug.ReadBuildInfo()
|
|
19
|
-
- if !ok || info.Main.Version == "" {
|
|
20
|
-
- return "(devel)"
|
|
21
|
-
- }
|
|
22
|
-
- return info.Main.Version
|
|
23
|
-
-}
|
|
24
|
-
+// Set by the reproducible Magpie release build.
|
|
25
|
-
+var managedVersion = "(devel)"
|
|
26
|
-
+
|
|
27
|
-
+func buildVersion() string { return managedVersion }
|
|
28
|
-
|
|
29
|
-
func runtimeConfigDTO(cfg config.Config) api.RuntimeConfig {
|
|
30
|
-
return api.RuntimeConfig{
|
|
31
|
-
diff --git a/internal/app/update.go b/internal/app/update.go
|
|
32
|
-
index 2b22ebe..f5dd6c3 100644
|
|
33
|
-
--- a/internal/app/update.go
|
|
34
|
-
+++ b/internal/app/update.go
|
|
35
|
-
@@ -1,300 +1,45 @@
|
|
36
|
-
package app
|
|
37
|
-
|
|
38
|
-
import (
|
|
39
|
-
- "archive/tar"
|
|
40
|
-
- "archive/zip"
|
|
41
|
-
- "bytes"
|
|
42
|
-
- "compress/gzip"
|
|
43
|
-
"context"
|
|
44
|
-
- "crypto/sha256"
|
|
45
|
-
- "encoding/hex"
|
|
46
|
-
- "encoding/json"
|
|
47
|
-
- "errors"
|
|
48
|
-
- "fmt"
|
|
49
|
-
- "io"
|
|
50
|
-
"net/http"
|
|
51
|
-
- "net/url"
|
|
52
|
-
"os"
|
|
53
|
-
"os/exec"
|
|
54
|
-
- "path"
|
|
55
|
-
"runtime"
|
|
56
|
-
"slices"
|
|
57
|
-
- "strings"
|
|
58
|
-
- "sync"
|
|
59
|
-
"syscall"
|
|
60
|
-
- "time"
|
|
61
|
-
-
|
|
62
|
-
- "github.com/minio/selfupdate"
|
|
63
|
-
- "golang.org/x/mod/semver"
|
|
64
|
-
|
|
65
|
-
"github.com/Mag1cFall/AIStudio2API/internal/api"
|
|
66
|
-
)
|
|
67
|
-
|
|
68
|
-
-// releaseAPI 为 GitHub 最新发行版本接口
|
|
69
|
-
-const releaseAPI = "https://api.github.com/repos/Mag1cFall/AIStudio2API/releases/latest"
|
|
70
|
-
-
|
|
71
|
-
-// releaseCheckInterval 为最新发行版本的缓存时长
|
|
72
|
-
-const releaseCheckInterval = time.Hour
|
|
73
|
-
-
|
|
74
|
-
-// githubRelease 为 GitHub 发行版本接口中用到的字段
|
|
75
|
-
-type githubRelease struct {
|
|
76
|
-
- TagName string `json:"tag_name"`
|
|
77
|
-
- Body string `json:"body"`
|
|
78
|
-
- HTMLURL string `json:"html_url"`
|
|
79
|
-
- PublishedAt time.Time `json:"published_at"`
|
|
80
|
-
- Assets []githubAsset `json:"assets"`
|
|
81
|
-
-}
|
|
82
|
-
-
|
|
83
|
-
-// githubAsset 为发行版本中的一个文件
|
|
84
|
-
-type githubAsset struct {
|
|
85
|
-
- Name string `json:"name"`
|
|
86
|
-
- URL string `json:"browser_download_url"`
|
|
87
|
-
- Digest string `json:"digest"`
|
|
88
|
-
-}
|
|
89
|
-
-
|
|
90
|
-
-// updateConflictError 表示当前状态不能安装更新
|
|
91
|
-
-type updateConflictError string
|
|
92
|
-
-
|
|
93
|
-
-func (err updateConflictError) Error() string { return string(err) }
|
|
94
|
-
-
|
|
95
|
-
-// HTTPStatus 返回更新冲突的 HTTP 状态码
|
|
96
|
-
-func (updateConflictError) HTTPStatus() int { return http.StatusConflict }
|
|
97
|
-
-
|
|
98
|
-
-// selfUpdater 读取最新发行版本并替换当前可执行文件
|
|
99
|
-
+// Magpie owns backend upgrades. This build has no release downloader or
|
|
100
|
-
+// executable replacement path, regardless of platform or environment.
|
|
101
|
-
type selfUpdater struct {
|
|
102
|
-
- version string
|
|
103
|
-
- mode string
|
|
104
|
-
- releaseURL string
|
|
105
|
-
- executable string
|
|
106
|
-
- proxy func() string
|
|
107
|
-
- log func(level, message string)
|
|
108
|
-
- restart func()
|
|
109
|
-
- mu sync.Mutex
|
|
110
|
-
- release *githubRelease
|
|
111
|
-
- checked time.Time
|
|
112
|
-
- checkErr error
|
|
113
|
-
- updating bool
|
|
114
|
-
+ version string
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
-// newSelfUpdater 按构建版本与运行环境创建替换 executable 的更新器
|
|
118
|
-
-func newSelfUpdater(version, executable string, proxy func() string, log func(level, message string), restart func()) *selfUpdater {
|
|
119
|
-
- return &selfUpdater{
|
|
120
|
-
- version: version, mode: updateMode(version), releaseURL: releaseAPI, executable: executable,
|
|
121
|
-
- proxy: proxy, log: log, restart: restart,
|
|
122
|
-
- }
|
|
123
|
-
+func newSelfUpdater(version, _ string, _ func() string, _ func(string, string), _ func()) *selfUpdater {
|
|
124
|
-
+ return &selfUpdater{version: version}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
-// updateMode 返回更新方式:binary 可原地替换,container 随镜像更新,development 为源码构建
|
|
128
|
-
-func updateMode(version string) string {
|
|
129
|
-
- if !semver.IsValid(version) || semver.Prerelease(version) != "" || semver.Build(version) != "" {
|
|
130
|
-
- return "development"
|
|
131
|
-
- }
|
|
132
|
-
- for _, marker := range []string{"/.dockerenv", "/run/.containerenv"} {
|
|
133
|
-
- if _, err := os.Stat(marker); err == nil {
|
|
134
|
-
- return "container"
|
|
135
|
-
- }
|
|
136
|
-
- }
|
|
137
|
-
- return "binary"
|
|
138
|
-
+func (u *selfUpdater) UpdateStatus(_ context.Context, _ bool) (api.UpdateStatus, error) {
|
|
139
|
-
+ return api.UpdateStatus{Current: u.version, Mode: "managed"}, nil
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
-// UpdateStatus 返回当前版本与最新发行版本,refresh 为 true 或缓存过期时重新读取
|
|
143
|
-
-func (u *selfUpdater) UpdateStatus(ctx context.Context, refresh bool) (api.UpdateStatus, error) {
|
|
144
|
-
- u.mu.Lock()
|
|
145
|
-
- stale := refresh || time.Since(u.checked) >= releaseCheckInterval
|
|
146
|
-
- u.mu.Unlock()
|
|
147
|
-
- if stale {
|
|
148
|
-
- release, err := u.fetchRelease(ctx)
|
|
149
|
-
- if ctx.Err() != nil {
|
|
150
|
-
- return api.UpdateStatus{}, ctx.Err()
|
|
151
|
-
- }
|
|
152
|
-
- u.mu.Lock()
|
|
153
|
-
- u.checked, u.checkErr = time.Now(), err
|
|
154
|
-
- if err == nil {
|
|
155
|
-
- u.release = release
|
|
156
|
-
- }
|
|
157
|
-
- u.mu.Unlock()
|
|
158
|
-
- }
|
|
159
|
-
- u.mu.Lock()
|
|
160
|
-
- defer u.mu.Unlock()
|
|
161
|
-
- return u.statusLocked(), nil
|
|
162
|
-
-}
|
|
163
|
-
+type managedUpdateDisabledError struct{}
|
|
164
|
-
|
|
165
|
-
-// ApplyUpdate 下载最新发行包,校验摘要后替换当前可执行文件并重启管理进程
|
|
166
|
-
-func (u *selfUpdater) ApplyUpdate(ctx context.Context) (api.UpdateStatus, error) {
|
|
167
|
-
- u.mu.Lock()
|
|
168
|
-
- if u.mode != "binary" {
|
|
169
|
-
- u.mu.Unlock()
|
|
170
|
-
- return api.UpdateStatus{}, updateConflictError("当前运行方式不支持在线更新")
|
|
171
|
-
- }
|
|
172
|
-
- if u.updating {
|
|
173
|
-
- u.mu.Unlock()
|
|
174
|
-
- return api.UpdateStatus{}, updateConflictError("更新正在进行")
|
|
175
|
-
- }
|
|
176
|
-
- u.updating = true
|
|
177
|
-
- u.mu.Unlock()
|
|
178
|
-
- release, err := u.fetchRelease(ctx)
|
|
179
|
-
- if err == nil && semver.Compare(release.TagName, u.version) <= 0 {
|
|
180
|
-
- err = updateConflictError("当前已是最新版本 " + u.version)
|
|
181
|
-
- }
|
|
182
|
-
- if err == nil {
|
|
183
|
-
- u.log("INFO", "在线更新 | 1/2 | 下载发行包 | 版本="+release.TagName)
|
|
184
|
-
- err = u.install(ctx, release)
|
|
185
|
-
- }
|
|
186
|
-
- u.mu.Lock()
|
|
187
|
-
- defer u.mu.Unlock()
|
|
188
|
-
- if err != nil {
|
|
189
|
-
- u.updating = false
|
|
190
|
-
- u.log("ERROR", "在线更新失败 | 错误="+err.Error())
|
|
191
|
-
- return api.UpdateStatus{}, err
|
|
192
|
-
- }
|
|
193
|
-
- u.release, u.checked, u.checkErr = release, time.Now(), nil
|
|
194
|
-
- u.log("INFO", "在线更新完成 | 版本="+release.TagName)
|
|
195
|
-
- status := u.statusLocked()
|
|
196
|
-
- u.restart()
|
|
197
|
-
- return status, nil
|
|
198
|
-
+func (managedUpdateDisabledError) Error() string {
|
|
199
|
-
+ return "在线更新已禁用,请通过 Magpie 插件升级 / Online updates are disabled; upgrade through the Magpie plugin."
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
-// statusLocked 在持有锁时生成更新状态
|
|
203
|
-
-func (u *selfUpdater) statusLocked() api.UpdateStatus {
|
|
204
|
-
- status := api.UpdateStatus{Current: u.version, Mode: u.mode, CheckedAt: u.checked, Updating: u.updating}
|
|
205
|
-
- if u.checkErr != nil {
|
|
206
|
-
- status.Error = u.checkErr.Error()
|
|
207
|
-
- }
|
|
208
|
-
- if release := u.release; release != nil {
|
|
209
|
-
- status.Latest, status.Notes, status.URL, status.PublishedAt = release.TagName, release.Body, release.HTMLURL, release.PublishedAt
|
|
210
|
-
- status.Available = semver.Compare(release.TagName, u.version) > 0
|
|
211
|
-
- }
|
|
212
|
-
- return status
|
|
213
|
-
-}
|
|
214
|
-
+func (managedUpdateDisabledError) HTTPStatus() int { return http.StatusForbidden }
|
|
215
|
-
|
|
216
|
-
-// fetchRelease 读取 GitHub 最新发行版本
|
|
217
|
-
-func (u *selfUpdater) fetchRelease(ctx context.Context) (*githubRelease, error) {
|
|
218
|
-
- response, err := u.get(ctx, u.releaseURL, "application/vnd.github+json")
|
|
219
|
-
- if err != nil {
|
|
220
|
-
- return nil, fmt.Errorf("读取最新发行版本: %w", err)
|
|
221
|
-
- }
|
|
222
|
-
- defer response.Body.Close()
|
|
223
|
-
- var release githubRelease
|
|
224
|
-
- if err := json.NewDecoder(response.Body).Decode(&release); err != nil {
|
|
225
|
-
- return nil, fmt.Errorf("解析最新发行版本: %w", err)
|
|
226
|
-
- }
|
|
227
|
-
- return &release, nil
|
|
228
|
-
-}
|
|
229
|
-
-
|
|
230
|
-
-// install 下载当前系统与架构的发行包,校验 SHA-256 后替换可执行文件
|
|
231
|
-
-func (u *selfUpdater) install(ctx context.Context, release *githubRelease) error {
|
|
232
|
-
- name, binary := releasePackage(release.TagName)
|
|
233
|
-
- index := slices.IndexFunc(release.Assets, func(asset githubAsset) bool { return asset.Name == name })
|
|
234
|
-
- if index < 0 {
|
|
235
|
-
- return fmt.Errorf("发行版本 %s 没有 %s", release.TagName, name)
|
|
236
|
-
- }
|
|
237
|
-
- asset := release.Assets[index]
|
|
238
|
-
- expected, ok := strings.CutPrefix(asset.Digest, "sha256:")
|
|
239
|
-
- if !ok {
|
|
240
|
-
- return fmt.Errorf("发行包 %s 缺少 SHA-256 摘要", name)
|
|
241
|
-
- }
|
|
242
|
-
- response, err := u.get(ctx, asset.URL, "application/octet-stream")
|
|
243
|
-
- if err != nil {
|
|
244
|
-
- return fmt.Errorf("下载 %s: %w", name, err)
|
|
245
|
-
- }
|
|
246
|
-
- archive, err := io.ReadAll(response.Body)
|
|
247
|
-
- response.Body.Close()
|
|
248
|
-
- if err != nil {
|
|
249
|
-
- return fmt.Errorf("下载 %s: %w", name, err)
|
|
250
|
-
- }
|
|
251
|
-
- if sum := sha256.Sum256(archive); hex.EncodeToString(sum[:]) != expected {
|
|
252
|
-
- return fmt.Errorf("发行包 %s 的 SHA-256 与发行版本记录不一致", name)
|
|
253
|
-
- }
|
|
254
|
-
- executable, err := extractExecutable(archive, name, binary)
|
|
255
|
-
- if err != nil {
|
|
256
|
-
- return err
|
|
257
|
-
- }
|
|
258
|
-
- u.log("INFO", "在线更新 | 2/2 | 替换可执行文件 | 版本="+release.TagName)
|
|
259
|
-
- if err := selfupdate.Apply(bytes.NewReader(executable), selfupdate.Options{TargetPath: u.executable}); err != nil {
|
|
260
|
-
- if rollback := selfupdate.RollbackError(err); rollback != nil {
|
|
261
|
-
- return fmt.Errorf("替换可执行文件: %w;恢复原文件失败: %v", err, rollback)
|
|
262
|
-
- }
|
|
263
|
-
- return fmt.Errorf("替换可执行文件: %w", err)
|
|
264
|
-
- }
|
|
265
|
-
- return nil
|
|
266
|
-
-}
|
|
267
|
-
-
|
|
268
|
-
-// get 经管理进程出口发出 GET,非 200 响应返回错误
|
|
269
|
-
-func (u *selfUpdater) get(ctx context.Context, address, accept string) (*http.Response, error) {
|
|
270
|
-
- transport := http.DefaultTransport.(*http.Transport).Clone()
|
|
271
|
-
- if proxy := u.proxy(); proxy != "" {
|
|
272
|
-
- proxyURL, err := url.Parse(proxy)
|
|
273
|
-
- if err != nil {
|
|
274
|
-
- return nil, fmt.Errorf("解析代理: %w", err)
|
|
275
|
-
- }
|
|
276
|
-
- transport.Proxy = http.ProxyURL(proxyURL)
|
|
277
|
-
- }
|
|
278
|
-
- request, err := http.NewRequestWithContext(ctx, http.MethodGet, address, nil)
|
|
279
|
-
- if err != nil {
|
|
280
|
-
- return nil, err
|
|
281
|
-
- }
|
|
282
|
-
- request.Header.Set("Accept", accept)
|
|
283
|
-
- response, err := (&http.Client{Transport: transport}).Do(request)
|
|
284
|
-
- if err != nil {
|
|
285
|
-
- return nil, err
|
|
286
|
-
- }
|
|
287
|
-
- if response.StatusCode != http.StatusOK {
|
|
288
|
-
- body, _ := io.ReadAll(io.LimitReader(response.Body, 512))
|
|
289
|
-
- response.Body.Close()
|
|
290
|
-
- return nil, fmt.Errorf("HTTP %d %s", response.StatusCode, strings.TrimSpace(string(body)))
|
|
291
|
-
- }
|
|
292
|
-
- return response, nil
|
|
293
|
-
-}
|
|
294
|
-
-
|
|
295
|
-
-// releasePackage 返回当前系统与架构的发行包文件名与其中的可执行文件名
|
|
296
|
-
-func releasePackage(tag string) (string, string) {
|
|
297
|
-
- base := fmt.Sprintf("aistudio2api-%s-%s-%s", tag, runtime.GOOS, runtime.GOARCH)
|
|
298
|
-
- if runtime.GOOS == "windows" {
|
|
299
|
-
- return base + ".zip", "aistudio2api.exe"
|
|
300
|
-
- }
|
|
301
|
-
- return base + ".tar.gz", "aistudio2api"
|
|
302
|
-
-}
|
|
303
|
-
-
|
|
304
|
-
-// extractExecutable 从 zip 或 tar.gz 发行包中取出可执行文件
|
|
305
|
-
-func extractExecutable(archive []byte, name, binary string) ([]byte, error) {
|
|
306
|
-
- if strings.HasSuffix(name, ".zip") {
|
|
307
|
-
- reader, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive)))
|
|
308
|
-
- if err != nil {
|
|
309
|
-
- return nil, fmt.Errorf("读取发行包: %w", err)
|
|
310
|
-
- }
|
|
311
|
-
- for _, file := range reader.File {
|
|
312
|
-
- if path.Base(file.Name) != binary {
|
|
313
|
-
- continue
|
|
314
|
-
- }
|
|
315
|
-
- opened, err := file.Open()
|
|
316
|
-
- if err != nil {
|
|
317
|
-
- return nil, fmt.Errorf("读取发行包: %w", err)
|
|
318
|
-
- }
|
|
319
|
-
- defer opened.Close()
|
|
320
|
-
- return io.ReadAll(opened)
|
|
321
|
-
- }
|
|
322
|
-
- return nil, fmt.Errorf("发行包中没有 %s", binary)
|
|
323
|
-
- }
|
|
324
|
-
- compressed, err := gzip.NewReader(bytes.NewReader(archive))
|
|
325
|
-
- if err != nil {
|
|
326
|
-
- return nil, fmt.Errorf("读取发行包: %w", err)
|
|
327
|
-
- }
|
|
328
|
-
- reader := tar.NewReader(compressed)
|
|
329
|
-
- for {
|
|
330
|
-
- header, err := reader.Next()
|
|
331
|
-
- if errors.Is(err, io.EOF) {
|
|
332
|
-
- return nil, fmt.Errorf("发行包中没有 %s", binary)
|
|
333
|
-
- }
|
|
334
|
-
- if err != nil {
|
|
335
|
-
- return nil, fmt.Errorf("读取发行包: %w", err)
|
|
336
|
-
- }
|
|
337
|
-
- if header.Typeflag == tar.TypeReg && path.Base(header.Name) == binary {
|
|
338
|
-
- return io.ReadAll(reader)
|
|
339
|
-
- }
|
|
340
|
-
- }
|
|
341
|
-
+func (u *selfUpdater) ApplyUpdate(_ context.Context) (api.UpdateStatus, error) {
|
|
342
|
-
+ return api.UpdateStatus{}, managedUpdateDisabledError{}
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
-// relaunch 以原参数启动替换后的可执行文件:Windows 上新进程沿用当前控制台,其他系统原地替换进程
|
|
346
|
-
+// Retained for the application's restart plumbing; the managed updater never
|
|
347
|
-
+// invokes its restart callback.
|
|
348
|
-
func relaunch(executable string, args []string) error {
|
|
349
|
-
args = append(slices.Clone(args), "-open-ui=false")
|
|
350
|
-
if runtime.GOOS == "windows" {
|
|
351
|
-
diff --git a/internal/app/update_managed_test.go b/internal/app/update_managed_test.go
|
|
352
|
-
new file mode 100644
|
|
353
|
-
index 0000000..ce86d14
|
|
354
|
-
--- /dev/null
|
|
355
|
-
+++ b/internal/app/update_managed_test.go
|
|
356
|
-
@@ -0,0 +1,41 @@
|
|
357
|
-
+package app
|
|
358
|
-
+
|
|
359
|
-
+import (
|
|
360
|
-
+ "context"
|
|
361
|
-
+ "errors"
|
|
362
|
-
+ "net/http"
|
|
363
|
-
+ "os"
|
|
364
|
-
+ "path/filepath"
|
|
365
|
-
+ "testing"
|
|
366
|
-
+)
|
|
367
|
-
+
|
|
368
|
-
+func TestManagedUpdaterCannotUpdateOrRestart(t *testing.T) {
|
|
369
|
-
+ executable := filepath.Join(t.TempDir(), "aistudio2api.exe")
|
|
370
|
-
+ original := []byte("existing executable")
|
|
371
|
-
+ if err := os.WriteFile(executable, original, 0600); err != nil {
|
|
372
|
-
+ t.Fatal(err)
|
|
373
|
-
+ }
|
|
374
|
-
+ for _, version := range []string{"v0.2.13", "v0.2.13-magpie.1", "(devel)"} {
|
|
375
|
-
+ t.Run(version, func(t *testing.T) {
|
|
376
|
-
+ u := newSelfUpdater(version, executable,
|
|
377
|
-
+ func() string { t.Fatal("must not query network proxy"); return "" },
|
|
378
|
-
+ func(string, string) { t.Fatal("must not enter the updater") },
|
|
379
|
-
+ func() { t.Fatal("must not restart") })
|
|
380
|
-
+ for _, refresh := range []bool{false, true} {
|
|
381
|
-
+ status, err := u.UpdateStatus(context.Background(), refresh)
|
|
382
|
-
+ if err != nil || status.Mode != "managed" || status.Available || status.Updating || status.Latest != "" || status.Current != version {
|
|
383
|
-
+ t.Fatalf("unexpected status: %+v, %v", status, err)
|
|
384
|
-
+ }
|
|
385
|
-
+ }
|
|
386
|
-
+ _, err := u.ApplyUpdate(context.Background())
|
|
387
|
-
+ var httpError interface{ HTTPStatus() int }
|
|
388
|
-
+ if !errors.As(err, &httpError) || httpError.HTTPStatus() != http.StatusForbidden {
|
|
389
|
-
+ t.Fatalf("expected forbidden: %v", err)
|
|
390
|
-
+ }
|
|
391
|
-
+ data, err := os.ReadFile(executable)
|
|
392
|
-
+ if err != nil || string(data) != string(original) {
|
|
393
|
-
+ t.Fatalf("executable changed: %v", err)
|
|
394
|
-
+ }
|
|
395
|
-
+ })
|
|
396
|
-
+ }
|
|
397
|
-
+}
|
|
398
|
-
diff --git a/web/src/components/VersionPanel.vue b/web/src/components/VersionPanel.vue
|
|
399
|
-
index 98d7968..a4ed70a 100644
|
|
400
|
-
--- a/web/src/components/VersionPanel.vue
|
|
401
|
-
+++ b/web/src/components/VersionPanel.vue
|
|
402
|
-
@@ -1,193 +1,30 @@
|
|
403
|
-
<script setup lang="ts">
|
|
404
|
-
-import { computed, onActivated, ref } from 'vue'
|
|
405
|
-
-import { api } from '@/api'
|
|
406
|
-
+import { computed } from 'vue'
|
|
407
|
-
import { useI18n } from '@/i18n'
|
|
408
|
-
-import { markdown } from '@/markdown'
|
|
409
|
-
import type { UpdateStatus } from '@/types'
|
|
410
|
-
-import UiIcon from '@/components/UiIcon.vue'
|
|
411
|
-
|
|
412
|
-
-const props = defineProps<{ status: UpdateStatus | null; activeRequests: number }>()
|
|
413
|
-
-const emit = defineEmits<{ status: [UpdateStatus]; restarting: [] }>()
|
|
414
|
-
-const { t, locale, errorText } = useI18n()
|
|
415
|
-
-const checking = ref(false)
|
|
416
|
-
-const confirming = ref(false)
|
|
417
|
-
-const phase = ref<'idle' | 'installing' | 'restarting'>('idle')
|
|
418
|
-
-const error = ref('')
|
|
419
|
-
-
|
|
420
|
-
-const latest = computed(() => props.status?.latest ?? '')
|
|
421
|
-
-const notes = computed(() => markdown.render(props.status?.notes ?? ''))
|
|
422
|
-
-const publishedAt = computed(() =>
|
|
423
|
-
- props.status?.published_at
|
|
424
|
-
- ? new Date(props.status.published_at).toLocaleString(locale.value)
|
|
425
|
-
- : '',
|
|
426
|
-
-)
|
|
427
|
-
-const installable = computed(
|
|
428
|
-
- () => props.status?.mode === 'binary' && props.status.available && !props.status.updating,
|
|
429
|
-
+defineProps<{ status: UpdateStatus | null; activeRequests: number }>()
|
|
430
|
-
+defineEmits<{ status: [UpdateStatus]; restarting: [] }>()
|
|
431
|
-
+const { t, locale } = useI18n()
|
|
432
|
-
+const message = computed(() =>
|
|
433
|
-
+ locale.value.startsWith('zh')
|
|
434
|
-
+ ? '在线更新已禁用,请通过 Magpie 插件升级。'
|
|
435
|
-
+ : 'Online updates are disabled. Upgrade through the Magpie plugin.',
|
|
436
|
-
)
|
|
437
|
-
-const summary = computed(() => {
|
|
438
|
-
- if (phase.value === 'installing') return t('version.installing')
|
|
439
|
-
- if (phase.value === 'restarting') return t('version.restarting')
|
|
440
|
-
- if (props.status?.available) return t('version.available').replace('{version}', latest.value)
|
|
441
|
-
- if (latest.value) return t('version.upToDate')
|
|
442
|
-
- return ''
|
|
443
|
-
-})
|
|
444
|
-
-const confirmText = computed(() =>
|
|
445
|
-
- t('version.confirmText')
|
|
446
|
-
- .replace('{version}', latest.value)
|
|
447
|
-
- .replace('{count}', String(props.activeRequests)),
|
|
448
|
-
-)
|
|
449
|
-
-
|
|
450
|
-
-// check 读取 GitHub 最新发行版本,refresh 为 true 时跳过服务端缓存
|
|
451
|
-
-async function check(refresh: boolean): Promise<void> {
|
|
452
|
-
- checking.value = true
|
|
453
|
-
- error.value = ''
|
|
454
|
-
- try {
|
|
455
|
-
- emit('status', await api.updateStatus(refresh))
|
|
456
|
-
- } catch (reason) {
|
|
457
|
-
- error.value = errorText(reason)
|
|
458
|
-
- } finally {
|
|
459
|
-
- checking.value = false
|
|
460
|
-
- }
|
|
461
|
-
-}
|
|
462
|
-
-
|
|
463
|
-
-// install 下载并替换为最新版本,成功后通知上层等待重启
|
|
464
|
-
-async function install(): Promise<void> {
|
|
465
|
-
- confirming.value = false
|
|
466
|
-
- phase.value = 'installing'
|
|
467
|
-
- error.value = ''
|
|
468
|
-
- try {
|
|
469
|
-
- emit('status', await api.applyUpdate())
|
|
470
|
-
- } catch (reason) {
|
|
471
|
-
- phase.value = 'idle'
|
|
472
|
-
- error.value = errorText(reason)
|
|
473
|
-
- return
|
|
474
|
-
- }
|
|
475
|
-
- phase.value = 'restarting'
|
|
476
|
-
- emit('restarting')
|
|
477
|
-
-}
|
|
478
|
-
-
|
|
479
|
-
-onActivated(() => void check(false))
|
|
480
|
-
</script>
|
|
481
|
-
|
|
482
|
-
<template>
|
|
483
|
-
- <section class="flex min-h-0 flex-1 flex-col overflow-auto md:overflow-hidden">
|
|
484
|
-
- <header class="flex flex-wrap items-center gap-3 border-b border-line px-4 py-4 md:px-8">
|
|
485
|
-
- <div class="min-w-0 flex-1">
|
|
486
|
-
- <h2 class="text-xl font-semibold text-white">{{ t('section.version.title') }}</h2>
|
|
487
|
-
- <p class="mt-1 text-sm text-gray-500">{{ t('section.version.description') }}</p>
|
|
488
|
-
- </div>
|
|
489
|
-
+ <section class="flex min-h-0 flex-1 flex-col overflow-auto">
|
|
490
|
-
+ <header class="border-b border-line px-4 py-4 md:px-8">
|
|
491
|
-
+ <h2 class="text-xl font-semibold text-white">{{ t('section.version.title') }}</h2>
|
|
492
|
-
</header>
|
|
493
|
-
- <div class="mx-auto flex min-h-0 w-full max-w-4xl flex-1 flex-col p-4 md:p-8">
|
|
494
|
-
- <article class="shrink-0 rounded-lg border border-line bg-panel p-4 md:p-6">
|
|
495
|
-
- <dl class="grid gap-4 sm:grid-cols-2">
|
|
496
|
-
- <div>
|
|
497
|
-
- <dt class="text-xs text-gray-500">{{ t('version.current') }}</dt>
|
|
498
|
-
- <dd class="mt-1 font-mono text-lg break-all text-white">
|
|
499
|
-
- {{ status?.current ?? '—' }}
|
|
500
|
-
- </dd>
|
|
501
|
-
- </div>
|
|
502
|
-
- <div>
|
|
503
|
-
- <dt class="text-xs text-gray-500">{{ t('version.latest') }}</dt>
|
|
504
|
-
- <dd class="mt-1 font-mono text-lg text-white">{{ latest || '—' }}</dd>
|
|
505
|
-
- <dd v-if="publishedAt" class="mt-0.5 text-xs text-gray-500">{{ publishedAt }}</dd>
|
|
506
|
-
- </div>
|
|
507
|
-
+ <div class="mx-auto w-full max-w-4xl p-4 md:p-8">
|
|
508
|
-
+ <article class="rounded-lg border border-line bg-panel p-4 md:p-6">
|
|
509
|
-
+ <dl>
|
|
510
|
-
+ <dt class="text-xs text-gray-500">{{ t('version.current') }}</dt>
|
|
511
|
-
+ <dd class="mt-1 font-mono text-lg break-all text-white">{{ status?.current ?? '—' }}</dd>
|
|
512
|
-
</dl>
|
|
513
|
-
-
|
|
514
|
-
- <p
|
|
515
|
-
- v-if="summary"
|
|
516
|
-
- class="mt-4 text-sm"
|
|
517
|
-
- :class="status?.available || phase !== 'idle' ? 'text-blue-300' : 'text-gray-400'"
|
|
518
|
-
- role="status"
|
|
519
|
-
- >
|
|
520
|
-
- {{ summary }}
|
|
521
|
-
- </p>
|
|
522
|
-
-
|
|
523
|
-
- <div class="mt-4 flex flex-wrap items-center gap-2">
|
|
524
|
-
- <button
|
|
525
|
-
- type="button"
|
|
526
|
-
- class="flex items-center gap-2 rounded border border-line bg-raised px-4 py-2 text-sm font-medium text-gray-200 transition hover:bg-line disabled:opacity-50"
|
|
527
|
-
- :disabled="checking || phase !== 'idle'"
|
|
528
|
-
- @click="check(true)"
|
|
529
|
-
- >
|
|
530
|
-
- <UiIcon :name="checking ? 'spinner' : 'refresh'" :size="15" />
|
|
531
|
-
- {{ t('version.check') }}
|
|
532
|
-
- </button>
|
|
533
|
-
- <button
|
|
534
|
-
- v-if="installable && !confirming"
|
|
535
|
-
- type="button"
|
|
536
|
-
- class="flex items-center gap-2 rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-500 disabled:opacity-50"
|
|
537
|
-
- :disabled="phase !== 'idle'"
|
|
538
|
-
- @click="confirming = true"
|
|
539
|
-
- >
|
|
540
|
-
- <UiIcon :name="phase === 'idle' ? 'download' : 'spinner'" :size="15" />
|
|
541
|
-
- {{ t('version.install').replace('{version}', latest) }}
|
|
542
|
-
- </button>
|
|
543
|
-
- <a
|
|
544
|
-
- v-if="status?.url"
|
|
545
|
-
- :href="status.url"
|
|
546
|
-
- target="_blank"
|
|
547
|
-
- rel="noreferrer"
|
|
548
|
-
- class="px-2 py-2 text-sm text-blue-400 hover:underline"
|
|
549
|
-
- >
|
|
550
|
-
- {{ t('version.release') }}
|
|
551
|
-
- </a>
|
|
552
|
-
- </div>
|
|
553
|
-
-
|
|
554
|
-
- <div
|
|
555
|
-
- v-if="confirming"
|
|
556
|
-
- role="alert"
|
|
557
|
-
- class="mt-4 rounded-md border border-amber-500/40 bg-amber-500/10 p-4 text-sm text-amber-200"
|
|
558
|
-
- >
|
|
559
|
-
- <p>{{ confirmText }}</p>
|
|
560
|
-
- <div class="mt-3 flex flex-wrap gap-2">
|
|
561
|
-
- <button
|
|
562
|
-
- type="button"
|
|
563
|
-
- class="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-500"
|
|
564
|
-
- @click="install"
|
|
565
|
-
- >
|
|
566
|
-
- {{ t('version.confirm') }}
|
|
567
|
-
- </button>
|
|
568
|
-
- <button
|
|
569
|
-
- type="button"
|
|
570
|
-
- class="rounded border border-line bg-raised px-4 py-2 text-sm font-medium text-gray-200 transition hover:bg-line"
|
|
571
|
-
- @click="confirming = false"
|
|
572
|
-
- >
|
|
573
|
-
- {{ t('common.cancel') }}
|
|
574
|
-
- </button>
|
|
575
|
-
- </div>
|
|
576
|
-
- </div>
|
|
577
|
-
-
|
|
578
|
-
- <div v-if="status?.mode === 'container'" class="mt-4 text-sm text-gray-400">
|
|
579
|
-
- <p>{{ t('version.container') }}</p>
|
|
580
|
-
- <code
|
|
581
|
-
- class="mt-2 block overflow-x-auto rounded border border-line bg-canvas px-3 py-2 font-mono text-xs text-gray-200"
|
|
582
|
-
- >docker compose pull && docker compose up -d</code
|
|
583
|
-
- >
|
|
584
|
-
- </div>
|
|
585
|
-
- <p v-else-if="status?.mode === 'development'" class="mt-4 text-sm text-gray-400">
|
|
586
|
-
- {{ t('version.development') }}
|
|
587
|
-
- </p>
|
|
588
|
-
-
|
|
589
|
-
- <p
|
|
590
|
-
- v-if="error || status?.error"
|
|
591
|
-
- role="alert"
|
|
592
|
-
- class="mt-4 rounded border border-red-500/40 bg-red-500/10 p-3 text-sm break-words text-red-300"
|
|
593
|
-
- >
|
|
594
|
-
- {{ error || status?.error }}
|
|
595
|
-
- </p>
|
|
596
|
-
- </article>
|
|
597
|
-
-
|
|
598
|
-
- <article
|
|
599
|
-
- v-if="status?.notes"
|
|
600
|
-
- class="mt-6 flex min-h-0 flex-1 flex-col rounded-lg border border-line bg-panel p-4 md:p-6"
|
|
601
|
-
- >
|
|
602
|
-
- <h3 class="font-bold text-gray-300">
|
|
603
|
-
- {{ t('version.notes').replace('{version}', latest) }}
|
|
604
|
-
- </h3>
|
|
605
|
-
- <div
|
|
606
|
-
- class="markdown mt-3 min-h-0 flex-1 text-sm leading-6 [overflow-wrap:anywhere] text-gray-300 md:overflow-y-auto"
|
|
607
|
-
- v-html="notes"
|
|
608
|
-
- />
|
|
609
|
-
+ <p class="mt-4 text-sm text-gray-400" role="status">{{ message }}</p>
|
|
610
|
-
</article>
|
|
611
|
-
</div>
|
|
612
|
-
</section>
|
|
613
|
-
diff --git a/web/src/types.ts b/web/src/types.ts
|
|
614
|
-
index e5c07cc..98a027b 100644
|
|
615
|
-
--- a/web/src/types.ts
|
|
616
|
-
+++ b/web/src/types.ts
|
|
617
|
-
@@ -88,7 +88,7 @@ export interface UpdateStatus {
|
|
618
|
-
current: string
|
|
619
|
-
latest?: string
|
|
620
|
-
available: boolean
|
|
621
|
-
- mode: 'binary' | 'container' | 'development'
|
|
622
|
-
+ mode: 'binary' | 'container' | 'development' | 'managed'
|
|
623
|
-
notes?: string
|
|
624
|
-
url?: string
|
|
625
|
-
published_at?: string
|