diff --git a/internal/i18n/locales/de.toml b/internal/i18n/locales/de.toml index 7bf4cee..f423c23 100644 --- a/internal/i18n/locales/de.toml +++ b/internal/i18n/locales/de.toml @@ -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" diff --git a/internal/ui/app.go b/internal/ui/app.go index 92380ed..c52f933 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -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) { diff --git a/internal/ui/assets/README.md b/internal/ui/assets/README.md index 7535148..6632260 100644 --- a/internal/ui/assets/README.md +++ b/internal/ui/assets/README.md @@ -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: ``` diff --git a/internal/ui/assets/app_icon.ico b/internal/ui/assets/app_icon.ico new file mode 100644 index 0000000..f2c5881 Binary files /dev/null and b/internal/ui/assets/app_icon.ico differ diff --git a/internal/ui/assets/rsrc_windows.syso b/internal/ui/assets/rsrc_windows.syso new file mode 100644 index 0000000..28804c3 Binary files /dev/null and b/internal/ui/assets/rsrc_windows.syso differ diff --git a/internal/ui/dialog.go b/internal/ui/dialog.go index 1bfb6ed..7cd00e1 100644 --- a/internal/ui/dialog.go +++ b/internal/ui/dialog.go @@ -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), ) }) }) diff --git a/internal/ui/dialog_welcome.go b/internal/ui/dialog_welcome.go new file mode 100644 index 0000000..5ccbea6 --- /dev/null +++ b/internal/ui/dialog_welcome.go @@ -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) +} diff --git a/internal/win32/tray_windows.go b/internal/win32/tray_windows.go index c86592a..9661118 100644 --- a/internal/win32/tray_windows.go +++ b/internal/win32/tray_windows.go @@ -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{})), diff --git a/tools/gen_ico/main.go b/tools/gen_ico/main.go new file mode 100644 index 0000000..63702fc --- /dev/null +++ b/tools/gen_ico/main.go @@ -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 +}