mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-23 23:29:08 +02:00
refactor, lint, cleanup
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user