From 1985caf99381e7ff73325887111b79d82a8723f2 Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Fri, 29 May 2026 14:04:45 +0200 Subject: [PATCH] add os detection --- client/ui/CLAUDE.md | 3 +- client/ui/frontend/CLAUDE.md | 3 +- client/ui/frontend/src/app.tsx | 11 +++++-- client/ui/frontend/src/lib/platform.ts | 44 ++++++++++++++++++++++++++ 4 files changed, 56 insertions(+), 5 deletions(-) create mode 100644 client/ui/frontend/src/lib/platform.ts diff --git a/client/ui/CLAUDE.md b/client/ui/CLAUDE.md index ca5c38684..7f2f0d49a 100644 --- a/client/ui/CLAUDE.md +++ b/client/ui/CLAUDE.md @@ -54,6 +54,7 @@ All services live in `services/` and assume a build tag `!android && !ios && !fr - `EventTriggerLogin = "trigger-login"` — tray asking the frontend's `startLogin()` to begin an SSO flow. The tray does **not** show the main window when emitting — the hidden webview is alive and subscribed, so `startLogin` runs and the only visible surface is the BrowserLogin popup it opens. - `EventBrowserLoginCancel = "browser-login:cancel"` — the `BrowserLogin` window's Cancel button or red-X close. `startLogin()` listens and tears down the daemon's pending `WaitSSOLogin`. - `preferences.EventPreferencesChanged = "netbird:preferences:changed"` — emitted after every successful `SetLanguage` (payload `{language}`). Both the tray menu rebuild and the React `i18next.changeLanguage` subscribe so a flip from any window paints everywhere. +- `EventSettingsOpen = "netbird:settings:open"` (payload: tab string, e.g. `"general"` / `"profiles"`) — emitted by `WindowManager.OpenSettings(tab)` to set the active tab before Go calls `Show`/`Focus`. The matching reset-to-General on close lives in the React side via `document.visibilitychange` (Wails events from the Go close hook race `Hide` and flash the previous tab for one frame). Daemon connection status strings (`services/peers.go`) mirror `internal.Status*` in `client/internal/state.go`: `Connected`, `Connecting`, `Idle`, `NeedsLogin`, `LoginFailed`, `SessionExpired`, plus the synthetic `DaemonUnavailable` emitted by `Peers` when the socket is unreachable. @@ -88,7 +89,7 @@ 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 (`pages/main/Header.tsx → WindowManager.OpenSettings("")`), the tray's Settings menu entry (`tray.go openSettings`), and the profile dropdown's "Manage Profiles" entry (`WindowManager.OpenSettings("profiles")`, which sets `?tab=profiles` in the start URL — `Settings.tsx` reads it via `useSearchParams`). The window hosts every settings tab — including **Profiles** (`ProfilesTab.tsx`, `UserCircle` icon, sits between Security and SSH), which lists profiles in a table with Deregister/Delete in a per-row kebab and an Add Profile button. 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 (opaque macOS backdrop, hidden inset title bar), fixed 900×640, no resize, no minimise/maximise. **Unlike the other auxiliary windows**, Settings is created eagerly (hidden) inside `NewWindowManager` and hides on close instead of being destroyed — first open is instant and the React layer keeps in-window state (selected tab, scroll, unsaved form fields) across reopens. `OpenSettings("")` reuses whatever state the user left behind; passing a non-empty tab forces `SetURL` so deep-links still navigate. +- **Settings** (`/#/settings`) — opened from the header gear icon (`pages/main/Header.tsx → WindowManager.OpenSettings("")`), the tray's Settings menu entry (`tray.go openSettings`), and the profile dropdown's "Manage Profiles" entry (`WindowManager.OpenSettings("profiles")`, which sets `?tab=profiles` in the start URL — `Settings.tsx` reads it via `useSearchParams`). The window hosts every settings tab — including **Profiles** (`ProfilesTab.tsx`, `UserCircle` icon, sits between Security and SSH), which lists profiles in a table with Deregister/Delete in a per-row kebab and an Add Profile button. 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 (opaque macOS backdrop, hidden inset title bar), fixed 900×640, no resize, no minimise/maximise. **Unlike the other auxiliary windows**, Settings is created eagerly (hidden) inside `NewWindowManager` and hides on close instead of being destroyed — first open is instant. The window stays at a single URL (`/#/settings`) forever; `OpenSettings(tab)` does **not** call `SetURL`. Instead it emits `netbird:settings:open` with the target tab (empty → `"general"`), then calls `Show`/`Focus`. `SettingsPage` keeps the active tab in React local state and listens for the event to switch. **Reset-on-close lives in the React side**, not the Go close hook: `SettingsPage` listens for `document.visibilitychange` and resets the tab to General when the page goes hidden. Doing it via `Event.Emit` from the close hook didn't work — the dispatch goroutine races `Hide`, the JS listener often runs only after the *next* `Show`, and the user sees a one-frame flash of the previous tab. The Page Visibility API fires before WebKit throttles the page, so the state update lands while we're still in foreground JS. (The earlier `SetURL` path re-loaded the WKWebView entirely, re-mounting the `AppLayout` provider stack and visibly flashing the `SettingsSkeleton` while `SettingsContext` re-fetched config.) - **BrowserLogin** (`/#/dialog/browser-login?uri=…`) — opened by the connection toggle's SSO flow (`pages/main/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** (`/#/dialog/session-expired`) and **SessionAboutToExpire** (`/#/dialog/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 no triggers wired — daemon-status integration is a follow-up. - **InstallProgress** (`/#/dialog/install-progress?version=`) — opened by `WindowManager.OpenInstallProgress(version)` from `ClientVersionContext` (force-install branch on `installing` flip, user-driven enforced branch from `triggerUpdate`). 360-wide auto-sized via `useAutoSizeWindow`, `AlwaysOnTop`. Owns its own polling loop against `Update.GetInstallerResult` with the 5-second daemon-down-grace (sustained gRPC failure = success → call `Update.Quit()`). Hides every other visible window on open (restored on close). diff --git a/client/ui/frontend/CLAUDE.md b/client/ui/frontend/CLAUDE.md index e03e33450..e32796643 100644 --- a/client/ui/frontend/CLAUDE.md +++ b/client/ui/frontend/CLAUDE.md @@ -27,7 +27,7 @@ React 18 + TS 5.7 (`strict`, `noImplicitAny: false`) + Vite 6 + Tailwind 3 (`dar | `/dialog/install-progress` | `UpdateInProgressDialog` (modules/auto-update/) | none | Auxiliary window (Go `WindowManager.OpenInstallProgress(version)`, always-on-top). Owns the install-result polling + 5s daemon-down-grace; calls `Update.Quit()` on success. Opened by `ClientVersionContext.triggerUpdate` (enforced user-driven branch) and on the `installing` flip from `netbird:update:state` (force-install branch). | | `/dialog/session-expired` | `SessionExpiredDialog` (modules/session/) | none | Auxiliary window (Go `WindowManager.OpenSessionExpired`, always-on-top) | | `/dialog/session-about-to-expire` | `SessionAboutToExpireDialog` (modules/session/) | none | Auxiliary window (Go `WindowManager.OpenSessionAboutToExpire(seconds)`, always-on-top, mm:ss countdown via `?seconds=`) | -| `/settings` | `SettingsPage` (modules/settings/) | `AppLayout` | Auxiliary window (Go `WindowManager.OpenSettings(tab)`). Inherits the shared provider stack from `AppLayout`; the page itself adds the draggable strip + tabs. The `Profiles` tab (`modules/profiles/ProfilesTab.tsx`, `UserCircle` icon, between Security and SSH) lists profiles in a table with Deregister/Delete in a per-row kebab and an Add Profile button. The header `ProfileDropdown`'s "Manage Profiles" entry calls `OpenSettings("profiles")` — `Settings.tsx` reads `?tab=` via `useSearchParams` so the window opens at that tab. | +| `/settings` | `SettingsPage` (modules/settings/) | `AppLayout` | Auxiliary window (Go `WindowManager.OpenSettings(tab)`). Inherits the shared provider stack from `AppLayout`; the page itself adds the draggable strip + tabs. The `Profiles` tab (`modules/profiles/ProfilesTab.tsx`, `UserCircle` icon, between Security and SSH) lists profiles in a table with Deregister/Delete in a per-row kebab and an Add Profile button. The header `ProfileDropdown`'s "Manage Profiles" entry calls `OpenSettings("profiles")`. The window stays at `/#/settings` for its whole lifetime — no `SetURL` between opens, so `AppLayout`'s providers never remount. Tab is React local state, driven by the `netbird:settings:open` event Go emits before `Show`. Reset-to-General on close is handled in React via `document.visibilitychange` (Page Visibility API), which fires *before* WebKit throttles the hidden page, unlike Wails events from the Go close hook which race `Hide` and leave the previous tab visible for one frame on the next open. | | `*` | `` | `AppLayout` | Catch-all | In `app.tsx` the four dialog routes are nested under a parent `` so the table reads as a tree, not a flat list. The Go side mirrors the prefix — `WindowManager` opens windows at `/#/dialog/`. The `dialog` group has no shared layout component; it's purely a URL grouping. @@ -84,6 +84,7 @@ Subscribe with `Events.On(name, handler)`. The handler receives `{ data: { +Promise.all([ + initI18n().catch((e) => { // Surface init failures in the console so a misconfigured glob // doesn't quietly blank the UI; render anyway with i18next in // whatever state it ended up in (t() will fall back to keys). console.error("i18n init failed:", e); - }) + }), + initPlatform().catch((e) => { + console.error("platform init failed:", e); + }), +]) .finally(() => { ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( diff --git a/client/ui/frontend/src/lib/platform.ts b/client/ui/frontend/src/lib/platform.ts new file mode 100644 index 000000000..c9207ca40 --- /dev/null +++ b/client/ui/frontend/src/lib/platform.ts @@ -0,0 +1,44 @@ +import { System } from "@wailsio/runtime"; + +export type Platform = { + isWindows11: boolean; + isMacOS: boolean; + isOtherOS: boolean; +}; + +let cached: Platform | null = null; + +// Windows 11 is Windows NT 10.0 with build number >= 22000. +function parseWindows11(version: string): boolean { + const match = version.match(/(\d+)\.(\d+)\.(\d+)/); + if (!match) return false; + return parseInt(match[3], 10) >= 22000; +} + +export async function initPlatform(): Promise { + if (cached) return; + const isMacOS = System.IsMac(); + const isWindows = System.IsWindows(); + let isWindows11 = false; + if (isWindows) { + const env = await System.Environment(); + isWindows11 = parseWindows11(env.OSInfo?.Version ?? ""); + } + cached = { + isWindows11, + isMacOS, + isOtherOS: !isMacOS && !isWindows11, + }; +} + +function get(): Platform { + if (!cached) { + throw new Error("platform: initPlatform() must complete before sync getters are used"); + } + return cached; +} + +export const getPlatform = (): Platform => get(); +export const isWindows11 = (): boolean => get().isWindows11; +export const isMacOS = (): boolean => get().isMacOS; +export const isOtherOS = (): boolean => get().isOtherOS;