Skip to content
186 linesCodeBlameRaw
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 */
15import type { DesktopBackend } from "@g1t/contracts";
16
17import type { DesktopRunnerView } from "./desktop-runner";
18
19/** What the app keeps (src/settings.rs `Settings`). */
20export 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`). */
36export 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. */
52export 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. */
55export type DesktopRunnerMenuEvent = { action: "toggle" | "open"; at: number | null };
56
57/** `window.g1tDesktop.settings`, from the app's bridge.js. */
58export 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. */
77export 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. */
84export 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. */
100export 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. */
107export 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. */
115export 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
125export type SectionId = (typeof SECTIONS)[number]["id"];
126
127/** The section the app asked for, or the first. */
128export 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). */
133export 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. */
136export 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). */
146export 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". */
159export 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. */
167export 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. */
174export 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". */
180export function hostOf(server: string): string {
181 try {
182 return new URL(server).host;
183 } catch {
184 return server;
185 }
186}