-
Settings
+
);
};
diff --git a/client/ui-wails/frontend/src/modules/settings/SettingsNavigation.tsx b/client/ui-wails/frontend/src/modules/settings/SettingsNavigation.tsx
new file mode 100644
index 000000000..f2079b715
--- /dev/null
+++ b/client/ui-wails/frontend/src/modules/settings/SettingsNavigation.tsx
@@ -0,0 +1,53 @@
+import { NavItem } from "@/components/NavItem.tsx";
+import {
+ InfoIcon,
+ LifeBuoyIcon,
+ NetworkIcon,
+ SlidersHorizontalIcon,
+ TerminalIcon,
+} from "lucide-react";
+
+export type SettingsSection =
+ | "general"
+ | "network"
+ | "ssh"
+ | "troubleshooting"
+ | "about";
+
+type Props = {
+ active: SettingsSection;
+ onChange: (section: SettingsSection) => void;
+};
+
+const ITEMS: {
+ id: SettingsSection;
+ icon: typeof SlidersHorizontalIcon;
+ title: string;
+}[] = [
+ { id: "general", icon: SlidersHorizontalIcon, title: "General" },
+ { id: "network", icon: NetworkIcon, title: "Network" },
+ { id: "ssh", icon: TerminalIcon, title: "SSH" },
+ { id: "troubleshooting", icon: LifeBuoyIcon, title: "Troubleshooting" },
+ { id: "about", icon: InfoIcon, title: "About" },
+];
+
+export const SettingsNavigation = ({ active, onChange }: Props) => {
+ return (
+
+ );
+};
diff --git a/client/ui-wails/frontend/src/screens/Main.tsx b/client/ui-wails/frontend/src/screens/Main.tsx
deleted file mode 100644
index 965258372..000000000
--- a/client/ui-wails/frontend/src/screens/Main.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import { useState } from "react";
-import { MainLeftSide, MainModule } from "@/layouts/MainLeftSide.tsx";
-import { MainRightSide } from "@/layouts/MainRightSide.tsx";
-import { Peers } from "@/modules/peers/Peers.tsx";
-import { Settings } from "@/modules/settings/Settings.tsx";
-
-type Props = {
-
-};
-export const Main = ({}: Props) => {
- const [active, setActive] = useState
("peers");
-
- return (
-
-
-
-
- {active === "peers" ? : }
-
-
-
- );
-};
diff --git a/client/ui-wails/frontend/wails-go-api (1).md b/client/ui-wails/frontend/wails-go-api (1).md
new file mode 100644
index 000000000..54c7084ad
--- /dev/null
+++ b/client/ui-wails/frontend/wails-go-api (1).md
@@ -0,0 +1,278 @@
+# Wails Go API surface for the React frontend
+
+All bindings live under `frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/`. Import them as:
+
+```ts
+import { Connection, Peers, Networks, Settings, Profiles, Debug, Update, Forwarding } from "./bindings/github.com/netbirdio/netbird/client/ui-wails/services";
+import * as $models from "./bindings/github.com/netbirdio/netbird/client/ui-wails/services/models";
+```
+
+Every method returns `$CancellablePromise` (Wails3 wrapper around a Promise — call `.cancel()` to abort the underlying gRPC stream / call).
+
+## Push events
+
+Subscribe with the Wails event API: `import { Events } from "@wailsio/runtime"`.
+
+| Event name | Payload type | Fires on |
+|---|---|---|
+| `netbird:status` | `Status` | Daemon connection-state change (Connected / Connecting / Disconnected / Idle), peer-list change, address change, management/signal flip. **Replaces polling**. |
+| `netbird:event` | `SystemEvent` | One push per daemon-emitted event (DNS/network/auth/connectivity/system). Drives toasts and the event log. |
+| `netbird:update:available` | `UpdateAvailable` | Daemon detected a new version. Show the update menu/banner. |
+| `netbird:update:progress` | `UpdateProgress` | `action:"show"` → open the update progress page; `action:"hide"` → close. |
+
+Calling `Peers.Watch()` once at boot starts both backend stream loops; both self-restart with backoff on errors.
+
+## Connection lifecycle — `Connection`
+
+```ts
+Connection.Up(p: UpParams): Promise
+Connection.Down(): Promise
+Connection.Login(p: LoginParams): Promise
+Connection.WaitSSOLogin(p: WaitSSOParams): Promise // returns email/userInfo
+Connection.Logout(p: LogoutParams): Promise
+```
+
+- **Up flow**: call `Login` first; if `LoginResult.needsSsoLogin === true` open `verificationUriComplete` in the browser, then call `WaitSSOLogin` with `{ userCode: LoginResult.userCode, hostname: ... }`. Once that resolves call `Up`.
+- **Down flow**: just `Down()`. The daemon transitions to `Idle`.
+
+```ts
+class LoginParams { profileName, username, managementUrl, setupKey, preSharedKey, hostname, hint: string }
+class LoginResult { needsSsoLogin: boolean; userCode, verificationUri, verificationUriComplete: string }
+class WaitSSOParams { userCode, hostname: string }
+class UpParams { profileName, username: string }
+class LogoutParams { profileName, username: string }
+```
+
+## Status / peer list — `Peers`
+
+```ts
+Peers.Get(): Promise // one-shot snapshot
+Peers.Watch(): Promise // call once at boot to enable push events
+```
+
+```ts
+class Status {
+ status: string // "Idle" | "Connecting" | "Connected" | "SessionExpired" (see below)
+ daemonVersion: string
+ management: PeerLink
+ signal: PeerLink
+ local: LocalPeer
+ peers: PeerStatus[]
+ events: SystemEvent[]
+}
+
+class PeerLink {
+ url: string
+ connected: boolean
+}
+
+class LocalPeer {
+ ip, pubKey, fqdn: string
+ networks: string[]
+}
+
+class PeerStatus {
+ ip, pubKey, fqdn: string
+ connStatus: string // "Connected" | "Connecting" | "Idle"
+ connStatusUpdateUnix: number // unix seconds
+ relayed: boolean
+ localIceCandidateType, remoteIceCandidateType: string
+ localIceCandidateEndpoint, remoteIceCandidateEndpoint: string
+ bytesRx, bytesTx: number
+ latencyMs: number
+ relayAddress: string // populated when relayed
+ lastHandshakeUnix: number
+ rosenpassEnabled: boolean
+ networks: string[]
+}
+
+class SystemEvent {
+ id: string
+ severity: string // "info" | "warning" | "error" | "critical"
+ category: string // "network" | "dns" | "authentication" | "connectivity" | "system"
+ message: string // technical / log message
+ userMessage: string // human-friendly message — render this
+ timestamp: number // unix seconds
+ metadata: Record
+}
+```
+
+### Connection-state values
+
+The `Status.status` field uses these literal strings (from the daemon):
+
+| Value | Meaning |
+|---|---|
+| `"Idle"` | Disconnected — Up not invoked, or Down completed |
+| `"Connecting"` | Up in progress |
+| `"Connected"` | Tunnel up |
+| `"SessionExpired"` | SSO token expired — needs Login again |
+
+(The Fyne UI also reads a synthetic `"Error"` label for some failed states; check `events` for details.)
+
+### ICE candidate type values
+
+`localIceCandidateType` / `remoteIceCandidateType` are pion/ICE strings: `"host"`, `"srflx"`, `"prflx"`, `"relay"`, or `""` while connecting.
+
+## Networks — `Networks`
+
+```ts
+Networks.List(): Promise
+Networks.Select(p: SelectNetworksParams): Promise
+Networks.Deselect(p: SelectNetworksParams): Promise
+```
+
+```ts
+class Network {
+ id, range: string // range is a CIDR
+ selected: boolean
+ domains: string[] // empty unless this is a domain network
+ resolvedIps: Record // domain -> IPs
+}
+
+class SelectNetworksParams {
+ networkIds: string[]
+ append: boolean // false = replace selection, true = merge with existing
+ all: boolean // true = ignore networkIds and target every network (Select-All / Deselect-All)
+}
+```
+
+The Fyne UI's All / Overlapping / Exit-node tabs are filters over the same `List()` result:
+- **Exit-node**: `range === "0.0.0.0/0" || range === "::/0"`
+- **Overlapping**: client-side detection of CIDR overlap among `range` values
+- **All**: everything
+
+## Forwarding / exposed services — `Forwarding`
+
+```ts
+Forwarding.List(): Promise
+```
+
+```ts
+class ForwardingRule {
+ protocol: string // "tcp" | "udp"
+ destinationPort: PortInfo
+ translatedAddress, translatedHostname: string
+ translatedPort: PortInfo
+}
+
+class PortInfo { // exactly one field is populated
+ port?: number
+ range?: PortRange
+}
+
+class PortRange { start, end: number }
+```
+
+## Profiles — `Profiles`
+
+```ts
+Profiles.List(username: string): Promise
+Profiles.GetActive(): Promise
+Profiles.Switch(p: ProfileRef): Promise
+Profiles.Add(p: ProfileRef): Promise
+Profiles.Remove(p: ProfileRef): Promise
+Profiles.Username(): Promise // current OS username
+```
+
+```ts
+class Profile { name: string; isActive: boolean }
+class ProfileRef { profileName, username: string }
+class ActiveProfile { profileName, username: string }
+```
+
+## Settings / config — `Settings`
+
+```ts
+Settings.GetConfig(p: ConfigParams): Promise
+Settings.SetConfig(p: SetConfigParams): Promise
+Settings.GetFeatures(): Promise
+```
+
+```ts
+class ConfigParams { profileName, username: string } // identifies which profile's config
+
+class Config {
+ managementUrl, adminUrl, configFile, logFile, preSharedKey: string
+ interfaceName: string; wireguardPort, mtu: number
+ disableAutoConnect, serverSshAllowed: boolean
+ rosenpassEnabled, rosenpassPermissive: boolean
+ disableNotifications, lazyConnectionEnabled, blockInbound: boolean
+ networkMonitor, disableClientRoutes, disableServerRoutes: boolean
+ disableDns, blockLanAccess: boolean
+ enableSshRoot, enableSshSftp: boolean
+ enableSshLocalPortForwarding, enableSshRemotePortForwarding: boolean
+ disableSshAuth: boolean
+ sshJwtCacheTtl: number
+}
+
+class SetConfigParams {
+ // identity (always required)
+ profileName, username: string
+ // any field below is optional — only the ones you set are pushed to the daemon
+ managementUrl?, adminUrl?, ...
+ // ... same shape as Config
+}
+
+class Features {
+ // feature flags from the daemon — hide UI sections when these are true
+ disableProfiles, disableUpdateSettings, disableNetworks: boolean
+}
+```
+
+`SetConfig` is partial — supply only the fields you want to change, plus `profileName` + `username`. Booleans use Go pointer-presence under the hood; on the TS side undefined / missing means "leave as-is".
+
+## Debug bundle / log level — `Debug`
+
+```ts
+Debug.GetLogLevel(): Promise
+Debug.SetLogLevel(lvl: LogLevel): Promise
+Debug.Bundle(p: DebugBundleParams): Promise
+```
+
+```ts
+class LogLevel { level: string } // "trace" | "debug" | "info" | "warning" | "error" | "panic"
+
+class DebugBundleParams {
+ anonymize: boolean
+ systemInfo: boolean
+ uploadUrl: string // empty string = no upload
+ logFileCount: number // 0 = default
+}
+
+class DebugBundleResult {
+ path: string // local path of the generated bundle
+ uploadedKey: string // populated when uploadUrl was set
+ uploadFailureReason: string // populated on upload error
+}
+```
+
+## Update flow — `Update`
+
+```ts
+Update.Trigger(): Promise // start the install
+Update.GetInstallerResult(): Promise // poll the install outcome (long-running)
+```
+
+```ts
+class UpdateResult { success: boolean; errorMsg: string }
+
+class UpdateAvailable { // payload of "netbird:update:available"
+ version: string
+ enforced: boolean // true = management server requires it
+}
+
+class UpdateProgress { // payload of "netbird:update:progress"
+ action: string // "show" | "hide"
+ version: string
+}
+```
+
+Typical flow:
+1. Listen for `"netbird:update:available"` → show the "Update X.Y.Z" affordance.
+2. User clicks → call `Update.Trigger()`.
+3. The page that shows the install progress polls `GetInstallerResult()` (15-min timeout). On `success: true` the daemon will exit; the app should `app.Quit()` (or restart). On `success: false` show `errorMsg`.
+
+## Toast notifications
+
+The tray sends OS notifications via `application/services/notifications` automatically for `netbird:event` events that have `userMessage`. The frontend doesn't need to do anything for that; the data is also delivered via `netbird:event` if you want to render an in-window log.
diff --git a/client/ui-wails/main.go b/client/ui-wails/main.go
index 69cb262c9..4ae597ac5 100644
--- a/client/ui-wails/main.go
+++ b/client/ui-wails/main.go
@@ -113,6 +113,7 @@ func main() {
InvisibleTitleBarHeight: 38,
Backdrop: application.MacBackdropTranslucent,
TitleBar: application.MacTitleBarHiddenInset,
+ CollectionBehavior: application.MacWindowCollectionBehaviorFullScreenNone,
},
})