diff --git a/client/ui/CLAUDE.md b/client/ui/CLAUDE.md index d7f9101b2..c6ef6a890 100644 --- a/client/ui/CLAUDE.md +++ b/client/ui/CLAUDE.md @@ -36,9 +36,9 @@ All services live in `services/` and assume a build tag `!android && !ios && !fr | `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` / `RevealFile` (cross-platform "show in file manager"). | | `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` / `OpenSessionExpired` / `OpenSessionAboutToExpire(seconds)` / `OpenInstallProgress(version)` / `CloseInstallProgress`. `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). Auxiliary windows are created on first open and **destroyed** on close (Wails-recommended singleton pattern; prevents the macOS dock-reopen from resurrecting hidden windows). | +| `WindowManager` | `windowmanager.go` | `OpenSettings(tab)` / `OpenBrowserLogin(uri)` / `CloseBrowserLogin` / `OpenSessionExpired` / `OpenSessionAboutToExpire(seconds)` / `OpenInstallProgress(version)` / `CloseInstallProgress` / `OpenWelcome` / `CloseWelcome` / `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}`; `SetLanguage(code)` validates against `i18n.Bundle.HasLanguage` and persists; `SetViewMode(mode)` validates against the known set (`default`/`advanced`) and persists. Both broadcast `netbird:preferences:changed`. `main.go` reads `viewMode` from the store to size the main window at startup. | +| `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. @@ -95,6 +95,7 @@ The main window is created up front in `main.go`. Auxiliary windows are created - **BrowserLogin** (`/#/dialog/browser-login?uri=…`) — opened by the connection toggle's SSO flow (`pages/main/ConnectionStatusSwitch.tsx`). 460×440, fixed size. The close button (red X) fires `EventBrowserLoginCancel` so the JS-side `startLogin()` can tear down the daemon's pending `WaitSSOLogin`. `WindowManager.CloseBrowserLogin` closes it programmatically when the flow completes. - **SessionExpired** (`/#/dialog/session-expired`) and **SessionAboutToExpire** (`/#/dialog/session-about-to-expire?seconds=`) — opened by `WindowManager.OpenSessionExpired` / `OpenSessionAboutToExpire(seconds)`. 460×380, fixed size, `AlwaysOnTop: true` (the user can't miss them). The React-side buttons close the window via `WindowManager.CloseSession*` and (for Sign-in / Stay-connected) emit `EventTriggerLogin` so the main window's `startLogin()` orchestrator handles the SSO flow.Currently no triggers wired — daemon-status integration is a follow-up. - **InstallProgress** (`/#/dialog/install-progress?version=`) — opened by `WindowManager.OpenInstallProgress(version)` from `ClientVersionContext` (force-install branch on `installing` flip, user-driven enforced branch from `triggerUpdate`). 360-wide auto-sized via `useAutoSizeWindow`, `AlwaysOnTop`. Owns its own polling loop against `Update.GetInstallerResult` with the 5-second daemon-down-grace (sustained gRPC failure = success → call `Update.Quit()`). Hides every other visible window on open (restored on close). +- **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()`. The four lazy auxiliary windows (BrowserLogin, SessionExpired, SessionAboutToExpire, InstallProgress) 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. diff --git a/client/ui/frontend/CLAUDE.md b/client/ui/frontend/CLAUDE.md index 27ddfb647..6bc517d10 100644 --- a/client/ui/frontend/CLAUDE.md +++ b/client/ui/frontend/CLAUDE.md @@ -27,6 +27,7 @@ React 18 + TS 5.7 (`strict`, `noImplicitAny: false`) + Vite 6 + Tailwind 3 (`dar | `/dialog/install-progress` | `UpdateInProgressDialog` (modules/auto-update/) | none | Auxiliary window (Go `WindowManager.OpenInstallProgress(version)`, always-on-top). Owns the install-result polling + 5s daemon-down-grace; calls `Update.Quit()` on success. Opened by `ClientVersionContext.triggerUpdate` (enforced user-driven branch) and on the `installing` flip from `netbird:update:state` (force-install branch). | | `/dialog/session-expired` | `SessionExpiredDialog` (modules/session/) | none | Auxiliary window (Go `WindowManager.OpenSessionExpired`, always-on-top) | | `/dialog/session-about-to-expire` | `SessionAboutToExpireDialog` (modules/session/) | none | Auxiliary window (Go `WindowManager.OpenSessionAboutToExpire(seconds)`, always-on-top, mm:ss countdown via `?seconds=`) | +| `/dialog/welcome` | `WelcomeDialog` (modules/welcome/) | none | Auxiliary window (Go `WindowManager.OpenWelcome`). First-launch onboarding — opened from `main.go`'s `ApplicationStarted` hook only when `prefStore.Get().OnboardingCompleted` is false. Two-step state machine: tray-screenshot pitch → Cloud-vs-self-hosted segmented control (conditional, see `shouldShowManagementStep`). Continue calls `Preferences.SetOnboardingCompleted(true)`, then `WindowManager.OpenMain()`, then `WindowManager.CloseWelcome()`. | | `/settings` | `SettingsPage` (modules/settings/) | `AppLayout` | Auxiliary window (Go `WindowManager.OpenSettings(tab)`). Inherits the shared provider stack from `AppLayout`; the page itself adds the draggable strip + tabs. The `Profiles` tab (`modules/profiles/ProfilesTab.tsx`, `UserCircle` icon, between Security and SSH) lists profiles in a table with Deregister/Delete in a per-row kebab and an Add Profile button. The header `ProfileDropdown`'s "Manage Profiles" entry calls `OpenSettings("profiles")`. The window stays at `/#/settings` for its whole lifetime — no `SetURL` between opens, so `AppLayout`'s providers never remount. Tab is React local state, driven by the `netbird:settings:open` event Go emits before `Show`. Reset-to-General on close is handled in React via `document.visibilitychange` (Page Visibility API), which fires *before* WebKit throttles the hidden page, unlike Wails events from the Go close hook which race `Hide` and leave the previous tab visible for one frame on the next open. | | `*` | `` | `AppLayout` | Catch-all | @@ -54,6 +55,7 @@ Page-specific chrome lives next to the page, not in the layout: - `modules/session/` — `SessionExpiredDialog.tsx` and `SessionAboutToExpireDialog.tsx` (session lifecycle dialog windows). - `modules/auto-update/` — `UpdateInProgressDialog.tsx`, `UpdateBadge.tsx`, `UpdateVersionCard.tsx`. Context lives in `contexts/`. - `modules/profiles/` — `ProfileAvatar.tsx`, `ProfileDropdown.tsx`, `ProfileCreationModal.tsx`, `ProfilesTab.tsx`. Context lives in `contexts/`. + - `modules/welcome/` — first-launch onboarding dialog window. `WelcomeDialog.tsx` is the orchestrator (state machine over `tray → management → finish`); each step has its own file (`WelcomeStepTray`, `WelcomeStepManagement`). The `management` step is conditionally rendered: only when active profile is `"default"`, the profile email is empty, and the current management URL is cloud-default-or-empty (`shouldShowManagementStep` in the orchestrator). Reachability of self-hosted URLs is a soft warning via `hooks/useManagementUrl.ts checkManagementUrlReachable`; the user can re-click Continue to proceed despite a failed check. No login step — once the dialog closes, the user lands in the main window and clicks Connect there, which runs the connect toggle's local `startLogin` orchestrator. Note: there's no `modules/daemon-status/` or `modules/debug-bundle/` folder. The daemon-status overlay is a generic presentational component (`components/empty-state/DaemonUnavailableOverlay.tsx`) and `useDebugBundle` is inlined into `contexts/DebugBundleContext.tsx` — both folders would be empty otherwise. - `contexts/` — every React context in the app lives here as a flat file (`StatusContext`, `ProfileContext`, `DebugBundleContext`, `ClientVersionContext`, `SettingsContext`, `NetworksContext`, `PeerDetailContext`, `ViewModeContext`, `NavSectionContext`). Single mental model: "where is the X context? `contexts/XContext.tsx`." @@ -67,7 +69,7 @@ Page-specific chrome lives next to the page, not in the layout: - Flat at root: `Badge.tsx`, `CopyToClipboard.tsx`, `DropdownMenu.tsx`, `SquareIcon.tsx`, `Tooltip.tsx`, `VerticalTabs.tsx` (one-of-a-kind primitives). - `layouts/` — `AppLayout.tsx` (the only router-level layout) plus the shared content shell `AppRightPanel.tsx` used by both `MainPage` and `SettingsPage`. - `hooks/` — reusable React hooks (`useAutoSizeWindow.ts`, `useKeyboardShortcut.ts`). -- `lib/` — pure utilities (no JSX, no React state): `cn.ts`, `errors.ts`, `formatters.ts` (byte/latency/relative-time helpers), `i18n.ts`, `welcome.ts`. +- `lib/` — pure utilities (no JSX, no React state): `cn.ts`, `errors.ts`, `formatters.ts` (byte/latency/relative-time helpers), `i18n.ts`, `welcome.ts`. Management-URL utilities (`CLOUD_MANAGEMENT_URL`, URL regex, `isValidManagementUrl`, `normalizeManagementUrl`, `isCloudManagementUrl`, `checkManagementUrlReachable`) live alongside the hook in `hooks/useManagementUrl.ts`. The SSO orchestrator (`startLogin` + `EVENT_TRIGGER_LOGIN` / `EVENT_BROWSER_LOGIN_CANCEL`) lives at module scope inside `modules/main/MainConnectionStatusSwitch.tsx` — the only caller. - `assets/` — fonts, logos, flags. `screens/` is a residual legacy bucket — don't add new code there. ## Wails event bus diff --git a/client/ui/frontend/src/app.tsx b/client/ui/frontend/src/app.tsx index 875ec0739..8512cbb08 100644 --- a/client/ui/frontend/src/app.tsx +++ b/client/ui/frontend/src/app.tsx @@ -5,6 +5,7 @@ import { HashRouter, Navigate, Route, Routes } from "react-router-dom"; import SessionExpiredDialog from "@/modules/session/SessionExpiredDialog.tsx"; import SessionAboutToExpireDialog from "@/modules/session/SessionAboutToExpireDialog.tsx"; import UpdateInProgressDialog from "@/modules/auto-update/UpdateInProgressDialog.tsx"; +import WelcomeDialog from "@/modules/welcome/WelcomeDialog.tsx"; import { AppLayout } from "@/layouts/AppLayout.tsx"; import { MainPage } from "@/modules/main/MainPage.tsx"; import { SettingsPage } from "@/modules/settings/SettingsPage.tsx"; @@ -39,6 +40,7 @@ Promise.all([ } /> } /> } /> + } /> }> } /> diff --git a/client/ui/frontend/src/assets/img/tray-darwin.png b/client/ui/frontend/src/assets/img/tray-darwin.png new file mode 100644 index 000000000..95223cabb Binary files /dev/null and b/client/ui/frontend/src/assets/img/tray-darwin.png differ diff --git a/client/ui/frontend/src/assets/img/tray-linux.png b/client/ui/frontend/src/assets/img/tray-linux.png new file mode 100644 index 000000000..95223cabb Binary files /dev/null and b/client/ui/frontend/src/assets/img/tray-linux.png differ diff --git a/client/ui/frontend/src/assets/img/tray-windows.png b/client/ui/frontend/src/assets/img/tray-windows.png new file mode 100644 index 000000000..95223cabb Binary files /dev/null and b/client/ui/frontend/src/assets/img/tray-windows.png differ diff --git a/client/ui/frontend/src/components/ManagementServerSwitch.tsx b/client/ui/frontend/src/components/ManagementServerSwitch.tsx index 5a31f68ad..4370e8147 100644 --- a/client/ui/frontend/src/components/ManagementServerSwitch.tsx +++ b/client/ui/frontend/src/components/ManagementServerSwitch.tsx @@ -7,21 +7,28 @@ import { ManagementMode } from "@/hooks/useManagementUrl.ts"; type Props = { value: ManagementMode; onChange: (mode: ManagementMode) => void; + // fullWidth stretches the segmented control to fill its container — + // the SettingsGeneral row uses the default (shrink-to-content) layout, + // the welcome dialog asks for the wide variant so the picker spans the + // narrow dialog width. + fullWidth?: boolean; }; -export const ManagementServerSwitch = ({ value, onChange }: Props) => { +export const ManagementServerSwitch = ({ value, onChange, fullWidth = false }: Props) => { const { t, i18n } = useTranslation(); + const itemClass = fullWidth ? "flex-1" : undefined; return ( onChange(v as ManagementMode)} + className={fullWidth ? "w-full" : undefined} > - + {""} {t("settings.general.management.cloud")} - + {t("settings.general.management.selfHosted")} diff --git a/client/ui/frontend/src/components/buttons/Button.tsx b/client/ui/frontend/src/components/buttons/Button.tsx index cabcbec45..138540619 100644 --- a/client/ui/frontend/src/components/buttons/Button.tsx +++ b/client/ui/frontend/src/components/buttons/Button.tsx @@ -15,7 +15,7 @@ interface ButtonProps extends ButtonHTMLAttributes, ButtonVar const buttonVariants = cva( [ "relative", - "text-sm focus:z-10 focus:ring-2 font-medium focus:outline-none whitespace-nowrap shadow-sm select-none", + "text-sm focus:z-10 focus:ring-2 font-medium focus:outline-none whitespace-nowrap shadow-sm select-none cursor-default", "inline-flex gap-2 items-center justify-center transition-colors focus:ring-offset-1", "disabled:opacity-40 disabled:cursor-not-allowed disabled:dark:text-nb-gray-300 dark:ring-offset-neutral-950/50", ], diff --git a/client/ui/frontend/src/components/dialog/ConfirmDialog.tsx b/client/ui/frontend/src/components/dialog/ConfirmDialog.tsx index a0a91e9aa..06ea19802 100644 --- a/client/ui/frontend/src/components/dialog/ConfirmDialog.tsx +++ b/client/ui/frontend/src/components/dialog/ConfirmDialog.tsx @@ -24,7 +24,7 @@ export const ConfirmDialog = forwardRef(func
diff --git a/client/ui/frontend/src/components/dialog/DialogDescription.tsx b/client/ui/frontend/src/components/dialog/DialogDescription.tsx index 9a4916194..68ae621ac 100644 --- a/client/ui/frontend/src/components/dialog/DialogDescription.tsx +++ b/client/ui/frontend/src/components/dialog/DialogDescription.tsx @@ -3,11 +3,32 @@ import { cn } from "@/lib/cn"; // DialogDescription is the supporting description text rendered under a // DialogHeading inside ConfirmDialog (and similar dialog surfaces). +type DialogAlign = "left" | "center" | "right"; + +const alignClass: Record = { + left: "text-left", + center: "text-center", + right: "text-right", +}; + type DialogDescriptionProps = { children: ReactNode; className?: string; + align?: DialogAlign; }; -export const DialogDescription = ({ children, className }: DialogDescriptionProps) => ( -

