Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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) {
Expand All @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
69 changes: 69 additions & 0 deletions internal/app/update.go
Original file line number Diff line number Diff line change
@@ -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`
23 changes: 23 additions & 0 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"pi-web/internal/render"
"pi-web/internal/rpc"
"pi-web/internal/sessions"
"pi-web/internal/updater"

_ "modernc.org/sqlite"
)
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
81 changes: 81 additions & 0 deletions internal/server/update.go
Original file line number Diff line number Diff line change
@@ -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()
}()
}
112 changes: 112 additions & 0 deletions internal/server/update_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading
Loading