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:
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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{})),
|
||||
|
||||
Reference in New Issue
Block a user