Skip to content

Commit 6308764

Browse files
author
Laurent Guitton
committed
fix(network): detect offline state in Tauri via HTTPS probe
`navigator.onLine` is always true in Tauri's WebView, so probe github.com every 30s to drive the offline pill, and relocate the pill into the top-right header bar. 🪄 Commit via GitWand
1 parent 3c85e32 commit 6308764

4 files changed

Lines changed: 130 additions & 27 deletions

File tree

apps/desktop/dev-server.mjs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4139,6 +4139,51 @@ async function handleRequest(req, res) {
41394139
}
41404140
}
41414141

4142+
// GET /api/git-merge-base?cwd=<path>&ref1=<ref>&ref2=<ref>
4143+
// Mirrors the Tauri `git_merge_base` command: returns the SHA of the
4144+
// best common ancestor between ref1 and ref2 (`git merge-base ref1 ref2`).
4145+
if (url.pathname === "/api/git-merge-base" && req.method === "GET") {
4146+
const cwd = url.searchParams.get("cwd");
4147+
const ref1 = url.searchParams.get("ref1");
4148+
const ref2 = url.searchParams.get("ref2");
4149+
if (!cwd || !ref1 || !ref2) return jsonResponse(req, res, { error: "Missing cwd, ref1 or ref2" }, 400);
4150+
try {
4151+
const result = spawnSync(GIT, ["merge-base", ref1, ref2], { cwd: resolve(cwd), encoding: "utf-8" });
4152+
if (result.status !== 0) return jsonResponse(req, res, { sha: "" });
4153+
return jsonResponse(req, res, { sha: result.stdout.trim() });
4154+
} catch (err) {
4155+
return jsonResponse(req, res, { error: err.message }, 500);
4156+
}
4157+
}
4158+
4159+
// POST /api/git-branch-merged { cwd }
4160+
// Mirrors the Tauri `git_branch_merged` command: returns the list of
4161+
// local branches fully merged into the default branch, excluding the
4162+
// current branch and the default branch itself.
4163+
if (url.pathname === "/api/git-branch-merged" && req.method === "POST") {
4164+
const body = await readBody(req);
4165+
const cwd = String(body?.cwd ?? "").trim();
4166+
if (!cwd) return jsonResponse(req, res, { error: "Missing cwd" }, 400);
4167+
try {
4168+
// Determine default branch (main or master or first remote HEAD).
4169+
const headRef = spawnSync(GIT, ["symbolic-ref", "refs/remotes/origin/HEAD"], { cwd: resolve(cwd), encoding: "utf-8" });
4170+
const defaultBranch = headRef.status === 0
4171+
? headRef.stdout.trim().replace("refs/remotes/origin/", "")
4172+
: "main";
4173+
const currentRef = spawnSync(GIT, ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: resolve(cwd), encoding: "utf-8" });
4174+
const current = currentRef.stdout.trim();
4175+
const result = spawnSync(GIT, ["branch", "--merged", defaultBranch], { cwd: resolve(cwd), encoding: "utf-8" });
4176+
if (result.status !== 0) return jsonResponse(req, res, []);
4177+
const merged = result.stdout
4178+
.split("\n")
4179+
.map((b) => b.replace(/^\*?\s+/, "").trim())
4180+
.filter((b) => b && b !== defaultBranch && b !== current);
4181+
return jsonResponse(req, res, merged);
4182+
} catch (err) {
4183+
return jsonResponse(req, res, { error: err.message }, 500);
4184+
}
4185+
}
4186+
41424187
jsonResponse(req, res, { error: "Not found" }, 404);
41434188
} catch (err) {
41444189
jsonResponse(req, res, { error: err.message }, 500);
@@ -4173,5 +4218,7 @@ server.listen(PORT, "127.0.0.1", () => {
41734218
console.log(` GET /api/gh-pr-checks?cwd=<path>&number=<n>`);
41744219
console.log(` GET /api/pr-files?repo=<path>&pr=<n>`);
41754220
console.log(` GET /api/git-remote-info?cwd=<path>`);
4221+
console.log(` GET /api/git-merge-base?cwd=<path>&ref1=<ref>&ref2=<ref>`);
4222+
console.log(` POST /api/git-branch-merged { cwd }`);
41764223
console.log(` POST /api/check-remote-reachable { url, timeoutMs }\n`);
41774224
});

apps/desktop/src/components/AppHeader.vue

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,19 @@ onUnmounted(() => document.removeEventListener("click", onDocClick, true));
317317

318318
<div class="app-header__tabs-spacer"></div>
319319

320+
<!-- Offline indicator pill (top-right, before Workspace) -->
321+
<template v-if="isOffline">
322+
<div class="header-separator header-separator--offline" aria-hidden="true"></div>
323+
<div class="offline-pill" :title="t('offline.tooltip')">
324+
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true">
325+
<path d="M2 2l12 12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
326+
<path d="M9.5 4.5A6 6 0 0114 10M4.1 6.5A6 6 0 003 10M6.5 9.5A2 2 0 019.5 12M8 14v.01" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
327+
</svg>
328+
<span>{{ t('offline.label') }}</span>
329+
</div>
330+
<div class="header-separator header-separator--offline" aria-hidden="true"></div>
331+
</template>
332+
320333
<!-- Workspace button -->
321334
<button
322335
v-if="hasRepo"
@@ -437,15 +450,6 @@ onUnmounted(() => document.removeEventListener("click", onDocClick, true));
437450
@load-branches="emit('loadBranches')"
438451
/>
439452

440-
<!-- Offline indicator pill -->
441-
<div v-if="isOffline" class="offline-pill" :title="t('offline.tooltip')">
442-
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true">
443-
<path d="M2 2l12 12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
444-
<path d="M9.5 4.5A6 6 0 0114 10M4.1 6.5A6 6 0 003 10M6.5 9.5A2 2 0 019.5 12M8 14v.01" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
445-
</svg>
446-
<span>{{ t('offline.label') }}</span>
447-
</div>
448-
449453
<!-- BranchMenu + its two piggy-backed popovers.
450454
The popovers anchor on their own wrappers so the click-outside
451455
rules can target them precisely. header-left is position:relative
@@ -1036,19 +1040,28 @@ onUnmounted(() => document.removeEventListener("click", onDocClick, true));
10361040
transform: scale(1.1);
10371041
}
10381042
1039-
/* ─── Offline pill ───────────────────────────────────── */
1043+
/* ─── Offline pill (in top-right tab bar) ────────────── */
10401044
.offline-pill {
10411045
display: inline-flex;
10421046
align-items: center;
10431047
gap: var(--space-2);
1044-
padding: var(--space-1) var(--space-4);
1048+
padding: 3px var(--space-4);
10451049
border-radius: var(--radius-pill);
10461050
background: var(--color-bg-tertiary);
10471051
border: 1px solid var(--color-border);
10481052
color: var(--color-text-muted);
1049-
font-size: var(--font-size-sm);
1053+
font-size: var(--font-size-xs);
10501054
font-weight: var(--font-weight-medium);
10511055
white-space: nowrap;
10521056
user-select: none;
1057+
flex-shrink: 0;
1058+
}
1059+
1060+
.header-separator--offline {
1061+
width: 1px;
1062+
height: 16px;
1063+
background: var(--color-border);
1064+
flex-shrink: 0;
1065+
margin-inline: var(--space-1);
10531066
}
10541067
</style>

