update session expiration dialog texts, add monitor aware position

This commit is contained in:
Eduard Gert
2026-06-08 18:09:22 +02:00
parent b067544c8a
commit 6b2ae1c34c
19 changed files with 321 additions and 226 deletions

View File

@@ -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=<n>`) — 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=<n>`) — 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=<v>`) — 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=<m>`) — 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 - <title>"` (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

View File

@@ -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

View File

@@ -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>

View File

@@ -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.

View File

@@ -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

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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.

View File

@@ -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)
}
}()
}

View File

@@ -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() })

View File

@@ -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(

View File

@@ -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)
}