From fa22a755ee5a339ebe6a68ff00f3a52046257016 Mon Sep 17 00:00:00 2001 From: Schwitzz Date: Fri, 30 Oct 2026 13:56:23 -0500 Subject: [PATCH] feat: add server settings (toggle on/off, port selection, start on system start) to Settings UI and backend --- apps/web/src/api.ts | 97 +++++++++++++++ apps/web/src/components/Settings.tsx | 164 +++++++++++++++++++++++++- apps/web/src/store/settings.ts | 8 ++ desktop/src/pubscript-desktop.c | 19 +++ server/src/index.ts | 170 ++++++++++++++++++++++++++- 5 files changed, 454 insertions(+), 4 deletions(-) diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 6c22670..9f94603 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -1,4 +1,5 @@ import { createUlid, type AuditLogEntry, type Role, type User } from "@pubscript/core"; +import { useSettings } from "./store/settings.js"; const BASE = "/api"; const TOKEN_KEY = "pubscript.admin.token"; @@ -138,6 +139,10 @@ async function bootstrapAdminSession(): Promise { } async function request(path: string, init?: RequestInit, retried = false): Promise { + const { serverEnabled } = useSettings.getState(); + if (!serverEnabled) { + throw new Error("Server disabled in settings (Local Storage Mode)"); + } const token = getStoredToken(); const headers: Record = { ...(init?.body ? { "Content-Type": "application/json" } : {}), @@ -361,4 +366,96 @@ export const api = { }), logout: () => request<{ ok: true }>("/auth/logout", { method: "POST" }), + + getServerConfig: async (portHint?: number): Promise<{ + online: boolean; + enabled: boolean; + port: number; + activePort?: number; + startOnBoot: boolean; + }> => { + const endpoints = [ + "/api/system/server-config", + ...(portHint ? [`http://localhost:${portHint}/api/system/server-config`] : []), + ]; + for (const url of endpoints) { + try { + const res = await fetch(url, { credentials: "include" }); + const ct = res.headers.get("content-type") ?? ""; + if (res.ok && ct.includes("application/json")) { + const data = (await res.json()) as { + enabled: boolean; + port: number; + activePort?: number; + startOnBoot: boolean; + }; + return { + online: true, + enabled: data.enabled, + port: data.port, + ...(data.activePort !== undefined ? { activePort: data.activePort } : {}), + startOnBoot: data.startOnBoot, + }; + } + } catch { + /* try next endpoint */ + } + } + return { + online: false, + enabled: true, + port: portHint ?? 8081, + startOnBoot: false, + }; + }, + + updateServerConfig: async ( + patch: { enabled?: boolean; port?: number; startOnBoot?: boolean }, + portHint?: number, + ): Promise<{ + online: boolean; + enabled: boolean; + port: number; + activePort?: number; + startOnBoot: boolean; + }> => { + const endpoints = [ + "/api/system/server-config", + ...(portHint ? [`http://localhost:${portHint}/api/system/server-config`] : []), + ]; + for (const url of endpoints) { + try { + const res = await fetch(url, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }); + const ct = res.headers.get("content-type") ?? ""; + if (res.ok && ct.includes("application/json")) { + const data = (await res.json()) as { + enabled: boolean; + port: number; + activePort?: number; + startOnBoot: boolean; + }; + return { + online: true, + enabled: data.enabled, + port: data.port, + ...(data.activePort !== undefined ? { activePort: data.activePort } : {}), + startOnBoot: data.startOnBoot, + }; + } + } catch { + /* try next endpoint */ + } + } + return { + online: false, + enabled: patch.enabled ?? true, + port: patch.port ?? portHint ?? 8081, + startOnBoot: patch.startOnBoot ?? false, + }; + }, }; diff --git a/apps/web/src/components/Settings.tsx b/apps/web/src/components/Settings.tsx index 3c91f0e..b2ab7af 100644 --- a/apps/web/src/components/Settings.tsx +++ b/apps/web/src/components/Settings.tsx @@ -1,10 +1,11 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useSettings, DEFAULT_AI_AGENTS, type Density, type AppSettings, } from "../store/settings.js"; +import { api } from "../api.js"; import { Select, Button, @@ -35,6 +36,82 @@ export function Settings() { const [selectedAgentId, setSelectedAgentId] = useState(s.activeAgentId); const [probing, setProbing] = useState(false); + // Server settings state + const [portDraft, setPortDraft] = useState(String(s.serverPort)); + const [serverOnline, setServerOnline] = useState(false); + const [activeRunningPort, setActiveRunningPort] = useState(null); + const [savingServerCfg, setSavingServerCfg] = useState(false); + + useEffect(() => { + let cancelled = false; + void api.getServerConfig(s.serverPort).then((res) => { + if (cancelled) return; + setServerOnline(res.online); + setActiveRunningPort(res.activePort ?? res.port); + if (res.online) { + setPortDraft(String(res.port)); + set({ + serverEnabled: res.enabled, + serverPort: res.port, + serverStartOnBoot: res.startOnBoot, + }); + } + }); + return () => { + cancelled = true; + }; + }, []); + + const handleToggleServerEnabled = async (enabled: boolean) => { + set({ serverEnabled: enabled }); + setSavingServerCfg(true); + try { + const res = await api.updateServerConfig({ enabled }, s.serverPort); + setServerOnline(res.online && enabled); + toast.success( + enabled + ? `Server enabled (port ${s.serverPort})` + : "Server toggled off — using Local Offline Storage mode", + ); + } finally { + setSavingServerCfg(false); + } + }; + + const handleApplyServerPort = async () => { + const parsed = parseInt(portDraft.trim(), 10); + if (Number.isNaN(parsed) || parsed < 1024 || parsed > 65535) { + toast.error("Please enter a valid port between 1024 and 65535"); + return; + } + set({ serverPort: parsed }); + setSavingServerCfg(true); + try { + const res = await api.updateServerConfig({ port: parsed }, s.serverPort); + setServerOnline(res.online); + setActiveRunningPort(res.activePort ?? parsed); + toast.success(`Server port saved (${parsed})`); + } finally { + setSavingServerCfg(false); + } + }; + + const handleToggleStartOnBoot = async (startOnBoot: boolean) => { + set({ serverStartOnBoot: startOnBoot }); + setSavingServerCfg(true); + try { + const res = await api.updateServerConfig({ startOnBoot }, s.serverPort); + setServerOnline(res.online); + toast.success( + startOnBoot + ? "Enabled Start on System Start (systemd user service & desktop autostart)" + : "Disabled Start on System Start", + ); + } finally { + setSavingServerCfg(false); + } + }; + const currentProvider = s.aiProviders.find((p) => p.id === selectedProviderId) ?? s.aiProviders[0]!; const currentAgent = @@ -429,6 +506,91 @@ export function Settings() { + {/* SERVER & SYSTEM STARTUP */} +
+

Server & System Startup

+ +
+
+
+ PubScript Server + + {!s.serverEnabled + ? "Off (Local Mode)" + : serverOnline + ? `Online · Port ${activeRunningPort ?? s.serverPort}` + : `Standby · Port ${s.serverPort}`} + +
+
+ Toggle the PubScript API & SQLite server on or off. When toggled off, PubScript runs in local offline storage mode. +
+
+ +
+ +
+
+
Server port
+
+ TCP port for the local/self-hosted PubScript server (1024–65535). Default: 8081 (web) / 18081 (desktop). +
+
+
+ setPortDraft(e.target.value)} + aria-label="Server port" + /> + +
+
+ +
+
+
Start on system start
+
+ Automatically launch the PubScript server when you log in or boot the system (configures systemd user service & XDG autostart). +
+
+ +
+
+ {/* WRITING & PROOFING */}

Writing & Proofing

diff --git a/apps/web/src/store/settings.ts b/apps/web/src/store/settings.ts index acce76f..867c157 100644 --- a/apps/web/src/store/settings.ts +++ b/apps/web/src/store/settings.ts @@ -263,6 +263,10 @@ export interface AppSettings { activeModel: string; aiAgents: AIAgentConfig[]; activeAgentId: string; + // Local / Self-Hosted Server & Startup Settings + serverEnabled: boolean; + serverPort: number; + serverStartOnBoot: boolean; } export const DEFAULT_SETTINGS: AppSettings = { @@ -292,6 +296,10 @@ export const DEFAULT_SETTINGS: AppSettings = { activeModel: DEFAULT_AI_PROVIDERS[0]!.defaultModel, aiAgents: DEFAULT_AI_AGENTS, activeAgentId: DEFAULT_AI_AGENTS[0]!.id, + // Local / Self-Hosted Server & Startup Settings + serverEnabled: true, + serverPort: 8081, + serverStartOnBoot: false, }; const STORAGE_KEY = "pubscript.settings.v1"; diff --git a/desktop/src/pubscript-desktop.c b/desktop/src/pubscript-desktop.c index 3e3efdf..e141afc 100644 --- a/desktop/src/pubscript-desktop.c +++ b/desktop/src/pubscript-desktop.c @@ -157,6 +157,25 @@ int main(int argc, char *argv[]) { } int port = 18081; + char cfg_path[2048]; + snprintf(cfg_path, sizeof(cfg_path), "%s/.config/pubscript/server.json", home); + FILE *cfg_fp = fopen(cfg_path, "r"); + if (cfg_fp) { + char buf[1024]; + size_t n = fread(buf, 1, sizeof(buf) - 1, cfg_fp); + buf[n] = '\0'; + fclose(cfg_fp); + const char *port_key = strstr(buf, "\"port\""); + if (port_key) { + const char *colon = strchr(port_key, ':'); + if (colon) { + int cfg_port = atoi(colon + 1); + if (cfg_port >= 1024 && cfg_port <= 65535) { + port = cfg_port; + } + } + } + } const char *env_port = getenv("PUBSCRIPT_DESKTOP_PORT"); if (env_port && *env_port) { int p = atoi(env_port); diff --git a/server/src/index.ts b/server/src/index.ts index 509e47d..a469fb9 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,5 +1,7 @@ -import { existsSync } from "node:fs"; -import { dirname, resolve } from "node:path"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import Fastify from "fastify"; import cors from "@fastify/cors"; @@ -62,7 +64,169 @@ export async function buildApp() { } catch { dbUp = false; } - return { ok: dbUp, db: dbUp ? "up" : "down", version: config.version, uptime: process.uptime() }; + return { + ok: dbUp, + db: dbUp ? "up" : "down", + version: config.version, + port: config.port, + uptime: process.uptime(), + }; + }); + + const sysConfigDir = join(homedir(), ".config", "pubscript"); + const sysConfigPath = join(sysConfigDir, "server.json"); + const autostartDir = join(homedir(), ".config", "autostart"); + const autostartDesktopPath = join(autostartDir, "pubscript-server.desktop"); + const systemdUserDir = join(homedir(), ".config", "systemd", "user"); + const systemdServicePath = join(systemdUserDir, "pubscript-server.service"); + + interface PersistedServerSysConfig { + enabled: boolean; + port: number; + startOnBoot: boolean; + } + + function loadPersistedServerSysConfig(): PersistedServerSysConfig { + try { + if (existsSync(sysConfigPath)) { + const raw = JSON.parse(readFileSync(sysConfigPath, "utf8")) as Partial; + return { + enabled: typeof raw.enabled === "boolean" ? raw.enabled : true, + port: + typeof raw.port === "number" && raw.port >= 1024 && raw.port <= 65535 + ? raw.port + : config.port, + startOnBoot: + typeof raw.startOnBoot === "boolean" + ? raw.startOnBoot + : existsSync(autostartDesktopPath) || existsSync(systemdServicePath), + }; + } + } catch { + /* ignore malformed config */ + } + return { + enabled: true, + port: config.port, + startOnBoot: existsSync(autostartDesktopPath) || existsSync(systemdServicePath), + }; + } + + function applySystemStartupFiles(cfg: PersistedServerSysConfig): boolean { + try { + mkdirSync(sysConfigDir, { recursive: true }); + writeFileSync(sysConfigPath, JSON.stringify(cfg, null, 2), "utf8"); + + const dataDir = join(homedir(), ".local", "share", "pubscript"); + const dbFile = join(dataDir, "pubscript.sqlite"); + const optServerEntry = "/opt/pubscript/server/dist/server.mjs"; + const localServerEntry = resolve(dirname(fileURLToPath(import.meta.url)), "index.ts"); + const execCmd = existsSync(optServerEntry) + ? `/usr/bin/env PORT=${cfg.port} PUBSCRIPT_DB=${dbFile} PUBSCRIPT_WEB_DIST=/opt/pubscript/apps/web/dist node ${optServerEntry}` + : `/usr/bin/env PORT=${cfg.port} PUBSCRIPT_DB=${dbFile} node --import tsx ${localServerEntry}`; + + if (cfg.startOnBoot) { + mkdirSync(autostartDir, { recursive: true }); + const desktopContent = [ + "[Desktop Entry]", + "Type=Application", + "Name=PubScript Server", + "Comment=PubScript Background API & Manuscript Server", + `Exec=${execCmd}`, + "Icon=pubscript", + "Terminal=false", + "Hidden=false", + "X-GNOME-Autostart-enabled=true", + "", + ].join("\n"); + writeFileSync(autostartDesktopPath, desktopContent, "utf8"); + + mkdirSync(systemdUserDir, { recursive: true }); + const serviceContent = [ + "[Unit]", + "Description=PubScript Local Writing Studio Server", + "After=network.target", + "", + "[Service]", + "Type=simple", + `Environment=PORT=${cfg.port}`, + `Environment=PUBSCRIPT_DB=${dbFile}`, + `ExecStart=${existsSync(optServerEntry) ? `/usr/bin/node ${optServerEntry}` : `/usr/bin/node --import tsx ${localServerEntry}`}`, + "Restart=on-failure", + "RestartSec=3", + "", + "[Install]", + "WantedBy=default.target", + "", + ].join("\n"); + writeFileSync(systemdServicePath, serviceContent, "utf8"); + try { + execFileSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" }); + execFileSync("systemctl", ["--user", "enable", "pubscript-server.service"], { + stdio: "ignore", + }); + } catch { + /* systemctl --user optional when XDG autostart is used */ + } + } else { + if (existsSync(autostartDesktopPath)) { + rmSync(autostartDesktopPath, { force: true }); + } + try { + execFileSync("systemctl", ["--user", "disable", "pubscript-server.service"], { + stdio: "ignore", + }); + } catch { + /* ignore */ + } + if (existsSync(systemdServicePath)) { + rmSync(systemdServicePath, { force: true }); + } + } + return true; + } catch { + return false; + } + } + + app.get("/api/system/server-config", async () => { + const persisted = loadPersistedServerSysConfig(); + return { + ok: true, + enabled: persisted.enabled, + port: persisted.port, + activePort: config.port, + startOnBoot: persisted.startOnBoot, + autostartPath: autostartDesktopPath, + systemdServicePath, + }; + }); + + app.post("/api/system/server-config", async (req, reply) => { + const body = (req.body ?? {}) as Partial; + const current = loadPersistedServerSysConfig(); + const nextPort = + typeof body.port === "number" ? Math.trunc(body.port) : current.port; + if (!Number.isFinite(nextPort) || nextPort < 1024 || nextPort > 65535) { + return reply.code(400).send({ error: "invalid_port" }); + } + const next: PersistedServerSysConfig = { + enabled: typeof body.enabled === "boolean" ? body.enabled : current.enabled, + port: nextPort, + startOnBoot: + typeof body.startOnBoot === "boolean" ? body.startOnBoot : current.startOnBoot, + }; + const configured = applySystemStartupFiles(next); + return { + ok: true, + enabled: next.enabled, + port: next.port, + activePort: config.port, + startOnBoot: next.startOnBoot, + autostartConfigured: configured, + autostartPath: autostartDesktopPath, + systemdServicePath, + }; }); registerAuthHook(app);