From db2ea7554fe3fa4b1f9e696fd40c2f8f3358ced3 Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Wed, 17 Jun 2026 11:42:55 +0200 Subject: [PATCH] remove mdm test scripts and other markdown files --- client/ui/.claude/settings.local.json | 8 - client/ui/CLAUDE.md | 178 ------------------ client/ui/LINUX-TRAY.md | 10 - client/ui/WAILS-DIALOGS.md | 56 ------ client/ui/frontend/CLAUDE.md | 187 ------------------- client/ui/i18n/TRANSLATING.md | 2 +- client/ui/scripts/mdm-fields.md | 41 ----- client/ui/scripts/mdm-toggle.ps1 | 196 -------------------- client/ui/scripts/mdm-toggle.sh | 253 -------------------------- client/ui/scripts/mdm-tui.bat | 4 - 10 files changed, 1 insertion(+), 934 deletions(-) delete mode 100644 client/ui/.claude/settings.local.json delete mode 100644 client/ui/CLAUDE.md delete mode 100644 client/ui/LINUX-TRAY.md delete mode 100644 client/ui/WAILS-DIALOGS.md delete mode 100644 client/ui/frontend/CLAUDE.md delete mode 100644 client/ui/scripts/mdm-fields.md delete mode 100644 client/ui/scripts/mdm-toggle.ps1 delete mode 100755 client/ui/scripts/mdm-toggle.sh delete mode 100644 client/ui/scripts/mdm-tui.bat diff --git a/client/ui/.claude/settings.local.json b/client/ui/.claude/settings.local.json deleted file mode 100644 index 5414cffbe..000000000 --- a/client/ui/.claude/settings.local.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(gh api *)", - "Bash(wails3 generate *)" - ] - } -} diff --git a/client/ui/CLAUDE.md b/client/ui/CLAUDE.md deleted file mode 100644 index 36450d80a..000000000 --- a/client/ui/CLAUDE.md +++ /dev/null @@ -1,178 +0,0 @@ -# NetBird Wails UI — Working Notes - -This is the Wails v3 desktop UI for NetBird. Go services live in `services/`; the React/TS frontend lives in `frontend/`; bindings between them are generated under `frontend/bindings/`. - -> **Keep these notes current.** When working in this directory with Claude, update this file (and `frontend/CLAUDE.md` for frontend-only changes) whenever you add a service, change an event name, shift a convention, rename a key directory, or land any other change that future-you would want to know about before reading the code. The goal is that a cold-start agent can orient itself from these notes without re-deriving the codebase. - -## Layout - -### Go (top-level package `main`) -- `main.go` — app entry. Builds the shared gRPC `Conn`, constructs services, registers them with Wails, creates the main webview window, then starts (in order) the Linux SNI watcher → tray → `peers.Watch` → `app.Run`. CLI flags: `--daemon-addr`, `--log-file` (repeatable; default is **empty** so "no flag" is distinguishable from explicit `--log-file console` — when empty `parseFlagsAndInitLog` falls back to `console` for `InitLog` and returns `userSetLogFile=false`), `--log-level` (`trace|debug|info|warn|error`, default `info`). See "GUI debug logging" below for what `userSetLogFile` gates. -- `dock_darwin.go` / `dock_other.go` — flips the macOS dock entry to follow window visibility. Wails seeds `Mac.ActivationPolicy` at startup but exposes no runtime setter, so `dock_darwin.go` calls `[NSApp setActivationPolicy:]` directly via cgo (with `activateIgnoringOtherApps:` on the 0→1 transition only — without it a newly-shown window opens behind whatever app the user is currently focused on; gating on the transition stops auxiliaries opening on top of an already-visible main from stealing focus). **The Go side is one call** — `main.go` does `initDockObserver()` from the `ApplicationStarted` hook (so NSApp is up). Everything else lives in cgo: the observer registers default-`NSNotificationCenter` block subscriptions for `NSWindowDidChangeOcclusionStateNotification` and `NSWindowWillCloseNotification` (`object:nil` so windows created later are picked up automatically — no per-window plumbing on the Go side, no `WindowManager` constructor wiring), each firing `refreshDockPolicy`. `refreshDockPolicy` `dispatch_async`s a recount onto the main queue, enumerates `[NSApp windows]`, filters by Wails's `WebviewWindow` subclass, and asks each for `NSWindow.isVisible` — true iff the window is ordered in **regardless of occlusion**. That's the fix for the occlusion problem we'd otherwise hit relying on Wails's WindowHide event (Wails maps `windowDidChangeOcclusionState` to it, so covering our window with another app would have looked like a hide). `dispatch_async` also defers past any pending `orderOut:` / `close:` from the originating notification, so destroy-on-close auxiliaries have actually left `[NSApp windows]` by the time we look. A `lastDockState` static int on the C side suppresses no-op flips (and the `activate` focus-steal that goes with them). Linux/Windows: `dock_other.go` is a no-op `initDockObserver()` since their panels already gate taskbar entries on window visibility. -- `tray.go` — `Tray` struct + menu. Subscribes to `EventStatus`, `EventSystem`, `EventUpdateAvailable`, `EventUpdateProgress`. Owns per-status icon/dot, Profiles submenu, Connect/Disconnect swap, About → Update, session-expired toast. -- **Tray menu updates go through `relayoutMenu` (whole-tree rebuild), never in-place submenu mutation.** Any dynamic menu change — status-text transitions (`tray_status.go applyStatus`, which also folds in daemon-version and session-deadline changes into one aggregated relayout per push), Profiles submenu (`tray_profiles.go loadProfiles` → caches rows under `profilesMu`, then `fillProfileSubmenu`), Exit Node submenu (`tray_exitnodes.go refreshExitNodes` → `fillExitNodeSubmenu`), and the About → Update row (`tray_update.go applyState` → `onMenuChange` callback) — rebuilds the entire menu via `Tray.relayoutMenu` (`buildMenu()` + repaint cached state + single `t.tray.SetMenu`). Serialised by `menuMu`. **Why:** on KDE/Plasma the StatusNotifierItem host caches a submenu's layout the first time it's opened (`GetLayout` for that submenu id) and never re-fetches it on a `LayoutUpdated(parent=0)` signal — so the old `submenu.Clear()`+`Add()` left both the visible rows AND the click→id mapping frozen on the first snapshot. Because `Clear()`+`Add()` allocates fresh monotonic item ids each time (Wails `menuitem.go`), clicks then sent ids the rebuilt `itemMap` no longer knew, and silently no-op'd ("Manage Profiles" stopped responding after the first switch). `buildMenu()` allocates a brand-new submenu container id each relayout, which Plasma treats as unseen and re-queries on next open — fixing both the stale paint and the dead clicks. Confirmed via `dbus-monitor`: a re-opened submenu issued no `GetLayout` until its container id changed. The whole-tree `SetMenu` also subsumes the older darwin detached-NSMenu workaround. `fill*Submenu` helpers are pure UI (read caches, no daemon fetch, no `SetMenu`) so `relayoutMenu` never recurses back into the fetchers. -- **Tray concurrency model (menuMu domain).** Wails dispatches `Event.On` listeners and menu `OnClick` callbacks on **fresh goroutines** (so `applyStatus` runs are concurrent with each other and with relayouts), and Wails `MenuItem` setters are not goroutine-safe. Hence `t.menu` and every `*Item`/`*Submenu` field on `Tray` are `menuMu`-owned: `buildMenu` reassigns them all on each relayout, and repaints happen from the caches inside `relayoutMenu` (caches are committed *before* the menu is touched, which makes a write on an orphaned pre-relayout item self-healing). Two sanctioned out-of-lock accesses: the Connect/Disconnect `OnClick` closures capture their own item, and `refreshSessionExpiresLabel` (30s ticker) snapshots `sessionExpiresItem` under `menuMu`. `relayoutMenu` is the **only post-startup `tray.SetMenu` call site** — never push a menu pointer snapshotted outside `menuMu` (it can reinstall a stale tree); `applyStatusIndicator` is `SetBitmap`-only, the relayout's trailing `SetMenu` is what repaints the macOS dot. Tray code never runs on the OS main thread, so taking `menuMu` from any goroutine can't deadlock the setters' `dispatch_sync`/`InvokeSync`. -- `tray_linux.go` — `init()` sets `WEBKIT_DISABLE_DMABUF_RENDERER=1` (blank-white window on VMs / minimal WMs) and `WEBKIT_DISABLE_COMPOSITING_MODE=1` (Intel/Mesa SIGSEGV in `g_application_run` via unimplemented DRM-format-modifier paths — DMABUF-disable alone doesn't cover the GL compositor). Both are skipped if the user already set the var. Also `WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS=1` when unprivileged userns are blocked. -- `tray_watcher_linux.go`, `xembed_host_linux.go`, `xembed_tray_linux.{c,h}` — in-process SNI watcher + XEmbed bridge for minimal WMs. See `LINUX-TRAY.md`. -- `signal_unix.go` / `signal_windows.go` — `listenForShowSignal`. Unix uses SIGUSR1; Windows uses a named event `Global\NetBirdQuickActionsTriggerEvent`. Mirrors the legacy Fyne UI's external-trigger contract so the installer / CLI keep working. -- `grpc.go` — lazy, mutex-protected gRPC `Conn` shared by every service. `DaemonAddr()`: `unix:///var/run/netbird.sock` on Linux/macOS, `tcp://127.0.0.1:41731` on Windows. -- `icons.go` — `//go:embed` tray/window PNGs. macOS uses template variants (`*-macos.png`); Linux uses a monochrome black/white pair (`*-mono.png` black for light panels, `*-mono-dark.png` white for dark panels); Windows reuses the colored light PNG (multi-frame `.ico` never redrew on Wails3's `NIM_MODIFY`). The `*-mono*` set is generated from the macOS template silhouettes (states differ by shape, not color); `tray_icon.go iconForState` branches on `runtime.GOOS` (`linux` → mono, else colored). -- **Linux mono icon theme selection** — Wails v3's Linux SNI backend ignores `SetDarkModeIcon` (its `setDarkModeIcon` just calls `setIcon`, last-write-wins — see `pkg/application/systemtray_linux.go`), and the SNI spec carries no panel light/dark hint. So `tray_theme_linux.go` detects the desktop colour scheme itself and `iconForState` picks black-vs-white, with `applyIcon` pushing a single `SetIcon` on Linux (no `SetDarkModeIcon`). Detection order: freedesktop **Settings portal** (`org.freedesktop.portal.Settings.Read` of `org.freedesktop.appearance`/`color-scheme`: 0=no-pref, 1=dark, 2=light) → on 0/unavailable, fall back to the **`GTK_THEME`** env var (`:dark` suffix ⇒ dark) → else default dark (suits the common dark panel). A private session-bus `SettingChanged` subscription repaints live on theme flips. `Tray.panelDark func() bool` is seeded by `startTrayTheme()` (Linux only; `tray_theme_other.go` is a no-op stub) before the first `applyIcon`; `panelIsDark()` returns true when `panelDark` is nil. - -### Wails services (`services/*.go`) -Each service is registered via `app.RegisterService(application.NewService(svc))`. Every method becomes a TS function in `frontend/bindings/.../services/`. Frontend-facing details (TS signatures, push events, models) are in `frontend/WAILS-API.md`. After editing any `services/*.go` or the proto, regenerate with `wails3 generate bindings -clean=true -ts` (or `pnpm bindings` from `frontend/`). `frontend/bindings/**` is gitignored. - -For frontend-side conventions (routing, providers, contexts) see `frontend/CLAUDE.md`. - -## Services rundown - -All services live in `services/` and assume a build tag `!android && !ios && !freebsd && !js`. Each takes a shared `DaemonConn` (`conn.go`) and is registered in `main.go`. - -| Service | File | Responsibility | -|---|---|---| -| `Connection` | `connection.go` | `Login` / `WaitSSOLogin` / `Up` / `Down` / `Logout` / `OpenURL`. `Up` is always async (`Async: true`); status flows back through `Peers`. `Login` Down-resets the daemon first to dislodge a stale WaitSSOLogin. `OpenURL` honors `$BROWSER`. | -| `Settings` | `settings.go` | `GetConfig` / `SetConfig` (partial update — pointer fields are sent, nil fields preserved) / `GetFeatures` (operator-disabled UI surfaces). | -| `Profiles` | `profile.go` | `Username` / `List` / `GetActive` / `Switch` / `Add` / `Remove`. `List` populates `Email` from the **user-side** state file (`profilemanager.NewProfileManager().GetProfileState`) — the daemon runs as root and can't read it. | -| `ProfileSwitcher` | `profileswitcher.go` | `SwitchActive` — the single entry point both tray and frontend should use for profile flips. Applies the reconnect policy (see "Profile switching" below), mirrors the daemon switch into the user-side `profilemanager`, drives optimistic feedback via `Peers.BeginProfileSwitch`. | -| `Peers` | `peers.go` | Daemon status snapshot + two long-running streams (`SubscribeStatus` → `EventStatus`, `SubscribeEvents` → `EventSystem`). Emits synthetic `StatusDaemonUnavailable` when the socket is unreachable. Owns the profile-switch suppression filter (`BeginProfileSwitch` / `CancelProfileSwitch` / `shouldSuppress`). Fan-outs update metadata into dedicated `EventUpdateAvailable` / `EventUpdateProgress` events. | -| `Networks` | `network.go` | `List` / `Select` / `Deselect` of routed networks. | -| `Forwarding` | `forwarding.go` | `List` exposed/forwarded services from the daemon's reverse-proxy table. | -| `Debug` | `debug.go` | `Bundle` (debug bundle creation + optional upload) / `Get|SetLogLevel` / `RegisterUILog` (report the GUI log path to the daemon for bundle collection) / `RevealFile` (cross-platform "show in file manager"; the platform body lives in `debug_reveal_{windows,other}.go`). **Windows:** the bundle is written by the daemon (SYSTEM) into `C:\Windows\SystemTemp`, whose ACL denies the logged-in user — a plain `explorer /select` can't traverse it. So the Windows reveal elevates via `shell32!ShellExecuteW` with the `runas` verb (UAC prompt); on decline/failure (`HINSTANCE <= 32`) it falls back to an unelevated `explorer `. | -| `Update` | `update.go` | `GetState` / `Trigger` (enforced installer) / `GetInstallerResult` / `Quit`. The install-progress UI lives in its own auxiliary window (`/#/dialog/install-progress`), opened by `WindowManager.OpenInstallProgress` — the daemon goes unreachable mid-install so it can't be inside the main window. | -| `WindowManager` | `windowmanager.go` | `OpenSettings(tab)` / `OpenBrowserLogin(uri)` / `CloseBrowserLogin` / `OpenSessionExpiration(seconds)` / `CloseSessionExpiration` / `OpenInstallProgress(version)` / `CloseInstallProgress` / `OpenWelcome` / `CloseWelcome` / `OpenError(title, message)` / `CloseError` / `OpenMain`. `OpenSettings("")` opens the General tab; pass a tab id (e.g. `"profiles"`) to deep-link, encoded as `?tab=…` in the start URL. `OpenInstallProgress` is `AlwaysOnTop` and hides every other visible window for the duration of the install (restored on close). `OpenMain` is the handoff path from the welcome window to the main UI (avoids depending on the tray). Auxiliary windows are created on first open and **destroyed** on close (Wails-recommended singleton pattern; prevents the macOS dock-reopen from resurrecting hidden windows). | -| `I18n` | `i18n.go` | Thin facade over `i18n.Bundle`. `Languages()` returns the shipped locales (`_index.json`); `Bundle(code)` returns the full key→text map for one language so the React layer can drive its own translation library. | -| `Preferences` | `preferences.go` | Thin facade over `preferences.Store`. `Get()` returns `{language, viewMode, onboardingCompleted}`; `SetLanguage(code)` validates against `i18n.Bundle.HasLanguage` and persists; `SetViewMode(mode)` validates against the known set (`default`/`advanced`) and persists; `SetOnboardingCompleted(bool)` persists the welcome-window dismissal. All broadcast `netbird:preferences:changed`. `main.go` reads `viewMode` from the store to size the main window at startup. | -| `Autostart` | `autostart.go` | Thin facade over Wails' `app.Autostart` (`*application.AutostartManager`). `Supported()` / `IsEnabled()` / `SetEnabled(bool)` — launch-the-UI-at-login toggle. The OS login-item registration (launchd/SMAppService on macOS, `HKCU\…\Run` on Windows, XDG `.desktop` on Linux) is the **single source of truth** — nothing is mirrored to the preferences file. `Enable` registers the running executable with no extra args (the app comes up hidden into the tray). Affects the **graphical UI only**, not the daemon/background service. `Supported()` is false on server/mobile builds (`ErrAutostartNotSupported`); the React toggle in `SettingsGeneral.tsx` hides itself when false. | - -`DaemonConn` is defined in `services/conn.go`; `ptrStr` (string-to-*string helper for proto pointer fields) lives there too. - -## Daemon proto -- Proto source: `../proto/daemon.proto`. Generated Go in `../proto/*.pb.go`. -- Regen: `cd ../proto && protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative daemon.proto` -- Pinned versions (see `daemon.pb.go` header): `protoc v7.34.1`, `protoc-gen-go v1.36.6`. CI's `proto-version-check` workflow fails on mismatch. -- After proto regen, also regen Wails bindings so the TS layer picks up new fields. - -## GUI debug logging - -When the daemon is put into **debug**/**trace** level, the GUI automatically writes a rotated `gui-client.log` in `os.UserConfigDir()/netbird/` and the daemon's **debug bundle** collects it. Pieces: - -- **`uilogpath.go`** (package `main`) — `uiLogPath()` resolves the path; `newDebugLog(userSetLogFile)` builds the `guilog.DebugLog` (disabled when the user passed `--log-file`, or when the config dir can't be resolved). -- **`guilog/debuglog.go` — `guilog.DebugLog`** (own package, **not** a Wails service — no React binding) — owns the file-logging side effects. `Apply(level)`: on debug/trace attaches `gui-client.log` alongside the console (via `util.SetLogOutputs`, MultiWriter, rotated by timberjack like the daemon log) and raises the logrus level; on a higher level detaches the file and restores `info`. Idempotent (`fileOn` guard) so the startup replay + a racing change-event are harmless. The `gui-client.log` is left on disk on quit (rotated by timberjack) for the debug bundle — there's no shutdown cleanup. `Path()` returns "" when disabled so the daemon won't try to collect a file the GUI never writes. -- **Activation rule:** any `--log-file` (even `console`) is a manual override → the controller is disabled and never touches logging. Only the *absence* of `--log-file` enables the daemon-driven `gui-client.log`. This is why `parseFlagsAndInitLog` seeds the flag default empty (Layout note above). -- **Level signalling — no new stream.** The daemon publishes a **marked `SystemEvent`** (`metadata[proto.MetadataKindKey]==proto.MetadataKindLogLevelChanged`, `metadata[proto.MetadataLevelKey]==`) on the existing `SubscribeEvents` stream. `DaemonFeed.dispatchSystemEvent` recognises the marker and routes it to the controller's `Apply` instead of an OS toast (same pattern as `proto.MetadataKindProfileListChanged`). The controller is injected into `NewDaemonFeed` (a `services.LogController` interface — no exported setter, so the Wails-bound `DaemonFeed` gains no binding), and `DaemonFeed` **re-registers the UI log path** (`RegisterUILog` RPC) on every event-stream (re)connect, so a daemon restart re-learns it. Startup case (daemon already in debug) is covered daemon-side: `Server.SubscribeEvents` (`client/server/event.go`) sends the current level once to each new subscriber; `SetLogLevel` publishes on change (`publishLogLevelChanged` in `client/server/server.go`). The metadata key/value markers live in `client/proto/metadata.go` so producer (daemon) and consumer (UI) share one definition. -- **Daemon side of the bundle:** `Server.RegisterUILog` stores the path in `Server.uiLogPath`; `DebugBundle` passes it to `debug.GeneratorDependencies.UILogPath`; `BundleGenerator.addUILog` adds `gui-client.log` + rotated siblings (`addRotatedLogFiles(dir, "gui-client")` — the glob is prefix-parametrised so it doesn't collide with the daemon's own `client*.log.*`). Missing file is non-fatal (the GUI only writes it while in debug). -- **JS-side logs/errors** already reach the same logrus pipeline via `frontend/src/lib/logs.ts` → `services.UILog.Log`, so once the file is attached, frontend console/`unhandledrejection`/`error` output is captured too. Go-side uncaught-goroutine panics go to stderr only (out of scope). - -## Events bus - -`main.go` registers five typed events for the frontend: `netbird:status` (`Status`), `netbird:event` (`SystemEvent`), `netbird:profile:changed` (`ProfileRef`), `netbird:update:available` (`UpdateAvailable`), `netbird:update:progress` (`UpdateProgress`). `netbird:profile:changed` fires from `ProfileSwitcher.SwitchActive` after a successful daemon-side switch — both the React `ProfileContext` and the tray subscribe so a flip driven from one surface paints in the others (the daemon itself does not emit a profile event). Plus three plain-string events: - -- `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. - -## Profile switching - -`services/profileswitcher.go` is the single source of truth for the reconnect policy. Both the tray (`tray.go switchProfile`) and the frontend (via `modules/profiles/ProfileContext.tsx`'s `switchProfile`, which `modules/profiles/ProfilesTab.tsx` and the header `ProfileDropdown` go through) call `ProfileSwitcher.SwitchActive`; identical inputs give identical state transitions. - -Reconnect policy (driven by `prevStatus` from `Peers.Get`): - -| Previous status | Action | Optimistic UI | Suppressed events until new flow begins | -|---|---|---|---| -| Connected | Switch + Down + Up | Connecting (synthetic) | Connected, Idle | -| Connecting | Switch + Down + Up | Connecting (unchanged) | Connected, Idle | -| NeedsLogin / LoginFailed / SessionExpired | Switch + Down | (no change) | — | -| Idle | Switch only | (no change) | — | - -Only Connected/Connecting trigger `Peers.BeginProfileSwitch`. That: -1. Sets a 30s `switchInProgress` guard. -2. Emits a synthetic `Status{Status: StatusConnecting}` so both tray and React paint immediately. -3. Tells `statusStreamLoop` to drop the daemon's stale Connected updates (peer count drops as the engine tears down) and the transient Idle in between Down and the new Up. - -`shouldSuppress` releases the guard as soon as a status that signals the new flow began arrives: -- **Suppressed**: Connected, Idle -- **Pass through and clear**: Connecting / NeedsLogin / LoginFailed / SessionExpired / DaemonUnavailable -- **Timeout fallback**: 30s elapsed → clear flag, emit normally. - -`Peers.CancelProfileSwitch` aborts the suppression — called by `tray.go handleDisconnect` so the user's "Disconnect while Connecting" click paints through immediately. - -Also: `ProfileSwitcher.SwitchActive` mirrors the daemon switch into the user-side `profilemanager` (`~/Library/Application Support/netbird/active_profile`). The CLI's `netbird up` reads this file and sends the resolved profile name back; if it diverges from the daemon's `/var/lib/netbird/active_profile.json`, the daemon silently flips back. Mirror failures don't abort the switch — surfaced as a warning. - -## Auxiliary windows (`WindowManager`) - -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. 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. -- **SessionExpiration** (`/#/dialog/session-expiration?seconds=`) — opened by `WindowManager.OpenSessionExpiration(seconds)`. 460×380, fixed size, `AlwaysOnTop: true`. The React-side buttons close the window via `WindowManager.CloseSessionExpiration` and (for Sign-in / Stay-connected) emit `EventTriggerLogin` so the main window's `startLogin()` orchestrator handles the SSO flow. Triggered by the tray today: `tray_session.go openSessionExpiration` fires it at T-FinalWarningLead when the earlier T-10 notification wasn't dismissed, and `openSessionExtendFlow` opens it on tray-row click seeded with the live remaining time. **Multi-monitor aware** — targets the display the OS cursor is currently on via `WindowManager.getScreenBasedOnCursorPosition`, which queries the native cursor location per-OS through `getCursorPosition` (`services/cursor_{darwin,windows,linux,other}.go`): `NSEvent.mouseLocation` flipped against the primary's frame height on macOS, `w32.GetCursorPos` + ScreenManager `PhysicalToDipPoint` on Windows, X11 `XQueryPointer` on Linux. The X11 query covers Wayland sessions too via XWayland, which ships by default on every supported Linux target. **Verified distro coverage**: Windows, macOS, Ubuntu 22.04 + 24.04 (GNOME-Wayland default + XWayland), Fedora 40 (GNOME-Wayland + XWayland), Debian 12 (GNOME default + XWayland), Arch Linux (any DE/compositor + XWayland), Linux Mint (Cinnamon-Xorg → Xorg direct), GNOME (Xorg + Wayland), Fluxbox (Xorg, exercised by the xembed-tray test path). Falls back gracefully (no panic, no error) to the main window's screen, then the OS default, when the cursor can't be resolved (headless / no DISPLAY / pure-Wayland-without-XWayland). Both first-create and re-show go through a single helper, `WindowManager.centerOnCursorScreen`: synchronous SetPosition first (covers full desktops and re-show with a still-alive GTK surface), then on minimal WMs (`recenterOnShow` — the Fluxbox/XEmbed-tray path) the same ~1s realize-detection retry loop `centerWhenReady` uses, because Wails' Linux SetPosition silently no-ops against a nil GdkSurface and Fluxbox would otherwise leave the window on the primary monitor. -- **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). -- **Welcome** (`/#/dialog/welcome`) — first-launch onboarding window opened by `WindowManager.OpenWelcome()` from `main.go`'s `ApplicationStarted` hook, gated by `prefStore.Get().OnboardingCompleted` so it only fires on a fresh install. Auto-sized via `useAutoSizeWindow`, centered (`InitialPosition: WindowCentered`), inherits `AlwaysOnTop` from `DialogWindowOptions`. Two-step state machine: **(1)** tray-screenshot pitch with the per-OS tray icon; **(2)** Cloud-vs-self-hosted segmented control with optional URL input — only rendered when `shouldShowManagementStep` returns true (default profile + no recorded email + management URL is empty/cloud-default). The Continue button on either terminal step flips `Preferences.SetOnboardingCompleted(true)`, calls `WindowManager.OpenMain()`, then `WindowManager.CloseWelcome()`. - -- **Error** (`/#/dialog/error?message=`) — the app's single error surface, opened by `WindowManager.OpenError(title, message)`. **This replaced the native OS MessageBox outright**: the frontend `errorDialog({Title, Message})` wrapper in `lib/errors.ts` now drives this window (same name/signature as before, so call sites were untouched), and the native `Dialogs.Error`/`Warning`/`Info`/`Question` wrappers plus the Windows `Detached` workaround were deleted (nothing called warning/info/question). Frameless NetBird chrome, `AlwaysOnTop` (inherited from `DialogWindowOptions`), auto-sized to the variable-length message via `useAutoSizeWindow`. **`title` is the window's chrome title** — set Go-side as `"NetBird - "` (empty falls back to the localised "Error"), *not* shown in the body — so it's excluded from `retitleAll` (a language flip must not clobber the live error title). **`message` is the body text**, carried as a query param (`errorDialogURL` query-escapes it so newlines/`&` in formatted daemon errors survive into `useSearchParams`). The left-aligned body is just the danger `SquareIcon` + message + a bottom-right Close button. A second error while one is open updates the live window (`SetTitle` + `SetURL`) instead of stacking another. Singleton, destroyed on close. The Close button (and the Escape key — keyboard cancellation) calls `WindowManager.CloseError()`. Note the behaviour change vs the old native box: `errorDialog()` resolves as soon as the window opens (it no longer blocks until dismissed). **macOS caveat:** the window uses `MacTitleBarHiddenInset`, so the chrome title isn't visibly rendered there — on macOS the error name would not be shown anywhere since it's no longer in the body. - -The four lazy auxiliary windows (BrowserLogin, SessionExpiration, InstallProgress, Error) 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 transient surfaces. Settings is the exception: it's created hidden up-front and uses a `RegisterHook` close interceptor (`e.Cancel(); Hide()`) to keep the webview warm. - -On macOS, `main.go` overrides Wails' default `applicationShouldHandleReopen` listener (which shows *every* hidden window — see `pkg/application/events_common_darwin.go`) by registering an application event hook that cancels the event and shows only the main window. Without this, clicking the dock icon would resurrect the hide-on-close Settings window alongside the main one. - -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. - -## Localisation (i18n) - -The locale tree under `client/ui/i18n/locales/` is the single source of truth for both Go (tray, OS notifications) and React (every user-facing string). It sits next to the Go `i18n` package (the tray's consumer) so a single JSON tree drives both surfaces. Layout: `_index.json` lists shipped languages (`code` / `displayName` / `englishName`); `<code>/common.json` per language. `en/common.json` must exist (the `Bundle` loader hard-fails without it); languages listed in `_index.json` without a bundle are skipped with a warning. Placeholders are single-braced (`"Install version {version}"`) — Go substitutes via `Bundle.Translate(lang, key, "name", value, ...)`; React uses i18next with `interpolation: { prefix: "{", suffix: "}" }`. - -**Bundle shape is Chrome-extension JSON**: each key maps to `{ "message": "...", "description": "..." }`, not a bare string. `description` is **translator context for Crowdin** (which reads it natively from the source file) and is ignored at runtime — only `en/common.json` needs descriptions; target bundles carry just `message`. Both loaders strip back to a flat `key→message` map: Go's `loadBundle` (`bundle.go`) unmarshals into `map[string]bundleEntry` and flattens (so `BundleFor`/`Translate` signatures are unchanged); `frontend/src/lib/i18n.ts` maps each entry's `message` into the i18next `resources`. When editing a string, edit `message`; when a key's purpose isn't obvious from its name, add/update its `description` so translators (and screenshots auto-tagging) have context. - -Adding a language: drop a `<code>/common.json` under `client/ui/i18n/locales/`, append a row to `_index.json`, rebuild. Go reads the tree via `//go:embed all:i18n/locales` in `client/ui/main.go`; Vite reads it via the `../../../i18n/locales/*/common.json` glob in `frontend/src/lib/i18n.ts`, with `server.fs.allow` in `vite.config.ts` whitelisting the parent dir so the dev server can serve files outside `frontend/`. - -Package layout: -- `client/ui/i18n/` — pure `LanguageCode` / `Language` / `Bundle` loader. No Wails / no daemon. Reads the tree from an `fs.FS` passed in by `main.go`. -- `client/ui/preferences/` — `Store` persists `UIPreferences{language}` to `os.UserConfigDir()/netbird/ui-preferences.json` (per-OS-user, shared across daemon profiles). Validates against an injected `LanguageValidator` (`*i18n.Bundle`). No file → in-memory default `en`, persisted on first `SetLanguage`. Broadcasts via in-process pub/sub + optional Wails event emitter. -- `services/i18n.go` + `services/preferences.go` — Wails facades. Preferences emits `netbird:preferences:changed` (payload `{language}`) on every `SetLanguage`. - -Key conventions: `tray.*` / `notify.*` (Go-side), `common.* / connect.* / nav.* / profile.* / settings.* / update.* / browserLogin.* / sessionExpiration.* / peers.*` (frontend). Keep keys stable — renames cascade everywhere. - -## Linux tray support - -The in-process `StatusNotifierWatcher` + XEmbed host that lets the tray work on minimal WMs is detailed in `LINUX-TRAY.md` (sibling). Touch that doc when modifying `tray_watcher_linux.go` / `xembed_host_linux.go` / `xembed_tray_linux.{c,h}`. - -## Wails Dialogs (frontend, `@wailsio/runtime`) - -The app no longer uses native `@wailsio/runtime` `Dialogs.*` message boxes — errors go through the custom Error window (see below), confirmations through the in-app `useConfirm()` modal. `WAILS-DIALOGS.md` (sibling) is retained only as reference for the native API surface and the Go-side frameless-window pattern, should a native file picker (`OpenFile`/`SaveFile`) ever be needed. - -## Conventions in this codebase - -### Errors → custom Error window - -User-actionable operation failures (config save, profile switch, debug bundle, update, login, etc.) surface via the frontend `errorDialog({Title, Message})` helper in `frontend/src/lib/errors.ts` (alongside `formatErrorMessage`), which opens the custom always-on-top **Error** auxiliary window (`WindowManager.OpenError`, `/#/dialog/error` — see the Auxiliary windows section). Use an action-named title — "Save Settings Failed", "Switch Profile Failed", not "Error" / "Something went wrong" (the window already shows a red error icon). The name `errorDialog` and its `{Title, Message}` shape are unchanged from when it wrapped the native `Dialogs.Error`, so call sites were untouched; the native `Dialogs.Error`/`Warning`/`Info`/`Question` wrappers and the Windows `Detached` workaround were removed (the native MessageBox could wedge the main window's close button — see the Error-window note). Confirmations use the in-app `useConfirm()` modal (`contexts/DialogContext.tsx`), which resolves to a boolean. - -**Skip dialogs entirely** for: inline form validation (`Input.tsx`, URL-format checks — too heavy for keystroke feedback); transient link errors on the dashboard (flap in/out with daemon — use an inline indicator); "partial success" notes inside an otherwise-OK flow (e.g. "bundle saved but upload failed" stays inline). The install-progress window owns its own error UI in-place (timeout/canceled/failed phases) — no error dialog needed there. - -### OS notifications - -The tray uses Wails' built-in `notifications` service. One `notifications.NotificationService` is created in `main.go` and passed into `TrayServices.Notifier`. Notification IDs are prefixed for coalescing: `netbird-update-<version>`, `netbird-event-<id>`, `netbird-tray-error`, `netbird-session-expired`. Notifications are gated by the user's "Notifications" toggle (cached in `Tray.notificationsEnabled`, seeded from `Settings.GetConfig` at boot). `Severity == "critical"` events bypass the gate, mirroring the legacy Fyne `event.Manager`. - -**All sends go through `safeSendNotification` (`tray_notify.go`)** — never call `Notifier.SendNotification`/`SendNotificationWithActions` directly. It swallows both errors *and panics*. The panic guard is load-bearing on Linux: Wails' notifier connects to the session bus in its `ServiceStartup`, and when that connect fails (headless box, no/unreachable `DBUS_SESSION_BUS_ADDRESS`, UI launched outside a desktop session) Wails logs the error but leaves the service registered with a **nil `*dbus.Conn`**. The next send then nil-derefs deep inside godbus (`Conn.getSerial`), and because sends run on a Wails event-dispatch goroutine the panic is fatal to the whole process — observed as a "catastrophic failure" crash on a Linux Mint VM when an update toast fired (`tray_update.go sendUpdateNotification`). `recover()` turns it into a logged no-op. The `tray_session.go` plain-notification fallback keys off the returned error, so a recovered panic (returns nil) correctly skips the fallback — the bus is dead, the plain send would panic too. - -### Profile switching invariants - -`ProfileSwitcher.SwitchActive` is the only switch path on the TS side — `ProfileContext.switchProfile` is the single TS wrapper, and `modules/profiles/ProfilesTab.tsx` + the header `ProfileDropdown` both go through it. The Go side captures `prevStatus`, drives the optimistic-Connecting paint via `Peers.BeginProfileSwitch`, mirrors into the user-side `profilemanager`, and conditionally fires Down/Up per the reconnect-policy table above. - -**Never call `Connection.Up` on an Idle/NeedsLogin daemon** — the daemon's internal 50s `waitForUp` blocks until `DeadlineExceeded`. `Connection.Up` from the frontend is reserved for the explicit Connect button (`ConnectionStatusSwitch.connect`) and the post-SSO resume inside `startLogin`; the gating for profile-switch reconnects lives Go-side in `ProfileSwitcher.SwitchActive`. - -## Build / dev tasks - -`task dev` (Wails dev, live reload), `task build` (prod build for the current OS, dispatches to `build/{darwin,linux,windows}/Taskfile.yml`), `task build:server` / `run:server` / `build:docker` / `run:docker` (server-mode variants in `build/Taskfile.yml`). **No** `task generate:bindings` alias — run `wails3 generate bindings -clean=true -ts` directly from this directory. CLI flags + log-target semantics are documented in the `main.go` bullet under "Layout". - -Both `windows:build` and `windows:build:console` (the latter outputs `bin/netbird-ui-console.exe` linked against the console subsystem, so Go stdout/stderr/logrus print to the launching terminal) honour `DEV=true`, which drops the `-tags production` flag. The `production` tag is what disables the WebKit/WebView2 DevTools inspector — so `DEV=true` is the only way to get a Windows binary where the frontend JS console is reachable (right-click → Inspect / F12). Cross-compile from Linux with `CGO_ENABLED=1 task windows:build:console DEV=true`. - -## Useful references -- `WAILS-DIALOGS.md` (sibling) — full `@wailsio/runtime` `Dialogs` API + per-OS behaviour + frameless-window pattern. -- `LINUX-TRAY.md` (sibling) — StatusNotifierWatcher + XEmbed host details. -- `frontend/WAILS-API.md` — frontend-facing binding signatures and model shapes. -- Wails v3 dialog docs: https://v3.wails.io/features/dialogs/message/ and https://v3.wails.io/features/dialogs/custom/ (may 403 from some clients). -- Wails v3 multiple-windows guidance: https://v3.wails.io/learn/multiple-windows/ -- Authoritative TS signatures: `frontend/node_modules/@wailsio/runtime/types/dialogs.d.ts`. -- Wails examples: https://github.com/wailsapp/wails/tree/master/v3/examples/dialogs diff --git a/client/ui/LINUX-TRAY.md b/client/ui/LINUX-TRAY.md deleted file mode 100644 index 51bbe3cf3..000000000 --- a/client/ui/LINUX-TRAY.md +++ /dev/null @@ -1,10 +0,0 @@ -# Linux tray support (StatusNotifierWatcher + XEmbed) - -Minimal WMs (Fluxbox, OpenBox, i3, dwm, vanilla GNOME without the AppIndicator extension) don't ship a `StatusNotifierWatcher`, so tray icons using libayatana-appindicator / freedesktop StatusNotifier silently fail. `main.go` calls `startStatusNotifierWatcher()` *before* `NewTray` so the Wails systray's `RegisterStatusNotifierItem` call hits the in-process watcher we control. - -- `tray_watcher_linux.go` — owns `org.kde.StatusNotifierWatcher` on the session bus if no other process has it. Safe to call unconditionally. -- `xembed_host_linux.go` + `xembed_tray_linux.{c,h}` — when an XEmbed tray (`_NET_SYSTEM_TRAY_S0`) is available, also start an in-process XEmbed host that bridges the SNI icon into the XEmbed tray. Reads `IconPixmap` over D-Bus, draws via cairo+X11, polls for clicks, fetches `com.canonical.dbusmenu.GetLayout` for the popup menu, fires `com.canonical.dbusmenu.Event` on click. - -**X error handling.** Xlib's default protocol-error handler calls `exit()`, so a single async X error from a tray race (tray manager window dying between `xembed_find_tray` and `xembed_dock`; `XMoveWindow` on a popup the WM already destroyed) would take the whole UI process down. `xembed_install_error_handlers()` (`xembed_tray_linux.c`) installs process-global logging no-op handlers via `XSetErrorHandler`/`XSetIOErrorHandler`; it's idempotent and called from the Go side after every `XOpenDisplay` (`newXembedHost`, `xembedTrayAvailable`). Because the handler slot is process-global, GTK's GDK init can clobber it (whoever installs last wins) — so the popup code's raw Xlib calls **on GDK's Display** (`x11_move_window`, the submenu `XTranslateCoordinates`) are additionally wrapped in GDK's own `gdk_x11_display_error_trap_push`/`_pop_ignored`, which is independent of the global handler. (Those GDK X11 backend calls warn `-Wdeprecated-declarations` in GTK4, like the existing `gdk_x11_surface_get_xid`/`gdk_x11_display_get_xdisplay` calls — expected, still functional.) - -Build is gated on `linux && !386`; the 386 build (no cgo) and non-Linux builds use the `tray_watcher_other.go` no-op. diff --git a/client/ui/WAILS-DIALOGS.md b/client/ui/WAILS-DIALOGS.md deleted file mode 100644 index 9768cf67e..000000000 --- a/client/ui/WAILS-DIALOGS.md +++ /dev/null @@ -1,56 +0,0 @@ -# Wails Dialogs (frontend, `@wailsio/runtime`) - -The frontend dialog API lives in `@wailsio/runtime` as `Dialogs`. Authoritative signatures are in -`frontend/node_modules/@wailsio/runtime/types/dialogs.d.ts`. - -See `CLAUDE.md` for project conventions on *when* to use these (errors vs. inline validation, confirmation flow, etc.). - -## Message dialogs - -```ts -import { Dialogs } from "@wailsio/runtime"; - -await Dialogs.Info({ Title, Message, Buttons?, Detached? }); -await Dialogs.Warning({ Title, Message, Buttons?, Detached? }); -await Dialogs.Error({ Title, Message, Buttons?, Detached? }); -await Dialogs.Question({ Title, Message, Buttons?, Detached? }); -``` - -All four return `Promise<string>` resolving to the **Label** of the button the user clicked. With no `Buttons` provided you get a single OK button — the promise just resolves when the user dismisses. - -`MessageDialogOptions` fields: -- `Title?: string` — window title (short). -- `Message?: string` — the body text. -- `Buttons?: Button[]` — custom buttons. Each `Button` is `{ Label?, IsCancel?, IsDefault? }`. `IsCancel` is what Esc/⌘. triggers; `IsDefault` is what Enter triggers. -- `Detached?: boolean` — when `true`, the dialog isn't tied to the parent window (no sheet behavior on macOS). - -## File dialogs - -`Dialogs.OpenFile(options)` and `Dialogs.SaveFile(options)` — see `dialogs.d.ts` for the full `OpenFileDialogOptions` / `SaveFileDialogOptions` field set (filters, ButtonText, multi-select, hidden files, alias resolution, directory mode, etc). - -## Per-OS behavior - -| Platform | Behavior | -|---|---| -| **macOS** | Sheet-style when attached to a parent window. Up to ~4 custom buttons render naturally. Keyboard: Enter = default, ⌘. or Esc = cancel. Follows system theme. Accessibility is built-in. | -| **Windows** | Modal `TaskDialog`-style. Standard button labels are nudged toward OS conventions. Keyboard: Enter = default, Esc = cancel. Follows system theme. | -| **Linux** | GTK dialogs — appearance varies by desktop environment (GNOME/KDE). Follows desktop theme. Standard keyboard nav. | - -Behavioural notes that affect us: -- The promise resolves with the **button label string**, not an index. Compare against the literal `Label` you passed (e.g. `if (result !== "Delete") return;`). -- `Buttons[]` on Linux/Windows uses the labels you supply, but the OS layout/styling is fixed. -- `Dialogs.Error` plays the platform error sound and uses the platform error icon. Don't use it for confirmations — use `Dialogs.Warning` or `Dialogs.Question`. -- Don't fire dialogs in a tight loop or from every keystroke — they interrupt focus and (on macOS) animate in/out. Debounce or guard with a `busy` flag. - -## Frameless / custom-window dialogs (Go side) - -When the native dialog API isn't enough — rich content, embedded webview, multi-screen flow — open a regular Wails window. This is done on the **Go side** via `app.Window.NewWithOptions(application.WebviewWindowOptions{...})`. Useful options: -- `Parent` — attach to a parent so OS treats it as a child. -- `AlwaysOnTop: true` — float above the parent. -- `Frameless: true` — no titlebar/chrome. -- `Resizable: false` (also `DisableResize: true` in v3) — fixed-size dialog feel. -- `Hidden: true` initially, then `dialog.Show()` + `dialog.SetFocus()`. - -We **do** use this pattern, but pragmatically: `WindowManager.OpenSettings` and `OpenBrowserLogin` are regular small webview windows (not modal sheets) with no resize, hidden minimise/maximise buttons, and a translucent macOS title bar. They're not classic "OS modal dialogs"; they're just lightweight ancillary windows that look the part. Modal behaviour (`parent.SetEnabled(false)`) is intentionally not used — the user can still click back to the main window. - -In-app modals (`NewProfileDialog`, delete-profile confirmation, etc.) are Radix `Dialog` primitives inside the main webview. Reach for a custom OS window only when content must escape the main window (BrowserLogin is the canonical example — its lifecycle is tied to the SSO wait) or when the window needs its own taskbar entry / dock icon. diff --git a/client/ui/frontend/CLAUDE.md b/client/ui/frontend/CLAUDE.md deleted file mode 100644 index c341b55a6..000000000 --- a/client/ui/frontend/CLAUDE.md +++ /dev/null @@ -1,187 +0,0 @@ -# NetBird Wails UI — Frontend Working Notes - -The React/TS frontend for the Wails v3 desktop UI. It runs inside the main Wails webview plus several auxiliary windows opened by Go (`services/windowmanager.go`). For Go-side conventions and the daemon gRPC layer see `../CLAUDE.md`. - -> **Keep these notes current.** Update this file whenever you change conventions, rename a context/provider, change the route table, add/remove a top-level dependency, or introduce a cross-cutting feature (i18n, theming, etc.). A cold-start agent should be able to orient from these notes without re-deriving the codebase. - -## Stack & tooling - -React 18 + TS 5.7 (`strict`, `noImplicitAny: false`) + Vite 6 + Tailwind 3 (`darkMode: "class"`) + Radix primitives + i18next + `@wailsio/runtime`. React Router v7 `HashRouter` (Wails serves a static bundle). pnpm only — `package.json` is authoritative for deps and scripts. Class merging: `cn(...)` in `src/lib/cn.ts`. framer-motion is used only by the connect toggle. `task dev` from `client/ui/` is the canonical dev entry point — it runs Vite on `WAILS_VITE_PORT || 9245`. - -## Path aliases & bindings - -`@/*` → `src/*`, `@bindings/*` → `bindings/github.com/netbirdio/netbird/client/ui/*` (set in both `tsconfig.json` and `vite.config.ts`). Canonical imports: `from "@bindings/services"` (functions) and `from "@bindings/services/models.js"` (types). - -`bindings/` is gitignored and fully generated. A fresh clone has no `bindings/` on disk, so `pnpm typecheck` fails until you run `pnpm bindings` (or `wails3 generate bindings -clean=true -ts` from `client/ui/`) once. `wails3 dev` regenerates on its own. - -## Routing (`app.tsx`) - -`HashRouter`. Dialog routes are grouped under a parent `<Route path="dialog">` (URL grouping only, no shared layout); the two in-window routes sit under `<AppLayout>`. The Go side mirrors the prefix — `WindowManager` opens windows at `/#/dialog/<name>`. - -| Path | Component (module) | Layout | Window | -|---|---|---|---| -| `/` | `MainPage` (modules/main/) | `AppLayout` | Main window | -| `/settings` | `SettingsPage` (modules/settings/) | `AppLayout` | Settings auxiliary window | -| `/dialog/browser-login` | `LoginWaitingForBrowserDialog` (modules/login/) | none | SSO browser-wait, always-on-top | -| `/dialog/install-progress` | `UpdateInProgressDialog` (modules/auto-update/) | none | Install progress, always-on-top | -| `/dialog/session-expiration` | `SessionExpirationDialog` (modules/session/) | none | Session expiry warning, always-on-top | -| `/dialog/welcome` | `WelcomeDialog` (modules/welcome/) | none | First-launch onboarding | -| `/dialog/error` | `ErrorDialog` (modules/error/) | none | App's single error surface, always-on-top | -| `*` | `<Navigate to="/">` | `AppLayout` | Catch-all | - -Auxiliary-window behaviour (sizing, always-on-top, create/destroy lifecycle) lives Go-side in `services/windowmanager.go` — see `../CLAUDE.md`. Frontend-relevant notes per window: - -- **Settings** — opened via `WindowManager.OpenSettings(tab)`. The window stays at `/#/settings` for its whole lifetime (no `SetURL` between opens, so `AppLayout`'s providers never remount). Active tab is React local state in `SettingsPage`, set from the `netbird:settings:open` event Go emits before `Show`. Reset-to-General on close is driven in React by a `document.visibilitychange` listener (the Page Visibility API fires before WebKit throttles the hidden page, unlike a Go close-hook event which races `Hide` and flashes the previous tab for one frame). -- **install-progress** — owns the install-result polling + 5s daemon-down-grace, calls `Update.Quit()` on success. Opened by `ClientVersionContext.triggerUpdate` (user-driven enforced branch) and on the `installing` flip from `netbird:update:state` (force-install branch). -- **session-expiration** — `?seconds=` drives an mm:ss countdown; at zero it flips to the expired copy. Sign-in / Stay-connected emit `trigger-login`; Logout calls `Connection.Logout`. -- **welcome** — opened from Go's `ApplicationStarted` hook only when `prefStore.Get().OnboardingCompleted` is false. Two-step state machine: tray-screenshot pitch → Cloud-vs-self-hosted step (conditional, see `shouldShowManagementStep`). Continue calls `Preferences.SetOnboardingCompleted(true)`, then `WindowManager.OpenMain()`, then `WindowManager.CloseWelcome()`. -- **error** — `errorDialog({Title, Message})` in `lib/errors.ts` opens this (not a native OS box). `title` is the window chrome title (set Go-side, not in the body); `message` is read from `useSearchParams` and rendered next to a danger `SquareIcon`, with a Close button (Escape also closes → `WindowManager.CloseError()`). - -## Layouts - -`AppLayout` is the only router-level layout. It mounts the shared provider stack and renders `<Outlet/>`: - -``` -DialogProvider → StatusProvider → ProfileProvider → DebugBundleProvider → ClientVersionProvider -``` - -- `DialogProvider` is outermost (and outside the daemon gate) so `useConfirm()` works regardless of daemon state. -- `StatusProvider` owns the single `DaemonFeed.Get` + `netbird:status` subscription and **only renders its children when the daemon is reachable** — otherwise it short-circuits to `<DaemonUnavailableOverlay/>`. Consequence: every downstream context can assume the daemon is reachable at mount, so no per-context availability gating. When the daemon flips unavailable the whole subtree unmounts and remounts fresh on return. -- Order matters: `SettingsContext` (mounted in `SettingsPage`) depends on `ProfileContext`; `ClientVersionContext` reads `StatusContext` events. - -`AppRightPanel` (in `layouts/`) is the shared content-panel shell used by the advanced-mode body; it supports an overlay slot (the peer-detail panel slides over it). - -Page-specific chrome and providers live in the page, not the layout: - -- **`MainPage`** (main window only) mounts `ViewModeProvider` (wraps the whole page — both `MainHeader` and `MainBody` read view mode; it calls `Window.SetSize`, so it must not be visible to the Settings window), `NetworksProvider`, and `PeerDetailProvider`. `NavSectionProvider` is mounted **only** inside the advanced-mode branch — default mode has no Peers/Networks tabs and no consumer of `useNavSection`. -- **`SettingsPage`** owns the `wails-draggable` strip at the top (so the macOS traffic-light buttons floating over the frameless window don't overlap content), then renders the vertical tabs. - -## Directory layout (`src/`) - -- `app.tsx` — root render + route table. The canonical registry of every route. Also wires init-time bootstrap (`initLogForwarding`, `welcome`, `initI18n`, `initPlatform`) before first render. -- `layouts/` — `AppLayout.tsx` (the only router-level layout) and `AppRightPanel.tsx` (shared content-panel shell). -- `modules/<feature>/` — each feature owns its folder: a `*Page.tsx` entry where applicable, plus its local components. - - `main/` — `MainPage.tsx`, `MainHeader.tsx`, `MainConnectionStatusSwitch.tsx` (connect toggle + the `startLogin` SSO orchestrator), `MainExitNodeSwitcher.tsx`. - - `main/advanced/` — advanced-mode-only surfaces: `Navigation.tsx` (Peers/Networks tab switch) plus `peers/` (`Peers.tsx`, `PeerDetailPanel.tsx`, `PeerFilters.tsx`) and `networks/` (`Networks.tsx`, `NetworkFilters.tsx`). There is no exit-nodes sub-module — exit-node state lives in `NetworksContext` and the UI is `MainExitNodeSwitcher` (shown in default mode too). - - `settings/` — `SettingsPage.tsx`, `SettingsNavigation.tsx`, `SettingsSection.tsx`, `SettingsSkeleton.tsx`, and the tab files flat (`SettingsGeneral`, `SettingsNetwork`, `SettingsSecurity`, `SettingsSSH`, `SettingsAdvanced`, `SettingsTroubleshooting`, `SettingsAbout`, `SettingsAccent`). The Profiles tab is `modules/profiles/ProfilesTab.tsx`. - - `profiles/` — `ProfileDropdown.tsx` (header), `ProfileCreationModal.tsx`, `ProfilesTab.tsx` (settings table), `ProfileAvatar.tsx`. Context in `contexts/ProfileContext.tsx`. The creation modal collects a profile name + management target (Cloud vs self-hosted + URL, reusing `ManagementServerSwitch` + `useManagementUrl`); `ProfilesTab.handleCreate` adds the profile, `Settings.SetConfig`s the `managementUrl` onto it (keyed by profile name, before switching), then switches. Row actions confirm via `useConfirm()`. - - `welcome/` — `WelcomeDialog.tsx` (orchestrator) + `WelcomeStepTray.tsx`, `WelcomeStepManagement.tsx`. The management step renders only when active profile is `"default"`, the profile email is empty, and the management URL is cloud-default-or-empty (`shouldShowManagementStep`). Self-hosted URL reachability is a soft warning (`useManagementUrl.checkManagementUrlReachable`) — the user can re-click Continue to proceed past a failed check. - - `login/` — `LoginWaitingForBrowserDialog.tsx` (SSO browser-wait window). - - `session/` — `SessionExpirationDialog.tsx`. - - `auto-update/` — `UpdateInProgressDialog.tsx`, `UpdateBadge.tsx`, `UpdateVersionCard.tsx`. Context in `contexts/ClientVersionContext.tsx`. - - `error/` — `ErrorDialog.tsx`. -- `contexts/` — every React context as a flat file: `StatusContext`, `ProfileContext`, `DebugBundleContext`, `ClientVersionContext`, `SettingsContext`, `MdmContext`, `NetworksContext`, `PeerDetailContext`, `ViewModeContext`, `NavSectionContext`, `DialogContext`. Mental model: "where is the X context? `contexts/XContext.tsx`." -- `components/` — presentational primitives, no daemon RPCs, no router: - - `buttons/` — `Button`, `IconButton`. - - `inputs/` — `Input`, `SearchInput`. - - `dialog/` — `Dialog`, `DialogActions`, `DialogDescription`, `DialogHeading`, `ConfirmDialog` (window-based dialog layout primitive), `ConfirmModal` (in-app Radix confirmation, usually driven via `useConfirm()`). - - `switches/` — `SwitchItem`, `SwitchItemGroup`, `ToggleSwitch`, `FancyToggleSwitch`. - - `typography/` — `Label`, `HelpText`. - - `empty-state/` — `EmptyState`, `NoResults`, `NotConnectedState`, `DaemonUnavailableOverlay`. - - Flat at root: `Badge`, `CopyToClipboard`, `DropdownMenu`, `SquareIcon`, `Tooltip`, `TruncatedText`, `VerticalTabs`, `LanguagePicker`, `ManagementServerSwitch`. -- `hooks/` — `useAutoSizeWindow.ts` (auto-size + `Window.Show` for auxiliary dialogs), `useKeyboardShortcut.ts`, `useManagementUrl.ts` (management-URL helpers: `CLOUD_MANAGEMENT_URL`, `isValidManagementUrl`, `normalizeManagementUrl`, `isNetbirdCloud`, `checkManagementUrlReachable`). -- `lib/` — pure utilities (no JSX, no React state): `cn.ts`, `errors.ts` (`formatErrorMessage` + the `errorDialog({Title, Message})` window wrapper), `formatters.ts` (byte/latency/relative-time + `shortenDns`), `sorting.ts` (`reconcileOrder` — order-preserving list reconciliation shared by the peers/networks/profiles lists), `i18n.ts`, `logs.ts` (forwards console + uncaught errors to the Go log pipeline), `platform.ts` (`isMacOS`/`isWindows`), `welcome.ts`. -- `assets/` — fonts, logos, flags. - -## Wails event bus - -Subscribe with `Events.On(name, handler)`; the handler receives `{ data: <typed payload> }`. Event-name strings live next to their usage (no central TS registry). Prefer one subscription at the context level over per-screen — the bus is process-wide and each `Events.On` adds an emit-time fan-out. - -| Event name | Payload | Emitted by | Consumed by | -|---|---|---|---| -| `netbird:status` | `Status` | `services/peers.go` | `StatusContext` (the only subscriber) | -| `netbird:profile:changed` | `ProfileRef` | `services/profileswitcher.go SwitchActive` | `ProfileContext` — refreshes so a tray-initiated switch paints in the UI | -| `netbird:update:state` | `UpdateState` | `services/peers.go fanOutUpdateEvents` + the updater's `progress_window:show` translator | `ClientVersionContext` — single source of truth for `updateAvailable / version / enforced / installing` | -| `netbird:settings:open` | `string` (tab id) | `services/windowmanager.go OpenSettings` (before `Show`) | `SettingsPage` — `setActive(e.data)`. Reset-on-close is the `visibilitychange` listener, not this event. | -| `netbird:preferences:changed` | `{ language }` | Go after `SetLanguage` / `SetViewMode` | `lib/i18n.ts` — calls `i18next.changeLanguage` so a flip from any window paints everywhere | -| `browser-login:cancel` | (none) | `LoginWaitingForBrowserDialog` Cancel button **or** Go on window close | `MainConnectionStatusSwitch`'s `startLogin()` to abort the in-flight `WaitSSOLogin` | -| `trigger-login` | (none) | `services.EventTriggerLogin` (reserved; no Go emitter today) | `MainConnectionStatusSwitch` subscribes and runs `startLogin()` | - -`netbird:event`, `netbird:update:available`, and `netbird:update:progress` are emitted Go-side for the tray but **not** subscribed on the TS side — the UI derives the same info from `useStatus().status.events`. - -## Contexts and state - -State that crosses screens/windows lives in context, each provider mounted exactly once. - -- **`useStatus`** (`StatusContext`) — `{ status, error, refresh, isReady, isDaemonAvailable, isDaemonUnavailable }`. Owns the single `DaemonFeed.Get` + `netbird:status` subscription and the daemon gate (see Layouts). `refresh()` after Connect/Disconnect to dodge a few hundred ms of event-stream lag. -- **`ProfileContext`** — `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` + a `refresh()`. -- **`SettingsContext`** — `setField` / `saveField` / `saveFields` / `saveNow` over `Settings.GetConfig|SetConfig` with 400ms debounce. Renders `<SettingsSkeleton/>` while `config === null`. **PSK mask quirk:** `GetConfig` returns existing PSKs as `"**********"`; sending the mask back round-trips it into storage and `wgtypes.ParseKey` fails on the next connect — `save` drops the field when it equals the mask. -- **`MdmContext`** — `useMdm()` returns `config.managedFields` as `Record<string, boolean>`, **keyed by the daemon's `mdm.Key*` names exactly as written in the policy source** (`managementURL`, `allowServerSSH`, `preSharedKey`, `wireguardPort`, `rosenpassEnabled`/`Permissive`, `disableClientRoutes`/`disableServerRoutes`, `disableAutoConnect`, `blockInbound`). No GUI-side renaming — what the Group Policy admin writes is what the lookup key is. Mounted in `AppLayout` (under `ProfileProvider`); fetches `Settings.GetConfig` once, re-fetches on the daemon's `netbird:event` `metadata.type=config_changed` push so policy flips paint live. No second copy of the locked *values* — MDM is a global override, so the active profile's resolved `useSettings().config.<field>` already carries the MDM-enforced value. Consumers: Settings tabs hide individual toggles/sections (both rosenpass keys managed ⇒ whole encryption section hidden); `mdm.*` fields are **is-managed flags** — `true` when the field name appears in `MDMManagedFields`, `false` otherwise. Consumers check truthiness (`!mdm.x` to mean "not managed"). The *resolved value* of a managed field is already enforced into `Config`, so consumers read it via `useSettings().config.<field>` and hide the toggle when `mdm.<field>` is true. **Three carve-outs** that carry the resolved value instead, populated outside the reflective is-managed loop in `services/settings.go` `GetRestrictions`: `mdm.managementURL` (string — used by `ProfileCreationModal` / `WelcomeDialog`, which need the URL to render the form); `mdm.allowServerSSH` (`*bool` / `boolean | null` — used by `SettingsNavigation` + `SettingsPage` to gate the SSH tab on `mdm.allowServerSSH ?? !features.disableUpdateSettings`; tri-state needed because the tab gate lives outside `SettingsProvider` and can't read `config.serverSshAllowed`, and MDM-managed-with-value-`false` must hide the tab — falsy fallthrough would leak it); and `mdm.disableAdvancedView` (`bool` — MDM-only on the daemon, no CLI fallback; plumbed via the `GetFeatures` RPC rather than `MDMManagedFields`, so set directly from `featResp.GetDisableAdvancedView()`. Value semantic: `true` iff MDM explicitly disables; the nil/false collapse is fine because both mean "advanced view available"). The three `features.*` gates (`disableProfiles`/`Networks`/`UpdateSettings`) live in `Features` because they accept a CLI-flag fallback in addition to MDM (`--disable-profiles` etc.) — `disableAdvancedView` doesn't, which is why it sits with the MDM-only carve-outs. `ProfileCreationModal` skips the Cloud/self-hosted picker when `managed.managementURL` is set and submits the resolved URL verbatim; `WelcomeDialog` reads `config.managedFields.managementURL` directly (sits outside `AppLayout`) to skip the management step on a fresh install. -- **`DebugBundleContext`** — stages `idle → preparing-trace → reconnecting → capturing → restoring-level → bundling → uploading → done`. Cancellable via `AbortController` at any stage; cancel restores the original log level best-effort. Upload URL is the hardcoded `NETBIRD_UPLOAD_URL`. -- **`ClientVersionContext`** — seeds from `Update.GetState()`, subscribes to `netbird:update:state`; exposes `{ updateAvailable, updateVersion, enforced, installing, triggerUpdate, updating }`. Three branches: - 1. `available && !enforced` — download-only; `UpdateVersionCard` → opens GitHub releases. - 2. `available && enforced && !installing` — user-driven; `triggerUpdate` opens the install-progress window then calls `Update.Trigger()`. - 3. `available && enforced && installing` — daemon already installing; the flip auto-opens the install-progress window. -- **`NetworksContext`** — routed networks + exit nodes derived from `status.networks`; optimistic overrides for instant toggle feedback. **`PeerDetailContext`** — which peer detail panel is open in advanced view. **`NavSectionContext`** — the advanced-mode Peers/Networks tab selection. - -### View mode + no client-side persistence - -`ViewModeProvider` (`contexts/ViewModeContext.tsx`, mounted in `MainPage`) owns `viewMode: "default" | "advanced"`, consumed via `useViewMode()`. `setViewMode` updates state, calls `Window.SetSize(width, <live frame height>)`, and persists via `Preferences.SetViewMode`. Widths live in `VIEW_WIDTH`: Default 380, Advanced 900. **The height is intentionally not asserted** — we read the current frame height via `Window.Size()` and pass it back, because Wails' macOS `windowSetSize` is `setFrame:` (frame, incl. ~28px title bar) while initial `windowNew` uses `initWithContentRect:` (content). Passing a constant would chop ~28px off the content area on the first switch. `main.go` opens the window at the saved width so there's no 380→900 flash on launch; the provider hydrates from `Preferences.Get()` on mount without triggering a resize. - -**No `localStorage` / `sessionStorage` / cookies anywhere** — persistence is the Go side's job: settings → `SetConfig`, language → `Preferences.SetLanguage`, view mode → `Preferences.SetViewMode`. - -## Localisation (i18n) - -Bootstrap in `src/lib/i18n.ts`, awaited before render in `app.tsx`. It reads the current language from `Preferences.Get()`, glob-imports every bundle from the shared tree at `client/ui/i18n/locales/` (sibling of the Go i18n package — same JSON drives both tray and React), inits i18next with `fallbackLng: "en"` and `interpolation: { prefix: "{", suffix: "}" }`, and subscribes to `netbird:preferences:changed` so a flip from any window calls `i18next.changeLanguage` here. - -**First-run browser-language detection.** When no preferences file exists, `Preferences.Get()` returns `language: ""` (the Go "unset" signal). `initI18n` walks `navigator.language` + `navigator.languages`, lowercases each, and picks the first base code (`de` from `de-DE`) with a shipped bundle — then `Preferences.SetLanguage(detected)` fire-and-forget so the next launch reads it back. No match (or store unreachable) falls through to `en`. From the second launch the persisted value wins. - -**Usage.** Default to the hook: - -```ts -import { useTranslation } from "react-i18next"; -const { t } = useTranslation(); -t("settings.tabs.general"); -t("update.card.versionAvailable", { version: updateVersion }); // placeholders -``` - -Outside React (module-scope event handlers, error titles) import the instance directly: `import i18next from "@/lib/i18n"`. - -**Bundle files.** Keys live in `client/ui/i18n/locales/<code>/common.json` in Chrome-extension JSON shape: each key maps to `{ "message": "...", "description": "..." }`. `description` is translator context for Crowdin (read from the source file, ignored at runtime) — only `en/common.json` carries descriptions; target bundles carry just `message`. `lib/i18n.ts` strips each entry to its `message` when building the i18next `resources`, so `t()` lookups are unchanged. Placeholders use single braces: `"Install version {version}"`. Add a key to `en/common.json` first (the fallback), then to every other locale. Missing keys fall back to English, then to the key itself (so the gap is visible in the UI). - -**Translating bundles.** `client/ui/i18n/TRANSLATING.md` is the authoritative brief for actually producing or reviewing a translation — written for any translator (human or AI agent). It carries the product context, the file-format rules, the placeholder/`\n`/plural constraints (the app has only a one/other plural split — no ICU rules), the per-language do-vs-don't-translate glossary (e.g. "Exit Node" stays English in de/hu but is translated in ru/es/fr/it/pt/zh), and the new-language + review procedures. Read it before adding or editing any locale; keep its glossary/procedures current when conventions change. - -**Adding a language.** Drop `client/ui/i18n/locales/<code>/common.json` (follow `TRANSLATING.md`) and append the row to `_index.json`. No flag asset is needed — `LanguagePicker.tsx` deliberately ships no flags ("flags represent countries, not languages"). `lib/i18n.ts` discovers bundles via `import.meta.glob('../../../i18n/locales/*/common.json', { eager: true })` (the tree lives outside `frontend/`, so `vite.config.ts` whitelists the parent dir under `server.fs.allow`) — no code change needed to wire a new locale. - -**What gets translated.** Every user-facing string. Don't add hard-coded English — add the key, then `t()`. Internal log strings and the `Update failed` fallback fed into `classifyError()` are not translated. - -## Login flow (`startLogin` in `MainConnectionStatusSwitch.tsx`) - -The SSO flow is a module-level `startLogin()` with a `loginInFlight` guard so a double-click can't fire two concurrent flows. Sequence: - -1. `Connection.Login({})` with empty fields — Go fills in active profile + OS user. -2. If SSO is needed (`needsSsoLogin`): - - `WindowManager.OpenBrowserLogin(uri)` opens the sign-in popup (hidden until React mounts and `useAutoSizeWindow` calls `Window.Show`). - - The dialog fires `Connection.OpenURL(uri)` from its mount effect (done from the dialog, not `startLogin`, so the browser doesn't race the still-hidden popup). - - `Promise.race(WaitSSOLogin, browser-login:cancel)`. - - On cancel: cancel the in-flight `WaitSSOLogin` gRPC so the daemon drops the abandoned device code. -3. `Connection.Up({})` to bring the new session up. - -`onSettled` (releasing the caller's React-level guard) fires the instant the flow ends — **before** the error dialog — never gated on the dialog. Errors that aren't cancellations surface via `errorDialog`. This is the only SSO entry point; there's no `/login` route — wire any new SSO trigger through here. - -## Dialogs convention - -**Errors → `errorDialog({Title, Message})` from `src/lib/errors.ts`** (which also exports `formatErrorMessage`), never `Dialogs.*` from `@wailsio/runtime`. Despite the name it opens the custom always-on-top `/#/dialog/error` window via `WindowManager.OpenError` (`modules/error/ErrorDialog.tsx`), not a native OS box. Use an action-named title ("Save Settings Failed", not "Error"). Title/message must already be localised. **`errorDialog()` resolves as soon as the window opens — it does not block until dismissed.** - -For **confirmations**, use `useConfirm()` from `contexts/DialogContext.tsx` — `const ok = await confirm({ title, description, confirmLabel, danger? })` resolves to a boolean. It renders a single shared `ConfirmModal` mounted at the provider level. Used by the Profiles tab and the management-server cloud switch. - -**Skip dialogs entirely** for inline form validation, transient link errors on the dashboard, and "partial success" notes inside an otherwise-OK flow. Full rationale in `../CLAUDE.md`. - -## Tailwind tokens - -Defined in `tailwind.config.ts`. `nb-gray` is the neutral palette (background `nb-gray-950`); `netbird` is brand orange (`#f68330`). New code uses `nb-gray` + `netbird` + semantic dot colors (`green-500`, `red-500`, `yellow-500`). `bg-conic-netbird` and the `pulse-reverse` / `spin-slow` / `ping-slow` keyframes are used only by the connect toggle. Fonts: Inter Variable (sans) + JetBrains Mono Variable (mono), under `src/assets/fonts/`. - -## Wails-specific quirks - -- **Window dragging.** Class `wails-draggable` on regions that should drag the OS window (headers, the Settings title strip, dialog wrappers). `wails-no-draggable` on interactive children inside a draggable region (buttons, inputs) — otherwise the drag swallows their click. -- **Webview asset access.** Reference assets through Vite: `import url from "@/assets/.../foo.svg"`. Absolute filesystem paths don't work in dev or prod. -- **`Window.SetSize(w, h)`.** Called from `ViewModeContext`'s `setViewMode`. Height is read fresh from `Window.Size()` and re-passed — see the View mode section for why a constant would shrink the content area. -- **Main-window width.** Windows uses a slightly narrower content width than macOS to compensate for the OS frame Wails counts differently (`MainPage` → `isWindows() ? 364 : 380`; see wails/wails#3260). -- **`Browser.OpenURL(url)`.** Used by `SettingsAbout` (legal links) and the BrowserLogin "Try again". `SettingsAbout` has a `window.open` fallback for when Wails refuses (non-http schemes are rejected). - -## Useful references - -- `WAILS-API.md` (sibling) — full per-service binding signatures, push-event payloads, and model field shapes. Every method returns `$CancellablePromise<T>` (`await` and ignore `.cancel()` in practice). Regenerate via `pnpm bindings` after any Go-side change. -- Wails v3 dialog signatures: `node_modules/@wailsio/runtime/types/dialogs.d.ts`. -- Wails v3 docs (may 403 from some clients): https://v3.wails.io/ -- `../CLAUDE.md` — Go-side conventions, service registration, profile-switching policy, auxiliary-window lifecycle, Linux tray internals. diff --git a/client/ui/i18n/TRANSLATING.md b/client/ui/i18n/TRANSLATING.md index bcf29ce83..88cbb8b11 100644 --- a/client/ui/i18n/TRANSLATING.md +++ b/client/ui/i18n/TRANSLATING.md @@ -130,4 +130,4 @@ A few habits that keep a bundle reading like one product rather than a word-for- A bundle can pass every check above and still read wrong on screen. **Run the app, switch to your language, and click through the real surfaces** — tray menu, main window, every Settings tab, the dialogs. Watch for text overflow or truncation, labels that are technically right but wrong *for what the control does*, leaked placeholders, and terms that drift between screens. -How to run the app and switch language: see `../CLAUDE.md` and `../frontend/CLAUDE.md`. Can't run it (e.g. a headless agent)? Say so in your summary — don't silently skip this step. +How to run the app and switch language: see the project README. Can't run it (e.g. a headless agent)? Say so in your summary — don't silently skip this step. diff --git a/client/ui/scripts/mdm-fields.md b/client/ui/scripts/mdm-fields.md deleted file mode 100644 index 02a5b51ed..000000000 --- a/client/ui/scripts/mdm-fields.md +++ /dev/null @@ -1,41 +0,0 @@ -# MDM dev cheatsheet - -Two platform helpers, same field set. The daemon's MDM ticker picks up changes -within ≤60s and restarts the engine in-process — no service restart needed. - -## macOS — `mdm-toggle.sh` - -Edits `/Library/Managed Preferences/io.netbird.client.plist`. - -```bash -./mdm-toggle.sh tui -``` - -Keys: number to edit a field, **k** to kick the daemon, **c** to clear the -plist, **l** to view recent MDM log lines, **r** to refresh, **q** to quit. - -## Windows — `mdm-toggle.ps1` - -**▶ [Launch the TUI](mdm-tui.bat)** (double-click `mdm-tui.bat` — it self-elevates). - -Edits `HKLM\Software\Policies\NetBird` (the key Group Policy / Intune ADMX / -a Registry CSP profile would populate — see `client/mdm/policy_windows.go`). -Self-elevates (HKLM writes need admin). Or run it directly: - -```powershell -.\mdm-toggle.ps1 -``` - -Same keys as the macOS TUI: number to edit a field, **k** to kick the daemon, -**c** to clear the policy key, **r** to refresh, **q** to quit. Registry type -mapping (mirrors `readRegistryValue`): `string → REG_SZ`, `integer → REG_DWORD`, -`bool → REG_DWORD` (1=true / 0=false), `array → REG_MULTI_SZ`. Value names are -case-insensitive (the loader canonicalises against the known key set); -service: `Netbird`. - -## Known keys - -- Strings/ints: `managementURL`, `preSharedKey`, `wireguardPort`, `splitTunnelMode` (`allow`/`disallow`) -- Array: `splitTunnelApps` -- Bools: `rosenpassEnabled`, `rosenpassPermissive`, `disableClientRoutes`, `disableServerRoutes`, `allowServerSSH`, `disableAutoConnect`, `blockInbound`, `disableMetricsCollection` -- Feature gates: `disableAdvancedView`, `disableProfiles`, `disableNetworks`, `disableUpdateSettings` (true = hide; false/unset = allow) diff --git a/client/ui/scripts/mdm-toggle.ps1 b/client/ui/scripts/mdm-toggle.ps1 deleted file mode 100644 index 2d00cdab0..000000000 --- a/client/ui/scripts/mdm-toggle.ps1 +++ /dev/null @@ -1,196 +0,0 @@ -<# -.SYNOPSIS - Interactive tester for NetBird's MDM policy on Windows. - -.DESCRIPTION - Edits HKLM\Software\Policies\NetBird (the same registry key the OS would - populate from Group Policy / Intune ADMX ingestion / a Registry CSP profile, - see client/mdm/policy_windows.go). The MDM ticker re-reads the key within - <=60s; the daemon restarts the engine itself in-process - no service restart - needed for a change to take effect (the [k] kick only forces an immediate - re-read or recovers a wedged daemon). - - Value-type mapping (mirrors readRegistryValue): - string -> REG_SZ - integer -> REG_DWORD - bool -> REG_DWORD (1=true, 0=false) GetBool treats !=0 as true - array -> REG_MULTI_SZ - - Requires elevation (writing HKLM). Re-launches itself elevated if needed. - - Keys: number to edit a field, [k] kick the daemon, [c] clear the whole - policy key, [r] refresh, [q] quit. -#> -[CmdletBinding()] -param() - -$ErrorActionPreference = 'Stop' - -$PolicyPath = 'HKLM:\Software\Policies\NetBird' - -# Known keys and their registry types. Order drives the TUI numbering. -$Fields = @( - @{ Key = 'managementURL'; Type = 'string' } - @{ Key = 'preSharedKey'; Type = 'string' } - @{ Key = 'wireguardPort'; Type = 'integer' } - @{ Key = 'splitTunnelMode'; Type = 'string' } # allow / disallow - @{ Key = 'splitTunnelApps'; Type = 'array' } - @{ Key = 'rosenpassEnabled'; Type = 'bool' } - @{ Key = 'rosenpassPermissive'; Type = 'bool' } - @{ Key = 'disableClientRoutes'; Type = 'bool' } - @{ Key = 'disableServerRoutes'; Type = 'bool' } - @{ Key = 'allowServerSSH'; Type = 'bool' } - @{ Key = 'disableAutoConnect'; Type = 'bool' } - @{ Key = 'blockInbound'; Type = 'bool' } - @{ Key = 'disableMetricsCollection'; Type = 'bool' } - @{ Key = 'disableAdvancedView'; Type = 'bool' } - @{ Key = 'disableProfiles'; Type = 'bool' } - @{ Key = 'disableNetworks'; Type = 'bool' } - @{ Key = 'disableUpdateSettings'; Type = 'bool' } -) - -# ---- elevation -------------------------------------------------------------- - -function Test-Admin { - $id = [Security.Principal.WindowsIdentity]::GetCurrent() - (New-Object Security.Principal.WindowsPrincipal $id).IsInRole( - [Security.Principal.WindowsBuiltinRole]::Administrator) -} - -if (-not (Test-Admin)) { - Write-Host 'Elevation required for HKLM writes - relaunching as admin...' -ForegroundColor Yellow - Start-Process -FilePath 'powershell.exe' -Verb RunAs -ArgumentList @( - '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', "`"$PSCommandPath`"") - exit 0 -} - -# ---- ANSI helpers (VT; conhost on Win10+ and Windows Terminal support it) ---- - -$IsTty = -not [Console]::IsOutputRedirected -$e = [char]27 -function C($code) { if ($IsTty) { "${e}[${code}m" } else { '' } } -$Reset = C '0'; $Bold = C '1'; $Dim = C '2' -$Green = C '32'; $Red = C '31'; $Yellow = C '33'; $Cyan = C '36' - -# ---- service ---------------------------------------------------------------- - -function Restart-Daemon { - $svc = Get-Service -Name 'Netbird', 'netbird' -ErrorAction SilentlyContinue | Select-Object -First 1 - if (-not $svc) { Write-Host 'Netbird service not found.' -ForegroundColor Red; return } - Write-Host "restarting service ($($svc.Name))..." - Restart-Service -Name $svc.Name -Force -} - -# ---- registry read/write ---------------------------------------------------- - -function Ensure-PolicyKey { - if (-not (Test-Path $PolicyPath)) { New-Item -Path $PolicyPath -Force | Out-Null } -} - -function Read-Value { - param([string]$Key) - if (-not (Test-Path $PolicyPath)) { return $null } - $item = Get-ItemProperty -Path $PolicyPath -Name $Key -ErrorAction SilentlyContinue - if ($null -eq $item) { return $null } - return $item.$Key -} - -function Set-Value { - param([string]$Key, [string]$Type, $Value) - Ensure-PolicyKey - switch ($Type) { - 'array' { Set-ItemProperty -Path $PolicyPath -Name $Key -Value ([string[]]$Value) -Type MultiString } - 'integer' { Set-ItemProperty -Path $PolicyPath -Name $Key -Value ([int]$Value) -Type DWord } - 'bool' { Set-ItemProperty -Path $PolicyPath -Name $Key -Value ([int]$Value) -Type DWord } - default { Set-ItemProperty -Path $PolicyPath -Name $Key -Value ([string]$Value) -Type String } - } -} - -function Clear-Value { - param([string]$Key) - if (Test-Path $PolicyPath) { - Remove-ItemProperty -Path $PolicyPath -Name $Key -ErrorAction SilentlyContinue - } -} - -function Clear-PolicyKey { - if (Test-Path $PolicyPath) { - Remove-Item -Path $PolicyPath -Recurse -Force - Write-Host "removed $PolicyPath" - } else { - Write-Host '(already absent)' - } -} - -# ---- TUI -------------------------------------------------------------------- - -function Render-Value { - param($Raw, [string]$Type) - if ($null -eq $Raw -or ($Raw -is [string] -and $Raw -eq '')) { - return "$Dim-$Reset" - } - switch ($Type) { - 'bool' { - if ([int]$Raw -ne 0) { "${Green}[+] true$Reset" } else { "${Red}[-] false$Reset" } - } - 'array' { "${Cyan}[$($Raw -join ' ')]$Reset" } - default { "$Yellow$Raw$Reset" } - } -} - -function Render-Screen { - Clear-Host - Write-Host "$Bold==== NetBird MDM Tester (Windows) ====$Reset" - Write-Host "${Dim}key:$Reset $PolicyPath`n" - for ($i = 0; $i -lt $Fields.Count; $i++) { - $f = $Fields[$i] - $raw = Read-Value $f.Key - $num = ('[{0,2}]' -f ($i + 1)) - $val = Render-Value $raw $f.Type - Write-Host (" $Bold$num$Reset {0,-30} {1} $Dim({2})$Reset" -f $f.Key, $val, $f.Type) - } - Write-Host '' - Write-Host " ${Bold}[k]$Reset kick daemon ${Bold}[c]$Reset clear key ${Bold}[r]$Reset refresh ${Bold}[q]$Reset quit" - Write-Host '' -} - -function Edit-Field { - param([int]$Index) - if ($Index -lt 1 -or $Index -gt $Fields.Count) { Write-Host ' (out of range)'; Start-Sleep -Milliseconds 600; return } - $f = $Fields[$Index - 1] - $cur = Read-Value $f.Key - switch ($f.Type) { - 'bool' { - # Cycle: unset -> true -> false -> unset - if ($null -eq $cur) { Set-Value $f.Key 'bool' 1 } - elseif ([int]$cur -ne 0) { Set-Value $f.Key 'bool' 0 } - else { Clear-Value $f.Key } - } - 'array' { - Write-Host " current: $($cur -join ' ')" - $line = Read-Host ' new array (space-separated, empty to unset)' - if ([string]::IsNullOrWhiteSpace($line)) { Clear-Value $f.Key } - else { Set-Value $f.Key 'array' ($line -split '\s+' | Where-Object { $_ }) } - } - default { - Write-Host " current: $cur" - $line = Read-Host " new $($f.Type) (empty to unset)" - if ([string]::IsNullOrWhiteSpace($line)) { Clear-Value $f.Key } - else { Set-Value $f.Key $f.Type $line } - } - } -} - -Write-Host 'starting MDM tester (q to quit)' -while ($true) { - Render-Screen - $choice = Read-Host '>' - switch -Regex ($choice) { - '^(q|quit|exit)$' { Write-Host 'bye'; return } - '^(k|kick)$' { Restart-Daemon; Start-Sleep -Milliseconds 600 } - '^(c|clear)$' { Clear-PolicyKey; Start-Sleep -Milliseconds 600 } - '^(r|)$' { } # redraw - '^\d+$' { Edit-Field ([int]$choice) } - default { Write-Host ' (unknown - number to edit, k/c/r/q)'; Start-Sleep -Milliseconds 600 } - } -} diff --git a/client/ui/scripts/mdm-toggle.sh b/client/ui/scripts/mdm-toggle.sh deleted file mode 100755 index 114b7b185..000000000 --- a/client/ui/scripts/mdm-toggle.sh +++ /dev/null @@ -1,253 +0,0 @@ -#!/usr/bin/env bash -# -# Dev helper for testing NetBird's MDM policy on macOS. -# -# Edits /Library/Managed Preferences/io.netbird.client.plist (the same path the -# OS would populate from a real Configuration Profile). The MDM ticker observes -# the change within ≤60s; the daemon restarts the engine itself in-process. -# -# Usage: -# ./mdm-toggle.sh tui # interactive terminal UI -# ./mdm-toggle.sh show # print current plist -# ./mdm-toggle.sh clear # wipe (delete) the plist -# ./mdm-toggle.sh restart # kick the daemon -# ./mdm-toggle.sh logs [-f] # tail daemon log filtered for MDM lines -# ./mdm-toggle.sh verify # plist + recent MDM log lines -# -# Known keys (see client/mdm/policy.go): -# managementURL preSharedKey wireguardPort rosenpassEnabled rosenpassPermissive -# disableClientRoutes disableServerRoutes allowServerSSH disableAutoConnect -# blockInbound disableMetricsCollection splitTunnelMode splitTunnelApps -# disableProfiles disableNetworks disableUpdateSettings disableAdvancedView - -set -euo pipefail - -PLIST="/Library/Managed Preferences/io.netbird.client.plist" -LAUNCHD_LABEL="system/netbird" -DAEMON_LOG="/var/log/netbird/client.log" -LOG_GREP='MDM|mdm|policy|engine restart|config_changed|managed|RegisterUILog' - -die() { echo "error: $*" >&2; exit 1; } - -require_sudo() { - if [[ $EUID -ne 0 ]]; then - exec sudo --preserve-env=HOME "$0" "$@" - fi -} - -ensure_plist() { - if [[ ! -f "$PLIST" ]]; then - mkdir -p "$(dirname "$PLIST")" - /usr/libexec/PlistBuddy -c "Save" "$PLIST" >/dev/null 2>&1 || \ - /bin/cat >"$PLIST" <<'EOF' -<?xml version="1.0" encoding="UTF-8"?> -<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> -<plist version="1.0"><dict/></plist> -EOF - chown root:wheel "$PLIST" - chmod 0644 "$PLIST" - fi -} - -restart_daemon() { - echo "kicking daemon ($LAUNCHD_LABEL)..." - launchctl kickstart -k "$LAUNCHD_LABEL" -} - -cmd_show() { - if [[ ! -f "$PLIST" ]]; then - echo "(no plist at $PLIST — MDM not active)" - return - fi - echo "# $PLIST" - /usr/libexec/PlistBuddy -c "Print" "$PLIST" -} - -cmd_clear() { - if [[ -f "$PLIST" ]]; then - rm -f "$PLIST" - echo "removed $PLIST" - else - echo "(already absent)" - fi -} - -cmd_logs() { - local follow=0 - [[ "${1:-}" == "-f" || "${1:-}" == "--follow" ]] && follow=1 - [[ -f "$DAEMON_LOG" ]] || die "daemon log not found at $DAEMON_LOG" - if [[ $follow -eq 1 ]]; then - echo "# tailing $DAEMON_LOG (Ctrl-C to stop) — filter: $LOG_GREP" - tail -F "$DAEMON_LOG" | grep -E --line-buffered --color=always "$LOG_GREP" - else - echo "# last MDM-relevant lines from $DAEMON_LOG" - grep -E --color=always "$LOG_GREP" "$DAEMON_LOG" | tail -30 - fi -} - -cmd_verify() { - echo "=== plist ===" - if [[ -f "$PLIST" ]]; then - /usr/libexec/PlistBuddy -c "Print" "$PLIST" - else - echo "(no plist at $PLIST — MDM not active)" - fi - echo - echo "=== daemon log (last 20 MDM-relevant lines) ===" - if [[ -f "$DAEMON_LOG" ]]; then - grep -E --color=always "$LOG_GREP" "$DAEMON_LOG" | tail -20 \ - || echo "(no MDM-related entries yet — wait up to 60s for next ticker fire)" - else - echo "(daemon log not found at $DAEMON_LOG)" - fi -} - -cmd_tui() { - local KEYS=( - managementURL preSharedKey wireguardPort splitTunnelMode splitTunnelApps - rosenpassEnabled rosenpassPermissive disableClientRoutes disableServerRoutes - allowServerSSH disableAutoConnect blockInbound disableMetricsCollection - disableAdvancedView disableProfiles disableNetworks disableUpdateSettings - ) - local TYPES=( - string string integer string array - bool bool bool bool - bool bool bool bool - bool bool bool bool - ) - - # ANSI helpers; bail to plain text when stdout isn't a tty. - local C_RESET="" C_BOLD="" C_DIM="" C_GREEN="" C_RED="" C_YELLOW="" C_CYAN="" - if [[ -t 1 ]]; then - C_RESET=$'\033[0m'; C_BOLD=$'\033[1m'; C_DIM=$'\033[2m' - C_GREEN=$'\033[32m'; C_RED=$'\033[31m'; C_YELLOW=$'\033[33m'; C_CYAN=$'\033[36m' - fi - - read_value() { - local key="$1" - [[ -f "$PLIST" ]] || { echo ""; return; } - /usr/libexec/PlistBuddy -c "Print :$key" "$PLIST" 2>/dev/null || true - } - - render_value() { - local raw="$1" type="$2" - if [[ -z "$raw" ]]; then - printf '%s—%s' "$C_DIM" "$C_RESET" - return - fi - case "$type" in - bool) - if [[ "$raw" == "true" ]]; then - printf '%s✔ true%s' "$C_GREEN" "$C_RESET" - else - printf '%s✘ false%s' "$C_RED" "$C_RESET" - fi ;; - array) - printf '%s[%s]%s' "$C_CYAN" "$(echo "$raw" | tr '\n' ' ' | tr -s ' ')" "$C_RESET" ;; - *) - printf '%s%s%s' "$C_YELLOW" "$raw" "$C_RESET" ;; - esac - } - - render_screen() { - clear - printf '%s════ NetBird MDM Tester ════%s\n' "$C_BOLD" "$C_RESET" - printf '%splist:%s %s %sdaemon log:%s %s\n\n' \ - "$C_DIM" "$C_RESET" "$PLIST" "$C_DIM" "$C_RESET" "$DAEMON_LOG" - local i - for i in "${!KEYS[@]}"; do - local key="${KEYS[$i]}" type="${TYPES[$i]}" raw - raw="$(read_value "$key")" - printf ' %s[%2d]%s %-30s %s%s(%s)%s\n' \ - "$C_BOLD" $((i+1)) "$C_RESET" "$key" \ - "$(render_value "$raw" "$type")" \ - "$C_DIM" "$type" "$C_RESET" - done - printf '\n' - printf ' %s[k]%s kick daemon %s[c]%s clear plist %s[l]%s show recent log %s[r]%s refresh %s[q]%s quit\n' \ - "$C_BOLD" "$C_RESET" "$C_BOLD" "$C_RESET" "$C_BOLD" "$C_RESET" "$C_BOLD" "$C_RESET" "$C_BOLD" "$C_RESET" - printf '\n' - } - - edit_field() { - local idx="$1" - if ! [[ "$idx" =~ ^[0-9]+$ ]] || (( idx < 1 || idx > ${#KEYS[@]} )); then - echo " (out of range)"; sleep 0.6; return - fi - local key="${KEYS[$((idx-1))]}" type="${TYPES[$((idx-1))]}" cur - cur="$(read_value "$key")" - ensure_plist - case "$type" in - bool) - # Cycle: unset → true → false → unset - case "$cur" in - "") /usr/libexec/PlistBuddy -c "Add :$key bool true" "$PLIST" ;; - "true") /usr/libexec/PlistBuddy -c "Delete :$key" "$PLIST" - /usr/libexec/PlistBuddy -c "Add :$key bool false" "$PLIST" ;; - "false") /usr/libexec/PlistBuddy -c "Delete :$key" "$PLIST" ;; - esac ;; - array) - echo " current: $cur" - read -r -p " new array (space-separated, empty to unset): " line - /usr/libexec/PlistBuddy -c "Delete :$key" "$PLIST" 2>/dev/null || true - if [[ -n "$line" ]]; then - /usr/libexec/PlistBuddy -c "Add :$key array" "$PLIST" - local i=0 v - for v in $line; do - /usr/libexec/PlistBuddy -c "Add :$key:$i string $v" "$PLIST" - i=$((i+1)) - done - fi ;; - *) - echo " current: $cur" - read -r -p " new $type (empty to unset): " line - /usr/libexec/PlistBuddy -c "Delete :$key" "$PLIST" 2>/dev/null || true - if [[ -n "$line" ]]; then - /usr/libexec/PlistBuddy -c "Add :$key $type $line" "$PLIST" - fi ;; - esac - } - - show_recent_log() { - clear - printf '%s── recent MDM-related daemon log lines ──%s\n' "$C_BOLD" "$C_RESET" - if [[ -f "$DAEMON_LOG" ]]; then - grep -E --color=always "$LOG_GREP" "$DAEMON_LOG" | tail -25 \ - || echo "(no MDM-related entries yet)" - else - echo "(daemon log not found at $DAEMON_LOG)" - fi - echo - read -r -p "press enter to return " _ - } - - echo "starting MDM tester (Ctrl-C or q to quit)" - while true; do - render_screen - read -r -p "> " choice || break - case "$choice" in - q|quit|exit) break ;; - k|kick) restart_daemon; sleep 0.6 ;; - c|clear) cmd_clear; sleep 0.6 ;; - l|logs) show_recent_log ;; - r|"") : ;; # redraw - *) edit_field "$choice" ;; - esac - done - echo "bye" -} - -sub="${1:-}"; shift || true - -case "$sub" in - show) cmd_show "$@"; exit 0 ;; - logs) require_sudo "$sub" "$@"; cmd_logs "$@"; exit 0 ;; - verify) require_sudo "$sub" "$@"; cmd_verify "$@"; exit 0 ;; - tui) require_sudo "$sub" "$@"; cmd_tui "$@"; exit 0 ;; - restart) require_sudo "$sub" "$@"; restart_daemon; exit 0 ;; - clear) require_sudo "$sub" "$@"; cmd_clear "$@"; exit 0 ;; - ""|-h|--help|help) - sed -n '2,18p' "$0" - ;; - *) die "unknown subcommand: $sub (try --help)" ;; -esac diff --git a/client/ui/scripts/mdm-tui.bat b/client/ui/scripts/mdm-tui.bat deleted file mode 100644 index d3fc49965..000000000 --- a/client/ui/scripts/mdm-tui.bat +++ /dev/null @@ -1,4 +0,0 @@ -@echo off -REM Double-click launcher for the NetBird MDM tester (Windows). -REM The .ps1 self-elevates. -powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0mdm-toggle.ps1"