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