diff --git a/internal/app/app.go b/internal/app/app.go index 73beb04..98f5898 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -22,6 +22,7 @@ import ( "pi-web/internal/server" "pi-web/internal/sessions" "pi-web/internal/ui" + "pi-web/internal/updater" "pi-web/internal/workers" "pi-web/web" ) @@ -66,6 +67,8 @@ func Main(version string) { } authMiddleware := auth.New(token) + versionChecker := updater.New(version) + var srv *server.Server manager := workers.NewManager(func(sessionID, sessionPath string) (workers.ChatWorker, error) { return rpc.NewPiWorkerWithStream(sessionPath, func(preview rpc.StreamPreview) { @@ -86,6 +89,9 @@ func Main(version string) { Models: func(ctx context.Context) (json.RawMessage, error) { return defaultModelsCache.get(ctx) }, + Updater: versionChecker, + RunInstall: runInstall, + RunRestart: runRestart, }) mux := http.NewServeMux() @@ -170,6 +176,8 @@ func Main(version string) { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() + go versionChecker.Start(ctx) + go func() { <-ctx.Done() shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) diff --git a/internal/app/update.go b/internal/app/update.go new file mode 100644 index 0000000..853df98 --- /dev/null +++ b/internal/app/update.go @@ -0,0 +1,69 @@ +package app + +import ( + "context" + "fmt" + "os" + "os/exec" + "runtime" + "syscall" + "time" +) + +// installChannel matches the dist-tag pi-web is published under and the +// updater queries (see internal/updater). +const installPackage = "npm:@ygncode/pi-web@beta" + +// runInstall installs the latest pi-web package via the `pi` CLI. Output is +// captured so a failure surfaces a useful message in the UI. +func runInstall(ctx context.Context) error { + cmd := exec.CommandContext(ctx, "pi", "install", installPackage) + out, err := cmd.CombinedOutput() + if err != nil { + msg := string(out) + if len(msg) > 500 { + msg = msg[len(msg)-500:] + } + return fmt.Errorf("%v: %s", err, msg) + } + return nil +} + +// runRestart restarts the pi-web service so the freshly installed binary takes +// over. The restart command is detached into its own session so it survives +// this process being torn down by the service manager. A fallback timer exits +// the process if the service manager does not replace us promptly. +func runRestart() error { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("sh", "-lc", darwinRestartScript) + case "linux": + cmd = exec.Command("systemctl", "--user", "restart", "pi-web.service") + default: + return fmt.Errorf("restart is only supported on macOS and Linux") + } + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start restart command: %w", err) + } + // If the service manager doesn't kill us (e.g. it already booted a fresh + // instance), exit so the old process doesn't linger holding the port. + time.AfterFunc(5*time.Second, func() { os.Exit(0) }) + return nil +} + +// darwinRestartScript mirrors the extension's `/pi-web restart`: re-bootstrap +// the launchd job, preserving the PI_WEB_TOKEN from the env file, then kick it. +const darwinRestartScript = `plist="$HOME/Library/LaunchAgents/com.pi-web.plist" +if [ ! -f "$plist" ]; then exit 127; fi +env_file="$HOME/.config/pi-web/env" +token="$(awk -F= '$1 == "PI_WEB_TOKEN" { sub(/^[^=]*=/, ""); print; exit }' "$env_file" 2>/dev/null || true)" +if [ -n "$token" ]; then + launchctl setenv PI_WEB_TOKEN "$token" 2>/dev/null || true +fi +launchctl bootout "gui/$(id -u)" "$plist" 2>/dev/null || launchctl unload "$plist" 2>/dev/null || true +launchctl bootstrap "gui/$(id -u)" "$plist" 2>/dev/null || launchctl load "$plist" +launchctl kickstart -k "gui/$(id -u)/com.pi-web" 2>/dev/null || launchctl start com.pi-web` diff --git a/internal/server/server.go b/internal/server/server.go index 5dd1c3d..4f1640d 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -20,6 +20,7 @@ import ( "pi-web/internal/render" "pi-web/internal/rpc" "pi-web/internal/sessions" + "pi-web/internal/updater" _ "modernc.org/sqlite" ) @@ -44,6 +45,15 @@ type Deps struct { RenderExportSession func(s sessions.Session, theme string) string Models func(ctx context.Context) (json.RawMessage, error) Now func() time.Time + // Updater reports current/latest version + changelog. Optional; when nil + // the version endpoints are not registered. + Updater *updater.Checker + // RunInstall installs the latest pi-web package (e.g. `pi install ...`). + // Optional; when nil /api/update responds 503. + RunInstall func(ctx context.Context) error + // RunRestart restarts the pi-web service (detached) so the new binary + // takes over. Optional; when nil /api/restart responds 503. + RunRestart func() error } // Server holds runtime state — connected SSE clients and last-seen modtimes @@ -71,6 +81,10 @@ type Server struct { stopOnce sync.Once wg sync.WaitGroup db *sql.DB + updater *updater.Checker + runInstall func(ctx context.Context) error + runRestart func() error + updateMu sync.Mutex // serializes install/restart operations } func New(deps Deps) *Server { @@ -121,6 +135,9 @@ func New(deps Deps) *Server { lastKnown: make(map[string]struct{}), stopCh: make(chan struct{}), db: db, + updater: deps.Updater, + runInstall: deps.RunInstall, + runRestart: deps.RunRestart, } if pm, err := NewPushManager(agentDir); err != nil { fmt.Fprintf(os.Stderr, "push notifications unavailable: %v\n", err) @@ -184,6 +201,12 @@ func (s *Server) Register(mux *http.ServeMux) { } mux.HandleFunc("/api/sounds", s.auth.Wrap(s.handleApiSounds)) mux.HandleFunc("/sounds/", s.handleSounds) + if s.updater != nil { + mux.HandleFunc("/api/version", s.auth.Wrap(s.handleVersion)) + mux.HandleFunc("/api/check-update", s.auth.Wrap(s.handleCheckUpdate)) + mux.HandleFunc("/api/update", s.auth.Wrap(s.handleUpdate)) + mux.HandleFunc("/api/restart", s.auth.Wrap(s.handleRestart)) + } } func (s *Server) loadSummaries() ([]sessions.SessionSummary, error) { diff --git a/internal/server/update.go b/internal/server/update.go new file mode 100644 index 0000000..9c97606 --- /dev/null +++ b/internal/server/update.go @@ -0,0 +1,81 @@ +package server + +import ( + "context" + "net/http" + "time" +) + +// handleVersion returns the cached version/update snapshot. GET only. +func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + writeJSON(w, 0, s.updater.Info()) +} + +// handleCheckUpdate forces a fresh remote check (bypassing the 6h cache) and +// returns the resulting snapshot. POST only. +func (s *Server) handleCheckUpdate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + info, err := s.updater.Check(ctx) + if err != nil { + writeJSONError(w, http.StatusBadGateway, "could not check for updates: "+err.Error()) + return + } + writeJSON(w, 0, info) +} + +// handleUpdate runs the install command synchronously and reports whether a +// restart is needed to pick up the new binary. POST only. The actual restart +// is a separate call (/api/restart) so the UI can sequence it explicitly. +func (s *Server) handleUpdate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + if s.runInstall == nil { + writeJSONError(w, http.StatusServiceUnavailable, "in-place update is not available") + return + } + s.updateMu.Lock() + defer s.updateMu.Unlock() + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute) + defer cancel() + if err := s.runInstall(ctx); err != nil { + writeJSONError(w, http.StatusInternalServerError, "update failed: "+err.Error()) + return + } + writeJSON(w, 0, map[string]any{"status": "updated", "needsRestart": true}) +} + +// handleRestart spawns a detached restart of the pi-web service and then lets +// the process exit. The response is flushed before the restart fires so the +// browser receives it; the browser then polls until the new process is up. +// POST only. +func (s *Server) handleRestart(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + if s.runRestart == nil { + writeJSONError(w, http.StatusServiceUnavailable, "restart is not available") + return + } + writeJSON(w, 0, map[string]any{"status": "restarting"}) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + // Give the response a moment to reach the client before we tear down. + go func() { + time.Sleep(300 * time.Millisecond) + _ = s.runRestart() + }() +} diff --git a/internal/server/update_test.go b/internal/server/update_test.go new file mode 100644 index 0000000..946eb05 --- /dev/null +++ b/internal/server/update_test.go @@ -0,0 +1,112 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "pi-web/internal/updater" +) + +func TestHandleVersionReturnsCurrent(t *testing.T) { + s := &Server{updater: updater.New("dev")} + req := httptest.NewRequest(http.MethodGet, "/api/version", nil) + w := httptest.NewRecorder() + s.handleVersion(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status=%d want 200", w.Code) + } + var info updater.Info + if err := json.Unmarshal(w.Body.Bytes(), &info); err != nil { + t.Fatalf("decode: %v", err) + } + if info.Current != "dev" { + t.Errorf("current=%q want dev", info.Current) + } + if info.HasUpdate { + t.Errorf("dev should not report update") + } +} + +func TestHandleVersionRejectsNonGet(t *testing.T) { + s := &Server{updater: updater.New("dev")} + req := httptest.NewRequest(http.MethodPost, "/api/version", nil) + w := httptest.NewRecorder() + s.handleVersion(w, req) + if w.Code != http.StatusMethodNotAllowed { + t.Fatalf("status=%d want 405", w.Code) + } +} + +func TestHandleUpdateRunsInstall(t *testing.T) { + called := false + s := &Server{ + updater: updater.New("0.0.1-beta.24"), + runInstall: func(ctx context.Context) error { called = true; return nil }, + } + req := httptest.NewRequest(http.MethodPost, "/api/update", nil) + w := httptest.NewRecorder() + s.handleUpdate(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + if !called { + t.Errorf("runInstall not called") + } + var resp struct { + Status string `json:"status"` + NeedsRestart bool `json:"needsRestart"` + } + _ = json.Unmarshal(w.Body.Bytes(), &resp) + if !resp.NeedsRestart { + t.Errorf("expected needsRestart true, got %+v", resp) + } +} + +func TestHandleUpdateSurfacesError(t *testing.T) { + s := &Server{ + updater: updater.New("0.0.1-beta.24"), + runInstall: func(ctx context.Context) error { return errors.New("boom") }, + } + req := httptest.NewRequest(http.MethodPost, "/api/update", nil) + w := httptest.NewRecorder() + s.handleUpdate(w, req) + if w.Code != http.StatusInternalServerError { + t.Fatalf("status=%d want 500", w.Code) + } +} + +func TestHandleUpdateUnavailableWhenNoInstaller(t *testing.T) { + s := &Server{updater: updater.New("0.0.1-beta.24")} + req := httptest.NewRequest(http.MethodPost, "/api/update", nil) + w := httptest.NewRecorder() + s.handleUpdate(w, req) + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("status=%d want 503", w.Code) + } +} + +func TestHandleRestartInvokesRunRestart(t *testing.T) { + done := make(chan struct{}) + s := &Server{ + updater: updater.New("0.0.1-beta.24"), + runRestart: func() error { close(done); return nil }, + } + req := httptest.NewRequest(http.MethodPost, "/api/restart", nil) + w := httptest.NewRecorder() + s.handleRestart(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status=%d want 200", w.Code) + } + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatalf("runRestart was not invoked") + } +} diff --git a/internal/ui/live_menu.go b/internal/ui/live_menu.go index c868ef4..afa9417 100644 --- a/internal/ui/live_menu.go +++ b/internal/ui/live_menu.go @@ -71,13 +71,18 @@ func homeMenuHTML() template.HTML { {Label: "GitHub", Href: "https://github.com/ygncode/pi-web", Attrs: `target="_blank" rel="noreferrer" role="menuitem"`}, }}, {Items: []liveMenuItem{ + { + Label: "Version", + Suffix: ``, + Attrs: `id="index-version-row" data-version-row role="menuitem"`, + }, {Label: "Settings", Suffix: "", Muted: true, Attrs: `role="menuitem"`}, }}, }, }) } -func sessionMenuHTML(id, class, bodyClass, itemClass, toggleID, themeIconClass, toggleClass, containerAttrs string) template.HTML { +func sessionMenuHTML(id, class, bodyClass, itemClass, toggleID, themeIconClass, toggleClass, versionStatusID, containerAttrs string) template.HTML { return renderLiveMenu(liveMenuData{ ID: id, Class: class, @@ -115,6 +120,11 @@ func sessionMenuHTML(id, class, bodyClass, itemClass, toggleID, themeIconClass, {Label: "GitHub", Href: "https://github.com/ygncode/pi-web", Attrs: `target="_blank" rel="noreferrer" role="menuitem"`}, }}, {Items: []liveMenuItem{ + { + Label: "Version", + Suffix: template.HTML(""), + Attrs: `data-action="version" data-version-row role="menuitem"`, + }, {Label: "Settings", Suffix: "", Muted: true, Attrs: `role="menuitem"`}, }}, }, @@ -130,6 +140,7 @@ func sessionDesktopMenuHTML() template.HTML { "command-menu-notify-status", "command-menu-theme-icon", "command-menu-toggle", + "command-menu-version-status", `role="menu" aria-labelledby="command-menu-btn" style="display: none;"`, ) } @@ -143,6 +154,7 @@ func sessionMobileMenuHTML() template.HTML { "mobile-command-notify-status", "mobile-command-theme-icon", "mobile-command-toggle", + "mobile-command-version-status", `style="display: none;"`, ) } diff --git a/internal/ui/live_templates/styles/menu.css b/internal/ui/live_templates/styles/menu.css index 3875a8e..6356045 100644 --- a/internal/ui/live_templates/styles/menu.css +++ b/internal/ui/live_templates/styles/menu.css @@ -338,12 +338,199 @@ border: 1px solid var(--pi-menu-border, #292a33) !important; color: var(--text, #e6e7eb) !important; } - + .sound-selector option { background-color: var(--surface-2, #191920); color: var(--text, #e6e7eb); } } +/* ── Version row status + update badge ─────────────────────────────────── */ +.version-status { + margin-left: auto; + font-size: 10px; + color: var(--pi-menu-muted-color, var(--muted)); + flex-shrink: 0; + white-space: nowrap; +} + +.version-status.has-update { + color: var(--pi-menu-accent-color, var(--accent)); + font-weight: 600; +} + +/* Small dot badge on the row label when an update is waiting. */ +[data-version-row].has-update > span:first-child::after { + content: ''; + display: inline-block; + width: 6px; + height: 6px; + margin-left: 6px; + border-radius: 50%; + background: var(--pi-menu-accent-color, var(--accent)); + vertical-align: middle; +} + +@media (max-width: 900px) { + .version-status { + font-size: 13px !important; + color: var(--muted) !important; + } + .version-status.has-update { + color: var(--accent) !important; + } +} + +/* ── Version / update modal ────────────────────────────────────────────── */ +.version-modal-overlay { + position: fixed; + inset: 0; + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; +} + +.version-modal-overlay.open { + opacity: 1; + pointer-events: auto; +} + +.version-modal { + width: 100%; + max-width: 440px; + max-height: min(80vh, 640px); + display: flex; + flex-direction: column; + background: var(--surface, #15151b); + border: 1px solid var(--pi-menu-border, #292a33); + border-radius: 12px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.45); + transform: translateY(8px) scale(0.98); + transition: transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1); + overflow: hidden; +} + +.version-modal-overlay.open .version-modal { + transform: translateY(0) scale(1); +} + +.version-modal-header { + display: flex; + align-items: center; + gap: 8px; + padding: 14px 16px; + border-bottom: 1px solid var(--pi-menu-border, #292a33); +} + +.version-modal-title { + font-weight: 700; + font-size: 14px; + color: var(--text, #e6e7eb); +} + +.version-modal-current { + font-size: 12px; + color: var(--muted, #858a96); +} + +.version-modal-close { + margin-left: auto; + border: 0; + background: transparent; + color: var(--muted, #858a96); + font-size: 20px; + line-height: 1; + cursor: pointer; + padding: 0 4px; +} + +.version-modal-close:hover { color: var(--text, #e6e7eb); } + +.version-modal-body { + padding: 16px; + overflow-y: auto; + font-size: 13px; + color: var(--text, #e6e7eb); + line-height: 1.5; +} + +.version-modal-lead { margin: 0 0 12px; } +.version-modal-notes { margin: 10px 0 0; font-size: 12px; color: var(--muted, #858a96); } +.version-modal-notes a, +.version-changelog a { color: var(--accent, #9cc7c0); } + +.version-changelog { + margin-top: 4px; + padding: 12px; + background: var(--surface-2, #191920); + border: 1px solid var(--pi-menu-border, #292a33); + border-radius: 8px; + max-height: 280px; + overflow-y: auto; +} + +.version-changelog h4 { margin: 12px 0 6px; font-size: 13px; } +.version-changelog h4:first-child { margin-top: 0; } +.version-changelog p { margin: 6px 0; } +.version-changelog ul { margin: 6px 0; padding-left: 18px; } +.version-changelog li { margin: 3px 0; } +.version-changelog code { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + padding: 1px 4px; + background: rgba(127, 127, 127, 0.18); + border-radius: 4px; +} +.version-changelog-empty { color: var(--muted, #858a96); margin: 0; } + +.version-modal-status { + padding: 0 16px 4px; + font-size: 12px; + color: var(--muted, #858a96); +} +.version-modal-status.error { color: #e06c75; } +.version-modal-status.info { color: var(--accent, #9cc7c0); } + +.version-modal-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + padding: 12px 16px 16px; + border-top: 1px solid var(--pi-menu-border, #292a33); +} + +.version-modal-btn { + font-family: inherit; + font-size: 13px; + padding: 8px 14px; + border-radius: 8px; + cursor: pointer; + border: 1px solid var(--pi-menu-border, #292a33); + background: transparent; + color: var(--text, #e6e7eb); + transition: background 0.15s ease, opacity 0.15s ease; +} + +.version-modal-btn.primary { + background: var(--accent, #9cc7c0); + border-color: var(--accent, #9cc7c0); + color: #0c0c10; + font-weight: 600; +} + +.version-modal-btn.ghost:hover { background: var(--pi-menu-hover-bg, rgba(127,127,127,0.12)); } +.version-modal-btn.primary:hover { opacity: 0.9; } +.version-modal-btn:disabled { opacity: 0.5; cursor: default; } + +body.version-modal-open { overflow: hidden; } + diff --git a/internal/updater/updater.go b/internal/updater/updater.go new file mode 100644 index 0000000..1d610ad --- /dev/null +++ b/internal/updater/updater.go @@ -0,0 +1,344 @@ +// Package updater checks whether a newer pi-web release is available. It +// compares the build-time version against the npm registry's published +// version (the install channel) and fetches the matching changelog from the +// GitHub Releases API. Results are cached in memory and refreshed by a +// background poll; callers can also force an immediate check. +package updater + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "regexp" + "strconv" + "strings" + "sync" + "time" +) + +const ( + defaultNPMURL = "https://registry.npmjs.org/@ygncode/pi-web" + defaultGitHubAPI = "https://api.github.com/repos/ygncode/pi-web" + // npmChannel is the dist-tag pi-web installs from (see pi install command). + npmChannel = "beta" + // PollInterval is how often the background goroutine refreshes the cache. + PollInterval = 6 * time.Hour + httpTimeout = 10 * time.Second +) + +// Info is the snapshot returned to the API layer (and marshalled to JSON). +type Info struct { + Current string `json:"current"` + Latest string `json:"latest"` + HasUpdate bool `json:"hasUpdate"` + IsDev bool `json:"isDev"` + Changelog string `json:"changelog"` + ChangelogURL string `json:"changelogUrl"` + CheckedAt string `json:"checkedAt"` +} + +// devVersionRe matches `git describe` development builds: a tag followed by a +// commits-ahead count and an abbreviated SHA (e.g. "-3-gd7e8bf2"), optionally +// "-dirty". Clean release builds are exactly the tag and don't match. +var devVersionRe = regexp.MustCompile(`-\d+-g[0-9a-f]{7,}|-dirty$`) + +// Checker holds the current version and the cached result of the last remote +// check. It is safe for concurrent use. +type Checker struct { + current string + npmURL string + githubAPI string + client *http.Client + + mu sync.RWMutex + latest string + changelog string + changelogURL string + checkedAt time.Time +} + +// New builds a Checker for the given build-time version. version "dev" (or +// empty) disables remote checks — Info always reports no update available. +func New(version string) *Checker { + if version == "" { + version = "dev" + } + return &Checker{ + current: version, + npmURL: defaultNPMURL, + githubAPI: defaultGitHubAPI, + client: &http.Client{Timeout: httpTimeout}, + } +} + +// isDev reports whether the current build is a local/dev build that should +// never be compared against published releases. This covers the literal "dev" +// sentinel as well as `git describe` builds that are ahead of a tag or dirty — +// updating those would silently downgrade local work to the published release. +func (c *Checker) isDev() bool { + return c.current == "" || c.current == "dev" || devVersionRe.MatchString(c.current) +} + +// Info returns the current cached snapshot without making network calls. +func (c *Checker) Info() Info { + c.mu.RLock() + defer c.mu.RUnlock() + return c.snapshotLocked() +} + +func (c *Checker) snapshotLocked() Info { + info := Info{ + Current: c.current, + Latest: c.latest, + IsDev: c.isDev(), + Changelog: c.changelog, + ChangelogURL: c.changelogURL, + } + if !c.checkedAt.IsZero() { + info.CheckedAt = c.checkedAt.UTC().Format(time.RFC3339) + } + if !c.isDev() && c.latest != "" { + info.HasUpdate = compareSemver(c.latest, c.current) > 0 + } + return info +} + +// Check performs a fresh remote fetch and updates the cache. It returns the +// resulting snapshot. For dev builds it short-circuits and only stamps +// checkedAt so the UI can show "checked just now". +func (c *Checker) Check(ctx context.Context) (Info, error) { + if c.isDev() { + c.mu.Lock() + c.checkedAt = time.Now() + info := c.snapshotLocked() + c.mu.Unlock() + return info, nil + } + + latest, err := c.fetchLatestVersion(ctx) + if err != nil { + return c.Info(), err + } + + var changelog, changelogURL string + if compareSemver(latest, c.current) > 0 { + changelog, changelogURL = c.fetchChangelog(ctx, latest) + } + + c.mu.Lock() + c.latest = latest + if changelog != "" || changelogURL != "" { + c.changelog = changelog + c.changelogURL = changelogURL + } + c.checkedAt = time.Now() + info := c.snapshotLocked() + c.mu.Unlock() + return info, nil +} + +// Start runs an initial check shortly after launch, then refreshes every +// PollInterval until ctx is cancelled. Intended to be run in its own goroutine. +func (c *Checker) Start(ctx context.Context) { + if c.isDev() { + return + } + // Small delay so startup isn't blocked on the network. + timer := time.NewTimer(3 * time.Second) + defer timer.Stop() + for { + select { + case <-ctx.Done(): + return + case <-timer.C: + checkCtx, cancel := context.WithTimeout(ctx, httpTimeout*2) + _, _ = c.Check(checkCtx) + cancel() + timer.Reset(PollInterval) + } + } +} + +// fetchLatestVersion reads the published version for the install channel from +// the npm registry packument (dist-tags), falling back to "latest". +func (c *Checker) fetchLatestVersion(ctx context.Context) (string, error) { + body, err := c.get(ctx, c.npmURL, "") + if err != nil { + return "", err + } + var doc struct { + DistTags map[string]string `json:"dist-tags"` + } + if err := json.Unmarshal(body, &doc); err != nil { + return "", fmt.Errorf("parse npm packument: %w", err) + } + if v := doc.DistTags[npmChannel]; v != "" { + return v, nil + } + if v := doc.DistTags["latest"]; v != "" { + return v, nil + } + return "", fmt.Errorf("no published version found for @ygncode/pi-web") +} + +// fetchChangelog tries the version-specific GitHub release first, then the +// generic "latest release". Failures are non-fatal — an empty changelog just +// means the UI shows the update without release notes. +func (c *Checker) fetchChangelog(ctx context.Context, version string) (body, url string) { + tag := "v" + strings.TrimPrefix(version, "v") + if rel, err := c.fetchRelease(ctx, c.githubAPI+"/releases/tags/"+tag); err == nil { + return rel.Body, rel.HTMLURL + } + if rel, err := c.fetchRelease(ctx, c.githubAPI+"/releases/latest"); err == nil { + return rel.Body, rel.HTMLURL + } + return "", "" +} + +type githubRelease struct { + Body string `json:"body"` + HTMLURL string `json:"html_url"` +} + +func (c *Checker) fetchRelease(ctx context.Context, url string) (githubRelease, error) { + var rel githubRelease + body, err := c.get(ctx, url, githubToken()) + if err != nil { + return rel, err + } + if err := json.Unmarshal(body, &rel); err != nil { + return rel, err + } + return rel, nil +} + +func (c *Checker) get(ctx context.Context, url, bearer string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "pi-web-updater") + if bearer != "" { + req.Header.Set("Authorization", "Bearer "+bearer) + } + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("GET %s: HTTP %d", url, resp.StatusCode) + } + return body, nil +} + +func githubToken() string { + return os.Getenv("GITHUB_TOKEN") +} + +// compareSemver compares two semver strings (optionally "v"-prefixed, with an +// optional prerelease suffix like "-beta.24"). It returns -1, 0, or 1. +// A release version outranks any prerelease with the same core (per semver). +func compareSemver(a, b string) int { + coreA, preA := splitVersion(a) + coreB, preB := splitVersion(b) + + for i := 0; i < 3; i++ { + if coreA[i] != coreB[i] { + if coreA[i] < coreB[i] { + return -1 + } + return 1 + } + } + return comparePrerelease(preA, preB) +} + +// splitVersion parses "v1.2.3-beta.4" into [1,2,3] and "beta.4". Missing +// numeric parts default to 0; unparseable parts are treated as 0. +func splitVersion(v string) ([3]int, string) { + v = strings.TrimSpace(v) + v = strings.TrimPrefix(v, "v") + core := v + pre := "" + if i := strings.IndexByte(v, '-'); i >= 0 { + core = v[:i] + pre = v[i+1:] + } + // Drop build metadata. + if i := strings.IndexByte(core, '+'); i >= 0 { + core = core[:i] + } + if i := strings.IndexByte(pre, '+'); i >= 0 { + pre = pre[:i] + } + var nums [3]int + for i, part := range strings.SplitN(core, ".", 3) { + if i > 2 { + break + } + nums[i], _ = strconv.Atoi(strings.TrimSpace(part)) + } + return nums, pre +} + +// comparePrerelease implements semver prerelease precedence: no prerelease +// outranks a prerelease; otherwise dot-separated identifiers are compared, +// numeric < non-numeric, numerics compared as integers. +func comparePrerelease(a, b string) int { + if a == b { + return 0 + } + if a == "" { + return 1 // release > prerelease + } + if b == "" { + return -1 + } + pa := strings.Split(a, ".") + pb := strings.Split(b, ".") + for i := 0; i < len(pa) && i < len(pb); i++ { + if c := compareIdentifier(pa[i], pb[i]); c != 0 { + return c + } + } + switch { + case len(pa) < len(pb): + return -1 + case len(pa) > len(pb): + return 1 + default: + return 0 + } +} + +func compareIdentifier(a, b string) int { + na, errA := strconv.Atoi(a) + nb, errB := strconv.Atoi(b) + bothNumeric := errA == nil && errB == nil + switch { + case bothNumeric: + switch { + case na < nb: + return -1 + case na > nb: + return 1 + default: + return 0 + } + case errA == nil: // numeric < non-numeric + return -1 + case errB == nil: + return 1 + default: + return strings.Compare(a, b) + } +} diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go new file mode 100644 index 0000000..2abb15e --- /dev/null +++ b/internal/updater/updater_test.go @@ -0,0 +1,141 @@ +package updater + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestCompareSemver(t *testing.T) { + tests := []struct { + a, b string + want int + }{ + {"1.2.3", "1.2.3", 0}, + {"1.2.4", "1.2.3", 1}, + {"1.2.3", "1.2.4", -1}, + {"1.3.0", "1.2.9", 1}, + {"2.0.0", "1.9.9", 1}, + {"v1.2.3", "1.2.3", 0}, + {"1.2.3", "v1.2.3", 0}, + // prerelease precedence: release > prerelease of same core + {"1.2.3", "1.2.3-beta.1", 1}, + {"1.2.3-beta.1", "1.2.3", -1}, + {"0.0.1-beta.25", "0.0.1-beta.24", 1}, + {"0.0.1-beta.24", "0.0.1-beta.25", -1}, + {"0.0.1-beta.24", "0.0.1-beta.24", 0}, + // numeric identifiers compare as ints, not strings + {"1.0.0-beta.10", "1.0.0-beta.9", 1}, + // build metadata ignored + {"1.2.3+abc", "1.2.3+def", 0}, + // fewer prerelease fields < more + {"1.0.0-beta", "1.0.0-beta.1", -1}, + } + for _, tt := range tests { + if got := compareSemver(tt.a, tt.b); got != tt.want { + t.Errorf("compareSemver(%q,%q)=%d want %d", tt.a, tt.b, got, tt.want) + } + } +} + +func TestIsDevDetectsLocalBuilds(t *testing.T) { + dev := []string{ + "dev", + "", + "v0.0.1-beta.24-3-gd7e8bf2-dirty", + "v0.0.1-beta.24-3-gd7e8bf2", + "0.0.1-beta.24-dirty", + } + for _, v := range dev { + if !New(v).isDev() { + t.Errorf("isDev(%q)=false, want true", v) + } + } + release := []string{ + "v0.0.1-beta.24", + "0.0.1-beta.24", + "1.2.3", + "v1.2.3", + } + for _, v := range release { + if New(v).isDev() { + t.Errorf("isDev(%q)=true, want false", v) + } + } +} + +func TestInfoDevNoUpdate(t *testing.T) { + c := New("dev") + info, err := c.Check(context.Background()) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if info.HasUpdate { + t.Errorf("dev build should never report an update") + } + if info.Current != "dev" { + t.Errorf("Current=%q want dev", info.Current) + } + if info.CheckedAt == "" { + t.Errorf("CheckedAt should be stamped even for dev") + } +} + +func TestCheckHasUpdate(t *testing.T) { + npm := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"dist-tags":{"beta":"0.0.1-beta.25","latest":"0.0.1-beta.20"}}`)) + })) + defer npm.Close() + gh := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"body":"## v0.0.1-beta.25\n- fix things","html_url":"https://example/release"}`)) + })) + defer gh.Close() + + c := New("0.0.1-beta.24") + c.npmURL = npm.URL + c.githubAPI = gh.URL + + info, err := c.Check(context.Background()) + if err != nil { + t.Fatalf("Check err: %v", err) + } + if !info.HasUpdate { + t.Fatalf("expected HasUpdate true, got %+v", info) + } + if info.Latest != "0.0.1-beta.25" { + t.Errorf("Latest=%q want 0.0.1-beta.25", info.Latest) + } + if info.Changelog == "" || info.ChangelogURL == "" { + t.Errorf("expected changelog populated, got %+v", info) + } + // Cached Info() should match. + if c.Info().Latest != info.Latest { + t.Errorf("cached Info() not updated") + } +} + +func TestCheckUpToDate(t *testing.T) { + npm := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"dist-tags":{"beta":"0.0.1-beta.24"}}`)) + })) + defer npm.Close() + // GitHub should not be needed when up to date; fail loudly if hit. + gh := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("github should not be queried when up to date") + w.WriteHeader(http.StatusInternalServerError) + })) + defer gh.Close() + + c := New("0.0.1-beta.24") + c.npmURL = npm.URL + c.githubAPI = gh.URL + + info, err := c.Check(context.Background()) + if err != nil { + t.Fatalf("Check err: %v", err) + } + if info.HasUpdate { + t.Errorf("expected no update, got %+v", info) + } +} diff --git a/web/src/index/index.js b/web/src/index/index.js index b071740..335e558 100644 --- a/web/src/index/index.js +++ b/web/src/index/index.js @@ -11,6 +11,7 @@ import { import { setupKeyboardNav } from '../shared/keyboard-nav.js'; import { toggleTheme, syncThemeIcons } from '../shared/theme.js'; import { setupSessionListPalette } from '../shared/session-list-palette.js'; +import { createVersionController } from '../shared/version.js'; export { createSessionsPage }; @@ -28,6 +29,8 @@ export function runIndexPage({ setupKeyboardNav({ windowImpl, documentImpl }); + createVersionController({ documentImpl, windowImpl }); + const openSearchBtn = documentImpl.getElementById('open-search'); const menuBtn = documentImpl.getElementById('web-menu-btn'); const webMenu = documentImpl.getElementById('web-menu'); diff --git a/web/src/session/live/command-menu.js b/web/src/session/live/command-menu.js index f9cd588..1ebaf10 100644 --- a/web/src/session/live/command-menu.js +++ b/web/src/session/live/command-menu.js @@ -2,6 +2,7 @@ import { isDoneNotifyEnabled, setupSoundSelector } from '../chat/done-notifier.j import { applyTheme, toggleTheme, syncThemeIcons } from '../../shared/theme.js'; import { showModelUsageModal } from './model-usage-modal.js'; import { showForkModal } from './fork-modal.js'; +import { openVersionModal } from '../../shared/version.js'; export { applyTheme, toggleTheme, syncThemeIcons }; @@ -333,6 +334,10 @@ export function setupCommandMenu({ .catch(() => showToast('Clone failed', documentImpl, windowImpl)); break; } + case 'version': + closeMenu(); + openVersionModal(); + break; case 'diff': showToast('Not yet implemented', documentImpl, windowImpl); closeMenu(); diff --git a/web/src/session/live/mobile-command-panel.js b/web/src/session/live/mobile-command-panel.js index abea85e..ecd9ebe 100644 --- a/web/src/session/live/mobile-command-panel.js +++ b/web/src/session/live/mobile-command-panel.js @@ -1,5 +1,6 @@ import { isDoneNotifyEnabled } from '../chat/done-notifier.js'; import { showModelUsageModal } from './model-usage-modal.js'; +import { openVersionModal } from '../../shared/version.js'; export function setupMobileCommandPanel({ documentImpl = document, @@ -127,6 +128,10 @@ export function setupMobileCommandPanel({ closePanel(); break; } + case 'version': + closePanel(); + openVersionModal(); + break; case 'rename': case 'fork': case 'clone': diff --git a/web/src/session/session.js b/web/src/session/session.js index 5309fc8..acfbfe5 100644 --- a/web/src/session/session.js +++ b/web/src/session/session.js @@ -31,6 +31,7 @@ import * as newSessionButton from './live/new-session-button.js'; import * as liveEvents from './live/live-events.js'; import * as liveRenderer from './live/live-renderer.js'; import { setupCommandMenu } from './live/command-menu.js'; +import { createVersionController } from '../shared/version.js'; import { setupKeyboardNav } from '../shared/keyboard-nav.js'; import { toggleTheme, syncThemeIcons } from '../shared/theme.js'; import { setupSessionListPalette } from '../shared/session-list-palette.js'; @@ -325,6 +326,8 @@ export function runSessionApp({ target = window } = {}) { setupKeyboardNav({ windowImpl: target, documentImpl }); + createVersionController({ documentImpl, windowImpl: target }); + setupCommandMenu({ documentImpl, windowImpl: target, diff --git a/web/src/shared/version.js b/web/src/shared/version.js new file mode 100644 index 0000000..a8d0187 --- /dev/null +++ b/web/src/shared/version.js @@ -0,0 +1,329 @@ +import { escapeHtml } from './escape.js'; + +// Module-level reference to the page's single controller so menu dispatchers +// (which only know the action name) can open the modal without threading a +// callback through every layer. +let active = null; + +export function openVersionModal() { + active?.openModal(); +} + +// renderChangelog converts a GitHub release body (markdown) into a small, +// XSS-safe HTML fragment. Everything is escaped first; only a handful of +// line-level constructs are then re-expanded. +export function renderChangelog(markdown) { + if (!markdown) return '

No release notes.

'; + const lines = String(markdown).replace(/\r\n/g, '\n').split('\n'); + const out = []; + let inList = false; + const closeList = () => { + if (inList) { + out.push(''); + inList = false; + } + }; + for (const raw of lines) { + const line = raw.trimEnd(); + const heading = line.match(/^#{1,6}\s+(.*)$/); + const bullet = line.match(/^\s*[-*]\s+(.*)$/); + if (heading) { + closeList(); + out.push(`

${inline(heading[1])}

`); + } else if (bullet) { + if (!inList) { + out.push('