refactor, lint, cleanup

This commit is contained in:
Eduard Gert
2026-06-09 16:31:52 +02:00
parent bada2b5b78
commit f8e3ac6d92
79 changed files with 1441 additions and 2463 deletions
@@ -2,35 +2,8 @@ import { useLayoutEffect, useRef } from "react";
import { Window } from "@wailsio/runtime";
import i18next from "@/lib/i18n";
// useAutoSizeWindow resizes the current Wails window so its height matches
// the measured height of the content element the returned ref is attached
// to. Width stays fixed (Wails has no "fit-content-width" notion and the
// dialog-style session windows want a stable horizontal footprint).
//
// On first measurement the hook also calls Window.Show()/Focus() — the
// Go-side opens the window with Hidden: true so the user never sees the
// initial placeholder size snap to the measured size. Subsequent
// measurements (content changes after mount) only adjust the size.
//
// Re-measures via ResizeObserver so adding/removing content (e.g. the
// SessionExpiration title swapping at countdown zero) keeps the chrome
// tight to the content with no scrollbar.
//
// Also re-measures on i18next `languageChanged`. The ResizeObserver in
// theory catches the same reflow when translated strings replace each
// other (DE/HU strings often wrap to more lines than EN), but in practice
// the observer can settle on a stale size before React's commit and the
// font's glyph metrics finish updating. An explicit double-rAF after the
// language flip guarantees the final layout is the one we measure.
//
// `ready` (default true) gates Window.SetSize + Window.Show. Pass false
// while the caller is still resolving its initial content (e.g. waiting
// on an async probe) so the window stays Hidden instead of briefly
// rendering placeholder padding at the wrong size — Linux/GNOME in
// particular paints whatever the frame ends up at, and a transient
// half-height frame can leak through. Flip ready=true once the real
// content is in the DOM; the effect re-runs, measures the final size,
// and shows the window.
// Sizes the current Wails window to the measured content height (keeping `width`),
// then shows it. Re-applies on content resize and language change.
export function useAutoSizeWindow<T extends HTMLElement>(width: number, ready: boolean = true) {
const ref = useRef<T | null>(null);
useLayoutEffect(() => {
@@ -39,39 +12,25 @@ export function useAutoSizeWindow<T extends HTMLElement>(width: number, ready: b
let shown = false;
let raf1 = 0;
let raf2 = 0;
const showOnce = () => {
if (shown) return;
shown = true;
Window.Show().catch(() => {});
Window.Focus().catch(() => {});
};
const apply = () => {
if (!ready) return;
const h = Math.ceil(el.getBoundingClientRect().height);
if (h <= 0) return;
// Wails Window.SetSize takes the *frame* size on every platform
// (Windows: SetWindowPos, macOS: setFrame:, Linux: GTK frame).
// The OS title bar lives inside the frame, so we have to add the
// chrome height before calling SetSize, or the title bar eats
// pixels from the bottom and the rendered content gets clipped.
//
// window.outerHeight / window.innerHeight are useless here:
// WebView2 (and WKWebView) report the WebView's own outer == inner
// because the WebView itself has no chrome — the OS title bar is
// outside the WebView's window object entirely. The only way to
// recover the chrome height is to compare the OS frame height
// (Wails-side Window.Size()) against the WebView viewport
// (window.innerHeight).
void Window.Size()
// Window.SetSize takes the frame size, so add the OS title-bar height or content clips.
Window.Size()
.then((frame) => {
const chrome = Math.max(0, frame.height - window.innerHeight);
return Window.SetSize(width, h + chrome);
})
.then(() => {
if (shown) return;
shown = true;
void Window.Show().catch(() => {});
void Window.Focus().catch(() => {});
})
.then(showOnce)
.catch(() => {});
};
// Double rAF: first frame lands after React commits the new
// translated strings, second frame lands after the browser has
// recomputed layout, so apply() sees the final box.
const scheduleApply = () => {
cancelAnimationFrame(raf1);
cancelAnimationFrame(raf2);
@@ -1,22 +1,15 @@
import { useEffect } from "react";
import { isMacOS } from "@/lib/platform";
export type Shortcut = {
key: string; // e.g. "k", "Escape", "/"
cmd?: boolean; // requires Cmd (mac) / Ctrl (win/linux)
key: string;
cmd?: boolean;
shift?: boolean;
alt?: boolean;
// When true (default), preventDefault is called on a match.
preventDefault?: boolean;
};
// Listens for a keyboard shortcut on the window and invokes `callback` on
// match. Disable conditionally via `enabled` to avoid stealing keys while a
// dialog/panel is in the foreground.
export const useKeyboardShortcut = (
shortcut: Shortcut,
callback: () => void,
enabled = true,
) => {
export const useKeyboardShortcut = (shortcut: Shortcut, callback: () => void, enabled = true) => {
useEffect(() => {
if (!enabled) return;
const onKey = (e: KeyboardEvent) => {
@@ -28,8 +21,8 @@ export const useKeyboardShortcut = (
if (shortcut.preventDefault !== false) e.preventDefault();
callback();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
globalThis.addEventListener("keydown", onKey);
return () => globalThis.removeEventListener("keydown", onKey);
}, [
shortcut.key,
shortcut.cmd,
@@ -41,16 +34,13 @@ export const useKeyboardShortcut = (
]);
};
// True on macOS — use the ⌘ glyph; otherwise show "Ctrl".
export const isMac =
typeof navigator !== "undefined" &&
/Mac|iPhone|iPad|iPod/i.test(navigator.platform);
export const formatShortcut = (shortcut: Shortcut): string => {
// navigator.platform is empty on some WebView2 builds → misrenders ⌘ as Ctrl on Mac.
const mac = isMacOS();
const parts: string[] = [];
if (shortcut.cmd) parts.push(isMac ? "⌘" : "Ctrl");
if (shortcut.shift) parts.push(isMac ? "⇧" : "Shift");
if (shortcut.alt) parts.push(isMac ? "⌥" : "Alt");
if (shortcut.cmd) parts.push(mac ? "⌘" : "Ctrl");
if (shortcut.shift) parts.push(mac ? "⇧" : "Shift");
if (shortcut.alt) parts.push(mac ? "⌥" : "Alt");
parts.push(shortcut.key.length === 1 ? shortcut.key.toUpperCase() : shortcut.key);
return parts.join(isMac ? "" : "+");
return parts.join(mac ? "" : "+");
};
@@ -5,21 +5,18 @@ import { useConfirm } from "@/contexts/DialogContext.tsx";
export const CLOUD_MANAGEMENT_URL = "https://api.netbird.io:443";
// URL_PATTERN matches http(s)://host[:port][/path][?query][#fragment].
// Host is domain, localhost, or IPv4. Used for syntactic validation only —
// reachability is checked separately via checkManagementUrlReachable.
// Matches http(s)://host[:port][/path][?query][#fragment]; host = domain, localhost, or IPv4.
// Syntactic validation only — reachability is checked via checkManagementUrlReachable.
export const URL_PATTERN = new RegExp(
"^(https?:\\/\\/)?" +
"((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|localhost|" +
"((\\d{1,3}\\.){3}\\d{1,3}))" +
"(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*" +
"(\\?[;&a-z\\d%_.~+=-]*)?" +
"(\\#[-a-z\\d_]*)?$",
String.raw`^(https?:\/\/)?` +
String.raw`((([a-z\d]([a-z\d-]*[a-z\d])?)\.)+[a-z]{2,}|localhost|` +
String.raw`((\d{1,3}\.){3}\d{1,3}))` +
String.raw`(\:\d+)?(\/[-a-z\d%_.~+]*)*` +
String.raw`(\?[;&a-z\d%_.~+=-]*)?` +
String.raw`(\#[-a-z\d_]*)?$`,
"i",
);
// normalizeManagementUrl prefixes an https:// scheme when the user omits
// it. Empty input stays empty.
export function normalizeManagementUrl(input: string): string {
const trimmed = input.trim();
if (!trimmed) return "";
@@ -27,28 +24,18 @@ export function normalizeManagementUrl(input: string): string {
return `https://${trimmed}`;
}
// isValidManagementUrl is a syntactic check via URL_PATTERN. Does not
// touch the network.
export function isValidManagementUrl(input: string): boolean {
const trimmed = input.trim();
if (!trimmed) return false;
return URL_PATTERN.test(trimmed);
}
// isCloudManagementUrl reports whether the stored URL is the NetBird
// Cloud default (or an empty/unset URL, which the daemon also treats as
// cloud-defaulting on first boot).
export function isCloudManagementUrl(url: string): boolean {
if (!url || url.trim() === "") return true;
return url === CLOUD_MANAGEMENT_URL;
}
// checkManagementUrlReachable does a best-effort no-cors GET against the
// URL with a short timeout. A resolved fetch (even opaque) means DNS +
// TCP + TLS landed; any rejection (network error, DNS, abort) is treated
// as unreachable. Self-hosted deployments behind internal-only DNS or
// with self-signed certs may return false positives — callers should
// surface this as a soft warning, not a hard block.
// Can false-negative for self-hosted behind internal DNS / self-signed certs — treat as a soft warning, not a hard block.
export async function checkManagementUrlReachable(
url: string,
timeoutMs: number = 5000,
@@ -80,15 +67,10 @@ export function useManagementUrl() {
const { t } = useTranslation();
const confirm = useConfirm();
const { config, saveField } = useSettings();
const [mode, setModeState] = useState<ManagementMode>(
modeFromUrl(config.managementUrl),
);
const [modeState, setModeState] = useState<ManagementMode>(modeFromUrl(config.managementUrl));
const [url, setUrl] = useState(
config.managementUrl === CLOUD_MANAGEMENT_URL ? "" : config.managementUrl,
);
// Self-hosted reachability soft-check, mirrored from the onboarding /
// profile-creation flows: a failed probe is a non-blocking orange warning,
// and a second Save with the same URL goes through regardless.
const [checking, setChecking] = useState(false);
const [unreachable, setUnreachable] = useState(false);
@@ -99,19 +81,12 @@ export function useManagementUrl() {
}
}, [config.managementUrl]);
// Clear the stale warning whenever the target changes.
useEffect(() => {
setUnreachable(false);
}, [url, mode]);
}, [url, modeState]);
const setMode = async (next: ManagementMode) => {
if (
next === ManagementMode.Cloud &&
config.managementUrl !== CLOUD_MANAGEMENT_URL
) {
// Switching from a self-hosted management server to NetBird Cloud
// re-points the client at a different deployment and forces a
// reconnect/re-login. Confirm via the in-app modal before applying.
if (next === ManagementMode.Cloud && config.managementUrl !== CLOUD_MANAGEMENT_URL) {
const ok = await confirm({
title: t("settings.general.management.switchCloudTitle"),
description: t("settings.general.management.switchCloudMessage"),
@@ -119,7 +94,9 @@ export function useManagementUrl() {
});
if (!ok) return;
setModeState(ManagementMode.Cloud);
void saveField("managementUrl", CLOUD_MANAGEMENT_URL);
saveField("managementUrl", CLOUD_MANAGEMENT_URL).catch((err: unknown) =>
console.error("save managementUrl failed", err),
);
return;
}
setModeState(next);
@@ -127,19 +104,14 @@ export function useManagementUrl() {
const normalizedUrl = normalizeManagementUrl(url);
const urlValid = isValidManagementUrl(url);
const targetUrl =
mode === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : normalizedUrl;
const targetUrl = modeState === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : normalizedUrl;
const dirty = targetUrl !== config.managementUrl;
const showError =
mode === ManagementMode.SelfHosted && url.trim() !== "" && !urlValid;
const canSave = dirty && (mode === ManagementMode.Cloud || urlValid);
const displayUrl = mode === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : url;
const showError = modeState === ManagementMode.SelfHosted && url.trim() !== "" && !urlValid;
const canSave = dirty && (modeState === ManagementMode.Cloud || urlValid);
const displayUrl = modeState === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : url;
const save = async () => {
// Self-hosted: probe the server first. A failed probe surfaces a soft
// warning and bails; a second Save (unreachable already set) skips the
// re-check and saves anyway, so the user can override a false negative.
if (mode === ManagementMode.SelfHosted && !unreachable) {
if (modeState === ManagementMode.SelfHosted && !unreachable) {
setChecking(true);
const reachable = await checkManagementUrlReachable(targetUrl);
setChecking(false);
@@ -153,7 +125,7 @@ export function useManagementUrl() {
};
return {
mode,
mode: modeState,
setMode,
url,
setUrl,