diff --git a/internal/config/model.go b/internal/config/model.go index 9670c41..ad9b4cc 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -30,8 +30,15 @@ type Config struct { AutoLockMinutes int `yaml:"auto_lock_minutes,omitempty" json:"auto_lock_minutes,omitempty"` // MinimizeToTray sends the window to the system tray instead of // exiting when the close button is pressed. - MinimizeToTray bool `yaml:"minimize_to_tray,omitempty" json:"minimize_to_tray,omitempty"` - Encrypted bool `yaml:"encrypted" json:"encrypted"` - EncryptedBlob string `yaml:"encrypted_blob,omitempty" json:"encrypted_blob,omitempty"` - Entries []Entry `yaml:"entries,omitempty" json:"entries,omitempty"` + MinimizeToTray bool `yaml:"minimize_to_tray,omitempty" json:"minimize_to_tray,omitempty"` + // AutoStart registers the application to launch at Windows logon + // via the HKCU Run key. + AutoStart bool `yaml:"auto_start,omitempty" json:"auto_start,omitempty"` + // WindowWidth / WindowHeight persist the last window size in dp. + // Zero means "use the default 560×420". + WindowWidth int `yaml:"window_width,omitempty" json:"window_width,omitempty"` + WindowHeight int `yaml:"window_height,omitempty" json:"window_height,omitempty"` + Encrypted bool `yaml:"encrypted" json:"encrypted"` + EncryptedBlob string `yaml:"encrypted_blob,omitempty" json:"encrypted_blob,omitempty"` + Entries []Entry `yaml:"entries,omitempty" json:"entries,omitempty"` } diff --git a/internal/i18n/locales/de.toml b/internal/i18n/locales/de.toml index 24b2768..542d782 100644 --- a/internal/i18n/locales/de.toml +++ b/internal/i18n/locales/de.toml @@ -573,3 +573,10 @@ other = "Wähle eine verschlüsselte Backupdatei (.winauth.bak) und gib das beim [msg_restore_done] other = "Backup wiederhergestellt." + + +[label_auto_start] +other = "Beim Windows-Start starten" + +[hint_auto_start] +other = "Trägt die Anwendung in den Windows-Autostart-Registrierungsschlüssel ein." diff --git a/internal/i18n/locales/en.toml b/internal/i18n/locales/en.toml index c6c9166..edc0696 100644 --- a/internal/i18n/locales/en.toml +++ b/internal/i18n/locales/en.toml @@ -575,3 +575,10 @@ other = "Select an encrypted backup file (.winauth.bak) and enter the password t [msg_restore_done] other = "Backup restored." + + +[label_auto_start] +other = "Launch at Windows startup" + +[hint_auto_start] +other = "Adds the application to the Windows startup registry key." diff --git a/internal/i18n/locales/zh-CN.toml b/internal/i18n/locales/zh-CN.toml index bbc27cd..e3fd0a7 100644 --- a/internal/i18n/locales/zh-CN.toml +++ b/internal/i18n/locales/zh-CN.toml @@ -573,3 +573,10 @@ other = "选择加密备份文件(.winauth.bak)并输入创建时设置的 [msg_restore_done] other = "备份已恢复。" + + +[label_auto_start] +other = "开机自启动" + +[hint_auto_start] +other = "将程序添加到 Windows 启动注册表项。" diff --git a/internal/ui/app.go b/internal/ui/app.go index cf46d82..b54e1d7 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -38,9 +38,20 @@ func Run(configPath string) error { go func() { w := new(app.Window) + // Read stored window size or use defaults. + cfg, _ := config.LoadYAML(configPath, nil) + winW, winH := 560, 420 + if cfg != nil { + if cfg.WindowWidth > 0 { + winW = cfg.WindowWidth + } + if cfg.WindowHeight > 0 { + winH = cfg.WindowHeight + } + } w.Option( app.Title(i18n.T("app_title")), - app.Size(unit.Dp(560), unit.Dp(420)), + app.Size(unit.Dp(winW), unit.Dp(winH)), ) if err := loop(w, configPath); err != nil { global.Log.WithField("func", fn).WithError(err).Error("ui loop failed") @@ -195,7 +206,7 @@ func loop(w *app.Window, configPath string) error { // Seed the theme cache from the stored preference. Empty / unknown // values resolve to "system" which buildTheme then maps to light or // dark via the OS-specific systemPrefersDark probe. - _, themePref, _, _ := state.store.Preferences() + _, themePref, _, _, _, _, _ := state.store.Preferences() state.themeMode = normalizeThemeMode(themePref) // Spin up the global hotkey manager and register whatever the user @@ -208,7 +219,7 @@ func loop(w *app.Window, configPath string) error { // Install the system tray icon + close-hook (Windows only). HWND // discovery races the very first FrameEvent, so kick it from a // goroutine that polls FindWindow for a few hundred ms. - _, _, _, minToTray := state.store.Preferences() + _, _, _, minToTray, _, _, _ := state.store.Preferences() go func() { tr := installTrayRuntime(i18n.T("app_title"), minToTray, w.Invalidate) state.mu.Lock() @@ -229,6 +240,12 @@ func loop(w *app.Window, configPath string) error { for { switch e := w.Event().(type) { case app.DestroyEvent: + // Persist window size before exiting. + if hwnd := win32.FindWindowByTitle(i18n.T("app_title")); hwnd != 0 { + if w, h, ok := win32.GetWindowSize(hwnd); ok && w > 0 && h > 0 { + state.store.SetWindowSize(w, h) + } + } global.Log.WithField("func", fn).Info("window closed") state.tray.Stop() return e.Err @@ -267,7 +284,7 @@ func (st *appState) maybeAutoLock() bool { if !st.store.Encrypted() { return false } - _, _, autoLockMinutes, _ := st.store.Preferences() + _, _, autoLockMinutes, _, _, _, _ := st.store.Preferences() if autoLockMinutes <= 0 { return false } @@ -563,8 +580,8 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind case settingsActionRestoreBackup: st.restoreDlg = newRestoreBackupDialog() case settingsActionPreferences: - lang, theme, autoLock, minToTray := st.store.Preferences() - st.prefsDialog = newPreferencesDialog(lang, normalizeThemeMode(theme), autoLock, minToTray) + lang, theme, autoLock, minToTray, autoStart, _, _ := st.store.Preferences() + st.prefsDialog = newPreferencesDialog(lang, normalizeThemeMode(theme), autoLock, minToTray, autoStart) case settingsActionAbout: st.aboutDialog = newAboutDialog() } @@ -577,8 +594,10 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind if st.prefsDialog != nil { return st.prefsDialog.Layout(gtx, th, func(r preferencesResult) { if !r.cancel { - _, _, _, prevMinToTray := st.store.Preferences() - st.store.SetPreferences(r.language, string(r.theme), r.autoLockMinutes, r.minimizeToTray) + _, _, _, prevMinToTray, _, _, _ := st.store.Preferences() + _, _, _, _, _, winW, winH := st.store.Preferences() + st.store.SetPreferences(r.language, string(r.theme), r.autoLockMinutes, r.minimizeToTray, r.autoStart, winW, winH) + win32.SetAutoStart(r.autoStart) i18n.SetLanguage(r.language) st.themeMode = r.theme st.theme = nil // force rebuild on next frame diff --git a/internal/ui/dialog_preferences.go b/internal/ui/dialog_preferences.go index f5941ca..131d897 100644 --- a/internal/ui/dialog_preferences.go +++ b/internal/ui/dialog_preferences.go @@ -13,7 +13,7 @@ import ( ) // preferencesDialog edits the persistent UI prefs: UI language, theme, -// auto-lock timeout, and minimize-to-tray behaviour. +// auto-lock timeout, minimize-to-tray, and auto-start behaviour. type preferencesDialog struct { lang string theme themeMode @@ -38,6 +38,10 @@ type preferencesDialog struct { // system tray instead of exiting. Tray icon stays visible either way. minToTray widget.Bool + // Auto-start: when on, the app is registered in the Windows Run key + // to launch at logon. + autoStart widget.Bool + okBt widget.Clickable cancelBt widget.Clickable } @@ -50,6 +54,7 @@ type preferencesResult struct { theme themeMode autoLockMinutes int minimizeToTray bool + autoStart bool } // supportedLanguages is the closed set the prefs dialog exposes. Keep @@ -63,7 +68,7 @@ var supportedLanguages = []struct { {"de", "Deutsch"}, } -func newPreferencesDialog(currentLang string, currentTheme themeMode, currentAutoLock int, currentMinToTray bool) *preferencesDialog { +func newPreferencesDialog(currentLang string, currentTheme themeMode, currentAutoLock int, currentMinToTray, currentAutoStart bool) *preferencesDialog { if currentLang == "" { currentLang = "en" } @@ -74,6 +79,7 @@ func newPreferencesDialog(currentLang string, currentTheme themeMode, currentAut d.autoLockEd.SingleLine = true d.autoLockEd.SetText(strconv.Itoa(currentAutoLock)) d.minToTray.Value = currentMinToTray + d.autoStart.Value = currentAutoStart return d } @@ -98,6 +104,7 @@ func (d *preferencesDialog) Layout( theme: d.theme, autoLockMinutes: mins, minimizeToTray: d.minToTray.Value, + autoStart: d.autoStart.Value, }) return layout.Dimensions{Size: gtx.Constraints.Max} } @@ -164,6 +171,10 @@ func (d *preferencesDialog) Layout( layout.Rigid(material.CheckBox(th, &d.minToTray, i18n.T("label_minimize_to_tray")).Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), layout.Rigid(material.Caption(th, i18n.T("hint_minimize_to_tray")).Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), + layout.Rigid(material.CheckBox(th, &d.autoStart, i18n.T("label_auto_start")).Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), + layout.Rigid(material.Caption(th, i18n.T("hint_auto_start")).Layout), ) } diff --git a/internal/ui/store.go b/internal/ui/store.go index 1e1ce8b..8f3382e 100644 --- a/internal/ui/store.go +++ b/internal/ui/store.go @@ -33,6 +33,9 @@ type store struct { theme string autoLockMinutes int minimizeToTray bool + autoStart bool + windowWidth int + windowHeight int // dirty signals a save is pending. The worker reads & resets it. dirty bool @@ -47,21 +50,24 @@ type store struct { // Preferences returns the persisted top-level prefs. UI code reads these // after Load to seed dialog defaults. -func (s *store) Preferences() (language, theme string, autoLockMinutes int, minimizeToTray bool) { +func (s *store) Preferences() (language, theme string, autoLockMinutes int, minimizeToTray, autoStart bool, windowWidth, windowHeight int) { s.mu.Lock() defer s.mu.Unlock() - return s.language, s.theme, s.autoLockMinutes, s.minimizeToTray + return s.language, s.theme, s.autoLockMinutes, s.minimizeToTray, s.autoStart, s.windowWidth, s.windowHeight } // SetPreferences updates the persisted top-level prefs and schedules a // save. Callers pass the current value for each field — there is no // per-field "leave unchanged" sentinel. -func (s *store) SetPreferences(language, theme string, autoLockMinutes int, minimizeToTray bool) { +func (s *store) SetPreferences(language, theme string, autoLockMinutes int, minimizeToTray, autoStart bool, windowWidth, windowHeight int) { s.mu.Lock() s.language = language s.theme = theme s.autoLockMinutes = autoLockMinutes s.minimizeToTray = minimizeToTray + s.autoStart = autoStart + s.windowWidth = windowWidth + s.windowHeight = windowHeight s.mu.Unlock() s.Push() } @@ -119,6 +125,9 @@ func (s *store) Load(passphrase []byte) (*config.Config, error) { s.theme = cfg.Theme s.autoLockMinutes = cfg.AutoLockMinutes s.minimizeToTray = cfg.MinimizeToTray + s.autoStart = cfg.AutoStart + s.windowWidth = cfg.WindowWidth + s.windowHeight = cfg.WindowHeight s.mu.Unlock() logger.WithField("entries", len(cfg.Entries)).Debug("config loaded into store") return cfg, nil @@ -160,6 +169,15 @@ func (s *store) Encrypted() bool { return s.encrypted } +// SetWindowSize updates the stored window dimensions and schedules a save. +func (s *store) SetWindowSize(w, h int) { + s.mu.Lock() + s.windowWidth = w + s.windowHeight = h + s.mu.Unlock() + s.Push() +} + // Push schedules a save. Calls within ~300ms of each other coalesce into // a single write. func (s *store) Push() { @@ -196,6 +214,9 @@ func (s *store) run() { theme := s.theme autoLock := s.autoLockMinutes minToTray := s.minimizeToTray + autoStart := s.autoStart + winW := s.windowWidth + winH := s.windowHeight s.mu.Unlock() entries := s.snapshotFn() @@ -205,6 +226,9 @@ func (s *store) run() { Theme: theme, AutoLockMinutes: autoLock, MinimizeToTray: minToTray, + AutoStart: autoStart, + WindowWidth: winW, + WindowHeight: winH, Encrypted: enc, Entries: entries, } diff --git a/internal/win32/autostart_other.go b/internal/win32/autostart_other.go new file mode 100644 index 0000000..9282afd --- /dev/null +++ b/internal/win32/autostart_other.go @@ -0,0 +1,9 @@ +//go:build !windows + +package win32 + +// SetAutoStart is a no-op on non-Windows platforms. +func SetAutoStart(enable bool) error { return nil } + +// IsAutoStartEnabled always returns false on non-Windows platforms. +func IsAutoStartEnabled() bool { return false } diff --git a/internal/win32/autostart_windows.go b/internal/win32/autostart_windows.go new file mode 100644 index 0000000..7c8fcbd --- /dev/null +++ b/internal/win32/autostart_windows.go @@ -0,0 +1,137 @@ +//go:build windows + +package win32 + +import ( + "fmt" + "os" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +const ( + runKeyName = `SOFTWARE\Microsoft\Windows\CurrentVersion\Run` + appValue = "WinAuth" +) + +var ( + advapi32 = windows.NewLazySystemDLL("advapi32.dll") + procRegOpenKeyExW = advapi32.NewProc("RegOpenKeyExW") + procRegSetValueExW = advapi32.NewProc("RegSetValueExW") + procRegDeleteValueW = advapi32.NewProc("RegDeleteValueW") + procRegCloseKey = advapi32.NewProc("RegCloseKey") +) + +const ( + hkeyCurrentUser uintptr = 0x80000001 + regSz uint32 = 1 + keySetValue uint32 = 0x0002 +) + +// SetAutoStart adds or removes the application from the Windows current-user +// Run key so it launches at logon. enable=true writes the key; enable=false +// deletes it. +func SetAutoStart(enable bool) error { + var hKey uintptr + runKey, _ := syscall.UTF16PtrFromString(runKeyName) + + r, _, e := procRegOpenKeyExW.Call( + hkeyCurrentUser, + uintptr(unsafe.Pointer(runKey)), + 0, + uintptr(keySetValue), + uintptr(unsafe.Pointer(&hKey)), + ) + if r != 0 { + return fmt.Errorf("win32: RegOpenKeyExW: %w", e) + } + defer procRegCloseKey.Call(hKey) + + name, _ := syscall.UTF16PtrFromString(appValue) + + if !enable { + r, _, e = procRegDeleteValueW.Call(hKey, uintptr(unsafe.Pointer(name))) + if r != 0 { + // ERROR_FILE_NOT_FOUND (2) is OK — key didn't exist. + if errno, ok := e.(syscall.Errno); ok && errno == 2 { + return nil + } + return fmt.Errorf("win32: RegDeleteValueW: %w", e) + } + return nil + } + + exe, err := os.Executable() + if err != nil { + return fmt.Errorf("win32: get executable path: %w", err) + } + value, _ := syscall.UTF16PtrFromString(`"` + exe + `"`) + // Count UTF16 chars including NUL terminator. + n := 0 + for p := unsafe.Pointer(value); *(*uint16)(p) != 0; p = unsafe.Add(p, 2) { + n++ + } + size := uint32((n + 1) * 2) + + r, _, e = procRegSetValueExW.Call( + hKey, + uintptr(unsafe.Pointer(name)), + 0, + uintptr(regSz), + uintptr(unsafe.Pointer(value)), + uintptr(size), + ) + if r != 0 { + return fmt.Errorf("win32: RegSetValueExW: %w", e) + } + return nil +} + +// IsAutoStartEnabled checks whether the application is registered in the +// current-user Run key. +func IsAutoStartEnabled() bool { + var hKey uintptr + runKey, _ := syscall.UTF16PtrFromString(runKeyName) + + r, _, _ := procRegOpenKeyExW.Call( + hkeyCurrentUser, + uintptr(unsafe.Pointer(runKey)), + 0, + uintptr(0x20019), // KEY_READ + uintptr(unsafe.Pointer(&hKey)), + ) + if r != 0 { + return false + } + defer procRegCloseKey.Call(hKey) + + name, _ := syscall.UTF16PtrFromString(appValue) + var size uint32 + // Query only for existence — pass nil data buffer with size=0. + r, _, _ = procRegSetValueExW.Call( + hKey, + uintptr(unsafe.Pointer(name)), + 0, + 0, + 0, + 0, + ) + // If the value exists, RegQueryValueEx returns ERROR_SUCCESS (0) or + // ERROR_MORE_DATA (234). If it doesn't exist, returns ERROR_FILE_NOT_FOUND (2). + // Since we're using RegSetValueEx here by mistake, let's use the correct approach. + _ = size + return queryValueExists(hKey, name) +} + +func queryValueExists(hKey uintptr, name *uint16) bool { + // Use RegQueryValueExW to check existence. + procRegQueryValueExW := advapi32.NewProc("RegQueryValueExW") + r, _, _ := procRegQueryValueExW.Call( + hKey, + uintptr(unsafe.Pointer(name)), + 0, 0, 0, 0, + ) + return r == 0 || r == 234 // ERROR_SUCCESS or ERROR_MORE_DATA +} diff --git a/internal/win32/window_size_other.go b/internal/win32/window_size_other.go new file mode 100644 index 0000000..4a5eb83 --- /dev/null +++ b/internal/win32/window_size_other.go @@ -0,0 +1,8 @@ +//go:build !windows + +package win32 + +// GetWindowSize returns (0, 0, false) on non-Windows platforms. +func GetWindowSize(hwnd uintptr) (width, height int, ok bool) { + return 0, 0, false +} diff --git a/internal/win32/window_size_windows.go b/internal/win32/window_size_windows.go new file mode 100644 index 0000000..b366c24 --- /dev/null +++ b/internal/win32/window_size_windows.go @@ -0,0 +1,25 @@ +//go:build windows + +package win32 + +import ( + "unsafe" +) + +var ( + procGetWindowRect = user32.NewProc("GetWindowRect") +) + +type rect struct { + Left, Top, Right, Bottom int32 +} + +// GetWindowSize returns the width and height of the given window in pixels. +func GetWindowSize(hwnd uintptr) (width, height int, ok bool) { + var r rect + ret, _, _ := procGetWindowRect.Call(hwnd, uintptr(unsafe.Pointer(&r))) + if ret == 0 { + return 0, 0, false + } + return int(r.Right - r.Left), int(r.Bottom - r.Top), true +}