Skip to content

Commit

The desktop app has settings of its own and a menu, and it is for using g1t, not browsing it.

Settings open in their own window, from File > Settings (Ctrl+, or Cmd+,), the tray's Settings…, or Desktop app settings in the site's account menu: open g1t when you sign in to your computer (the autostart plugin), start in the tray when it does, keep running when the window closes or quit, show notifications, flash the taskbar icon or bounce the dock, show the badge, show the menu bar, zoom, and download updates by themselves. They are kept with the server in desktop.json and take effect at once. The window has a menu, as a desktop app does: File (g1t on macOS), Edit, View with reload and zoom, Go with back, forward, home and notifications, Window and Help. The tray's rows are handled with it. Signed out in the app, g1t.sh sends you to sign in instead of the landing page: the launcher opens /?app=desktop, which sets a cookie the home page reads from then on, and an older app that opens / sets it from the page. The site's pages for reading about g1t (pricing, policies, security, support, downloads, status) open in the system's browser, as links to other sites, the docs among them, already did.

Claude-Session:
https://claude.ai/code/session_01MMRPpook51KPMQQRurb5kC
Claudeagent and Claudeagentcommitted Parent3dd024dBrowse files
23 files+1268−790/23 viewed
+13−2
11 // The launcher's work: ask the app for its server, check that it answers,
2−// go there. `?change` opens the choice of server instead. Everything it
2+// go there, as the desktop app (`?app=desktop`, which takes someone signed
3+// out to the sign-in page rather than the site's landing page). `?change` opens the choice of server instead. Everything it
34 // asks is a command in src/bridge.rs, answered only to this page.
45 (function () {
56 var sections = { connecting: byId("connecting"), offline: byId("offline"), choose: byId("choose") };
2930 }
3031 }
3132
33+ function appUrl(server) {
34+ try {
35+ var url = new URL(server);
36+ url.searchParams.set("app", "desktop");
37+ return url.href;
38+ } catch (error) {
39+ return server;
40+ }
41+ }
42+
3243 function connect(server) {
3344 show("connecting");
3445 byId("connecting-text").textContent = "Connecting to " + hostOf(server) + "…";
3546 invoke("reachable", { url: server })
3647 .then(function (ok) {
37− if (ok) location.replace(server);
48+ if (ok) location.replace(appUrl(server));
3849 else offline(server);
3950 })
4051 .catch(function () {
+169−0
1+/* The settings window, in the launcher's colours (launcher.css): rows of
2+ settings under small headings, each with its switch on the right. */
3+:root {
4+ --bg: #0f0f11;
5+ --fg: #ededf0;
6+ --muted: #9a9aa6;
7+ --line: #26262c;
8+ --surface: #17171b;
9+ --accent: #b6a8ff;
10+ --accent-ink: #15112e;
11+ --error: #ff8a8a;
12+ color-scheme: dark;
13+}
14+* {
15+ box-sizing: border-box;
16+}
17+html,
18+body {
19+ margin: 0;
20+}
21+body {
22+ background: var(--bg);
23+ color: var(--fg);
24+ font: 14px/1.45 system-ui, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
25+ -webkit-font-smoothing: antialiased;
26+ user-select: none;
27+ cursor: default;
28+}
29+main {
30+ max-width: 36rem;
31+ margin: 0 auto;
32+ padding: 1.5rem 1.25rem 1.25rem;
33+}
34+h1 {
35+ font-size: 1.25rem;
36+ font-weight: 600;
37+ margin: 0 0 0.25rem;
38+}
39+h2 {
40+ font-size: 0.75rem;
41+ font-weight: 600;
42+ letter-spacing: 0.06em;
43+ text-transform: uppercase;
44+ color: var(--muted);
45+ margin: 0 0 0.25rem;
46+}
47+p {
48+ margin: 0;
49+}
50+.lede {
51+ margin-bottom: 1.5rem;
52+}
53+.muted {
54+ color: var(--muted);
55+}
56+.small {
57+ font-size: 0.8125rem;
58+}
59+section {
60+ margin-bottom: 1.5rem;
61+}
62+.setting {
63+ display: flex;
64+ align-items: center;
65+ justify-content: space-between;
66+ gap: 1rem;
67+ padding: 0.75rem 0;
68+ border-bottom: 1px solid var(--line);
69+}
70+section .setting:last-child {
71+ border-bottom: 0;
72+}
73+.setting > span {
74+ display: flex;
75+ flex-direction: column;
76+ gap: 0.125rem;
77+ min-width: 0;
78+}
79+.setting strong {
80+ font-weight: 500;
81+ overflow-wrap: anywhere;
82+}
83+.setting .muted {
84+ font-size: 0.8125rem;
85+}
86+.setting.off {
87+ opacity: 0.5;
88+}
89+input[type="checkbox"][role="switch"] {
90+ appearance: none;
91+ flex: none;
92+ width: 36px;
93+ height: 20px;
94+ margin: 0;
95+ border-radius: 999px;
96+ background: var(--line);
97+ position: relative;
98+ transition: background 0.15s;
99+}
100+input[type="checkbox"][role="switch"]::after {
101+ content: "";
102+ position: absolute;
103+ top: 2px;
104+ left: 2px;
105+ width: 16px;
106+ height: 16px;
107+ border-radius: 50%;
108+ background: var(--fg);
109+ transition: transform 0.15s;
110+}
111+input[type="checkbox"][role="switch"]:checked {
112+ background: var(--accent);
113+}
114+input[type="checkbox"][role="switch"]:checked::after {
115+ transform: translateX(16px);
116+ background: var(--accent-ink);
117+}
118+input:disabled {
119+ opacity: 0.5;
120+}
121+@media (prefers-reduced-motion: reduce) {
122+ input[type="checkbox"][role="switch"],
123+ input[type="checkbox"][role="switch"]::after {
124+ transition: none;
125+ }
126+}
127+button,
128+select {
129+ flex: none;
130+ font: inherit;
131+ font-size: 0.8125rem;
132+ font-weight: 500;
133+ padding: 0.375rem 0.75rem;
134+ border-radius: 8px;
135+ border: 1px solid var(--line);
136+ background: var(--surface);
137+ color: var(--fg);
138+ cursor: default;
139+}
140+button:hover,
141+select:hover {
142+ border-color: #3a3a44;
143+}
144+button:disabled {
145+ opacity: 0.6;
146+}
147+button:focus-visible,
148+select:focus-visible,
149+input:focus-visible,
150+a:focus-visible {
151+ outline: 2px solid var(--accent);
152+ outline-offset: 2px;
153+}
154+a {
155+ color: var(--muted);
156+ text-decoration: underline;
157+ text-underline-offset: 3px;
158+}
159+a:hover {
160+ color: var(--fg);
161+}
162+.error {
163+ color: var(--error);
164+ font-size: 0.875rem;
165+ margin-bottom: 1rem;
166+}
167+.foot {
168+ text-align: center;
169+}
+100−0
1+<!doctype html>
2+<!--
3+ The desktop app's settings, in a window of their own: opened from the
4+ menu, the tray, or the site's account menu (src/settings.rs). Each
5+ change is kept as soon as it is made; there is nothing to save.
6+-->
7+<html lang="en">
8+ <head>
9+ <meta charset="utf-8" />
10+ <meta name="viewport" content="width=device-width, initial-scale=1" />
11+ <meta name="color-scheme" content="dark" />
12+ <title>g1t settings</title>
13+ <link rel="stylesheet" href="settings.css" />
14+ </head>
15+ <body>
16+ <main>
17+ <h1>Settings</h1>
18+ <p class="muted lede">For the desktop app on this computer. Your account's settings, notifications included, are on g1t itself.</p>
19+
20+ <section aria-labelledby="general-heading">
21+ <h2 id="general-heading">Starting and closing</h2>
22+ <label class="setting">
23+ <span><strong>Open g1t when you sign in</strong><span class="muted" id="open-at-login-note">g1t starts with your computer.</span></span>
24+ <input type="checkbox" role="switch" id="openAtLogin" />
25+ </label>
26+ <label class="setting">
27+ <span><strong>Start in the tray</strong><span class="muted" id="start-hidden-note">Opened at sign-in, g1t waits in the tray without opening its window.</span></span>
28+ <input type="checkbox" role="switch" id="startHidden" />
29+ </label>
30+ <label class="setting">
31+ <span><strong>Keep g1t running when the window closes</strong><span class="muted">It stays in the tray for notifications. Turned off, closing the window quits.</span></span>
32+ <input type="checkbox" role="switch" id="closeToTray" />
33+ </label>
34+ </section>
35+
36+ <section aria-labelledby="notifications-heading">
37+ <h2 id="notifications-heading">Notifications</h2>
38+ <label class="setting">
39+ <span><strong>Show notifications</strong><span class="muted">From your system, for what needs you while g1t is behind another window. Which things notify you is set on g1t.</span></span>
40+ <input type="checkbox" role="switch" id="notifications" />
41+ </label>
42+ <label class="setting">
43+ <span><strong id="flash-label">Flash the taskbar icon</strong><span class="muted">When a notification arrives and the window is not in front.</span></span>
44+ <input type="checkbox" role="switch" id="flash" />
45+ </label>
46+ <label class="setting">
47+ <span><strong>Show a badge on the icon</strong><span class="muted">How many things are waiting for you.</span></span>
48+ <input type="checkbox" role="switch" id="badge" />
49+ </label>
50+ </section>
51+
52+ <section aria-labelledby="window-heading">
53+ <h2 id="window-heading">Window</h2>
54+ <label class="setting" id="menu-bar-row">
55+ <span><strong>Show the menu bar</strong><span class="muted">File, Edit, View, Go, Window and Help, under the title.</span></span>
56+ <input type="checkbox" role="switch" id="menuBar" />
57+ </label>
58+ <label class="setting">
59+ <span><strong>Zoom</strong><span class="muted" id="zoom-note">The View menu changes it too.</span></span>
60+ <select id="zoom">
61+ <option value="0.67">67%</option>
62+ <option value="0.75">75%</option>
63+ <option value="0.8">80%</option>
64+ <option value="0.9">90%</option>
65+ <option value="1">100%</option>
66+ <option value="1.1">110%</option>
67+ <option value="1.25">125%</option>
68+ <option value="1.5">150%</option>
69+ <option value="1.75">175%</option>
70+ <option value="2">200%</option>
71+ </select>
72+ </label>
73+ </section>
74+
75+ <section aria-labelledby="updates-heading">
76+ <h2 id="updates-heading">Updates</h2>
77+ <label class="setting">
78+ <span><strong>Download updates by themselves</strong><span class="muted">Each one installs when you restart or quit g1t, never in the middle of something.</span></span>
79+ <input type="checkbox" role="switch" id="autoUpdate" />
80+ </label>
81+ <div class="setting">
82+ <span><strong id="version">g1t</strong><span class="muted" id="update-status">&nbsp;</span></span>
83+ <button type="button" id="check">Check for updates</button>
84+ </div>
85+ </section>
86+
87+ <section aria-labelledby="server-heading">
88+ <h2 id="server-heading">Server</h2>
89+ <div class="setting">
90+ <span><strong id="server">g1t.sh</strong><span class="muted">The g1t this app opens. Change it if you run g1t yourself.</span></span>
91+ <button type="button" id="change-server">Change…</button>
92+ </div>
93+ </section>
94+
95+ <p class="error" id="error" role="alert" hidden></p>
96+ <p class="muted small foot"><a href="https://docs.g1t.sh/guides/desktop/">About the desktop app</a></p>
97+ </main>
98+ <script src="settings.js"></script>
99+ </body>
100+</html>
+124−0
1+// The settings window's work: show what the app has, and keep each change
2+// the moment it is made. Everything it asks is a command in
3+// src/bridge.rs, answered only to this window.
4+(function () {
5+ var SWITCHES = ["startHidden", "closeToTray", "notifications", "flash", "badge", "menuBar", "autoUpdate"];
6+ var current = null;
7+
8+ function byId(id) {
9+ return document.getElementById(id);
10+ }
11+
12+ function invoke(command, args) {
13+ var core = window.__TAURI__ && window.__TAURI__.core;
14+ if (core && typeof core.invoke === "function") return core.invoke(command, args || {});
15+ return Promise.reject(new Error("This page belongs inside the g1t desktop app."));
16+ }
17+
18+ function fail(error) {
19+ var box = byId("error");
20+ box.textContent = typeof error === "string" ? error : (error && error.message) || "That could not be changed.";
21+ box.hidden = false;
22+ }
23+
24+ function hostOf(server) {
25+ try {
26+ return new URL(server).host;
27+ } catch (error) {
28+ return server;
29+ }
30+ }
31+
32+ // What changes between Windows, macOS and Linux.
33+ function platform(name) {
34+ byId("flash-label").textContent =
35+ name === "macos" ? "Bounce the dock icon" : name === "windows" ? "Flash the taskbar icon" : "Ask for attention";
36+ // macOS keeps its menu bar at the top of the screen, always there.
37+ byId("menu-bar-row").hidden = name === "macos";
38+ if (name === "macos") byId("zoom-note").textContent = "The View menu changes it too: ⌘+, ⌘− and ⌘0.";
39+ else byId("zoom-note").textContent = "The View menu changes it too: Ctrl+=, Ctrl+− and Ctrl+0.";
40+ }
41+
42+ function render(view) {
43+ current = view;
44+ byId("error").hidden = true;
45+ byId("openAtLogin").checked = !!view.openAtLogin;
46+ SWITCHES.forEach(function (key) {
47+ byId(key).checked = !!view[key];
48+ });
49+ // Starting in the tray only means something when g1t starts at sign-in.
50+ byId("startHidden").disabled = !view.openAtLogin;
51+ byId("startHidden").closest(".setting").classList.toggle("off", !view.openAtLogin);
52+ var zoom = byId("zoom");
53+ zoom.value = String(view.zoom);
54+ if (zoom.value === "") zoom.value = "1";
55+ byId("version").textContent = "g1t " + view.version;
56+ if (view.updateReady) byId("update-status").textContent = "Version " + view.updateReady + " is downloaded. Restart g1t to use it.";
57+ byId("server").textContent = hostOf(view.server);
58+ }
59+
60+ function settingsOf() {
61+ var settings = {};
62+ SWITCHES.forEach(function (key) {
63+ settings[key] = byId(key).checked;
64+ });
65+ settings.zoom = Number(byId("zoom").value) || 1;
66+ return settings;
67+ }
68+
69+ function save() {
70+ invoke("save_settings", { settings: settingsOf() }).then(render, function (error) {
71+ fail(error);
72+ if (current) render(current);
73+ });
74+ }
75+
76+ SWITCHES.forEach(function (key) {
77+ byId(key).addEventListener("change", save);
78+ });
79+ byId("zoom").addEventListener("change", save);
80+ byId("openAtLogin").addEventListener("change", function (event) {
81+ invoke("set_open_at_login", { on: event.target.checked }).then(render, function (error) {
82+ fail(error);
83+ if (current) render(current);
84+ });
85+ });
86+
87+ byId("check").addEventListener("click", function () {
88+ var button = byId("check");
89+ var status = byId("update-status");
90+ button.disabled = true;
91+ status.textContent = "Checking…";
92+ invoke("check_for_updates")
93+ .then(function (answer) {
94+ if (answer === "current") status.textContent = "You have the newest version.";
95+ else if (answer.indexOf("ready:") === 0) status.textContent = "Version " + answer.slice(6) + " is downloaded. Restart g1t to use it.";
96+ else status.textContent = answer;
97+ })
98+ .catch(function (error) {
99+ status.textContent = "Could not check: " + (typeof error === "string" ? error : "try again later.");
100+ })
101+ .then(function () {
102+ button.disabled = false;
103+ });
104+ });
105+
106+ byId("change-server").addEventListener("click", function () {
107+ invoke("change_server").catch(fail);
108+ });
109+
110+ // Links out, in the system's browser (src/bridge.js does the same on the site).
111+ document.addEventListener("click", function (event) {
112+ var anchor = event.target && event.target.closest ? event.target.closest("a[href]") : null;
113+ if (!anchor) return;
114+ event.preventDefault();
115+ invoke("open_url", { url: anchor.href }).catch(function () {});
116+ });
117+
118+ invoke("get_settings")
119+ .then(function (view) {
120+ platform(view.platform);
121+ render(view);
122+ })
123+ .catch(fail);
124+})();
+106−5
217217 checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
218218
219219 [[package]]
220+name = "auto-launch"
221+version = "0.6.0"
222+source = "registry+https://github.com/rust-lang/crates.io-index"
223+checksum = "17918dba7ecf78b9a14507ec9f984e7977d13fad87dc86d61038980a45d128cf"
224+dependencies = [
225+ "dirs 6.0.0",
226+ "os_info",
227+ "smappservice-rs",
228+ "thiserror 2.0.21",
229+ "windows-registry",
230+ "windows-result",
231+]
232+
233+[[package]]
220234 name = "autocfg"
221235 version = "1.5.1"
222236 source = "registry+https://github.com/rust-lang/crates.io-index"
819833
820834 [[package]]
821835 name = "dirs"
836+version = "6.0.0"
837+source = "registry+https://github.com/rust-lang/crates.io-index"
838+checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
839+dependencies = [
840+ "dirs-sys",
841+]
842+
843+[[package]]
844+name = "dirs"
822845 version = "7.0.0"
823846 source = "registry+https://github.com/rust-lang/crates.io-index"
824847 checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f"
12311254 "serde_json",
12321255 "tauri",
12331256 "tauri-build",
1257+ "tauri-plugin-autostart",
12341258 "tauri-plugin-deep-link",
12351259 "tauri-plugin-notification",
12361260 "tauri-plugin-opener",
23452369 checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
23462370
23472371 [[package]]
2372+name = "nix"
2373+version = "0.31.3"
2374+source = "registry+https://github.com/rust-lang/crates.io-index"
2375+checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
2376+dependencies = [
2377+ "bitflags 2.13.2",
2378+ "cfg-if",
2379+ "cfg_aliases",
2380+ "libc",
2381+]
2382+
2383+[[package]]
23482384 name = "notify-rust"
23492385 version = "4.18.2"
23502386 source = "registry+https://github.com/rust-lang/crates.io-index"
25602596 ]
25612597
25622598 [[package]]
2599+name = "objc2-security"
2600+version = "0.3.2"
2601+source = "registry+https://github.com/rust-lang/crates.io-index"
2602+checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a"
2603+dependencies = [
2604+ "bitflags 2.13.2",
2605+ "objc2",
2606+ "objc2-core-foundation",
2607+]
2608+
2609+[[package]]
2610+name = "objc2-service-management"
2611+version = "0.3.2"
2612+source = "registry+https://github.com/rust-lang/crates.io-index"
2613+checksum = "b213642d6959cc6023ceb1217aa595eaaf09b8094ce95127c103cab611fe65e8"
2614+dependencies = [
2615+ "block2",
2616+ "objc2",
2617+ "objc2-core-foundation",
2618+ "objc2-foundation",
2619+ "objc2-security",
2620+]
2621+
2622+[[package]]
25632623 name = "objc2-ui-kit"
25642624 version = "0.3.2"
25652625 source = "registry+https://github.com/rust-lang/crates.io-index"
26542714 ]
26552715
26562716 [[package]]
2717+name = "os_info"
2718+version = "3.15.0"
2719+source = "registry+https://github.com/rust-lang/crates.io-index"
2720+checksum = "9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b"
2721+dependencies = [
2722+ "android_system_properties",
2723+ "log",
2724+ "nix",
2725+ "objc2",
2726+ "objc2-foundation",
2727+ "objc2-ui-kit",
2728+ "windows-sys 0.61.2",
2729+]
2730+
2731+[[package]]
26572732 name = "osakit"
26582733 version = "0.3.1"
26592734 source = "registry+https://github.com/rust-lang/crates.io-index"
37653840 checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b"
37663841
37673842 [[package]]
3843+name = "smappservice-rs"
3844+version = "0.1.3"
3845+source = "registry+https://github.com/rust-lang/crates.io-index"
3846+checksum = "52703b97a53101cf5d4580e0737aaa634ce1fdcfc123c16b2a9658e358013488"
3847+dependencies = [
3848+ "objc2",
3849+ "objc2-foundation",
3850+ "objc2-service-management",
3851+ "thiserror 2.0.21",
3852+]
3853+
3854+[[package]]
37683855 name = "socket2"
37693856 version = "0.6.5"
37703857 source = "registry+https://github.com/rust-lang/crates.io-index"
40384125 "anyhow",
40394126 "bytes",
40404127 "cookie",
4041− "dirs",
4128+ "dirs 7.0.0",
40424129 "dunce",
40434130 "embed_plist",
40444131 "getrandom 0.3.4",
40894176 dependencies = [
40904177 "anyhow",
40914178 "cargo_toml",
4092− "dirs",
4179+ "dirs 7.0.0",
40934180 "glob",
40944181 "heck 0.5.0",
40954182 "json-patch",
41604247 ]
41614248
41624249 [[package]]
4250+name = "tauri-plugin-autostart"
4251+version = "2.7.0"
4252+source = "registry+https://github.com/rust-lang/crates.io-index"
4253+checksum = "bce56ceb0a99b8ab5eebc977ba9ffc62391b862648cb9ab76fc8bc7231b8f7e2"
4254+dependencies = [
4255+ "auto-launch",
4256+ "serde",
4257+ "serde_json",
4258+ "tauri",
4259+ "tauri-plugin",
4260+ "thiserror 2.0.21",
4261+]
4262+
4263+[[package]]
41634264 name = "tauri-plugin-deep-link"
41644265 version = "2.6.1"
41654266 source = "registry+https://github.com/rust-lang/crates.io-index"
42464347 checksum = "855a336eb389d2c1502244fd48fa2d8cb487a430f691bbaed025195f99a04489"
42474348 dependencies = [
42484349 "base64 0.22.1",
4249− "dirs",
4350+ "dirs 7.0.0",
42504351 "flate2",
42514352 "futures-util",
42524353 "http",
47324833 checksum = "b2b9c52859a94554803ccd4a24b98f74148ebc73b90676d783f3490b1bff9d72"
47334834 dependencies = [
47344835 "crossbeam-channel",
4735− "dirs",
4836+ "dirs 7.0.0",
47364837 "libappindicator",
47374838 "muda",
47384839 "objc2",
54755576 "block2",
54765577 "cookie",
54775578 "crossbeam-channel",
5478− "dirs",
5579+ "dirs 7.0.0",
54795580 "dom_query",
54805581 "dpi",
54815582 "dunce",
+2−0
2929 tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
3030 # The window's size and place, kept between launches.
3131 tauri-plugin-window-state = "2"
32+# Opening g1t when someone signs in to their computer (settings.rs).
33+tauri-plugin-autostart = "2"
3234 serde = { version = "1", features = ["derive"] }
3335 serde_json = "1"
3436 url = "2"
+7−0
1+{
2+ "$schema": "../gen/schemas/desktop-schema.json",
3+ "identifier": "settings",
4+ "description": "The settings window, a page bundled with the app (launcher/settings.html). Its commands answer only this window (src/bridge.rs).",
5+ "windows": ["settings"],
6+ "permissions": ["core:default"]
7+}
+12−3
11 //! The badge: what needs the person, on the app's icon and in the tray's
22 //! hover text. macOS and Linux draw a count on the icon; Windows has no
3−//! count, so it gets a lavender dot over the taskbar icon instead.
3+//! count, so it gets a lavender dot over the taskbar icon instead. With
4+//! the badge turned off in settings, the icon stays clean and only the
5+//! tray's hover text counts.
46
57 use tauri::{AppHandle, Manager};
68
810
911 pub fn set(app: &AppHandle, count: i64) {
1012 *app.state::<config::State>().waiting.lock().unwrap() = count;
13+ let shown = if config::settings(app).badge { count } else { 0 };
1114 if let Some(window) = app.get_webview_window(WINDOW) {
1215 #[cfg(target_os = "windows")]
13− let _ = window.set_overlay_icon(if count > 0 { Some(dot()) } else { None });
16+ let _ = window.set_overlay_icon(if shown > 0 { Some(dot()) } else { None });
1417 #[cfg(not(target_os = "windows"))]
15− let _ = window.set_badge_count(if count > 0 { Some(count) } else { None });
18+ let _ = window.set_badge_count(if shown > 0 { Some(shown) } else { None });
1619 }
1720 if let Some(tray) = app.tray_by_id(tray::TRAY) {
1821 let text = match count {
2427 }
2528 }
2629
30+/// Draws the badge again, after the setting changed.
31+pub fn refresh(app: &AppHandle) {
32+ let count = *app.state::<config::State>().waiting.lock().unwrap();
33+ set(app, count);
34+}
35+
2736 /// A lavender dot with a dark edge, 32 pixels square, drawn here so
2837 /// nothing needs loading.
2938 #[cfg(target_os = "windows")]
+18−4
99 if (window.g1tDesktop) return;
1010 var VERSION = "__VERSION__";
1111 var PLATFORM = "__PLATFORM__";
12+ // The site's pages for reading about g1t, not using it, which open in
13+ // the browser (navigation.rs); one ending in "/" is every page under it.
14+ var OUTSIDE = "__OUTSIDE__";
1215
1316 function invoke(command, args) {
1417 var core = window.__TAURI__ && window.__TAURI__.core;
7477 checkForUpdates: function () {
7578 return invoke("check_for_updates");
7679 },
80+ // The app's own settings, in a window of their own.
81+ openSettings: function () {
82+ quietly(invoke("open_settings"));
83+ },
7784 };
7885 Object.defineProperty(window, "g1tDesktop", { value: Object.freeze(bridge), writable: false, configurable: false });
7986
80− // Links out. A link to another site opens in the system's browser, and
81− // the window stays on g1t; a link that asks for a new tab, or a click
87+ // Links out. A link to another site, or to one of the site's pages for
88+ // reading about g1t, opens in the system's browser, and the window stays
89+ // where it is; a link that asks for a new tab, or a click
8290 // with Ctrl or Cmd held, goes on in this window, which has no tabs.
91+ function outside(url) {
92+ var path = url.pathname.length > 1 ? url.pathname.replace(/\/+$/, "") : url.pathname;
93+ return OUTSIDE.some(function (page) {
94+ return page.charAt(page.length - 1) === "/" ? path.indexOf(page) === 0 : path === page;
95+ });
96+ }
8397 function destination(href) {
8498 try {
8599 var url = new URL(href, location.href);
96110 if (!anchor || anchor.hasAttribute("download")) return;
97111 var url = destination(anchor.getAttribute("href"));
98112 if (!url) return;
99− if (url.origin !== location.origin) {
113+ if (url.origin !== location.origin || outside(url)) {
100114 event.preventDefault();
101115 event.stopImmediatePropagation();
102116 bridge.openUrl(url.href);
112126 window.open = function (href, target, features) {
113127 var url = destination(href);
114128 if (url) {
115− if (url.origin === location.origin) location.assign(url.href);
129+ if (url.origin === location.origin && !outside(url)) location.assign(url.href);
116130 else bridge.openUrl(url.href);
117131 return null;
118132 }
+72−3
77 //! loads.
88
99 use serde::Serialize;
10−use tauri::{AppHandle, Webview};
10+use tauri::{AppHandle, Manager, UserAttentionType, Webview};
1111 use tauri_plugin_notification::NotificationExt;
1212 use tauri_plugin_opener::OpenerExt;
1313 use url::Url;
1414
15−use crate::{badge, config, updates};
15+use crate::settings::{self, Settings};
16+use crate::{WINDOW, badge, config, updates};
1617
1718 const SCRIPT: &str = include_str!("bridge.js");
1819
1920 /// The bridge, run at the start of every page the window loads.
2021 pub fn script() -> String {
21− SCRIPT.replace("__VERSION__", env!("CARGO_PKG_VERSION")).replace("__PLATFORM__", std::env::consts::OS)
22+ SCRIPT
23+ .replace("__VERSION__", env!("CARGO_PKG_VERSION"))
24+ .replace("__PLATFORM__", std::env::consts::OS)
25+ .replace("\"__OUTSIDE__\"", &serde_json::to_string(crate::navigation::OUTSIDE).expect("a list of paths is JSON"))
2226 }
2327
2428 /// What the site and the launcher learn about the app.
4751 if config::is_launcher(&url) { Ok(()) } else { Err("only the launcher may ask that".into()) }
4852 }
4953
54+/// The page asking is the settings window's, bundled with the app.
55+fn from_settings(webview: &Webview) -> Result<(), String> {
56+ from_launcher(webview)?;
57+ if webview.label() == settings::SETTINGS { Ok(()) } else { Err("only the settings window may ask that".into()) }
58+}
59+
5060 fn from_either(app: &AppHandle, webview: &Webview) -> Result<(), String> {
5161 from_site(app, webview).or_else(|_| from_launcher(webview))
5262 }
6777 #[tauri::command]
6878 pub fn notify(app: AppHandle, webview: Webview, title: String, body: String) -> Result<(), String> {
6979 from_site(&app, &webview)?;
80+ let settings = config::settings(&app);
81+ if let Some(window) = app.get_webview_window(WINDOW).filter(|window| settings.flash && !window.is_focused().unwrap_or(false)) {
82+ let _ = window.request_user_attention(Some(UserAttentionType::Informational));
83+ }
84+ if !settings.notifications {
85+ return Ok(());
86+ }
7087 app.notification().builder().title(title).body(body).show().map_err(|error| error.to_string())
7188 }
7289
119136 from_either(&app, &webview)?;
120137 updates::check(&app, true).await
121138 }
139+
140+/// Opens the app's settings window: the site's account menu asks for it.
141+#[tauri::command]
142+pub fn open_settings(app: AppHandle, webview: Webview) -> Result<(), String> {
143+ from_either(&app, &webview)?;
144+ settings::open(&app);
145+ Ok(())
146+}
147+
148+/// What the settings window shows.
149+#[tauri::command]
150+pub fn get_settings(app: AppHandle, webview: Webview) -> Result<settings::View, String> {
151+ from_settings(&webview)?;
152+ Ok(settings::view(&app))
153+}
154+
155+/// The settings window changed something: everything but opening at sign-in.
156+#[tauri::command]
157+pub fn save_settings(app: AppHandle, webview: Webview, settings: Settings) -> Result<settings::View, String> {
158+ from_settings(&webview)?;
159+ settings::apply(&app, settings)?;
160+ Ok(settings::view(&app))
161+}
162+
163+/// Opening at sign-in, which the system keeps.
164+#[tauri::command]
165+pub fn set_open_at_login(app: AppHandle, webview: Webview, on: bool) -> Result<settings::View, String> {
166+ from_settings(&webview)?;
167+ settings::open_at_login(&app, on)?;
168+ Ok(settings::view(&app))
169+}
170+
171+/// The settings window's "Change…": the main window goes to the launcher's choice of server.
172+#[tauri::command]
173+pub fn change_server(app: AppHandle, webview: Webview) -> Result<(), String> {
174+ from_settings(&webview)?;
175+ if let Some(window) = app.get_webview_window(settings::SETTINGS) {
176+ let _ = window.close();
177+ }
178+ crate::tray::launcher(&app, "change");
179+ Ok(())
180+}
181+
182+#[cfg(test)]
183+mod tests {
184+ #[test]
185+ fn the_bridge_has_nothing_left_to_fill_in() {
186+ let script = super::script();
187+ assert!(!script.contains("__VERSION__") && !script.contains("__PLATFORM__") && !script.contains("__OUTSIDE__"));
188+ assert!(script.contains(r#"var OUTSIDE = ["/pricing""#));
189+ }
190+}
+40−13
11 //! What the app keeps between launches, and what it knows while running.
22 //!
3−//! The one setting is the server: `https://g1t.sh` unless someone runs a
4−//! g1t of their own, kept in `desktop.json` in the app's config folder
5−//! (`~/.config/g1t` on Linux, `~/Library/Application Support/sh.g1t.desktop`
6−//! on macOS, `%APPDATA%\sh.g1t.desktop` on Windows). The server's pages are
7−//! the only remote pages allowed to talk to the app (`allow_site`).
3+//! The server, `https://g1t.sh` unless someone runs a g1t of their own,
4+//! and the app's own settings (settings.rs) are kept in `desktop.json` in
5+//! the app's config folder (`~/.config/sh.g1t.desktop` on Linux,
6+//! `~/Library/Application Support/sh.g1t.desktop` on macOS,
7+//! `%APPDATA%\sh.g1t.desktop` on Windows). The server's pages are the only
8+//! remote pages allowed to talk to the app (`allow_site`).
89
910 use std::fs;
1011 use std::path::PathBuf;
3334 pub update: Mutex<Option<updates::Ready>>,
3435 /// What the badge shows.
3536 pub waiting: Mutex<i64>,
37+ /// The app's own settings, as the settings window last left them.
38+ pub settings: Mutex<Settings>,
3639 }
3740
41+use crate::settings::Settings;
3842 use crate::updates;
3943
4044 #[derive(Default, Serialize, Deserialize)]
4145 struct File {
4246 #[serde(default)]
4347 server: Option<String>,
48+ #[serde(flatten)]
49+ settings: Settings,
4450 }
4551
4652 fn path(app: &AppHandle) -> Option<PathBuf> {
6167 Some(url)
6268 }
6369
64−/// Reads the saved server, or takes g1t.sh.
70+/// Reads the saved server, or takes g1t.sh, and the saved settings.
6571 pub fn load(app: &AppHandle) {
6672 let file: File = path(app)
6773 .and_then(|path| fs::read(path).ok())
6874 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
6975 .unwrap_or_default();
7076 let server = file.server.as_deref().and_then(parse).unwrap_or_else(|| Url::parse(DEFAULT_SERVER).expect("the default server is a URL"));
71− *app.state::<State>().server.lock().unwrap() = Some(server);
77+ let state = app.state::<State>();
78+ *state.server.lock().unwrap() = Some(server);
79+ *state.settings.lock().unwrap() = file.settings;
7280 }
7381
82+/// Writes the server and the settings as they are now.
83+fn save(app: &AppHandle) -> Result<(), String> {
84+ let state = app.state::<State>();
85+ let file = File {
86+ server: state.server.lock().unwrap().as_ref().map(Url::to_string),
87+ settings: state.settings.lock().unwrap().clone(),
88+ };
89+ let path = path(app).ok_or("The app has no folder to keep its settings in.")?;
90+ if let Some(dir) = path.parent() {
91+ fs::create_dir_all(dir).map_err(|error| error.to_string())?;
92+ }
93+ fs::write(&path, serde_json::to_vec_pretty(&file).map_err(|error| error.to_string())?).map_err(|error| error.to_string())
94+}
95+
96+pub fn settings(app: &AppHandle) -> Settings {
97+ app.state::<State>().settings.lock().unwrap().clone()
98+}
99+
100+/// Keeps new settings.
101+pub fn set_settings(app: &AppHandle, settings: Settings) -> Result<(), String> {
102+ *app.state::<State>().settings.lock().unwrap() = settings;
103+ save(app)
104+}
105+
74106 pub fn server(app: &AppHandle) -> Url {
75107 app.state::<State>()
76108 .server
83115 /// Saves a new server and lets its pages talk to the app.
84116 pub fn set_server(app: &AppHandle, text: &str) -> Result<Url, String> {
85117 let server = parse(text).ok_or("That is not a web address. It looks like https://g1t.example.com.")?;
86− let file = File { server: Some(server.to_string()) };
87− let path = path(app).ok_or("The app has no folder to keep its settings in.")?;
88− if let Some(dir) = path.parent() {
89− fs::create_dir_all(dir).map_err(|error| error.to_string())?;
90− }
91− fs::write(&path, serde_json::to_vec_pretty(&file).map_err(|error| error.to_string())?).map_err(|error| error.to_string())?;
92118 *app.state::<State>().server.lock().unwrap() = Some(server.clone());
119+ save(app)?;
93120 allow_site(app).map_err(|error| error.to_string())?;
94121 Ok(server)
95122 }
+41−9
22 //! own. The window loads the live site, so every change to g1t reaches it
33 //! the moment it is deployed and nothing here needs a new build for it;
44 //! only what is native lives in this program: notifications from the
5−//! system, a badge on the icon, a tray, `g1t://` links, the server to use,
6−//! and updates that install themselves (docs.g1t.sh/guides/desktop/).
5+//! system, a badge on the icon, a tray, a menu, `g1t://` links, the server
6+//! to use, the app's own settings, and updates that install themselves
7+//! (docs.g1t.sh/guides/desktop/).
78 //!
89 //! The site finds the app at `window.g1tDesktop` (bridge.js), the one
910 //! contract between the two; what the page asks of it is answered by the
1516 mod bridge;
1617 mod config;
1718 mod links;
19+mod menu;
1820 mod navigation;
21+mod settings;
1922 mod tray;
2023 mod updates;
2124
3538 .plugin(tauri_plugin_notification::init())
3639 .plugin(tauri_plugin_opener::init())
3740 .plugin(tauri_plugin_updater::Builder::new().build())
38− .plugin(tauri_plugin_window_state::Builder::new().build())
41+ // The window's size and place come back in setup, without the plugin
42+ // also showing it: at sign-in g1t may start in the tray.
43+ .plugin(tauri_plugin_window_state::Builder::new().skip_initial_state(WINDOW).with_denylist(&[settings::SETTINGS]).build())
44+ // Opening at sign-in, which settings turn on; the argument tells that start from someone's.
45+ .plugin(tauri_plugin_autostart::init(tauri_plugin_autostart::MacosLauncher::LaunchAgent, Some(vec![settings::AT_LOGIN])))
3946 .manage(config::State::default())
4047 .invoke_handler(tauri::generate_handler![
4148 bridge::desktop_info,
4552 bridge::reachable,
4653 bridge::set_server,
4754 bridge::check_for_updates,
55+ bridge::open_settings,
56+ bridge::get_settings,
57+ bridge::save_settings,
58+ bridge::set_open_at_login,
59+ bridge::change_server,
4860 ])
61+ .on_menu_event(|app, event| menu::handle(app, event.id().as_ref()))
4962 .setup(|app| {
5063 let handle = app.handle().clone();
5164 config::load(&handle);
5265 config::allow_site(&handle)?;
53− let window = WebviewWindowBuilder::new(app, WINDOW, WebviewUrl::App("index.html".into()))
66+ let hidden = settings::starts_hidden(&handle);
67+ let prefs = config::settings(&handle);
68+ let builder = WebviewWindowBuilder::new(app, WINDOW, WebviewUrl::App("index.html".into()))
5469 .title("g1t")
5570 .inner_size(1280.0, 840.0)
5671 .min_inner_size(720.0, 480.0)
72+ .visible(false)
5773 .initialization_script(bridge::script())
5874 .on_navigation({
5975 let handle = handle.clone();
6278 .on_page_load({
6379 let handle = handle.clone();
6480 move |_webview, payload| config::at(&handle, payload.url().clone())
65− })
66− .build()?;
67− let _ = window.restore_state(StateFlags::all());
68− // Closing the window keeps g1t running, for notifications; Quit in the tray ends it.
81+ });
82+ // macOS has one menu bar, the app's; elsewhere the menu is the window's own.
83+ #[cfg(target_os = "macos")]
84+ app.set_menu(menu::build(&handle)?)?;
85+ #[cfg(not(target_os = "macos"))]
86+ let builder = builder.menu(menu::build(&handle)?);
87+ let window = builder.build()?;
88+ // Opened at sign-in, g1t waits in the tray; otherwise the window comes up where it was.
89+ let _ = window.restore_state(StateFlags::all() - StateFlags::VISIBLE);
90+ if !hidden {
91+ let _ = window.show();
92+ }
93+ #[cfg(not(target_os = "macos"))]
94+ if !prefs.menu_bar {
95+ let _ = window.hide_menu();
96+ }
97+ if prefs.zoom != 1.0 {
98+ let _ = window.set_zoom(prefs.zoom);
99+ }
100+ // Closing the window keeps g1t running, for notifications, unless settings say it quits.
69101 window.on_window_event({
70102 let handle = handle.clone();
71103 move |event| {
72104 if let WindowEvent::CloseRequested { api, .. } = event {
73105 api.prevent_close();
74− tray::hide(&handle);
106+ tray::close(&handle);
75107 }
76108 }
77109 });
+168−0
1+//! The app's menu: File, Edit, View, Go, Window and Help, as a desktop app
2+//! has. On macOS it is the menu bar at the top of the screen, with g1t's
3+//! own menu first; on Windows and Linux it sits under the window's title,
4+//! and settings can hide it. The tray's menu (tray.rs) shares its ids, and
5+//! every menu event, from either, comes to `handle`.
6+
7+use tauri::menu::{AboutMetadata, Menu, MenuItem, PredefinedMenuItem, Submenu};
8+use tauri::{AppHandle, Manager, Wry};
9+use tauri_plugin_opener::OpenerExt;
10+
11+use crate::{WINDOW, links, settings, tray, updates};
12+
13+const DOCS: &str = "https://docs.g1t.sh/guides/desktop/";
14+const STATUS: &str = "https://status.g1t.sh/";
15+
16+pub fn build(app: &AppHandle) -> tauri::Result<Menu<Wry>> {
17+ let separator = || PredefinedMenuItem::separator(app);
18+ let item = |id: &str, text: &str, accelerator: Option<&str>| MenuItem::with_id(app, id, text, true, accelerator);
19+
20+ let settings_item = item("settings", if cfg!(target_os = "macos") { "Settings…" } else { "Settings" }, Some("CmdOrCtrl+,"))?;
21+ let check = item("check", "Check for updates…", None)?;
22+ let server = item("server", "Use another g1t…", None)?;
23+ let quit = item("quit", "Quit g1t", Some("CmdOrCtrl+Q"))?;
24+
25+ let edit = Submenu::with_items(
26+ app,
27+ "Edit",
28+ true,
29+ &[
30+ &PredefinedMenuItem::undo(app, None)?,
31+ &PredefinedMenuItem::redo(app, None)?,
32+ &separator()?,
33+ &PredefinedMenuItem::cut(app, None)?,
34+ &PredefinedMenuItem::copy(app, None)?,
35+ &PredefinedMenuItem::paste(app, None)?,
36+ &PredefinedMenuItem::select_all(app, None)?,
37+ ],
38+ )?;
39+ let view = Submenu::with_items(
40+ app,
41+ "View",
42+ true,
43+ &[
44+ &item("reload", "Reload", Some("CmdOrCtrl+R"))?,
45+ &separator()?,
46+ &item("zoom-reset", "Actual size", Some("CmdOrCtrl+0"))?,
47+ &item("zoom-in", "Zoom in", Some("CmdOrCtrl+="))?,
48+ &item("zoom-out", "Zoom out", Some("CmdOrCtrl+-"))?,
49+ &separator()?,
50+ &PredefinedMenuItem::fullscreen(app, None)?,
51+ ],
52+ )?;
53+ let go = Submenu::with_items(
54+ app,
55+ "Go",
56+ true,
57+ &[
58+ &item("back", "Back", Some(if cfg!(target_os = "macos") { "Cmd+[" } else { "Alt+Left" }))?,
59+ &item("forward", "Forward", Some(if cfg!(target_os = "macos") { "Cmd+]" } else { "Alt+Right" }))?,
60+ &separator()?,
61+ &item("home", "Home", None)?,
62+ &item("notifications", "Notifications", None)?,
63+ ],
64+ )?;
65+ let window = Submenu::with_items(
66+ app,
67+ "Window",
68+ true,
69+ &[&PredefinedMenuItem::minimize(app, None)?, &PredefinedMenuItem::maximize(app, None)?],
70+ )?;
71+ let help = Submenu::with_items(app, "Help", true, &[&item("docs", "Desktop app guide", None)?, &item("status", "g1t.sh status", None)?, &separator()?, &check])?;
72+ // macOS keeps About in g1t's own menu; elsewhere it closes Help.
73+ #[cfg(not(target_os = "macos"))]
74+ {
75+ help.append(&separator()?)?;
76+ help.append(&PredefinedMenuItem::about(app, Some("About g1t"), Some(about()))?)?;
77+ }
78+
79+ #[cfg(target_os = "macos")]
80+ {
81+ let g1t = Submenu::with_items(
82+ app,
83+ "g1t",
84+ true,
85+ &[
86+ &PredefinedMenuItem::about(app, Some("About g1t"), Some(about()))?,
87+ &separator()?,
88+ &settings_item,
89+ &server,
90+ &separator()?,
91+ &PredefinedMenuItem::services(app, None)?,
92+ &separator()?,
93+ &PredefinedMenuItem::hide(app, None)?,
94+ &PredefinedMenuItem::hide_others(app, None)?,
95+ &PredefinedMenuItem::show_all(app, None)?,
96+ &separator()?,
97+ &quit,
98+ ],
99+ )?;
100+ let file = Submenu::with_items(app, "File", true, &[&PredefinedMenuItem::close_window(app, None)?])?;
101+ Menu::with_items(app, &[&g1t, &file, &edit, &view, &go, &window, &help])
102+ }
103+ #[cfg(not(target_os = "macos"))]
104+ {
105+ let file = Submenu::with_items(
106+ app,
107+ "File",
108+ true,
109+ &[&settings_item, &server, &separator()?, &item("close", "Close window", Some("CmdOrCtrl+W"))?, &quit],
110+ )?;
111+ Menu::with_items(app, &[&file, &edit, &view, &go, &window, &help])
112+ }
113+}
114+
115+fn about() -> AboutMetadata<'static> {
116+ AboutMetadata {
117+ name: Some("g1t".into()),
118+ version: Some(env!("CARGO_PKG_VERSION").into()),
119+ copyright: Some("Flagon, Inc.".into()),
120+ website: Some("https://g1t.sh".into()),
121+ website_label: Some("g1t.sh".into()),
122+ ..Default::default()
123+ }
124+}
125+
126+/// Runs the script on the main window's page.
127+fn eval(app: &AppHandle, script: &str) {
128+ if let Some(window) = app.get_webview_window(WINDOW) {
129+ let _ = window.eval(script);
130+ }
131+}
132+
133+/// A menu row was chosen, from the app's menu or the tray's.
134+pub fn handle(app: &AppHandle, id: &str) {
135+ match id {
136+ "open" => tray::show(app),
137+ "settings" => settings::open(app),
138+ "server" => tray::launcher(app, "change"),
139+ "check" => {
140+ let app = app.clone();
141+ tauri::async_runtime::spawn(async move {
142+ let _ = updates::check(&app, true).await;
143+ });
144+ }
145+ "restart" => updates::restart(app),
146+ "quit" => updates::quit(app),
147+ "close" => tray::close(app),
148+ "reload" => {
149+ if let Some(window) = app.get_webview_window(WINDOW) {
150+ let _ = window.reload();
151+ }
152+ }
153+ "zoom-in" => settings::zoom(app, 1),
154+ "zoom-out" => settings::zoom(app, -1),
155+ "zoom-reset" => settings::zoom(app, 0),
156+ "back" => eval(app, "history.back()"),
157+ "forward" => eval(app, "history.forward()"),
158+ "home" => links::go(app, "/"),
159+ "notifications" => links::go(app, "/notifications"),
160+ "docs" => {
161+ let _ = app.opener().open_url(DOCS, None::<&str>);
162+ }
163+ "status" => {
164+ let _ = app.opener().open_url(STATUS, None::<&str>);
165+ }
166+ _ => {}
167+ }
168+}
+34−2
11 //! Where the window may go: the launcher, the server's pages, and the
22 //! pages that sign someone in there. Any other address opens in the
3−//! browser the system uses, and the window stays where it is.
3+//! browser the system uses, and the window stays where it is. So do the
4+//! server's pages for reading about g1t rather than using it (`OUTSIDE`):
5+//! the window is for working in, the browser for browsing. bridge.js
6+//! sends the same pages out when a link inside the site goes to one.
47
58 use tauri::AppHandle;
69 use tauri_plugin_opener::OpenerExt;
1114 /// Signing in with GitHub goes there and comes back.
1215 const SIGN_IN_HOSTS: &[&str] = &["github.com", "www.github.com"];
1316
17+/// The server's pages that open in the browser: pricing, policies,
18+/// security, support, the desktop app's own downloads, and status. An
19+/// entry ending in `/` is every page under it.
20+pub const OUTSIDE: &[&str] = &["/pricing", "/policies", "/policies/", "/security", "/support", "/download", "/downloads/", "/status"];
21+
22+/// Whether a path on the server is one for the browser.
23+pub fn outside(path: &str) -> bool {
24+ let path = if path.len() > 1 { path.trim_end_matches('/') } else { path };
25+ OUTSIDE.iter().any(|page| if page.ends_with('/') { path.starts_with(page) } else { path == *page })
26+}
27+
1428 /// Whether the window may load `url`.
1529 pub fn allowed(app: &AppHandle, url: &Url) -> bool {
1630 match url.scheme() {
1832 "http" | "https" | "tauri" => {}
1933 _ => return false,
2034 }
21− if config::is_launcher(url) || config::is_server(app, url) {
35+ if config::is_launcher(url) || (config::is_server(app, url) && !outside(url.path())) {
2236 return true;
2337 }
2438 if url.host_str().is_some_and(|host| SIGN_IN_HOSTS.contains(&host)) {
2741 let _ = app.opener().open_url(url.as_str(), None::<&str>);
2842 false
2943 }
44+
45+#[cfg(test)]
46+mod tests {
47+ use super::outside;
48+
49+ #[test]
50+ fn reading_about_g1t_is_for_the_browser() {
51+ assert!(outside("/pricing"));
52+ assert!(outside("/pricing/"));
53+ assert!(outside("/policies/terms"));
54+ assert!(outside("/downloads/desktop/latest.json"));
55+ assert!(!outside("/"));
56+ assert!(!outside("/login"));
57+ assert!(!outside("/acme/-/chat"));
58+ assert!(!outside("/pricingx"));
59+ assert!(!outside("/acme/security"));
60+ }
61+}
+172−0
1+//! The app's own settings: how it starts, what closing the window does,
2+//! how it tells the person something arrived, the menu bar, zoom and
3+//! updates. They live in their own window (`launcher/settings.html`),
4+//! opened from the menu, the tray, or the site's account menu, and are
5+//! kept with the server in `desktop.json` (config.rs). Whether g1t opens
6+//! when someone signs in to their computer is kept by the system itself,
7+//! through the autostart plugin, and read back from it.
8+
9+use serde::{Deserialize, Serialize};
10+use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder};
11+use tauri_plugin_autostart::ManagerExt;
12+
13+use crate::{WINDOW, config};
14+
15+/// The settings window's label.
16+pub const SETTINGS: &str = "settings";
17+
18+/// The argument the system starts g1t with at sign-in, so it can tell a
19+/// start of its own from someone opening it.
20+pub const AT_LOGIN: &str = "--at-login";
21+
22+/// The zoom levels the View menu steps through.
23+pub const ZOOMS: &[f64] = &[0.67, 0.75, 0.8, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0];
24+
25+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
26+#[serde(default, rename_all = "camelCase")]
27+pub struct Settings {
28+ /// Opened at sign-in, g1t waits in the tray instead of opening its window.
29+ pub start_hidden: bool,
30+ /// Closing the window leaves g1t running in the tray, for notifications;
31+ /// otherwise closing it quits.
32+ pub close_to_tray: bool,
33+ /// Notifications from the system, for what the site says needs the person.
34+ pub notifications: bool,
35+ /// The taskbar icon flashes, or the dock icon bounces, with a notification.
36+ pub flash: bool,
37+ /// What needs the person, counted on the icon.
38+ pub badge: bool,
39+ /// The menu bar under the title (Windows and Linux; macOS has its own at the top).
40+ pub menu_bar: bool,
41+ /// The site's zoom, 1 for as it is.
42+ pub zoom: f64,
43+ /// Updates download by themselves; a person can still check from the menu.
44+ pub auto_update: bool,
45+}
46+
47+impl Default for Settings {
48+ fn default() -> Self {
49+ Self { start_hidden: true, close_to_tray: true, notifications: true, flash: true, badge: true, menu_bar: true, zoom: 1.0, auto_update: true }
50+ }
51+}
52+
53+impl Settings {
54+ /// A zoom the app offers, nearest to the one asked for.
55+ pub fn clamp_zoom(zoom: f64) -> f64 {
56+ ZOOMS.iter().copied().min_by(|a, b| (a - zoom).abs().total_cmp(&(b - zoom).abs())).unwrap_or(1.0)
57+ }
58+}
59+
60+/// What the settings window shows: the settings, and what the app and the
61+/// system know.
62+#[derive(Serialize)]
63+#[serde(rename_all = "camelCase")]
64+pub struct View {
65+ #[serde(flatten)]
66+ pub settings: Settings,
67+ pub open_at_login: bool,
68+ pub version: String,
69+ pub platform: String,
70+ pub server: String,
71+ pub update_ready: Option<String>,
72+}
73+
74+pub fn view(app: &AppHandle) -> View {
75+ View {
76+ settings: config::settings(app),
77+ open_at_login: app.autolaunch().is_enabled().unwrap_or(false),
78+ version: env!("CARGO_PKG_VERSION").into(),
79+ platform: std::env::consts::OS.into(),
80+ server: config::server(app).to_string(),
81+ update_ready: crate::updates::ready(app),
82+ }
83+}
84+
85+/// Keeps new settings and puts them to work at once.
86+pub fn apply(app: &AppHandle, mut settings: Settings) -> Result<(), String> {
87+ settings.zoom = Settings::clamp_zoom(settings.zoom);
88+ let before = config::settings(app);
89+ config::set_settings(app, settings.clone())?;
90+ if let Some(window) = app.get_webview_window(WINDOW) {
91+ if before.menu_bar != settings.menu_bar {
92+ let _ = if settings.menu_bar { window.show_menu() } else { window.hide_menu() };
93+ }
94+ if before.zoom != settings.zoom {
95+ let _ = window.set_zoom(settings.zoom);
96+ }
97+ }
98+ if before.badge != settings.badge {
99+ crate::badge::refresh(app);
100+ }
101+ Ok(())
102+}
103+
104+/// Turns opening at sign-in on or off, with the system.
105+pub fn open_at_login(app: &AppHandle, on: bool) -> Result<(), String> {
106+ let autolaunch = app.autolaunch();
107+ let result = if on { autolaunch.enable() } else { autolaunch.disable() };
108+ result.map_err(|error| error.to_string())
109+}
110+
111+/// Steps the zoom in (1), out (-1), or back to as it is (0).
112+pub fn zoom(app: &AppHandle, step: i32) {
113+ let mut settings = config::settings(app);
114+ let at = ZOOMS.iter().position(|zoom| *zoom == Settings::clamp_zoom(settings.zoom)).unwrap_or(4);
115+ settings.zoom = match step {
116+ 0 => 1.0,
117+ 1 => ZOOMS[(at + 1).min(ZOOMS.len() - 1)],
118+ _ => ZOOMS[at.saturating_sub(1)],
119+ };
120+ let _ = apply(app, settings);
121+}
122+
123+/// Whether this launch is the system's, at sign-in, and g1t should wait in the tray.
124+pub fn starts_hidden(app: &AppHandle) -> bool {
125+ std::env::args().any(|arg| arg == AT_LOGIN) && config::settings(app).start_hidden
126+}
127+
128+/// Opens the settings window, or brings it forward.
129+pub fn open(app: &AppHandle) {
130+ if let Some(window) = app.get_webview_window(SETTINGS) {
131+ let _ = window.unminimize();
132+ let _ = window.show();
133+ let _ = window.set_focus();
134+ return;
135+ }
136+ let built = WebviewWindowBuilder::new(app, SETTINGS, WebviewUrl::App("settings.html".into()))
137+ .title("g1t settings")
138+ .inner_size(560.0, 680.0)
139+ .min_inner_size(440.0, 420.0)
140+ .maximizable(false)
141+ .minimizable(false)
142+ .center()
143+ // The page is bundled with the app; its links go to the browser (settings.js).
144+ .on_navigation(config::is_launcher)
145+ .build();
146+ match built {
147+ Ok(window) => {
148+ let _ = window.set_focus();
149+ }
150+ Err(error) => eprintln!("g1t: the settings window could not open: {error}"),
151+ }
152+}
153+
154+#[cfg(test)]
155+mod tests {
156+ use super::Settings;
157+
158+ #[test]
159+ fn a_missing_setting_is_its_default() {
160+ let settings: Settings = serde_json::from_str(r#"{"closeToTray":false}"#).unwrap();
161+ assert!(!settings.close_to_tray);
162+ assert!(settings.notifications);
163+ assert_eq!(settings.zoom, 1.0);
164+ }
165+
166+ #[test]
167+ fn zoom_is_one_the_app_offers() {
168+ assert_eq!(Settings::clamp_zoom(1.07), 1.1);
169+ assert_eq!(Settings::clamp_zoom(9.0), 2.0);
170+ assert_eq!(Settings::clamp_zoom(0.1), 0.67);
171+ }
172+}
+16−16
11 //! The tray icon and its menu, and showing and hiding the window. Closing
2−//! the window hides it; g1t keeps running for notifications, and the
3−//! tray's Open, or the icon, brings it back. Quit is here too.
2+//! the window hides it, unless settings say closing quits; g1t keeps
3+//! running for notifications, and the tray's Open, or the icon, brings it
4+//! back. Quit is here too. The menu's rows are handled with the app
5+//! menu's (menu.rs).
46
57 use std::sync::OnceLock;
68
810 use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
911 use tauri::{AppHandle, Manager, Wry};
1012
11−use crate::{WINDOW, updates};
13+use crate::{WINDOW, config, updates};
1214
1315 pub const TRAY: &str = "g1t";
1416
1921 let open = MenuItem::with_id(app, "open", "Open g1t", true, None::<&str>)?;
2022 let check = MenuItem::with_id(app, "check", "Check for updates…", true, None::<&str>)?;
2123 let restart = MenuItem::with_id(app, "restart", "Restart to finish updating", false, None::<&str>)?;
24+ let settings = MenuItem::with_id(app, "settings", "Settings…", true, None::<&str>)?;
2225 let server = MenuItem::with_id(app, "server", "Use another g1t…", true, None::<&str>)?;
2326 let quit = MenuItem::with_id(app, "quit", "Quit g1t", true, None::<&str>)?;
2427 let menu = Menu::with_items(
2932 &check,
3033 &restart,
3134 &PredefinedMenuItem::separator(app)?,
35+ &settings,
3236 &server,
3337 &PredefinedMenuItem::separator(app)?,
3438 &quit,
3943 .menu(&menu)
4044 .show_menu_on_left_click(false)
4145 .tooltip("g1t")
42− .on_menu_event(|app, event| match event.id().as_ref() {
43− "open" => show(app),
44− "check" => {
45− let app = app.clone();
46− tauri::async_runtime::spawn(async move {
47− let _ = updates::check(&app, true).await;
48− });
49− }
50− "restart" => updates::restart(app),
51− "server" => launcher(app, "change"),
52− "quit" => updates::quit(app),
53− _ => {}
54− })
5546 .on_tray_icon_event(|tray, event| {
5647 if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. } = event {
5748 show(tray.app_handle());
8071 }
8172 }
8273
74+/// The window was closed: it waits in the tray, or g1t quits, as settings say.
75+pub fn close(app: &AppHandle) {
76+ if config::settings(app).close_to_tray {
77+ hide(app);
78+ } else {
79+ updates::quit(app);
80+ }
81+}
82+
8383 pub fn hide(app: &AppHandle) {
8484 if let Some(window) = app.get_webview_window(WINDOW) {
8585 let _ = window.hide();
+5−2
2525 const FIRST_CHECK: Duration = Duration::from_secs(15);
2626 const EVERY: Duration = Duration::from_secs(6 * 60 * 60);
2727
28−/// Checks soon after the start, then every six hours.
28+/// Checks soon after the start, then every six hours, unless settings
29+/// turned that off; a person can still check from the menu.
2930 pub fn schedule(app: AppHandle) {
3031 tauri::async_runtime::spawn(async move {
3132 tokio::time::sleep(FIRST_CHECK).await;
3233 loop {
33− let _ = check(&app, false).await;
34+ if config::settings(&app).auto_update {
35+ let _ = check(&app, false).await;
36+ }
3437 tokio::time::sleep(EVERY).await;
3538 }
3639 });
+67−13
1717 - **Updates that install themselves**, signed by g1t, in the background.
1818 - **Your own g1t**, if you [run one](/guides/self-hosting/): the app points
1919 at any server.
20+- **Settings of its own**: opening when you sign in to your computer,
21+ what closing the window does, notifications, the badge, zoom and updates.
2022
2123 Get it at [g1t.sh/download](https://g1t.sh/download), or from your account
2224 menu, **Get the desktop app**.
3234 Every file's SHA-256 is in `SHA256SUMS` beside it, at
3335 `https://g1t.sh/downloads/desktop/<version>/`.
3436
35−The app opens on g1t.sh and asks you to sign in once; it keeps you signed
36−in like a browser does. Signing in with GitHub goes to github.com and comes
37−back inside the window.
37+The app opens on g1t.sh's sign-in page, not the site's landing page: it
38+is for using g1t, not for reading about it. It keeps you signed in like a
39+browser does, and signing out brings you back to the sign-in page.
40+Signing in with GitHub goes to github.com and comes back inside the
41+window.
3842
3943 ## Notifications and the badge
4044
5155
5256 The icon carries a badge with what is waiting for you: the count on macOS
5357 and Linux, a lavender dot on Windows. The tray icon's hover text says the
54−number everywhere.
58+number everywhere. With a notification, the taskbar icon flashes on
59+Windows and the dock icon bounces on macOS, while the window is not in
60+front. [The app's settings](#settings) turn off each of these: the
61+notifications, the flash and the badge.
5562
5663 [Chat sounds](/guides/chat/#sounds-and-do-not-disturb) play as on the
5764 site, through the app's own audio.
6269 on macOS) so notifications still arrive. Click the tray icon, or its
6370 **Open g1t**, to bring the window back; on macOS the dock icon does too.
6471 **Quit g1t** in the tray menu ends it. The window remembers its size and
65−place.
72+place. To have closing the window quit instead, turn off **Keep g1t
73+running when the window closes** in [the app's settings](#settings).
74+
75+The tray menu also holds **Settings…**, **Check for updates…**, **Use
76+another g1t…** and, when an update is downloaded, **Restart to update**.
77+
78+### The menu
79+
80+The app has the menus a desktop app has: under the window's title on
81+Windows and Linux, at the top of the screen on macOS.
82+
83+| Menu | What is in it |
84+| --- | --- |
85+| **File** (**g1t** on macOS) | **Settings** (Ctrl+, or ⌘,), **Use another g1t…**, **Close window** (Ctrl+W), **Quit g1t** (Ctrl+Q or ⌘Q) |
86+| **Edit** | Undo, redo, cut, copy, paste and select all |
87+| **View** | **Reload** (Ctrl+R), **Actual size** (Ctrl+0), **Zoom in** (Ctrl+=), **Zoom out** (Ctrl+-), full screen |
88+| **Go** | **Back** (Alt+Left, or ⌘[), **Forward** (Alt+Right, or ⌘]), **Home**, **Notifications** |
89+| **Window** | Minimize and maximize |
90+| **Help** | **Desktop app guide** and **g1t.sh status**, in your browser; **Check for updates…**; **About g1t** |
91+
92+On Windows and Linux, **Show the menu bar** in the settings hides it; the
93+tray's **Settings…** and your account menu still open the settings to bring
94+it back.
95+
96+## Settings
97+
98+The app's own settings are in a window of their own: **Settings** in the
99+File menu (the g1t menu on macOS), **Settings…** in the tray, or **Desktop
100+app settings** in your account menu on g1t. Each change takes effect at
101+once and is kept on this computer. Your account's settings, which things
102+notify you among them, stay on g1t, in [your settings](/guides/notifications/#settings).
103+
104+| Setting | Default | What it does |
105+| --- | --- | --- |
106+| **Open g1t when you sign in** | Off | Starts g1t with your computer. The system keeps this: a login item on macOS, a startup entry on Windows, an autostart entry on Linux. |
107+| **Start in the tray** | On | Opened at sign-in, g1t waits in the tray without opening its window. Opening it yourself always shows the window. |
108+| **Keep g1t running when the window closes** | On | Closing the window leaves g1t in the tray for notifications. Off, closing the window quits. |
109+| **Show notifications** | On | Notifications from your system. Off, nothing pops up from the app, whatever your account's settings say. |
110+| **Flash the taskbar icon** (**Bounce the dock icon** on macOS) | On | Asks for your attention with a notification while the window is not in front. |
111+| **Show a badge on the icon** | On | The count, or the dot on Windows. Off, the icon stays clean; the tray's hover text still counts. |
112+| **Show the menu bar** | On | Windows and Linux only. |
113+| **Zoom** | 100% | How large g1t is drawn, from 67% to 200%. The View menu changes it too. |
114+| **Download updates by themselves** | On | Off, the app never checks on its own; **Check for updates** still does. |
66115
67−The tray menu also holds **Check for updates…**, **Use another g1t…** and,
68−when an update is downloaded, **Restart to update**.
116+The settings window also shows the app's version, with **Check for
117+updates**, and the server it opens, with **Change…** to [use another
118+g1t](#your-own-g1t).
69119
70120 ## Links
71121
76126 and other apps on your computer can use it.
77127
78128 A link to another site opens in the browser your system uses; the window
79−stays on g1t. Links on g1t that would open a new tab open in the window
80−instead, as it has no tabs. Signing in with GitHub is the one trip
81−outside g1t that stays in the window.
129+stays on g1t. That includes this documentation and the status page. So do
130+g1t's own pages for reading about g1t rather than using it: pricing, the
131+policies, security, support and the downloads. Links on g1t that would
132+open a new tab open in the window instead, as it has no tabs. Signing in
133+with GitHub is the one trip outside g1t that stays in the window.
82134
83135 ## Updates
84136
86138 shortly after it starts and every six hours, and downloads one in the
87139 background. It never installs under you: a notification and the tray's
88140 **Restart to update** say it is ready, and the restart, or quitting,
89−installs it. **Check for updates…** in the tray asks now and tells you the
90−answer.
141+installs it. **Check for updates…** in the tray or the Help menu asks now
142+and tells you the answer. To stop the app checking on its own, turn off
143+**Download updates by themselves** in [the settings](#settings).
91144
92145 Every release is signed with g1t's key, whose public half is built into
93146 the app; a download whose signature does not check out is thrown away.
97150 ## Your own g1t
98151
99152 If you [run g1t yourself](/guides/self-hosting/), point the app at it:
100−**Use another g1t…** in the tray menu, then your server's address, such as
153+**Use another g1t…** in the tray or the File menu, or **Change…** in [the
154+settings](#settings), then your server's address, such as
101155 `https://g1t.example.com`. The app keeps it and opens there from then on;
102156 **Use g1t.sh** on the same page goes back. Only that server's pages can
103157 talk to the app, whichever it is, and updates still come from g1t.sh.
+9−2
1−import { Activity, BarChart3, Building2, MessagesSquare, Bell, BookMarked, BookOpen, Bookmark, Blocks, Bot, Box, Check, ChevronDown, ChevronLeft, ChevronRight, CircleDot, GripVertical, CircleUserRound, Code2, Coins, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, KanbanSquare, Keyboard, KeyRound, Layers, LayoutDashboard, LayoutGrid, LifeBuoy, ListTree, Lock, LogOut, Mail, MonitorDown, Network, Package, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, Shapes, Shield, ShieldCheck, Scale, Smile, Sparkles, Store, House, Ticket, TrendingUp, UserRoundKey, Users, UsersRound, Webhook, X, ArrowLeftRight } from "lucide-react";
1+import { Activity, BarChart3, Building2, MessagesSquare, Bell, BookMarked, BookOpen, Bookmark, Blocks, Bot, Box, Check, ChevronDown, ChevronLeft, ChevronRight, CircleDot, GripVertical, CircleUserRound, Code2, Coins, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, KanbanSquare, Keyboard, KeyRound, Layers, LayoutDashboard, LayoutGrid, LifeBuoy, ListTree, Lock, LogOut, Mail, MonitorCog, MonitorDown, Network, Package, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, Shapes, Shield, ShieldCheck, Scale, Smile, Sparkles, Store, House, Ticket, TrendingUp, UserRoundKey, Users, UsersRound, Webhook, X, ArrowLeftRight } from "lucide-react";
22 import { Fragment, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
33 import { Link, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router";
44
77 import { InMain } from "./landmark";
88 import { CommandPalette, type PaletteCommand, PaletteKey, usePaletteShortcut } from "./command-palette";
99 import { NotificationsBell } from "./inbox";
10−import { useDesktopApp } from "../lib/notify-client";
10+import { desktopBridge, useDesktopApp } from "../lib/notify-client";
1111 import { SpendPill } from "./spend";
1212 import { PinButton } from "./pin-button";
1313 import { Hint } from "./ui/hint";
462462 Your settings
463463 </Link>
464464 </DropdownMenuItem>
465+ {/* The app's own: starting with the computer, closing to the tray, the badge (apps/desktop). */}
466+ {inApp && desktopBridge()?.openSettings && (
467+ <DropdownMenuItem className={MENU_ROW} onSelect={() => desktopBridge()?.openSettings?.()}>
468+ <MonitorCog />
469+ Desktop app settings
470+ </DropdownMenuItem>
471+ )}
465472 {invitesListed && (
466473 <DropdownMenuItem asChild className={MENU_ROW}>
467474 <Link to="/settings/invites">
+18−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { fromDesktopApp, launchedFromDesktopApp } from "./desktop.ts";
5+
6+const request = (cookie?: string) => new Request("https://g1t.sh/", { headers: cookie ? { cookie } : {} });
7+
8+test("the desktop app is known by its cookie", () => {
9+ assert.equal(fromDesktopApp(request("g1t_ws=acme; g1t_app=desktop")), true);
10+ assert.equal(fromDesktopApp(request("g1t_app=web")), false);
11+ assert.equal(fromDesktopApp(request()), false);
12+});
13+
14+test("the launcher opens the site with ?app=desktop", () => {
15+ assert.equal(launchedFromDesktopApp(new URL("https://g1t.sh/?app=desktop")), true);
16+ assert.equal(launchedFromDesktopApp(new URL("https://g1t.sh/?app=other")), false);
17+ assert.equal(launchedFromDesktopApp(new URL("https://g1t.sh/")), false);
18+});
+37−0
1+/**
2+ * The desktop app (apps/desktop) is for using g1t, not for browsing the
3+ * site: someone signed out there lands on the sign-in page, never the
4+ * landing page. The site knows it is inside the app by a cookie. The
5+ * app's launcher opens `/?app=desktop`, and the home page answers that by
6+ * setting the cookie; the bridge's presence on a page sets it too, for a
7+ * build of the app older than the query (routes/home.tsx). The app keeps
8+ * its own cookies, apart from any browser's, so the cookie never follows
9+ * someone to the site in their browser.
10+ */
11+
12+import { readCookie } from "./github.ts";
13+
14+export const DESKTOP_COOKIE = "g1t_app";
15+export const DESKTOP_VALUE = "desktop";
16+/** The query the launcher opens the server with: `/?app=desktop`. */
17+export const DESKTOP_QUERY = "app";
18+
19+/** A year: the cookie outlives sign-outs, and the app renews it at every launch. */
20+export const DESKTOP_COOKIE_SECONDS = 365 * 24 * 60 * 60;
21+
22+/** Whether a request comes from the desktop app's window. */
23+export function fromDesktopApp(request: Request): boolean {
24+ return readCookie(request.headers.get("cookie"), DESKTOP_COOKIE) === DESKTOP_VALUE;
25+}
26+
27+/** Whether a request is the launcher's first, `/?app=desktop`. */
28+export function launchedFromDesktopApp(url: URL): boolean {
29+ return url.searchParams.get(DESKTOP_QUERY) === DESKTOP_VALUE;
30+}
31+
32+/** Sets the cookie from the page, for an app that opened `/` without the query. */
33+export function rememberDesktopApp(): void {
34+ if (typeof document === "undefined") return;
35+ const secure = location.protocol === "https:" ? "; Secure" : "";
36+ document.cookie = `${DESKTOP_COOKIE}=${DESKTOP_VALUE}; Path=/; SameSite=Lax; Max-Age=${DESKTOP_COOKIE_SECONDS}${secure}`;
37+}
+9−0
6868 * onNavigate(listener) { return () => {} },
6969 * // Looks for an update now: "current", "ready:<version>", or rejects with why.
7070 * checkForUpdates() { return Promise.resolve("current") },
71+ * // Opens the app's own settings (starting with the computer, closing to
72+ * // the tray, notifications, the badge, zoom, updates) in their window.
73+ * openSettings() {},
7174 * };
75+ *
76+ * The app also opens the site's pages for reading about g1t (pricing,
77+ * policies, security, support, downloads) in the system's browser, and
78+ * opens `/?app=desktop` at launch, so someone signed out there is sent to
79+ * sign in rather than shown the landing page (lib/desktop.ts).
7280 * ```
7381 */
7482 export type DesktopBridge = {
7987 openUrl(url: string): void;
8088 onNavigate?(listener: (href: string) => void): () => void;
8189 checkForUpdates?(): Promise<string>;
90+ openSettings?(): void;
8291 };
8392
8493 declare global {
+29−5
11 import { env } from "cloudflare:workers";
2−import { data, redirect } from "react-router";
2+import { useEffect } from "react";
3+import { data, redirect, useNavigate } from "react-router";
34
45 import type { Route } from "./+types/home";
6+import { DESKTOP_COOKIE, DESKTOP_COOKIE_SECONDS, DESKTOP_QUERY, DESKTOP_VALUE, fromDesktopApp, launchedFromDesktopApp, rememberDesktopApp } from "../lib/desktop";
7+import { cookie } from "../lib/github";
8+import { desktopBridge } from "../lib/notify-client";
59 import { page } from "../lib/meta";
610 import { application, organization } from "../lib/structured-data";
711 import { WORKSPACE_COOKIE, chosenWorkspace } from "../lib/workspace-choice";
2933 * their workspace (routes/workspace/home.tsx); Mission control's code
3034 * panels are Code's Overview. `/?agent=new` and the rest of the query go
3135 * with them.
36+ *
37+ * The desktop app is for using g1t, so a visitor there goes to the sign-in
38+ * page instead (lib/desktop.ts). Its launcher opens `/?app=desktop`, which
39+ * sets the cookie that says so.
3240 */
3341 export async function loader({ context, request }: Route.LoaderArgs) {
3442 const viewer = getViewer(context);
35− if (!viewer) return data({ signedIn: false as const });
3643 const url = new URL(request.url);
44+ const launched = launchedFromDesktopApp(url);
45+ const headers = new Headers();
46+ if (launched) {
47+ headers.set("set-cookie", cookie(DESKTOP_COOKIE, DESKTOP_VALUE, DESKTOP_COOKIE_SECONDS));
48+ url.searchParams.delete(DESKTOP_QUERY);
49+ }
50+ if (!viewer) {
51+ if (launched || fromDesktopApp(request)) throw redirect("/login", { headers });
52+ return data({ signedIn: false as const });
53+ }
3754 const slug = chosenWorkspace(viewer.workspaces ?? [], readCookie(request.headers.get("cookie"), WORKSPACE_COOKIE))?.slug ?? null;
3855 // A workspace-less account is sent to make one by the session (lib/session.server.ts).
39− if (!slug) throw redirect("/workspaces/new");
40− throw redirect(homePath(slug, url.search));
56+ if (!slug) throw redirect("/workspaces/new", { headers });
57+ throw redirect(homePath(slug, url.search), { headers });
4158 }
4259
4360 /** What the composer said back, when the issue opened but the agent did not start, or nothing opened. */
6481 if (!refused) throw redirect(issuePath(repo, issue.number));
6582 return data<DelegateResult>({ error: null, notStarted: refused });
6683 }
67−
6884
6985 export default function Home() {
86+ const navigate = useNavigate();
87+ // A build of the desktop app older than `?app=desktop` opens `/` as it
88+ // is: the page sets the cookie itself and moves on to signing in.
89+ useEffect(() => {
90+ if (!desktopBridge()) return;
91+ rememberDesktopApp();
92+ navigate("/login", { replace: true });
93+ }, [navigate]);
7094 return <Landing />;
7195 }