classify daemon login errors and surface localised dialogs

The daemon returns gRPC errors whose message is a wrapped mgm + JWT
stack (e.g. "invalid jwt token, err: token could not be parsed: ...").
Showing that in a native dialog is unreadable. Connection now maps the
substrings it recognises to a ClientError{code, short, long} so the UI
can render a localised summary plus a Details: block carrying the raw
daemon text. formatErrorMessage on the TS side reads the structured
payload from Wails' Error.cause (or the JSON-stringified Error.message)
and falls back to plain Error.message for callers not yet migrated.

Also bumps Wails to v3.0.0-alpha.95.
This commit is contained in:
Zoltan Papp
2026-05-20 19:13:13 +02:00
parent 341848b1ae
commit d3b660afba
16 changed files with 262 additions and 36 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

4
go.mod
View File

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

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