mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-24 01:11:29 +02:00
update session expiration dialog texts, add monitor aware position
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
42
client/ui/services/cursor_darwin.go
Normal file
42
client/ui/services/cursor_darwin.go
Normal 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
|
||||
}
|
||||
54
client/ui/services/cursor_linux.go
Normal file
54
client/ui/services/cursor_linux.go
Normal 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
|
||||
}
|
||||
9
client/ui/services/cursor_other.go
Normal file
9
client/ui/services/cursor_other.go
Normal 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
|
||||
}
|
||||
17
client/ui/services/cursor_windows.go
Normal file
17
client/ui/services/cursor_windows.go
Normal 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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -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() })
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user