feat: persist main window state

This commit is contained in:
QiuSW
2026-07-09 00:34:47 +08:00
parent 91debb9ad6
commit 7942add8b6
14 changed files with 362 additions and 23 deletions
+9 -4
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"log"
"runtime"
"sync"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
@@ -17,7 +18,8 @@ type AppService struct {
SecondWindow *application.WebviewWindow
MainWindow *application.WebviewWindow
Config *AppConfigService
Config *AppConfigService
windowStateMu sync.Mutex
Tray *application.SystemTray
I18nFS embed.FS
@@ -45,15 +47,16 @@ func ConfigureAppService(a *AppService, app *application.App, i18nFS embed.FS, s
a.Config = NewAppConfigService(suidb)
isWindows := runtime.GOOS == "windows"
mainWindowState := a.loadMainWindowStateOptions()
mainWindow := app.NewWebviewWindowWithOptions(application.WebviewWindowOptions{
Title: "main",
Name: "main",
Width: mainWindowWidth,
Height: mainWindowHeight,
Width: mainWindowState.Width,
Height: mainWindowState.Height,
MinWidth: mainWindowMinWidth,
MinHeight: mainWindowMinHeight,
InitialPosition: application.WindowCentered,
StartState: application.WindowStateNormal,
StartState: mainWindowState.StartState,
DisableResize: false,
Frameless: isWindows,
Mac: application.MacWindow{
@@ -83,6 +86,7 @@ func ConfigureAppService(a *AppService, app *application.App, i18nFS embed.FS, s
secondWindow.Hide()
a.SecondWindow = secondWindow
a.MainWindow = mainWindow
a.registerMainWindowStatePersistence()
RegisterWindowAndCallback(HotkeyShowSecondWindow, a.SecondWindow, func() {
if a.SecondWindow == nil {
@@ -93,6 +97,7 @@ func ConfigureAppService(a *AppService, app *application.App, i18nFS embed.FS, s
})
a.MainWindow.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
a.persistMainWindowState()
a.MainWindow.Hide()
e.Cancel()
})
+16 -5
View File
@@ -58,7 +58,7 @@ func settingsDefinitions() []models.AppSettingDefinition {
Label: "启动窗口状态",
Type: settingTypeSelect,
DefaultValue: "normal",
Description: "主窗口启动时默认状态;尺寸恢复由 T-015 继续落地。",
Description: "主窗口启动时恢复的窗口状态。",
Options: []models.AppSettingOption{
{Value: "normal", Label: "普通窗口"},
{Value: "maximized", Label: "最大化"},
@@ -70,7 +70,7 @@ func settingsDefinitions() []models.AppSettingDefinition {
Label: "默认宽度",
Type: settingTypeNumber,
DefaultValue: "1280",
Description: "主窗口默认宽度,当前窗口创建逻辑使用固定默认值。",
Description: "主窗口普通状态下保存的宽度。",
},
{
Key: "window.height",
@@ -78,15 +78,15 @@ func settingsDefinitions() []models.AppSettingDefinition {
Label: "默认高度",
Type: settingTypeNumber,
DefaultValue: "800",
Description: "主窗口默认高度,当前窗口创建逻辑使用固定默认值。",
Description: "主窗口普通状态下保存的高度。",
},
{
Key: "window.remember_state",
Category: "window",
Label: "记住窗口状态",
Type: settingTypeBoolean,
DefaultValue: "false",
Description: "保存主窗口尺寸和最大化状态,T-015 使用该配置。",
DefaultValue: "true",
Description: "保存并恢复主窗口尺寸和最大化状态。",
},
{
Key: "shortcut.open_second_window",
@@ -221,6 +221,17 @@ func defaultAppConfigSQL() string {
return builder.String()
}
func appConfigDataMigrationSQL() string {
return `
UPDATE appconfig
SET value = 'true',
description = '保存并恢复主窗口尺寸和最大化状态。'
WHERE key = 'window.remember_state'
AND value = 'false'
AND description = '保存主窗口尺寸和最大化状态,T-015 使用该配置。';
`
}
func sqlString(value string) string {
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
}
+3
View File
@@ -113,6 +113,9 @@ func initDefaultData(db *sql.DB) error {
if _, err := db.Exec(defaultAppConfigSQL()); err != nil {
return err
}
if _, err := db.Exec(appConfigDataMigrationSQL()); err != nil {
return err
}
if _, err := db.Exec(defaultHotkeysSQL()); err != nil {
return err
}
+26 -1
View File
@@ -80,7 +80,7 @@ func TestAppConfigServiceSettingsSchema(t *testing.T) {
"window.start_state": "normal",
"window.width": "1280",
"window.height": "800",
"window.remember_state": "false",
"window.remember_state": "true",
"shortcut.open_second_window": "Alt+P",
"shortcut.open_main_window": "Alt+M",
"data.root_mode": "app_config_dir",
@@ -114,6 +114,31 @@ func TestAppConfigServiceSettingsSchema(t *testing.T) {
}
}
func TestMigrateUpgradesWindowRememberStateOldDefault(t *testing.T) {
store := newTestStore(t)
if _, err := store.DB.Exec(`
UPDATE appconfig
SET value = 'false',
description = '保存主窗口尺寸和最大化状态,T-015 使用该配置。'
WHERE key = 'window.remember_state'
`); err != nil {
t.Fatalf("set old window remember default: %v", err)
}
if err := Migrate(store.DB); err != nil {
t.Fatalf("migrate old window remember default: %v", err)
}
var value string
if err := store.DB.QueryRow("SELECT value FROM appconfig WHERE key = 'window.remember_state'").Scan(&value); err != nil {
t.Fatalf("query window remember state: %v", err)
}
if value != "true" {
t.Fatalf("window.remember_state = %q, want true", value)
}
}
func TestAppConfigServiceValidatesSchemaValues(t *testing.T) {
store := newTestStore(t)
service := NewAppConfigService(store)
+165
View File
@@ -0,0 +1,165 @@
package services
import (
"log"
"strconv"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
)
const (
windowStartStateNormal = "normal"
windowStartStateMaximized = "maximized"
settingKeyWindowStartState = "window.start_state"
settingKeyWindowWidth = "window.width"
settingKeyWindowHeight = "window.height"
settingKeyWindowRememberState = "window.remember_state"
)
type mainWindowStateOptions struct {
Width int
Height int
StartState application.WindowState
Remember bool
}
func (a *AppService) loadMainWindowStateOptions() mainWindowStateOptions {
options := mainWindowStateOptions{
Width: mainWindowWidth,
Height: mainWindowHeight,
StartState: application.WindowStateNormal,
Remember: true,
}
if a == nil || a.Config == nil {
return options
}
options.Remember = configBool(a.Config, settingKeyWindowRememberState, true)
if !options.Remember {
return options
}
options.Width = max(configInt(a.Config, settingKeyWindowWidth, mainWindowWidth), mainWindowMinWidth)
options.Height = max(configInt(a.Config, settingKeyWindowHeight, mainWindowHeight), mainWindowMinHeight)
if configString(a.Config, settingKeyWindowStartState, windowStartStateNormal) == windowStartStateMaximized {
options.StartState = application.WindowStateMaximised
}
return options
}
func (a *AppService) registerMainWindowStatePersistence() {
if a == nil || a.MainWindow == nil {
return
}
a.MainWindow.OnWindowEvent(events.Common.WindowDidResize, func(_ *application.WindowEvent) {
a.persistMainWindowNormalSize()
})
a.MainWindow.OnWindowEvent(events.Common.WindowMaximise, func(_ *application.WindowEvent) {
a.persistMainWindowStartState(windowStartStateMaximized)
})
a.MainWindow.OnWindowEvent(events.Common.WindowRestore, func(_ *application.WindowEvent) {
a.persistMainWindowState()
})
a.MainWindow.OnWindowEvent(events.Common.WindowUnMaximise, func(_ *application.WindowEvent) {
a.persistMainWindowState()
})
}
func (a *AppService) shouldRememberMainWindowState() bool {
if a == nil || a.Config == nil {
return false
}
return configBool(a.Config, settingKeyWindowRememberState, true)
}
func (a *AppService) persistMainWindowState() {
if !a.shouldRememberMainWindowState() || a.MainWindow == nil {
return
}
if a.MainWindow.IsMaximised() {
a.persistMainWindowStartState(windowStartStateMaximized)
return
}
if a.MainWindow.IsFullscreen() || a.MainWindow.IsMinimised() {
return
}
a.persistMainWindowNormalSize()
}
func (a *AppService) persistMainWindowStartState(state string) {
if !a.shouldRememberMainWindowState() || a.Config == nil {
return
}
a.windowStateMu.Lock()
defer a.windowStateMu.Unlock()
if err := a.Config.SetAppConfig(settingKeyWindowStartState, state); err != nil {
log.Printf("persist main window state failed: %v", err)
}
}
func (a *AppService) persistMainWindowNormalSize() {
if !a.shouldRememberMainWindowState() || a.MainWindow == nil || a.Config == nil {
return
}
if a.MainWindow.IsMaximised() || a.MainWindow.IsFullscreen() || a.MainWindow.IsMinimised() {
return
}
width, height := a.MainWindow.Size()
if width < mainWindowMinWidth || height < mainWindowMinHeight {
return
}
a.windowStateMu.Lock()
defer a.windowStateMu.Unlock()
if err := a.Config.SetAppConfig(settingKeyWindowStartState, windowStartStateNormal); err != nil {
log.Printf("persist main window start state failed: %v", err)
}
if err := a.Config.SetAppConfig(settingKeyWindowWidth, strconv.Itoa(width)); err != nil {
log.Printf("persist main window width failed: %v", err)
}
if err := a.Config.SetAppConfig(settingKeyWindowHeight, strconv.Itoa(height)); err != nil {
log.Printf("persist main window height failed: %v", err)
}
}
func configString(config *AppConfigService, key string, fallback string) string {
if config == nil {
return fallback
}
value, err := config.GetAppConfig(key)
if err != nil || value == "" {
return fallback
}
return value
}
func configBool(config *AppConfigService, key string, fallback bool) bool {
value := configString(config, key, "")
if value == "" {
return fallback
}
parsed, err := strconv.ParseBool(value)
if err != nil {
return fallback
}
return parsed
}
func configInt(config *AppConfigService, key string, fallback int) int {
value := configString(config, key, "")
if value == "" {
return fallback
}
parsed, err := strconv.Atoi(value)
if err != nil {
return fallback
}
return parsed
}
+101
View File
@@ -0,0 +1,101 @@
package services
import (
"testing"
"github.com/wailsapp/wails/v3/pkg/application"
)
func TestLoadMainWindowStateOptionsDefaults(t *testing.T) {
store := newTestStore(t)
service := &AppService{Config: NewAppConfigService(store)}
options := service.loadMainWindowStateOptions()
if options.Width != mainWindowWidth {
t.Fatalf("width = %d, want %d", options.Width, mainWindowWidth)
}
if options.Height != mainWindowHeight {
t.Fatalf("height = %d, want %d", options.Height, mainWindowHeight)
}
if options.StartState != application.WindowStateNormal {
t.Fatalf("start state = %v, want normal", options.StartState)
}
if !options.Remember {
t.Fatal("remember = false, want true")
}
}
func TestLoadMainWindowStateOptionsUsesSavedValues(t *testing.T) {
store := newTestStore(t)
config := NewAppConfigService(store)
service := &AppService{Config: config}
if err := config.SetAppConfig(settingKeyWindowWidth, "1440"); err != nil {
t.Fatalf("set width: %v", err)
}
if err := config.SetAppConfig(settingKeyWindowHeight, "900"); err != nil {
t.Fatalf("set height: %v", err)
}
if err := config.SetAppConfig(settingKeyWindowStartState, windowStartStateMaximized); err != nil {
t.Fatalf("set start state: %v", err)
}
options := service.loadMainWindowStateOptions()
if options.Width != 1440 {
t.Fatalf("width = %d, want 1440", options.Width)
}
if options.Height != 900 {
t.Fatalf("height = %d, want 900", options.Height)
}
if options.StartState != application.WindowStateMaximised {
t.Fatalf("start state = %v, want maximised", options.StartState)
}
}
func TestLoadMainWindowStateOptionsIgnoresSavedValuesWhenDisabled(t *testing.T) {
store := newTestStore(t)
config := NewAppConfigService(store)
service := &AppService{Config: config}
if err := config.SetAppConfig(settingKeyWindowRememberState, "false"); err != nil {
t.Fatalf("set remember state: %v", err)
}
if err := config.SetAppConfig(settingKeyWindowWidth, "1440"); err != nil {
t.Fatalf("set width: %v", err)
}
if err := config.SetAppConfig(settingKeyWindowStartState, windowStartStateMaximized); err != nil {
t.Fatalf("set start state: %v", err)
}
options := service.loadMainWindowStateOptions()
if options.Width != mainWindowWidth {
t.Fatalf("width = %d, want %d", options.Width, mainWindowWidth)
}
if options.StartState != application.WindowStateNormal {
t.Fatalf("start state = %v, want normal", options.StartState)
}
if options.Remember {
t.Fatal("remember = true, want false")
}
}
func TestLoadMainWindowStateOptionsClampsSmallSavedSize(t *testing.T) {
store := newTestStore(t)
config := NewAppConfigService(store)
service := &AppService{Config: config}
if _, err := store.DB.Exec("UPDATE appconfig SET value = '900' WHERE key = ?", settingKeyWindowWidth); err != nil {
t.Fatalf("set raw width: %v", err)
}
if _, err := store.DB.Exec("UPDATE appconfig SET value = '600' WHERE key = ?", settingKeyWindowHeight); err != nil {
t.Fatalf("set raw height: %v", err)
}
options := service.loadMainWindowStateOptions()
if options.Width != mainWindowMinWidth {
t.Fatalf("width = %d, want %d", options.Width, mainWindowMinWidth)
}
if options.Height != mainWindowMinHeight {
t.Fatalf("height = %d, want %d", options.Height, mainWindowMinHeight)
}
}