mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-27 00:59:07 +02:00
Merge main into embedded-vnc
This commit is contained in:
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
)
|
||||
|
||||
// Autostart facade over Wails' AutostartManager. The OS login-item registration
|
||||
// Autostart facade over Wails' AutostartManager. The OS autostart entry registration
|
||||
// is the single source of truth; nothing is mirrored to preferences.
|
||||
type Autostart struct {
|
||||
mgr *application.AutostartManager
|
||||
|
||||
@@ -35,7 +35,7 @@ type LoginResult struct {
|
||||
VerificationURIComplete string `json:"verificationUriComplete"`
|
||||
}
|
||||
|
||||
// WaitSSOParams are the inputs to WaitSSOLogin.
|
||||
// WaitSSOParams are the inputs to waitSSOLogin.
|
||||
type WaitSSOParams struct {
|
||||
UserCode string `json:"userCode"`
|
||||
Hostname string `json:"hostname"`
|
||||
@@ -125,23 +125,6 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Connection) WaitSSOLogin(ctx context.Context, p WaitSSOParams) (string, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
log.Infof("waiting for SSO login to complete")
|
||||
resp, err := cli.WaitSSOLogin(ctx, &proto.WaitSSOLoginRequest{
|
||||
UserCode: p.UserCode,
|
||||
Hostname: p.Hostname,
|
||||
})
|
||||
if err != nil {
|
||||
return "", s.classifyDaemonError(err)
|
||||
}
|
||||
log.Infof("SSO login completed, daemon reported success")
|
||||
return resp.GetEmail(), nil
|
||||
}
|
||||
|
||||
func (s *Connection) Up(ctx context.Context, p UpParams) error {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
@@ -162,6 +145,27 @@ func (s *Connection) Up(ctx context.Context, p UpParams) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// WaitSSOLoginAndUp blocks until the SSO login completes and then brings the
|
||||
// connection up, both from the Go side. Keeping the post-login Up here rather
|
||||
// than as a frontend continuation is deliberate: during SSO the tray window is
|
||||
// hidden and the webview is suspended (macOS App Nap / hidden-window timer
|
||||
// throttling), so a frontend-driven Up would not run until the user woke the
|
||||
// window (e.g. by hovering the tray icon). Doing it in Go connects the moment
|
||||
// the daemon reports SSO success. Returns the authenticated user's email.
|
||||
func (s *Connection) WaitSSOLoginAndUp(ctx context.Context, wait WaitSSOParams, up UpParams) (string, error) {
|
||||
email, err := s.waitSSOLogin(ctx, wait)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := s.Up(ctx, up); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return email, nil
|
||||
}
|
||||
|
||||
func (s *Connection) Down(ctx context.Context) error {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
@@ -221,6 +225,26 @@ func (s *Connection) Logout(ctx context.Context, p LogoutParams) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitSSOLogin blocks until the daemon reports the SSO login result and returns
|
||||
// the authenticated user's email. It is unexported because the frontend drives
|
||||
// SSO through the exported WaitSSOLoginAndUp.
|
||||
func (s *Connection) waitSSOLogin(ctx context.Context, p WaitSSOParams) (string, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
log.Infof("waiting for SSO login to complete")
|
||||
resp, err := cli.WaitSSOLogin(ctx, &proto.WaitSSOLoginRequest{
|
||||
UserCode: p.UserCode,
|
||||
Hostname: p.Hostname,
|
||||
})
|
||||
if err != nil {
|
||||
return "", s.classifyDaemonError(err)
|
||||
}
|
||||
log.Infof("SSO login completed, daemon reported success")
|
||||
return resp.GetEmail(), nil
|
||||
}
|
||||
|
||||
// classifyDaemonError maps a gRPC error to a localised ClientError.
|
||||
func (s *Connection) classifyDaemonError(err error) *ClientError {
|
||||
return s.classifier.classify(err)
|
||||
|
||||
@@ -6,13 +6,30 @@ import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
||||
gcodes "google.golang.org/grpc/codes"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/ui/i18n"
|
||||
"github.com/netbirdio/netbird/client/ui/preferences"
|
||||
)
|
||||
|
||||
// privilegeErrorInfo returns the daemon's privilege-refusal detail, if the error
|
||||
// carries one.
|
||||
func privilegeErrorInfo(err error) (*errdetails.ErrorInfo, bool) {
|
||||
for _, detail := range gstatus.Convert(err).Details() {
|
||||
info, ok := detail.(*errdetails.ErrorInfo)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if info.GetReason() == ipcauth.ErrorReasonPrivilegeRequired && info.GetDomain() == ipcauth.ErrorDomain {
|
||||
return info, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// ErrorTranslator localises daemon errors; runtime impl is *i18n.Bundle.
|
||||
type ErrorTranslator interface {
|
||||
Translate(lang i18n.LanguageCode, key string, args ...string) string
|
||||
@@ -30,6 +47,10 @@ type ClientError struct {
|
||||
Code string `json:"code"`
|
||||
Short string `json:"short"`
|
||||
Long string `json:"long"`
|
||||
// Command is a command the user can run to complete the operation
|
||||
// themselves, set when the daemon refused it for want of privileges. The
|
||||
// frontend offers it for copying.
|
||||
Command string `json:"command,omitempty"`
|
||||
}
|
||||
|
||||
// Error returns the short message for plain Go callers.
|
||||
@@ -72,6 +93,24 @@ func (c errorClassifier) classify(err error) *ClientError {
|
||||
msg = st.Message()
|
||||
grpcCode = st.Code()
|
||||
}
|
||||
|
||||
// A refusal for want of privileges carries its own summary and the command
|
||||
// that performs the operation, both written for the user. Surface them
|
||||
// verbatim: no substring guessing, and no localisation of a message the
|
||||
// daemon composed.
|
||||
if info, ok := privilegeErrorInfo(err); ok {
|
||||
summary := info.GetMetadata()[ipcauth.ErrorMetaSummary]
|
||||
if summary == "" {
|
||||
summary = msg
|
||||
}
|
||||
return &ClientError{
|
||||
Code: "privilege_required",
|
||||
Short: summary,
|
||||
Long: summary,
|
||||
Command: info.GetMetadata()[ipcauth.ErrorMetaCommand],
|
||||
}
|
||||
}
|
||||
|
||||
lower := strings.ToLower(msg)
|
||||
|
||||
code := "unknown"
|
||||
|
||||
@@ -12,13 +12,15 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
)
|
||||
|
||||
// ProfileSwitcher holds the reconnect policy shared by the tray and React
|
||||
// frontend so both flip profiles identically. The policy keys off prevStatus
|
||||
// from DaemonFeed.Get at SwitchActive entry:
|
||||
// ProfileSwitcher holds the switch policy shared by the tray and React
|
||||
// frontend so both flip profiles identically. SwitchActive (plain selection:
|
||||
// header dropdown, tray submenu) always connects after the switch;
|
||||
// SwitchActiveNoConnect (manage-profiles screen) never does, so the user can
|
||||
// still adjust the management URL before connecting. prevStatus from
|
||||
// DaemonFeed.Get at entry only decides the teardown:
|
||||
//
|
||||
// Connected/Connecting → Switch + Down + Up; optimistic Connecting paint.
|
||||
// NeedsLogin/LoginFailed/SessionExpired → Switch + Down; clear stale error for re-login.
|
||||
// Idle → Switch only.
|
||||
// Connected/Connecting/NeedsLogin/LoginFailed/SessionExpired → Down first.
|
||||
// Idle → no Down.
|
||||
type ProfileSwitcher struct {
|
||||
profiles *Profiles
|
||||
connection *Connection
|
||||
@@ -29,29 +31,40 @@ func NewProfileSwitcher(profiles *Profiles, connection *Connection, feed *Daemon
|
||||
return &ProfileSwitcher{profiles: profiles, connection: connection, feed: feed}
|
||||
}
|
||||
|
||||
// SwitchActive switches to the named profile applying the reconnect policy.
|
||||
// SwitchActive switches to the named profile and always connects afterwards.
|
||||
func (s *ProfileSwitcher) SwitchActive(ctx context.Context, p ProfileRef) error {
|
||||
return s.switchActive(ctx, p, true)
|
||||
}
|
||||
|
||||
// SwitchActiveNoConnect switches to the named profile without connecting,
|
||||
// tearing down any existing connection first.
|
||||
func (s *ProfileSwitcher) SwitchActiveNoConnect(ctx context.Context, p ProfileRef) error {
|
||||
return s.switchActive(ctx, p, false)
|
||||
}
|
||||
|
||||
func (s *ProfileSwitcher) switchActive(ctx context.Context, p ProfileRef, connect bool) error {
|
||||
prevStatus := ""
|
||||
if st, err := s.feed.Get(ctx); err == nil {
|
||||
prevStatus = st.Status
|
||||
} else {
|
||||
log.Warnf("profileswitcher: get status: %v", err)
|
||||
if s.feed != nil {
|
||||
if st, err := s.feed.Get(ctx); err == nil {
|
||||
prevStatus = st.Status
|
||||
} else {
|
||||
log.Warnf("profileswitcher: get status: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
wasActive := strings.EqualFold(prevStatus, StatusConnected) ||
|
||||
strings.EqualFold(prevStatus, StatusConnecting)
|
||||
needsDown := wasActive ||
|
||||
needsDown := strings.EqualFold(prevStatus, StatusConnected) ||
|
||||
strings.EqualFold(prevStatus, StatusConnecting) ||
|
||||
strings.EqualFold(prevStatus, StatusNeedsLogin) ||
|
||||
strings.EqualFold(prevStatus, StatusLoginFailed) ||
|
||||
strings.EqualFold(prevStatus, StatusSessionExpired)
|
||||
|
||||
log.Infof("profileswitcher: switch profile=%q prevStatus=%q wasActive=%v needsDown=%v",
|
||||
p.ProfileName, prevStatus, wasActive, needsDown)
|
||||
log.Infof("profileswitcher: switch profile=%q prevStatus=%q connect=%v needsDown=%v",
|
||||
p.ProfileName, prevStatus, connect, needsDown)
|
||||
|
||||
// Optimistic Connecting paint only when wasActive: those prevStatuses emit
|
||||
// stale Connected + transient Idle pushes during Down that must be
|
||||
// suppressed until Up resumes the stream (see DaemonFeed suppression table).
|
||||
if wasActive {
|
||||
// Optimistic Connecting paint plus stale-push suppression during Down (see
|
||||
// DaemonFeed suppression table); also arms the login-watch that pops
|
||||
// browser-login when the new profile turns out to need SSO.
|
||||
if connect && s.feed != nil {
|
||||
s.feed.BeginProfileSwitch()
|
||||
}
|
||||
|
||||
@@ -76,9 +89,9 @@ func (s *ProfileSwitcher) SwitchActive(ctx context.Context, p ProfileRef) error
|
||||
}
|
||||
}
|
||||
|
||||
if wasActive {
|
||||
if connect {
|
||||
if err := s.connection.Up(ctx, UpParams(p)); err != nil {
|
||||
return fmt.Errorf("reconnect %q: %w", p.ProfileName, err)
|
||||
return fmt.Errorf("connect %q: %w", p.ProfileName, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,10 @@ import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/daemonaddr"
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
@@ -22,6 +26,7 @@ type MDMFields struct {
|
||||
AllowServerVNC *bool `json:"allowServerVNC"`
|
||||
DisableVNCApproval bool `json:"disableVNCApproval"`
|
||||
DisableAutoConnect bool `json:"disableAutoConnect"`
|
||||
DisableAutostart bool `json:"disableAutostart"`
|
||||
BlockInbound bool `json:"blockInbound"`
|
||||
DisableMetricsCollection bool `json:"disableMetricsCollection"`
|
||||
SplitTunnelMode bool `json:"splitTunnelMode"`
|
||||
@@ -40,6 +45,19 @@ type Restrictions struct {
|
||||
Features Features `json:"features"`
|
||||
}
|
||||
|
||||
// Privilege tells the frontend whether this process may perform the changes the
|
||||
// daemon restricts to root/administrator, and carries the command for each so a
|
||||
// disabled control can show the way to do it.
|
||||
type Privilege struct {
|
||||
Privileged bool `json:"privileged"`
|
||||
// Actor names what the operation requires ("root", "administrator privileges").
|
||||
Actor string `json:"actor"`
|
||||
// Commands equivalent to the settings the daemon guards, ready to copy.
|
||||
AllowSSHServer string `json:"allowSshServer"`
|
||||
EnableSSHRoot string `json:"enableSshRoot"`
|
||||
DisableSSHAuth string `json:"disableSshAuth"`
|
||||
}
|
||||
|
||||
type ConfigParams struct {
|
||||
ProfileName string `json:"profileName"`
|
||||
Username string `json:"username"`
|
||||
@@ -111,11 +129,19 @@ type SetConfigParams struct {
|
||||
}
|
||||
|
||||
type Settings struct {
|
||||
conn DaemonConn
|
||||
conn DaemonConn
|
||||
classifier errorClassifier
|
||||
// daemonAddr is where the daemon listens, used to tell whether it runs as
|
||||
// this user and would therefore authorize us: see Privilege.
|
||||
daemonAddr string
|
||||
}
|
||||
|
||||
func NewSettings(conn DaemonConn) *Settings {
|
||||
return &Settings{conn: conn}
|
||||
func NewSettings(conn DaemonConn, translator ErrorTranslator, prefs LanguagePreference, daemonAddr string) *Settings {
|
||||
return &Settings{
|
||||
conn: conn,
|
||||
classifier: errorClassifier{translator: translator, prefs: prefs},
|
||||
daemonAddr: daemonAddr,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Settings) GetConfig(ctx context.Context, p ConfigParams) (Config, error) {
|
||||
@@ -198,8 +224,47 @@ func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) error {
|
||||
DisableSSHAuth: p.DisableSSHAuth,
|
||||
SshJWTCacheTTL: p.SSHJWTCacheTTL,
|
||||
}
|
||||
_, err = cli.SetConfig(ctx, req)
|
||||
return err
|
||||
if _, err := cli.SetConfig(ctx, req); err != nil {
|
||||
// Classified so the frontend gets the daemon's guidance instead of the
|
||||
// gRPC envelope, which is what a refused privileged change looks like.
|
||||
return s.classifier.classify(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Privilege reports whether this UI process could carry out the changes the
|
||||
// daemon restricts to root/administrator, and the command that performs the one
|
||||
// users hit in the SSH settings. It applies the daemon's own rule to what it can
|
||||
// see locally, so the frontend can present those controls as unavailable up front
|
||||
// instead of letting a save fail. No daemon round-trip, so it also works while the
|
||||
// daemon is down.
|
||||
//
|
||||
// Being root or an elevated administrator is one way. The other is running as the
|
||||
// daemon's own user while the daemon is unprivileged, which the daemon accepts
|
||||
// because such a caller can already rewrite the config it reads; that is the
|
||||
// rootless-container and Windows netstack-mode case, and it is read from the
|
||||
// ownership of the socket or pipe the daemon created.
|
||||
func (s *Settings) Privilege() Privilege {
|
||||
id, err := ipcauth.CurrentProcessIdentity()
|
||||
if err != nil {
|
||||
// Fail closed: report unprivileged, which only ever disables controls.
|
||||
log.Warnf("cannot read this process's identity, treating it as unprivileged: %v", err)
|
||||
return newPrivilege(false)
|
||||
}
|
||||
if id.IsPrivileged() {
|
||||
return newPrivilege(true)
|
||||
}
|
||||
return newPrivilege(daemonaddr.DaemonRunsAsSelf(s.daemonAddr))
|
||||
}
|
||||
|
||||
func newPrivilege(privileged bool) Privilege {
|
||||
return Privilege{
|
||||
Privileged: privileged,
|
||||
Actor: ipcauth.PrivilegedActor(),
|
||||
AllowSSHServer: ipcauth.UpCommand("--allow-server-ssh"),
|
||||
EnableSSHRoot: ipcauth.UpCommand("--enable-ssh-root"),
|
||||
DisableSSHAuth: ipcauth.UpCommand("--disable-ssh-auth"),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Settings) GetRestrictions(ctx context.Context) (Restrictions, error) {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package services
|
||||
|
||||
import "sync/atomic"
|
||||
|
||||
var (
|
||||
sessionEnding atomic.Bool
|
||||
quitting atomic.Bool
|
||||
)
|
||||
|
||||
func BeginSessionEnd() {
|
||||
sessionEnding.Store(true)
|
||||
}
|
||||
|
||||
func AbortSessionEnd() {
|
||||
sessionEnding.Store(false)
|
||||
}
|
||||
|
||||
func BeginShutdown() {
|
||||
quitting.Store(true)
|
||||
}
|
||||
|
||||
func ShuttingDown() bool {
|
||||
return sessionEnding.Load() || quitting.Load()
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/ui/updater"
|
||||
"github.com/netbirdio/netbird/version"
|
||||
)
|
||||
|
||||
// UpdateResult mirrors TriggerUpdateResponse.
|
||||
@@ -33,6 +34,12 @@ func (s *Update) GetState() updater.State {
|
||||
return s.holder.Get()
|
||||
}
|
||||
|
||||
// DownloadURL returns the platform-appropriate installer download link for
|
||||
// manual (non-enforced) updates.
|
||||
func (s *Update) DownloadURL() string {
|
||||
return version.DownloadUrl()
|
||||
}
|
||||
|
||||
// Quit exits the app. Scheduled off the calling goroutine so the JS caller's
|
||||
// response returns before the runtime tears down.
|
||||
func (s *Update) Quit() {
|
||||
|
||||
@@ -155,6 +155,9 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo
|
||||
})
|
||||
// Hide (not destroy) on close to keep React state; reset to General for a flash-free reopen.
|
||||
s.settings.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
|
||||
if ShuttingDown() {
|
||||
return
|
||||
}
|
||||
e.Cancel()
|
||||
s.app.Event.Emit(EventSettingsOpen, "general")
|
||||
s.settings.Hide()
|
||||
@@ -186,37 +189,38 @@ func (s *WindowManager) OpenBrowserLogin(uri string) {
|
||||
startURL = "/#/dialog/browser-login?uri=" + url.QueryEscape(uri)
|
||||
}
|
||||
s.hideOtherWindowsLocked("browser-login")
|
||||
// Prefer the main window's screen (multi-monitor); falls back to OS-default centering.
|
||||
var screen *application.Screen
|
||||
if s.mainWindow != nil {
|
||||
if sc, err := s.mainWindow.GetScreen(); err == nil {
|
||||
screen = sc
|
||||
}
|
||||
}
|
||||
opts := DialogWindowOptions("browser-login", s.title("window.title.signIn"), startURL, s.linuxIcon)
|
||||
// Not always-on-top: it would obscure the browser tab the user logs in through.
|
||||
opts.AlwaysOnTop = false
|
||||
opts.InitialPosition = application.WindowCentered
|
||||
opts.Screen = screen
|
||||
// Open on the active (where users cursor is) display, like the session-expiration dialog.
|
||||
opts.Screen = s.getScreenBasedOnCursorPosition()
|
||||
s.browserLogin = s.app.Window.NewWithOptions(opts)
|
||||
bl := s.browserLogin
|
||||
// Red-X close means cancel: emit the event so startLogin() tears down the SSO wait.
|
||||
bl.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
|
||||
s.app.Event.Emit(EventBrowserLoginCancel)
|
||||
s.mu.Lock()
|
||||
s.browserLogin = nil
|
||||
s.restoreHiddenWindowsLocked()
|
||||
// Only a live user red-X still has this registered; programmatic closers
|
||||
// nil s.browserLogin first and clean up themselves. Guarding here stops a
|
||||
// stale close event from wiping a replacement popup's state.
|
||||
userClosed := s.browserLogin == bl
|
||||
if userClosed {
|
||||
s.browserLogin = nil
|
||||
s.restoreHiddenWindowsLocked()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if userClosed {
|
||||
s.app.Event.Emit(EventBrowserLoginCancel)
|
||||
}
|
||||
})
|
||||
s.centerWhenReady(s.browserLogin)
|
||||
s.centerOnCursorScreen(s.browserLogin)
|
||||
return
|
||||
}
|
||||
if uri != "" {
|
||||
s.browserLogin.SetURL("/#/dialog/browser-login?uri=" + url.QueryEscape(uri))
|
||||
}
|
||||
s.centerOnCursorScreen(s.browserLogin)
|
||||
s.browserLogin.Show()
|
||||
s.browserLogin.Focus()
|
||||
s.centerWhenReady(s.browserLogin)
|
||||
}
|
||||
|
||||
// BrowserLoginWindow returns the live SSO popup, or nil. While non-nil it is the
|
||||
@@ -239,6 +243,15 @@ func (s *WindowManager) CloseBrowserLogin() {
|
||||
s.mu.Lock()
|
||||
w := s.browserLogin
|
||||
s.browserLogin = nil
|
||||
// The WindowClosing hook no-ops on a programmatic close, so restore here —
|
||||
// but only if a popup was actually open. The frontend calls this even when no
|
||||
// popup was ever shown (e.g. resetDialog() after an early RequestExtend failure,
|
||||
// or connection.ts's catch path), and hiddenForLogin is shared with
|
||||
// OpenInstallProgress, so an unconditional restore could re-show windows a
|
||||
// still-running install-progress is hiding.
|
||||
if w != nil {
|
||||
s.restoreHiddenWindowsLocked()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if w != nil {
|
||||
w.Close()
|
||||
@@ -332,6 +345,35 @@ func (s *WindowManager) CloseApproval() {
|
||||
}
|
||||
}
|
||||
|
||||
// CloseRenewFlow tears down the SSO session-renewal UI in a single call: it
|
||||
// closes the browser-login popup and the session-expiration window together.
|
||||
func (s *WindowManager) CloseRenewFlow() {
|
||||
s.mu.Lock()
|
||||
bl := s.browserLogin
|
||||
se := s.sessionExpiration
|
||||
s.browserLogin = nil
|
||||
s.sessionExpiration = nil
|
||||
if se != nil {
|
||||
kept := s.hiddenForLogin[:0]
|
||||
for _, w := range s.hiddenForLogin {
|
||||
if w != se {
|
||||
kept = append(kept, w)
|
||||
}
|
||||
}
|
||||
s.hiddenForLogin = kept
|
||||
}
|
||||
s.restoreHiddenWindowsLocked()
|
||||
s.mu.Unlock()
|
||||
|
||||
// Close after unlock so the re-entrant handlers can take s.mu.
|
||||
if bl != nil {
|
||||
bl.Close()
|
||||
}
|
||||
if se != nil {
|
||||
se.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// OpenInstallProgress shows the install-progress window and hides the rest for the duration
|
||||
// (restored on close). It owns its own result polling since the daemon restarts mid-install.
|
||||
func (s *WindowManager) OpenInstallProgress(version string) {
|
||||
@@ -404,12 +446,17 @@ func (s *WindowManager) CloseWelcome() {
|
||||
}
|
||||
}
|
||||
|
||||
// OpenError shows the custom error dialog; title/message are pre-localised and ride in the
|
||||
// start URL. A second error replaces the open one via SetURL. Singleton, destroyed on close.
|
||||
func (s *WindowManager) OpenError(title, message string) {
|
||||
// OpenError shows the custom error dialog; title/message/command are pre-localised
|
||||
// and ride in the start URL. command is optional and, when set, is offered for
|
||||
// copying so the user can run the operation the daemon refused. A second error
|
||||
// replaces the open one via SetURL. Singleton, destroyed on close.
|
||||
func (s *WindowManager) OpenError(title, message, command string) {
|
||||
if ShuttingDown() {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
startURL := errorDialogURL(title, message)
|
||||
startURL := errorDialogURL(title, message, command)
|
||||
if s.errorDialog == nil {
|
||||
s.errorDialog = s.app.Window.NewWithOptions(
|
||||
DialogWindowOptions("error", s.title("window.title.error"), startURL, s.linuxIcon),
|
||||
@@ -609,8 +656,8 @@ func (s *WindowManager) getScreenBasedOnCursorPosition() *application.Screen {
|
||||
return nil
|
||||
}
|
||||
|
||||
// errorDialogURL builds the error window's start URL with title/message as escaped query params.
|
||||
func errorDialogURL(title, message string) string {
|
||||
// errorDialogURL builds the error window's start URL with title/message/command as escaped query params.
|
||||
func errorDialogURL(title, message, command string) string {
|
||||
q := url.Values{}
|
||||
if title != "" {
|
||||
q.Set("title", title)
|
||||
@@ -618,6 +665,9 @@ func errorDialogURL(title, message string) string {
|
||||
if message != "" {
|
||||
q.Set("message", message)
|
||||
}
|
||||
if command != "" {
|
||||
q.Set("command", command)
|
||||
}
|
||||
startURL := "/#/dialog/error"
|
||||
if enc := q.Encode(); enc != "" {
|
||||
startURL += "?" + enc
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package services
|
||||
|
||||
import "github.com/wailsapp/wails/v3/pkg/w32"
|
||||
|
||||
// Wails assigns w32.AllowDarkModeForWindow only on builds >= 18334 but calls it
|
||||
// without a nil check when a window requests the Dark theme, crashing older
|
||||
// builds such as Windows Server 2019 (17763). Those builds still get a dark
|
||||
// title bar via the pre-20H1 DWM attribute that w32.SetTheme applies, so a
|
||||
// no-op stub keeps the Dark theme fully working there.
|
||||
func init() {
|
||||
if w32.AllowDarkModeForWindow == nil {
|
||||
w32.AllowDarkModeForWindow = func(w32.HWND, bool) uintptr { return 0 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user