diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml new file mode 100644 index 00000000..71b6bb39 --- /dev/null +++ b/.github/workflows/release-publish.yml @@ -0,0 +1,129 @@ +name: release-publish + +on: + workflow_dispatch: + inputs: + tag: + description: "Release tag to publish (v2.1.4 or browser-extension-v0.11.0); published tags can retry README sync" + required: true + +permissions: + contents: write + pull-requests: write + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + NODE_VERSION: "24" + RELEASE_TAG: ${{ inputs.tag }} + +concurrency: + group: publish-release + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Publish draft with explicit Latest policy + id: release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + shell: bash + run: | + set -euo pipefail + if [[ "$RELEASE_TAG" =~ ^browser-extension-v[0-9]+(\.[0-9]+){0,3}$ ]]; then + latest=false + elif [[ "$RELEASE_TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + latest=true + else + echo "Unsupported release tag: $RELEASE_TAG" >&2 + exit 1 + fi + + release_json="$RUNNER_TEMP/javboss-release.json" + gh release view "$RELEASE_TAG" --repo "$GH_REPO" --json isDraft,isPrerelease,assets > "$release_json" + jq -e '.isPrerelease == false' "$release_json" > /dev/null + if [[ "$latest" == true ]]; then + for platform in windows-x86_64 linux-x86_64 macos-x86_64 macos-arm64; do + jq -e --arg name "javboss-${RELEASE_TAG}-${platform}.zip" 'any(.assets[]; .name == $name)' "$release_json" > /dev/null + done + else + jq -e --arg name "javboss-${RELEASE_TAG}.zip" 'any(.assets[]; .name == $name)' "$release_json" > /dev/null + fi + jq -e '[.assets[] | select(.name | test("^javboss-browser-extension-v[0-9]+(\\.[0-9]+){0,3}\\.zip$"))] | length <= 1' "$release_json" > /dev/null + if jq -e '.isDraft == true' "$release_json" > /dev/null; then + gh release edit "$RELEASE_TAG" --repo "$GH_REPO" --draft=false --latest="$latest" + else + echo "Release is already published; checking whether README sync is needed." + fi + + # Retrying an older release must not replace the current Latest links. + update_readme=true + if [[ "$latest" == true ]]; then + latest_tag="$(gh api "repos/$GH_REPO/releases/latest" --jq .tag_name)" + if [[ "$latest_tag" == "$RELEASE_TAG" ]]; then + echo "Updating README for the latest application release." + else + update_readme=false + echo "Latest is $latest_tag; leaving README unchanged." + fi + fi + echo "update_readme=$update_readme" >> "$GITHUB_OUTPUT" + + - name: Checkout publishing scripts + if: steps.release.outputs.update_readme == 'true' + uses: actions/checkout@v5 + with: + path: release-tools + persist-credentials: false + + - name: Checkout main for README update + if: steps.release.outputs.update_readme == 'true' + uses: actions/checkout@v5 + with: + ref: main + path: main + # Optional: use a PAT if the README PR needs to trigger other workflows. + token: ${{ secrets.RELEASE_PUBLISH_TOKEN || github.token }} + + - name: Setup Node + if: steps.release.outputs.update_readme == 'true' + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Create or update README pull request + if: steps.release.outputs.update_readme == 'true' + working-directory: main + env: + GH_TOKEN: ${{ secrets.RELEASE_PUBLISH_TOKEN || github.token }} + GH_REPO: ${{ github.repository }} + shell: bash + run: | + set -euo pipefail + node "$GITHUB_WORKSPACE/release-tools/scripts/update-release-version.mjs" "$RELEASE_TAG" "$RUNNER_TEMP/javboss-release.json" README.md + if git diff --quiet -- README.md; then + echo "README download links already point to $RELEASE_TAG." + exit 0 + fi + + branch="release-readme/${RELEASE_TAG}" + remote_sha="$(git ls-remote --heads origin "refs/heads/$branch" | cut -f1)" + git checkout -b "$branch" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -- README.md + git commit -m "docs: update download links to $RELEASE_TAG" + # Only replace this workflow's branch, and reject concurrent changes. + git push --force-with-lease="refs/heads/$branch:$remote_sha" origin "HEAD:refs/heads/$branch" + + title="docs: update download links for $RELEASE_TAG" + body_file="$RUNNER_TEMP/readme-pr-body.md" + printf 'Updates the applicable application and browser extension download links in README.md for [%s](https://github.com/%s/releases/tag/%s).\n\nGenerated after publishing the release. Merge this PR to update the links on main.\n' "$RELEASE_TAG" "$GH_REPO" "$RELEASE_TAG" > "$body_file" + pr_number="$(gh pr list --repo "$GH_REPO" --head "$branch" --base main --state open --json number --jq '.[0].number // empty')" + if [[ -n "$pr_number" ]]; then + gh pr edit "$pr_number" --repo "$GH_REPO" --title "$title" --body-file "$body_file" + else + gh pr create --repo "$GH_REPO" --head "$branch" --base main --title "$title" --body-file "$body_file" + fi diff --git a/.github/workflows/update-release-version.yml b/.github/workflows/update-release-version.yml deleted file mode 100644 index 9078dd7d..00000000 --- a/.github/workflows/update-release-version.yml +++ /dev/null @@ -1,71 +0,0 @@ -name: update-release-version - -on: - workflow_dispatch: - inputs: - version: - description: "Release version, for example v1.8.2" - required: true - -permissions: - contents: write - pull-requests: write - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" - NODE_VERSION: "23.11.0" - -jobs: - update: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version: ${{ env.NODE_VERSION }} - - - name: Update release version - env: - VERSION_INPUT: ${{ inputs.version }} - run: node scripts/update-release-version.mjs "$VERSION_INPUT" - - - name: Create pull request - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - VERSION_INPUT: ${{ inputs.version }} - run: | - set -euo pipefail - - if git diff --quiet; then - echo "No version changes to publish." - exit 0 - fi - - branch="release-version/${VERSION_INPUT}" - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git fetch origin "refs/heads/${branch}:refs/remotes/origin/${branch}" || true - git checkout -b "$branch" - git add README.md - git commit -m "chore: update release version to ${VERSION_INPUT}" - git push --force-with-lease origin "$branch" - - if gh pr view "$branch" --repo "$GH_REPO" >/dev/null 2>&1; then - gh pr edit "$branch" \ - --repo "$GH_REPO" \ - --title "chore: update release version to ${VERSION_INPUT}" \ - --body "Updates the README download links to ${VERSION_INPUT}." - else - gh pr create \ - --repo "$GH_REPO" \ - --head "$branch" \ - --base "${GITHUB_REF_NAME}" \ - --title "chore: update release version to ${VERSION_INPUT}" \ - --body "Updates the README download links to ${VERSION_INPUT}." - fi diff --git a/.gitignore b/.gitignore index d72ca665..cc69e919 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .gocache/ +.updates/ data/ /cache/ docker-data/ diff --git a/cmd/server/main.go b/cmd/server/main.go index 57348adc..af64ae57 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -31,6 +31,7 @@ import ( "javboss/internal/jav/translation" "javboss/internal/models" "javboss/internal/mpv" + "javboss/internal/update" "javboss/internal/util" "javboss/internal/download" @@ -54,6 +55,13 @@ const ( ) func main() { + if len(os.Args) == 3 && os.Args[1] == "--apply-update" { + if err := update.RunHelper(os.Args[2]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return + } os.Exit(run()) } @@ -94,6 +102,18 @@ func run() int { if background { return 0 } + if buildMode == "release" && !common.ContainerMode() { + lock, err := update.AcquireRuntimeLock(baseDir) + if err != nil { + if errors.Is(err, util.ErrLockHeld) { + notifyAlreadyRunning("JavBoss 已在运行或更新中。") + return 0 + } + logger.Printf("acquire update runtime lock: %v", err) + return 1 + } + defer lock.Release() + } bootstrapCfg, err := clientpkg.LoadBootstrapConfig(baseDir) if err != nil { @@ -185,6 +205,8 @@ func run() int { logger.Printf("password reset applied; all existing sessions were revoked") } runtimeCfg := applyRuntimeConfig(ctx) + update.Init(ctx, update.Options{BaseDir: baseDir, DatabasePath: cfg.DatabasePath, + Version: common.Version, Release: buildMode == "release", Container: common.ContainerMode(), Stop: stop}) allowLANAccess := configBool(runtimeCfg["allow_lan_access"]) && !common.ContainerMode() var activeDirs []models.Directory @@ -268,6 +290,7 @@ func run() int { actualPort := listener.Addr().(*net.TCPAddr).Port displayURL := fmt.Sprintf("http://localhost:%d", actualPort) logger.Printf("server listening on %s", listener.Addr().String()) + update.AcknowledgeStartup(baseDir, common.Version) if err := serveWithReleaseControls(ctx, stop, displayURL, "", logger, func() error { return api.ServeHTTP(ctx, srv, listener, allowLANAccess, !common.ContainerMode()) }); err != nil { @@ -289,6 +312,8 @@ func resolveClientServerURL(flagValue, configuredValue string) string { } func runClientMode(ctx context.Context, stop context.CancelFunc, baseDir, serverURL string, configuredPort int, logger *log.Logger) error { + update.Init(ctx, update.Options{BaseDir: baseDir, Version: common.Version, + Release: buildMode == "release", Container: common.ContainerMode(), Stop: stop}) port := configuredPort if port == 0 { if buildMode == "release" && !common.ContainerMode() { @@ -330,6 +355,7 @@ func runClientMode(ctx context.Context, stop context.CancelFunc, baseDir, server defer listener.Close() displayURL := fmt.Sprintf("http://localhost:%d", port) logger.Printf("client mode listening on %s, remote server %s", listenAddr, strings.TrimSpace(serverURL)) + update.AcknowledgeStartup(baseDir, common.Version) if err := serveWithReleaseControls(ctx, stop, displayURL, serverURL, logger, func() error { return api.ServeHTTP(ctx, srv, listener, false, false) }); err != nil { diff --git a/go.mod b/go.mod index fb62d374..c73a519a 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/shirou/gopsutil/v4 v4.25.9 github.com/urfave/cli/v3 v3.13.0 golang.org/x/crypto v0.40.0 + golang.org/x/mod v0.25.0 golang.org/x/net v0.42.0 golang.org/x/sys v0.35.0 golang.org/x/term v0.33.0 @@ -88,7 +89,6 @@ require ( go.uber.org/mock v0.5.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/arch v0.20.0 // indirect - golang.org/x/mod v0.25.0 // indirect golang.org/x/sync v0.16.0 // indirect golang.org/x/text v0.27.0 // indirect golang.org/x/tools v0.34.0 // indirect diff --git a/internal/api/api.go b/internal/api/api.go index 11b1f3dc..40848b83 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -3,11 +3,22 @@ package api import ( "net/http" + "javboss/internal/update" + "github.com/gin-gonic/gin" ) // RegisterRoutes wires handlers onto the provided router. func RegisterRoutes(router gin.IRoutes) { + // Update status/check/install have no query parameters. Installation uses the + // official release selected by the server and requires normal session auth. + for _, route := range []struct{ method, path string }{ + {http.MethodGet, "/system/update"}, + {http.MethodPost, "/system/update/check"}, + {http.MethodPost, "/system/update/install"}, + } { + router.Handle(route.method, route.path, func(c *gin.Context) { update.ServeHTTP(c.Writer, c.Request, "/system/update") }) + } router.GET("/auth/extension-tokens", listExtensionTokens) router.POST("/auth/extension-tokens", createExtensionToken) router.POST("/auth/extension-tokens/:id/rotate", rotateExtensionToken) diff --git a/internal/api/update_api_test.go b/internal/api/update_api_test.go new file mode 100644 index 00000000..78d9a0d0 --- /dev/null +++ b/internal/api/update_api_test.go @@ -0,0 +1,33 @@ +package api + +import ( + "net/http" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestUpdateEndpointsRequireSessionAndSameOrigin(t *testing.T) { + deps := newTestDependencies() + gin.SetMode(gin.TestMode) + router := NewRouter("", testAuthService(deps, t)) + for _, tc := range []struct{ method, path string }{ + {http.MethodGet, "/system/update"}, + {http.MethodPost, "/system/update/check"}, + {http.MethodPost, "/system/update/install"}, + } { + response := performRequest(router, tc.method, tc.path, nil, "", nil) + if response.Code != http.StatusUnauthorized { + t.Fatalf("%s %s status=%d", tc.method, tc.path, response.Code) + } + } + login := performRequest(router, http.MethodPost, "/auth/login", []byte(`{"password":"admin"}`), "http://example.com", nil) + if login.Code != http.StatusOK { + t.Fatalf("login=%d %s", login.Code, login.Body.String()) + } + cookie := login.Result().Cookies()[0] + response := performRequest(router, http.MethodPost, "/system/update/install", nil, "http://attacker.example", cookie) + if response.Code != http.StatusForbidden { + t.Fatalf("cross-origin update status=%d", response.Code) + } +} diff --git a/internal/client/client.go b/internal/client/client.go index d63844fc..661a7619 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -18,6 +18,7 @@ import ( "javboss/internal/common/logging" "javboss/internal/mpv" + "javboss/internal/update" ) const maxClientRequestBody = 1 << 20 @@ -137,6 +138,8 @@ func (c *Client) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } switch { + case r.URL.Path == "/__client/update" || strings.HasPrefix(r.URL.Path, "/__client/update/"): + update.ServeHTTP(w, r, "/__client/update") case r.URL.Path == "/healthz": c.handleHealth(w) case strings.HasPrefix(r.URL.Path, "/__client/media/"): diff --git a/internal/client/update_test.go b/internal/client/update_test.go new file mode 100644 index 00000000..e5ef6887 --- /dev/null +++ b/internal/client/update_test.go @@ -0,0 +1,44 @@ +package client + +import ( + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" +) + +func TestClientUpdateNeverForwardsLocalInstall(t *testing.T) { + var requests atomic.Int32 + remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests.Add(1); w.WriteHeader(http.StatusOK) })) + defer remote.Close() + client := newTestClient(t, remote.URL, nil) + for _, path := range []string{"/__client/update", "/__client/update/check", "/__client/update/install"} { + method := http.MethodPost + if path == "/__client/update" { + method = http.MethodGet + } + req := httptest.NewRequest(method, "http://127.0.0.1"+path, nil) + req.Header.Set("Origin", "http://127.0.0.1") + response := httptest.NewRecorder() + client.ServeHTTP(response, req) + if response.Code != http.StatusServiceUnavailable { + t.Fatalf("local status=%d", response.Code) + } + } + if requests.Load() != 0 { + t.Fatal("local update reached remote server") + } + req := httptest.NewRequest(http.MethodPost, "http://127.0.0.1/__client/update/install", nil) + req.Header.Set("Origin", "http://attacker.example") + response := httptest.NewRecorder() + client.ServeHTTP(response, req) + if response.Code != http.StatusForbidden { + t.Fatalf("cross-origin status=%d", response.Code) + } + req = httptest.NewRequest(http.MethodGet, "http://127.0.0.1/system/update", nil) + response = httptest.NewRecorder() + client.ServeHTTP(response, req) + if requests.Load() != 1 { + t.Fatal("remote update status was not proxied") + } +} diff --git a/internal/update/archive.go b/internal/update/archive.go new file mode 100644 index 00000000..749a9ec5 --- /dev/null +++ b/internal/update/archive.go @@ -0,0 +1,189 @@ +package update + +import ( + "archive/zip" + "context" + "fmt" + "io" + "net/http" + "os" + "path" + "path/filepath" + "strings" +) + +const maxArchiveSize int64 = 2 << 30 +const maxExpandedSize int64 = 4 << 30 + +type progressWriter struct { + io.Writer + report func(int64) + count int64 +} + +func (w *progressWriter) Write(p []byte) (int, error) { + n, err := w.Writer.Write(p) + w.count += int64(n) + w.report(w.count) + return n, err +} + +func downloadArchive(ctx context.Context, client *http.Client, a asset, target string, progress func(int64, int64)) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.URL, nil) + if err != nil { + return err + } + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("download release: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download release: HTTP %d", resp.StatusCode) + } + if resp.ContentLength > maxArchiveSize { + return fmt.Errorf("release archive exceeds size limit") + } + total := resp.ContentLength + if total < 0 { + total = 0 + } + progress(0, total) + f, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { + return err + } + defer f.Close() + w := &progressWriter{Writer: f, report: func(n int64) { progress(n, total) }} + n, err := io.Copy(w, io.LimitReader(resp.Body, maxArchiveSize+1)) + if err != nil { + return fmt.Errorf("download release: %w", err) + } + if n == 0 || n > maxArchiveSize { + return fmt.Errorf("invalid release archive size: %d", n) + } + if resp.ContentLength >= 0 && n != resp.ContentLength { + return fmt.Errorf("release size mismatch: got %d, want %d", n, resp.ContentLength) + } + return f.Sync() +} + +// Never extract links or allow a ZIP entry to escape the staging directory. +// Packaged config.toml is deliberately excluded from installation below. +func extractArchive(ctx context.Context, archive, dest, root string) error { + r, err := zip.OpenReader(archive) + if err != nil { + return fmt.Errorf("open release archive: %w", err) + } + defer r.Close() + if len(r.File) > 50000 { + return fmt.Errorf("too many archive entries") + } + seen := make(map[string]bool) + var expanded int64 + for _, entry := range r.File { + if err := ctx.Err(); err != nil { + return err + } + name := strings.TrimSuffix(entry.Name, "/") + if strings.ContainsAny(name, `\:`) || path.Clean(name) != name || + (name != root && !strings.HasPrefix(name, root+"/")) { + return fmt.Errorf("invalid archive path %q", entry.Name) + } + for _, component := range strings.Split(name, "/") { + if !portableArchiveName(component) { + return fmt.Errorf("unsupported archive path %q", entry.Name) + } + } + rel := strings.TrimPrefix(strings.TrimPrefix(name, root), "/") + key := strings.ToLower(rel) + if seen[key] { + return fmt.Errorf("duplicate archive path %q", entry.Name) + } + seen[key] = true + mode := entry.Mode() + if !mode.IsRegular() && !mode.IsDir() { + return fmt.Errorf("unsupported archive entry %q", entry.Name) + } + target := filepath.Join(dest, filepath.FromSlash(rel)) + if mode.IsDir() { + if err := os.MkdirAll(target, 0755); err != nil { + return err + } + continue + } + if entry.UncompressedSize64 > uint64(maxExpandedSize-expanded) { + return fmt.Errorf("release archive is too large") + } + expanded += int64(entry.UncompressedSize64) + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return err + } + if err := extractFile(entry, target); err != nil { + return err + } + } + return nil +} + +// Windows strips trailing spaces/dots and recognizes device names even with an +// extension. Reject these on every platform so an archive cannot alias another +// path (in particular a parent directory) when installed on Windows. +func portableArchiveName(name string) bool { + if name == "" || strings.TrimRight(name, ". ") != name { + return false + } + for _, c := range name { + if c < 32 || strings.ContainsRune(`<>"|?*`, c) { + return false + } + } + base, _, _ := strings.Cut(strings.ToUpper(name), ".") + switch base { + case "CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$": + return false + } + if len(base) == 4 && (strings.HasPrefix(base, "COM") || strings.HasPrefix(base, "LPT")) && base[3] >= '1' && base[3] <= '9' { + return false + } + return true +} + +func extractFile(entry *zip.File, target string) error { + src, err := entry.Open() + if err != nil { + return err + } + defer src.Close() + mode := os.FileMode(0644) + if entry.Mode().Perm()&0111 != 0 { + mode = 0755 + } + dst, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode) + if err != nil { + return err + } + _, copyErr := io.Copy(dst, io.LimitReader(src, int64(entry.UncompressedSize64)+1)) + closeErr := dst.Close() + if copyErr != nil { + return copyErr + } + return closeErr +} + +func executableName() string { + if platformOS() == "windows" { + return "javboss.exe" + } + return "javboss" +} + +func validatePayload(root string) error { + for _, rel := range []string{executableName(), "web/dist/index.html"} { + info, err := os.Lstat(filepath.Join(root, filepath.FromSlash(rel))) + if err != nil || !info.Mode().IsRegular() || info.Size() == 0 { + return fmt.Errorf("release is missing %s", rel) + } + } + return os.Chmod(filepath.Join(root, executableName()), 0755) +} diff --git a/internal/update/helper.go b/internal/update/helper.go new file mode 100644 index 00000000..d60b80d7 --- /dev/null +++ b/internal/update/helper.go @@ -0,0 +1,319 @@ +package update + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "javboss/internal/common/logging" + "javboss/internal/util" + + _ "github.com/mattn/go-sqlite3" + "github.com/shirou/gopsutil/v4/process" +) + +const readyEnvironment = "JAVBOSS_UPDATE_READY" + +type installPlan struct { + BaseDir string `json:"base_dir"` + StageDir string `json:"stage_dir"` + ParentPID int `json:"parent_pid"` + Args []string `json:"args"` + DatabasePath string `json:"database_path,omitempty"` + Version string `json:"version"` +} + +type Result struct { + Version string `json:"version"` + Error string `json:"error,omitempty"` + FinishedAt int64 `json:"finished_at"` +} + +// AcquireRuntimeLock also protects client mode. Holding this for the process +// lifetime prevents another application instance from entering during replacement. +func AcquireRuntimeLock(base string) (*util.FileLock, error) { + if err := os.MkdirAll(filepath.Join(base, "data"), 0755); err != nil { + return nil, err + } + return util.AcquireFileLock(filepath.Join(base, "data", "update-runtime.lock")) +} + +// AcknowledgeStartup is called only after the DB migrations and listener setup +// succeed. The helper retains backups on timeout; it never restores a database +// behind a running new process or discards new user writes. +func AcknowledgeStartup(base, version string) { + marker := os.Getenv(readyEnvironment) + _ = os.Unsetenv(readyEnvironment) + if marker == "" { + return + } + if resolved, err := filepath.EvalSymlinks(base); err == nil { + base = resolved + } + parent, err := filepath.Rel(filepath.Join(base, ".updates"), marker) + if err != nil || parent == ".." || strings.HasPrefix(parent, ".."+string(os.PathSeparator)) || filepath.IsAbs(parent) { + return + } + _ = os.WriteFile(marker, []byte(version), 0600) +} + +// RunHelper must be dispatched before flags, logging, tray setup, or DB access. +func RunHelper(planPath string) error { + var plan installPlan + if err := readJSON(planPath, &plan); err != nil { + return err + } + if err := validatePlan(planPath, plan); err != nil { + return err + } + err := applyPlan(plan) + result := Result{Version: plan.Version, FinishedAt: time.Now().UnixMilli()} + if err != nil { + result.Error = err.Error() + } + if writeErr := writeJSON(filepath.Join(plan.BaseDir, "data", "update-result.json"), result); writeErr != nil { + return errors.Join(err, writeErr) + } + return err +} + +func validatePlan(planPath string, plan installPlan) error { + if !filepath.IsAbs(plan.BaseDir) || !filepath.IsAbs(plan.StageDir) || plan.ParentPID <= 0 || !releaseTag.MatchString(plan.Version) { + return errors.New("invalid update plan") + } + if filepath.Dir(plan.StageDir) != filepath.Join(plan.BaseDir, ".updates") || filepath.Clean(planPath) != filepath.Join(plan.StageDir, "plan.json") { + return errors.New("update plan is outside staging directory") + } + exe, err := os.Executable() + if err != nil || filepath.Dir(exe) != plan.StageDir { + return errors.New("update helper must run from staging directory") + } + return validatePayload(filepath.Join(plan.StageDir, "payload")) +} + +func applyPlan(plan installPlan) error { + parent, err := process.NewProcess(int32(plan.ParentPID)) + if err != nil { + return fmt.Errorf("find original application: %w", err) + } + // Cache creation time before telling the parent it can exit (PID reuse). + if _, err := parent.CreateTime(); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(plan.StageDir, "helper-ready"), []byte("ready"), 0600); err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + if err := waitForExit(ctx, parent); err != nil { + return fmt.Errorf("application did not exit; no files changed: %w", err) + } + lock, err := AcquireRuntimeLock(plan.BaseDir) + if err != nil { + return fmt.Errorf("another application instance started; no files changed: %w", err) + } + locked := true + defer func() { + if locked { + _ = lock.Release() + } + }() + backup := filepath.Join(plan.StageDir, "backup") + restartOld := func(cause error) error { + _ = lock.Release() + locked = false + cmd, startErr := startDetached(filepath.Join(plan.BaseDir, executableName()), plan.Args, plan.BaseDir, "") + if startErr == nil { + _ = cmd.Process.Release() + } + return errors.Join(cause, startErr) + } + if err := os.MkdirAll(backup, 0700); err != nil { + return restartOld(err) + } + if plan.DatabasePath != "" { + if err := backupDatabase(plan.DatabasePath, filepath.Join(backup, "database.sqlite")); err != nil { + return restartOld(fmt.Errorf("database backup failed; no files changed: %w", err)) + } + } + payload := filepath.Join(plan.StageDir, "payload") + if err := os.WriteFile(filepath.Join(payload, ".version"), []byte(plan.Version+"\n"), 0644); err != nil { + return restartOld(err) + } + transaction, err := replacePayload(plan.BaseDir, payload, backup) + if err != nil { + if transaction.rollbackFailed { + return fmt.Errorf("update recovery needs manual attention in %s: %w", backup, err) + } + return restartOld(fmt.Errorf("update files restored: %w", err)) + } + _ = lock.Release() + locked = false + marker := filepath.Join(plan.StageDir, "application-ready") + cmd, err := startDetached(filepath.Join(plan.BaseDir, executableName()), plan.Args, plan.BaseDir, marker) + if err != nil { + // Start failed before a new process existed: file-only rollback is safe. + lock, lockErr := AcquireRuntimeLock(plan.BaseDir) + if lockErr != nil { + return errors.Join(err, lockErr) + } + rollbackErr := transaction.rollback() + _ = lock.Release() + if rollbackErr != nil { + return errors.Join(err, rollbackErr) + } + old, startErr := startDetached(filepath.Join(plan.BaseDir, executableName()), plan.Args, plan.BaseDir, "") + if startErr == nil { + _ = old.Process.Release() + } + return errors.Join(err, startErr) + } + _ = cmd.Process.Release() + startupCtx, startupCancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer startupCancel() + if err := waitFile(startupCtx, marker, plan.Version); err != nil { + return fmt.Errorf("new version did not confirm startup; backup retained in %s (database was not automatically restored): %w", backup, err) + } + // The new version is ready; its backup is no longer needed. Cleanup failure + // must not turn a successful update into a failed one. + if err := os.RemoveAll(backup); err != nil { + logging.Error("update succeeded but backup cleanup failed for %s: %v", backup, err) + } + // Discard previous successful update directories after startup is confirmed. + entries, _ := os.ReadDir(filepath.Dir(plan.StageDir)) + for _, entry := range entries { + old := filepath.Join(filepath.Dir(plan.StageDir), entry.Name()) + if entry.IsDir() && strings.HasPrefix(entry.Name(), "update-") && old != plan.StageDir { + if _, err := os.Stat(filepath.Join(old, "application-ready")); err == nil { + _ = os.RemoveAll(old) + } + } + } + return nil +} + +func waitForExit(ctx context.Context, parent *process.Process) error { + ticker := time.NewTicker(200 * time.Millisecond) + defer ticker.Stop() + for { + running, err := parent.IsRunning() + if err != nil { + return err + } + if !running { + return nil + } + states, _ := parent.Status() + for _, state := range states { + if state == process.Zombie { + return nil + } + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } +} + +func backupDatabase(source, dest string) error { + if _, err := os.Stat(source); err != nil { + return err + } + database, err := sql.Open("sqlite3", databaseURI(source)) + if err != nil { + return err + } + defer database.Close() + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + _, err = database.ExecContext(ctx, "VACUUM INTO ?", dest) + return err +} + +func databaseURI(source string) string { + path := filepath.ToSlash(source) + // A Windows drive belongs in the URI path, not its authority (file:///C:/...). + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + u := url.URL{Scheme: "file", Path: path, RawQuery: "mode=rw&_busy_timeout=5000"} + return u.String() +} + +func waitFile(ctx context.Context, name, expected string) error { + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + for { + data, err := os.ReadFile(name) + if err == nil && string(data) == expected { + return nil + } + if err != nil && !os.IsNotExist(err) { + return err + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } +} + +func readJSON(name string, value any) error { + f, err := os.Open(name) + if err != nil { + return err + } + defer f.Close() + return json.NewDecoder(io.LimitReader(f, 1<<20)).Decode(value) +} + +func writeJSON(name string, value any) error { + data, err := json.Marshal(value) + if err != nil { + return err + } + f, err := os.CreateTemp(filepath.Dir(name), ".update-json-") + if err != nil { + return err + } + defer os.Remove(f.Name()) + if _, err := f.Write(data); err != nil { + f.Close() + return err + } + if err := f.Sync(); err != nil { + f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + return os.Rename(f.Name(), name) +} + +func copyFile(source, dest string, mode os.FileMode) error { + src, err := os.Open(source) + if err != nil { + return err + } + defer src.Close() + dst, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) + if err != nil { + return err + } + _, copyErr := io.Copy(dst, src) + syncErr := dst.Sync() + closeErr := dst.Close() + return errors.Join(copyErr, syncErr, closeErr) +} diff --git a/internal/update/helper_test.go b/internal/update/helper_test.go new file mode 100644 index 00000000..dd18010b --- /dev/null +++ b/internal/update/helper_test.go @@ -0,0 +1,137 @@ +package update + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + "time" +) + +// Exercise real process handoff, lock release, replacement, restart arguments, +// and readiness without downloading or replacing the developer's installation. +func TestHelperReplacesOnlyAfterParentExitAndRestarts(t *testing.T) { + base := t.TempDir() + stage := filepath.Join(base, ".updates", "update-test") + payload := filepath.Join(stage, "payload") + putFile(t, payload, "web/dist/index.html", "new frontend") + putFile(t, base, "web/dist/index.html", "old frontend") + putFile(t, base, "config.toml", "user settings") + putFile(t, base, executableName(), "old executable") + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + if err := copyFile(self, filepath.Join(payload, executableName()), 0755); err != nil { + t.Fatal(err) + } + helperPath := filepath.Join(stage, executableName()) + if err := copyFile(self, helperPath, 0755); err != nil { + t.Fatal(err) + } + planPath := filepath.Join(stage, "plan.json") + plan := installPlan{BaseDir: base, StageDir: stage, Version: "v2.0.0", Args: []string{"-test.run=^TestUpdateStartedSubprocess$"}} + if err := writeJSON(planPath, plan); err != nil { + t.Fatal(err) + } + env := append(os.Environ(), "JAVBOSS_TEST_UPDATE_PLAN="+planPath) + parent := exec.Command(self, "-test.run=^TestUpdateParentSubprocess$") + parent.Env = env + if err := parent.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = parent.Process.Kill() }) + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second) + defer cancel() + if err := waitFile(ctx, filepath.Join(stage, "parent-ready"), "ready"); err != nil { + t.Fatal(err) + } + plan.ParentPID = parent.Process.Pid + if err := writeJSON(planPath, plan); err != nil { + t.Fatal(err) + } + helper := exec.Command(helperPath, "-test.run=^TestUpdateHelperSubprocess$") + helper.Env = env + if err := helper.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = helper.Process.Kill() }) + if err := waitFile(ctx, filepath.Join(stage, "helper-ready"), "ready"); err != nil { + t.Fatal(err) + } + requireFile(t, base, executableName(), "old executable") + requireFile(t, base, "web/dist/index.html", "old frontend") + putFile(t, stage, "parent-exit", "exit") + if err := parent.Wait(); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- helper.Wait() }() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + requireFile(t, base, "web/dist/index.html", "new frontend") + requireFile(t, base, "config.toml", "user settings") + requireFile(t, base, ".version", "v2.0.0\n") + requireFile(t, stage, "application-ready", "v2.0.0") + if _, err := os.Stat(filepath.Join(stage, "backup")); !os.IsNotExist(err) { + t.Fatalf("backup remained after successful startup: %v", err) + } + var result Result + if err := readJSON(filepath.Join(base, "data/update-result.json"), &result); err != nil || result.Error != "" || result.Version != "v2.0.0" { + t.Fatalf("result=%+v err=%v", result, err) + } +} + +func subprocessPlan(t *testing.T) (string, installPlan) { + t.Helper() + path := os.Getenv("JAVBOSS_TEST_UPDATE_PLAN") + if path == "" { + t.Skip("subprocess only") + } + var plan installPlan + if err := readJSON(path, &plan); err != nil { + t.Fatal(err) + } + return path, plan +} + +func TestUpdateParentSubprocess(t *testing.T) { + _, plan := subprocessPlan(t) + lock, err := AcquireRuntimeLock(plan.BaseDir) + if err != nil { + t.Fatal(err) + } + defer lock.Release() + putFile(t, plan.StageDir, "parent-ready", "ready") + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second) + defer cancel() + if err := waitFile(ctx, filepath.Join(plan.StageDir, "parent-exit"), "exit"); err != nil { + t.Fatal(err) + } +} + +func TestUpdateHelperSubprocess(t *testing.T) { + path, _ := subprocessPlan(t) + if err := RunHelper(path); err != nil { + t.Fatal(err) + } +} + +func TestUpdateStartedSubprocess(t *testing.T) { + _, plan := subprocessPlan(t) + lock, err := AcquireRuntimeLock(plan.BaseDir) + if err != nil { + t.Fatal(err) + } + defer lock.Release() + requireFile(t, plan.BaseDir, "config.toml", "user settings") + requireFile(t, plan.StageDir, "backup/files/"+executableName(), "old executable") + AcknowledgeStartup(plan.BaseDir, "v2.0.0") +} diff --git a/internal/update/manager.go b/internal/update/manager.go new file mode 100644 index 00000000..4c91728e --- /dev/null +++ b/internal/update/manager.go @@ -0,0 +1,328 @@ +// Package update checks official releases and stages desktop updates. The helper +// runs before normal application initialization and never opens the application DB +// through db.Open (which would execute migrations). +package update + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "sync/atomic" + "time" + + "javboss/internal/common/logging" + "javboss/internal/util" + + "golang.org/x/mod/semver" +) + +type Status struct { + CurrentVersion string `json:"current_version"` + LatestVersion string `json:"latest_version,omitempty"` + Available bool `json:"available"` + CanInstall bool `json:"can_install"` + DisabledReason string `json:"disabled_reason,omitempty"` + Phase string `json:"phase"` + ReleaseURL string `json:"release_url,omitempty"` + Notes string `json:"notes,omitempty"` + NotesHTML string `json:"notes_html,omitempty"` + CheckedAt int64 `json:"checked_at,omitempty"` + Downloaded int64 `json:"downloaded_bytes"` + Total int64 `json:"total_bytes"` + Error string `json:"error,omitempty"` + LastResult *Result `json:"last_result,omitempty"` +} + +type Options struct { + BaseDir string + DatabasePath string // Empty for client mode. + Version string + Release bool + Container bool + Stop context.CancelFunc +} + +type Manager struct { + mu sync.Mutex + ctx context.Context + options Options + client *http.Client + endpoint string + platform string + status Status + selected asset +} + +var active atomic.Pointer[Manager] + +func platformOS() string { return runtime.GOOS } + +func newManager(ctx context.Context, options Options) *Manager { + m := &Manager{ctx: ctx, options: options, client: util.NewHTTPClient(0), + endpoint: "https://github.com/" + repository + "/releases/latest", platform: platformName(runtime.GOOS, runtime.GOARCH), + status: Status{CurrentVersion: options.Version, Phase: "idle"}} + switch { + case !options.Release || !releaseTag.MatchString(options.Version): + m.status.DisabledReason = "development" + case options.Container: + m.status.DisabledReason = "container" + case m.platform == "": + m.status.DisabledReason = "platform" + } + return m +} + +// Init installs the process-local updater. Checks never close the application; +// installing always requires the explicit POST from the confirmation dialog. +func Init(ctx context.Context, options Options) { + m := newManager(ctx, options) + active.Store(m) + if m.status.DisabledReason == "development" { + return + } + go func() { + m.StartCheck() + ticker := time.NewTicker(24 * time.Hour) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + m.StartCheck() + } + } + }() +} + +func (m *Manager) Status() Status { + m.mu.Lock() + result := m.status + m.mu.Unlock() + var last Result + if readJSON(filepath.Join(m.options.BaseDir, "data", "update-result.json"), &last) == nil { + result.LastResult = &last + } + return result +} + +func busy(phase string) bool { return phase != "idle" && phase != "error" } + +func (m *Manager) StartCheck() error { + m.mu.Lock() + defer m.mu.Unlock() + if busy(m.status.Phase) { + return errors.New("an update operation is already running") + } + if m.ctx.Err() != nil { + return m.ctx.Err() + } + m.status.Phase, m.status.Error = "checking", "" + go m.check() + return nil +} + +func (m *Manager) check() { + ctx, cancel := context.WithTimeout(m.ctx, 30*time.Second) + defer cancel() + r, err := latestRelease(ctx, m.client, m.endpoint) + var notes releaseNotes + if err == nil && r != nil { + notesCtx, notesCancel := context.WithTimeout(ctx, 10*time.Second) + var notesErr error + notes, notesErr = readReleaseNotes(notesCtx, m.client, strings.TrimSuffix(m.endpoint, "/latest")+"/tag/"+r.Tag) + notesCancel() + if notesErr != nil { + logging.Error("read release notes for %s: %v", r.Tag, notesErr) + } + } + m.mu.Lock() + defer m.mu.Unlock() + m.status.Phase = "idle" + m.status.CanInstall = false + m.selected = asset{} + if err != nil { + m.status.Phase, m.status.Error = "error", err.Error() + return + } + m.status.CheckedAt = time.Now().UnixMilli() + m.status.Available, m.status.CanInstall = false, false + m.status.LatestVersion, m.status.Notes, m.status.NotesHTML, m.status.ReleaseURL = "", "", "", "" + if r == nil { + return + } + m.status.LatestVersion, m.status.Notes, m.status.NotesHTML = r.Tag, notes.Text, notes.HTML + m.status.ReleaseURL = "https://github.com/" + repository + "/releases/tag/" + r.Tag + m.status.Available = semver.IsValid(m.options.Version) && semver.Compare(r.Tag, m.options.Version) > 0 + if !m.status.Available || m.status.DisabledReason != "" { + return + } + selected, err := releaseAsset(r, m.platform) + if err != nil { + m.status.Error = err.Error() + return + } + m.selected, m.status.CanInstall = selected, true +} + +func (m *Manager) StartInstall() error { + m.mu.Lock() + defer m.mu.Unlock() + if busy(m.status.Phase) { + return errors.New("an update operation is already running") + } + if !m.status.CanInstall || !m.status.Available || m.options.Stop == nil { + return errors.New("no installable update is available") + } + if m.ctx.Err() != nil { + return m.ctx.Err() + } + a, version := m.selected, m.status.LatestVersion + m.status.Phase, m.status.Error = "downloading", "" + m.status.Downloaded, m.status.Total = 0, 0 + go func() { + if err := m.prepare(a, version); err != nil { + logging.Error("application update failed: %v", err) + m.mu.Lock() + m.status.Phase, m.status.Error = "error", err.Error() + m.mu.Unlock() + } + }() + return nil +} + +func (m *Manager) prepare(a asset, version string) error { + executable, err := os.Executable() + if err != nil { + return err + } + executable, err = filepath.EvalSymlinks(executable) + if err != nil { + return err + } + base, err := filepath.EvalSymlinks(m.options.BaseDir) + if err != nil { + return err + } + if executable != filepath.Join(base, executableName()) { + return errors.New("updating a renamed or relocated executable is not supported") + } + updates := filepath.Join(base, ".updates") + if err := os.MkdirAll(updates, 0700); err != nil { + return fmt.Errorf("create update directory: %w", err) + } + lock, err := util.AcquireFileLock(filepath.Join(updates, "download.lock")) + if err != nil { + return fmt.Errorf("another updater is active: %w", err) + } + defer lock.Release() + stage, err := os.MkdirTemp(updates, "update-") + if err != nil { + return err + } + handedOff := false + defer func() { + if !handedOff { + os.RemoveAll(stage) + } + }() + ctx, cancel := context.WithTimeout(m.ctx, 30*time.Minute) + defer cancel() + archive := filepath.Join(stage, "release.zip") + if err := downloadArchive(ctx, m.client, a, archive, func(n, total int64) { + m.mu.Lock() + m.status.Downloaded, m.status.Total = n, total + m.mu.Unlock() + }); err != nil { + return err + } + m.mu.Lock() + m.status.Phase = "preparing" + m.mu.Unlock() + payload := filepath.Join(stage, "payload") + if err := extractArchive(ctx, archive, payload, "javboss-"+version+"-"+m.platform); err != nil { + return err + } + if err := validatePayload(payload); err != nil { + return err + } + if err := os.Remove(archive); err != nil { + return err + } + helper := filepath.Join(stage, "helper") + if runtime.GOOS == "windows" { + helper += ".exe" + } + if err := copyFile(executable, helper, 0700); err != nil { + return err + } + plan := installPlan{BaseDir: base, StageDir: stage, ParentPID: os.Getpid(), Args: os.Args[1:], + DatabasePath: m.options.DatabasePath, Version: version} + planPath := filepath.Join(stage, "plan.json") + if err := writeJSON(planPath, plan); err != nil { + return err + } + cmd, err := startDetached(helper, []string{"--apply-update", planPath}, stage, "") + if err != nil { + return err + } + readyCtx, readyCancel := context.WithTimeout(ctx, 15*time.Second) + defer readyCancel() + if err := waitFile(readyCtx, filepath.Join(stage, "helper-ready"), "ready"); err != nil { + _ = cmd.Process.Kill() + _ = cmd.Wait() + return fmt.Errorf("update helper did not start: %w", err) + } + _ = cmd.Process.Release() + handedOff = true + m.mu.Lock() + m.status.Phase = "restarting" + m.mu.Unlock() + // Exactly the same cancellation used by the tray/keyboard Exit action. + m.options.Stop() + return nil +} + +// ServeHTTP is shared by authenticated server routes and local client routes. +// The caller owns authentication and same-origin checks. No URL/path is accepted +// from the request: installation only uses the release selected by this process. +func ServeHTTP(w http.ResponseWriter, r *http.Request, prefix string) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + m := active.Load() + if m == nil { + writeHTTPError(w, http.StatusServiceUnavailable, "更新服务不可用", "Updater is unavailable") + return + } + var err error + switch { + case r.Method == http.MethodGet && r.URL.Path == prefix: + case r.Method == http.MethodPost && r.URL.Path == prefix+"/check": + err = m.StartCheck() + case r.Method == http.MethodPost && r.URL.Path == prefix+"/install": + err = m.StartInstall() + default: + writeHTTPError(w, http.StatusNotFound, "接口不存在", "Endpoint not found") + return + } + if err != nil { + writeHTTPError(w, http.StatusConflict, "无法执行更新操作:"+err.Error(), err.Error()) + return + } + if r.Method == http.MethodPost { + w.WriteHeader(http.StatusAccepted) + } + _ = json.NewEncoder(w).Encode(m.Status()) +} + +func writeHTTPError(w http.ResponseWriter, status int, zh, en string) { + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{"error_zh": zh, "error_en": en}) +} diff --git a/internal/update/notes.go b/internal/update/notes.go new file mode 100644 index 00000000..9843142c --- /dev/null +++ b/internal/update/notes.go @@ -0,0 +1,116 @@ +package update + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/PuerkitoBio/goquery" + "golang.org/x/net/html" +) + +type releaseNotes struct { + Text string + HTML string +} + +// Read the selected tag's public page, independently of API access or assets. +// Keep both formats for older clients; clients must sanitize HTML before rendering. +func readReleaseNotes(ctx context.Context, client *http.Client, endpoint string) (releaseNotes, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return releaseNotes{}, err + } + req.Header.Set("User-Agent", "JavBoss-Updater") + resp, err := client.Do(req) + if err != nil { + return releaseNotes{}, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return releaseNotes{}, fmt.Errorf("release page: HTTP %d", resp.StatusCode) + } + const maxPageSize = 4 << 20 + data, err := io.ReadAll(io.LimitReader(resp.Body, maxPageSize+1)) + if err != nil { + return releaseNotes{}, err + } + if len(data) > maxPageSize { + return releaseNotes{}, fmt.Errorf("release page is too large") + } + doc, err := goquery.NewDocumentFromReader(bytes.NewReader(data)) + if err != nil { + return releaseNotes{}, err + } + body := doc.Find(`[data-test-selector="body-content"].markdown-body`).First() + if body.Length() == 0 { + return releaseNotes{}, fmt.Errorf("release notes not found on release page") + } + // Relative GitHub links/images must not resolve against the JavBoss host. + base, err := url.Parse(endpoint) + if err != nil { + return releaseNotes{}, err + } + body.Find("[href], [src]").Each(func(_ int, element *goquery.Selection) { + for _, attr := range []string{"href", "src"} { + if raw, ok := element.Attr(attr); ok { + if link, err := url.Parse(raw); err == nil { + element.SetAttr(attr, base.ResolveReference(link).String()) + } else { + element.RemoveAttr(attr) + } + } + } + }) + markup, err := body.Html() + if err != nil { + return releaseNotes{}, err + } + var text strings.Builder + var walk func(*html.Node) + walk = func(node *html.Node) { + if node.Type == html.TextNode { + text.WriteString(node.Data) + return + } + if node.Type != html.ElementNode { + return + } + switch node.Data { + case "script", "style", "button", "svg": + return + case "br": + text.WriteByte('\n') + return + } + block := false + switch node.Data { + case "div", "p", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "li", "pre", "blockquote", "tr": + block = true + text.WriteByte('\n') + } + if node.Data == "li" { + text.WriteString("• ") + } + for child := node.FirstChild; child != nil; child = child.NextSibling { + walk(child) + } + if block { + text.WriteByte('\n') + } else if node.Data == "td" || node.Data == "th" { + text.WriteByte('\t') + } + } + walk(body.Get(0)) + lines := make([]string, 0) + for _, line := range strings.Split(text.String(), "\n") { + if line = strings.TrimSpace(line); line != "" { + lines = append(lines, line) + } + } + return releaseNotes{Text: strings.Join(lines, "\n"), HTML: markup}, nil +} diff --git a/internal/update/notes_test.go b/internal/update/notes_test.go new file mode 100644 index 00000000..e3656083 --- /dev/null +++ b/internal/update/notes_test.go @@ -0,0 +1,99 @@ +package update + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestReleaseNotesFromPublicPage(t *testing.T) { + for _, tc := range []struct { + name, page, want string + status int + bad bool + }{ + {"formatted", `
模型配置", `说明`} {
+ if !strings.Contains(notes.HTML, fragment) {
+ t.Fatalf("missing HTML fragment %q: %s", fragment, notes.HTML)
+ }
+ }
+ }
+ })
+ }
+}
+
+func TestReleaseNotesResolvesRelativeLinks(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write([]byte(``))
+ }))
+ defer server.Close()
+ notes, err := readReleaseNotes(t.Context(), server.Client(), server.URL+"/releases/tag/v2.1.3")
+ if err != nil || !strings.Contains(notes.HTML, `href="`+server.URL+`/repo/issues/1"`) || !strings.Contains(notes.HTML, `src="`+server.URL+`/releases/image.png"`) {
+ t.Fatalf("notes=%+v err=%v", notes, err)
+ }
+}
+
+func TestUnavailableNotesDoNotBlockInstall(t *testing.T) {
+ m := newManager(t.Context(), Options{BaseDir: t.TempDir(), Version: "v1.0.0", Release: true})
+ m.platform = "linux-x86_64"
+ m.status.Notes = "outdated notes"
+ m.status.NotesHTML = "outdated notes
" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodHead && r.URL.Path == "/releases/latest" { + w.Header().Set("Location", "https://github.com/"+repository+"/releases/tag/v2.1.3") + w.WriteHeader(http.StatusFound) + return + } + if r.Method != http.MethodGet || r.URL.Path != "/releases/tag/v2.1.3" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL) + } + w.WriteHeader(http.StatusForbidden) + })) + defer server.Close() + m.endpoint = server.URL + "/releases/latest" + m.client = server.Client() + m.check() + status := m.Status() + if !status.CanInstall || !status.Available || status.Error != "" || status.Notes != "" || status.NotesHTML != "" || status.LatestVersion != "v2.1.3" { + t.Fatalf("status=%+v", status) + } +} + +func TestReleaseNotesRequestCanBeCanceled(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cancel() + <-r.Context().Done() + })) + defer server.Close() + defer cancel() + if _, err := readReleaseNotes(ctx, server.Client(), server.URL); err == nil { + t.Fatal("canceled notes request succeeded") + } +} diff --git a/internal/update/process.go b/internal/update/process.go new file mode 100644 index 00000000..833ebf9b --- /dev/null +++ b/internal/update/process.go @@ -0,0 +1,41 @@ +package update + +import ( + "os" + "os/exec" + "path/filepath" + "strings" +) + +func startDetached(executable string, args []string, dir, readyFile string) (*exec.Cmd, error) { + logDir := filepath.Join(dir, "logs") + if err := os.MkdirAll(logDir, 0755); err != nil { + return nil, err + } + log, err := os.OpenFile(filepath.Join(logDir, "update.log"), os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0600) + if err != nil { + return nil, err + } + defer log.Close() + input, err := os.Open(os.DevNull) + if err != nil { + return nil, err + } + defer input.Close() + cmd := exec.Command(executable, args...) + cmd.Dir = dir + for _, env := range os.Environ() { + if !strings.HasPrefix(env, readyEnvironment+"=") && !strings.HasPrefix(env, "JAVBOSS_BACKGROUND_CHILD=") { + cmd.Env = append(cmd.Env, env) + } + } + if readyFile != "" { + cmd.Env = append(cmd.Env, readyEnvironment+"="+readyFile) + } + cmd.Stdin, cmd.Stdout, cmd.Stderr = input, log, log + detachProcess(cmd) + if err := cmd.Start(); err != nil { + return nil, err + } + return cmd, nil +} diff --git a/internal/update/process_unix.go b/internal/update/process_unix.go new file mode 100644 index 00000000..92d5fff8 --- /dev/null +++ b/internal/update/process_unix.go @@ -0,0 +1,10 @@ +//go:build !windows + +package update + +import ( + "os/exec" + "syscall" +) + +func detachProcess(cmd *exec.Cmd) { cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} } diff --git a/internal/update/process_windows.go b/internal/update/process_windows.go new file mode 100644 index 00000000..24597cca --- /dev/null +++ b/internal/update/process_windows.go @@ -0,0 +1,12 @@ +//go:build windows + +package update + +import ( + "os/exec" + "syscall" +) + +func detachProcess(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x00000008 | 0x00000200} +} diff --git a/internal/update/releases.go b/internal/update/releases.go new file mode 100644 index 00000000..305766f6 --- /dev/null +++ b/internal/update/releases.go @@ -0,0 +1,89 @@ +package update + +import ( + "context" + "fmt" + "net/http" + "net/url" + "regexp" + "strings" + + "golang.org/x/mod/semver" +) + +const repository = "Solr159/JavBoss" + +var releaseTag = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+$`) + +type asset struct { + Name string + URL string +} + +type release struct { + Tag string +} + +func platformName(goos, goarch string) string { + return map[string]string{ + "windows/amd64": "windows-x86_64", "linux/amd64": "linux-x86_64", + "darwin/amd64": "macos-x86_64", "darwin/arm64": "macos-arm64", + }[goos+"/"+goarch] +} + +// Read GitHub's Latest redirect without fetching its HTML or using the REST API. +// Fail closed if Latest was accidentally assigned to a browser extension release. +func latestRelease(ctx context.Context, client *http.Client, endpoint string) (*release, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodHead, endpoint, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "JavBoss-Updater") + redirectClient := *client + redirectClient.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + resp, err := redirectClient.Do(req) + if err != nil { + return nil, fmt.Errorf("check latest release: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return nil, nil + } + switch resp.StatusCode { + case http.StatusMovedPermanently, http.StatusFound, http.StatusSeeOther, http.StatusTemporaryRedirect, http.StatusPermanentRedirect: + default: + return nil, fmt.Errorf("check latest release: expected a redirect, got HTTP %d", resp.StatusCode) + } + location, err := url.Parse(resp.Header.Get("Location")) + if err != nil { + return nil, fmt.Errorf("invalid latest release redirect: %w", err) + } + // GitHub may return a relative Location. Resolve against the trusted website, + // not a caller-supplied Host or the redirected destination. + base, _ := url.Parse("https://github.com/" + repository + "/releases/latest") + location = base.ResolveReference(location) + prefix := "/" + repository + "/releases/tag/" + if location.Scheme != "https" || location.Host != "github.com" || location.User != nil || + location.RawQuery != "" || location.Fragment != "" || !strings.HasPrefix(location.Path, prefix) { + return nil, fmt.Errorf("latest release does not point to an official application release") + } + tag := strings.TrimPrefix(location.Path, prefix) + if !releaseTag.MatchString(tag) || !semver.IsValid(tag) { + return nil, fmt.Errorf("latest release is not a stable application version: %s", tag) + } + return &release{Tag: tag}, nil +} + +// Release archives have fixed names, so no API or manifest is needed. +func releaseAsset(r *release, platform string) (asset, error) { + if r == nil || !releaseTag.MatchString(r.Tag) || !semver.IsValid(r.Tag) { + return asset{}, fmt.Errorf("invalid application release version") + } + switch platform { + case "windows-x86_64", "linux-x86_64", "macos-x86_64", "macos-arm64": + default: + return asset{}, fmt.Errorf("unsupported release platform: %s", platform) + } + name := "javboss-" + r.Tag + "-" + platform + ".zip" + return asset{Name: name, URL: "https://github.com/" + repository + "/releases/download/" + r.Tag + "/" + name}, nil +} diff --git a/internal/update/releases_test.go b/internal/update/releases_test.go new file mode 100644 index 00000000..11a88e45 --- /dev/null +++ b/internal/update/releases_test.go @@ -0,0 +1,81 @@ +package update + +import ( + "archive/zip" + "bytes" + "errors" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" +) + +func TestFailedCheckClearsPreviousInstall(t *testing.T) { + m := newManager(t.Context(), Options{BaseDir: t.TempDir(), Version: "v1.0.0", Release: true}) + m.status.CanInstall = true + m.selected = asset{Name: "old.zip"} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer server.Close() + m.endpoint = server.URL + "/releases/latest" + m.client = server.Client() + m.check() + status := m.Status() + if status.CanInstall || status.Error == "" || m.selected.Name != "" { + t.Fatalf("status=%+v selected=%+v", status, m.selected) + } +} + +func TestDownloadFollowsAssetRedirectAfterLatestCheck(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/releases/latest": + w.Header().Set("Location", "https://github.com/"+repository+"/releases/tag/v2.1.4") + w.WriteHeader(http.StatusFound) + case "/release.zip": + http.Redirect(w, r, "/asset-content", http.StatusFound) + case "/asset-content": + _, _ = w.Write([]byte("archive contents")) + default: + t.Errorf("unexpected URL: %s", r.URL) + } + })) + defer server.Close() + client := server.Client() + if _, err := latestRelease(t.Context(), client, server.URL+"/releases/latest"); err != nil { + t.Fatal(err) + } + target := filepath.Join(t.TempDir(), "release.zip") + if err := downloadArchive(t.Context(), client, asset{URL: server.URL + "/release.zip"}, target, func(int64, int64) {}); err != nil { + t.Fatal(err) + } + requireFile(t, filepath.Dir(target), "release.zip", "archive contents") +} + +func TestExtractArchiveRejectsCorruptContents(t *testing.T) { + var buffer bytes.Buffer + writer := zip.NewWriter(&buffer) + entry, err := writer.CreateHeader(&zip.FileHeader{Name: "package/file", Method: zip.Store}) + if err != nil { + t.Fatal(err) + } + content := []byte("unique archive content") + if _, err := entry.Write(content); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + data := buffer.Bytes() + index := bytes.Index(data, content) + if index < 0 { + t.Fatal("missing stored content") + } + data[index] ^= 1 + root := t.TempDir() + archive := putFile(t, root, "corrupt.zip", string(data)) + if err := extractArchive(t.Context(), archive, filepath.Join(root, "payload"), "package"); !errors.Is(err, zip.ErrChecksum) { + t.Fatalf("expected ZIP checksum failure, got %v", err) + } +} diff --git a/internal/update/replace.go b/internal/update/replace.go new file mode 100644 index 00000000..a9d0e0d9 --- /dev/null +++ b/internal/update/replace.go @@ -0,0 +1,119 @@ +package update + +import ( + "errors" + "fmt" + "os" + "path/filepath" +) + +type replacedFile struct { + Target string `json:"target"` + Backup string `json:"backup"` + Old bool `json:"old"` + Installed bool `json:"installed"` +} + +type replacement struct { + files []*replacedFile + journal string + rename func(string, string) error + rollbackFailed bool +} + +func replacePayload(base, payload, backup string) (*replacement, error) { + t := &replacement{journal: filepath.Join(backup, "files.json"), rename: os.Rename} + return t, t.apply(base, payload, backup) +} + +func (t *replacement) apply(base, payload, backup string) (err error) { + defer func() { + if err != nil { + rollbackErr := t.rollback() + t.rollbackFailed = rollbackErr != nil + err = errors.Join(err, rollbackErr) + } + }() + // Only release-owned paths are replaced. Never copy default configuration or + // touch data/tools (user-installed FFmpeg), covers, videos, or client settings. + paths := []string{executableName(), "web/dist", "internal/bin/ffprobe", "internal/bin/ffprobe.exe", + "internal/bin/ffmpeg", "internal/bin/ffmpeg.exe", "internal/bin/mpv", "modernz", "javboss.command", ".version"} + for _, rel := range paths { + rel = filepath.FromSlash(rel) + src := filepath.Join(payload, rel) + if _, err := os.Lstat(src); os.IsNotExist(err) { + continue + } else if err != nil { + return err + } + if err := validateParents(base, rel); err != nil { + return err + } + target, saved := filepath.Join(base, rel), filepath.Join(backup, "files", rel) + if err := os.MkdirAll(filepath.Dir(saved), 0700); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return err + } + entry := &replacedFile{Target: target, Backup: saved} + t.files = append(t.files, entry) + if _, err := os.Lstat(target); err == nil { + if err := t.rename(target, saved); err != nil { + return err + } + entry.Old = true + } else if !os.IsNotExist(err) { + return err + } + if err := writeJSON(t.journal, t.files); err != nil { + return err + } + if err := t.rename(src, target); err != nil { + return err + } + entry.Installed = true + if err := writeJSON(t.journal, t.files); err != nil { + return err + } + } + return nil +} + +func validateParents(base, rel string) error { + for parent := filepath.Dir(rel); parent != "."; parent = filepath.Dir(parent) { + info, err := os.Lstat(filepath.Join(base, parent)) + if os.IsNotExist(err) { + continue + } + if err != nil { + return err + } + if !info.IsDir() { + return fmt.Errorf("update parent is not a regular directory: %s", parent) + } + } + return nil +} + +func (t *replacement) rollback() error { + var failures []error + for i := len(t.files) - 1; i >= 0; i-- { + entry := t.files[i] + if entry.Installed { + if err := os.RemoveAll(entry.Target); err != nil { + failures = append(failures, err) + continue + } + entry.Installed = false + } + if entry.Old { + if err := os.Rename(entry.Backup, entry.Target); err != nil { + failures = append(failures, err) + continue + } + entry.Old = false + } + } + return errors.Join(failures...) +} diff --git a/internal/update/update_test.go b/internal/update/update_test.go new file mode 100644 index 00000000..be822b05 --- /dev/null +++ b/internal/update/update_test.go @@ -0,0 +1,361 @@ +package update + +import ( + "archive/zip" + "bytes" + "context" + "database/sql" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" +) + +func putFile(t *testing.T, root, rel, value string) string { + t.Helper() + name := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(name), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(name, []byte(value), 0644); err != nil { + t.Fatal(err) + } + return name +} + +func requireFile(t *testing.T, root, rel, value string) { + t.Helper() + data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(rel))) + if err != nil || string(data) != value { + t.Fatalf("%s = %q, %v; want %q", rel, data, err, value) + } +} + +func TestLatestReleaseUsesOnlyValidatedRedirect(t *testing.T) { + prefix := "https://github.com/" + repository + "/releases/tag/" + for _, tc := range []struct { + name, location, want string + status int + bad bool + }{ + {"stable", prefix + "v2.10.0", "v2.10.0", 302, false}, + {"relative", "/" + repository + "/releases/tag/v2.10.0", "v2.10.0", 302, false}, + {"extension", prefix + "browser-extension-v9.0.0", "", 302, true}, + {"prerelease", prefix + "v2.10.0-rc.1", "", 302, true}, + {"foreign host", "https://example.com/" + repository + "/releases/tag/v2.10.0", "", 302, true}, + {"foreign repo", "https://github.com/other/repo/releases/tag/v2.10.0", "", 302, true}, + {"insecure", strings.Replace(prefix, "https:", "http:", 1) + "v2.10.0", "", 302, true}, + {"query", prefix + "v2.10.0?x=1", "", 302, true}, + {"missing location", "", "", 302, true}, + {"no release", "", "", 404, false}, + {"forbidden", "", "", 403, true}, + {"html instead of redirect", "", "", 200, true}, + } { + t.Run(tc.name, func(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.Method != http.MethodHead || r.URL.Path != "/releases/latest" || r.URL.RawQuery != "" { + t.Errorf("unexpected request %s %s", r.Method, r.URL) + } + w.Header().Set("Location", tc.location) + w.WriteHeader(tc.status) + })) + defer server.Close() + r, err := latestRelease(t.Context(), server.Client(), server.URL+"/releases/latest") + if (err != nil) != tc.bad || requests != 1 { + t.Fatalf("release=%+v requests=%d err=%v", r, requests, err) + } + if tc.want != "" && (r == nil || r.Tag != tc.want) { + t.Fatalf("release=%+v", r) + } + if tc.status == 404 && r != nil { + t.Fatalf("expected no release: %+v", r) + } + }) + } +} + +func TestReleaseAssetUsesFixedOfficialURL(t *testing.T) { + for _, platform := range []string{"windows-x86_64", "linux-x86_64", "macos-x86_64", "macos-arm64"} { + a, err := releaseAsset(&release{Tag: "v2.0.0"}, platform) + name := "javboss-v2.0.0-" + platform + ".zip" + if err != nil || a.Name != name || a.URL != "https://github.com/"+repository+"/releases/download/v2.0.0/"+name { + t.Fatalf("asset=%+v err=%v", a, err) + } + } + for _, tag := range []string{"browser-extension-v2.0.0", "v2.0.0-rc.1", "v02.0.0", "../v2.0.0"} { + if _, err := releaseAsset(&release{Tag: tag}, "linux-x86_64"); err == nil { + t.Fatalf("accepted invalid version %s", tag) + } + } + if _, err := releaseAsset(&release{Tag: "v2.0.0"}, "../linux-x86_64"); err == nil { + t.Fatal("accepted unsupported platform") + } +} + +func TestDownloadWithoutManifest(t *testing.T) { + content := "release bytes" + for _, tc := range []struct { + name string + length int64 + status int + bad bool + }{ + {"known size", int64(len(content)), 200, false}, + {"chunked", -1, 200, false}, + {"truncated", int64(len(content) + 1), 200, true}, + {"over limit", maxArchiveSize + 1, 200, true}, + {"empty", 0, 200, true}, + {"missing asset", -1, 404, true}, + } { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if tc.length >= 0 { + w.Header().Set("Content-Length", fmt.Sprint(tc.length)) + } + w.WriteHeader(tc.status) + w.(http.Flusher).Flush() + if tc.length != 0 { + _, _ = w.Write([]byte(content)) + } + })) + defer server.Close() + var downloaded, total int64 + target := filepath.Join(t.TempDir(), "release.zip") + err := downloadArchive(t.Context(), server.Client(), asset{URL: server.URL}, target, func(n, size int64) { downloaded, total = n, size }) + if (err != nil) != tc.bad { + t.Fatalf("err=%v", err) + } + if !tc.bad { + wantTotal := tc.length + if wantTotal < 0 { + wantTotal = 0 + } + if downloaded != int64(len(content)) || total != wantTotal { + t.Fatalf("downloaded=%d total=%d", downloaded, total) + } + requireFile(t, filepath.Dir(target), "release.zip", content) + } + }) + } +} + +func TestExtractArchiveRejectsUnsafeEntries(t *testing.T) { + for _, tc := range []struct { + name, path string + mode os.FileMode + bad bool + }{ + {"valid", "package/web/dist/index.html", 0644, false}, + {"traversal", "package/../escaped", 0644, true}, + {"absolute", "/package/file", 0644, true}, + {"backslash", `package/..\escaped`, 0644, true}, + {"wrong root", "other/file", 0644, true}, + {"link", "package/link", os.ModeSymlink | 0777, true}, + {"drive", "package/C:/file", 0644, true}, + {"windows parent alias", "package/.. /escaped", 0644, true}, + {"windows device", "package/NUL.txt", 0644, true}, + {"windows trailing dot", "package/file.", 0644, true}, + } { + t.Run(tc.name, func(t *testing.T) { + var buffer bytes.Buffer + writer := zip.NewWriter(&buffer) + header := &zip.FileHeader{Name: tc.path, Method: zip.Deflate} + header.SetMode(tc.mode) + file, err := writer.CreateHeader(header) + if err != nil { + t.Fatal(err) + } + _, _ = file.Write([]byte("content")) + if err := writer.Close(); err != nil { + t.Fatal(err) + } + root := t.TempDir() + archive := putFile(t, root, "archive.zip", buffer.String()) + err = extractArchive(t.Context(), archive, filepath.Join(root, "payload"), "package") + if (err != nil) != tc.bad { + t.Fatalf("err=%v", err) + } + }) + } +} + +func TestReplacementPreservesDataAndRollsBackFailures(t *testing.T) { + for _, failAt := range []int{0, 1, 2, 3, 4, 5, 6} { + t.Run(fmt.Sprint(failAt), func(t *testing.T) { + base, payload, backup := t.TempDir(), t.TempDir(), t.TempDir() + for _, rel := range []string{executableName(), "web/dist/index.html", "modernz/file"} { + putFile(t, base, rel, "old") + putFile(t, payload, rel, "new") + } + for _, rel := range []string{"config.toml", "data/javboss.db", "data/client/settings.json", "data/tools/ffmpeg"} { + putFile(t, base, rel, "user") + putFile(t, payload, rel, "default") + } + putFile(t, base, "web/dist/obsolete.js", "old asset") + calls := 0 + tx := &replacement{journal: filepath.Join(backup, "files.json"), rename: func(a, b string) error { + calls++ + if calls == failAt { + return errors.New("simulated locked file") + } + return os.Rename(a, b) + }} + err := tx.apply(base, payload, backup) + if (err != nil) != (failAt > 0) { + t.Fatalf("err=%v", err) + } + want := "new" + if failAt > 0 { + want = "old" + } + for _, rel := range []string{executableName(), "web/dist/index.html", "modernz/file"} { + requireFile(t, base, rel, want) + } + for _, rel := range []string{"config.toml", "data/javboss.db", "data/client/settings.json", "data/tools/ffmpeg"} { + requireFile(t, base, rel, "user") + } + if failAt == 0 { + if _, err := os.Stat(filepath.Join(base, "web/dist/obsolete.js")); !os.IsNotExist(err) { + t.Fatal("obsolete frontend survived replacement") + } + } + }) + } +} + +func TestDatabaseBackupIncludesWALAndDoesNotMigrate(t *testing.T) { + root := t.TempDir() + source := filepath.Join(root, "database.db") + dest := filepath.Join(root, "backup.db") + db, err := sql.Open("sqlite3", source) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err = db.Exec("PRAGMA journal_mode=WAL; PRAGMA wal_autocheckpoint=0; CREATE TABLE example (value TEXT); INSERT INTO example VALUES ('saved')"); err != nil { + t.Fatal(err) + } + if err = backupDatabase(source, dest); err != nil { + t.Fatal(err) + } + backup, err := sql.Open("sqlite3", dest) + if err != nil { + t.Fatal(err) + } + defer backup.Close() + var got string + if err = backup.QueryRow("SELECT value FROM example").Scan(&got); err != nil || got != "saved" { + t.Fatalf("backup=%q err=%v", got, err) + } + var count int + if err = backup.QueryRow("SELECT count(*) FROM sqlite_master WHERE name='goose_db_version'").Scan(&count); err != nil || count != 0 { + t.Fatalf("migration unexpectedly run: %d %v", count, err) + } +} + +func TestManagerCheckNeverStopsApplicationAndDisablesUnsupportedInstall(t *testing.T) { + for _, tc := range []struct { + name string + release, container bool + version, reason string + }{ + {"desktop", true, false, "v1.0.0", ""}, + {"container", true, true, "v1.0.0", "container"}, + {"development", false, false, "dev", "development"}, + {"already current", true, false, "v2.0.0", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + var stops atomic.Int32 + m := newManager(t.Context(), Options{BaseDir: t.TempDir(), Version: tc.version, Release: tc.release, Container: tc.container, Stop: func() { stops.Add(1) }}) + m.platform = "linux-x86_64" + name := "javboss-v2.0.0-linux-x86_64.zip" + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.Method == http.MethodHead && r.URL.Path == "/releases/latest" { + w.Header().Set("Location", "https://github.com/"+repository+"/releases/tag/v2.0.0") + w.WriteHeader(http.StatusFound) + return + } + if r.Method == http.MethodGet && r.URL.Path == "/releases/tag/v2.0.0" { + _, _ = w.Write([]byte(`更新说明
更新说明
" { + t.Fatalf("requests=%d notes=%q", requests, status.Notes) + } + if installable && m.selected.URL != "https://github.com/"+repository+"/releases/download/v2.0.0/"+name { + t.Fatalf("wrong download URL: %s", m.selected.URL) + } + if stops.Load() != 0 { + t.Fatal("checking stopped app") + } + if tc.reason != "" && m.StartInstall() == nil { + t.Fatal("unsupported install accepted") + } + }) + } +} + +func TestBusyOperationsAndHTTPNeverAcceptArbitraryDownloads(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + m := newManager(ctx, Options{BaseDir: t.TempDir(), Version: "v1.0.0", Release: true}) + m.status.Phase = "downloading" + if m.StartCheck() == nil || m.StartInstall() == nil { + t.Fatal("concurrent operation accepted") + } + old := active.Swap(m) + defer active.Store(old) + m.status.Phase = "idle" + w := httptest.NewRecorder() + ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/system/update/install", strings.NewReader(`{"url":"https://attacker/evil","path":"/tmp/evil"}`)), "/system/update") + if w.Code != http.StatusConflict { + t.Fatalf("unexpected status=%d", w.Code) + } + cancel() + if !errors.Is(m.StartCheck(), context.Canceled) { + t.Fatal("started check during shutdown") + } +} + +func TestWaitFileCancellation(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), time.Millisecond) + defer cancel() + if err := waitFile(ctx, filepath.Join(t.TempDir(), "absent"), "ready"); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("error=%v", err) + } +} + +func TestDatabaseURIEscapesPathsAndWindowsDrives(t *testing.T) { + for _, path := range []string{"/tmp/数据库 ?#.db", "C:/Users/My Name/数据库.db"} { + parsed, err := url.Parse(databaseURI(path)) + if err != nil { + t.Fatal(err) + } + if parsed.Host != "" || parsed.Fragment != "" || parsed.Query().Get("mode") != "rw" || strings.TrimPrefix(parsed.Path, "/") != strings.TrimPrefix(path, "/") { + t.Fatalf("invalid SQLite URI: %s", parsed) + } + } +} diff --git a/scripts/cli/tests/install.test.mjs b/scripts/cli/tests/install.test.mjs index af7ae9fb..2b9564f6 100644 --- a/scripts/cli/tests/install.test.mjs +++ b/scripts/cli/tests/install.test.mjs @@ -288,10 +288,18 @@ test("version update only changes README links", (t) => { for (const name of ["install.sh", "install.ps1"]) { fs.writeFileSync(path.join(dir, "scripts", name), "installer must stay unchanged\n"); } - fs.writeFileSync(path.join(dir, "README.md"), "https://github.com/Solr159/JavBoss/releases/download/v1.0.0/javboss-v1.0.0-linux-x86_64.zip\n"); + const platforms = ["windows-x86_64", "linux-x86_64", "macos-x86_64", "macos-arm64"]; + const extension = "https://github.com/Solr159/JavBoss/releases/download/v1.0.0/javboss-browser-extension-v0.14.1.zip"; + const before = ["Release documentation", ...platforms.map(platform => + `https://github.com/Solr159/JavBoss/releases/download/v1.0.0/javboss-v1.0.0-${platform}.zip`), extension].join("\n"); + fs.writeFileSync(path.join(dir, "README.md"), before); const result = spawnSync(process.execPath, [path.join(dir, "scripts", "update-release-version.mjs"), "v9.8.7"], { encoding: "utf8" }); assert.equal(result.status, 0, result.stderr); - assert.match(fs.readFileSync(path.join(dir, "README.md"), "utf8"), /download\/v9\.8\.7\/javboss-v9\.8\.7-linux-x86_64.zip/); + const expected = before.replaceAll(/download\/v1\.0\.0\/javboss-v1\.0\.0-/g, "download/v9.8.7/javboss-v9.8.7-"); + assert.equal(fs.readFileSync(path.join(dir, "README.md"), "utf8"), expected); + const retry = spawnSync(process.execPath, [path.join(dir, "scripts", "update-release-version.mjs"), "v9.8.7"], { encoding: "utf8" }); + assert.equal(retry.status, 0, retry.stderr); + assert.equal(fs.readFileSync(path.join(dir, "README.md"), "utf8"), expected); for (const name of ["install.sh", "install.ps1"]) { assert.equal(fs.readFileSync(path.join(dir, "scripts", name), "utf8"), "installer must stay unchanged\n"); } diff --git a/scripts/cli/tests/update-release-version.test.mjs b/scripts/cli/tests/update-release-version.test.mjs new file mode 100644 index 00000000..e749af41 --- /dev/null +++ b/scripts/cli/tests/update-release-version.test.mjs @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const script = fileURLToPath(new URL("../../update-release-version.mjs", import.meta.url)); +const baseURL = "https://github.com/Solr159/JavBoss/releases/download/"; +const appURL = baseURL + "v1.0.0/javboss-v1.0.0-linux-x86_64.zip"; +const extURL = version => baseURL + `v1.0.0/javboss-browser-extension-v${version}.zip`; + +for (const scenario of [ + { name: "standalone extension updates only its own link", tag: "browser-extension-v0.15.0", extension: "0.15.0", current: "0.14.1", update: true }, + { name: "bundled extension uses the main release tag and its own asset version", tag: "v2.1.4", extension: "0.15.0", current: "0.14.1", update: true }, + { name: "main release without an extension preserves the extension link", tag: "v2.1.4", current: "0.14.1", update: false }, + { name: "old standalone retry cannot downgrade the extension", tag: "browser-extension-v0.14.0", extension: "0.14.0", current: "0.15.0", update: false }, + { name: "older bundled extension cannot downgrade a newer standalone link", tag: "v2.1.4", extension: "0.14.0", current: "0.15.0", update: false, standalone: true }, + { name: "extension versions compare numerically", tag: "browser-extension-v0.10.0", extension: "0.10.0", current: "0.9.0", update: true }, + { name: "extension versions support four components", tag: "browser-extension-v0.14.1.2", extension: "0.14.1.2", current: "0.14.1", update: true }, +]) { + test(scenario.name, (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "javboss-readme-update-")); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const readme = path.join(dir, "README.md"); + const metadata = path.join(dir, "release.json"); + const oldExtension = scenario.standalone + ? baseURL + `browser-extension-v${scenario.current}/javboss-browser-extension-v${scenario.current}.zip` + : extURL(scenario.current); + const before = `Keep documentation\n[App](${appURL})\n[Extension](${oldExtension})\n`; + fs.writeFileSync(readme, before); + fs.writeFileSync(metadata, JSON.stringify({ assets: scenario.extension ? [{ name: `javboss-browser-extension-v${scenario.extension}.zip` }] : [] })); + let expected = before; + if (scenario.tag.startsWith("v")) { + expected = expected.replace(appURL, baseURL + `${scenario.tag}/javboss-${scenario.tag}-linux-x86_64.zip`); + } + if (scenario.update) { + expected = expected.replace(oldExtension, baseURL + `${scenario.tag}/javboss-browser-extension-v${scenario.extension}.zip`); + } + for (let retry = 0; retry < 2; retry++) { + const result = spawnSync(process.execPath, [script, scenario.tag, metadata, readme], { encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + assert.equal(fs.readFileSync(readme, "utf8"), expected); + } + }); +} + +for (const scenario of [ + { name: "missing extension asset", tag: "browser-extension-v0.15.0", assets: [] }, + { name: "mismatched extension asset", tag: "browser-extension-v0.15.0", assets: ["javboss-browser-extension-v0.14.1.zip"] }, + { name: "ambiguous bundled extension assets", tag: "v2.1.4", assets: ["javboss-browser-extension-v0.15.0.zip", "javboss-browser-extension-v0.14.1.zip"] }, + { name: "missing README extension link", tag: "v2.1.4", assets: ["javboss-browser-extension-v0.15.0.zip"], missingLink: true }, + { name: "invalid release tag", tag: "../invalid", assets: [] }, +]) { + test(`${scenario.name} leaves README untouched`, (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "javboss-readme-update-")); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const readme = path.join(dir, "README.md"); + const metadata = path.join(dir, "release.json"); + const before = appURL + "\n" + (scenario.missingLink ? "" : extURL("0.14.1")); + fs.writeFileSync(readme, before); + fs.writeFileSync(metadata, JSON.stringify({ assets: scenario.assets.map(name => ({ name })) })); + const result = spawnSync(process.execPath, [script, scenario.tag, metadata, readme], { encoding: "utf8" }); + assert.notEqual(result.status, 0); + assert.equal(fs.readFileSync(readme, "utf8"), before); + }); +} diff --git a/scripts/update-release-version.mjs b/scripts/update-release-version.mjs index 06ea9df0..536b75d1 100644 --- a/scripts/update-release-version.mjs +++ b/scripts/update-release-version.mjs @@ -6,45 +6,72 @@ import { fileURLToPath } from "node:url"; const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); const ROOT_DIR = path.dirname(SCRIPT_DIR); const REPO = "Solr159/JavBoss"; - const version = String(process.argv[2] || "").trim(); -if (!/^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$/.test(version)) { - console.error("usage: node scripts/update-release-version.mjs vX.Y.Z"); +const metadataPath = process.argv[3]; +// The workflow reads its script from the selected ref, but edits only main's README. +const readmePath = path.resolve(process.argv[4] || path.join(ROOT_DIR, "README.md")); +const applicationRelease = /^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.test(version); +const extensionRelease = /^browser-extension-v[0-9]+(?:\.[0-9]+){0,3}$/.test(version); +if (!applicationRelease && !extensionRelease) { + console.error("usage: node scripts/update-release-version.mjs RELEASE_TAG [RELEASE_JSON] [README_PATH]"); process.exit(1); } -async function replaceRequired(relativePath, replacements) { - const filePath = path.join(ROOT_DIR, relativePath); - let content = await fs.readFile(filePath, "utf8"); - let changed = false; - - for (const [pattern, replacement] of replacements) { - pattern.lastIndex = 0; - if (!pattern.test(content)) { - throw new Error(`${relativePath}: pattern not found: ${pattern}`); - } - pattern.lastIndex = 0; - content = content.replace(pattern, replacement); - changed = true; - } +const extensionAssetPattern = /^javboss-browser-extension-v([0-9]+(?:\.[0-9]+){0,3})\.zip$/; +let extensionAsset; +if (metadataPath) { + const release = JSON.parse(await fs.readFile(metadataPath, "utf8")); + const extensions = release.assets.filter(asset => extensionAssetPattern.test(asset.name)); + if (extensions.length > 1) throw new Error("release has multiple browser extension packages"); + extensionAsset = extensions[0]?.name; +} else if (extensionRelease) { + extensionAsset = `javboss-${version}.zip`; +} +if (extensionRelease && extensionAsset !== `javboss-${version}.zip`) { + throw new Error("release is missing its browser extension package"); +} - if (changed) { - await fs.writeFile(filePath, content); - } +const before = await fs.readFile(readmePath, "utf8"); +let content = before; +function replaceRequired(pattern, replacement) { + if (!pattern.test(content)) throw new Error(`README.md: pattern not found: ${pattern}`); + pattern.lastIndex = 0; + content = content.replace(pattern, replacement); } -const platformPattern = "(windows-x86_64|linux-x86_64|macos-x86_64|macos-arm64)"; -const existingVersionPattern = "v[0-9]+\\.[0-9]+\\.[0-9]+(?:[-.][0-9A-Za-z.-]+)?"; -const releaseUrlPattern = new RegExp( - `https://github\\.com/${REPO}/releases/download/` + - `${existingVersionPattern}/` + - `javboss-${existingVersionPattern}-` + - `${platformPattern}\\.zip`, - "g", -); +if (applicationRelease) { + const platformPattern = "(windows-x86_64|linux-x86_64|macos-x86_64|macos-arm64)"; + const existingVersionPattern = "v[0-9]+\\.[0-9]+\\.[0-9]+(?:[-.][0-9A-Za-z.-]+)?"; + const releaseUrlPattern = new RegExp( + `https://github\\.com/${REPO}/releases/download/` + + `${existingVersionPattern}/javboss-${existingVersionPattern}-${platformPattern}\\.zip`, "g", + ); + replaceRequired(releaseUrlPattern, + `https://github.com/${REPO}/releases/download/${version}/javboss-${version}-$1.zip`); +} -const releaseUrlReplacement = `https://github.com/${REPO}/releases/download/${version}/javboss-${version}-$1.zip`; +// Compare the extension's own 1–4 numeric version components, not the main tag. +function compareExtensionVersions(left, right) { + const a = left.split(".").map(BigInt); + const b = right.split(".").map(BigInt); + for (let i = 0; i < 4; i++) { + const diff = (a[i] ?? 0n) - (b[i] ?? 0n); + if (diff !== 0n) return diff > 0n ? 1 : -1; + } + return 0; +} -await replaceRequired("README.md", [[releaseUrlPattern, releaseUrlReplacement]]); +if (extensionAsset) { + const extensionVersion = extensionAsset.match(extensionAssetPattern)[1]; + const extensionUrlPattern = new RegExp( + `https://github\\.com/${REPO}/releases/download/[^/\\s<>"()]+/` + + "javboss-browser-extension-v([0-9]+(?:\\.[0-9]+){0,3})\\.zip", "g", + ); + replaceRequired(extensionUrlPattern, (url, installedVersion) => { + if (compareExtensionVersions(extensionVersion, installedVersion) < 0) return url; + return `https://github.com/${REPO}/releases/download/${version}/${extensionAsset}`; + }); +} -console.log(`updated release version to ${version}`); +if (content !== before) await fs.writeFile(readmePath, content); +console.log(content === before ? "README download links are already up to date" : `updated README download links for ${version}`); diff --git a/web/package-lock.json b/web/package-lock.json index 3f57a224..ac6f3b8d 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -13,6 +13,7 @@ "@emotion/styled": "^11.14.1", "@mui/icons-material": "^7.3.6", "@mui/material": "^7.3.6", + "dompurify": "^3.4.17", "flv.js": "^1.6.2", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -1219,6 +1220,13 @@ "@types/react": "*" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -2229,6 +2237,15 @@ "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz", "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==" }, + "node_modules/dompurify": { + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.17.tgz", + "integrity": "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", diff --git a/web/package.json b/web/package.json index 71b3b090..e7a4e849 100644 --- a/web/package.json +++ b/web/package.json @@ -18,6 +18,7 @@ "@emotion/styled": "^11.14.1", "@mui/icons-material": "^7.3.6", "@mui/material": "^7.3.6", + "dompurify": "^3.4.17", "flv.js": "^1.6.2", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/web/src/features/settings/api.js b/web/src/features/settings/api.js index ac0e4381..976d791e 100644 --- a/web/src/features/settings/api.js +++ b/web/src/features/settings/api.js @@ -50,3 +50,37 @@ export async function downloadFFmpeg() { if (!res.ok) throw await apiError(res) return parseJSONResponse(res) } + +export async function fetchAppUpdate({ local = false, signal } = {}) { + const res = await apiFetch(local ? '/__client/update' : '/system/update', { + signal: updateRequestSignal(signal), + cache: 'no-store', + }) + if (!res.ok) throw await apiError(res) + return parseJSONResponse(res) +} + +function updateRequestSignal(signal) { + const timeout = AbortSignal.timeout(5000) + return signal ? AbortSignal.any([signal, timeout]) : timeout +} + +// A restarted service may be reachable before its update endpoint can be read +// by the old page. Probe the public endpoint for the selected target instead. +// In client mode /auth/status is proxied to the server; /healthz is local. +export async function isAppUpdateTargetReady({ local = false, signal } = {}) { + const res = await fetch(local ? '/healthz' : '/auth/status', { + signal: updateRequestSignal(signal), + cache: 'no-store', + }) + if (!res.ok) return false + const status = await parseJSONResponse(res) + return local ? status?.status === 'ok' : typeof status?.authenticated === 'boolean' +} + +export async function requestAppUpdate(action, { local = false } = {}) { + const prefix = local ? '/__client/update' : '/system/update' + const res = await apiFetch(`${prefix}/${action}`, { method: 'POST' }) + if (!res.ok) throw await apiError(res) + return parseJSONResponse(res) +} diff --git a/web/src/features/settings/components/AppUpdateSettings.jsx b/web/src/features/settings/components/AppUpdateSettings.jsx new file mode 100644 index 00000000..ee28821f --- /dev/null +++ b/web/src/features/settings/components/AppUpdateSettings.jsx @@ -0,0 +1,322 @@ +import { useEffect, useState } from 'react' +import { + Alert, + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + LinearProgress, + MenuItem, + TextField, +} from '@mui/material' +import ArrowForwardRoundedIcon from '@mui/icons-material/ArrowForwardRounded' +import SystemUpdateAltRoundedIcon from '@mui/icons-material/SystemUpdateAltRounded' +import { fetchAppUpdate, isAppUpdateTargetReady, requestAppUpdate } from '@/features/settings/api' +import { getErrorMessage } from '@/utils/errors' +import { zh } from '@/utils/i18n' +import ReleaseNotes from '@/features/settings/components/ReleaseNotes' + +export default function AppUpdateSettings({ clientMode = false }) { + const [target, setTarget] = useState(clientMode ? 'local' : 'server') + return ( ++ {zh('当前版本', 'Current version')}:{status?.current_version || '—'} +
++ {zh('最新版本', 'Latest version')}:{status?.latest_version || '—'} +
+ {status?.checked_at > 0 && ( ++ {zh('上次检查', 'Last checked')}:{new Date(status.checked_at).toLocaleString()} +
+ )} ++ {reconnecting + ? zh('JavBoss 暂时断开,正在重新连接…', 'JavBoss is disconnected. Reconnecting…') + : phaseText} +
++ {zh('没有可用的新版本。', 'No newer version is available.')} +
+ )} +{text}
+ ) +} diff --git a/web/tests/features/settings/AppUpdateSettings.browser.test.js b/web/tests/features/settings/AppUpdateSettings.browser.test.js new file mode 100644 index 00000000..6cc4c296 --- /dev/null +++ b/web/tests/features/settings/AppUpdateSettings.browser.test.js @@ -0,0 +1,277 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { browserUnavailable, openBrowser } from '../../helpers/browser.js' + +async function setup(t, clientMode = false) { + const browser = await openBrowser(t, { cacheDir: 'node_modules/.vite-update-settings-test' }) + const { origin, command, evaluate, waitFor } = browser + await command('Page.navigate', { url: `${origin}/tests/fixtures/app.html?view=video` }) + await waitFor(`document.querySelector('aside button[aria-label="Settings"]')`) + await evaluate(`{ + window.updateRequests = []; + window.updateStatus = { + current_version: 'v1.0.0', latest_version: 'v2.0.0', + available: true, can_install: true, phase: 'idle', checked_at: Date.now() + }; + const originalFetch = window.fetch; + window.fetch = (input, init = {}) => { + const path = new URL(input, location.origin).pathname; + if (path.startsWith('/system/update') || path.startsWith('/__client/update')) { + window.updateRequests.push({path, method: init.method || 'GET'}); + if (path.endsWith('/install')) window.updateStatus.phase = 'downloading'; + return Promise.resolve(Response.json(window.updateStatus)); + } + return originalFetch(input, init); + }; + window.testStore.setState({config: {...window.testStore.getState().config, runtime_client: '${clientMode}'}}); + document.querySelector('aside button[aria-label="Settings"]').click(); + }`) + await waitFor(`document.querySelector('[aria-labelledby="global-settings-title"]')`) + await evaluate( + `Array.from(document.querySelectorAll('aside button')).find(button => button.textContent === 'Check for Updates').click()` + ) + await waitFor(`document.body.textContent.includes('v2.0.0')`) + return browser +} + +test( + 'installation requires confirmation and download errors remain visible', + { skip: browserUnavailable, timeout: 60000 }, + async (t) => { + const { evaluate, waitFor } = await setup(t) + const click = (text) => + evaluate( + `Array.from(document.querySelectorAll('button')).find(button => button.textContent === '${text}').click()` + ) + await click('Update and restart') + await waitFor(`document.getElementById('app-update-confirm-title')`) + assert.equal( + await evaluate(`window.updateRequests.filter(r => r.path.endsWith('/install')).length`), + 0 + ) + await click('Cancel') + await waitFor(`!document.getElementById('app-update-confirm-title')`) + assert.equal( + await evaluate(`window.updateRequests.filter(r => r.path.endsWith('/install')).length`), + 0 + ) + await click('Update and restart') + await waitFor(`document.getElementById('app-update-confirm-title')`) + await click('Continue update') + await waitFor( + `window.updateRequests.filter(r => r.path === '/system/update/install').length === 1` + ) + await waitFor(`document.body.textContent.includes('Downloading update')`) + assert.equal( + await evaluate( + `Array.from(document.querySelectorAll('button')).find(b => b.textContent === 'Update and restart').disabled` + ), + true + ) + await evaluate( + `window.updateStatus.phase = 'error'; window.updateStatus.error = 'release SHA256 mismatch'` + ) + await waitFor(`document.body.textContent.includes('release SHA256 mismatch')`) + assert.equal( + await evaluate( + `Array.from(document.querySelectorAll('button')).find(b => b.textContent === 'Update and restart').disabled` + ), + false + ) + assert.deepEqual(await evaluate('window.appErrors'), []) + } +) + +test( + 'client updates default to local and the remote target is explicit', + { skip: browserUnavailable, timeout: 60000 }, + async (t) => { + const { evaluate, waitFor } = await setup(t, true) + assert.ok(await evaluate(`window.updateRequests.some(r => r.path === '/__client/update')`)) + assert.equal( + await evaluate(`window.updateRequests.some(r => r.path === '/system/update')`), + false + ) + await evaluate( + `document.querySelector('[role="combobox"]').dispatchEvent(new MouseEvent('mousedown', {bubbles:true}))` + ) + await waitFor(`document.querySelector('[role="option"][data-value="server"]')`) + await evaluate(`document.querySelector('[role="option"][data-value="server"]').click()`) + await waitFor(`window.updateRequests.some(r => r.path === '/system/update')`) + await evaluate( + `Array.from(document.querySelectorAll('button')).find(b => b.textContent === 'Update and restart').click()` + ) + await waitFor(`document.body.textContent.includes('on the remote server')`) + assert.equal( + await evaluate(`window.updateRequests.some(r => r.path.endsWith('/install'))`), + false + ) + assert.deepEqual(await evaluate('window.appErrors'), []) + } +) + +for (const target of ['server', 'local client', 'remote server']) { + test( + `reconnect refreshes the old page when the ${target} returns without its update API`, + { skip: browserUnavailable, timeout: 60000 }, + async (t) => { + const { evaluate, waitFor } = await setup(t, target !== 'server') + if (target === 'remote server') { + await evaluate( + `document.querySelector('[role="combobox"]').dispatchEvent(new MouseEvent('mousedown', {bubbles:true}))` + ) + await waitFor(`document.querySelector('[role="option"][data-value="server"]')`) + await evaluate(`document.querySelector('[role="option"][data-value="server"]').click()`) + await waitFor(`window.updateRequests.some(r => r.path === '/system/update')`) + } + const started = await evaluate('performance.timeOrigin') + const readinessPath = target === 'local client' ? '/healthz' : '/auth/status' + await evaluate(`{ + window.targetReady = false; + window.updateDisconnected = false; + window.readinessRequests = []; + const originalFetch = window.fetch; + window.fetch = (input, init = {}) => { + const path = new URL(input, location.origin).pathname; + if (window.updateDisconnected && (path === '/system/update' || path === '/__client/update')) { + return Promise.resolve(Response.json({error_en: 'Update endpoint unavailable'}, {status: 503})); + } + if (path === '${readinessPath}') { + window.readinessRequests.push(path); + return Promise.resolve(window.targetReady + ? Response.json(${target === 'local client' ? "{status: 'ok'}" : '{authenticated: false}'}) + : Response.json({}, {status: 503})); + } + // A live client must not be mistaken for a restarted remote server. + if (path === '/healthz') return Promise.resolve(Response.json({status: 'ok'})); + return originalFetch(input, init); + }; + Array.from(document.querySelectorAll('button')).find(b => b.textContent === 'Update and restart').click(); + }`) + await waitFor(`document.getElementById('app-update-confirm-title')`) + await evaluate( + `Array.from(document.querySelectorAll('button')).find(b => b.textContent === 'Continue update').click()` + ) + await waitFor(`window.updateRequests.some(r => r.path.endsWith('/install'))`) + await evaluate(`window.updateDisconnected = true`) + await waitFor(`document.body.textContent.includes('JavBoss is disconnected. Reconnecting')`) + await waitFor(`window.readinessRequests.length > 0`) + assert.equal(await evaluate('performance.timeOrigin'), started) + assert.ok( + await evaluate( + `Array.from(document.querySelectorAll('button')).some(b => b.textContent === 'Refresh page' && !b.disabled)` + ) + ) + await evaluate(`window.targetReady = true`) + await waitFor(`performance.timeOrigin !== ${started}`) + await waitFor(`document.querySelector('aside button[aria-label="Settings"]')`) + assert.equal( + await evaluate( + `document.body.textContent.includes('JavBoss is disconnected. Reconnecting')` + ), + false + ) + assert.deepEqual(await evaluate('window.appErrors'), []) + } + ) +} + +test( + 'a stalled update status request times out and probes the restarted service', + { skip: browserUnavailable, timeout: 60000 }, + async (t) => { + const { evaluate, waitFor } = await setup(t) + const started = await evaluate('performance.timeOrigin') + await evaluate(`{ + window.stallUpdate = false; + const originalTimeout = AbortSignal.timeout; + AbortSignal.timeout = () => originalTimeout(100); + const originalFetch = window.fetch; + window.fetch = (input, init = {}) => { + if (window.stallUpdate && new URL(input, location.origin).pathname === '/system/update') { + return new Promise((resolve, reject) => { + init.signal.addEventListener('abort', () => reject(init.signal.reason), {once: true}); + }); + } + return originalFetch(input, init); + }; + Array.from(document.querySelectorAll('button')).find(b => b.textContent === 'Update and restart').click(); + }`) + await waitFor(`document.getElementById('app-update-confirm-title')`) + await evaluate( + `Array.from(document.querySelectorAll('button')).find(b => b.textContent === 'Continue update').click()` + ) + await waitFor(`window.updateRequests.some(r => r.path.endsWith('/install'))`) + await evaluate(`window.stallUpdate = true`) + await waitFor(`performance.timeOrigin !== ${started}`) + await waitFor(`document.querySelector('aside button[aria-label="Settings"]')`) + assert.deepEqual(await evaluate('window.appErrors'), []) + } +) + +test( + 'release notes preserve formatting, sanitize HTML and fall back to plain text', + { skip: browserUnavailable, timeout: 60000 }, + async (t) => { + const { evaluate, waitFor } = await setup(t) + const markup = `Bold text and emphasis
+line one\nline twoA quotation+
| Platform | Windows |
|---|
Safe text
` + await evaluate(`{ + window.notesXSS = 0; + window.updateStatus.notes = 'Plain notes'; + window.updateStatus.notes_html = ${JSON.stringify(markup)}; + Array.from(document.querySelectorAll('button')).find(b => b.textContent === 'Check for updates').click(); + }`) + await waitFor(`document.querySelector('.release-notes h1')`) + const result = await evaluate(`(() => { + const root = document.querySelector('.release-notes'); + const link = root.querySelector('a'); + return { + title: root.previousElementSibling.textContent, + headingSize: parseFloat(getComputedStyle(root.querySelector('h1')).fontSize), + bodySize: parseFloat(getComputedStyle(root).fontSize), + weight: getComputedStyle(root.querySelector('strong')).fontWeight, + listStyle: getComputedStyle(root.querySelector('ul')).listStyleType, + orderedStyle: getComputedStyle(root.querySelector('ol')).listStyleType, + code: root.querySelector('pre code').textContent, + target: link.target, rel: link.rel, + unsafeLink: root.querySelectorAll('a')[1].hasAttribute('href'), + unsafeElements: root.querySelectorAll('script, svg, iframe, [onerror], [onload], [style], [class], [id]').length, + unsafeImage: root.querySelector('img').hasAttribute('src'), + xss: window.notesXSS + }; + })()`) + assert.equal(result.title, 'v2.0.0') + assert.ok(result.headingSize > result.bodySize) + assert.ok(Number(result.weight) >= 600) + assert.equal(result.listStyle, 'disc') + assert.equal(result.orderedStyle, 'decimal') + assert.equal(result.code, 'line one\nline two') + assert.equal(result.target, '_blank') + assert.match(result.rel, /noopener/) + assert.match(result.rel, /noreferrer/) + assert.equal(result.unsafeLink, false) + assert.equal(result.unsafeImage, false) + assert.equal(result.unsafeElements, 0) + assert.equal(result.xss, 0) + + await evaluate(`{ + window.updateStatus.notes_html = ''; + window.updateStatus.notes = 'Legacy plain text'; + Array.from(document.querySelectorAll('button')).find(b => b.textContent === 'Check for updates').click(); + }`) + await waitFor( + `!document.querySelector('.release-notes') && document.body.textContent.includes('Legacy plain text')` + ) + assert.deepEqual(await evaluate('window.appErrors'), []) + } +)