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 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.13`) | gemini-web2api-go (`4.22.0-magpie.1` optimized build) |
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.13-magpie.1`, based on upstream `v0.2.13`. Upgrade the plugin to update the backend. Official managed releases at or below that upstream version 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 directs users to upgrade through the Magpie plugin; the update endpoint returns HTTP 403 and update checks do not contact GitHub. Independently managed external servers are unaffected. See [docs/studio-release.md](docs/studio-release.md) for the patch and reproducible build process.
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.13`) | gemini-web2api-go (`4.22.0-magpie.1` 优化版) |
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.13-magpie.1`(基于上游 `v0.2.13`),通过升级插件更新后端。同基线或更旧的官方托管后端在下次管理启动时自动升级,保留账号、端口与登录凭据。
139
- - **托管后端已在 macOS、Linux、Windows 统一禁用在线更新**:管理页提示“请通过 Magpie 插件升级”,更新接口返回 HTTP 403,检查更新也不会访问 GitHub。用户自行连接的外部服务不受影响。补丁和可复现构建流程见 [docs/studio-release.md](docs/studio-release.md)。
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` 等);已知发生数据迁移时应升级插件以匹配新版后端。
@@ -1,29 +1,29 @@
1
1
  # AI Studio 托管后端构建
2
2
 
3
- 插件使用 `0.2.13-magpie.1`,基于 AIStudio2API `v0.2.13` 的固定 commit。上游版本、commit、Go 工具链、安装包及二进制 SHA-256 均记录在 `lib/studio-release.json`。MIT 许可见 `LICENSE-AISTUDIO`。
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
- `patches/studio-managed.patch` 包含后端与管理页面修改:
7
+ 上游从 v0.2.14 起支持由分发方管理更新,不再需要补丁。构建时通过 `-ldflags` 写入两个值:
8
8
 
9
- - 后端移除在线更新的下载、文件替换逻辑;`GET /api/update`(包括 `?refresh=1`)仅返回本地版本及 `mode: managed`,不访问 GitHub。
10
- - `POST /api/update` 返回 HTTP 403,提示通过 Magpie 插件升级;不替换文件、不重启。此策略无环境变量或管理页开关,在所有构建平台相同。
11
- - 管理页版本面板显示当前版本及中英文升级提示,不提供检查更新或安装更新按钮。
12
- - 设置明确的 `v0.2.13-magpie.1` 版本标识。插件将同基线或更旧的官方版视为需要升级;更新的官方版或不认识的定制版仍提示冲突。
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
- # 按固定源代码和补丁构建所有五个平台,并核对现有 pin。
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
- # 制作新版本:审查源码/补丁后重新生成所有平台 pin。
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 更新返回 403、二进制及 PID 不变、管理页面无更新入口、停止与状态。`.github/workflows/studio.yml` 在五种系统/架构上重新构建、对比 SHA-256 并运行该验证;本机交叉编译不代替 Windows 真机验证。
45
+ 真实安装验证检查启动、版本、GET 更新状态、POST 更新返回 409、二进制及 PID 不变、管理页面显示托管提示、停止与状态。`.github/workflows/studio.yml` 在五种系统/架构上重新构建、对比 SHA-256 并运行该验证;本机交叉编译不代替 Windows 真机验证。
46
46
 
47
- 发布时先审查构建记录和各平台 CI 结果,再将安装包及许可证上传到本仓库 `studio-v0.2.13-magpie.1` release。该 release 可公开下载后,才能发布引用它的插件版本,否则新安装会遇到 404。构建脚本及 CI 不自动发布 release,也不发布 npm 包。
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` 指定目录。修改前备份账号和配置;该定制版本与上游 v0.2.13 使用相同的数据格式。
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` 也能正常使用。
@@ -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 can move to the managed
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 parts = /^\d+\.\d+\.\d+$/.test(runningVersion) ? runningVersion.split('.').map(Number) : null;
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
 
@@ -1,38 +1,38 @@
1
1
  {
2
- "version": "0.2.13-magpie.1",
2
+ "version": "0.2.14-magpie.1",
3
3
  "repository": "BUKOWSKIREAL/magpie-gemini-web",
4
- "tag": "studio-v0.2.13-magpie.1",
4
+ "tag": "studio-v0.2.14-magpie.1",
5
5
  "goVersion": "1.27.1",
6
6
  "upstream": {
7
- "version": "0.2.13",
7
+ "version": "0.2.14",
8
8
  "repository": "https://github.com/Mag1cFall/AIStudio2API.git",
9
- "commit": "a784c5193b3ba5c4894d536b2f4cd73e87f99475"
9
+ "commit": "370a313814099b508881807effa2a9d72f2d7e66"
10
10
  },
11
11
  "binaries": {
12
12
  "darwin-arm64": {
13
- "asset": "aistudio2api-v0.2.13-magpie.1-darwin-arm64.tar.gz",
14
- "sha256": "3145f1fc3e88dafc1c43ecfa4ed7c6f82d1126a2d8add02605d05f1c5c404144",
15
- "binarySha256": "7d727bf1cc5a86d9c1a2a99d73d645f38a0597489ef62e2bf20b0d33dd87369d"
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.13-magpie.1-darwin-amd64.tar.gz",
19
- "sha256": "96994351570105a6de4e1c95800231ad20d762ee010cfa238bfef11101124c27",
20
- "binarySha256": "428963b5bd3c9c35b88c7a7f29923c5a5a6bef5d0fcd511274daa8a8ebc9f686"
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.13-magpie.1-linux-amd64.tar.gz",
24
- "sha256": "9e24fb139abb0bcd8f6cbab23ac1875136f3ecacd30919fbcc25fe342feb369c",
25
- "binarySha256": "de8c25f07870d83dd2ebc03b9be1f6e15b4f93f454dfe8f5c4182c72261bcbfa"
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.13-magpie.1-linux-arm64.tar.gz",
29
- "sha256": "7b7057abd8e298685f4ca835e014a48c9b1904a253ebfbc053e4b1d7712c532c",
30
- "binarySha256": "151fd830cf0da8801d49e448021d73cde0adb51e982b3a8604cdcbc5babd21c4"
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.13-magpie.1-windows-amd64.zip",
34
- "sha256": "aa337556362ce9c16cae71117a9c12e20b623b12665dd5f36cf01bf152d72fbb",
35
- "binarySha256": "4a0c32be722dcc27c9046fe410d6af7efd576c4192cb1158c84468cada4fbd30"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magpie-gemini-web",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
4
4
  "description": "Gemini Web and Google AI Studio provider for Magpie, backed by gemini-web2api-go and AIStudio2API",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
@@ -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
- }), /Magpie/);
40
- assert.equal(updateHTTP, 403);
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
- assert.match(panel, /Online updates are disabled/);
51
- assert.ok(!panel.includes('applyUpdate'), 'version panel must not offer online installation');
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 403, unchanged executable/PID, managed UI, clean stop`);
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 + reviewed patch -> managed archives, never a developer's dirty tree.
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 patch = path.join(root, 'patches/studio-managed.patch');
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.managedVersion=v${release.version}`];
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, patchSha256: hash(patch), flags, binaries
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 &amp;&amp; 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