| 1 | /** |
| 2 | * The desktop app's own settings, from the site's side. Inside the app |
| 3 | * (apps/desktop), the site draws them as a dialog over itself |
| 4 | * (components/desktop-settings.tsx), and the runner's menu under the title |
| 5 | * bar's dot (components/desktop-runner-menu.tsx); the app opens either by |
| 6 | * an event (`onOpen`, `onRunnerMenu`) from its title bar, menu, tray and |
| 7 | * shortcuts, once the page has said it can (`ready`). Until then, and on |
| 8 | * any page that isn't the site's, they open in windows of the app's own. |
| 9 | * |
| 10 | * The settings are the app's (src/settings.rs `Settings`), in camelCase as |
| 11 | * it keeps them. A change is saved over the settings as the app has them |
| 12 | * at that moment (`saveSetting`), so a change made elsewhere at the same |
| 13 | * time, from the menu's zoom or the Runner window, isn't undone. |
| 14 | */ |
| 15 | import type { DesktopBackend } from "@g1t/contracts"; |
| 16 | |
| 17 | import type { DesktopRunnerView } from "./desktop-runner"; |
| 18 | |
| 19 | /** What the app keeps (src/settings.rs `Settings`). */ |
| 20 | export type DesktopSettings = { |
| 21 | startHidden: boolean; |
| 22 | closeToTray: boolean; |
| 23 | notifications: boolean; |
| 24 | flash: boolean; |
| 25 | badge: boolean; |
| 26 | systemTitleBar: boolean; |
| 27 | zoom: number; |
| 28 | autoUpdate: boolean; |
| 29 | runner: boolean; |
| 30 | runnerBackend: DesktopBackend; |
| 31 | runnerWorkspace: string | null; |
| 32 | runnerCapabilities: { linux: boolean; windows: boolean; macos: boolean }; |
| 33 | }; |
| 34 | |
| 35 | /** What the settings show: the settings, and what the app and the system know (src/settings.rs `View`). */ |
| 36 | export type DesktopSettingsView = DesktopSettings & { |
| 37 | openAtLogin: boolean; |
| 38 | version: string; |
| 39 | platform: string; |
| 40 | server: string; |
| 41 | /** A version downloaded and waiting for a restart. */ |
| 42 | updateReady: string | null; |
| 43 | /** When the app last heard back about updates, in milliseconds since 1970. */ |
| 44 | checkedAt: number | null; |
| 45 | /** The title bar setting differs from the one the window has: a restart applies it. */ |
| 46 | titleBarChanged: boolean; |
| 47 | customFrame: boolean; |
| 48 | runnerView: DesktopRunnerView; |
| 49 | }; |
| 50 | |
| 51 | /** The app asks for the settings: at a section, and looking for updates as they open. */ |
| 52 | export type DesktopSettingsOpening = { section: string | null; check: boolean }; |
| 53 | |
| 54 | /** The app asks for the runner menu: `toggle` from the title bar's dot, or `open`; `at` is where the dot is across the bar. */ |
| 55 | export type DesktopRunnerMenuEvent = { action: "toggle" | "open"; at: number | null }; |
| 56 | |
| 57 | /** `window.g1tDesktop.settings`, from the app's bridge.js. */ |
| 58 | export type DesktopSettingsBridge = { |
| 59 | /** The page draws the settings and the runner menu (true), or no longer (false). */ |
| 60 | ready(ready?: boolean): Promise<void>; |
| 61 | get(): Promise<DesktopSettingsView>; |
| 62 | save(settings: DesktopSettings): Promise<DesktopSettingsView>; |
| 63 | setOpenAtLogin(on: boolean): Promise<DesktopSettingsView>; |
| 64 | /** "current", "ready:<version>", or rejects with why. */ |
| 65 | checkForUpdates(): Promise<string>; |
| 66 | restart(): Promise<void>; |
| 67 | restartToUpdate(): Promise<void>; |
| 68 | changeServer(): Promise<void>; |
| 69 | onOpen(listener: (opening: DesktopSettingsOpening) => void): () => void; |
| 70 | onRunnerMenu(listener: (menu: DesktopRunnerMenuEvent) => void): () => void; |
| 71 | onUpdateReady(listener: (version: string) => void): () => void; |
| 72 | /** Whether the runner menu is open, which the title bar's dot shows. */ |
| 73 | setRunnerMenuOpen(open: boolean): void; |
| 74 | }; |
| 75 | |
| 76 | /** The bridge's settings, inside a desktop app new enough to have them; null elsewhere. */ |
| 77 | export function desktopSettings(): DesktopSettingsBridge | null { |
| 78 | if (typeof window === "undefined") return null; |
| 79 | const settings = window.g1tDesktop?.settings; |
| 80 | return settings && typeof settings.get === "function" && typeof settings.onOpen === "function" ? settings : null; |
| 81 | } |
| 82 | |
| 83 | /** Every setting the app keeps, which a save sends whole. */ |
| 84 | export const KEPT = [ |
| 85 | "startHidden", |
| 86 | "closeToTray", |
| 87 | "notifications", |
| 88 | "flash", |
| 89 | "badge", |
| 90 | "systemTitleBar", |
| 91 | "zoom", |
| 92 | "autoUpdate", |
| 93 | "runner", |
| 94 | "runnerBackend", |
| 95 | "runnerWorkspace", |
| 96 | "runnerCapabilities", |
| 97 | ] as const satisfies readonly (keyof DesktopSettings)[]; |
| 98 | |
| 99 | /** The settings as the app has them now, with one change made. */ |
| 100 | export function settingsOf(fresh: DesktopSettingsView, change: Partial<DesktopSettings>): DesktopSettings { |
| 101 | const settings = {} as Record<string, unknown>; |
| 102 | for (const key of KEPT) settings[key] = fresh[key]; |
| 103 | return { ...(settings as DesktopSettings), ...change }; |
| 104 | } |
| 105 | |
| 106 | /** Saves one change over the settings as the app has them at that moment. */ |
| 107 | export async function saveSetting(change: Partial<DesktopSettings>): Promise<DesktopSettingsView> { |
| 108 | const bridge = desktopSettings(); |
| 109 | if (!bridge) throw new Error("This is the desktop app's."); |
| 110 | const fresh = await bridge.get(); |
| 111 | return bridge.save(settingsOf(fresh, change)); |
| 112 | } |
| 113 | |
| 114 | /** The dialog's sections, in their order down its side. */ |
| 115 | export const SECTIONS = [ |
| 116 | { id: "general", label: "Starting and closing" }, |
| 117 | { id: "runner", label: "Runner" }, |
| 118 | { id: "notifications", label: "Notifications" }, |
| 119 | { id: "window", label: "Window" }, |
| 120 | { id: "updates", label: "Updates" }, |
| 121 | { id: "server", label: "Server" }, |
| 122 | { id: "about", label: "About" }, |
| 123 | ] as const; |
| 124 | |
| 125 | export type SectionId = (typeof SECTIONS)[number]["id"]; |
| 126 | |
| 127 | /** The section the app asked for, or the first. */ |
| 128 | export function sectionOf(value: unknown): SectionId { |
| 129 | return SECTIONS.find((section) => section.id === value)?.id ?? "general"; |
| 130 | } |
| 131 | |
| 132 | /** The zoom levels the app offers (src/settings.rs ZOOMS). */ |
| 133 | export const ZOOMS = [0.67, 0.75, 0.8, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2]; |
| 134 | |
| 135 | /** The words that change between Windows, macOS and Linux. */ |
| 136 | export function platformWords(platform: string) { |
| 137 | const mac = platform === "macos"; |
| 138 | return { |
| 139 | flash: mac ? "Bounce the dock icon" : platform === "windows" ? "Flash the taskbar icon" : "Ask for attention", |
| 140 | systemTitleBar: mac ? "In place of g1t's own, which sits under the window's buttons." : "In place of g1t's own, with the menu bar under it.", |
| 141 | zoom: mac ? "The View menu changes it too: ⌘+, ⌘− and ⌘0." : "The View menu changes it too: Ctrl+=, Ctrl+− and Ctrl+0.", |
| 142 | }; |
| 143 | } |
| 144 | |
| 145 | /** What each way of running jobs can reach, said plainly (src/runner/backend.rs). */ |
| 146 | export const BACKEND_NOTE: Record<DesktopBackend, { text: string; warn: boolean }> = { |
| 147 | sandboxed: { text: "The runner and every job run in a sandbox of their own. Nothing a job does reaches this computer.", warn: false }, |
| 148 | docker: { |
| 149 | text: "Each job runs in a container of its own, but the runner controls this computer's Docker, which can reach your files. Not a sandbox.", |
| 150 | warn: true, |
| 151 | }, |
| 152 | host: { |
| 153 | text: "Jobs run directly on this computer, as you: they can read, change and delete anything your account can. For jobs that need this system itself, such as building a Windows installer.", |
| 154 | warn: true, |
| 155 | }, |
| 156 | }; |
| 157 | |
| 158 | /** When, in a few words: "today at 9:41 AM", "Oct 3 at 9:41 AM". */ |
| 159 | export function when(ms: number, now = Date.now()): string { |
| 160 | const date = new Date(ms); |
| 161 | const time = date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); |
| 162 | const today = new Date(now).toDateString() === date.toDateString(); |
| 163 | return today ? `today at ${time}` : `${date.toLocaleDateString([], { month: "short", day: "numeric" })} at ${time}`; |
| 164 | } |
| 165 | |
| 166 | /** Where this copy of the app stands with updates, in a line. */ |
| 167 | export function updateLine(view: Pick<DesktopSettingsView, "updateReady" | "checkedAt" | "autoUpdate">, now = Date.now()): string { |
| 168 | if (view.updateReady) return `Version ${view.updateReady} is downloaded and installs when g1t restarts.`; |
| 169 | if (view.checkedAt) return `Up to date. Last checked ${when(view.checkedAt, now)}.`; |
| 170 | return view.autoUpdate ? "Not checked yet since g1t started; it checks on its own shortly." : "Checking on its own is off."; |
| 171 | } |
| 172 | |
| 173 | /** Why a check for updates didn't answer, in a line. */ |
| 174 | export function checkFailed(error: unknown): string { |
| 175 | const text = typeof error === "string" ? error : error instanceof Error ? error.message : ""; |
| 176 | return !text || /sending request|connect|dns|timed out/i.test(text) ? "Could not reach g1t.sh to check. Check your connection and try again." : `Could not check: ${text}`; |
| 177 | } |
| 178 | |
| 179 | /** The server, as its host: "g1t.sh". */ |
| 180 | export function hostOf(server: string): string { |
| 181 | try { |
| 182 | return new URL(server).host; |
| 183 | } catch { |
| 184 | return server; |
| 185 | } |
| 186 | } |