server+admin: per-instance auto-update settings + availability check (#382)
* server: signed release-channel manifest + publishing tooling
Groundwork for instance auto-updating: server builds publish to the public
releases repo with an Ed25519-signed manifest.json feed carrying three channel
pointers (edge / beta / stable). The signature covers the exact base64 payload
bytes; the payload carries each binary's sha256, so binaries need no separate
signature and a replayed stale feed is detectable via generated_at.
- release.go: manifest format, sign/verify, baked public key (private seed is
in 1Password), and the lchat release keygen|sign|verify|payload subcommands
- deploy/release-server.sh: publish (build HEAD -> server-<sha> release ->
point edge) and promote <ver> beta|stable (manifest edit, no rebuild)
- docs/server-releases.md: channels, publishing flow, trust model
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* server+admin: per-instance auto-update settings + availability check
Admins choose how their space follows server releases: install updates
automatically (default on), download new versions automatically (default on),
and which channel to track (stable default; beta/edge opt-in). Settings live
in the instance config table and are exposed three ways:
- GET/POST /api/admin/settings (both admin consoles read+write them)
- GET /api/update-prefs, un-authed: what the box updater reads over the
instance's unix socket (an old binary 404ing = defaults)
- a 6-hourly signed-manifest check caches availability in memory so the
admin consoles can say 'a new version is available on <channel>'
Both admin surfaces get an Updates section (standalone /admin page + the
in-chat Admin app via the host bridge), with inline status lines, no toasts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* server: validate update_channel before any settings write (review fix)
A combined request like {user_invites:true, update_channel:nightly} was
persisting the invite flip and then 400ing — the validation ran after three
pre-existing field writes. Validate first, write after; test now covers the
combined-field case instead of just documenting it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
9 files changed,
+519
−24
changelog/2026-07-08-auto-updates.md+5 −0db.go+27 −0main.go+42 −4updatecheck.go+100 −0updatecheck_test.go+130 −0web/pages/admin.html+25 −0web/static/admin.js+60 −0web/static/apps/admin.js+109 −19web/static/shell.js+21 −1
diff --git a/changelog/2026-07-08-auto-updates.md b/changelog/2026-07-08-auto-updates.md| new file mode 100644 |
| index 0000000..957bcea |
| --- /dev/null |
| +++ b/changelog/2026-07-08-auto-updates.md |
| @@ -0,0 +1,5 @@ |
| +🤖 Your space can now keep itself up to date. Admins get a new Updates section |
| +in the admin panel: choose a release channel — Stable (well tested, the |
| +default), Beta (features a little earlier), or Edge (the bleeding edge) — and |
| +whether updates download and install automatically. Updates take seconds and |
| +never touch your messages. |
diff --git a/db.go b/db.go| index 630931e..9a9f686 100644 |
| --- a/db.go |
| +++ b/db.go |
| @@ -106,6 +106,33 @@ func (db *DB) setUsersCanInvite(on bool) error { |
| return db.setConfig("user_invites_enabled", v) |
| } |
| +// Update settings: whether this instance follows its release channel (see |
| +// docs/server-releases.md). Unlike usersCanInvite, the booleans default ON — |
| +// an absent key reads true — so a fresh instance keeps itself current unless |
| +// the admin opts out. The box updater reads these via GET /api/update-prefs. |
| +func (db *DB) updateAuto() bool { |
| + v, ok, _ := db.getConfig("update_auto") |
| + return !ok || v == "1" |
| +} |
| +func (db *DB) setUpdateAuto(on bool) error { return db.setConfig("update_auto", boolStr(on)) } |
| + |
| +func (db *DB) updateDownload() bool { |
| + v, ok, _ := db.getConfig("update_download") |
| + return !ok || v == "1" |
| +} |
| +func (db *DB) setUpdateDownload(on bool) error { return db.setConfig("update_download", boolStr(on)) } |
| + |
| +// updateChannel is the release channel this instance tracks; anything unset or |
| +// unrecognised (e.g. a channel later renamed) falls back to stable. |
| +func (db *DB) updateChannel() string { |
| + v, _, _ := db.getConfig("update_channel") |
| + if !validChannel(v) { |
| + return "stable" |
| + } |
| + return v |
| +} |
| +func (db *DB) setUpdateChannel(c string) error { return db.setConfig("update_channel", c) } |
| + |
| // DB wraps the SQLite connection. Note: the server never stores message |
| // plaintext or any encryption key. Messages live here only as opaque |
| // ciphertext blobs produced by the browser. |
diff --git a/main.go b/main.go| index 531597c..57912fb 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -96,6 +96,9 @@ type Server struct { |
| // reading on any one of them — not just the exact device they're on. |
| active sync.Map |
| activeUsers sync.Map |
| + // upd caches the last verified release-manifest fetch, so the admin console |
| + // can say whether the tracked channel has something newer (updatecheck.go). |
| + upd updateState |
| } |
| func main() { |
| @@ -231,6 +234,7 @@ func main() { |
| // Mini-app scheduling: deliver due "future messages" (miniapps.go). Tests |
| // call sweepDueScheduled directly, so only the real server runs the ticker. |
| go s.runScheduler() |
| + go s.runUpdateChecker() |
| ln, where, err := listen(*addr, *socket) |
| if err != nil { |
| @@ -404,6 +408,7 @@ func (s *Server) handler() http.Handler { |
| // Frontend build version — clients poll this on returning to the app and reload |
| // if it changed (auto-update). No auth: it's just a build hash. |
| mux.HandleFunc("GET /api/version", s.getVersion) |
| + mux.HandleFunc("GET /api/update-prefs", s.getUpdatePrefs) |
| mux.HandleFunc("GET /api/changelog", s.getChangelog) |
| // The mini-app authoring guide. Static documentation compiled into the |
| // binary — never instance data — so, like /api/version, it isn't token-gated: |
| @@ -2460,10 +2465,14 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| writeJSON(w, http.StatusOK, map[string]any{ |
| - "user_invites": s.db.usersCanInvite(), |
| - "admin_name": s.db.getAdminName(), |
| - "admin_email": s.db.getAdminEmail(), |
| - "setup_complete": s.db.setupComplete(), |
| + "user_invites": s.db.usersCanInvite(), |
| + "admin_name": s.db.getAdminName(), |
| + "admin_email": s.db.getAdminEmail(), |
| + "setup_complete": s.db.setupComplete(), |
| + "update_auto": s.db.updateAuto(), |
| + "update_download": s.db.updateDownload(), |
| + "update_channel": s.db.updateChannel(), |
| + "update": s.updateInfo(), |
| }) |
| } |
| @@ -2480,11 +2489,22 @@ func (s *Server) setSettings(w http.ResponseWriter, r *http.Request) { |
| // The photo's alt text, editable without re-uploading the image (the |
| // admin panel's alt field). Plaintext branding, like the photo itself. |
| AvatarAlt *string `json:"avatar_alt"` |
| + // Auto-update prefs (docs/server-releases.md); the box updater acts on |
| + // them, the instance just stores + reports them. |
| + UpdateAuto *bool `json:"update_auto"` |
| + UpdateDownload *bool `json:"update_download"` |
| + UpdateChannel *string `json:"update_channel"` |
| } |
| if json.NewDecoder(r.Body).Decode(&body) != nil { |
| httpError(w, http.StatusBadRequest, "bad request") |
| return |
| } |
| + // Validate everything before writing anything, so a rejected request never |
| + // half-applies (fields are independent config keys, not one transaction). |
| + if body.UpdateChannel != nil && !validChannel(*body.UpdateChannel) { |
| + httpError(w, http.StatusBadRequest, "unknown channel (edge, beta, or stable)") |
| + return |
| + } |
| if body.UserInvites != nil { |
| if err := s.db.setUsersCanInvite(*body.UserInvites); err != nil { |
| httpError(w, http.StatusInternalServerError, "db error") |
| @@ -2503,6 +2523,24 @@ func (s *Server) setSettings(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| } |
| + if body.UpdateAuto != nil { |
| + if err := s.db.setUpdateAuto(*body.UpdateAuto); err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + } |
| + if body.UpdateDownload != nil { |
| + if err := s.db.setUpdateDownload(*body.UpdateDownload); err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + } |
| + if body.UpdateChannel != nil { |
| + if err := s.db.setUpdateChannel(*body.UpdateChannel); err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + } |
| writeJSON(w, http.StatusOK, map[string]any{"ok": true, "user_invites": s.db.usersCanInvite()}) |
| } |
diff --git a/updatecheck.go b/updatecheck.go| new file mode 100644 |
| index 0000000..75d88d1 |
| --- /dev/null |
| +++ b/updatecheck.go |
| @@ -0,0 +1,100 @@ |
| +package main |
| + |
| +import ( |
| + "fmt" |
| + "io" |
| + "net/http" |
| + "sync" |
| + "time" |
| +) |
| + |
| +// Instance-side update awareness. The instance never applies updates itself — |
| +// it runs sandboxed and can't restart anything; the box updater (`lchat update |
| +// run`, root) does the staging and swapping. The instance's two jobs here: |
| +// |
| +// - Tell the box updater what the admin chose: GET /api/update-prefs (read |
| +// over the instance's unix socket, like the deploy health checks). Un-authed |
| +// by design — it reveals less than the already-public /api/version build id, |
| +// and an old binary 404ing means "defaults" (auto-update on, stable). |
| +// - Know whether its channel has something newer, so the admin console can |
| +// say "a new version is available" — a periodic fetch+verify of the signed |
| +// release manifest (release.go), surfaced through GET /api/admin/settings. |
| +// A fetch failure just leaves the last result stale; nothing acts on it. |
| + |
| +// updateState is the last verified manifest fetch, guarded by mu — written by |
| +// the checker goroutine, read by getSettings. |
| +type updateState struct { |
| + mu sync.Mutex |
| + manifest *releaseManifest |
| + checkedAt time.Time |
| +} |
| + |
| +// getUpdatePrefs reports the admin's update settings to the box updater. |
| +func (s *Server) getUpdatePrefs(w http.ResponseWriter, r *http.Request) { |
| + writeJSON(w, http.StatusOK, map[string]any{ |
| + "auto_update": s.db.updateAuto(), |
| + "auto_download": s.db.updateDownload(), |
| + "channel": s.db.updateChannel(), |
| + }) |
| +} |
| + |
| +// runUpdateChecker refreshes the availability info: once shortly after boot |
| +// (so a fresh admin console isn't blank for hours), then every 6h. The box |
| +// updater runs its own checks; this one is display-only, so failures are |
| +// logged by the caller of checkForUpdate and otherwise ignored. |
| +func (s *Server) runUpdateChecker() { |
| + time.Sleep(time.Minute) |
| + for { |
| + _ = s.checkForUpdate() |
| + time.Sleep(6 * time.Hour) |
| + } |
| +} |
| + |
| +// checkForUpdate fetches and verifies the release manifest, keeping the whole |
| +// channel map — so an admin switching channels sees that channel's version |
| +// immediately, without waiting for the next fetch. |
| +func (s *Server) checkForUpdate() error { |
| + pub, err := releasePubKey() |
| + if err != nil { |
| + return err |
| + } |
| + client := &http.Client{Timeout: 30 * time.Second} |
| + resp, err := client.Get(releaseManifestURL) |
| + if err != nil { |
| + return err |
| + } |
| + defer resp.Body.Close() |
| + if resp.StatusCode != http.StatusOK { |
| + return fmt.Errorf("fetch manifest: %s", resp.Status) |
| + } |
| + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) |
| + if err != nil { |
| + return err |
| + } |
| + m, err := verifyReleaseManifest(pub, raw) |
| + if err != nil { |
| + return err |
| + } |
| + s.upd.mu.Lock() |
| + s.upd.manifest, s.upd.checkedAt = m, time.Now() |
| + s.upd.mu.Unlock() |
| + return nil |
| +} |
| + |
| +// updateInfo is the availability block getSettings serves the admin consoles: |
| +// what we run, what the tracked channel offers, and how fresh that knowledge is. |
| +func (s *Server) updateInfo() map[string]any { |
| + info := map[string]any{"build": buildVersion} |
| + s.upd.mu.Lock() |
| + m, checkedAt := s.upd.manifest, s.upd.checkedAt |
| + s.upd.mu.Unlock() |
| + if m == nil { |
| + return info // no successful check yet — the console shows just the build |
| + } |
| + info["checked_at"] = checkedAt.Unix() |
| + if e, ok := m.Channels[s.db.updateChannel()]; ok { |
| + info["available_version"] = e.Version |
| + info["available_notes"] = e.Notes |
| + } |
| + return info |
| +} |
diff --git a/updatecheck_test.go b/updatecheck_test.go| new file mode 100644 |
| index 0000000..6b28610 |
| --- /dev/null |
| +++ b/updatecheck_test.go |
| @@ -0,0 +1,130 @@ |
| +package main |
| + |
| +import ( |
| + "crypto/ed25519" |
| + "crypto/rand" |
| + "encoding/base64" |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestUpdatePrefsDefaultsAndSettings(t *testing.T) { |
| + ts, _ := newTestServer(t) |
| + |
| + // Defaults: auto-update on, auto-download on, stable channel — un-authed, |
| + // this is what the box updater reads over the instance socket. |
| + var prefs struct { |
| + AutoUpdate bool `json:"auto_update"` |
| + AutoDownload bool `json:"auto_download"` |
| + Channel string `json:"channel"` |
| + } |
| + json.NewDecoder(mustGet(t, ts.URL+"/api/update-prefs").Body).Decode(&prefs) |
| + if !prefs.AutoUpdate || !prefs.AutoDownload || prefs.Channel != "stable" { |
| + t.Fatalf("defaults = %+v, want on/on/stable", prefs) |
| + } |
| + |
| + // An unknown channel is rejected, and rejected atomically — a combined |
| + // request must not half-apply its valid fields before the 400. |
| + if resp := adminPost(t, ts.URL+"/api/admin/settings", `{"user_invites":true,"update_channel":"nightly"}`); resp.StatusCode != http.StatusBadRequest { |
| + t.Fatalf("bad channel: want 400, got %d", resp.StatusCode) |
| + } |
| + var check struct { |
| + UserInvites bool `json:"user_invites"` |
| + } |
| + json.NewDecoder(adminGet(t, ts.URL+"/api/admin/settings").Body).Decode(&check) |
| + if check.UserInvites { |
| + t.Fatal("rejected request still flipped user_invites (partial write)") |
| + } |
| + |
| + // Partial update: flip auto-update off + move to edge; download untouched. |
| + if resp := adminPost(t, ts.URL+"/api/admin/settings", `{"update_auto":false,"update_channel":"edge"}`); resp.StatusCode != http.StatusOK { |
| + t.Fatalf("set: %d", resp.StatusCode) |
| + } |
| + json.NewDecoder(mustGet(t, ts.URL+"/api/update-prefs").Body).Decode(&prefs) |
| + if prefs.AutoUpdate || !prefs.AutoDownload || prefs.Channel != "edge" { |
| + t.Fatalf("after set = %+v, want off/on/edge", prefs) |
| + } |
| + |
| + // The admin console reads the same values (plus the availability block). |
| + var settings struct { |
| + UpdateAuto bool `json:"update_auto"` |
| + UpdateDownload bool `json:"update_download"` |
| + UpdateChannel string `json:"update_channel"` |
| + Update map[string]any `json:"update"` |
| + } |
| + json.NewDecoder(adminGet(t, ts.URL+"/api/admin/settings").Body).Decode(&settings) |
| + if settings.UpdateAuto || !settings.UpdateDownload || settings.UpdateChannel != "edge" { |
| + t.Fatalf("settings = %+v", settings) |
| + } |
| + if settings.Update["build"] != buildVersion { |
| + t.Fatalf("update.build = %v", settings.Update["build"]) |
| + } |
| + if _, ok := settings.Update["available_version"]; ok { |
| + t.Fatal("available_version present before any manifest check") |
| + } |
| +} |
| + |
| +func TestUpdateCheckerSurfacesAvailableVersion(t *testing.T) { |
| + ts, srv := newTestServer(t) |
| + |
| + // A signed manifest on a local server, trusted via a swapped-in test key. |
| + pub, priv, _ := ed25519.GenerateKey(rand.Reader) |
| + wrapper, err := signReleaseManifest(priv, &releaseManifest{ |
| + Channels: map[string]releaseEntry{ |
| + "stable": {Version: "new1234", URL: "https://example.com/lchat", |
| + SHA256: strings.Repeat("ab", 32), Notes: "a better build"}, |
| + }, |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + feed := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Write(wrapper) |
| + })) |
| + defer feed.Close() |
| + |
| + oldKey, oldURL := releasePubKeyB64, releaseManifestURL |
| + releasePubKeyB64 = base64.StdEncoding.EncodeToString(pub) |
| + releaseManifestURL = feed.URL |
| + defer func() { releasePubKeyB64, releaseManifestURL = oldKey, oldURL }() |
| + |
| + if err := srv.checkForUpdate(); err != nil { |
| + t.Fatalf("checkForUpdate: %v", err) |
| + } |
| + |
| + var settings struct { |
| + Update struct { |
| + AvailableVersion string `json:"available_version"` |
| + AvailableNotes string `json:"available_notes"` |
| + CheckedAt int64 `json:"checked_at"` |
| + } `json:"update"` |
| + } |
| + json.NewDecoder(adminGet(t, ts.URL+"/api/admin/settings").Body).Decode(&settings) |
| + if settings.Update.AvailableVersion != "new1234" || settings.Update.AvailableNotes != "a better build" { |
| + t.Fatalf("update block = %+v", settings.Update) |
| + } |
| + if settings.Update.CheckedAt == 0 || time.Since(time.Unix(settings.Update.CheckedAt, 0)) > time.Minute { |
| + t.Fatalf("checked_at = %d", settings.Update.CheckedAt) |
| + } |
| + |
| + // Switching channels reflects immediately from the cached manifest — and a |
| + // channel with no entry (edge here) just shows nothing available. |
| + adminPost(t, ts.URL+"/api/admin/settings", `{"update_channel":"edge"}`) |
| + var after struct { |
| + Update map[string]any `json:"update"` |
| + } |
| + json.NewDecoder(adminGet(t, ts.URL+"/api/admin/settings").Body).Decode(&after) |
| + if _, ok := after.Update["available_version"]; ok { |
| + t.Fatal("edge has no entry; available_version should be absent") |
| + } |
| + |
| + // A tampered feed must not replace the cached good manifest. |
| + wrapper[len(wrapper)-10]++ |
| + if err := srv.checkForUpdate(); err == nil { |
| + t.Fatal("tampered manifest accepted") |
| + } |
| +} |
diff --git a/web/pages/admin.html b/web/pages/admin.html| index f9e59bf..db742f3 100644 |
| --- a/web/pages/admin.html |
| +++ b/web/pages/admin.html |
| @@ -84,6 +84,31 @@ |
| </label> |
| </section> |
| + <section> |
| + <h2>Updates</h2> |
| + <p class="sub" style="margin:0 0 8px"> |
| + The server can keep itself up to date. Stable is well tested and what most |
| + spaces should run; Beta gets features a little earlier; Edge is the bleeding |
| + edge. Updates install in seconds and never touch your data. |
| + </p> |
| + <p id="updateStatus" class="sub" style="margin:0 0 8px"></p> |
| + <label class="toggle-row" style="margin:0 0 8px"> |
| + <input type="checkbox" id="updateAuto" /> |
| + <span>Install updates automatically</span> |
| + </label> |
| + <label class="toggle-row" style="margin:0 0 8px"> |
| + <input type="checkbox" id="updateDownload" /> |
| + <span>Download new versions automatically</span> |
| + </label> |
| + <label class="field-label" for="updateChannel">Release channel</label> |
| + <select id="updateChannel" style="display:block;width:100%;margin:0 0 8px"> |
| + <option value="stable">Stable — well tested (recommended)</option> |
| + <option value="beta">Beta — near-stable, newer features</option> |
| + <option value="edge">Edge — the bleeding edge</option> |
| + </select> |
| + <p id="updateFeedback" class="sub" style="margin:0" aria-live="polite"></p> |
| + </section> |
| + |
| <section> |
| <h2>Add an agent</h2> |
| <p class="sub" style="margin:0 0 8px">Adds an AI bot as a member. This mints a one-time link the agent sidecar uses to set up the bot's identity; it then appears in chats as a member with a BOT badge. The sidecar for this instance must be running.</p> |
diff --git a/web/static/admin.js b/web/static/admin.js| index 740d2f3..6433e22 100644 |
| --- a/web/static/admin.js |
| +++ b/web/static/admin.js |
| @@ -39,6 +39,7 @@ async function init() { |
| wireGroupName(); |
| wireServerAvatar(); |
| wireUserInvites(); |
| + wireUpdates(); |
| wireCustomApps(); |
| wireTestPush(); |
| wireCreateAdmin(); |
| @@ -65,6 +66,65 @@ async function wireUserInvites() { |
| }; |
| } |
| +// ---- updates (auto-update settings + what the channel offers) ----------- |
| + |
| +// Feedback is inline (the status + feedback lines occupy layout space) — no |
| +// floating toast, per the convention in CLAUDE.md. |
| +async function wireUpdates() { |
| + const auto = document.getElementById("updateAuto"); |
| + const dl = document.getElementById("updateDownload"); |
| + const chan = document.getElementById("updateChannel"); |
| + const status = document.getElementById("updateStatus"); |
| + const feedback = document.getElementById("updateFeedback"); |
| + |
| + const flash = (msg, isError = false) => { |
| + feedback.textContent = msg; |
| + feedback.style.color = isError ? "#c0392b" : ""; |
| + setTimeout(() => { |
| + if (feedback.textContent === msg) feedback.textContent = ""; |
| + }, 4000); |
| + }; |
| + |
| + const renderStatus = (s) => { |
| + const u = s.update || {}; |
| + let line = `Running build ${u.build || "unknown"}`; |
| + if (u.available_version && u.available_version !== u.build) { |
| + line += ` — ${u.available_version} is available on ${s.update_channel}`; |
| + } else if (u.checked_at) { |
| + line += ` — up to date on ${s.update_channel}`; |
| + } else { |
| + line += " — hasn't checked for updates yet"; |
| + } |
| + status.textContent = line + "."; |
| + }; |
| + |
| + let prevChannel = "stable"; |
| + try { |
| + const s = await (await api("GET", "/api/admin/settings")).json(); |
| + auto.checked = !!s.update_auto; |
| + dl.checked = !!s.update_download; |
| + chan.value = prevChannel = s.update_channel || "stable"; |
| + renderStatus(s); |
| + } catch {} |
| + |
| + const save = async (body, revert) => { |
| + const res = await api("POST", "/api/admin/settings", body); |
| + if (!res.ok) { |
| + revert(); |
| + return flash("Couldn't save that", true); |
| + } |
| + prevChannel = chan.value; |
| + // Re-read so the status line reflects the (possibly new) channel at once. |
| + try { |
| + renderStatus(await (await api("GET", "/api/admin/settings")).json()); |
| + } catch {} |
| + flash("Saved"); |
| + }; |
| + auto.onchange = () => save({ update_auto: auto.checked }, () => (auto.checked = !auto.checked)); |
| + dl.onchange = () => save({ update_download: dl.checked }, () => (dl.checked = !dl.checked)); |
| + chan.onchange = () => save({ update_channel: chan.value }, () => (chan.value = prevChannel)); |
| +} |
| + |
| // ---- this space (name + optional administrator identity) ---------------- |
| async function wireGroupName() { |
diff --git a/web/static/apps/admin.js b/web/static/apps/admin.js| index e4e7492..7c32725 100644 |
| --- a/web/static/apps/admin.js |
| +++ b/web/static/apps/admin.js |
| @@ -65,7 +65,7 @@ export const app = defineApp({ |
| } |
| .admin-row { display: flex; gap: 10px; align-items: center; margin: 10px 0; flex-wrap: wrap; } |
| .admin-row label { flex: 0 0 104px; font-size: 14px; color: var(--muted); } |
| -.admin-row input[type="text"], .admin-row input[type="email"] { |
| +.admin-row input[type="text"], .admin-row input[type="email"], .admin-row select { |
| flex: 1 1 160px; min-width: 0; font: inherit; padding: 8px 12px; |
| border: 1px solid var(--border); border-radius: 10px; background: transparent; color: var(--ink); |
| } |
| @@ -126,6 +126,28 @@ export const app = defineApp({ |
| if (elm.textContent === msg) elm.textContent = ""; |
| }, 2500); |
| }; |
| + // A setting-as-a-sentence row: text on the left, sliding switch on the |
| + // right. Returns the checkbox for wiring. |
| + const switchRow = (parent, text) => { |
| + const row = document.createElement("div"); |
| + row.className = "admin-row"; |
| + const wrap = document.createElement("label"); |
| + wrap.className = "admin-switch-label"; |
| + const t = document.createElement("span"); |
| + t.className = "admin-switch-text"; |
| + t.textContent = text; |
| + const sw = document.createElement("span"); |
| + sw.className = "admin-switch"; |
| + const check = document.createElement("input"); |
| + check.type = "checkbox"; |
| + const knob = document.createElement("span"); |
| + knob.className = "knob"; |
| + sw.append(check, knob); |
| + wrap.append(t, sw); |
| + row.appendChild(wrap); |
| + parent.appendChild(row); |
| + return check; |
| + }; |
| // ---- The invitation preview: what these settings actually control ------- |
| // A live miniature of the join card (the one keyless surface): the photo and |
| @@ -207,24 +229,7 @@ export const app = defineApp({ |
| // ---- Permissions --------------------------------------------------------- |
| const perms = section("Permissions"); |
| - const invRow = document.createElement("div"); |
| - invRow.className = "admin-row"; |
| - // The setting IS the sentence: text on the left, switch on the right. |
| - const invWrap = document.createElement("label"); |
| - invWrap.className = "admin-switch-label"; |
| - const invText = document.createElement("span"); |
| - invText.className = "admin-switch-text"; |
| - invText.textContent = "Members can invite people"; |
| - const invSwitch = document.createElement("span"); |
| - invSwitch.className = "admin-switch"; |
| - const invCheck = document.createElement("input"); |
| - invCheck.type = "checkbox"; |
| - const invKnob = document.createElement("span"); |
| - invKnob.className = "knob"; |
| - invSwitch.append(invCheck, invKnob); |
| - invWrap.append(invText, invSwitch); |
| - invRow.appendChild(invWrap); |
| - perms.appendChild(invRow); |
| + const invCheck = switchRow(perms, "Members can invite people"); |
| const permsStatus = statusLine(perms); |
| invCheck.onchange = async () => { |
| @@ -238,6 +243,85 @@ export const app = defineApp({ |
| } |
| }; |
| + // ---- Updates --------------------------------------------------------------- |
| + const updSec = section("Updates"); |
| + const updDesc = document.createElement("p"); |
| + updDesc.style.cssText = "font-size:13px;color:var(--muted);margin:0 0 12px"; |
| + updDesc.textContent = |
| + "The server can keep itself up to date. Stable is well tested; Beta gets features a little earlier; Edge is the bleeding edge. Updates install in seconds and never touch your data."; |
| + updSec.appendChild(updDesc); |
| + const updInfo = document.createElement("p"); |
| + updInfo.style.cssText = "font-size:13px;color:var(--muted);margin:0 0 12px"; |
| + updSec.appendChild(updInfo); |
| + const autoCheck = switchRow(updSec, "Install updates automatically"); |
| + const dlCheck = switchRow(updSec, "Download new versions automatically"); |
| + const chanRow = document.createElement("div"); |
| + chanRow.className = "admin-row"; |
| + const chanLabel = document.createElement("label"); |
| + chanLabel.textContent = "Channel"; |
| + const chanSelect = document.createElement("select"); |
| + for (const [value, text] of [ |
| + ["stable", "Stable — well tested (recommended)"], |
| + ["beta", "Beta — near-stable"], |
| + ["edge", "Edge — the bleeding edge"], |
| + ]) { |
| + const o = document.createElement("option"); |
| + o.value = value; |
| + o.textContent = text; |
| + chanSelect.appendChild(o); |
| + } |
| + chanRow.append(chanLabel, chanSelect); |
| + updSec.appendChild(chanRow); |
| + const updStatus = statusLine(updSec); |
| + |
| + const paintUpdateInfo = (update, channel) => { |
| + const u = update || {}; |
| + let line = `Running build ${u.build || "unknown"}`; |
| + if (u.available_version && u.available_version !== u.build) { |
| + line += ` — ${u.available_version} is available on ${channel}`; |
| + } else if (u.checked_at) { |
| + line += ` — up to date on ${channel}`; |
| + } else { |
| + line += " — hasn't checked for updates yet"; |
| + } |
| + updInfo.textContent = line + "."; |
| + }; |
| + |
| + const updState = { update: {}, channel: "stable" }; |
| + const saveUpdatePref = async (patch, revert, saved) => { |
| + if (await bridge.setUpdatePrefs(patch)) { |
| + flash(updStatus, saved); |
| + } else { |
| + revert(); |
| + flash(updStatus, "Couldn't save — try again."); |
| + } |
| + }; |
| + autoCheck.onchange = () => |
| + saveUpdatePref( |
| + { update_auto: autoCheck.checked }, |
| + () => (autoCheck.checked = !autoCheck.checked), |
| + autoCheck.checked ? "Updates will install automatically." : "Updates won't install until you choose to.", |
| + ); |
| + dlCheck.onchange = () => |
| + saveUpdatePref( |
| + { update_download: dlCheck.checked }, |
| + () => (dlCheck.checked = !dlCheck.checked), |
| + dlCheck.checked ? "New versions will download automatically." : "New versions won't download automatically.", |
| + ); |
| + chanSelect.onchange = () => { |
| + const prev = updState.channel; |
| + saveUpdatePref( |
| + { update_channel: chanSelect.value }, |
| + () => (chanSelect.value = prev), |
| + `Now on the ${chanSelect.value} channel.`, |
| + ).then(() => { |
| + if (chanSelect.value !== prev) { |
| + updState.channel = chanSelect.value; |
| + paintUpdateInfo(updState.update, updState.channel); |
| + } |
| + }); |
| + }; |
| + |
| // ---- Photo ---------------------------------------------------------------- |
| const photoSec = section("Photo"); |
| const photoRow = document.createElement("div"); |
| @@ -295,6 +379,12 @@ export const app = defineApp({ |
| nameInput.value = info.name || ""; |
| emailInput.value = info.email || ""; |
| invCheck.checked = !!info.userInvites; |
| + autoCheck.checked = !!info.updateAuto; |
| + dlCheck.checked = !!info.updateDownload; |
| + updState.channel = info.updateChannel || "stable"; |
| + updState.update = info.update || {}; |
| + chanSelect.value = updState.channel; |
| + paintUpdateInfo(updState.update, updState.channel); |
| photoState.alt = info.avatarAlt || ""; |
| photoImg.alt = photoState.alt; |
| altInput.value = photoState.alt; |
diff --git a/web/static/shell.js b/web/static/shell.js| index 70cbeeb..ac2fcd0 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -3039,11 +3039,31 @@ setSetupBridge({ |
| fetch(`/api/group?token=${encodeURIComponent(session.token)}`).then((r) => r.json()), |
| fetch("/api/admin/settings", { headers: adminAuth() }).then((r) => r.json()), |
| ]); |
| - return { name: g.name || "", nameSet: !!g.name_set, email: g.admin_email || "", avatar: !!g.avatar, avatarAlt: g.avatar_alt || "", userInvites: !!s.user_invites }; |
| + return { |
| + name: g.name || "", |
| + nameSet: !!g.name_set, |
| + email: g.admin_email || "", |
| + avatar: !!g.avatar, |
| + avatarAlt: g.avatar_alt || "", |
| + userInvites: !!s.user_invites, |
| + updateAuto: !!s.update_auto, |
| + updateDownload: !!s.update_download, |
| + updateChannel: s.update_channel || "stable", |
| + update: s.update || {}, |
| + }; |
| } catch { |
| return null; |
| } |
| }, |
| + // Auto-update prefs — a partial update; pass only the changed field |
| + // (update_auto / update_download / update_channel). |
| + setUpdatePrefs: async (patch) => { |
| + try { |
| + return (await fetch("/api/admin/settings", { method: "POST", headers: adminAuth(), body: JSON.stringify(patch) })).ok; |
| + } catch { |
| + return false; |
| + } |
| + }, |
| // Edit the photo's alt text without re-uploading the image. |
| setAvatarAlt: async (alt) => { |
| try { |