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)
|
||||
}
|
||||
@@ -103,7 +103,11 @@ const (
|
||||
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{})),
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// gen_ico produces a multi-resolution .ico file from a single PNG
|
||||
// source. The output embeds each size as a PNG payload (Vista+ .ico
|
||||
// format) so the result stays small and crisp.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go run ./tools/gen_ico [src.png] [dst.ico]
|
||||
//
|
||||
// Defaults:
|
||||
//
|
||||
// src = internal/ui/assets/app_icon.png
|
||||
// dst = internal/ui/assets/app_icon.ico
|
||||
//
|
||||
// After generating the .ico, embed it into the Windows binary via:
|
||||
//
|
||||
// go run github.com/akavel/rsrc@latest \
|
||||
// -ico internal/ui/assets/app_icon.ico \
|
||||
// -o internal/ui/assets/rsrc_windows.syso
|
||||
//
|
||||
// rsrc_windows.syso is auto-linked by `go build` on Windows.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
// icoSizes are the embedded resolutions. Windows picks the largest size
|
||||
// that fits the target use (title bar = 16, Alt-Tab = 32, Explorer = up
|
||||
// to 256) and scales down. Skipping sizes Windows can't read anyway
|
||||
// keeps the file under 200 KB.
|
||||
var icoSizes = []int{16, 24, 32, 48, 64, 128, 256}
|
||||
|
||||
// icoEntry holds one image's dimensions and its encoded PNG bytes.
|
||||
type icoEntry struct {
|
||||
w, h int
|
||||
png []byte
|
||||
}
|
||||
|
||||
func main() {
|
||||
srcPath := "internal/ui/assets/app_icon.png"
|
||||
dstPath := "internal/ui/assets/app_icon.ico"
|
||||
if len(os.Args) > 1 {
|
||||
srcPath = os.Args[1]
|
||||
}
|
||||
if len(os.Args) > 2 {
|
||||
dstPath = os.Args[2]
|
||||
}
|
||||
if err := run(srcPath, dstPath); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "gen_ico:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(srcPath, dstPath string) error {
|
||||
srcF, err := os.Open(srcPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open src: %w", err)
|
||||
}
|
||||
defer srcF.Close()
|
||||
src, _, err := image.Decode(srcF)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode src: %w", err)
|
||||
}
|
||||
|
||||
var entries []icoEntry
|
||||
for _, size := range icoSizes {
|
||||
dst := image.NewNRGBA(image.Rect(0, 0, size, size))
|
||||
// CatmullRom gives sharper results at small sizes than the
|
||||
// default nearest-neighbour.
|
||||
draw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, dst); err != nil {
|
||||
return fmt.Errorf("encode %dpx: %w", size, err)
|
||||
}
|
||||
entries = append(entries, icoEntry{w: size, h: size, png: buf.Bytes()})
|
||||
}
|
||||
|
||||
out, err := os.Create(dstPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create dst: %w", err)
|
||||
}
|
||||
defer out.Close()
|
||||
return writeICO(out, entries)
|
||||
}
|
||||
|
||||
// writeICO serialises a Vista+ PNG-based .ico file: ICONDIR + one
|
||||
// ICONDIRENTRY per image + the PNG bytes concatenated.
|
||||
func writeICO(w io.Writer, entries []icoEntry) error {
|
||||
if _, err := w.Write([]byte{
|
||||
0, 0, // reserved
|
||||
1, 0, // type = 1 (icon)
|
||||
byte(len(entries)), 0, // count
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
const dirHdrLen = 6
|
||||
const dirEntryLen = 16
|
||||
headerLen := dirHdrLen + dirEntryLen*len(entries)
|
||||
|
||||
offset := headerLen
|
||||
for _, e := range entries {
|
||||
wByte := byte(e.w)
|
||||
if e.w >= 256 {
|
||||
// 0 means 256 in the .ico directory.
|
||||
wByte = 0
|
||||
}
|
||||
hByte := byte(e.h)
|
||||
if e.h >= 256 {
|
||||
hByte = 0
|
||||
}
|
||||
size := uint32(len(e.png))
|
||||
|
||||
entry := make([]byte, dirEntryLen)
|
||||
entry[0] = wByte
|
||||
entry[1] = hByte
|
||||
entry[2] = 0 // color palette
|
||||
entry[3] = 0 // reserved
|
||||
binary.LittleEndian.PutUint16(entry[4:], 1) // planes
|
||||
binary.LittleEndian.PutUint16(entry[6:], 32) // bit count
|
||||
binary.LittleEndian.PutUint32(entry[8:], size)
|
||||
binary.LittleEndian.PutUint32(entry[12:], uint32(offset))
|
||||
if _, err := w.Write(entry); err != nil {
|
||||
return err
|
||||
}
|
||||
offset += int(size)
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
if _, err := w.Write(e.png); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user