magpie-gemini-web 0.5.2 → 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 +10 -5
- package/README.md +10 -5
- package/docs/studio-release.md +12 -12
- package/lib/studio-native.mjs +285 -43
- package/lib/studio-release.json +19 -19
- package/lib/studio.mjs +72 -18
- package/package.json +1 -1
- package/scripts/studio-managed-smoke.mjs +7 -5
- package/scripts/studio-release.mjs +6 -6
- package/scripts/studio.mjs +17 -26
- package/patches/studio-managed.patch +0 -625
|
@@ -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
|