From 6b2ae1c34c9819256bed677d433fc9d324c6ae58 Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Mon, 8 Jun 2026 18:09:22 +0200 Subject: [PATCH] update session expiration dialog texts, add monitor aware position --- client/ui/CLAUDE.md | 8 +- client/ui/frontend/CLAUDE.md | 9 +- client/ui/frontend/src/app.tsx | 6 +- .../src/components/dialog/ConfirmDialog.tsx | 2 +- .../frontend/src/hooks/useAutoSizeWindow.ts | 2 +- ...Dialog.tsx => SessionExpirationDialog.tsx} | 82 ++++++---- .../modules/session/SessionExpiredDialog.tsx | 55 ------- client/ui/i18n/locales/de/common.json | 29 ++-- client/ui/i18n/locales/en/common.json | 29 ++-- client/ui/i18n/locales/hu/common.json | 29 ++-- client/ui/services/cursor_darwin.go | 42 +++++ client/ui/services/cursor_linux.go | 54 ++++++ client/ui/services/cursor_other.go | 9 + client/ui/services/cursor_windows.go | 17 ++ client/ui/services/session.go | 2 +- client/ui/services/windowmanager.go | 154 ++++++++++-------- client/ui/tray.go | 2 +- client/ui/tray_events.go | 4 +- client/ui/tray_session.go | 12 +- 19 files changed, 321 insertions(+), 226 deletions(-) rename client/ui/frontend/src/modules/session/{SessionAboutToExpireDialog.tsx => SessionExpirationDialog.tsx} (70%) delete mode 100644 client/ui/frontend/src/modules/session/SessionExpiredDialog.tsx create mode 100644 client/ui/services/cursor_darwin.go create mode 100644 client/ui/services/cursor_linux.go create mode 100644 client/ui/services/cursor_other.go create mode 100644 client/ui/services/cursor_windows.go diff --git a/client/ui/CLAUDE.md b/client/ui/CLAUDE.md index 6b085d8ca..62b9d6eb7 100644 --- a/client/ui/CLAUDE.md +++ b/client/ui/CLAUDE.md @@ -37,7 +37,7 @@ 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` / `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). | +| `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. | @@ -94,13 +94,13 @@ The main window is created up front in `main.go`. Auxiliary windows are created - **Settings** (`/#/settings`) — opened from the header gear icon (`pages/main/Header.tsx → WindowManager.OpenSettings("")`), the tray's Settings menu entry (`tray.go openSettings`), and the profile dropdown's "Manage Profiles" entry (`WindowManager.OpenSettings("profiles")`, which sets `?tab=profiles` in the start URL — `Settings.tsx` reads it via `useSearchParams`). The window hosts every settings tab — including **Profiles** (`ProfilesTab.tsx`, `UserCircle` icon, sits between Security and SSH), which lists profiles in a table with Deregister/Delete in a per-row kebab and an Add Profile button. Both call sites go through `WindowManager` so the user sees the same dedicated frameless window from either trigger — the tray used to repurpose the main window via `SetURL("/#/settings")`, which replaced the main UI in place. Frameless-look (opaque macOS backdrop, hidden inset title bar), fixed 900×640, no resize, no minimise/maximise. **Unlike the other auxiliary windows**, Settings is created eagerly (hidden) inside `NewWindowManager` and hides on close instead of being destroyed — first open is instant. The window stays at a single URL (`/#/settings`) forever; `OpenSettings(tab)` does **not** call `SetURL`. Instead it emits `netbird:settings:open` with the target tab (empty → `"general"`), then calls `Show`/`Focus`. `SettingsPage` keeps the active tab in React local state and listens for the event to switch. **Reset-on-close lives in the React side**, not the Go close hook: `SettingsPage` listens for `document.visibilitychange` and resets the tab to General when the page goes hidden. Doing it via `Event.Emit` from the close hook didn't work — the dispatch goroutine races `Hide`, the JS listener often runs only after the *next* `Show`, and the user sees a one-frame flash of the previous tab. The Page Visibility API fires before WebKit throttles the page, so the state update lands while we're still in foreground JS. (The earlier `SetURL` path re-loaded the WKWebView entirely, re-mounting the `AppLayout` provider stack and visibly flashing the `SettingsSkeleton` while `SettingsContext` re-fetched config.) - **BrowserLogin** (`/#/dialog/browser-login?uri=…`) — opened by the connection toggle's SSO flow (`pages/main/ConnectionStatusSwitch.tsx`). 460×440, fixed size. The close button (red X) fires `EventBrowserLoginCancel` so the JS-side `startLogin()` can tear down the daemon's pending `WaitSSOLogin`. `WindowManager.CloseBrowserLogin` closes it programmatically when the flow completes. -- **SessionExpired** (`/#/dialog/session-expired`) and **SessionAboutToExpire** (`/#/dialog/session-about-to-expire?seconds=`) — opened by `WindowManager.OpenSessionExpired` / `OpenSessionAboutToExpire(seconds)`. 460×380, fixed size, `AlwaysOnTop: true` (the user can't miss them). The React-side buttons close the window via `WindowManager.CloseSession*` and (for Sign-in / Stay-connected) emit `EventTriggerLogin` so the main window's `startLogin()` orchestrator handles the SSO flow.Currently no triggers wired — daemon-status integration is a follow-up. +- **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/dialogs.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 five lazy auxiliary windows (BrowserLogin, SessionExpired, SessionAboutToExpire, 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. +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. @@ -117,7 +117,7 @@ Package layout: - `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.* / sessionExpired.* / peers.*` (frontend). Keep keys stable — renames cascade everywhere. +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 diff --git a/client/ui/frontend/CLAUDE.md b/client/ui/frontend/CLAUDE.md index 33b7fe1e7..8dbffd5bb 100644 --- a/client/ui/frontend/CLAUDE.md +++ b/client/ui/frontend/CLAUDE.md @@ -25,8 +25,7 @@ React 18 + TS 5.7 (`strict`, `noImplicitAny: false`) + Vite 6 + Tailwind 3 (`dar | `/` | `MainPage` (modules/main/) | `AppLayout` | Main window default route | | `/dialog/browser-login` | `LoginWaitingForBrowserDialog` (modules/login/) | none | Auxiliary window (Go `WindowManager.OpenBrowserLogin`) | | `/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/session-expiration` | `SessionExpirationDialog` (modules/session/) | none | Auxiliary window (Go `WindowManager.OpenSessionExpiration(seconds)`, always-on-top, mm:ss countdown via `?seconds=`). Drives both the soon-to-expire warning and (when seconds elapse to zero) the expired state. | | `/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. | | `/dialog/error` | `ErrorDialog` (modules/error/) | none | Auxiliary window (Go `WindowManager.OpenError(title, message)`, always-on-top). The app's single error surface — `lib/dialogs.ts`'s `errorDialog({Title, Message})` opens this instead of the old native OS MessageBox. `title` is the window chrome title (`"NetBird - <title>"`, set Go-side, not shown in body); `message` is read from `useSearchParams` and rendered as the left-aligned body next to a danger `SquareIcon`, with a bottom-right Close button (Escape also closes → `WindowManager.CloseError()`). | @@ -53,7 +52,7 @@ Page-specific chrome lives next to the page, not in the layout: - `modules/main/advanced/` — advanced-mode-only surfaces. `Navigation.tsx` plus the three feature sub-modules whose tabs only render here: `peers/`, `networks/`, `exit-nodes/`. - `modules/settings/` — `SettingsPage.tsx`, shared helpers (`SettingsSection.tsx`, `SettingsNavigation.tsx`, `SettingsSkeleton.tsx`), and all tab files flat (`SettingsGeneral`, `SettingsNetwork`, `SettingsSSH`, `SettingsSecurity`, `SettingsAdvanced`, `SettingsTroubleshooting`, `SettingsAbout`, `SettingsAccent`). `ManagementServerSwitch` and `LanguagePicker` are shared in `components/`; `useManagementUrl` is in `hooks/`. - `modules/login/` — `LoginWaitingForBrowserDialog.tsx` (the SSO browser-wait window). - - `modules/session/` — `SessionExpiredDialog.tsx` and `SessionAboutToExpireDialog.tsx` (session lifecycle dialog windows). + - `modules/session/` — `SessionExpirationDialog.tsx` (session expiration warning + expired state). - `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/`. The creation modal collects both the profile name and a management target (Cloud vs self-hosted + URL, reusing `ManagementServerSwitch` + the `useManagementUrl` helpers like the onboarding step); `ProfilesTab.handleCreate` adds the profile, `Settings.SetConfig`s the chosen `managementUrl` onto it (keyed by profile name, before switching), then switches to it. Row actions (switch/deregister/delete) confirm via the shared `useConfirm()` modal. - `modules/error/` — `ErrorDialog.tsx`, the custom always-on-top error window that replaced the native OS MessageBox. Opened by Go `WindowManager.OpenError(title, message)`, driven from the frontend by `errorDialog({Title, Message})` in `lib/dialogs.ts`. @@ -158,7 +157,7 @@ Compare against the variable, never against an English literal. **Language picker.** `src/components/LanguagePicker.tsx` is mounted inside the Language section of `SettingsGeneral.tsx`. It populates from `I18n.Languages()` (matches `_index.json`) and calls `Preferences.SetLanguage(code)` on selection. The preference write triggers `netbird:preferences:changed`, which both the local i18next instance and every other open window listen to. -**What gets translated.** Every user-facing string in the polished AppLayout/Settings/Update/BrowserLogin/SessionExpired/Peers surfaces. Don't add hard-coded user-facing English to new code — add the key, then `t()`. Internal log strings, dev-only forced-state strings in `ClientVersionContext`, and the `Update failed` fallback fed into `classifyError()` (which then renders a translated description) are not translated. +**What gets translated.** Every user-facing string in the polished AppLayout/Settings/Update/BrowserLogin/SessionExpiration/Peers surfaces. Don't add hard-coded user-facing English to new code — add the key, then `t()`. Internal log strings, dev-only forced-state strings in `ClientVersionContext`, and the `Update failed` fallback fed into `classifyError()` (which then renders a translated description) are not translated. ## Login flow (`startLogin` in `ConnectionStatusSwitch.tsx`) @@ -202,7 +201,7 @@ Defined in `tailwind.config.ts`. `nb-gray` is the neutral palette (background = ## Things in flight (don't be surprised by) - **`screens/Peers.tsx`** uses live `Peers.Get` data. **`modules/peers/Peers.tsx`** uses `mockPeers.ts`. The mock-driven one is mounted under `Main.tsx`'s `AppRightPanel` and is what the user sees today; the real-data one isn't wired into the route table. -- **`modules/session/SessionExpiredDialog.tsx`** and **`modules/session/SessionAboutToExpireDialog.tsx`** are the always-on-top auxiliary windows. No triggers wired today — a daemon-status hook (status `SessionExpired`, plus a future "about-to-expire" signal) will drive them later. Sign-in / Stay-connected emit `EventTriggerLogin` so the main window's `startLogin()` orchestrator handles the SSO flow; Logout uses `Connection.Logout({profileName, username})`. +- **`modules/session/SessionExpirationDialog.tsx`** is the always-on-top auxiliary window for the SSO expiration warning. Triggered by the tray (`tray_session.go openSessionExpiration` at T-FinalWarningLead; `openSessionExtendFlow` from the "Expires in …" tray row). Sign-in / Stay-connected emit `EventTriggerLogin` so the main window's `startLogin()` orchestrator handles the SSO flow; Logout uses `Connection.Logout({profileName, username})`. When the countdown hits zero the same component flips to the "expired" copy (`sessionExpiration.expired*` keys). ## Wails Go API reference diff --git a/client/ui/frontend/src/app.tsx b/client/ui/frontend/src/app.tsx index 3afcc802b..1f143fbd6 100644 --- a/client/ui/frontend/src/app.tsx +++ b/client/ui/frontend/src/app.tsx @@ -2,8 +2,7 @@ import React from "react"; import ReactDOM from "react-dom/client"; import "./globals.css"; import { HashRouter, Navigate, Route, Routes } from "react-router-dom"; -import SessionExpiredDialog from "@/modules/session/SessionExpiredDialog.tsx"; -import SessionAboutToExpireDialog from "@/modules/session/SessionAboutToExpireDialog.tsx"; +import SessionExpirationDialog from "@/modules/session/SessionExpirationDialog.tsx"; import UpdateInProgressDialog from "@/modules/auto-update/UpdateInProgressDialog.tsx"; import WelcomeDialog from "@/modules/welcome/WelcomeDialog.tsx"; import ErrorDialog from "@/modules/error/ErrorDialog.tsx"; @@ -39,8 +38,7 @@ Promise.all([ <Route path="dialog"> <Route path="browser-login" element={<LoginWaitingForBrowserDialog />} /> <Route path="install-progress" element={<UpdateInProgressDialog />} /> - <Route path="session-expired" element={<SessionExpiredDialog />} /> - <Route path="session-about-to-expire" element={<SessionAboutToExpireDialog />} /> + <Route path="session-expiration" element={<SessionExpirationDialog />} /> <Route path="welcome" element={<WelcomeDialog />} /> <Route path="error" element={<ErrorDialog />} /> </Route> diff --git a/client/ui/frontend/src/components/dialog/ConfirmDialog.tsx b/client/ui/frontend/src/components/dialog/ConfirmDialog.tsx index 06ea19802..161944e1d 100644 --- a/client/ui/frontend/src/components/dialog/ConfirmDialog.tsx +++ b/client/ui/frontend/src/components/dialog/ConfirmDialog.tsx @@ -3,7 +3,7 @@ import { cn } from "@/lib/cn.ts"; import { isMacOS } from "@/lib/platform.ts"; // ConfirmDialog is the shared layout wrapper used by dialog-style window -// surfaces (SessionExpired, SessionAboutToExpire, …). Purely a layout +// surfaces (SessionExpiration, …). Purely a layout // primitive — callers compose the contents (SquareIcon, DialogHeading, // DialogDescription, DialogActions) so each dialog can tweak its own // internal structure without growing the ConfirmDialog API. diff --git a/client/ui/frontend/src/hooks/useAutoSizeWindow.ts b/client/ui/frontend/src/hooks/useAutoSizeWindow.ts index 2d0c32a28..cbb59aa9c 100644 --- a/client/ui/frontend/src/hooks/useAutoSizeWindow.ts +++ b/client/ui/frontend/src/hooks/useAutoSizeWindow.ts @@ -13,7 +13,7 @@ import i18next from "@/lib/i18n"; // measurements (content changes after mount) only adjust the size. // // Re-measures via ResizeObserver so adding/removing content (e.g. the -// SessionAboutToExpire title swapping at countdown zero) keeps the chrome +// SessionExpiration title swapping at countdown zero) keeps the chrome // tight to the content with no scrollbar. // // Also re-measures on i18next `languageChanged`. The ResizeObserver in diff --git a/client/ui/frontend/src/modules/session/SessionAboutToExpireDialog.tsx b/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx similarity index 70% rename from client/ui/frontend/src/modules/session/SessionAboutToExpireDialog.tsx rename to client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx index 232c3062a..3ec4423c5 100644 --- a/client/ui/frontend/src/modules/session/SessionAboutToExpireDialog.tsx +++ b/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx @@ -1,20 +1,16 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useSearchParams } from "react-router-dom"; +import { Events } from "@wailsio/runtime"; import { errorDialog } from "@/lib/dialogs.ts"; -import { ClockIcon } from "lucide-react"; +import { AlertCircleIcon, ClockIcon } from "lucide-react"; import { Button } from "@/components/buttons/Button"; import { ConfirmDialog } from "@/components/dialog/ConfirmDialog"; import { DialogActions } from "@/components/dialog/DialogActions"; import { DialogDescription } from "@/components/dialog/DialogDescription"; import { DialogHeading } from "@/components/dialog/DialogHeading"; import { SquareIcon } from "@/components/SquareIcon"; -import { - Connection, - Profiles as ProfilesSvc, - Session, - WindowManager, -} from "@bindings/services"; +import { Connection, Profiles as ProfilesSvc, Session, WindowManager } from "@bindings/services"; import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow"; import { formatErrorMessage } from "@/lib/errors.ts"; @@ -40,7 +36,7 @@ function formatRemaining(seconds: number): string { return `${pad(minutes)}:${pad(secs)}`; } -export default function SessionAboutToExpireDialog() { +export default function SessionExpirationDialog() { const { t } = useTranslation(); const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH); const [params] = useSearchParams(); @@ -68,6 +64,20 @@ export default function SessionAboutToExpireDialog() { return () => window.clearInterval(id); }, [remaining]); + // Auto-close when the daemon flips back to Connected — covers extend + // flows started from outside this window (tray notification action, + // another UI surface) so the user isn't left staring at a stale dialog. + useEffect(() => { + const off = Events.On("netbird:status", (ev: { data: { status?: string } }) => { + if (ev?.data?.status === "Connected") { + WindowManager.CloseSessionExpiration().catch(console.error); + } + }); + return () => { + off(); + }; + }, []); + // Mirrors tray.go::runExtendSession: starts the daemon SSO extend flow, // opens the browser for the user to sign in, blocks on the daemon until // the new deadline arrives. Tunnel stays up; success simply closes the @@ -100,10 +110,10 @@ export default function SessionAboutToExpireDialog() { // relevant. return; } - WindowManager.CloseSessionAboutToExpire().catch(console.error); + WindowManager.CloseSessionExpiration().catch(console.error); } catch (e) { await errorDialog({ - Title: t("sessionAboutToExpire.extendFailedTitle"), + Title: t("sessionExpiration.extendFailedTitle"), Message: formatErrorMessage(e), }); } finally { @@ -121,10 +131,10 @@ export default function SessionAboutToExpireDialog() { profileName: active.profileName || "default", username, }); - WindowManager.CloseSessionAboutToExpire().catch(console.error); + WindowManager.CloseSessionExpiration().catch(console.error); } catch (e) { await errorDialog({ - Title: t("sessionAboutToExpire.logoutFailedTitle"), + Title: t("sessionExpiration.logoutFailedTitle"), Message: formatErrorMessage(e), }); } finally { @@ -132,33 +142,41 @@ export default function SessionAboutToExpireDialog() { } }, [busy, t]); + const close = useCallback(() => { + WindowManager.CloseSessionExpiration().catch(console.error); + }, []); + return ( <ConfirmDialog ref={contentRef}> - <SquareIcon icon={ClockIcon} /> + <SquareIcon icon={expired ? AlertCircleIcon : ClockIcon} /> <div className={"flex flex-col items-center gap-1"}> <DialogHeading> {expired - ? t("sessionAboutToExpire.expired") + ? t("sessionExpiration.expired") : soon - ? t("sessionAboutToExpire.title") - : t("sessionAboutToExpire.titleLater")} + ? t("sessionExpiration.title") + : t("sessionExpiration.titleLater")} </DialogHeading> <DialogDescription> - {soon - ? t("sessionAboutToExpire.description") - : t("sessionAboutToExpire.descriptionLater")} + {expired + ? t("sessionExpiration.expiredDescription") + : soon + ? t("sessionExpiration.description") + : t("sessionExpiration.descriptionLater")} </DialogDescription> </div> - <div - className={ - "font-mono font-semibold text-2xl tabular-nums text-nb-gray-50 tracking-wider" - } - aria-live={"polite"} - > - {formatRemaining(remaining)} - </div> + {!expired && ( + <div + className={ + "font-mono font-semibold text-2xl tabular-nums text-nb-gray-50 tracking-wider" + } + aria-live={"polite"} + > + {formatRemaining(remaining)} + </div> + )} <DialogActions> <Button @@ -167,18 +185,20 @@ export default function SessionAboutToExpireDialog() { size={"md"} className={"w-full"} onClick={stay} - disabled={expired || busy} + disabled={busy} > - {t("sessionAboutToExpire.stay")} + {expired + ? t("sessionExpiration.authenticate") + : t("sessionExpiration.stay")} </Button> <Button variant={"secondary"} size={"md"} className={"w-full"} - onClick={logout} + onClick={expired ? close : logout} disabled={busy} > - {t("sessionAboutToExpire.logout")} + {expired ? t("sessionExpiration.close") : t("sessionExpiration.logout")} </Button> </DialogActions> </ConfirmDialog> diff --git a/client/ui/frontend/src/modules/session/SessionExpiredDialog.tsx b/client/ui/frontend/src/modules/session/SessionExpiredDialog.tsx deleted file mode 100644 index a4a44285a..000000000 --- a/client/ui/frontend/src/modules/session/SessionExpiredDialog.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { useCallback } from "react"; -import { useTranslation } from "react-i18next"; -import { Events } from "@wailsio/runtime"; -import { AlertCircleIcon } from "lucide-react"; -import { Button } from "@/components/buttons/Button"; -import { ConfirmDialog } from "@/components/dialog/ConfirmDialog"; -import { DialogActions } from "@/components/dialog/DialogActions"; -import { DialogDescription } from "@/components/dialog/DialogDescription"; -import { DialogHeading } from "@/components/dialog/DialogHeading"; -import { SquareIcon } from "@/components/SquareIcon"; -import { WindowManager } from "@bindings/services"; -import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow"; - -const EVENT_TRIGGER_LOGIN = "trigger-login"; -const WINDOW_WIDTH = 360; - -export default function SessionExpiredDialog() { - const { t } = useTranslation(); - const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH); - - const signIn = useCallback(() => { - void Events.Emit(EVENT_TRIGGER_LOGIN); - WindowManager.CloseSessionExpired().catch(console.error); - }, []); - - const later = useCallback(() => { - WindowManager.CloseSessionExpired().catch(console.error); - }, []); - - return ( - <ConfirmDialog ref={contentRef}> - <SquareIcon icon={AlertCircleIcon} /> - - <div className={"flex flex-col items-center gap-1"}> - <DialogHeading>{t("sessionExpired.title")}</DialogHeading> - <DialogDescription>{t("sessionExpired.description")}</DialogDescription> - </div> - - <DialogActions> - <Button - autoFocus - variant={"primary"} - size={"md"} - className={"w-full"} - onClick={signIn} - > - {t("sessionExpired.signIn")} - </Button> - <Button variant={"secondary"} size={"md"} className={"w-full"} onClick={later}> - {t("sessionExpired.later")} - </Button> - </DialogActions> - </ConfirmDialog> - ); -} diff --git a/client/ui/i18n/locales/de/common.json b/client/ui/i18n/locales/de/common.json index 2db418475..32f4c71c2 100644 --- a/client/ui/i18n/locales/de/common.json +++ b/client/ui/i18n/locales/de/common.json @@ -341,8 +341,7 @@ "window.title.settings": "Einstellungen", "window.title.signIn": "Anmeldung", - "window.title.sessionExpired": "Sitzung abgelaufen", - "window.title.sessionExpiring": "Sitzung läuft ab", + "window.title.sessionExpiration": "Sitzung läuft ab", "window.title.updating": "Aktualisierung", "window.title.welcome": "Willkommen bei NetBird", "window.title.error": "Fehler", @@ -368,20 +367,18 @@ "browserLogin.tryAgain": "Erneut versuchen", "browserLogin.openFailedTitle": "Browser konnte nicht geöffnet werden", - "sessionExpired.title": "Sitzung abgelaufen", - "sessionExpired.description": "Ihre NetBird-Sitzung ist abgelaufen. Melden Sie sich erneut an, damit Ihre Geräte verbunden bleiben.", - "sessionExpired.later": "Später", - "sessionExpired.signIn": "Anmelden", - - "sessionAboutToExpire.title": "Sitzung läuft bald ab", - "sessionAboutToExpire.titleLater": "Ihre Sitzung läuft ab", - "sessionAboutToExpire.description": "Dieses Gerät wird bald getrennt. Browser-Anmeldung zum Erneuern erforderlich.", - "sessionAboutToExpire.descriptionLater": "Eine Browser-Anmeldung hält dieses Gerät mit Ihrem Netzwerk verbunden.", - "sessionAboutToExpire.stay": "Sitzung erneuern", - "sessionAboutToExpire.logout": "Abmelden", - "sessionAboutToExpire.expired": "Sitzung abgelaufen", - "sessionAboutToExpire.extendFailedTitle": "Sitzungsverlängerung fehlgeschlagen", - "sessionAboutToExpire.logoutFailedTitle": "Abmeldung fehlgeschlagen", + "sessionExpiration.title": "Sitzung läuft bald ab", + "sessionExpiration.titleLater": "Ihre Sitzung läuft ab", + "sessionExpiration.description": "Dieses Gerät wird bald getrennt. Browser-Anmeldung zum Erneuern erforderlich.", + "sessionExpiration.descriptionLater": "Eine Browser-Anmeldung hält dieses Gerät mit Ihrem Netzwerk verbunden.", + "sessionExpiration.stay": "Sitzung erneuern", + "sessionExpiration.authenticate": "Anmelden", + "sessionExpiration.logout": "Abmelden", + "sessionExpiration.expired": "Sitzung abgelaufen", + "sessionExpiration.expiredDescription": "Gerät getrennt. Mit Browser-Anmeldung authentifizieren, um erneut zu verbinden.", + "sessionExpiration.close": "Schließen", + "sessionExpiration.extendFailedTitle": "Sitzungsverlängerung fehlgeschlagen", + "sessionExpiration.logoutFailedTitle": "Abmeldung fehlgeschlagen", "peers.search.placeholder": "Nach Name oder IP suchen", "peers.filter.all": "Alle", diff --git a/client/ui/i18n/locales/en/common.json b/client/ui/i18n/locales/en/common.json index 2698e2b12..c07bc76be 100644 --- a/client/ui/i18n/locales/en/common.json +++ b/client/ui/i18n/locales/en/common.json @@ -343,8 +343,7 @@ "window.title.settings": "Settings", "window.title.signIn": "Sign-in", - "window.title.sessionExpired": "Session Expired", - "window.title.sessionExpiring": "Session Expiring", + "window.title.sessionExpiration": "Session Expiring", "window.title.updating": "Updating", "window.title.welcome": "Welcome to NetBird", "window.title.error": "Error", @@ -370,20 +369,18 @@ "browserLogin.tryAgain": "Try again", "browserLogin.openFailedTitle": "Open Browser Failed", - "sessionExpired.title": "Session expired", - "sessionExpired.description": "Your NetBird session has expired. Sign in again to keep your devices connected.", - "sessionExpired.later": "Later", - "sessionExpired.signIn": "Sign in", - - "sessionAboutToExpire.title": "Session expiring soon", - "sessionAboutToExpire.titleLater": "Your session will expire", - "sessionAboutToExpire.description": "This device will disconnect soon. Renew with a browser sign-in.", - "sessionAboutToExpire.descriptionLater": "A browser sign-in keeps this device connected to your network.", - "sessionAboutToExpire.stay": "Renew session", - "sessionAboutToExpire.logout": "Logout", - "sessionAboutToExpire.expired": "Session expired", - "sessionAboutToExpire.extendFailedTitle": "Extend Session Failed", - "sessionAboutToExpire.logoutFailedTitle": "Logout Failed", + "sessionExpiration.title": "Session expiring soon", + "sessionExpiration.titleLater": "Your session will expire", + "sessionExpiration.description": "This device will disconnect soon. Renew with a browser sign-in.", + "sessionExpiration.descriptionLater": "A browser sign-in keeps this device connected to your network.", + "sessionExpiration.stay": "Renew session", + "sessionExpiration.authenticate": "Authenticate", + "sessionExpiration.logout": "Logout", + "sessionExpiration.expired": "Session expired", + "sessionExpiration.expiredDescription": "Device disconnected. Authenticate with a browser sign-in to reconnect.", + "sessionExpiration.close": "Close", + "sessionExpiration.extendFailedTitle": "Extend Session Failed", + "sessionExpiration.logoutFailedTitle": "Logout Failed", "peers.search.placeholder": "Search by name or IP", "peers.filter.all": "All", diff --git a/client/ui/i18n/locales/hu/common.json b/client/ui/i18n/locales/hu/common.json index af6cfdea6..f177b7890 100644 --- a/client/ui/i18n/locales/hu/common.json +++ b/client/ui/i18n/locales/hu/common.json @@ -341,8 +341,7 @@ "window.title.settings": "Beállítások", "window.title.signIn": "Bejelentkezés", - "window.title.sessionExpired": "Munkamenet lejárt", - "window.title.sessionExpiring": "Munkamenet lejár", + "window.title.sessionExpiration": "Munkamenet lejár", "window.title.updating": "Frissítés", "window.title.welcome": "Üdvözli a NetBird", "window.title.error": "Hiba", @@ -368,20 +367,18 @@ "browserLogin.tryAgain": "Próbálja újra", "browserLogin.openFailedTitle": "A böngésző megnyitása sikertelen", - "sessionExpired.title": "Munkamenet lejárt", - "sessionExpired.description": "A NetBird munkamenete lejárt. Jelentkezzen be újra, hogy az eszközei kapcsolatban maradjanak.", - "sessionExpired.later": "Később", - "sessionExpired.signIn": "Bejelentkezés", - - "sessionAboutToExpire.title": "A munkamenet hamarosan lejár", - "sessionAboutToExpire.titleLater": "A munkamenete lejár", - "sessionAboutToExpire.description": "Az eszköz hamarosan lecsatlakozik. Megújításhoz böngészős bejelentkezés kell.", - "sessionAboutToExpire.descriptionLater": "Egy böngészős bejelentkezés a hálózaton tartja az eszközt.", - "sessionAboutToExpire.stay": "Munkamenet megújítása", - "sessionAboutToExpire.logout": "Kijelentkezés", - "sessionAboutToExpire.expired": "Munkamenet lejárt", - "sessionAboutToExpire.extendFailedTitle": "A munkamenet meghosszabbítása sikertelen", - "sessionAboutToExpire.logoutFailedTitle": "Kijelentkezés sikertelen", + "sessionExpiration.title": "A munkamenet hamarosan lejár", + "sessionExpiration.titleLater": "A munkamenete lejár", + "sessionExpiration.description": "Az eszköz hamarosan lecsatlakozik. Megújításhoz böngészős bejelentkezés kell.", + "sessionExpiration.descriptionLater": "Egy böngészős bejelentkezés a hálózaton tartja az eszközt.", + "sessionExpiration.stay": "Munkamenet megújítása", + "sessionExpiration.authenticate": "Bejelentkezés", + "sessionExpiration.logout": "Kijelentkezés", + "sessionExpiration.expired": "Munkamenet lejárt", + "sessionExpiration.expiredDescription": "Eszköz lecsatlakozott. Hitelesítés böngészős bejelentkezéssel az újracsatlakozáshoz.", + "sessionExpiration.close": "Bezárás", + "sessionExpiration.extendFailedTitle": "A munkamenet meghosszabbítása sikertelen", + "sessionExpiration.logoutFailedTitle": "Kijelentkezés sikertelen", "peers.search.placeholder": "Keresés név vagy IP alapján", "peers.filter.all": "Összes", diff --git a/client/ui/services/cursor_darwin.go b/client/ui/services/cursor_darwin.go new file mode 100644 index 000000000..a927ff719 --- /dev/null +++ b/client/ui/services/cursor_darwin.go @@ -0,0 +1,42 @@ +//go:build darwin + +package services + +/* +#cgo CFLAGS: -x objective-c +#cgo LDFLAGS: -framework Foundation -framework Cocoa -framework AppKit +#import <Cocoa/Cocoa.h> +#import <AppKit/AppKit.h> + +typedef struct CursorPoint { + int x; + int y; + int ok; +} CursorPoint; + +// NSEvent.mouseLocation is Y-up from primary's bottom-left; flip against +// the primary's frame height so the point matches Wails' Y-down Screen.Bounds. +CursorPoint nbGetCursorPos(void) { + CursorPoint p = {0, 0, 0}; + NSArray<NSScreen *> *screens = [NSScreen screens]; + if (screens == nil || screens.count == 0) return p; + NSScreen *primary = [screens firstObject]; + if (primary == nil) return p; + NSPoint loc = [NSEvent mouseLocation]; + p.x = (int)loc.x; + p.y = (int)(primary.frame.size.height - loc.y); + p.ok = 1; + return p; +} +*/ +import "C" + +import "github.com/wailsapp/wails/v3/pkg/application" + +func getCursorPosition(_ *application.App) (application.Point, bool) { + res := C.nbGetCursorPos() + if res.ok == 0 { + return application.Point{}, false + } + return application.Point{X: int(res.x), Y: int(res.y)}, true +} diff --git a/client/ui/services/cursor_linux.go b/client/ui/services/cursor_linux.go new file mode 100644 index 000000000..5edd58429 --- /dev/null +++ b/client/ui/services/cursor_linux.go @@ -0,0 +1,54 @@ +//go:build linux + +package services + +/* +#cgo pkg-config: x11 +#cgo LDFLAGS: -lX11 +#include <X11/Xlib.h> +#include <stdlib.h> + +typedef struct CursorPoint { + int x; + int y; + int ok; +} CursorPoint; + +// XQueryPointer hits Xorg directly on X11 sessions and XWayland on +// Wayland sessions (shipped by default on the supported distros). ok=0 +// when no X server is reachable — caller falls back gracefully. +CursorPoint nbGetCursorPos(void) { + CursorPoint p = {0, 0, 0}; + Display *dpy = XOpenDisplay(NULL); + if (!dpy) return p; + Window root = DefaultRootWindow(dpy); + if (root == 0) { XCloseDisplay(dpy); return p; } + Window root_return = 0, child_return = 0; + int root_x = 0, root_y = 0, win_x = 0, win_y = 0; + unsigned int mask_return = 0; + if (XQueryPointer(dpy, root, &root_return, &child_return, + &root_x, &root_y, &win_x, &win_y, &mask_return)) { + p.x = root_x; + p.y = root_y; + p.ok = 1; + } + XCloseDisplay(dpy); + return p; +} +*/ +import "C" + +import "github.com/wailsapp/wails/v3/pkg/application" + +func getCursorPosition(app *application.App) (application.Point, bool) { + res := C.nbGetCursorPos() + if res.ok == 0 { + return application.Point{}, false + } + p := application.Point{X: int(res.x), Y: int(res.y)} + // X11 root coords are physical pixels; Screen.Bounds is in DIPs. + if app == nil || app.Screen == nil { + return p, true + } + return app.Screen.PhysicalToDipPoint(p), true +} diff --git a/client/ui/services/cursor_other.go b/client/ui/services/cursor_other.go new file mode 100644 index 000000000..ed9d55938 --- /dev/null +++ b/client/ui/services/cursor_other.go @@ -0,0 +1,9 @@ +//go:build !darwin && !windows && !linux + +package services + +import "github.com/wailsapp/wails/v3/pkg/application" + +func getCursorPosition(_ *application.App) (application.Point, bool) { + return application.Point{}, false +} diff --git a/client/ui/services/cursor_windows.go b/client/ui/services/cursor_windows.go new file mode 100644 index 000000000..42ed32d74 --- /dev/null +++ b/client/ui/services/cursor_windows.go @@ -0,0 +1,17 @@ +//go:build windows + +package services + +import ( + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/w32" +) + +func getCursorPosition(app *application.App) (application.Point, bool) { + x, y, ok := w32.GetCursorPos() + if !ok || app == nil || app.Screen == nil { + return application.Point{}, false + } + // GetCursorPos is in physical pixels; Screen.Bounds is in DIPs. + return app.Screen.PhysicalToDipPoint(application.Point{X: x, Y: y}), true +} diff --git a/client/ui/services/session.go b/client/ui/services/session.go index 862bb5710..a4e103ec1 100644 --- a/client/ui/services/session.go +++ b/client/ui/services/session.go @@ -20,7 +20,7 @@ type ( // Session is the Wails-bound wrapper around authsession.Session. It only // re-exposes the subset the React frontend actually calls -// (SessionAboutToExpireDialog.tsx: RequestExtend + WaitExtend). The tray +// (SessionExpirationDialog.tsx: RequestExtend + WaitExtend). The tray // uses authsession.Session directly, so methods that only the tray needs // (DismissWarning) are deliberately absent here — keeping the generated // TS surface minimal. diff --git a/client/ui/services/windowmanager.go b/client/ui/services/windowmanager.go index ca241558b..d3d06324b 100644 --- a/client/ui/services/windowmanager.go +++ b/client/ui/services/windowmanager.go @@ -105,8 +105,8 @@ func LinuxAppearanceOptions(icon []byte) application.LinuxWindow { } // DialogWindowOptions is the baseline for every auxiliary dialog window -// (BrowserLogin, SessionExpired, SessionAboutToExpire, InstallProgress). -// All four share size (360x320), the no-resize / no-min / no-max chrome, +// (BrowserLogin, SessionExpiration, InstallProgress). +// All share size (360x320), the no-resize / no-min / no-max chrome, // Hidden-on-create (so the React side can auto-size before first paint), // AlwaysOnTop (the dialogs interrupt the user, the SSO popup overrides // this), and the shared background/Mac/Windows appearance. Callers fill @@ -152,8 +152,7 @@ type WindowManager struct { linuxIcon []byte settings *application.WebviewWindow browserLogin *application.WebviewWindow - sessionExpired *application.WebviewWindow - sessionAboutToExpire *application.WebviewWindow + sessionExpiration *application.WebviewWindow installProgress *application.WebviewWindow welcome *application.WebviewWindow errorDialog *application.WebviewWindow @@ -268,8 +267,7 @@ func (s *WindowManager) retitleAll() { wins := []pair{ {s.settings, "window.title.settings"}, {s.browserLogin, "window.title.signIn"}, - {s.sessionExpired, "window.title.sessionExpired"}, - {s.sessionAboutToExpire, "window.title.sessionExpiring"}, + {s.sessionExpiration, "window.title.sessionExpiration"}, {s.installProgress, "window.title.updating"}, {s.welcome, "window.title.welcome"}, {s.errorDialog, "window.title.error"}, @@ -428,77 +426,37 @@ func (s *WindowManager) CloseBrowserLogin() { } } -// OpenSessionExpired shows the "session expired" prompt window above all -// other application windows. Singleton — destroyed on close. -// -// The window is created Hidden so the React side can measure its content -// and call Window.SetSize + Window.Show before the user sees the chrome — -// otherwise the user would briefly see the 360x320 placeholder snapping to -// the measured height. Re-opens (singleton already alive) Show/Focus -// directly here. -func (s *WindowManager) OpenSessionExpired() { +// OpenSessionExpiration shows the countdown warning above all other +// windows on the display the cursor is currently on. `seconds` seeds the +// mm:ss countdown rendered React-side. Singleton, destroyed on close. +func (s *WindowManager) OpenSessionExpiration(seconds int) { s.mu.Lock() defer s.mu.Unlock() - if s.sessionExpired == nil { - s.sessionExpired = s.app.Window.NewWithOptions( - DialogWindowOptions("session-expired", s.title("window.title.sessionExpired"), "/#/dialog/session-expired", s.linuxIcon), - ) - s.sessionExpired.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { + startURL := "/#/dialog/session-expiration?seconds=" + strconv.Itoa(seconds) + if s.sessionExpiration == nil { + opts := DialogWindowOptions("session-expiration", s.title("window.title.sessionExpiration"), startURL, s.linuxIcon) + opts.Screen = s.getScreenBasedOnCursorPosition() + opts.InitialPosition = application.WindowCentered + s.sessionExpiration = s.app.Window.NewWithOptions(opts) + s.sessionExpiration.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { s.mu.Lock() - s.sessionExpired = nil + s.sessionExpiration = nil s.mu.Unlock() }) - s.centerWhenReady(s.sessionExpired) + s.centerOnCursorScreen(s.sessionExpiration) return } - s.sessionExpired.Show() - s.sessionExpired.Focus() - s.centerWhenReady(s.sessionExpired) + s.sessionExpiration.SetURL(startURL) + s.centerOnCursorScreen(s.sessionExpiration) + s.sessionExpiration.Show() + s.sessionExpiration.Focus() } -// CloseSessionExpired destroys the session-expired window if open. -func (s *WindowManager) CloseSessionExpired() { +// CloseSessionExpiration destroys the countdown warning window if open. +func (s *WindowManager) CloseSessionExpiration() { s.mu.Lock() - w := s.sessionExpired - s.sessionExpired = nil - s.mu.Unlock() - if w != nil { - w.Close() - } -} - -// OpenSessionAboutToExpire shows the countdown warning window above all -// other application windows. `seconds` seeds the initial countdown value -// rendered as mm:ss in the React layer. Singleton — destroyed on close. -// Window is created Hidden so the React side can auto-size before paint -// (see OpenSessionExpired comment). -func (s *WindowManager) OpenSessionAboutToExpire(seconds int) { - s.mu.Lock() - defer s.mu.Unlock() - startURL := "/#/dialog/session-about-to-expire?seconds=" + strconv.Itoa(seconds) - if s.sessionAboutToExpire == nil { - s.sessionAboutToExpire = s.app.Window.NewWithOptions( - DialogWindowOptions("session-about-to-expire", s.title("window.title.sessionExpiring"), startURL, s.linuxIcon), - ) - s.sessionAboutToExpire.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { - s.mu.Lock() - s.sessionAboutToExpire = nil - s.mu.Unlock() - }) - s.centerWhenReady(s.sessionAboutToExpire) - return - } - s.sessionAboutToExpire.SetURL(startURL) - s.sessionAboutToExpire.Show() - s.sessionAboutToExpire.Focus() - s.centerWhenReady(s.sessionAboutToExpire) -} - -// CloseSessionAboutToExpire destroys the countdown warning window if open. -func (s *WindowManager) CloseSessionAboutToExpire() { - s.mu.Lock() - w := s.sessionAboutToExpire - s.sessionAboutToExpire = nil + w := s.sessionExpiration + s.sessionExpiration = nil s.mu.Unlock() if w != nil { w.Close() @@ -698,6 +656,27 @@ func (s *WindowManager) SetRecenterOnShow(pred func() bool) { s.recenterOnShow = pred } +// getScreenBasedOnCursorPosition returns the display the OS cursor is +// on, falling back through main-window screen → nil (Wails treats nil +// as OS-default placement). Linux uses XQueryPointer via XWayland on +// Wayland sessions, which ships by default on the supported distros. +func (s *WindowManager) getScreenBasedOnCursorPosition() *application.Screen { + if s.app == nil || s.app.Screen == nil { + return nil + } + if p, ok := getCursorPosition(s.app); ok { + if sc := s.app.Screen.ScreenNearestDipPoint(p); sc != nil { + return sc + } + } + if s.mainWindow != nil { + if sc, err := s.mainWindow.GetScreen(); err == nil { + return sc + } + } + return nil +} + // centerWhenReady centers w once its native window actually exists — but only // in environments where the WM won't do it for us (recenterOnShow). On full // desktops the WM centers small windows and restores position across hide -> @@ -733,3 +712,44 @@ func (s *WindowManager) centerWhenReady(w *application.WebviewWindow) { } }() } + +// centerOnCursorScreen centers w in the work area of the display the +// cursor is on. Each guard is a no-op (nil window, no cursor screen, +// zero size, zero work area) so a headless / no-monitor session is safe. +// On minimal WMs (recenterOnShow → Fluxbox/XEmbed) the same retry loop +// centerWhenReady uses kicks in: Linux SetPosition silently no-ops while +// the GdkSurface is nil, and a non-zero post-move Position is the +// signal that it landed. +func (s *WindowManager) centerOnCursorScreen(w *application.WebviewWindow) { + if w == nil { + return + } + place := func() { + screen := s.getScreenBasedOnCursorPosition() + if screen == nil { + return + } + width, height := w.Size() + if width <= 0 || height <= 0 { + return + } + wa := screen.WorkArea + if wa.Width <= 0 || wa.Height <= 0 { + return + } + w.SetPosition(wa.X+(wa.Width-width)/2, wa.Y+(wa.Height-height)/2) + } + place() + if s.recenterOnShow == nil || !s.recenterOnShow() { + return + } + go func() { + for i := 0; i < 50; i++ { + place() + if x, y := w.Position(); x != 0 || y != 0 { + return + } + time.Sleep(20 * time.Millisecond) + } + }() +} diff --git a/client/ui/tray.go b/client/ui/tray.go index e038a84c9..dd54cfda5 100644 --- a/client/ui/tray.go +++ b/client/ui/tray.go @@ -478,7 +478,7 @@ func (t *Tray) buildMenu() *application.Menu { // its account email, and the SSO session deadline read as a single block. // Hidden until applyStatus sees a non-zero SessionExpiresAt on the daemon // Status snapshot — peers without SSO tracking or with login expiry - // disabled never reveal this row. Click opens the SessionAboutToExpire + // disabled never reveal this row. Click opens the SessionExpiration // window so the user can extend the session ahead of the daemon's // T-FinalWarningLead auto-prompt. t.sessionExpiresItem = menu.Add("").OnClick(func(*application.Context) { t.openSessionExtendFlow() }) diff --git a/client/ui/tray_events.go b/client/ui/tray_events.go index 1a628f0bc..804430347 100644 --- a/client/ui/tray_events.go +++ b/client/ui/tray_events.go @@ -50,11 +50,11 @@ func (t *Tray) onSystemEvent(ev *application.CustomEvent) { // the active UI language regardless of what the daemon (which has // no locale context) writes into UserMessage. // - T-FinalWarningLead (MetaSessionFinal=true) → auto-open the - // SessionAboutToExpire dialog. No OS notification here; the + // SessionExpiration dialog. No OS notification here; the // dialog is the last-chance reminder, doubling up would be noise. if se.Metadata != nil && se.Metadata[authsession.MetaWarning] == "true" { if se.Metadata[authsession.MetaFinal] == "true" { - t.openSessionAboutToExpire() + t.openSessionExpiration() return } t.notifySessionWarning( diff --git a/client/ui/tray_session.go b/client/ui/tray_session.go index 0a540b431..55b7e6394 100644 --- a/client/ui/tray_session.go +++ b/client/ui/tray_session.go @@ -28,7 +28,7 @@ const ( notifyActionDismiss = "dismiss" // finalWarningCountdownSeconds is the countdown shown in the auto-opened - // SessionAboutToExpire dialog. Mirrors sessionwatch.FinalWarningLead + // SessionExpiration dialog. Mirrors sessionwatch.FinalWarningLead // (2 minutes); the values stay in sync by hand because the lead is fixed // for the initial rollout. finalWarningCountdownSeconds = 120 @@ -302,18 +302,18 @@ func (t *Tray) dismissSessionWarning() { } } -// openSessionAboutToExpire fires the auto-opened fallback dialog at +// openSessionExpiration fires the auto-opened fallback dialog at // T-FinalWarningLead when the user did not dismiss the earlier T-10 // notification. Idempotent on the WindowManager side (a second call // while the window is already open is a no-op). -func (t *Tray) openSessionAboutToExpire() { +func (t *Tray) openSessionExpiration() { if t.svc.WindowManager == nil { return } - t.svc.WindowManager.OpenSessionAboutToExpire(finalWarningCountdownSeconds) + t.svc.WindowManager.OpenSessionExpiration(finalWarningCountdownSeconds) } -// openSessionExtendFlow opens the SessionAboutToExpire window seeded with +// openSessionExtendFlow opens the SessionExpiration window seeded with // the actual remaining time on the cached SSO deadline. Triggered by a // click on the "Expires in …" tray row so the user can extend the session // proactively, instead of waiting for the daemon's T-FinalWarningLead @@ -333,5 +333,5 @@ func (t *Tray) openSessionExtendFlow() { if seconds <= 0 { return } - t.svc.WindowManager.OpenSessionAboutToExpire(seconds) + t.svc.WindowManager.OpenSessionExpiration(seconds) }