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
| 1 | 1 | // 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 | |
| 3 | 4 | // asks is a command in src/bridge.rs, answered only to this page. | |
| 4 | 5 | (function () { | |
| 5 | 6 | var sections = { connecting: byId("connecting"), offline: byId("offline"), choose: byId("choose") }; | |
| ⋯ | |||
| 29 | 30 | } | |
| 30 | 31 | } | |
| 31 | 32 | ||
| 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 | + | ||
| 32 | 43 | function connect(server) { | |
| 33 | 44 | show("connecting"); | |
| 34 | 45 | byId("connecting-text").textContent = "Connecting to " + hostOf(server) + "…"; | |
| 35 | 46 | invoke("reachable", { url: server }) | |
| 36 | 47 | .then(function (ok) { | |
| 37 | − | if (ok) location.replace(server); | |
| 48 | + | if (ok) location.replace(appUrl(server)); | |
| 38 | 49 | else offline(server); | |
| 39 | 50 | }) | |
| 40 | 51 | .catch(function () { | |
| 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 | + | } |
| 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"> </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> |
| 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 | + | })(); |
| 217 | 217 | checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" | |
| 218 | 218 | ||
| 219 | 219 | [[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]] | |
| 220 | 234 | name = "autocfg" | |
| 221 | 235 | version = "1.5.1" | |
| 222 | 236 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ | |||
| 819 | 833 | ||
| 820 | 834 | [[package]] | |
| 821 | 835 | 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" | |
| 822 | 845 | version = "7.0.0" | |
| 823 | 846 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 824 | 847 | checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" | |
| ⋯ | |||
| 1231 | 1254 | "serde_json", | |
| 1232 | 1255 | "tauri", | |
| 1233 | 1256 | "tauri-build", | |
| 1257 | + | "tauri-plugin-autostart", | |
| 1234 | 1258 | "tauri-plugin-deep-link", | |
| 1235 | 1259 | "tauri-plugin-notification", | |
| 1236 | 1260 | "tauri-plugin-opener", | |
| ⋯ | |||
| 2345 | 2369 | checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" | |
| 2346 | 2370 | ||
| 2347 | 2371 | [[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]] | |
| 2348 | 2384 | name = "notify-rust" | |
| 2349 | 2385 | version = "4.18.2" | |
| 2350 | 2386 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ | |||
| 2560 | 2596 | ] | |
| 2561 | 2597 | ||
| 2562 | 2598 | [[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]] | |
| 2563 | 2623 | name = "objc2-ui-kit" | |
| 2564 | 2624 | version = "0.3.2" | |
| 2565 | 2625 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ | |||
| 2654 | 2714 | ] | |
| 2655 | 2715 | ||
| 2656 | 2716 | [[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]] | |
| 2657 | 2732 | name = "osakit" | |
| 2658 | 2733 | version = "0.3.1" | |
| 2659 | 2734 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ | |||
| 3765 | 3840 | checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" | |
| 3766 | 3841 | ||
| 3767 | 3842 | [[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]] | |
| 3768 | 3855 | name = "socket2" | |
| 3769 | 3856 | version = "0.6.5" | |
| 3770 | 3857 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ | |||
| 4038 | 4125 | "anyhow", | |
| 4039 | 4126 | "bytes", | |
| 4040 | 4127 | "cookie", | |
| 4041 | − | "dirs", | |
| 4128 | + | "dirs 7.0.0", | |
| 4042 | 4129 | "dunce", | |
| 4043 | 4130 | "embed_plist", | |
| 4044 | 4131 | "getrandom 0.3.4", | |
| ⋯ | |||
| 4089 | 4176 | dependencies = [ | |
| 4090 | 4177 | "anyhow", | |
| 4091 | 4178 | "cargo_toml", | |
| 4092 | − | "dirs", | |
| 4179 | + | "dirs 7.0.0", | |
| 4093 | 4180 | "glob", | |
| 4094 | 4181 | "heck 0.5.0", | |
| 4095 | 4182 | "json-patch", | |
| ⋯ | |||
| 4160 | 4247 | ] | |
| 4161 | 4248 | ||
| 4162 | 4249 | [[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]] | |
| 4163 | 4264 | name = "tauri-plugin-deep-link" | |
| 4164 | 4265 | version = "2.6.1" | |
| 4165 | 4266 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ | |||
| 4246 | 4347 | checksum = "855a336eb389d2c1502244fd48fa2d8cb487a430f691bbaed025195f99a04489" | |
| 4247 | 4348 | dependencies = [ | |
| 4248 | 4349 | "base64 0.22.1", | |
| 4249 | − | "dirs", | |
| 4350 | + | "dirs 7.0.0", | |
| 4250 | 4351 | "flate2", | |
| 4251 | 4352 | "futures-util", | |
| 4252 | 4353 | "http", | |
| ⋯ | |||
| 4732 | 4833 | checksum = "b2b9c52859a94554803ccd4a24b98f74148ebc73b90676d783f3490b1bff9d72" | |
| 4733 | 4834 | dependencies = [ | |
| 4734 | 4835 | "crossbeam-channel", | |
| 4735 | − | "dirs", | |
| 4836 | + | "dirs 7.0.0", | |
| 4736 | 4837 | "libappindicator", | |
| 4737 | 4838 | "muda", | |
| 4738 | 4839 | "objc2", | |
| ⋯ | |||
| 5475 | 5576 | "block2", | |
| 5476 | 5577 | "cookie", | |
| 5477 | 5578 | "crossbeam-channel", | |
| 5478 | − | "dirs", | |
| 5579 | + | "dirs 7.0.0", | |
| 5479 | 5580 | "dom_query", | |
| 5480 | 5581 | "dpi", | |
| 5481 | 5582 | "dunce", | |
| 29 | 29 | tauri-plugin-single-instance = { version = "2", features = ["deep-link"] } | |
| 30 | 30 | # The window's size and place, kept between launches. | |
| 31 | 31 | tauri-plugin-window-state = "2" | |
| 32 | + | # Opening g1t when someone signs in to their computer (settings.rs). | |
| 33 | + | tauri-plugin-autostart = "2" | |
| 32 | 34 | serde = { version = "1", features = ["derive"] } | |
| 33 | 35 | serde_json = "1" | |
| 34 | 36 | url = "2" |
| 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 | + | } |
| 1 | 1 | //! The badge: what needs the person, on the app's icon and in the tray's | |
| 2 | 2 | //! 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. | |
| 4 | 6 | ||
| 5 | 7 | use tauri::{AppHandle, Manager}; | |
| 6 | 8 | ||
| ⋯ | |||
| 8 | 10 | ||
| 9 | 11 | pub fn set(app: &AppHandle, count: i64) { | |
| 10 | 12 | *app.state::<config::State>().waiting.lock().unwrap() = count; | |
| 13 | + | let shown = if config::settings(app).badge { count } else { 0 }; | |
| 11 | 14 | if let Some(window) = app.get_webview_window(WINDOW) { | |
| 12 | 15 | #[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 }); | |
| 14 | 17 | #[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 }); | |
| 16 | 19 | } | |
| 17 | 20 | if let Some(tray) = app.tray_by_id(tray::TRAY) { | |
| 18 | 21 | let text = match count { | |
| ⋯ | |||
| 24 | 27 | } | |
| 25 | 28 | } | |
| 26 | 29 | ||
| 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 | + | ||
| 27 | 36 | /// A lavender dot with a dark edge, 32 pixels square, drawn here so | |
| 28 | 37 | /// nothing needs loading. | |
| 29 | 38 | #[cfg(target_os = "windows")] | |
| 9 | 9 | if (window.g1tDesktop) return; | |
| 10 | 10 | var VERSION = "__VERSION__"; | |
| 11 | 11 | 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__"; | |
| 12 | 15 | ||
| 13 | 16 | function invoke(command, args) { | |
| 14 | 17 | var core = window.__TAURI__ && window.__TAURI__.core; | |
| ⋯ | |||
| 74 | 77 | checkForUpdates: function () { | |
| 75 | 78 | return invoke("check_for_updates"); | |
| 76 | 79 | }, | |
| 80 | + | // The app's own settings, in a window of their own. | |
| 81 | + | openSettings: function () { | |
| 82 | + | quietly(invoke("open_settings")); | |
| 83 | + | }, | |
| 77 | 84 | }; | |
| 78 | 85 | Object.defineProperty(window, "g1tDesktop", { value: Object.freeze(bridge), writable: false, configurable: false }); | |
| 79 | 86 | ||
| 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 | |
| 82 | 90 | // 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 | + | } | |
| 83 | 97 | function destination(href) { | |
| 84 | 98 | try { | |
| 85 | 99 | var url = new URL(href, location.href); | |
| ⋯ | |||
| 96 | 110 | if (!anchor || anchor.hasAttribute("download")) return; | |
| 97 | 111 | var url = destination(anchor.getAttribute("href")); | |
| 98 | 112 | if (!url) return; | |
| 99 | − | if (url.origin !== location.origin) { | |
| 113 | + | if (url.origin !== location.origin || outside(url)) { | |
| 100 | 114 | event.preventDefault(); | |
| 101 | 115 | event.stopImmediatePropagation(); | |
| 102 | 116 | bridge.openUrl(url.href); | |
| ⋯ | |||
| 112 | 126 | window.open = function (href, target, features) { | |
| 113 | 127 | var url = destination(href); | |
| 114 | 128 | if (url) { | |
| 115 | − | if (url.origin === location.origin) location.assign(url.href); | |
| 129 | + | if (url.origin === location.origin && !outside(url)) location.assign(url.href); | |
| 116 | 130 | else bridge.openUrl(url.href); | |
| 117 | 131 | return null; | |
| 118 | 132 | } | |
| 7 | 7 | //! loads. | |
| 8 | 8 | ||
| 9 | 9 | use serde::Serialize; | |
| 10 | − | use tauri::{AppHandle, Webview}; | |
| 10 | + | use tauri::{AppHandle, Manager, UserAttentionType, Webview}; | |
| 11 | 11 | use tauri_plugin_notification::NotificationExt; | |
| 12 | 12 | use tauri_plugin_opener::OpenerExt; | |
| 13 | 13 | use url::Url; | |
| 14 | 14 | ||
| 15 | − | use crate::{badge, config, updates}; | |
| 15 | + | use crate::settings::{self, Settings}; | |
| 16 | + | use crate::{WINDOW, badge, config, updates}; | |
| 16 | 17 | ||
| 17 | 18 | const SCRIPT: &str = include_str!("bridge.js"); | |
| 18 | 19 | ||
| 19 | 20 | /// The bridge, run at the start of every page the window loads. | |
| 20 | 21 | 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")) | |
| 22 | 26 | } | |
| 23 | 27 | ||
| 24 | 28 | /// What the site and the launcher learn about the app. | |
| ⋯ | |||
| 47 | 51 | if config::is_launcher(&url) { Ok(()) } else { Err("only the launcher may ask that".into()) } | |
| 48 | 52 | } | |
| 49 | 53 | ||
| 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 | + | ||
| 50 | 60 | fn from_either(app: &AppHandle, webview: &Webview) -> Result<(), String> { | |
| 51 | 61 | from_site(app, webview).or_else(|_| from_launcher(webview)) | |
| 52 | 62 | } | |
| ⋯ | |||
| 67 | 77 | #[tauri::command] | |
| 68 | 78 | pub fn notify(app: AppHandle, webview: Webview, title: String, body: String) -> Result<(), String> { | |
| 69 | 79 | 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 | + | } | |
| 70 | 87 | app.notification().builder().title(title).body(body).show().map_err(|error| error.to_string()) | |
| 71 | 88 | } | |
| 72 | 89 | ||
| ⋯ | |||
| 119 | 136 | from_either(&app, &webview)?; | |
| 120 | 137 | updates::check(&app, true).await | |
| 121 | 138 | } | |
| 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 | + | } | |
| 1 | 1 | //! What the app keeps between launches, and what it knows while running. | |
| 2 | 2 | //! | |
| 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`). | |
| 8 | 9 | ||
| 9 | 10 | use std::fs; | |
| 10 | 11 | use std::path::PathBuf; | |
| ⋯ | |||
| 33 | 34 | pub update: Mutex<Option<updates::Ready>>, | |
| 34 | 35 | /// What the badge shows. | |
| 35 | 36 | pub waiting: Mutex<i64>, | |
| 37 | + | /// The app's own settings, as the settings window last left them. | |
| 38 | + | pub settings: Mutex<Settings>, | |
| 36 | 39 | } | |
| 37 | 40 | ||
| 41 | + | use crate::settings::Settings; | |
| 38 | 42 | use crate::updates; | |
| 39 | 43 | ||
| 40 | 44 | #[derive(Default, Serialize, Deserialize)] | |
| 41 | 45 | struct File { | |
| 42 | 46 | #[serde(default)] | |
| 43 | 47 | server: Option<String>, | |
| 48 | + | #[serde(flatten)] | |
| 49 | + | settings: Settings, | |
| 44 | 50 | } | |
| 45 | 51 | ||
| 46 | 52 | fn path(app: &AppHandle) -> Option<PathBuf> { | |
| ⋯ | |||
| 61 | 67 | Some(url) | |
| 62 | 68 | } | |
| 63 | 69 | ||
| 64 | − | /// Reads the saved server, or takes g1t.sh. | |
| 70 | + | /// Reads the saved server, or takes g1t.sh, and the saved settings. | |
| 65 | 71 | pub fn load(app: &AppHandle) { | |
| 66 | 72 | let file: File = path(app) | |
| 67 | 73 | .and_then(|path| fs::read(path).ok()) | |
| 68 | 74 | .and_then(|bytes| serde_json::from_slice(&bytes).ok()) | |
| 69 | 75 | .unwrap_or_default(); | |
| 70 | 76 | 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; | |
| 72 | 80 | } | |
| 73 | 81 | ||
| 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 | + | ||
| 74 | 106 | pub fn server(app: &AppHandle) -> Url { | |
| 75 | 107 | app.state::<State>() | |
| 76 | 108 | .server | |
| ⋯ | |||
| 83 | 115 | /// Saves a new server and lets its pages talk to the app. | |
| 84 | 116 | pub fn set_server(app: &AppHandle, text: &str) -> Result<Url, String> { | |
| 85 | 117 | 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())?; | |
| 92 | 118 | *app.state::<State>().server.lock().unwrap() = Some(server.clone()); | |
| 119 | + | save(app)?; | |
| 93 | 120 | allow_site(app).map_err(|error| error.to_string())?; | |
| 94 | 121 | Ok(server) | |
| 95 | 122 | } | |
| 2 | 2 | //! own. The window loads the live site, so every change to g1t reaches it | |
| 3 | 3 | //! the moment it is deployed and nothing here needs a new build for it; | |
| 4 | 4 | //! 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/). | |
| 7 | 8 | //! | |
| 8 | 9 | //! The site finds the app at `window.g1tDesktop` (bridge.js), the one | |
| 9 | 10 | //! contract between the two; what the page asks of it is answered by the | |
| ⋯ | |||
| 15 | 16 | mod bridge; | |
| 16 | 17 | mod config; | |
| 17 | 18 | mod links; | |
| 19 | + | mod menu; | |
| 18 | 20 | mod navigation; | |
| 21 | + | mod settings; | |
| 19 | 22 | mod tray; | |
| 20 | 23 | mod updates; | |
| 21 | 24 | ||
| ⋯ | |||
| 35 | 38 | .plugin(tauri_plugin_notification::init()) | |
| 36 | 39 | .plugin(tauri_plugin_opener::init()) | |
| 37 | 40 | .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]))) | |
| 39 | 46 | .manage(config::State::default()) | |
| 40 | 47 | .invoke_handler(tauri::generate_handler![ | |
| 41 | 48 | bridge::desktop_info, | |
| ⋯ | |||
| 45 | 52 | bridge::reachable, | |
| 46 | 53 | bridge::set_server, | |
| 47 | 54 | 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, | |
| 48 | 60 | ]) | |
| 61 | + | .on_menu_event(|app, event| menu::handle(app, event.id().as_ref())) | |
| 49 | 62 | .setup(|app| { | |
| 50 | 63 | let handle = app.handle().clone(); | |
| 51 | 64 | config::load(&handle); | |
| 52 | 65 | 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())) | |
| 54 | 69 | .title("g1t") | |
| 55 | 70 | .inner_size(1280.0, 840.0) | |
| 56 | 71 | .min_inner_size(720.0, 480.0) | |
| 72 | + | .visible(false) | |
| 57 | 73 | .initialization_script(bridge::script()) | |
| 58 | 74 | .on_navigation({ | |
| 59 | 75 | let handle = handle.clone(); | |
| ⋯ | |||
| 62 | 78 | .on_page_load({ | |
| 63 | 79 | let handle = handle.clone(); | |
| 64 | 80 | 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. | |
| 69 | 101 | window.on_window_event({ | |
| 70 | 102 | let handle = handle.clone(); | |
| 71 | 103 | move |event| { | |
| 72 | 104 | if let WindowEvent::CloseRequested { api, .. } = event { | |
| 73 | 105 | api.prevent_close(); | |
| 74 | − | tray::hide(&handle); | |
| 106 | + | tray::close(&handle); | |
| 75 | 107 | } | |
| 76 | 108 | } | |
| 77 | 109 | }); | |
| 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 | + | } |
| 1 | 1 | //! Where the window may go: the launcher, the server's pages, and the | |
| 2 | 2 | //! 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. | |
| 4 | 7 | ||
| 5 | 8 | use tauri::AppHandle; | |
| 6 | 9 | use tauri_plugin_opener::OpenerExt; | |
| ⋯ | |||
| 11 | 14 | /// Signing in with GitHub goes there and comes back. | |
| 12 | 15 | const SIGN_IN_HOSTS: &[&str] = &["github.com", "www.github.com"]; | |
| 13 | 16 | ||
| 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 | + | ||
| 14 | 28 | /// Whether the window may load `url`. | |
| 15 | 29 | pub fn allowed(app: &AppHandle, url: &Url) -> bool { | |
| 16 | 30 | match url.scheme() { | |
| ⋯ | |||
| 18 | 32 | "http" | "https" | "tauri" => {} | |
| 19 | 33 | _ => return false, | |
| 20 | 34 | } | |
| 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())) { | |
| 22 | 36 | return true; | |
| 23 | 37 | } | |
| 24 | 38 | if url.host_str().is_some_and(|host| SIGN_IN_HOSTS.contains(&host)) { | |
| ⋯ | |||
| 27 | 41 | let _ = app.opener().open_url(url.as_str(), None::<&str>); | |
| 28 | 42 | false | |
| 29 | 43 | } | |
| 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 | + | } | |
| 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 | + | } |
| 1 | 1 | //! 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). | |
| 4 | 6 | ||
| 5 | 7 | use std::sync::OnceLock; | |
| 6 | 8 | ||
| ⋯ | |||
| 8 | 10 | use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; | |
| 9 | 11 | use tauri::{AppHandle, Manager, Wry}; | |
| 10 | 12 | ||
| 11 | − | use crate::{WINDOW, updates}; | |
| 13 | + | use crate::{WINDOW, config, updates}; | |
| 12 | 14 | ||
| 13 | 15 | pub const TRAY: &str = "g1t"; | |
| 14 | 16 | ||
| ⋯ | |||
| 19 | 21 | let open = MenuItem::with_id(app, "open", "Open g1t", true, None::<&str>)?; | |
| 20 | 22 | let check = MenuItem::with_id(app, "check", "Check for updates…", true, None::<&str>)?; | |
| 21 | 23 | 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>)?; | |
| 22 | 25 | let server = MenuItem::with_id(app, "server", "Use another g1t…", true, None::<&str>)?; | |
| 23 | 26 | let quit = MenuItem::with_id(app, "quit", "Quit g1t", true, None::<&str>)?; | |
| 24 | 27 | let menu = Menu::with_items( | |
| ⋯ | |||
| 29 | 32 | &check, | |
| 30 | 33 | &restart, | |
| 31 | 34 | &PredefinedMenuItem::separator(app)?, | |
| 35 | + | &settings, | |
| 32 | 36 | &server, | |
| 33 | 37 | &PredefinedMenuItem::separator(app)?, | |
| 34 | 38 | &quit, | |
| ⋯ | |||
| 39 | 43 | .menu(&menu) | |
| 40 | 44 | .show_menu_on_left_click(false) | |
| 41 | 45 | .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 | − | }) | |
| 55 | 46 | .on_tray_icon_event(|tray, event| { | |
| 56 | 47 | if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. } = event { | |
| 57 | 48 | show(tray.app_handle()); | |
| ⋯ | |||
| 80 | 71 | } | |
| 81 | 72 | } | |
| 82 | 73 | ||
| 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 | + | ||
| 83 | 83 | pub fn hide(app: &AppHandle) { | |
| 84 | 84 | if let Some(window) = app.get_webview_window(WINDOW) { | |
| 85 | 85 | let _ = window.hide(); | |
| 25 | 25 | const FIRST_CHECK: Duration = Duration::from_secs(15); | |
| 26 | 26 | const EVERY: Duration = Duration::from_secs(6 * 60 * 60); | |
| 27 | 27 | ||
| 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. | |
| 29 | 30 | pub fn schedule(app: AppHandle) { | |
| 30 | 31 | tauri::async_runtime::spawn(async move { | |
| 31 | 32 | tokio::time::sleep(FIRST_CHECK).await; | |
| 32 | 33 | loop { | |
| 33 | − | let _ = check(&app, false).await; | |
| 34 | + | if config::settings(&app).auto_update { | |
| 35 | + | let _ = check(&app, false).await; | |
| 36 | + | } | |
| 34 | 37 | tokio::time::sleep(EVERY).await; | |
| 35 | 38 | } | |
| 36 | 39 | }); |
| 17 | 17 | - **Updates that install themselves**, signed by g1t, in the background. | |
| 18 | 18 | - **Your own g1t**, if you [run one](/guides/self-hosting/): the app points | |
| 19 | 19 | 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. | |
| 20 | 22 | ||
| 21 | 23 | Get it at [g1t.sh/download](https://g1t.sh/download), or from your account | |
| 22 | 24 | menu, **Get the desktop app**. | |
| ⋯ | |||
| 32 | 34 | Every file's SHA-256 is in `SHA256SUMS` beside it, at | |
| 33 | 35 | `https://g1t.sh/downloads/desktop/<version>/`. | |
| 34 | 36 | ||
| 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. | |
| 38 | 42 | ||
| 39 | 43 | ## Notifications and the badge | |
| 40 | 44 | ||
| ⋯ | |||
| 51 | 55 | ||
| 52 | 56 | The icon carries a badge with what is waiting for you: the count on macOS | |
| 53 | 57 | 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. | |
| 55 | 62 | ||
| 56 | 63 | [Chat sounds](/guides/chat/#sounds-and-do-not-disturb) play as on the | |
| 57 | 64 | site, through the app's own audio. | |
| ⋯ | |||
| 62 | 69 | on macOS) so notifications still arrive. Click the tray icon, or its | |
| 63 | 70 | **Open g1t**, to bring the window back; on macOS the dock icon does too. | |
| 64 | 71 | **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. | | |
| 66 | 115 | ||
| 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). | |
| 69 | 119 | ||
| 70 | 120 | ## Links | |
| 71 | 121 | ||
| ⋯ | |||
| 76 | 126 | and other apps on your computer can use it. | |
| 77 | 127 | ||
| 78 | 128 | 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. | |
| 82 | 134 | ||
| 83 | 135 | ## Updates | |
| 84 | 136 | ||
| ⋯ | |||
| 86 | 138 | shortly after it starts and every six hours, and downloads one in the | |
| 87 | 139 | background. It never installs under you: a notification and the tray's | |
| 88 | 140 | **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). | |
| 91 | 144 | ||
| 92 | 145 | Every release is signed with g1t's key, whose public half is built into | |
| 93 | 146 | the app; a download whose signature does not check out is thrown away. | |
| ⋯ | |||
| 97 | 150 | ## Your own g1t | |
| 98 | 151 | ||
| 99 | 152 | 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 | |
| 101 | 155 | `https://g1t.example.com`. The app keeps it and opens there from then on; | |
| 102 | 156 | **Use g1t.sh** on the same page goes back. Only that server's pages can | |
| 103 | 157 | talk to the app, whichever it is, and updates still come from g1t.sh. | |
| 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"; | |
| 2 | 2 | import { Fragment, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 3 | 3 | import { Link, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router"; | |
| 4 | 4 | ||
| ⋯ | |||
| 7 | 7 | import { InMain } from "./landmark"; | |
| 8 | 8 | import { CommandPalette, type PaletteCommand, PaletteKey, usePaletteShortcut } from "./command-palette"; | |
| 9 | 9 | import { NotificationsBell } from "./inbox"; | |
| 10 | − | import { useDesktopApp } from "../lib/notify-client"; | |
| 10 | + | import { desktopBridge, useDesktopApp } from "../lib/notify-client"; | |
| 11 | 11 | import { SpendPill } from "./spend"; | |
| 12 | 12 | import { PinButton } from "./pin-button"; | |
| 13 | 13 | import { Hint } from "./ui/hint"; | |
| ⋯ | |||
| 462 | 462 | Your settings | |
| 463 | 463 | </Link> | |
| 464 | 464 | </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 | + | )} | |
| 465 | 472 | {invitesListed && ( | |
| 466 | 473 | <DropdownMenuItem asChild className={MENU_ROW}> | |
| 467 | 474 | <Link to="/settings/invites"> | |
| 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 | + | }); |
| 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 | + | } |
| 68 | 68 | * onNavigate(listener) { return () => {} }, | |
| 69 | 69 | * // Looks for an update now: "current", "ready:<version>", or rejects with why. | |
| 70 | 70 | * 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() {}, | |
| 71 | 74 | * }; | |
| 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). | |
| 72 | 80 | * ``` | |
| 73 | 81 | */ | |
| 74 | 82 | export type DesktopBridge = { | |
| ⋯ | |||
| 79 | 87 | openUrl(url: string): void; | |
| 80 | 88 | onNavigate?(listener: (href: string) => void): () => void; | |
| 81 | 89 | checkForUpdates?(): Promise<string>; | |
| 90 | + | openSettings?(): void; | |
| 82 | 91 | }; | |
| 83 | 92 | ||
| 84 | 93 | declare global { | |
| 1 | 1 | 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"; | |
| 3 | 4 | ||
| 4 | 5 | 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"; | |
| 5 | 9 | import { page } from "../lib/meta"; | |
| 6 | 10 | import { application, organization } from "../lib/structured-data"; | |
| 7 | 11 | import { WORKSPACE_COOKIE, chosenWorkspace } from "../lib/workspace-choice"; | |
| ⋯ | |||
| 29 | 33 | * their workspace (routes/workspace/home.tsx); Mission control's code | |
| 30 | 34 | * panels are Code's Overview. `/?agent=new` and the rest of the query go | |
| 31 | 35 | * 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. | |
| 32 | 40 | */ | |
| 33 | 41 | export async function loader({ context, request }: Route.LoaderArgs) { | |
| 34 | 42 | const viewer = getViewer(context); | |
| 35 | − | if (!viewer) return data({ signedIn: false as const }); | |
| 36 | 43 | 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 | + | } | |
| 37 | 54 | const slug = chosenWorkspace(viewer.workspaces ?? [], readCookie(request.headers.get("cookie"), WORKSPACE_COOKIE))?.slug ?? null; | |
| 38 | 55 | // 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 }); | |
| 41 | 58 | } | |
| 42 | 59 | ||
| 43 | 60 | /** What the composer said back, when the issue opened but the agent did not start, or nothing opened. */ | |
| ⋯ | |||
| 64 | 81 | if (!refused) throw redirect(issuePath(repo, issue.number)); | |
| 65 | 82 | return data<DelegateResult>({ error: null, notStarted: refused }); | |
| 66 | 83 | } | |
| 67 | − | ||
| 68 | 84 | ||
| 69 | 85 | 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]); | |
| 70 | 94 | return <Landing />; | |
| 71 | 95 | } | |