mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-20 23:22:39 -04:00
Compare commits
4 Commits
feature/se
...
ui-refacto
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a458ead8b | ||
|
|
aab8274b1a | ||
|
|
d3b660afba | ||
|
|
341848b1ae |
@@ -109,7 +109,7 @@ func TestUpdateZeroBeforeAnythingIsNoop(t *testing.T) {
|
||||
w := newWatcher(50*time.Millisecond, r)
|
||||
defer w.Close()
|
||||
|
||||
w.Update(time.Time{})
|
||||
_ = w.Update(time.Time{})
|
||||
|
||||
if got := r.snapshot(); len(got) != 0 {
|
||||
t.Fatalf("expected no events on initial zero, got %+v", got)
|
||||
@@ -122,7 +122,7 @@ func TestUpdateNonZeroFiresStateChange(t *testing.T) {
|
||||
defer w.Close()
|
||||
|
||||
d := time.Now().Add(time.Hour)
|
||||
w.Update(d)
|
||||
_ = w.Update(d)
|
||||
|
||||
events := waitForEvents(t, r, 1)
|
||||
if events[0].kind != stateChange {
|
||||
@@ -139,9 +139,9 @@ func TestSameDeadlineIsNoop(t *testing.T) {
|
||||
defer w.Close()
|
||||
|
||||
d := time.Now().Add(time.Hour)
|
||||
w.Update(d)
|
||||
w.Update(d)
|
||||
w.Update(d)
|
||||
_ = w.Update(d)
|
||||
_ = w.Update(d)
|
||||
_ = w.Update(d)
|
||||
|
||||
events := waitForEvents(t, r, 1)
|
||||
if len(events) != 1 {
|
||||
@@ -157,7 +157,7 @@ func TestWarningFiresOnceWithinLeadWindow(t *testing.T) {
|
||||
|
||||
// Deadline 80ms out — warning should fire after ~30ms.
|
||||
d := time.Now().Add(80 * time.Millisecond)
|
||||
w.Update(d)
|
||||
_ = w.Update(d)
|
||||
|
||||
events := waitForEvents(t, r, 2)
|
||||
if events[0].kind != stateChange {
|
||||
@@ -174,7 +174,7 @@ func TestWarningFiresImmediatelyWhenAlreadyInsideWindow(t *testing.T) {
|
||||
defer w.Close()
|
||||
|
||||
d := time.Now().Add(10 * time.Millisecond)
|
||||
w.Update(d)
|
||||
_ = w.Update(d)
|
||||
|
||||
events := waitForEvents(t, r, 2)
|
||||
if !events[1].isWarning() {
|
||||
@@ -189,12 +189,12 @@ func TestNewDeadlineCancelsPriorTimer(t *testing.T) {
|
||||
defer w.Close()
|
||||
|
||||
first := time.Now().Add(80 * time.Millisecond) // would fire warning ~30ms in
|
||||
w.Update(first)
|
||||
_ = w.Update(first)
|
||||
|
||||
// Replace with a far-future deadline before the warning fires.
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
second := time.Now().Add(time.Hour)
|
||||
w.Update(second)
|
||||
_ = w.Update(second)
|
||||
|
||||
// Wait past when first's warning would have fired.
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
@@ -211,14 +211,14 @@ func TestRefreshAfterFireArmsNewWarning(t *testing.T) {
|
||||
defer w.Close()
|
||||
|
||||
first := time.Now().Add(50 * time.Millisecond)
|
||||
w.Update(first)
|
||||
_ = w.Update(first)
|
||||
|
||||
// Wait for stateChange + warning of the first cycle.
|
||||
waitForEvents(t, r, 2)
|
||||
|
||||
// Simulate a successful extend: brand new deadline.
|
||||
second := time.Now().Add(60 * time.Millisecond)
|
||||
w.Update(second)
|
||||
_ = w.Update(second)
|
||||
|
||||
// 4 events total: stateChange, warning (first), stateChange, warning (second).
|
||||
events := waitForEvents(t, r, 4)
|
||||
@@ -236,10 +236,10 @@ func TestUpdateZeroAfterNonZeroClearsState(t *testing.T) {
|
||||
defer w.Close()
|
||||
|
||||
d := time.Now().Add(2 * time.Hour)
|
||||
w.Update(d)
|
||||
_ = w.Update(d)
|
||||
waitForEvents(t, r, 1)
|
||||
|
||||
w.Update(time.Time{})
|
||||
_ = w.Update(time.Time{})
|
||||
|
||||
events := waitForEvents(t, r, 2)
|
||||
if events[1].kind != stateChange {
|
||||
@@ -333,7 +333,7 @@ func TestCloseSilencesUpdates(t *testing.T) {
|
||||
w := newWatcher(50*time.Millisecond, r)
|
||||
w.Close()
|
||||
|
||||
w.Update(time.Now().Add(time.Hour))
|
||||
_ = w.Update(time.Now().Add(time.Hour))
|
||||
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
if got := r.snapshot(); len(got) != 0 {
|
||||
@@ -348,7 +348,7 @@ func TestFinalWarningFiresAfterRegularWarning(t *testing.T) {
|
||||
defer w.Close()
|
||||
|
||||
d := time.Now().Add(100 * time.Millisecond)
|
||||
w.Update(d)
|
||||
_ = w.Update(d)
|
||||
|
||||
// Expect stateChange + warning + final-warning.
|
||||
events := waitForEvents(t, r, 3)
|
||||
@@ -384,7 +384,7 @@ func TestDismissSuppressesFinalWarning(t *testing.T) {
|
||||
defer w.Close()
|
||||
|
||||
d := time.Now().Add(100 * time.Millisecond)
|
||||
w.Update(d)
|
||||
_ = w.Update(d)
|
||||
|
||||
// Wait for the warning publish so we know we're inside the warning
|
||||
// window, then dismiss before the final timer would fire.
|
||||
@@ -415,7 +415,7 @@ func TestDismissResetByNewDeadline(t *testing.T) {
|
||||
defer w.Close()
|
||||
|
||||
first := time.Now().Add(100 * time.Millisecond)
|
||||
w.Update(first)
|
||||
_ = w.Update(first)
|
||||
|
||||
// Dismiss against the first deadline.
|
||||
w.Dismiss()
|
||||
@@ -423,7 +423,7 @@ func TestDismissResetByNewDeadline(t *testing.T) {
|
||||
// Replace with a fresh deadline before the first's timers complete.
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
second := time.Now().Add(100 * time.Millisecond)
|
||||
w.Update(second)
|
||||
_ = w.Update(second)
|
||||
|
||||
// The second cycle must publish a final-warning (the dismiss state
|
||||
// did not carry over).
|
||||
@@ -448,7 +448,7 @@ func TestDismissBeforeUpdateIsNoop(t *testing.T) {
|
||||
w.Dismiss()
|
||||
|
||||
d := time.Now().Add(100 * time.Millisecond)
|
||||
w.Update(d)
|
||||
_ = w.Update(d)
|
||||
|
||||
// Final warning should still publish — Dismiss only acts on the current
|
||||
// deadline, and there was none at the time of the call.
|
||||
|
||||
@@ -1048,16 +1048,16 @@ func HumaniseDuration(d time.Duration) string {
|
||||
}
|
||||
|
||||
const (
|
||||
day = 24 * time.Hour
|
||||
hour = time.Hour
|
||||
min = time.Minute
|
||||
day = 24 * time.Hour
|
||||
hour = time.Hour
|
||||
minute = time.Minute
|
||||
)
|
||||
|
||||
days := d / day
|
||||
d -= days * day
|
||||
hours := d / hour
|
||||
d -= hours * hour
|
||||
minutes := d / min
|
||||
days := int64(d / day)
|
||||
d -= time.Duration(days) * day
|
||||
hours := int64(d / hour)
|
||||
d -= time.Duration(hours) * hour
|
||||
minutes := int64(d / minute)
|
||||
|
||||
switch {
|
||||
case days > 0:
|
||||
|
||||
@@ -11,6 +11,7 @@ import { NewProfileModal } from "@/components/NewProfileModal";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
import { useProfile } from "@/modules/profile/ProfileContext";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatErrorMessage } from "@/lib/errors";
|
||||
|
||||
type ProfileDropdownProps = {
|
||||
onManageProfiles?: () => void;
|
||||
@@ -40,7 +41,7 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
|
||||
} catch (e) {
|
||||
await Dialogs.Error({
|
||||
Title: title,
|
||||
Message: e instanceof Error ? e.message : String(e),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
@@ -70,7 +71,7 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
|
||||
} catch (e) {
|
||||
await Dialogs.Error({
|
||||
Title: t("profile.error.createTitle"),
|
||||
Message: e instanceof Error ? e.message : String(e),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -293,5 +293,14 @@
|
||||
|
||||
"daemon.unavailable.title": "NetBird-Dienst läuft nicht",
|
||||
"daemon.unavailable.description": "Die App stellt automatisch die Verbindung wieder her, sobald der Dienst läuft.",
|
||||
"daemon.unavailable.docsLink": "Dokumentation"
|
||||
"daemon.unavailable.docsLink": "Dokumentation",
|
||||
|
||||
"error.jwt_clock_skew": "Anmeldung fehlgeschlagen: Die Uhr dieses Geräts ist nicht mit dem Server synchron. Bitte synchronisieren Sie die Systemuhr und versuchen Sie es erneut.",
|
||||
"error.jwt_expired": "Ihr Anmeldetoken ist abgelaufen. Bitte melden Sie sich erneut an.",
|
||||
"error.jwt_signature_invalid": "Anmeldung fehlgeschlagen: Die Token-Signatur ist ungültig. Bitte wenden Sie sich an Ihren Administrator.",
|
||||
"error.session_expired": "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.",
|
||||
"error.invalid_setup_key": "Der Setup-Schlüssel fehlt oder ist ungültig.",
|
||||
"error.permission_denied": "Die Anmeldung wurde vom Server abgelehnt.",
|
||||
"error.daemon_unreachable": "Der NetBird-Dienst antwortet nicht. Bitte prüfen Sie, ob der Dienst läuft.",
|
||||
"error.unknown": "Vorgang fehlgeschlagen. Technische Details siehe unten."
|
||||
}
|
||||
|
||||
@@ -314,5 +314,14 @@
|
||||
|
||||
"daemon.unavailable.title": "NetBird Service Is Not Running",
|
||||
"daemon.unavailable.description": "The app will reconnect automatically once the service is running.",
|
||||
"daemon.unavailable.docsLink": "Documentation"
|
||||
"daemon.unavailable.docsLink": "Documentation",
|
||||
|
||||
"error.jwt_clock_skew": "Sign-in failed: this device's clock is out of sync with the server. Please sync your system clock and try again.",
|
||||
"error.jwt_expired": "Your sign-in token has expired. Please sign in again.",
|
||||
"error.jwt_signature_invalid": "Sign-in failed: the token signature is invalid. Please contact your administrator.",
|
||||
"error.session_expired": "Your session has expired. Please sign in again.",
|
||||
"error.invalid_setup_key": "The setup key is missing or invalid.",
|
||||
"error.permission_denied": "Sign-in was rejected by the server.",
|
||||
"error.daemon_unreachable": "The NetBird daemon is not responding. Please check that the service is running.",
|
||||
"error.unknown": "Operation failed. See details for the technical message."
|
||||
}
|
||||
|
||||
@@ -293,5 +293,14 @@
|
||||
|
||||
"daemon.unavailable.title": "A NetBird szolgáltatás nem fut",
|
||||
"daemon.unavailable.description": "Az alkalmazás automatikusan újracsatlakozik, amint a szolgáltatás újra elérhető.",
|
||||
"daemon.unavailable.docsLink": "Dokumentáció"
|
||||
"daemon.unavailable.docsLink": "Dokumentáció",
|
||||
|
||||
"error.jwt_clock_skew": "A bejelentkezés sikertelen: az eszköz órája eltér a szerverétől. Kérjük, szinkronizálja a rendszer óráját, majd próbálja újra.",
|
||||
"error.jwt_expired": "A bejelentkezési token lejárt. Kérjük, jelentkezzen be újra.",
|
||||
"error.jwt_signature_invalid": "A bejelentkezés sikertelen: a token aláírása érvénytelen. Kérjük, lépjen kapcsolatba a rendszergazdával.",
|
||||
"error.session_expired": "A munkamenet lejárt. Kérjük, jelentkezzen be újra.",
|
||||
"error.invalid_setup_key": "A telepítési kulcs hiányzik vagy érvénytelen.",
|
||||
"error.permission_denied": "A szerver elutasította a bejelentkezést.",
|
||||
"error.daemon_unreachable": "A NetBird szolgáltatás nem válaszol. Kérjük, ellenőrizze, hogy fut-e a szolgáltatás.",
|
||||
"error.unknown": "A művelet meghiúsult. A technikai részleteket a Details mezőben találja."
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ToggleSwitch } from "@/components/ToggleSwitch.tsx";
|
||||
import { useStatus } from "@/modules/daemon-status/StatusContext.tsx";
|
||||
import { useProfile } from "@/modules/profile/ProfileContext.tsx";
|
||||
import { cn } from "@/lib/cn.ts";
|
||||
import { formatErrorMessage } from "@/lib/errors.ts";
|
||||
import netbirdFullLogo from "@/assets/logos/netbird-full.svg";
|
||||
|
||||
enum ConnectionState {
|
||||
@@ -38,8 +39,7 @@ const NEEDS_LOGIN_STATES = new Set([
|
||||
"LoginFailed",
|
||||
]);
|
||||
|
||||
const errorMessage = (e: unknown) =>
|
||||
e instanceof Error ? e.message : String(e);
|
||||
const errorMessage = formatErrorMessage;
|
||||
|
||||
// startLogin drives the daemon's SSO login end-to-end. The BrowserLogin
|
||||
// popup window is the only login UI; errors surface as a native
|
||||
@@ -230,6 +230,14 @@ export const ConnectionStatusSwitch = () => {
|
||||
// See connect() above — clear via the effect, not eagerly.
|
||||
};
|
||||
|
||||
// Tracks whether the daemon has entered Connecting during the
|
||||
// current "connect" action. Lets us distinguish "still waiting for
|
||||
// the daemon to start" (Idle → Idle) from "the connect flow was
|
||||
// cancelled externally" (Connecting → Idle, e.g. tray Disconnect
|
||||
// while the UI was Connecting). Reset whenever action returns to
|
||||
// null.
|
||||
const sawConnectingRef = useRef(false);
|
||||
|
||||
// Release the action latch when the daemon settles on a terminal
|
||||
// state for the user's intent — and, in the connect → NeedsLogin
|
||||
// case, hand off to driveLogin so the user doesn't have to click
|
||||
@@ -237,6 +245,13 @@ export const ConnectionStatusSwitch = () => {
|
||||
// .finally, not here: Login's internal Down makes the daemon flap
|
||||
// through Idle, which would otherwise look like a terminal state.
|
||||
useEffect(() => {
|
||||
if (action === null) {
|
||||
sawConnectingRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (daemonState === "Connecting") {
|
||||
sawConnectingRef.current = true;
|
||||
}
|
||||
if (action === "connect") {
|
||||
if (needsLogin) {
|
||||
driveLogin();
|
||||
@@ -244,6 +259,14 @@ export const ConnectionStatusSwitch = () => {
|
||||
}
|
||||
if (daemonState === "Connected" || unreachable) {
|
||||
setAction(null);
|
||||
return;
|
||||
}
|
||||
// Cancelled externally (e.g. tray Disconnect during our
|
||||
// Connecting): the daemon went back to Idle after we'd
|
||||
// observed Connecting. Clear the latch so the UI stops
|
||||
// showing Connecting forever.
|
||||
if (sawConnectingRef.current && daemonState === "Idle") {
|
||||
setAction(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
52
client/ui/frontend/src/lib/errors.ts
Normal file
52
client/ui/frontend/src/lib/errors.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// Shared error formatter for native dialog bodies.
|
||||
//
|
||||
// The Go service layer (client/ui/services/connection.go classifyDaemonError)
|
||||
// wraps daemon errors in a ClientError struct exposed to the TS side as
|
||||
// {code, short, long}. Short is already localised (Go reads the current
|
||||
// preferences.Store language and resolves "error.<code>" via i18n.Bundle).
|
||||
// Long always carries the unwrapped raw daemon message so the operator can
|
||||
// see the JWT / mgm stack when the short text is too generic.
|
||||
//
|
||||
// Wails wraps Go-returned errors as Error({message, cause, kind}) where
|
||||
// .message holds the JSON-stringified payload and the structured object
|
||||
// lives on .cause — Object.keys(err) is empty in that case. We therefore
|
||||
// probe .cause first, then fall back to parsing .message as JSON, then
|
||||
// to plain .message text for callers that still hand us a raw Error.
|
||||
const extractClientError = (e: unknown): { short?: string; long?: string } | null => {
|
||||
if (!e || typeof e !== "object") return null;
|
||||
const withCause = e as { cause?: unknown; message?: unknown };
|
||||
if (withCause.cause && typeof withCause.cause === "object") {
|
||||
return withCause.cause as { short?: string; long?: string };
|
||||
}
|
||||
if (typeof withCause.message === "string") {
|
||||
const m = withCause.message.trim();
|
||||
if (m.startsWith("{") && m.endsWith("}")) {
|
||||
try {
|
||||
const parsed = JSON.parse(m);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
if ("cause" in parsed && parsed.cause && typeof parsed.cause === "object") {
|
||||
return parsed.cause as { short?: string; long?: string };
|
||||
}
|
||||
return parsed as { short?: string; long?: string };
|
||||
}
|
||||
} catch {
|
||||
// not JSON — fall through to plain-message handling
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const formatErrorMessage = (e: unknown): string => {
|
||||
const ce = extractClientError(e);
|
||||
if (ce) {
|
||||
const short = typeof ce.short === "string" ? ce.short : "";
|
||||
const long = typeof ce.long === "string" ? ce.long : "";
|
||||
if (short && long && long !== short) {
|
||||
return `${short}\n\nDetails: ${long}`;
|
||||
}
|
||||
if (short) return short;
|
||||
}
|
||||
if (e instanceof Error) return e.message;
|
||||
return String(e);
|
||||
};
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
WindowManager,
|
||||
} from "@bindings/services";
|
||||
import { useAutoSizeWindow } from "@/lib/useAutoSizeWindow";
|
||||
import { formatErrorMessage } from "@/lib/errors.ts";
|
||||
|
||||
const DEFAULT_SECONDS = 360;
|
||||
const WINDOW_WIDTH = 360;
|
||||
@@ -63,7 +64,7 @@ export default function SessionAboutToExpireDialog() {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const start = await Session.RequestExtend({});
|
||||
const start = await Session.RequestExtend({ hint: "" });
|
||||
const uri = start.verificationUriComplete || start.verificationUri;
|
||||
if (uri) {
|
||||
try {
|
||||
@@ -80,7 +81,7 @@ export default function SessionAboutToExpireDialog() {
|
||||
} catch (e) {
|
||||
await Dialogs.Error({
|
||||
Title: t("sessionAboutToExpire.extendFailedTitle"),
|
||||
Message: e instanceof Error ? e.message : String(e),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "@bindings/services";
|
||||
import type { DebugBundleResult } from "@bindings/services/models.js";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { formatErrorMessage } from "@/lib/errors.ts";
|
||||
import { useProfile } from "@/modules/profile/ProfileContext.tsx";
|
||||
|
||||
const NETBIRD_UPLOAD_URL = "https://upload.debug.netbird.io/upload-url";
|
||||
@@ -158,7 +159,7 @@ export const useDebugBundle = () => {
|
||||
setStage({ kind: "idle" });
|
||||
await Dialogs.Error({
|
||||
Title: i18next.t("settings.error.debugBundleTitle"),
|
||||
Message: e instanceof Error ? e.message : String(e),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
} finally {
|
||||
if (abortRef.current === ctrl) abortRef.current = null;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { HelpText } from "@/components/HelpText";
|
||||
import { Label } from "@/components/Label";
|
||||
import { loadLanguages } from "@/lib/i18n";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatErrorMessage } from "@/lib/errors";
|
||||
|
||||
// Flags live alongside the rest of the SVG flag library under
|
||||
// assets/flags/1x1 and are filename-matched to the language code
|
||||
@@ -91,7 +92,7 @@ export function LanguagePicker() {
|
||||
} catch (e) {
|
||||
await Dialogs.Error({
|
||||
Title: t("settings.error.saveTitle"),
|
||||
Message: e instanceof Error ? e.message : String(e),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
|
||||
@@ -13,9 +13,7 @@ import type { Config } from "@bindings/services/models.js";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { useProfile } from "@/modules/profile/ProfileContext.tsx";
|
||||
import { SkeletonSettings } from "@/modules/skeletons/SkeletonSettings.tsx";
|
||||
|
||||
const errorMessage = (e: unknown) =>
|
||||
e instanceof Error ? e.message : String(e);
|
||||
import { formatErrorMessage as errorMessage } from "@/lib/errors.ts";
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 400;
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import i18next from "@/lib/i18n";
|
||||
import { useProfile } from "@/modules/profile/ProfileContext";
|
||||
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatErrorMessage } from "@/lib/errors";
|
||||
|
||||
const DEFAULT_PROFILE = "default";
|
||||
|
||||
@@ -45,7 +46,7 @@ export function SettingsProfiles() {
|
||||
} catch (e) {
|
||||
await Dialogs.Error({
|
||||
Title: title,
|
||||
Message: e instanceof Error ? e.message : String(e),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
@@ -90,7 +91,7 @@ export function SettingsProfiles() {
|
||||
} catch (e) {
|
||||
await Dialogs.Error({
|
||||
Title: i18next.t("profile.error.createTitle"),
|
||||
Message: e instanceof Error ? e.message : String(e),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Loader2 } from "lucide-react";
|
||||
import { Dialogs } from "@wailsio/runtime";
|
||||
import { Update as UpdateSvc } from "@bindings/services";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { formatErrorMessage } from "@/lib/errors";
|
||||
|
||||
const TIMEOUT_MS = 15 * 60 * 1000;
|
||||
|
||||
@@ -20,7 +21,7 @@ export default function Update() {
|
||||
UpdateSvc.Trigger().catch((e) => {
|
||||
if (cancelled) return;
|
||||
setFailed(true);
|
||||
void showError(e instanceof Error ? e.message : String(e));
|
||||
void showError(formatErrorMessage(e));
|
||||
});
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
@@ -123,7 +123,6 @@ func main() {
|
||||
},
|
||||
})
|
||||
|
||||
connection := services.NewConnection(conn)
|
||||
settings := services.NewSettings(conn)
|
||||
profiles := services.NewProfiles(conn)
|
||||
// updater.Holder owns the typed update State. Peers feeds the daemon
|
||||
@@ -133,7 +132,6 @@ func main() {
|
||||
update := services.NewUpdate(conn, updaterHolder)
|
||||
peers := services.NewPeers(conn, app.Event, updaterHolder)
|
||||
notifier := notifications.New()
|
||||
profileSwitcher := services.NewProfileSwitcher(profiles, connection, peers)
|
||||
|
||||
// localesFS reroots the embedded tree at the locales directory itself
|
||||
// so the bundle sees _index.json and <lang>/common.json at the top
|
||||
@@ -156,6 +154,11 @@ func main() {
|
||||
}
|
||||
localizer := NewLocalizer(bundle, prefStore)
|
||||
|
||||
// Connection lives after bundle + prefStore so it can localise daemon
|
||||
// errors (services.NewConnection takes both as dependencies).
|
||||
connection := services.NewConnection(conn, bundle, prefStore)
|
||||
profileSwitcher := services.NewProfileSwitcher(profiles, connection, peers)
|
||||
|
||||
app.RegisterService(application.NewService(connection))
|
||||
// authsession.Session owns the full extend + dismiss surface; the tray
|
||||
// drives the "Extend now" action from the T-10 OS notification through
|
||||
|
||||
@@ -4,15 +4,143 @@ package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/ui/i18n"
|
||||
"github.com/netbirdio/netbird/client/ui/preferences"
|
||||
)
|
||||
|
||||
// ErrorTranslator is the subset of i18n.Bundle Connection needs to localise
|
||||
// daemon errors. Defined as an interface so tests can stub it; the runtime
|
||||
// implementation is *i18n.Bundle.
|
||||
type ErrorTranslator interface {
|
||||
Translate(lang i18n.LanguageCode, key string, args ...string) string
|
||||
}
|
||||
|
||||
// LanguagePreference is the subset of preferences.Store Connection needs
|
||||
// to discover the current UI language at error-classification time. The
|
||||
// runtime implementation is *preferences.Store.
|
||||
type LanguagePreference interface {
|
||||
Get() preferences.UIPreferences
|
||||
}
|
||||
|
||||
// ClientError is a structured error returned to the frontend.
|
||||
//
|
||||
// The daemon hands us gRPC errors whose Message is a stack of wrapped strings
|
||||
// from the management server and the underlying JWT library, for example:
|
||||
//
|
||||
// "invalid jwt token, err: token could not be parsed: token has invalid
|
||||
// claims: token used before issued"
|
||||
//
|
||||
// Showing that raw message in a native dialog is unreadable, so we map the
|
||||
// substrings we recognise to a {code, short, long} triple. The frontend
|
||||
// translates Code through i18n (preferred); Short is an English fallback so
|
||||
// the dialog still reads cleanly if a code is missing from the locale; Long
|
||||
// always carries the unwrapped daemon message for the operator.
|
||||
type ClientError struct {
|
||||
Code string `json:"code"`
|
||||
Short string `json:"short"`
|
||||
Long string `json:"long"`
|
||||
}
|
||||
|
||||
// Error returns the user-facing short message so plain Go callers and the
|
||||
// Wails default error path still get a readable string.
|
||||
func (e *ClientError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return e.Short
|
||||
}
|
||||
|
||||
// MarshalJSON encodes the full {code, short, long} triple so the Wails
|
||||
// binding emits a structured object instead of the default "error: ..."
|
||||
// string. The TS layer accesses these fields via try/catch.
|
||||
func (e *ClientError) MarshalJSON() ([]byte, error) {
|
||||
if e == nil {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
type alias ClientError
|
||||
return json.Marshal((*alias)(e))
|
||||
}
|
||||
|
||||
// classifyDaemonError turns a raw gRPC error from the daemon into a
|
||||
// ClientError with a stable code and a short localised summary. The Long
|
||||
// field always carries the unwrapped daemon message so the operator can
|
||||
// inspect the root cause when the short text is too generic. Short is
|
||||
// looked up via i18n under "error.<code>": i18n.Bundle.Translate already
|
||||
// handles current-language → English → key passthrough, so any missing
|
||||
// locale entry surfaces as a visible "error.<code>" string in the dialog —
|
||||
// a deliberate fail-loud signal that the bundle needs updating.
|
||||
func (s *Connection) classifyDaemonError(err error) *ClientError {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
msg := err.Error()
|
||||
if st, ok := gstatus.FromError(err); ok {
|
||||
msg = st.Message()
|
||||
}
|
||||
lower := strings.ToLower(msg)
|
||||
|
||||
code := "unknown"
|
||||
switch {
|
||||
case strings.Contains(lower, "token used before issued"),
|
||||
strings.Contains(lower, "token is not valid yet"):
|
||||
code = "jwt_clock_skew"
|
||||
case strings.Contains(lower, "token is expired"),
|
||||
strings.Contains(lower, "token has expired"):
|
||||
code = "jwt_expired"
|
||||
case strings.Contains(lower, "token signature is invalid"):
|
||||
code = "jwt_signature_invalid"
|
||||
case strings.Contains(lower, "peer login has expired"):
|
||||
code = "session_expired"
|
||||
case strings.Contains(lower, "invalid setup-key"),
|
||||
strings.Contains(lower, "invalid setup key"):
|
||||
code = "invalid_setup_key"
|
||||
case strings.Contains(lower, "permission denied"):
|
||||
code = "permission_denied"
|
||||
case strings.Contains(lower, "no connection could be made"),
|
||||
strings.Contains(lower, "connection refused"),
|
||||
strings.Contains(lower, "context deadline exceeded"):
|
||||
code = "daemon_unreachable"
|
||||
}
|
||||
|
||||
return &ClientError{
|
||||
Code: code,
|
||||
Short: s.translateShort(code),
|
||||
Long: msg,
|
||||
}
|
||||
}
|
||||
|
||||
// translateShort resolves the localised short message for code. The i18n
|
||||
// Bundle's own Translate already falls back current-language → English →
|
||||
// key passthrough, so callers either see the localised string or the bare
|
||||
// "error.<code>" key (which makes the missing translation obvious). If
|
||||
// the translator is nil — e.g. a Connection constructed in a unit test —
|
||||
// we return the key for the same reason.
|
||||
func (s *Connection) translateShort(code string) string {
|
||||
key := "error." + code
|
||||
if s.translator == nil {
|
||||
return key
|
||||
}
|
||||
lang := i18n.DefaultLanguage
|
||||
if s.prefs != nil {
|
||||
if pref := s.prefs.Get().Language; pref != "" {
|
||||
lang = pref
|
||||
}
|
||||
}
|
||||
return s.translator.Translate(lang, key)
|
||||
}
|
||||
|
||||
// LoginParams carries the fields the UI sets when starting a login.
|
||||
type LoginParams struct {
|
||||
ProfileName string `json:"profileName"`
|
||||
@@ -52,11 +180,17 @@ type LogoutParams struct {
|
||||
|
||||
// Connection groups the daemon RPCs that drive login / connect / disconnect.
|
||||
type Connection struct {
|
||||
conn DaemonConn
|
||||
conn DaemonConn
|
||||
translator ErrorTranslator
|
||||
prefs LanguagePreference
|
||||
}
|
||||
|
||||
func NewConnection(conn DaemonConn) *Connection {
|
||||
return &Connection{conn: conn}
|
||||
// NewConnection wires Connection with its translation dependencies. Either
|
||||
// translator or prefs may be nil; in that case classifyDaemonError falls
|
||||
// back to the English Short text baked into the error map. main.go always
|
||||
// supplies both at startup.
|
||||
func NewConnection(conn DaemonConn, translator ErrorTranslator, prefs LanguagePreference) *Connection {
|
||||
return &Connection{conn: conn, translator: translator, prefs: prefs}
|
||||
}
|
||||
|
||||
func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, error) {
|
||||
@@ -117,7 +251,7 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err
|
||||
|
||||
resp, err := cli.Login(ctx, req)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
return LoginResult{}, s.classifyDaemonError(err)
|
||||
}
|
||||
return LoginResult{
|
||||
NeedsSSOLogin: resp.GetNeedsSSOLogin(),
|
||||
@@ -137,7 +271,7 @@ func (s *Connection) WaitSSOLogin(ctx context.Context, p WaitSSOParams) (string,
|
||||
Hostname: p.Hostname,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", s.classifyDaemonError(err)
|
||||
}
|
||||
return resp.GetEmail(), nil
|
||||
}
|
||||
@@ -155,8 +289,10 @@ func (s *Connection) Up(ctx context.Context, p UpParams) error {
|
||||
if p.Username != "" {
|
||||
req.Username = ptrStr(p.Username)
|
||||
}
|
||||
_, err = cli.Up(ctx, req)
|
||||
return err
|
||||
if _, err = cli.Up(ctx, req); err != nil {
|
||||
return s.classifyDaemonError(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Connection) Down(ctx context.Context) error {
|
||||
@@ -164,8 +300,10 @@ func (s *Connection) Down(ctx context.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = cli.Down(ctx, &proto.DownRequest{})
|
||||
return err
|
||||
if _, err = cli.Down(ctx, &proto.DownRequest{}); err != nil {
|
||||
return s.classifyDaemonError(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OpenURL launches the user's preferred browser to display url. Mirrors the
|
||||
@@ -201,6 +339,8 @@ func (s *Connection) Logout(ctx context.Context, p LogoutParams) error {
|
||||
if p.Username != "" {
|
||||
req.Username = ptrStr(p.Username)
|
||||
}
|
||||
_, err = cli.Logout(ctx, req)
|
||||
return err
|
||||
if _, err = cli.Logout(ctx, req); err != nil {
|
||||
return s.classifyDaemonError(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
package main
|
||||
|
||||
/*
|
||||
#cgo pkg-config: x11 gtk+-3.0 cairo cairo-xlib
|
||||
#cgo pkg-config: x11 gtk4 gtk4-x11 cairo cairo-xlib
|
||||
#cgo LDFLAGS: -lX11
|
||||
#include "xembed_tray_linux.h"
|
||||
#include <X11/Xlib.h>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <cairo/cairo-xlib.h>
|
||||
#include <cairo/cairo.h>
|
||||
#include <gtk/gtk.h>
|
||||
#include <gdk/x11/gdkx.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
@@ -241,7 +242,7 @@ int xembed_poll_event(Display *dpy, Window icon_win,
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* --- GTK3 popup window menu support --- */
|
||||
/* --- GTK4 popup window menu support --- */
|
||||
|
||||
/* Implemented in Go via //export */
|
||||
extern void goMenuItemClicked(int id);
|
||||
@@ -274,18 +275,19 @@ static void free_popup_data(popup_data *pd) {
|
||||
free(pd);
|
||||
}
|
||||
|
||||
|
||||
/* Close every popup window — top-level plus any open submenus.
|
||||
Called when the user clicks an actionable item or focus leaves the
|
||||
top-level window. */
|
||||
menu tree. */
|
||||
static void close_all_popups(void) {
|
||||
for (GList *l = submenu_popups; l; l = l->next) {
|
||||
gtk_widget_destroy(GTK_WIDGET(l->data));
|
||||
gtk_window_destroy(GTK_WINDOW(l->data));
|
||||
}
|
||||
g_list_free(submenu_popups);
|
||||
submenu_popups = NULL;
|
||||
|
||||
if (popup_win) {
|
||||
gtk_widget_hide(popup_win);
|
||||
gtk_widget_set_visible(popup_win, FALSE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,24 +298,26 @@ static void on_button_clicked(GtkButton *btn, gpointer user_data) {
|
||||
goMenuItemClicked(id);
|
||||
}
|
||||
|
||||
static void on_check_toggled(GtkToggleButton *btn, gpointer user_data) {
|
||||
static void on_check_toggled(GtkCheckButton *btn, gpointer user_data) {
|
||||
(void)btn;
|
||||
int id = GPOINTER_TO_INT(user_data);
|
||||
close_all_popups();
|
||||
goMenuItemClicked(id);
|
||||
}
|
||||
|
||||
/* When any popup loses focus we want to close the entire popup tree —
|
||||
unless focus moved to another window we own (e.g. opening a submenu).
|
||||
focus-out fires before the corresponding focus-in on the new window,
|
||||
so we defer the check to an idle callback: by then any sibling popup
|
||||
has had a chance to grab focus. If none of our windows still has
|
||||
toplevel focus, the user clicked outside the menu tree → tear down. */
|
||||
/* The popup is a regular WM-managed window (not override-redirect),
|
||||
so the WM hands keyboard focus to it on map. When focus moves
|
||||
elsewhere — the user clicked somewhere else, switched apps, etc. —
|
||||
the focus controller's "leave" signal fires and we tear down the
|
||||
menu tree. Submenus open from inside the top-level popup, so we
|
||||
defer the actual close to an idle callback: that gives the new
|
||||
submenu a chance to take focus first, and we only close if none of
|
||||
our windows still has it. */
|
||||
static gboolean any_popup_has_focus(void) {
|
||||
if (popup_win && gtk_window_has_toplevel_focus(GTK_WINDOW(popup_win)))
|
||||
if (popup_win && gtk_window_is_active(GTK_WINDOW(popup_win)))
|
||||
return TRUE;
|
||||
for (GList *l = submenu_popups; l; l = l->next) {
|
||||
if (gtk_window_has_toplevel_focus(GTK_WINDOW(l->data)))
|
||||
if (gtk_window_is_active(GTK_WINDOW(l->data)))
|
||||
return TRUE;
|
||||
}
|
||||
return FALSE;
|
||||
@@ -321,17 +325,90 @@ static gboolean any_popup_has_focus(void) {
|
||||
|
||||
static gboolean focus_out_recheck(gpointer user_data) {
|
||||
(void)user_data;
|
||||
if (!any_popup_has_focus()) {
|
||||
if (!any_popup_has_focus())
|
||||
close_all_popups();
|
||||
}
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
static gboolean on_popup_focus_out(GtkWidget *widget, GdkEvent *event,
|
||||
gpointer user_data) {
|
||||
(void)widget; (void)event; (void)user_data;
|
||||
static void on_popup_focus_leave(GtkEventControllerFocus *ctrl,
|
||||
gpointer user_data) {
|
||||
(void)ctrl; (void)user_data;
|
||||
g_idle_add(focus_out_recheck, NULL);
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
/* Attach a focus controller that fires close_all_popups on focus loss. */
|
||||
static void attach_outside_click_close(GtkWidget *win) {
|
||||
GtkEventController *focus = gtk_event_controller_focus_new();
|
||||
g_signal_connect(focus, "leave",
|
||||
G_CALLBACK(on_popup_focus_leave), NULL);
|
||||
gtk_widget_add_controller(win, focus);
|
||||
}
|
||||
|
||||
/* Move a GtkWindow at the X11 level. GTK4 removed gtk_window_move(); the
|
||||
GdkSurface is mapped to a real X11 Window we can reposition with
|
||||
XMoveWindow. Must be called after the window has been realized (i.e.
|
||||
after gtk_widget_set_visible TRUE).
|
||||
|
||||
The popup is **not** override-redirect — the WM keeps managing it so
|
||||
focus tracking still works (focus-out fires when the user clicks
|
||||
elsewhere). We tag the window with a stack of EWMH hints that make
|
||||
sane WMs (fluxbox, openbox, i3, kwin, mutter) render it like a
|
||||
floating menu: above the tray panel, skipped from taskbar/pager,
|
||||
no decorations. */
|
||||
static void x11_move_window(GtkWidget *win, int x, int y) {
|
||||
GdkSurface *surface = gtk_native_get_surface(GTK_NATIVE(win));
|
||||
if (!surface || !GDK_IS_X11_SURFACE(surface))
|
||||
return;
|
||||
Window xid = gdk_x11_surface_get_xid(surface);
|
||||
GdkDisplay *display = gdk_surface_get_display(surface);
|
||||
Display *xdpy = gdk_x11_display_get_xdisplay(GDK_X11_DISPLAY(display));
|
||||
|
||||
/* _NET_WM_WINDOW_TYPE_POPUP_MENU: makes fluxbox / openbox / etc
|
||||
render the window above panels and skip decorations. Must be
|
||||
set before the window is mapped to be honoured by some WMs;
|
||||
on already-mapped windows it works for most modern WMs but a
|
||||
few need an unmap/map cycle to re-read the property. */
|
||||
Atom wm_type = XInternAtom(xdpy, "_NET_WM_WINDOW_TYPE", False);
|
||||
Atom wm_type_popup = XInternAtom(xdpy, "_NET_WM_WINDOW_TYPE_POPUP_MENU", False);
|
||||
XChangeProperty(xdpy, xid, wm_type, XA_ATOM, 32,
|
||||
PropModeReplace, (unsigned char *)&wm_type_popup, 1);
|
||||
|
||||
/* _NET_WM_STATE_ABOVE + SKIP_TASKBAR + SKIP_PAGER. Bundled into
|
||||
one property write. */
|
||||
Atom wm_state = XInternAtom(xdpy, "_NET_WM_STATE", False);
|
||||
Atom state_above = XInternAtom(xdpy, "_NET_WM_STATE_ABOVE", False);
|
||||
Atom state_skip_tb = XInternAtom(xdpy, "_NET_WM_STATE_SKIP_TASKBAR", False);
|
||||
Atom state_skip_pg = XInternAtom(xdpy, "_NET_WM_STATE_SKIP_PAGER", False);
|
||||
Atom states[3] = { state_above, state_skip_tb, state_skip_pg };
|
||||
XChangeProperty(xdpy, xid, wm_state, XA_ATOM, 32,
|
||||
PropModeReplace, (unsigned char *)states, 3);
|
||||
|
||||
XMoveWindow(xdpy, xid, x, y);
|
||||
XRaiseWindow(xdpy, xid);
|
||||
|
||||
/* POPUP_MENU windows aren't given keyboard focus by most WMs (the
|
||||
spec says they're "menus", which traditionally use a grab rather
|
||||
than focus). Without focus GtkEventControllerFocus's leave signal
|
||||
never fires, so we'd have no way to notice the user clicking
|
||||
elsewhere. Ask the WM to activate us via _NET_ACTIVE_WINDOW
|
||||
(source=2 means "pager / pseudo-user request" which most WMs
|
||||
honour without timestamp checks). This is safer than calling
|
||||
XSetInputFocus directly — that races the X server with the
|
||||
not-yet-fully-mapped window and trips BadMatch. */
|
||||
Atom net_active = XInternAtom(xdpy, "_NET_ACTIVE_WINDOW", False);
|
||||
XClientMessageEvent ev;
|
||||
memset(&ev, 0, sizeof(ev));
|
||||
ev.type = ClientMessage;
|
||||
ev.window = xid;
|
||||
ev.message_type = net_active;
|
||||
ev.format = 32;
|
||||
ev.data.l[0] = 2; /* source: pager */
|
||||
ev.data.l[1] = CurrentTime;
|
||||
XSendEvent(xdpy, DefaultRootWindow(xdpy), False,
|
||||
SubstructureRedirectMask | SubstructureNotifyMask,
|
||||
(XEvent *)&ev);
|
||||
|
||||
XFlush(xdpy);
|
||||
}
|
||||
|
||||
/* Forward declaration — submenu buttons need to schedule a child popup. */
|
||||
@@ -346,55 +423,81 @@ typedef struct {
|
||||
static void on_submenu_button_clicked(GtkButton *btn, gpointer user_data) {
|
||||
submenu_open_data *sd = (submenu_open_data *)user_data;
|
||||
|
||||
GtkWidget *win = gtk_window_new(GTK_WINDOW_TOPLEVEL);
|
||||
gtk_window_set_type_hint(GTK_WINDOW(win), GDK_WINDOW_TYPE_HINT_POPUP_MENU);
|
||||
GtkWidget *win = gtk_window_new();
|
||||
gtk_window_set_decorated(GTK_WINDOW(win), FALSE);
|
||||
gtk_window_set_resizable(GTK_WINDOW(win), FALSE);
|
||||
gtk_window_set_skip_taskbar_hint(GTK_WINDOW(win), TRUE);
|
||||
gtk_window_set_skip_pager_hint(GTK_WINDOW(win), TRUE);
|
||||
gtk_window_set_keep_above(GTK_WINDOW(win), TRUE);
|
||||
|
||||
g_signal_connect(win, "focus-out-event",
|
||||
G_CALLBACK(on_popup_focus_out), NULL);
|
||||
attach_outside_click_close(win);
|
||||
|
||||
GtkWidget *vbox = build_menu_box(sd->items, sd->count);
|
||||
gtk_container_add(GTK_CONTAINER(win), vbox);
|
||||
gtk_window_set_child(GTK_WINDOW(win), vbox);
|
||||
|
||||
/* GtkButton has no native GdkWindow of its own — gtk_widget_get_window
|
||||
returns the parent popup's window. To get the button's screen-space
|
||||
position we read the popup origin (ox, oy) and add the button's
|
||||
allocation within the popup. */
|
||||
gint ox, oy;
|
||||
gdk_window_get_origin(gtk_widget_get_window(GTK_WIDGET(btn)), &ox, &oy);
|
||||
GtkAllocation alloc;
|
||||
gtk_widget_get_allocation(GTK_WIDGET(btn), &alloc);
|
||||
int ax = ox + alloc.x;
|
||||
int ay = oy + alloc.y;
|
||||
/* Need the anchor button's position in root coordinates. GTK4
|
||||
removed gtk_widget_translate_coordinates(); compute via the
|
||||
button's bounds within its native widget plus the native
|
||||
surface's screen origin via X11. */
|
||||
graphene_rect_t bounds;
|
||||
if (!gtk_widget_compute_bounds(GTK_WIDGET(btn),
|
||||
GTK_WIDGET(gtk_widget_get_native(GTK_WIDGET(btn))),
|
||||
&bounds)) {
|
||||
bounds.origin.x = 0;
|
||||
bounds.origin.y = 0;
|
||||
bounds.size.width = 0;
|
||||
bounds.size.height = 0;
|
||||
}
|
||||
GdkSurface *anchor_surface =
|
||||
gtk_native_get_surface(gtk_widget_get_native(GTK_WIDGET(btn)));
|
||||
int ox = 0, oy = 0;
|
||||
if (anchor_surface && GDK_IS_X11_SURFACE(anchor_surface)) {
|
||||
Window axid = gdk_x11_surface_get_xid(anchor_surface);
|
||||
GdkDisplay *display = gdk_surface_get_display(anchor_surface);
|
||||
Display *xdpy = gdk_x11_display_get_xdisplay(GDK_X11_DISPLAY(display));
|
||||
Window child;
|
||||
XTranslateCoordinates(xdpy, axid, DefaultRootWindow(xdpy),
|
||||
0, 0, &ox, &oy, &child);
|
||||
}
|
||||
int ax = ox + (int)bounds.origin.x;
|
||||
int ay = oy + (int)bounds.origin.y;
|
||||
|
||||
gtk_widget_show_all(win);
|
||||
gint sw, sh;
|
||||
gtk_window_get_size(GTK_WINDOW(win), &sw, &sh);
|
||||
gtk_widget_set_visible(win, TRUE);
|
||||
|
||||
int sw, sh;
|
||||
gtk_window_get_default_size(GTK_WINDOW(win), &sw, &sh);
|
||||
if (sw <= 0 || sh <= 0) {
|
||||
/* default_size returns -1,-1 if never explicitly set; fall back
|
||||
to the measured preferred size. */
|
||||
GtkRequisition req;
|
||||
gtk_widget_get_preferred_size(win, NULL, &req);
|
||||
sw = req.width;
|
||||
sh = req.height;
|
||||
}
|
||||
|
||||
/* The parent popup grows upward from the tray, so submenu items
|
||||
sit closer to the bottom of the screen than to the top. Align
|
||||
the submenu's BOTTOM to the anchor button's bottom: the popup
|
||||
grows upward, level with the row that opened it. Don't clamp
|
||||
to the monitor top — that would re-position the submenu next
|
||||
to an unrelated sibling row above the anchor. */
|
||||
int final_x = ax + alloc.width;
|
||||
int final_y = ay + alloc.height - sh;
|
||||
grows upward, level with the row that opened it. */
|
||||
int final_x = ax + (int)bounds.size.width;
|
||||
int final_y = ay + (int)bounds.size.height - sh;
|
||||
|
||||
/* Horizontal flip against the monitor under the anchor button. */
|
||||
GdkDisplay *display = gtk_widget_get_display(win);
|
||||
GdkMonitor *monitor = gdk_display_get_monitor_at_point(display, ax, ay);
|
||||
if (monitor) {
|
||||
GListModel *monitors = gdk_display_get_monitors(display);
|
||||
guint n = g_list_model_get_n_items(monitors);
|
||||
for (guint i = 0; i < n; i++) {
|
||||
GdkMonitor *m = (GdkMonitor *)g_list_model_get_item(monitors, i);
|
||||
GdkRectangle geom;
|
||||
gdk_monitor_get_geometry(monitor, &geom);
|
||||
if (final_x + sw > geom.x + geom.width)
|
||||
final_x = ax - sw; /* flip to the left */
|
||||
gdk_monitor_get_geometry(m, &geom);
|
||||
if (ax >= geom.x && ax < geom.x + geom.width &&
|
||||
ay >= geom.y && ay < geom.y + geom.height) {
|
||||
if (final_x + sw > geom.x + geom.width)
|
||||
final_x = ax - sw; /* flip to the left */
|
||||
g_object_unref(m);
|
||||
break;
|
||||
}
|
||||
g_object_unref(m);
|
||||
}
|
||||
|
||||
gtk_window_move(GTK_WINDOW(win), final_x, final_y);
|
||||
x11_move_window(win, final_x, final_y);
|
||||
gtk_window_present(GTK_WINDOW(win));
|
||||
|
||||
submenu_popups = g_list_prepend(submenu_popups, win);
|
||||
@@ -402,8 +505,7 @@ static void on_submenu_button_clicked(GtkButton *btn, gpointer user_data) {
|
||||
|
||||
/* Build a vbox of GtkWidgets for the supplied items. Used for both the
|
||||
top-level popup and each submenu popup. The submenu_open_data attached
|
||||
to submenu buttons is freed when the submenu_popups list is cleared
|
||||
(we use the button's "destroy" signal). */
|
||||
to submenu buttons is freed when the button is destroyed. */
|
||||
static void on_button_destroy_free_data(GtkWidget *widget, gpointer user_data) {
|
||||
(void)widget;
|
||||
free(user_data);
|
||||
@@ -417,19 +519,21 @@ static GtkWidget *build_menu_box(xembed_menu_item *items, int count) {
|
||||
|
||||
if (mi->is_separator) {
|
||||
GtkWidget *sep = gtk_separator_new(GTK_ORIENTATION_HORIZONTAL);
|
||||
gtk_box_pack_start(GTK_BOX(vbox), sep, FALSE, FALSE, 2);
|
||||
gtk_widget_set_margin_top(sep, 2);
|
||||
gtk_widget_set_margin_bottom(sep, 2);
|
||||
gtk_box_append(GTK_BOX(vbox), sep);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mi->is_check) {
|
||||
GtkWidget *chk = gtk_check_button_new_with_label(
|
||||
mi->label ? mi->label : "");
|
||||
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(chk), mi->checked);
|
||||
gtk_check_button_set_active(GTK_CHECK_BUTTON(chk), mi->checked);
|
||||
gtk_widget_set_sensitive(chk, mi->enabled);
|
||||
g_signal_connect(chk, "toggled",
|
||||
G_CALLBACK(on_check_toggled),
|
||||
GINT_TO_POINTER(mi->id));
|
||||
gtk_box_pack_start(GTK_BOX(vbox), chk, FALSE, FALSE, 0);
|
||||
gtk_box_append(GTK_BOX(vbox), chk);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -447,9 +551,10 @@ static GtkWidget *build_menu_box(xembed_menu_item *items, int count) {
|
||||
|
||||
GtkWidget *btn = gtk_button_new_with_label(label_text);
|
||||
gtk_widget_set_sensitive(btn, mi->enabled);
|
||||
gtk_button_set_relief(GTK_BUTTON(btn), GTK_RELIEF_NONE);
|
||||
GtkWidget *lbl = gtk_bin_get_child(GTK_BIN(btn));
|
||||
if (lbl) gtk_label_set_xalign(GTK_LABEL(lbl), 0.0);
|
||||
gtk_button_set_has_frame(GTK_BUTTON(btn), FALSE);
|
||||
GtkWidget *lbl = gtk_button_get_child(GTK_BUTTON(btn));
|
||||
if (GTK_IS_LABEL(lbl))
|
||||
gtk_label_set_xalign(GTK_LABEL(lbl), 0.0);
|
||||
|
||||
free(display_label);
|
||||
|
||||
@@ -468,7 +573,7 @@ static GtkWidget *build_menu_box(xembed_menu_item *items, int count) {
|
||||
G_CALLBACK(on_button_clicked),
|
||||
GINT_TO_POINTER(mi->id));
|
||||
}
|
||||
gtk_box_pack_start(GTK_BOX(vbox), btn, FALSE, FALSE, 0);
|
||||
gtk_box_append(GTK_BOX(vbox), btn);
|
||||
}
|
||||
|
||||
return vbox;
|
||||
@@ -480,38 +585,35 @@ static gboolean popup_menu_idle(gpointer user_data) {
|
||||
/* Destroy old top-level (and orphan submenus) before rebuilding. */
|
||||
close_all_popups();
|
||||
if (popup_win) {
|
||||
gtk_widget_destroy(popup_win);
|
||||
gtk_window_destroy(GTK_WINDOW(popup_win));
|
||||
popup_win = NULL;
|
||||
}
|
||||
|
||||
popup_win = gtk_window_new(GTK_WINDOW_TOPLEVEL);
|
||||
gtk_window_set_type_hint(GTK_WINDOW(popup_win),
|
||||
GDK_WINDOW_TYPE_HINT_POPUP_MENU);
|
||||
popup_win = gtk_window_new();
|
||||
gtk_window_set_decorated(GTK_WINDOW(popup_win), FALSE);
|
||||
gtk_window_set_resizable(GTK_WINDOW(popup_win), FALSE);
|
||||
gtk_window_set_skip_taskbar_hint(GTK_WINDOW(popup_win), TRUE);
|
||||
gtk_window_set_skip_pager_hint(GTK_WINDOW(popup_win), TRUE);
|
||||
gtk_window_set_keep_above(GTK_WINDOW(popup_win), TRUE);
|
||||
|
||||
/* Close on focus loss. */
|
||||
g_signal_connect(popup_win, "focus-out-event",
|
||||
G_CALLBACK(on_popup_focus_out), NULL);
|
||||
attach_outside_click_close(popup_win);
|
||||
|
||||
GtkWidget *vbox = build_menu_box(pd->items, pd->count);
|
||||
gtk_container_add(GTK_CONTAINER(popup_win), vbox);
|
||||
gtk_window_set_child(GTK_WINDOW(popup_win), vbox);
|
||||
|
||||
gtk_widget_show_all(popup_win);
|
||||
gtk_widget_set_visible(popup_win, TRUE);
|
||||
|
||||
/* Position the window above the click point (menu grows upward
|
||||
from tray). Use measured preferred size — default_size is -1
|
||||
until set. */
|
||||
GtkRequisition req;
|
||||
gtk_widget_get_preferred_size(popup_win, NULL, &req);
|
||||
int win_w = req.width;
|
||||
int win_h = req.height;
|
||||
|
||||
/* Position the window above the click point (menu grows upward from tray). */
|
||||
gint win_w, win_h;
|
||||
gtk_window_get_size(GTK_WINDOW(popup_win), &win_w, &win_h);
|
||||
int final_x = pd->x - win_w / 2;
|
||||
int final_y = pd->y - win_h;
|
||||
if (final_x < 0) final_x = 0;
|
||||
if (final_y < 0) final_y = pd->y; /* fallback: below click */
|
||||
gtk_window_move(GTK_WINDOW(popup_win), final_x, final_y);
|
||||
x11_move_window(popup_win, final_x, final_y);
|
||||
|
||||
/* Grab focus so focus-out-event works. */
|
||||
gtk_window_present(GTK_WINDOW(popup_win));
|
||||
|
||||
/* The vbox+children retain pointers into pd->items (via submenu
|
||||
|
||||
4
go.mod
4
go.mod
@@ -103,7 +103,7 @@ require (
|
||||
github.com/ti-mo/conntrack v0.5.1
|
||||
github.com/ti-mo/netfilter v0.5.2
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.94
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.95
|
||||
github.com/yusufpapurcu/wmi v1.2.4
|
||||
github.com/zcalusic/sysinfo v1.1.3
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0
|
||||
@@ -191,7 +191,7 @@ require (
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.9.0 // indirect
|
||||
github.com/go-git/go-git/v5 v5.19.0 // indirect
|
||||
github.com/go-git/go-git/v5 v5.19.1 // indirect
|
||||
github.com/go-ldap/ldap/v3 v3.4.13 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
|
||||
8
go.sum
8
go.sum
@@ -194,8 +194,8 @@ github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmm
|
||||
github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
||||
github.com/go-git/go-git/v5 v5.19.0 h1:+WkVUQZSy/F1Gb13udrMKjIM2PrzsNfDKFSfo5tkMtc=
|
||||
github.com/go-git/go-git/v5 v5.19.0/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ=
|
||||
github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00=
|
||||
github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
|
||||
@@ -703,8 +703,8 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.94 h1:c/0ZZTj3BFbZQD1s5KHwsshlhunH6YC++gt+cGYV6qA=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.94/go.mod h1:4cKvtUppwqYC9tVtvgHWzEmXfUnuLEV3q8d0Jh6xkQQ=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.95 h1:Rve8djRSldn6381q2l8gw8XEnzPX/4So6VsRM6bc7Vs=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.95/go.mod h1:3euiK0wb6vnXvxiHysRYYbukCa060bLSsfrvN7sZg4k=
|
||||
github.com/wailsapp/wails/webview2 v1.0.24 h1:uULnjCSaRfMlU84mS3kjLgPsRosEOIusVK1nFOHZHzs=
|
||||
github.com/wailsapp/wails/webview2 v1.0.24/go.mod h1:sdf+s0nAdxlzVWf9SCxC15XaxnQPJeY+uU1Ucn3jHQM=
|
||||
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||
|
||||
Reference in New Issue
Block a user