diff --git a/client/ui/CLAUDE.md b/client/ui/CLAUDE.md index 8efffcd66..5e4f41664 100644 --- a/client/ui/CLAUDE.md +++ b/client/ui/CLAUDE.md @@ -88,10 +88,11 @@ Also: `ProfileSwitcher.SwitchActive` mirrors the daemon switch into the user-sid The main window is created up front in `main.go`. Auxiliary windows are created on demand by `services.WindowManager`: -- **Settings** (`/#/settings`) — opened from the header gear icon (`layouts/Header.tsx → WindowManager.OpenSettings`). Frameless-look (translucent macOS backdrop, hidden inset title bar), fixed 900×640, no resize, no minimise/maximise. +- **Settings** (`/#/settings`) — opened from the header gear icon (`layouts/Header.tsx → WindowManager.OpenSettings`) **and** the tray's Settings menu entry (`tray.go openSettings` → `WindowManager.OpenSettings`). Both call sites go through `WindowManager` so the user sees the same dedicated frameless window from either trigger — the tray used to repurpose the main window via `SetURL("/#/settings")`, which replaced the main UI in place. Frameless-look (translucent macOS backdrop, hidden inset title bar), fixed 900×640, no resize, no minimise/maximise. - **BrowserLogin** (`/#/browser-login?uri=…`) — opened by the connection toggle's SSO flow (`layouts/ConnectionStatusSwitch.tsx`). 460×440, fixed size. The close button (red X) fires `EventBrowserLoginCancel` so the JS-side `startLogin()` can tear down the daemon's pending `WaitSSOLogin`. `WindowManager.CloseBrowserLogin` closes it programmatically when the flow completes. +- **SessionExpired** (`/#/session-expired`) and **SessionAboutToExpire** (`/#/session-about-to-expire?seconds=`) — opened by `WindowManager.OpenSessionExpired` / `OpenSessionAboutToExpire(seconds)`. 460×380, fixed size, `AlwaysOnTop: true` (the user can't miss them). The React-side buttons close the window via `WindowManager.CloseSession*` and (for Sign-in / Stay-connected) emit `EventTriggerLogin` so the main window's `startLogin()` orchestrator handles the SSO flow. Currently triggered only by the DEV-only "Development" Settings tab; daemon-status integration is a follow-up. -Both windows are **destroyed** on close (mutex-guarded singleton; `closing` hook nils the field). Destroying rather than hiding is deliberate — Wails' macOS dock-reopen handler resurrects hidden windows, which we don't want for auxiliaries. +All four auxiliary windows are **destroyed** on close (mutex-guarded singleton; `closing` hook nils the field). Destroying rather than hiding is deliberate — Wails' macOS dock-reopen handler resurrects hidden windows, which we don't want for auxiliaries. The main window is **hidden** on close (the `WindowClosing` hook calls `e.Cancel(); window.Hide()`). The user reaches "really quit" through the tray → Quit menu entry. diff --git a/client/ui/frontend/CLAUDE.md b/client/ui/frontend/CLAUDE.md index efe505208..44b809566 100644 --- a/client/ui/frontend/CLAUDE.md +++ b/client/ui/frontend/CLAUDE.md @@ -26,11 +26,12 @@ React 18 + TS 5.7 (`strict`, `noImplicitAny: false`) + Vite 6 + Tailwind 3 (`dar | `/quick` | `QuickActions` | none | Standalone — **prototype**, not currently invoked by the Go side | | `/browser-login` | `BrowserLogin` | none | Auxiliary window (Go `WindowManager.OpenBrowserLogin`) | | `/update` | `Update` (pages) | none | Main window during enforced-update install | -| `/session-expired` | `SessionExpired` | none | Standalone — **prototype**, no buttons wired | +| `/session-expired` | `SessionExpired` (modules/session) | none | Auxiliary window (Go `WindowManager.OpenSessionExpired`, always-on-top) | +| `/session-about-to-expire` | `SessionAboutToExpire` (modules/session) | none | Auxiliary window (Go `WindowManager.OpenSessionAboutToExpire(seconds)`, always-on-top, mm:ss countdown via `?seconds=`) | | `/settings` | `Settings` | `SettingsLayout` | Auxiliary window (Go `WindowManager.OpenSettings`) | | `*` | `` | `AppLayout` | Catch-all | -`AppLayout` wraps `Header + ` in this provider order: `ProfileProvider → DebugBundleProvider → ClientVersionProvider`. The order matters — `DebugBundleProvider` reads `useProfile`, and `ClientVersionProvider` paints the `` so it has to be outermost in terms of z-index but innermost in the tree. `AppLayout` also owns the wide/narrow `expanded` state as plain `useState` (no persistence) and passes it to `Header` via props and to `Main` via Outlet context (`MainOutletContext`). +`AppLayout` wraps `Header + ` in this provider order: `StatusProvider → ProfileProvider → DebugBundleProvider → ClientVersionProvider`. `StatusProvider` (in `modules/daemon-status/StatusContext.tsx`) owns the single `Peers.Get` + `netbird:status` subscription, exposes `{ status, error, refresh, isReady, isDaemonAvailable, isDaemonUnavailable }`, **and only renders its children when the daemon is reachable** — until the first `Peers.Get` resolves and on `DaemonUnavailable` it short-circuits to just the `` (also owned by the provider). The consequence: every context downstream (`ProfileProvider`, `DebugBundleProvider`, `ClientVersionProvider`) can assume the daemon is reachable at mount time — no per-context `useStatus` gating. When the daemon flips back to unavailable the whole downstream subtree unmounts and remounts fresh once it returns. The remaining order is structural — `DebugBundleProvider` reads `useProfile`, and `ClientVersionProvider` paints `` so it has to be outermost in z-index but innermost in the tree. `AppLayout` also owns the wide/narrow `expanded` state as plain `useState` (no persistence) and passes it to `Header` via props and to `Main` via Outlet context (`MainOutletContext`). `SettingsLayout` uses the same provider stack minus the `Header`. It also reserves a 38px `wails-draggable` strip at the top so the macOS traffic-light buttons (the window uses `MacTitleBarHiddenInset`) don't overlap content. @@ -44,7 +45,7 @@ Subscribe with `Events.On(name, handler)`. The handler receives `{ data: `. `refresh()` after Connect/Disconnect to dodge a few hundred ms of event-stream lag. Other contexts (e.g. `ProfileContext`) read the boolean flags to skip RPCs while the daemon socket is down. - **`ProfileContext`** (`modules/profile/`) — `username`, `activeProfile`, `profiles`, plus `refresh` / `switchProfile` / `addProfile` / `removeProfile` / `logoutProfile`. `switchProfile` delegates to `ProfileSwitcher.SwitchActive` (the Go-side single source of truth — drives the optimistic-Connecting paint and `Peers` suppression). The other methods are thin wrappers over `Profiles.*` / `Connection.Logout` plus a `refresh()`. @@ -160,7 +161,7 @@ Defined in `tailwind.config.ts`. `nb-gray` is the neutral palette (background = - **`screens/Profiles.tsx`** still imports bindings via the deep relative path. It's the example of the preferred `ProfileSwitcher.SwitchActive` flow but otherwise pre-AppLayout. - **`pages/Debug.tsx`** is the legacy debug-bundle screen. The polished flow is in `modules/settings/SettingsTroubleshooting.tsx` (via `useDebugBundle`). `pages/Debug.tsx` isn't currently routed. - **`pages/Update.tsx`** and **`screens/Update.tsx`** are two different update pages. The route table points at `pages/Update.tsx` (the production one with the 15-minute timeout, daemon-down-grace, and error-mapping). The `screens/Update.tsx` is an older simpler variant. -- **`pages/SessionExpired.tsx`** is fully rendered but the Sign-in / Later buttons have no onClick handlers yet. +- **`modules/session/SessionExpired.tsx`** and **`modules/session/SessionAboutToExpire.tsx`** are the always-on-top auxiliary windows. Today they're only triggered via the DEV-only "Development" tab in Settings (`SettingsDevelopment.tsx`) — a daemon-status hook (status `SessionExpired`, plus a future "about-to-expire" signal) will drive them later. Sign-in / Stay-connected emit `EventTriggerLogin` so the main window's `startLogin()` orchestrator handles the SSO flow; Logout uses `Connection.Logout({profileName, username})`. - **`screens/QuickActions.tsx`** is wired to `/quick` in the route table but nothing on the Go side currently navigates there. - **`UpdateAvailableBanner`** is force-enabled via `FORCE_UPDATE_AVAILABLE = true` and additionally TODO-commented for the "only when management has auto updates enabled + force updates is disabled" case. - **`lib/MainModuleContext.tsx`** is exported but unused. Candidate for deletion. diff --git a/client/ui/frontend/src/app.tsx b/client/ui/frontend/src/app.tsx index 9129563a4..ae094d5c6 100644 --- a/client/ui/frontend/src/app.tsx +++ b/client/ui/frontend/src/app.tsx @@ -3,7 +3,8 @@ import ReactDOM from "react-dom/client"; import "./globals.css"; import { HashRouter, Navigate, Route, Routes } from "react-router-dom"; import QuickActions from "@/screens/QuickActions.tsx"; -import SessionExpired from "@/pages/SessionExpired.tsx"; +import SessionExpired from "@/modules/session/SessionExpired.tsx"; +import SessionAboutToExpire from "@/modules/session/SessionAboutToExpire.tsx"; import Update from "@/screens/Update.tsx"; import { AppLayout } from "@/layouts/AppLayout.tsx"; import { SettingsLayout } from "@/layouts/SettingsLayout.tsx"; @@ -34,6 +35,7 @@ initI18n() } /> } /> } /> + } /> }> } /> diff --git a/client/ui/frontend/src/hooks/useStatus.ts b/client/ui/frontend/src/hooks/useStatus.ts deleted file mode 100644 index d7768733e..000000000 --- a/client/ui/frontend/src/hooks/useStatus.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { useCallback, useEffect, useState } from "react"; -import { Events } from "@wailsio/runtime"; -import { Peers } from "@bindings/services"; -import type { Status } from "@bindings/services/models.js"; - -const EVENT_STATUS = "netbird:status"; - -// useStatus loads the current daemon status once and re-renders whenever the -// peers service emits a fresh snapshot over the Wails event bus. Callers can -// also force a manual refresh (e.g. right after Connection.Up/Down) so the -// view never lags behind a user action even if the daemon event stream is -// briefly silent. -export function useStatus(): { - status: Status | null; - error: string | null; - refresh: () => Promise; -} { - const [status, setStatus] = useState(null); - const [error, setError] = useState(null); - - const refresh = useCallback(async () => { - try { - const s = await Peers.Get(); - setStatus(s); - setError(null); - } catch (e) { - setError(String(e)); - } - }, []); - - useEffect(() => { - void refresh(); - - const off = Events.On(EVENT_STATUS, (ev: { data: Status }) => { - setStatus(ev.data); - setError(null); - }); - - return () => { - off(); - }; - }, [refresh]); - - return { status, error, refresh }; -} diff --git a/client/ui/frontend/src/i18n/locales/de/common.json b/client/ui/frontend/src/i18n/locales/de/common.json index 71248a1de..c57d77036 100644 --- a/client/ui/frontend/src/i18n/locales/de/common.json +++ b/client/ui/frontend/src/i18n/locales/de/common.json @@ -268,6 +268,12 @@ "sessionExpired.later": "Später", "sessionExpired.signIn": "Anmelden", + "sessionAboutToExpire.title": "Sitzung läuft bald ab", + "sessionAboutToExpire.description": "Ihre NetBird-Sitzung läuft in Kürze ab. Bleiben Sie verbunden, damit Ihre Geräte online bleiben.", + "sessionAboutToExpire.stay": "Verbunden bleiben", + "sessionAboutToExpire.logout": "Abmelden", + "sessionAboutToExpire.expired": "Sitzung abgelaufen", + "peers.search.placeholder": "Nach Peer-Name, DNS oder IP-Adresse suchen", "peers.filter.all": "Alle", "peers.filter.online": "Online", diff --git a/client/ui/frontend/src/i18n/locales/en/common.json b/client/ui/frontend/src/i18n/locales/en/common.json index 4ca45c5bf..08cc3708a 100644 --- a/client/ui/frontend/src/i18n/locales/en/common.json +++ b/client/ui/frontend/src/i18n/locales/en/common.json @@ -268,6 +268,12 @@ "sessionExpired.later": "Later", "sessionExpired.signIn": "Sign in", + "sessionAboutToExpire.title": "Session expiring soon", + "sessionAboutToExpire.description": "Your NetBird session will expire shortly. Stay connected to keep your devices online.", + "sessionAboutToExpire.stay": "Stay connected", + "sessionAboutToExpire.logout": "Logout", + "sessionAboutToExpire.expired": "Session expired", + "peers.search.placeholder": "Search by peer name, DNS or IP address", "peers.filter.all": "All", "peers.filter.online": "Online", diff --git a/client/ui/frontend/src/i18n/locales/hu/common.json b/client/ui/frontend/src/i18n/locales/hu/common.json index d446af172..2c527791e 100644 --- a/client/ui/frontend/src/i18n/locales/hu/common.json +++ b/client/ui/frontend/src/i18n/locales/hu/common.json @@ -268,6 +268,12 @@ "sessionExpired.later": "Később", "sessionExpired.signIn": "Bejelentkezés", + "sessionAboutToExpire.title": "A munkamenet hamarosan lejár", + "sessionAboutToExpire.description": "A NetBird munkamenete hamarosan lejár. Maradjon csatlakoztatva, hogy az eszközei elérhetők maradjanak.", + "sessionAboutToExpire.stay": "Maradjon csatlakoztatva", + "sessionAboutToExpire.logout": "Kijelentkezés", + "sessionAboutToExpire.expired": "Munkamenet lejárt", + "peers.search.placeholder": "Keresés társ neve, DNS vagy IP-cím alapján", "peers.filter.all": "Összes", "peers.filter.online": "Online", diff --git a/client/ui/frontend/src/layouts/AppLayout.tsx b/client/ui/frontend/src/layouts/AppLayout.tsx index 098680ee9..0adb39169 100644 --- a/client/ui/frontend/src/layouts/AppLayout.tsx +++ b/client/ui/frontend/src/layouts/AppLayout.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { Outlet } from "react-router-dom"; import { Header } from "@/layouts/Header.tsx"; import { ClientVersionProvider } from "@/modules/auto-update/ClientVersionContext.tsx"; -import { DaemonUnavailableOverlay } from "@/modules/daemon-status/DaemonUnavailableOverlay.tsx"; +import { StatusProvider } from "@/modules/daemon-status/StatusContext.tsx"; import { DebugBundleProvider } from "@/modules/debug-bundle/DebugBundleContext.tsx"; import { ProfileProvider } from "@/modules/profile/ProfileContext.tsx"; @@ -16,15 +16,16 @@ export const AppLayout = () => { const [expanded, setExpanded] = useState(false); return (
- - - -
- - - - - + + + + +
+ + + + +
); }; diff --git a/client/ui/frontend/src/layouts/ConnectionStatusSwitch.tsx b/client/ui/frontend/src/layouts/ConnectionStatusSwitch.tsx index 3093ad599..284dd0c59 100644 --- a/client/ui/frontend/src/layouts/ConnectionStatusSwitch.tsx +++ b/client/ui/frontend/src/layouts/ConnectionStatusSwitch.tsx @@ -4,7 +4,7 @@ import { Dialogs, Events } from "@wailsio/runtime"; import { Connection, WindowManager } from "@bindings/services"; import i18next from "@/lib/i18n"; import { ToggleSwitch } from "@/components/ToggleSwitch.tsx"; -import { useStatus } from "@/hooks/useStatus"; +import { useStatus } from "@/modules/daemon-status/StatusContext.tsx"; import { useProfile } from "@/modules/profile/ProfileContext.tsx"; import { cn } from "@/lib/cn.ts"; import netbirdFullLogo from "@/assets/logos/netbird-full.svg"; diff --git a/client/ui/frontend/src/layouts/SettingsLayout.tsx b/client/ui/frontend/src/layouts/SettingsLayout.tsx index 985b92692..6f74ea7e4 100644 --- a/client/ui/frontend/src/layouts/SettingsLayout.tsx +++ b/client/ui/frontend/src/layouts/SettingsLayout.tsx @@ -1,6 +1,6 @@ import { Outlet } from "react-router-dom"; import { ClientVersionProvider } from "@/modules/auto-update/ClientVersionContext.tsx"; -import { DaemonUnavailableOverlay } from "@/modules/daemon-status/DaemonUnavailableOverlay.tsx"; +import { StatusProvider } from "@/modules/daemon-status/StatusContext.tsx"; import { DebugBundleProvider } from "@/modules/debug-bundle/DebugBundleContext.tsx"; import { ProfileProvider } from "@/modules/profile/ProfileContext.tsx"; @@ -18,19 +18,20 @@ import { ProfileProvider } from "@/modules/profile/ProfileContext.tsx"; export const SettingsLayout = () => { return (
- - - -
- - - - - + + + + +
+ + + + +
); }; diff --git a/client/ui/frontend/src/modules/daemon-status/DaemonUnavailableOverlay.tsx b/client/ui/frontend/src/modules/daemon-status/DaemonUnavailableOverlay.tsx index 742f99913..339af6073 100644 --- a/client/ui/frontend/src/modules/daemon-status/DaemonUnavailableOverlay.tsx +++ b/client/ui/frontend/src/modules/daemon-status/DaemonUnavailableOverlay.tsx @@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next"; import { AlertCircleIcon, BookText } from "lucide-react"; import { Browser } from "@wailsio/runtime"; import { Button } from "@/components/Button"; -import { useStatus } from "@/hooks/useStatus"; +import { useStatus } from "@/modules/daemon-status/StatusContext.tsx"; const DOCS_URL = "https://docs.netbird.io/how-to/installation"; @@ -12,9 +12,9 @@ function openUrl(url: string) { export const DaemonUnavailableOverlay = () => { const { t } = useTranslation(); - const { status } = useStatus(); + const { isDaemonUnavailable } = useStatus(); - if (status?.status !== "DaemonUnavailable") return null; + if (!isDaemonUnavailable) return null; return (
Promise; + isReady: boolean; + isDaemonUnavailable: boolean; + isDaemonAvailable: boolean; +}; + +const StatusContext = createContext(null); + +export const useStatus = () => { + const ctx = useContext(StatusContext); + if (!ctx) { + throw new Error("useStatus must be used inside StatusProvider"); + } + return ctx; +}; + +export const StatusProvider = ({ children }: { children: ReactNode }) => { + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + try { + const s = await Peers.Get(); + setStatus(s); + setError(null); + } catch (e) { + // Peers.Get returns a gRPC error when the socket itself is + // unreachable (daemon not running, missing socket, etc.); only + // the streaming path synthesizes a DaemonUnavailable status. + // Synthesize one here too so the overlay paints on cold start + // without a daemon — otherwise the whole UI stays blank since + // `isReady` would never flip and StatusProvider's short-circuit + // wouldn't render either children or the overlay. + setStatus({ status: "DaemonUnavailable" } as Status); + setError(String(e)); + } + }, []); + + useEffect(() => { + void refresh(); + const off = Events.On(EVENT_STATUS, (ev: { data: Status }) => { + setStatus(ev.data); + setError(null); + }); + return () => { + off(); + }; + }, [refresh]); + + const isReady = status !== null; + const isDaemonUnavailable = isReady && status.status === "DaemonUnavailable"; + const isDaemonAvailable = isReady && !isDaemonUnavailable; + + // Don't mount children until the first Peers.Get has resolved and the + // daemon is reachable. Consumers (ProfileContext, SettingsContext, …) + // can then assume any daemon RPC they make at mount will reach the + // socket — no per-context availability gating. When the daemon flips + // back to unavailable the children unmount and remount fresh once it + // returns. + return ( + + {isDaemonAvailable && children} + + + ); +}; diff --git a/client/ui/frontend/src/modules/profile/ProfileContext.tsx b/client/ui/frontend/src/modules/profile/ProfileContext.tsx index f57d27594..185c9438c 100644 --- a/client/ui/frontend/src/modules/profile/ProfileContext.tsx +++ b/client/ui/frontend/src/modules/profile/ProfileContext.tsx @@ -74,7 +74,10 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => { }, []); useEffect(() => { - refresh(); + void refresh(); + }, [refresh]); + + useEffect(() => { // The tray and other windows drive switches through the same // ProfileSwitcher.SwitchActive RPC, which emits this event on success. // Without the subscription, a tray-initiated switch leaves this diff --git a/client/ui/frontend/src/modules/session/SessionAboutToExpire.tsx b/client/ui/frontend/src/modules/session/SessionAboutToExpire.tsx new file mode 100644 index 000000000..e2b7a681e --- /dev/null +++ b/client/ui/frontend/src/modules/session/SessionAboutToExpire.tsx @@ -0,0 +1,118 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useSearchParams } from "react-router-dom"; +import { Events } from "@wailsio/runtime"; +import { ClockIcon } from "lucide-react"; +import { Button } from "@/components/Button"; +import { + Connection, + Profiles as ProfilesSvc, + WindowManager, +} from "@bindings/services"; + +const EVENT_TRIGGER_LOGIN = "trigger-login"; +const DEFAULT_SECONDS = 360; + +function formatMMSS(seconds: number): string { + const s = Math.max(0, seconds | 0); + const m = Math.floor(s / 60); + const r = s % 60; + return `${String(m).padStart(2, "0")}:${String(r).padStart(2, "0")}`; +} + +export default function SessionAboutToExpire() { + const { t } = useTranslation(); + const [params] = useSearchParams(); + const initialSeconds = useMemo(() => { + const raw = params.get("seconds"); + if (!raw) return DEFAULT_SECONDS; + const n = Number.parseInt(raw, 10); + return Number.isFinite(n) && n > 0 ? n : DEFAULT_SECONDS; + }, [params]); + + const [remaining, setRemaining] = useState(initialSeconds); + const expired = remaining <= 0; + + useEffect(() => { + setRemaining(initialSeconds); + }, [initialSeconds]); + + useEffect(() => { + if (remaining <= 0) return; + const id = window.setInterval(() => { + setRemaining((s) => (s <= 1 ? 0 : s - 1)); + }, 1000); + return () => window.clearInterval(id); + }, [remaining]); + + const stay = useCallback(() => { + void Events.Emit(EVENT_TRIGGER_LOGIN); + WindowManager.CloseSessionAboutToExpire().catch(console.error); + }, []); + + const logout = useCallback(async () => { + try { + const username = await ProfilesSvc.Username(); + const active = await ProfilesSvc.GetActive(); + await Connection.Logout({ + profileName: active.profileName || "default", + username, + }); + } catch (e) { + console.error("logout from session-about-to-expire failed", e); + } finally { + WindowManager.CloseSessionAboutToExpire().catch(console.error); + } + }, []); + + return ( +
+
+ +
+

+ {expired + ? t("sessionAboutToExpire.expired") + : t("sessionAboutToExpire.title")} +

+

+ {t("sessionAboutToExpire.description")} +

+
+ {formatMMSS(remaining)} +
+
+ + +
+
+ ); +} diff --git a/client/ui/frontend/src/pages/SessionExpired.tsx b/client/ui/frontend/src/modules/session/SessionExpired.tsx similarity index 53% rename from client/ui/frontend/src/pages/SessionExpired.tsx rename to client/ui/frontend/src/modules/session/SessionExpired.tsx index b511a8eb9..f117ea0fe 100644 --- a/client/ui/frontend/src/pages/SessionExpired.tsx +++ b/client/ui/frontend/src/modules/session/SessionExpired.tsx @@ -1,13 +1,28 @@ +import { useCallback } from "react"; import { useTranslation } from "react-i18next"; +import { Events } from "@wailsio/runtime"; import { ShieldAlertIcon } from "lucide-react"; import { Button } from "@/components/Button"; +import { WindowManager } from "@bindings/services"; + +const EVENT_TRIGGER_LOGIN = "trigger-login"; export default function SessionExpired() { const { t } = useTranslation(); + + const signIn = useCallback(() => { + void Events.Emit(EVENT_TRIGGER_LOGIN); + WindowManager.CloseSessionExpired().catch(console.error); + }, []); + + const later = useCallback(() => { + WindowManager.CloseSessionExpired().catch(console.error); + }, []); + return (
- -
diff --git a/client/ui/frontend/src/modules/settings/Settings.tsx b/client/ui/frontend/src/modules/settings/Settings.tsx index 0d82ffc07..1fa9bf975 100644 --- a/client/ui/frontend/src/modules/settings/Settings.tsx +++ b/client/ui/frontend/src/modules/settings/Settings.tsx @@ -13,6 +13,7 @@ import { SettingsSSH } from "@/modules/settings/SettingsSSH.tsx"; import { SettingsAdvanced } from "@/modules/settings/SettingsAdvanced.tsx"; import { SettingsTroubleshooting } from "@/modules/settings/SettingsTroubleshooting.tsx"; import { SettingsAbout } from "@/modules/settings/SettingsAbout.tsx"; +import { SettingsDevelopment } from "@/modules/settings/SettingsDevelopment.tsx"; // The settings window always opens at General. The only way to land on a // different tab is via navigation state (e.g. the update-available header @@ -59,6 +60,11 @@ export const Settings = () => { + {import.meta.env.DEV && ( + + + + )}
diff --git a/client/ui/frontend/src/modules/settings/SettingsAbout.tsx b/client/ui/frontend/src/modules/settings/SettingsAbout.tsx index d3583e81e..025926521 100644 --- a/client/ui/frontend/src/modules/settings/SettingsAbout.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsAbout.tsx @@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next"; import { Browser } from "@wailsio/runtime"; import netbirdFull from "@/assets/logos/netbird-full.svg"; import pkg from "../../../package.json"; -import { useStatus } from "@/hooks/useStatus"; +import { useStatus } from "@/modules/daemon-status/StatusContext.tsx"; import { UpdateVersionCard } from "@/modules/auto-update/UpdateVersionCard"; import { useAccentTrigger } from "@/modules/settings/SettingsAccent"; diff --git a/client/ui/frontend/src/modules/settings/SettingsDevelopment.tsx b/client/ui/frontend/src/modules/settings/SettingsDevelopment.tsx new file mode 100644 index 000000000..a61e9348b --- /dev/null +++ b/client/ui/frontend/src/modules/settings/SettingsDevelopment.tsx @@ -0,0 +1,30 @@ +import { Button } from "@/components/Button"; +import { WindowManager } from "@bindings/services"; +import { SectionGroup } from "@/modules/settings/SettingsSection.tsx"; + +export function SettingsDevelopment() { + return ( + +
+ + +
+
+ ); +} diff --git a/client/ui/frontend/src/modules/settings/SettingsNavigationTriggers.tsx b/client/ui/frontend/src/modules/settings/SettingsNavigationTriggers.tsx index 629b9e5ca..7f45170f6 100644 --- a/client/ui/frontend/src/modules/settings/SettingsNavigationTriggers.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsNavigationTriggers.tsx @@ -5,6 +5,7 @@ import { UpdateBadge } from "@/modules/auto-update/UpdateBadge.tsx"; import { useClientVersion } from "@/modules/auto-update/ClientVersionContext.tsx"; import { BoltIcon, + HammerIcon, InfoIcon, LifeBuoyIcon, NetworkIcon, @@ -62,6 +63,13 @@ export const SettingsNavigationTriggers = () => { title={t("settings.tabs.about")} adornment={aboutAdornment} /> + {import.meta.env.DEV && ( + + )}
); diff --git a/client/ui/frontend/src/screens/Peers.tsx b/client/ui/frontend/src/screens/Peers.tsx index 327b6ac38..919eeceb0 100644 --- a/client/ui/frontend/src/screens/Peers.tsx +++ b/client/ui/frontend/src/screens/Peers.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from "react"; import { ChevronDown, ChevronRight, Network, ShieldCheck, Zap } from "lucide-react"; -import { useStatus } from "../hooks/useStatus"; +import { useStatus } from "@/modules/daemon-status/StatusContext.tsx"; import type { PeerStatus } from "@bindings/services/models.js"; import { Card } from "../components/Card"; import { Input } from "../components/Input"; diff --git a/client/ui/frontend/src/screens/QuickActions.tsx b/client/ui/frontend/src/screens/QuickActions.tsx index 1f867b154..a5ad81bcd 100644 --- a/client/ui/frontend/src/screens/QuickActions.tsx +++ b/client/ui/frontend/src/screens/QuickActions.tsx @@ -1,5 +1,5 @@ import { CheckCircle2, Circle, Loader2, Power } from "lucide-react"; -import { useStatus } from "../hooks/useStatus"; +import { useStatus } from "@/modules/daemon-status/StatusContext.tsx"; import { Connection } from "@bindings/services"; import { Button } from "../components/Button"; import { cn } from "../lib/cn"; diff --git a/client/ui/main.go b/client/ui/main.go index db2ea70ec..2629fd580 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -219,6 +219,7 @@ func main() { Notifier: notifier, Update: update, ProfileSwitcher: profileSwitcher, + WindowManager: windowManager, Localizer: localizer, }) listenForShowSignal(context.Background(), tray) diff --git a/client/ui/services/windowmanager.go b/client/ui/services/windowmanager.go index 67ae2c0d9..1a97af9e7 100644 --- a/client/ui/services/windowmanager.go +++ b/client/ui/services/windowmanager.go @@ -4,6 +4,7 @@ package services import ( "net/url" + "strconv" "sync" "github.com/wailsapp/wails/v3/pkg/application" @@ -29,10 +30,12 @@ const EventBrowserLoginCancel = "browser-login:cancel" // "Cleanup on close"). Destroying rather than hiding means the dock-reopen // handler doesn't find a hidden window to resurrect. type WindowManager struct { - app *application.App - settings *application.WebviewWindow - browserLogin *application.WebviewWindow - mu sync.Mutex + app *application.App + settings *application.WebviewWindow + browserLogin *application.WebviewWindow + sessionExpired *application.WebviewWindow + sessionAboutToExpire *application.WebviewWindow + mu sync.Mutex } func NewWindowManager(app *application.App) *WindowManager { @@ -130,3 +133,99 @@ func (s *WindowManager) CloseBrowserLogin() { w.Close() } } + +// OpenSessionExpired shows the "session expired" prompt window above all +// other application windows. Singleton — destroyed on close. +func (s *WindowManager) OpenSessionExpired() { + s.mu.Lock() + defer s.mu.Unlock() + if s.sessionExpired == nil { + s.sessionExpired = s.app.Window.NewWithOptions(application.WebviewWindowOptions{ + Name: "session-expired", + Title: "NetBird", + Width: 460, + Height: 380, + DisableResize: true, + AlwaysOnTop: true, + MinimiseButtonState: application.ButtonHidden, + MaximiseButtonState: application.ButtonHidden, + CloseButtonState: application.ButtonEnabled, + BackgroundColour: application.NewRGB(24, 26, 29), + URL: "/#/session-expired", + Mac: application.MacWindow{ + InvisibleTitleBarHeight: 38, + Backdrop: application.MacBackdropTranslucent, + TitleBar: application.MacTitleBarHiddenInset, + CollectionBehavior: application.MacWindowCollectionBehaviorFullScreenNone, + }, + }) + s.sessionExpired.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { + s.mu.Lock() + s.sessionExpired = nil + s.mu.Unlock() + }) + } + s.sessionExpired.Show() + s.sessionExpired.Focus() +} + +// CloseSessionExpired destroys the session-expired window if open. +func (s *WindowManager) CloseSessionExpired() { + s.mu.Lock() + w := s.sessionExpired + s.sessionExpired = nil + s.mu.Unlock() + if w != nil { + w.Close() + } +} + +// OpenSessionAboutToExpire shows the countdown warning window above all +// other application windows. `seconds` seeds the initial countdown value +// rendered as mm:ss in the React layer. Singleton — destroyed on close. +func (s *WindowManager) OpenSessionAboutToExpire(seconds int) { + s.mu.Lock() + defer s.mu.Unlock() + startURL := "/#/session-about-to-expire?seconds=" + strconv.Itoa(seconds) + if s.sessionAboutToExpire == nil { + s.sessionAboutToExpire = s.app.Window.NewWithOptions(application.WebviewWindowOptions{ + Name: "session-about-to-expire", + Title: "NetBird", + Width: 460, + Height: 380, + DisableResize: true, + AlwaysOnTop: true, + MinimiseButtonState: application.ButtonHidden, + MaximiseButtonState: application.ButtonHidden, + CloseButtonState: application.ButtonEnabled, + BackgroundColour: application.NewRGB(24, 26, 29), + URL: startURL, + Mac: application.MacWindow{ + InvisibleTitleBarHeight: 38, + Backdrop: application.MacBackdropTranslucent, + TitleBar: application.MacTitleBarHiddenInset, + CollectionBehavior: application.MacWindowCollectionBehaviorFullScreenNone, + }, + }) + s.sessionAboutToExpire.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { + s.mu.Lock() + s.sessionAboutToExpire = nil + s.mu.Unlock() + }) + } else { + s.sessionAboutToExpire.SetURL(startURL) + } + s.sessionAboutToExpire.Show() + s.sessionAboutToExpire.Focus() +} + +// CloseSessionAboutToExpire destroys the countdown warning window if open. +func (s *WindowManager) CloseSessionAboutToExpire() { + s.mu.Lock() + w := s.sessionAboutToExpire + s.sessionAboutToExpire = nil + s.mu.Unlock() + if w != nil { + w.Close() + } +} diff --git a/client/ui/tray.go b/client/ui/tray.go index 3e5fb6d9f..63281a9b8 100644 --- a/client/ui/tray.go +++ b/client/ui/tray.go @@ -56,6 +56,7 @@ type TrayServices struct { Notifier *notifications.NotificationService Update *services.Update ProfileSwitcher *services.ProfileSwitcher + WindowManager *services.WindowManager // Localizer is the tray's bridge to translations. Constructed in main // from i18n.Bundle + preferences.Store; the Wails-bound facades // (services.I18n, services.Preferences) are registered separately for @@ -294,7 +295,7 @@ func (t *Tray) buildMenu() *application.Menu { // block-inbound, auto-connect, notifications) and profile switching // all live in the in-window Settings page now. The tray menu only // surfaces the day-to-day actions. - t.settingsItem = menu.Add(t.loc.T("tray.menu.settings")).OnClick(func(*application.Context) { t.openRoute("/settings") }) + t.settingsItem = menu.Add(t.loc.T("tray.menu.settings")).OnClick(func(*application.Context) { t.svc.WindowManager.OpenSettings() }) t.debugItem = menu.Add(t.loc.T("tray.menu.debugBundle")).OnClick(func(*application.Context) { t.openRoute("/debug") }) menu.AddSeparator()