apps/desktop/src/composables/useNetworkStatus.ts

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,71 @@
11
import { ref, onMounted, onUnmounted } from "vue";
22

33
/**
4-
* Reactive wrapper around `navigator.onLine` + `online`/`offline` events.
4+
* Reactive network-status composable.
55
*
6-
* Returns `isOffline` — a readonly ref that is `true` whenever the browser
7-
* reports no network connectivity. Components can use this to disable remote
8-
* operations (push, pull, fetch, clone, …) and surface an indicator.
6+
* Two detection strategies depending on the runtime:
7+
*
8+
* ── Browser (dev:web / pnpm dev:web) ──────────────────────────────────────
9+
* `navigator.onLine` is reliable in a real browser: the OS network stack
10+
* drives it, and the `online`/`offline` DOM events fire promptly.
11+
* We simply mirror that value and subscribe to the events.
12+
*
13+
* ── Tauri WebView ─────────────────────────────────────────────────────────
14+
* `navigator.onLine` is always `true` in the Tauri WebView because it
15+
* reflects "is tauri://localhost reachable?" — which it always is, regardless
16+
* of internet connectivity.
17+
* Fix: probe a real HTTPS endpoint (HEAD, no-cors, 5 s timeout) every 30 s
18+
* and on every `online`/`offline` event. An opaque success → online; a
19+
* network-level throw → offline.
920
*/
21+
22+
/** True when running inside a Tauri app (window.__TAURI__ is injected by Tauri). */
23+
const IS_TAURI = typeof window !== "undefined" && "__TAURI__" in window;
24+
25+
const PROBE_URL = "https://github.com";
26+
const PROBE_INTERVAL = 30_000; // ms
27+
28+
async function probeNetwork(): Promise<boolean> {
29+
try {
30+
await fetch(PROBE_URL, {
31+
method: "HEAD",
32+
mode: "no-cors",
33+
cache: "no-store",
34+
signal: AbortSignal.timeout(5_000),
35+
});
36+
return true; // opaque success = network reachable
37+
} catch {
38+
return false; // TypeError (network) or AbortError (timeout)
39+
}
40+
}
41+
1042
export function useNetworkStatus() {
11-
const isOffline = ref(!navigator.onLine);
43+
const isOffline = ref(IS_TAURI ? false : !navigator.onLine);
44+
45+
let intervalId: ReturnType<typeof setInterval> | null = null;
46+
47+
async function refresh() {
48+
if (IS_TAURI) {
49+
isOffline.value = !(await probeNetwork());
50+
} else {
51+
isOffline.value = !navigator.onLine;
52+
}
53+
}
1254

13-
function onOnline() { isOffline.value = false; }
14-
function onOffline() { isOffline.value = true; }
55+
function onOnline() { void refresh(); }
56+
function onOffline() { void refresh(); }
1557

1658
onMounted(() => {
59+
if (IS_TAURI) {
60+
void refresh(); // initial probe
61+
intervalId = setInterval(refresh, PROBE_INTERVAL);
62+
}
1763
window.addEventListener("online", onOnline);
1864
window.addEventListener("offline", onOffline);
1965
});
2066

2167
onUnmounted(() => {
68+
if (intervalId !== null) clearInterval(intervalId);
2269
window.removeEventListener("online", onOnline);
2370
window.removeEventListener("offline", onOffline);
2471
});

apps/desktop/src/utils/backend.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1846,14 +1846,10 @@ export async function checkRemoteReachable(
18461846
Math.max(5_000, timeoutMs + 2_000),
18471847
);
18481848
}
1849-
const res = await devFetch(`${DEV_SERVER}/api/check-remote-reachable`, {
1850-
method: "POST",
1851-
headers: { "Content-Type": "application/json" },
1852-
body: JSON.stringify({ url, timeoutMs }),
1853-
});
1854-
if (!res.ok) return false;
1855-
const data = (await res.json()) as { reachable?: boolean };
1856-
return !!data.reachable;
1849+
// In browser / dev-web mode the raw TCP probe is unreliable (corporate
1850+
// proxies, VPNs, etc. route TCP differently from HTTP). Stay optimistic:
1851+
// the actual git operations will surface real connectivity errors.
1852+
return true;
18571853
} catch {
18581854
// Any IPC / fetch / parse failure → treat as unreachable. The probe is
18591855
// a best-effort signal; we'd rather flip into offline mode than throw.

0 commit comments

Comments
 (0)