diff --git a/desktop/src/download.go b/desktop/src/download.go index f5117651..aad0c461 100644 --- a/desktop/src/download.go +++ b/desktop/src/download.go @@ -34,11 +34,15 @@ func pythonExe(root string) string { func ensurePortable(status func(string)) error { root := portableDir() - if launchReady(root) { - status("正在使用已有运行环境…") + if launchReady(root) && !portableNeedsReplace(root) { + status("正在加载运行环境…") return nil } - status("首次启动,正在解压内置运行环境…") + if launchReady(root) { + status("正在升级运行环境…") + } else { + status("正在准备运行环境…") + } if err := extractPortable(root); err != nil { return err } @@ -69,6 +73,69 @@ func extractPortable(root string) error { return unzipGreen(zipPath, root) } +func portableNeedsReplace(root string) bool { + bundled := bundledVERSION() + if len(bundled) == 0 { + return false + } + local, err := os.ReadFile(filepath.Join(root, "VERSION.txt")) + if err != nil { + return true + } + return !bytes.Equal(bytes.TrimSpace(local), bytes.TrimSpace(bundled)) +} + +func bundledVERSION() []byte { + if os.Getenv("OCTOP_DESKTOP_PORTABLE_ZIP") != "" { + path, err := bundledPortableZip() + if err != nil { + return nil + } + return zipPathVERSION(path) + } + if len(embeddedPortable) > 0 { + r, err := zip.NewReader(bytes.NewReader(embeddedPortable), int64(len(embeddedPortable))) + if err != nil { + return nil + } + return zipFilesVERSION(r.File) + } + path, err := bundledPortableZip() + if err != nil { + return nil + } + return zipPathVERSION(path) +} + +func zipPathVERSION(zipPath string) []byte { + r, err := zip.OpenReader(zipPath) + if err != nil { + return nil + } + defer r.Close() + return zipFilesVERSION(r.File) +} + +func zipFilesVERSION(files []*zip.File) []byte { + for _, f := range files { + parts := strings.SplitN(f.Name, "/", 2) + if len(parts) < 2 || parts[1] != "VERSION.txt" { + continue + } + rc, err := f.Open() + if err != nil { + return nil + } + data, err := io.ReadAll(rc) + rc.Close() + if err != nil { + return nil + } + return data + } + return nil +} + func bundledPortableZip() (string, error) { name := fmt.Sprintf("Octop-%s.zip", greenPlat()) if override := os.Getenv("OCTOP_DESKTOP_PORTABLE_ZIP"); override != "" { diff --git a/desktop/src/download_test.go b/desktop/src/download_test.go index 952bc6b8..8994ea80 100644 --- a/desktop/src/download_test.go +++ b/desktop/src/download_test.go @@ -15,7 +15,7 @@ func TestEnsurePortableUsesEmbeddedPackage(t *testing.T) { t.Setenv("OCTOP_DESKTOP_PORTABLE_ZIP", "") zipPath := filepath.Join(t.TempDir(), "embedded.zip") - writeTestGreenZip(t, zipPath) + writeTestGreenZip(t, zipPath, "0.9.0") data, err := os.ReadFile(zipPath) if err != nil { t.Fatal(err) @@ -38,7 +38,7 @@ func TestEnsurePortableUsesBundledPackage(t *testing.T) { zipPath := filepath.Join(t.TempDir(), "Octop-"+greenPlat()+".zip") t.Setenv("OCTOP_DESKTOP_PORTABLE_ZIP", zipPath) - writeTestGreenZip(t, zipPath) + writeTestGreenZip(t, zipPath, "0.9.0") var statuses []string err := ensurePortable(func(status string) { @@ -58,6 +58,69 @@ func TestEnsurePortableUsesBundledPackage(t *testing.T) { } } +func TestEnsurePortableReplacesDifferentVERSION(t *testing.T) { + home := t.TempDir() + t.Setenv("OCTOP_HOME", home) + root := portableDir() + seedLaunchReady(t, root, "0.9.0") + marker := filepath.Join(root, "keep-me.txt") + if err := os.WriteFile(marker, []byte("stale"), 0o644); err != nil { + t.Fatal(err) + } + + zipPath := filepath.Join(t.TempDir(), "Octop-"+greenPlat()+".zip") + t.Setenv("OCTOP_DESKTOP_PORTABLE_ZIP", zipPath) + writeTestGreenZip(t, zipPath, "0.9.1") + + var statuses []string + if err := ensurePortable(func(status string) { + statuses = append(statuses, status) + }); err != nil { + t.Fatal(err) + } + if len(statuses) == 0 || statuses[0] != "检测到新版本,正在更新运行环境…" { + t.Fatalf("unexpected statuses: %v", statuses) + } + if _, err := os.Stat(marker); err == nil { + t.Fatal("older portable dir should be removed before extract") + } + got, err := os.ReadFile(filepath.Join(root, "VERSION.txt")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(got), "octop_version=0.9.1") { + t.Fatalf("VERSION.txt = %q, want 0.9.1", got) + } +} + +func TestEnsurePortableKeepsMatchingVERSION(t *testing.T) { + home := t.TempDir() + t.Setenv("OCTOP_HOME", home) + root := portableDir() + seedLaunchReady(t, root, "0.9.1") + marker := filepath.Join(root, "keep-me.txt") + if err := os.WriteFile(marker, []byte("keep"), 0o644); err != nil { + t.Fatal(err) + } + + zipPath := filepath.Join(t.TempDir(), "Octop-"+greenPlat()+".zip") + t.Setenv("OCTOP_DESKTOP_PORTABLE_ZIP", zipPath) + writeTestGreenZip(t, zipPath, "0.9.1") + + var statuses []string + if err := ensurePortable(func(status string) { + statuses = append(statuses, status) + }); err != nil { + t.Fatal(err) + } + if len(statuses) == 0 || statuses[0] != "正在使用已有运行环境…" { + t.Fatalf("unexpected statuses: %v", statuses) + } + if _, err := os.Stat(marker); err != nil { + t.Fatal("matching portable dir should be kept") + } +} + func TestBundledPortableZipRequiresMatchingPackage(t *testing.T) { t.Setenv("OCTOP_DESKTOP_PORTABLE_ZIP", filepath.Join(t.TempDir(), "missing.zip")) if _, err := bundledPortableZip(); err == nil { @@ -83,14 +146,37 @@ func TestLaunchReadyRejectsFlattenedPythonSymlink(t *testing.T) { } } -func writeTestGreenZip(t *testing.T, path string) { +func seedLaunchReady(t *testing.T, root, version string) { + t.Helper() + if err := os.MkdirAll(filepath.Join(root, "runtime", "bin"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "launch.py"), []byte("test"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "VERSION.txt"), []byte("octop_version="+version+"\n"), 0o644); err != nil { + t.Fatal(err) + } + py := filepath.Join(root, "runtime", "bin", "python3") + if runtime.GOOS == "windows" { + if err := os.MkdirAll(filepath.Join(root, "runtime"), 0o755); err != nil { + t.Fatal(err) + } + py = filepath.Join(root, "runtime", "python.exe") + } + if err := os.WriteFile(py, make([]byte, 2048), 0o755); err != nil { + t.Fatal(err) + } +} + +func writeTestGreenZip(t *testing.T, path, version string) { t.Helper() f, err := os.Create(path) if err != nil { t.Fatal(err) } w := zip.NewWriter(f) - files := []string{"Octop-test/launch.py"} + files := []string{"Octop-test/launch.py", "Octop-test/VERSION.txt"} if runtime.GOOS == "windows" { files = append(files, "Octop-test/runtime/python.exe") } else { @@ -105,6 +191,9 @@ func writeTestGreenZip(t *testing.T, path string) { if strings.HasSuffix(name, "/python3") { header.SetMode(os.ModeSymlink | 0o755) content = []byte("python3.12") + } else if strings.HasSuffix(name, "/VERSION.txt") { + header.SetMode(0o644) + content = []byte("octop_version=" + version + "\n") } else { header.SetMode(0o755) if strings.HasSuffix(name, "/python3.12") || strings.HasSuffix(name, "/python.exe") { diff --git a/desktop/src/main.go b/desktop/src/main.go index 3a60c5c7..5ca50e29 100644 --- a/desktop/src/main.go +++ b/desktop/src/main.go @@ -36,6 +36,9 @@ type App struct { trayClickMu sync.Mutex lastTrayClick time.Time trayClickTimer *time.Timer + + runtimeReady chan struct{} + runtimeReadyOnce sync.Once } func (a *App) ServiceName() string { return "desktop" } @@ -185,10 +188,31 @@ func (a *App) boot() { a.showDashboard(base) } +func (a *App) signalRuntimeReady() { + a.runtimeReadyOnce.Do(func() { + if a.runtimeReady != nil { + close(a.runtimeReady) + } + }) +} + +func (a *App) waitRuntimeReady(timeout time.Duration) { + if a.runtimeReady == nil { + return + } + select { + case <-a.runtimeReady: + case <-time.After(timeout): + } +} + func (a *App) showDashboard(base string) { if a.window == nil { return } + // ExecJS is queued until wails:runtime:ready. Dashboard never sends that + // handshake; wait for the shell page so overlay injects work after SetURL. + a.waitRuntimeReady(8 * time.Second) a.window.SetURL(base) a.scheduleDragOverlay() s := a.store.get() @@ -311,8 +335,9 @@ func (a *App) requestQuit() { func main() { store := &settingsStore{cur: loadSettings()} api := &App{ - store: store, - sleep: &sleepGuard{}, + store: store, + sleep: &sleepGuard{}, + runtimeReady: make(chan struct{}), } app := application.New(application.Options{ @@ -350,6 +375,9 @@ func main() { BackgroundColour: application.NewRGB(247, 248, 250), }) api.window = win + win.OnWindowEvent(events.Common.WindowRuntimeReady, func(_ *application.WindowEvent) { + api.signalRuntimeReady() + }) app.Event.On("desktop:toggle-maximise", func(_ *application.CustomEvent) { win.ToggleMaximise() })