feat(ui): 首次启动加密引导 + 真窗口图标 (.syso)

首次启动:
- 配置文件不存在时弹欢迎对话框:选「启用密码保护」或「稍后再说」
- 选启用直接进 setPasswordDialog,OK 时 store.SetPassword 触发首次保存
- io/fs.ErrNotExist 检测新分支
- modalCard 抽出 modalShell 内核以便 welcomeDialog 自定义 footer

真窗口图标:
- tools/gen_ico 从 PNG 生成多分辨率 .ico(7 尺寸:16~256)
- 提交 rsrc_windows.syso(rsrc 通过 goproxy.cn 生成),go build 自动链入
- tray_windows.go 改用嵌入资源 ID=1,失败 fallback IDI_APPLICATION
- README 更新完整的图标生成流程
This commit is contained in:
2026-06-12 10:07:13 +08:00
parent 727be3557a
commit 133091a32e
9 changed files with 317 additions and 35 deletions
+14
View File
@@ -509,3 +509,17 @@ other = "Symbol ändern..."
[dialog_icon_picker_title]
other = "Symbol auswählen"
# --- Erster Start: Verschlüsselungsauswahl ---
[dialog_welcome_title]
other = "Willkommen bei WinAuth"
[msg_welcome_intro]
other = "Wähle, ob die Liste der Authenticatoren verschlüsselt auf der Festplatte gespeichert werden soll. Die Verschlüsselung schützt die Datei mit einem Passwort deiner Wahl. Du kannst dies später jederzeit in den Einstellungen ändern."
[btn_welcome_enable_password]
other = "Passwortschutz aktivieren"
[btn_welcome_skip]
other = "Vorerst überspringen"
+26 -2
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"image/color"
"io/fs"
"os"
"sync"
"time"
@@ -97,6 +98,7 @@ type appState struct {
pwDialog *passwordDialog
setPwDialog *setPasswordDialog
changePwDlg *changePasswordDialog
welcomeDlg *welcomeDialog
importDialog *importLegacyDialog
hotkeyDialog *hotkeyDialog
hotkeyTarget *entry
@@ -169,10 +171,15 @@ func loop(w *app.Window, configPath string) error {
w.Invalidate()
})
// First-load attempt: empty passphrase. If the file is encrypted we'll
// surface a password dialog on the first frame.
// First-load attempt: empty passphrase. The branches are:
// - file missing → first-run welcome dialog (encrypt / skip)
// - file encrypted → password dialog
// - other load error → log + show error banner
if cfg, err := state.store.Load(nil); err != nil {
switch {
case errors.Is(err, fs.ErrNotExist):
global.Log.WithField("func", fn).Info("no config file; showing first-run welcome")
state.welcomeDlg = newWelcomeDialog()
case errors.Is(err, ErrPasswordRequired):
state.pwDialog = newPasswordDialog(i18n.T("msg_password_required"))
default:
@@ -474,6 +481,23 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
st.rowMenu = newRowActionMenu(moreTargetIdx, total)
}
// First-run welcome dialog. Routed before all other dialogs so the
// encryption choice appears immediately on a fresh install.
if st.welcomeDlg != nil {
return st.welcomeDlg.Layout(gtx, th, func(r welcomeResult) {
st.welcomeDlg = nil
if r.encrypt {
// Hand off to the existing setPasswordDialog. OK on
// that dialog will call store.SetPassword, which
// creates the config file.
st.setPwDialog = newSetPasswordDialog()
}
// skip: leave entries empty and unencrypted; the user
// can encrypt later via Settings → Set password.
w.Invalidate()
})
}
// Password retry / first-decrypt loop.
if st.pwDialog != nil {
return st.pwDialog.Layout(gtx, th, func(pw []byte, ok bool) {
+17 -3
View File
@@ -25,13 +25,27 @@ embedded at build time via `//go:embed` in `internal/ui/vendor_icons.go`.
A 256×256 placeholder for the application window/tray icon. Gio v0.7 has
no runtime API to set the window icon — for Windows you need to embed a
`.ico` resource via `rsrc` or `goversioninfo` at build time:
`.ico` resource via `rsrc` or `goversioninfo` at build time. The
workflow is:
```
go install github.com/akavel/rsrc@latest
rsrc -ico app.ico -o cmd/winauth/rsrc_windows.syso
# 1. Regenerate app_icon.png (optional — only if you change the source)
go run ./tools/gen_icons
# 2. Bake a multi-resolution app_icon.ico from the PNG
go run ./tools/gen_ico
# 3. Compile the .ico into a COFF .syso that Go auto-links
GOPROXY=https://goproxy.cn,direct go run github.com/akavel/rsrc@latest \
-ico internal/ui/assets/app_icon.ico \
-o internal/ui/assets/rsrc_windows.syso
```
`rsrc_windows.syso` lives next to other assets so `go build` picks it up
without any extra build-tag wiring. The tray runtime loads the icon by
resource id 1 (the first (and only) icon rsrc emits); if loading fails
it falls back to `IDI_APPLICATION`.
To regenerate the placeholders, run:
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.
+23 -10
View File
@@ -34,8 +34,29 @@ func modalCard(
cancelBtn *widget.Clickable,
body layout.Widget,
) layout.Dimensions {
fillBackground(gtx, activePalette.ScrimBg)
footer := func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(material.Button(th, cancelBtn, cancelLabel).Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Inset{Left: unit.Dp(8)}.Layout(gtx,
material.Button(th, okBtn, okLabel).Layout)
}),
)
}
return modalShell(gtx, th, title, body, footer)
}
// modalShell is the common backdrop + title + body + custom-footer
// layout. Used by modalCard for the standard OK/Cancel footer and by
// dialogs (e.g. welcomeDialog) that need a custom action bar.
func modalShell(
gtx layout.Context,
th *material.Theme,
title string,
body layout.Widget,
footer layout.Widget,
) layout.Dimensions {
fillBackground(gtx, activePalette.ScrimBg)
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Max.X = gtx.Dp(420)
return widget.Border{
@@ -50,15 +71,7 @@ func modalCard(
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(body),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(material.Button(th, cancelBtn, cancelLabel).Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Inset{Left: unit.Dp(8)}.Layout(gtx,
material.Button(th, okBtn, okLabel).Layout)
}),
)
}),
layout.Rigid(footer),
)
})
})
+66
View File
@@ -0,0 +1,66 @@
package ui
import (
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
)
// welcomeDialog is the first-run prompt. Triggered when the config
// file does not exist. The user picks between enabling password
// protection and skipping — both branches hand control back to the
// caller via onDone.
type welcomeDialog struct {
enableBtn widget.Clickable
skipBtn widget.Clickable
}
func newWelcomeDialog() *welcomeDialog {
return &welcomeDialog{}
}
// welcomeResult is what onDone receives. encrypt=true means the user
// picked "Enable password protection" and the caller should raise the
// setPasswordDialog next. skip=true means the user dismissed the prompt
// and we should start with an empty, unencrypted config.
type welcomeResult struct {
encrypt bool
skip bool
}
func (d *welcomeDialog) Layout(
gtx layout.Context, th *material.Theme,
onDone func(welcomeResult),
) layout.Dimensions {
if d.enableBtn.Clicked(gtx) {
onDone(welcomeResult{encrypt: true})
return layout.Dimensions{Size: gtx.Constraints.Max}
}
if d.skipBtn.Clicked(gtx) {
onDone(welcomeResult{skip: true})
return layout.Dimensions{Size: gtx.Constraints.Max}
}
body := func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.Body1(th, i18n.T("msg_welcome_intro")).Layout),
)
}
// Custom footer: two side-by-side actions. Use the shared modalShell
// to reuse the standard backdrop/title/inset; only the action row
// differs from the OK/Cancel modalCard pattern.
footer := func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(material.Button(th, &d.enableBtn, i18n.T("btn_welcome_enable_password")).Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Inset{Left: unit.Dp(8)}.Layout(gtx,
material.Button(th, &d.skipBtn, i18n.T("btn_welcome_skip")).Layout)
}),
)
}
return modalShell(gtx, th, i18n.T("dialog_welcome_title"), body, footer)
}
+27 -20
View File
@@ -88,22 +88,26 @@ func (m *TrayManager) Stop() {
}
const (
wmUser uint32 = 0x0400
wmTrayCallback = wmUser + 1
wmTrayQuit = wmUser + 2
cmdQuitSentinel uint32 = 0xFFFF
nimAdd uint32 = 0x00000000
nimDelete uint32 = 0x00000002
nifMessage uint32 = 0x00000001
nifIcon uint32 = 0x00000002
nifTip uint32 = 0x00000004
wmCommand uint32 = 0x0111
wmRButtonUp uint32 = 0x0205
wmLButtonUp uint32 = 0x0202
mfString uint32 = 0x00000000
tpmLeftAlign uint32 = 0x0000
tpmRightButton uint32 = 0x0002
idiApplication uintptr = 32512
wmUser uint32 = 0x0400
wmTrayCallback = wmUser + 1
wmTrayQuit = wmUser + 2
cmdQuitSentinel uint32 = 0xFFFF
nimAdd uint32 = 0x00000000
nimDelete uint32 = 0x00000002
nifMessage uint32 = 0x00000001
nifIcon uint32 = 0x00000002
nifTip uint32 = 0x00000004
wmCommand uint32 = 0x0111
wmRButtonUp uint32 = 0x0205
wmLButtonUp uint32 = 0x0202
mfString uint32 = 0x00000000
tpmLeftAlign uint32 = 0x0000
tpmRightButton uint32 = 0x0002
// idiApplication is the stock placeholder; only used if loading
// our embedded icon resource fails. Our resource (assigned by
// `akavel/rsrc`) lives at id 1.
idiApplication uintptr = 32512
appIconID uintptr = 1
)
// trayClassName is unique enough to avoid clashing if another component
@@ -182,10 +186,13 @@ func (m *TrayManager) run(initErr chan<- error) {
m.menu = menu
m.mu.Unlock()
// Load a stock icon as the placeholder. Real apps ship an .ico
// resource and load it via LoadImageW; doing so requires a build-time
// .syso embed which is documented in internal/ui/assets/README.md.
hIcon, _, _ := procLoadIconW.Call(0, idiApplication)
// Load the application icon embedded in rsrc_windows.syso. Falls
// back to the stock application icon if the resource is missing
// (e.g. cross-compile that omitted the .syso).
hIcon, _, _ := procLoadIconW.Call(hinst, appIconID)
if hIcon == 0 {
hIcon, _, _ = procLoadIconW.Call(0, idiApplication)
}
nid := notifyIconData{
cbSize: uint32(unsafe.Sizeof(notifyIconData{})),