eleven / messenger Public

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>
Paul Campbell c405f720115071b3f64c7c648144d5e7a979c505 parent 441efb2
9 files changed, +519 −24
  • changelog/2026-07-08-auto-updates.md +5 −0
  • db.go +27 −0
  • main.go +42 −4
  • updatecheck.go +100 −0
  • updatecheck_test.go +130 −0
  • web/pages/admin.html +25 −0
  • web/static/admin.js +60 −0
  • web/static/apps/admin.js +109 −19
  • web/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 {