{children}

+export const DialogDescription = ({ children, className, align = "center" }: DialogDescriptionProps) => ( + // w-full for the same reason DialogHeading carries it — see the + // comment there. The default text-center remains visually identical + // to before; left/right alignment now anchors to the dialog content + // edge instead of collapsing to no-op on a content-width box. +

+ {children} +

); diff --git a/client/ui/frontend/src/components/dialog/DialogHeading.tsx b/client/ui/frontend/src/components/dialog/DialogHeading.tsx index d3e438f50..4b5e3fa33 100644 --- a/client/ui/frontend/src/components/dialog/DialogHeading.tsx +++ b/client/ui/frontend/src/components/dialog/DialogHeading.tsx @@ -4,13 +4,34 @@ import { cn } from "@/lib/cn"; // DialogHeading is the title text used inside ConfirmDialog (and any other // dialog-style surface with the same shape). Pair with DialogDescription // for the standard title/description stack. +type DialogAlign = "left" | "center" | "right"; + +const alignClass: Record = { + left: "text-left", + center: "text-center", + right: "text-right", +}; + type DialogHeadingProps = { children: ReactNode; className?: string; + align?: DialogAlign; }; -export const DialogHeading = ({ children, className }: DialogHeadingProps) => ( -

+export const DialogHeading = ({ children, className, align = "center" }: DialogHeadingProps) => ( + // w-full so the alignClass actually has a box to anchor against. + // The wrapping

defaulted to content width inside a flex column, + // which made `text-left` a no-op (nothing to push the text away + // from). Stretching the element is invisible for the default + // text-center case (center of content == center of box) and lets + // text-left/right line up with the dialog's content edge. +

{children}

); diff --git a/client/ui/frontend/src/hooks/useManagementUrl.ts b/client/ui/frontend/src/hooks/useManagementUrl.ts index b4633f3cc..405b7aa05 100644 --- a/client/ui/frontend/src/hooks/useManagementUrl.ts +++ b/client/ui/frontend/src/hooks/useManagementUrl.ts @@ -3,21 +3,12 @@ import { warningDialog } from "@/lib/dialogs.ts"; import i18next from "@/lib/i18n"; import { useSettings } from "@/contexts/SettingsContext.tsx"; -export enum ManagementMode { - Cloud = "cloud", - SelfHosted = "selfhosted", -} - export const CLOUD_MANAGEMENT_URL = "https://api.netbird.io:443"; -function normalizeManagementUrl(input: string): string { - const trimmed = input.trim(); - if (!trimmed) return ""; - if (/^https?:\/\//i.test(trimmed)) return trimmed; - return `https://${trimmed}`; -} - -const URL_PATTERN = new RegExp( +// URL_PATTERN matches http(s)://host[:port][/path][?query][#fragment]. +// Host is domain, localhost, or IPv4. Used for syntactic validation only — +// reachability is checked separately via checkManagementUrlReachable. +export const URL_PATTERN = new RegExp( "^(https?:\\/\\/)?" + "((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|localhost|" + "((\\d{1,3}\\.){3}\\d{1,3}))" + @@ -27,12 +18,60 @@ const URL_PATTERN = new RegExp( "i", ); -function isValidManagementUrl(input: string): boolean { +// normalizeManagementUrl prefixes an https:// scheme when the user omits +// it. Empty input stays empty. +export function normalizeManagementUrl(input: string): string { + const trimmed = input.trim(); + if (!trimmed) return ""; + if (/^https?:\/\//i.test(trimmed)) return trimmed; + return `https://${trimmed}`; +} + +// isValidManagementUrl is a syntactic check via URL_PATTERN. Does not +// touch the network. +export function isValidManagementUrl(input: string): boolean { const trimmed = input.trim(); if (!trimmed) return false; return URL_PATTERN.test(trimmed); } +// isCloudManagementUrl reports whether the stored URL is the NetBird +// Cloud default (or an empty/unset URL, which the daemon also treats as +// cloud-defaulting on first boot). +export function isCloudManagementUrl(url: string): boolean { + if (!url || url.trim() === "") return true; + return url === CLOUD_MANAGEMENT_URL; +} + +// checkManagementUrlReachable does a best-effort no-cors GET against the +// URL with a short timeout. A resolved fetch (even opaque) means DNS + +// TCP + TLS landed; any rejection (network error, DNS, abort) is treated +// as unreachable. Self-hosted deployments behind internal-only DNS or +// with self-signed certs may return false positives — callers should +// surface this as a soft warning, not a hard block. +export async function checkManagementUrlReachable( + url: string, + timeoutMs: number = 5000, +): Promise { + const target = normalizeManagementUrl(url); + if (!target) return false; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + await fetch(target, { method: "GET", mode: "no-cors", signal: controller.signal }); + return true; + } catch { + return false; + } finally { + clearTimeout(timer); + } +} + +export enum ManagementMode { + Cloud = "cloud", + SelfHosted = "selfhosted", +} + function modeFromUrl(url: string): ManagementMode { return url === CLOUD_MANAGEMENT_URL ? ManagementMode.Cloud : ManagementMode.SelfHosted; } diff --git a/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx b/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx index ba0f65137..cacc1368d 100644 --- a/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx +++ b/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx @@ -12,37 +12,36 @@ import { formatErrorMessage } from "@/lib/errors.ts"; import { CopyToClipboard } from "@/components/CopyToClipboard"; import netbirdFullLogo from "@/assets/logos/netbird-full.svg"; -enum ConnectionState { - Disconnected = "disconnected", - Connecting = "connecting", - Connected = "connected", - Disconnecting = "disconnecting", -} - -// NeedsLogin / SessionExpired / DaemonUnavailable never reach this map — -// connState collapses them into Connecting or Disconnected upstream. -const STATUS_KEY: Record = { - [ConnectionState.Disconnected]: "connect.status.disconnected", - [ConnectionState.Connecting]: "connect.status.connecting", - [ConnectionState.Connected]: "connect.status.connected", - [ConnectionState.Disconnecting]: "connect.status.disconnecting", -}; - +// EVENT_BROWSER_LOGIN_CANCEL is emitted by the BrowserLogin window's close +// button (Go side) and by the in-dialog Cancel button. startLogin uses it +// to break the WaitSSOLogin race so the daemon doesn't hang on a stale +// device code. const EVENT_BROWSER_LOGIN_CANCEL = "browser-login:cancel"; + +// EVENT_TRIGGER_LOGIN lets any window ask the main window's connect-toggle +// to drive a login flow. Mirrors services.EventTriggerLogin on the Go side. +// The tray emits it from menu items so the React UI (which owns the SSO +// orchestration and the browser-login window) takes over. const EVENT_TRIGGER_LOGIN = "trigger-login"; -const NEEDS_LOGIN_STATES = new Set(["NeedsLogin", "SessionExpired", "LoginFailed"]); - -// Re-enable the switch after this long in a transitioning state so the user -// can force a Connection.Down on a stuck Connecting/Disconnecting flow. -const FORCE_TOGGLE_DELAY_MS = 7000; - -const errorMessage = formatErrorMessage; - -// startLogin drives the daemon's SSO login end-to-end. The BrowserLogin -// popup window is the only login UI; errors surface as a native -// Dialogs.Error. Concurrent calls are dropped via the inFlight guard. +// loginInFlight is a module-level guard. SSO login involves multiple async +// hops (Login → BrowserLogin window → WaitSSOLogin → Up); a second concurrent +// call would race on the daemon's pending device code and on the popup +// window's singleton, leading to confusing UX. Calls past the first are +// dropped silently — the first invocation owns the flow until it settles. let loginInFlight = false; + +// startLogin drives the daemon's SSO login end-to-end: +// 1. Connection.Login — daemon returns a verification URI if SSO is needed. +// 2. WindowManager.OpenBrowserLogin — show the in-app sign-in popup. +// 3. Race WaitSSOLogin vs the user clicking Cancel. +// 4. On success: Connection.Up. +// 5. On cancel: cancel the in-flight WaitSSOLogin gRPC so the daemon +// drops the abandoned device code (avoids an Idle blink on the tray). +// +// Errors that aren't user cancellations surface via errorDialog. Concurrent +// calls are dropped via loginInFlight. The BrowserLogin window is closed in +// all exit paths so a stray popup doesn't outlive the flow. async function startLogin(): Promise { if (loginInFlight) return; loginInFlight = true; @@ -64,10 +63,6 @@ async function startLogin(): Promise { if (result.needsSsoLogin) { const uri = result.verificationUriComplete || result.verificationUri; if (uri) { - // Open the in-app sign-in popup first; the dialog itself - // fires Connection.OpenURL after it's actually on screen - // (see WaitingForBrowserDialog) so the system browser - // doesn't land on top of a still-hidden NetBird window. try { await WindowManager.OpenBrowserLogin(uri); } catch (e) { @@ -94,12 +89,6 @@ async function startLogin(): Promise { } if (cancelled) { - // Cancel the in-flight WaitSSOLogin gRPC instead of a heavy - // Down. The daemon ties the wait to this call's context - // (server.go WaitSSOLogin), so cancelling ends the wait and - // clears the abandoned OAuth flow — a fresh Login then starts - // a new device code, with no Idle blink on the tray. Swallow - // the cancellation rejection on the abandoned promise. waitPromise.cancel?.(); void waitPromise.catch(() => {}); return; @@ -112,7 +101,7 @@ async function startLogin(): Promise { if (cancelled) return; await errorDialog({ Title: i18next.t("connect.error.loginTitle"), - Message: errorMessage(e), + Message: formatErrorMessage(e), }); } finally { offCancel?.(); @@ -120,6 +109,30 @@ async function startLogin(): Promise { } } +enum ConnectionState { + Disconnected = "disconnected", + Connecting = "connecting", + Connected = "connected", + Disconnecting = "disconnecting", +} + +// NeedsLogin / SessionExpired / DaemonUnavailable never reach this map — +// connState collapses them into Connecting or Disconnected upstream. +const STATUS_KEY: Record = { + [ConnectionState.Disconnected]: "connect.status.disconnected", + [ConnectionState.Connecting]: "connect.status.connecting", + [ConnectionState.Connected]: "connect.status.connected", + [ConnectionState.Disconnecting]: "connect.status.disconnecting", +}; + +const NEEDS_LOGIN_STATES = new Set(["NeedsLogin", "SessionExpired", "LoginFailed"]); + +// Re-enable the switch after this long in a transitioning state so the user +// can force a Connection.Down on a stuck Connecting/Disconnecting flow. +const FORCE_TOGGLE_DELAY_MS = 7000; + +const errorMessage = formatErrorMessage; + export const MainConnectionStatusSwitch = () => { const { t } = useTranslation(); const { status, refresh } = useStatus(); diff --git a/client/ui/frontend/src/modules/welcome/WelcomeDialog.tsx b/client/ui/frontend/src/modules/welcome/WelcomeDialog.tsx new file mode 100644 index 000000000..e6231c9d9 --- /dev/null +++ b/client/ui/frontend/src/modules/welcome/WelcomeDialog.tsx @@ -0,0 +1,194 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + Preferences, + Profiles as ProfilesSvc, + Settings as SettingsSvc, + WindowManager, +} from "@bindings/services"; +import { SetConfigParams } from "@bindings/services/models.js"; +import { ConfirmDialog } from "@/components/dialog/ConfirmDialog"; +import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow"; +import { errorDialog } from "@/lib/dialogs"; +import { formatErrorMessage } from "@/lib/errors"; +import i18next from "@/lib/i18n"; +import { isCloudManagementUrl } from "@/hooks/useManagementUrl"; +import { WelcomeStepTray } from "./WelcomeStepTray"; +import { WelcomeStepManagement } from "./WelcomeStepManagement"; + +const WINDOW_WIDTH = 360; + +// WelcomeStep is the orchestrator's state machine. The transitions: +// tray → management (if eligible) → finish +// tray → finish (otherwise) +// Login itself is no longer part of onboarding — once the welcome window +// closes the user lands in the main window and clicks Connect there. +type WelcomeStep = "tray" | "management"; + +// shouldShowManagementStep asks the user about Cloud vs self-hosted only +// on a pristine setup — default profile, no email recorded (no successful +// login yet), and the management URL is either unset or already the cloud +// default. Any other state means the user (or a previous run) already +// made a deliberate choice and we shouldn't second-guess it. +function shouldShowManagementStep( + activeProfile: string, + email: string, + managementUrl: string, +): boolean { + if (activeProfile !== "default") return false; + if (email.trim() !== "") return false; + return isCloudManagementUrl(managementUrl); +} + +// initial flow snapshot resolved at mount. Held in component state so the +// step-2 management input can hydrate from initialUrl, and so the +// "should we even show step 2" check is computed once (the user can't +// change profile / URL from inside the welcome window). +type InitialState = { + profileName: string; + username: string; + managementUrl: string; + needsManagementStep: boolean; +}; + +export default function WelcomeDialog() { + const contentRef = useAutoSizeWindow(WINDOW_WIDTH); + const [step, setStep] = useState("tray"); + const [initial, setInitial] = useState(null); + const [closing, setClosing] = useState(false); + + // Probe daemon state on mount: who's the active profile, do they + // have an email recorded, and what management URL is configured? + // Errors fall through to "skip the management step" so a daemon + // hiccup never blocks onboarding entirely. + useEffect(() => { + let cancelled = false; + (async () => { + try { + // Resolve username + active profile first so GetConfig + List + // can target the actual profile (passing empty strings would + // work today since the daemon falls back to the default + // profile, but being explicit shields us from future + // changes to that fallback). + const [username, active] = await Promise.all([ + ProfilesSvc.Username(), + ProfilesSvc.GetActive(), + ]); + const profileName = active.profileName || "default"; + const [config, list] = await Promise.all([ + SettingsSvc.GetConfig({ profileName, username }), + ProfilesSvc.List(username), + ]); + const profile = list.find((p) => p.name === profileName); + const email = profile?.email ?? ""; + if (cancelled) return; + setInitial({ + profileName, + username, + managementUrl: config.managementUrl, + needsManagementStep: shouldShowManagementStep( + profileName, + email, + config.managementUrl, + ), + }); + } catch (e) { + console.error("welcome: initial probe failed", e); + if (cancelled) return; + // Conservative fallback: skip the management step rather + // than block onboarding behind a daemon hiccup. + setInitial({ + profileName: "default", + username: "", + managementUrl: "", + needsManagementStep: false, + }); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + // finish persists the onboarding flag, opens the main window so the + // user has somewhere to land, and closes the welcome window. Called + // at the end of every successful flow (tray-only and tray→management + // alike). The Connect button in the main window picks up from here. + const finish = useCallback(async () => { + if (closing) return; + setClosing(true); + try { + await Preferences.SetOnboardingCompleted(true); + } catch (e) { + console.error("persist onboarding flag:", e); + } + try { + await WindowManager.OpenMain(); + } catch (e) { + console.error("open main window:", e); + } + try { + await WindowManager.CloseWelcome(); + } catch (e) { + console.error("close welcome window:", e); + } + }, [closing]); + + const handleTrayContinue = useCallback(async () => { + if (initial?.needsManagementStep) { + setStep("management"); + } else { + await finish(); + } + }, [initial, finish]); + + const handleManagementContinue = useCallback( + async (url: string) => { + if (!initial) return; + try { + // SetConfig is a partial update — pointer fields left + // undefined are preserved (services/settings.go). We only + // touch managementUrl; adminUrl stays empty here because + // the daemon already has its own value loaded. + await SettingsSvc.SetConfig( + new SetConfigParams({ + profileName: initial.profileName, + username: initial.username, + managementUrl: url, + }), + ); + } catch (e) { + await errorDialog({ + Title: i18next.t("settings.error.saveTitle"), + Message: formatErrorMessage(e), + }); + throw e; + } + setInitial((s) => (s ? { ...s, managementUrl: url } : s)); + await finish(); + }, + [initial, finish], + ); + + const content = useMemo(() => { + if (!initial) { + // Probe in flight — render an empty container so the dialog + // window measures something tiny instead of flashing the + // tray step before we know whether step 2 applies. The probe + // completes within a single tick on a healthy daemon. + return
; + } + switch (step) { + case "tray": + return ; + case "management": + return ( + + ); + } + }, [initial, step, handleTrayContinue, handleManagementContinue]); + + return {content}; +} diff --git a/client/ui/frontend/src/modules/welcome/WelcomeStepManagement.tsx b/client/ui/frontend/src/modules/welcome/WelcomeStepManagement.tsx new file mode 100644 index 000000000..1d5b80cec --- /dev/null +++ b/client/ui/frontend/src/modules/welcome/WelcomeStepManagement.tsx @@ -0,0 +1,138 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/buttons/Button"; +import { DialogActions } from "@/components/dialog/DialogActions"; +import { DialogDescription } from "@/components/dialog/DialogDescription"; +import { DialogHeading } from "@/components/dialog/DialogHeading"; +import { Input } from "@/components/inputs/Input"; +import { ManagementServerSwitch } from "@/components/ManagementServerSwitch"; +import { + CLOUD_MANAGEMENT_URL, + ManagementMode, + checkManagementUrlReachable, + isCloudManagementUrl, + isValidManagementUrl, + normalizeManagementUrl, +} from "@/hooks/useManagementUrl"; +import { cn } from "@/lib/cn.ts"; +import { isMacOS } from "@/lib/platform.ts"; + +type WelcomeStepManagementProps = { + // initialUrl is the management URL the daemon is already configured + // with (empty / cloud-default both render as Cloud selected). + initialUrl: string; + // onContinue is invoked with the URL the user wants to persist. The + // parent owns the actual Settings.SetConfig call so the dialog stays + // free of context dependencies. + onContinue: (url: string) => Promise; +}; + +export function WelcomeStepManagement({ initialUrl, onContinue }: WelcomeStepManagementProps) { + const { t } = useTranslation(); + const startsCloud = isCloudManagementUrl(initialUrl); + const [mode, setMode] = useState( + startsCloud ? ManagementMode.Cloud : ManagementMode.SelfHosted, + ); + const [url, setUrl] = useState(startsCloud ? "" : initialUrl); + const [syntaxError, setSyntaxError] = useState(null); + // unreachable: soft warning. Continue stays enabled — user can confirm + // they typed it right and proceed (matches self-hosted-behind-internal- + // DNS / VPN scenarios where the in-app fetch would false-negative). + const [unreachable, setUnreachable] = useState(false); + const [checking, setChecking] = useState(false); + + const trimmedUrl = url.trim(); + const syntaxValid = mode === ManagementMode.Cloud || isValidManagementUrl(trimmedUrl); + // Continue is no longer disabled for an empty / invalid self-hosted + // URL; a Continue click in that state focuses the input and renders + // an inline error so the user actively notices what's missing. + const inputRef = useRef(null); + + // Reset inline error/warning whenever the user edits the URL or flips + // mode — otherwise the warning lingers next to a just-corrected value. + useEffect(() => { + setSyntaxError(null); + setUnreachable(false); + }, [url, mode]); + + const handleContinue = useCallback(async () => { + if (checking) return; + if (mode === ManagementMode.SelfHosted && (!trimmedUrl || !syntaxValid)) { + // Empty or syntactically invalid URL — Continue stays enabled + // so the click registers; surface the error inline and focus + // the input so the user has somewhere to fix it. + setSyntaxError(t("welcome.management.urlInvalid")); + inputRef.current?.focus(); + return; + } + const target = + mode === ManagementMode.Cloud + ? CLOUD_MANAGEMENT_URL + : normalizeManagementUrl(trimmedUrl); + if (mode === ManagementMode.SelfHosted) { + setChecking(true); + const reachable = await checkManagementUrlReachable(target); + setChecking(false); + // First failed check: show soft warning + bail. A second click + // with the same URL skips the check (unreachable still true) + // so the user can proceed if they're sure. + if (!reachable && !unreachable) { + setUnreachable(true); + return; + } + } + try { + await onContinue(target); + } catch (e) { + // Parent surfaces save errors via errorDialog; keep a console + // breadcrumb but don't double-render. + console.error("save management url:", e); + } + }, [checking, mode, syntaxValid, trimmedUrl, unreachable, onContinue, t]); + + const inputError = useMemo(() => { + if (syntaxError) return syntaxError; + if (unreachable) return t("welcome.management.urlUnreachable"); + return undefined; + }, [syntaxError, unreachable, t]); + + return ( + <> +
+ {t("welcome.management.title")} + + {t("welcome.management.description")} + +
+ +
+ +
+ + {mode === ManagementMode.SelfHosted && ( +
+ setUrl(e.target.value)} + error={inputError} + autoFocus + /> +
+ )} + + + + + + ); +} diff --git a/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx b/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx new file mode 100644 index 000000000..6702b0d90 --- /dev/null +++ b/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx @@ -0,0 +1,61 @@ +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/buttons/Button"; +import { DialogActions } from "@/components/dialog/DialogActions"; +import { DialogDescription } from "@/components/dialog/DialogDescription"; +import { DialogHeading } from "@/components/dialog/DialogHeading"; +import { isMacOS, isWindows } from "@/lib/platform"; +import trayScreenshotDarwin from "@/assets/img/tray-darwin.png"; +import trayScreenshotWindows from "@/assets/img/tray-windows.png"; +import trayScreenshotLinux from "@/assets/img/tray-linux.png"; + +// trayScreenshotForOS picks the marketing screenshot that shows the +// NetBird tray icon in its native menu/task bar — so the onboarding pitch +// matches the chrome the user will actually be hunting for. Evaluated +// inside the component so initPlatform() has finished by the time +// isMacOS/isWindows run (the static imports above only load the bytes, +// no platform check). +function trayScreenshotForOS(): string { + if (isMacOS()) return trayScreenshotDarwin; + if (isWindows()) return trayScreenshotWindows; + return trayScreenshotLinux; +} + +type WelcomeStepTrayProps = { + onContinue: () => void; +}; + +export function WelcomeStepTray({ onContinue }: WelcomeStepTrayProps) { + const { t } = useTranslation(); + const trayScreenshot = trayScreenshotForOS(); + + return ( + <> +
+ {""} +
+ +
+ {t("welcome.title")} + {t("welcome.description")} +
+ + + + + + ); +} diff --git a/client/ui/i18n/locales/de/common.json b/client/ui/i18n/locales/de/common.json index 371167771..5e274de87 100644 --- a/client/ui/i18n/locales/de/common.json +++ b/client/ui/i18n/locales/de/common.json @@ -313,6 +313,23 @@ "window.title.sessionExpired": "Sitzung abgelaufen", "window.title.sessionExpiring": "Sitzung läuft ab", "window.title.updating": "Aktualisierung", + "window.title.welcome": "Willkommen bei NetBird", + + "welcome.title": "Suchen Sie NetBird in der Taskleiste", + "welcome.description": "NetBird läuft in Ihrer Taskleiste. Klicken Sie auf das Symbol, um sich zu verbinden, Profile zu wechseln oder die Einstellungen zu öffnen.", + "welcome.continue": "Weiter", + "welcome.back": "Zurück", + "welcome.management.title": "NetBird einrichten", + "welcome.management.description": "Klicken Sie auf „Weiter“, um loszulegen, oder wählen Sie Self-hosted, wenn Sie einen eigenen NetBird-Server haben.", + "welcome.management.cloud.title": "NetBird Cloud", + "welcome.management.cloud.description": "Nutzen Sie unseren gehosteten Dienst. Keine Einrichtung nötig.", + "welcome.management.selfHosted.title": "Selbst gehostet", + "welcome.management.selfHosted.description": "Verbindung zu Ihrem eigenen Management-Server.", + "welcome.management.urlLabel": "URL des Management-Servers", + "welcome.management.urlPlaceholder": "https://netbird.selfhosted.com:443", + "welcome.management.urlInvalid": "Bitte geben Sie eine gültige URL ein, z. B. https://netbird.selfhosted.com:443", + "welcome.management.urlUnreachable": "Wir konnten diesen Server nicht erreichen. Überprüfen Sie die URL oder Ihr Netzwerk — Sie können trotzdem fortfahren, wenn Sie sicher sind, dass sie korrekt ist.", + "welcome.management.checking": "Wird geprüft …", "browserLogin.title": "Setzen Sie den Anmeldevorgang im Browser fort", "browserLogin.notSeeing": "Sehen Sie den Browser-Tab nicht?", diff --git a/client/ui/i18n/locales/en/common.json b/client/ui/i18n/locales/en/common.json index d84621812..38e1d3bcc 100644 --- a/client/ui/i18n/locales/en/common.json +++ b/client/ui/i18n/locales/en/common.json @@ -332,6 +332,23 @@ "window.title.sessionExpired": "Session Expired", "window.title.sessionExpiring": "Session Expiring", "window.title.updating": "Updating", + "window.title.welcome": "Welcome to NetBird", + + "welcome.title": "Look for NetBird in your tray", + "welcome.description": "NetBird lives in your tray. Click the icon to connect, switch profiles, or open settings.", + "welcome.continue": "Continue", + "welcome.back": "Back", + "welcome.management.title": "Set up NetBird", + "welcome.management.description": "Click Continue to get started, or pick Self-hosted if you have your own NetBird server.", + "welcome.management.cloud.title": "NetBird Cloud", + "welcome.management.cloud.description": "Use our hosted service. No setup required.", + "welcome.management.selfHosted.title": "Self-hosted", + "welcome.management.selfHosted.description": "Connect to your own management server.", + "welcome.management.urlLabel": "Management server URL", + "welcome.management.urlPlaceholder": "https://netbird.selfhosted.com:443", + "welcome.management.urlInvalid": "Please enter a valid URL, e.g., https://netbird.selfhosted.com:443", + "welcome.management.urlUnreachable": "We couldn't reach this server. Check the URL or your network and try again — you can also continue if you're sure it's correct.", + "welcome.management.checking": "Checking…", "browserLogin.title": "Continue in your browser to complete the login", "browserLogin.notSeeing": "Not seeing the browser tab?", diff --git a/client/ui/i18n/locales/hu/common.json b/client/ui/i18n/locales/hu/common.json index ae2dbcfa5..69abc4aca 100644 --- a/client/ui/i18n/locales/hu/common.json +++ b/client/ui/i18n/locales/hu/common.json @@ -313,6 +313,23 @@ "window.title.sessionExpired": "Munkamenet lejárt", "window.title.sessionExpiring": "Munkamenet lejár", "window.title.updating": "Frissítés", + "window.title.welcome": "Üdvözli a NetBird", + + "welcome.title": "Keresse a NetBirdöt a tálcán", + "welcome.description": "A NetBird a tálcán fut. Kattintson az ikonra a csatlakozáshoz, profilváltáshoz vagy a beállítások megnyitásához.", + "welcome.continue": "Folytatás", + "welcome.back": "Vissza", + "welcome.management.title": "NetBird beállítása", + "welcome.management.description": "Kattintson a Folytatás gombra a kezdéshez, vagy válassza a Self-hosted lehetőséget, ha saját NetBird-szervere van.", + "welcome.management.cloud.title": "NetBird Cloud", + "welcome.management.cloud.description": "Használja az általunk üzemeltetett szolgáltatást. Nincs szükség beállításra.", + "welcome.management.selfHosted.title": "Saját üzemeltetésű", + "welcome.management.selfHosted.description": "Csatlakozás a saját menedzsmentszerveréhez.", + "welcome.management.urlLabel": "Menedzsmentszerver URL", + "welcome.management.urlPlaceholder": "https://netbird.selfhosted.com:443", + "welcome.management.urlInvalid": "Adjon meg egy érvényes URL-t, pl. https://netbird.selfhosted.com:443", + "welcome.management.urlUnreachable": "Nem sikerült elérni a szervert. Ellenőrizze az URL-t vagy a hálózatot — ha biztos benne, hogy helyes, folytathatja.", + "welcome.management.checking": "Ellenőrzés…", "browserLogin.title": "Folytassa a böngészőben a bejelentkezés befejezéséhez", "browserLogin.notSeeing": "Nem látja a böngésző fülét?", diff --git a/client/ui/main.go b/client/ui/main.go index b0c8208b7..dd523101a 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -149,9 +149,19 @@ func main() { // so they don't linger as hidden windows that Wails's macOS dock-reopen // handler would pop back up. windowManager := services.NewWindowManager(app, window, bundle, prefStore, iconWindow) - windowManager.WatchLanguage(prefStore) app.RegisterService(application.NewService(windowManager)) + // Welcome / onboarding window. First launch only — the Continue + // button in the dialog flips OnboardingCompleted=true via the + // Preferences service before closing, so subsequent launches skip + // straight to the tray-only flow. ApplicationStarted hook so the + // Wails window machinery is fully up before the window is created. + if !prefStore.Get().OnboardingCompleted { + app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) { + windowManager.OpenWelcome() + }) + } + // Register an in-process StatusNotifierWatcher so the tray works on // minimal WMs (Fluxbox, OpenBox, i3, dwm, vanilla GNOME without the // AppIndicator extension) that don't ship one themselves. No-op on diff --git a/client/ui/preferences/store.go b/client/ui/preferences/store.go index f8ca17dc2..f10662a56 100644 --- a/client/ui/preferences/store.go +++ b/client/ui/preferences/store.go @@ -68,8 +68,9 @@ func (v ViewMode) IsValid() bool { // frontend. Pointer-free because the whole document is rewritten on every // change — there are no per-field partial updates. type UIPreferences struct { - Language i18n.LanguageCode `json:"language"` - ViewMode ViewMode `json:"viewMode"` + Language i18n.LanguageCode `json:"language"` + ViewMode ViewMode `json:"viewMode"` + OnboardingCompleted bool `json:"onboardingCompleted"` } // LanguageValidator is the dependency Store needs to reject SetLanguage @@ -162,6 +163,28 @@ func (s *Store) SetViewMode(mode ViewMode) error { return nil } +// SetOnboardingCompleted persists the welcome-window dismissal so the +// welcome flow doesn't run again on subsequent launches. Idempotent — a +// repeat of the current value is a no-op (no disk write, no broadcast). +func (s *Store) SetOnboardingCompleted(done bool) error { + s.mu.Lock() + if s.current.OnboardingCompleted == done { + s.mu.Unlock() + return nil + } + next := s.current + next.OnboardingCompleted = done + if err := s.persistLocked(next); err != nil { + s.mu.Unlock() + return fmt.Errorf("persist preferences: %w", err) + } + s.current = next + s.mu.Unlock() + + s.broadcast(next) + return nil +} + // SetLanguage validates and persists a new language preference, then // broadcasts the change to internal subscribers (tray) and the emitter // (frontend). diff --git a/client/ui/services/preferences.go b/client/ui/services/preferences.go index fc185bf30..27fd47a1c 100644 --- a/client/ui/services/preferences.go +++ b/client/ui/services/preferences.go @@ -36,3 +36,8 @@ func (s *Preferences) SetLanguage(_ context.Context, lang i18n.LanguageCode) err func (s *Preferences) SetViewMode(_ context.Context, mode preferences.ViewMode) error { return s.store.SetViewMode(mode) } + +// SetOnboardingCompleted persists the welcome-flow dismissal flag. +func (s *Preferences) SetOnboardingCompleted(_ context.Context, done bool) error { + return s.store.SetOnboardingCompleted(done) +} diff --git a/client/ui/services/windowmanager.go b/client/ui/services/windowmanager.go index e914dbde7..bb9ca399f 100644 --- a/client/ui/services/windowmanager.go +++ b/client/ui/services/windowmanager.go @@ -154,6 +154,7 @@ type WindowManager struct { sessionExpired *application.WebviewWindow sessionAboutToExpire *application.WebviewWindow installProgress *application.WebviewWindow + welcome *application.WebviewWindow // hiddenForLogin remembers windows that were visible when the // BrowserLogin popup opened. They were Hide()n to keep focus on the // SSO flow without resorting to AlwaysOnTop, and are restored when @@ -192,6 +193,26 @@ func (s *WindowManager) title(key string) string { // at click time. func NewWindowManager(app *application.App, mainWindow *application.WebviewWindow, translator ErrorTranslator, prefs LanguagePreference, linuxIcon []byte) *WindowManager { s := &WindowManager{app: app, mainWindow: mainWindow, translator: translator, prefs: prefs, linuxIcon: linuxIcon} + // If the prefs implementation also exposes Subscribe (the runtime + // *preferences.Store does), wire up a goroutine that re-titles every + // live auxiliary window on language flip. Done here — instead of via + // an exported WatchLanguage method on the service — so the Wails + // binding generator doesn't try to expose a LanguageSubscriber-taking + // method to the frontend (interface params can't round-trip through + // JSON and would emit a generator warning). + if sub, ok := prefs.(LanguageSubscriber); ok && sub != nil { + ch, _ := sub.Subscribe() + go func() { + var last i18n.LanguageCode + for p := range ch { + if p.Language == "" || p.Language == last { + continue + } + last = p.Language + s.retitleAll() + } + }() + } s.settings = app.Window.NewWithOptions(application.WebviewWindowOptions{ Name: "settings", Title: s.title("window.title.settings"), @@ -221,31 +242,6 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo return s } -// WatchLanguage subscribes to UI preference changes and re-applies the -// localised title to every live auxiliary window whenever the language -// flips. The eagerly-created Settings window outlives its first paint, so -// without this its title would stay frozen in the language it was created -// in; the on-demand dialog windows (BrowserLogin, Session*, InstallProgress) -// can also coexist with a Settings-driven language change. Safe to call -// once at startup; subsequent calls overwrite the previous subscription. -// No-op when sub is nil. -func (s *WindowManager) WatchLanguage(sub LanguageSubscriber) { - if sub == nil { - return - } - ch, _ := sub.Subscribe() - go func() { - var last i18n.LanguageCode - for p := range ch { - if p.Language == "" || p.Language == last { - continue - } - last = p.Language - s.retitleAll() - } - }() -} - // retitleAll re-applies the localised title to every currently-alive // auxiliary window. Reads the window pointers under s.mu so a concurrent // Open*/Close* can't observe a torn slice. SetTitle itself dispatches to @@ -262,6 +258,7 @@ func (s *WindowManager) retitleAll() { {s.sessionExpired, "window.title.sessionExpired"}, {s.sessionAboutToExpire, "window.title.sessionExpiring"}, {s.installProgress, "window.title.updating"}, + {s.welcome, "window.title.welcome"}, } s.mu.Unlock() for _, p := range wins { @@ -529,3 +526,57 @@ func (s *WindowManager) CloseInstallProgress() { w.Close() } } + +// OpenWelcome shows the first-launch onboarding window. The React side +// auto-sizes the window height to its content; the Continue button calls +// Preferences.SetOnboardingCompleted(true) before closing so the flow +// doesn't re-run. Singleton, destroyed on close. Created Hidden so the +// React side can auto-size before paint. +func (s *WindowManager) OpenWelcome() { + s.mu.Lock() + defer s.mu.Unlock() + if s.welcome == nil { + opts := DialogWindowOptions("welcome", s.title("window.title.welcome"), "/#/dialog/welcome", s.linuxIcon) + opts.Width = 420 + // Onboarding stays AlwaysOnTop (inherited from DialogWindowOptions) + // so the user can't accidentally bury the first-launch flow behind + // another window and lose track of how to finish setup. + // Land in the middle of the user's primary display — the welcome + // flow is identity-defining and shouldn't read as an incidental + // dialog floating in a corner. WindowCentered + nil Screen + // resolves against the primary display (see WebviewWindowOptions). + opts.InitialPosition = application.WindowCentered + s.welcome = s.app.Window.NewWithOptions(opts) + w := s.welcome + w.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { + s.mu.Lock() + s.welcome = nil + s.mu.Unlock() + }) + return + } + s.welcome.Show() + s.welcome.Focus() +} + +// CloseWelcome destroys the welcome window if open. +func (s *WindowManager) CloseWelcome() { + s.mu.Lock() + w := s.welcome + s.welcome = nil + s.mu.Unlock() + if w != nil { + w.Close() + } +} + +// OpenMain brings the main window forward. Used by the welcome Continue +// button to hand off from onboarding to the regular UI without depending +// on the tray. +func (s *WindowManager) OpenMain() { + if s.mainWindow == nil { + return + } + s.mainWindow.Show() + s.mainWindow.Focus() +}