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]
|
[dialog_icon_picker_title]
|
||||||
other = "Symbol auswählen"
|
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"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"image/color"
|
"image/color"
|
||||||
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -97,6 +98,7 @@ type appState struct {
|
|||||||
pwDialog *passwordDialog
|
pwDialog *passwordDialog
|
||||||
setPwDialog *setPasswordDialog
|
setPwDialog *setPasswordDialog
|
||||||
changePwDlg *changePasswordDialog
|
changePwDlg *changePasswordDialog
|
||||||
|
welcomeDlg *welcomeDialog
|
||||||
importDialog *importLegacyDialog
|
importDialog *importLegacyDialog
|
||||||
hotkeyDialog *hotkeyDialog
|
hotkeyDialog *hotkeyDialog
|
||||||
hotkeyTarget *entry
|
hotkeyTarget *entry
|
||||||
@@ -169,10 +171,15 @@ func loop(w *app.Window, configPath string) error {
|
|||||||
w.Invalidate()
|
w.Invalidate()
|
||||||
})
|
})
|
||||||
|
|
||||||
// First-load attempt: empty passphrase. If the file is encrypted we'll
|
// First-load attempt: empty passphrase. The branches are:
|
||||||
// surface a password dialog on the first frame.
|
// - 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 {
|
if cfg, err := state.store.Load(nil); err != nil {
|
||||||
switch {
|
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):
|
case errors.Is(err, ErrPasswordRequired):
|
||||||
state.pwDialog = newPasswordDialog(i18n.T("msg_password_required"))
|
state.pwDialog = newPasswordDialog(i18n.T("msg_password_required"))
|
||||||
default:
|
default:
|
||||||
@@ -474,6 +481,23 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
|
|||||||
st.rowMenu = newRowActionMenu(moreTargetIdx, total)
|
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.
|
// Password retry / first-decrypt loop.
|
||||||
if st.pwDialog != nil {
|
if st.pwDialog != nil {
|
||||||
return st.pwDialog.Layout(gtx, th, func(pw []byte, ok bool) {
|
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
|
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
|
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
|
# 1. Regenerate app_icon.png (optional — only if you change the source)
|
||||||
rsrc -ico app.ico -o cmd/winauth/rsrc_windows.syso
|
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:
|
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,
|
cancelBtn *widget.Clickable,
|
||||||
body layout.Widget,
|
body layout.Widget,
|
||||||
) layout.Dimensions {
|
) 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 {
|
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
gtx.Constraints.Max.X = gtx.Dp(420)
|
gtx.Constraints.Max.X = gtx.Dp(420)
|
||||||
return widget.Border{
|
return widget.Border{
|
||||||
@@ -50,15 +71,7 @@ func modalCard(
|
|||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||||
layout.Rigid(body),
|
layout.Rigid(body),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(footer),
|
||||||
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)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 (
|
const (
|
||||||
wmUser uint32 = 0x0400
|
wmUser uint32 = 0x0400
|
||||||
wmTrayCallback = wmUser + 1
|
wmTrayCallback = wmUser + 1
|
||||||
wmTrayQuit = wmUser + 2
|
wmTrayQuit = wmUser + 2
|
||||||
cmdQuitSentinel uint32 = 0xFFFF
|
cmdQuitSentinel uint32 = 0xFFFF
|
||||||
nimAdd uint32 = 0x00000000
|
nimAdd uint32 = 0x00000000
|
||||||
nimDelete uint32 = 0x00000002
|
nimDelete uint32 = 0x00000002
|
||||||
nifMessage uint32 = 0x00000001
|
nifMessage uint32 = 0x00000001
|
||||||
nifIcon uint32 = 0x00000002
|
nifIcon uint32 = 0x00000002
|
||||||
nifTip uint32 = 0x00000004
|
nifTip uint32 = 0x00000004
|
||||||
wmCommand uint32 = 0x0111
|
wmCommand uint32 = 0x0111
|
||||||
wmRButtonUp uint32 = 0x0205
|
wmRButtonUp uint32 = 0x0205
|
||||||
wmLButtonUp uint32 = 0x0202
|
wmLButtonUp uint32 = 0x0202
|
||||||
mfString uint32 = 0x00000000
|
mfString uint32 = 0x00000000
|
||||||
tpmLeftAlign uint32 = 0x0000
|
tpmLeftAlign uint32 = 0x0000
|
||||||
tpmRightButton uint32 = 0x0002
|
tpmRightButton uint32 = 0x0002
|
||||||
idiApplication uintptr = 32512
|
// 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
|
// 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.menu = menu
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
|
||||||
// Load a stock icon as the placeholder. Real apps ship an .ico
|
// Load the application icon embedded in rsrc_windows.syso. Falls
|
||||||
// resource and load it via LoadImageW; doing so requires a build-time
|
// back to the stock application icon if the resource is missing
|
||||||
// .syso embed which is documented in internal/ui/assets/README.md.
|
// (e.g. cross-compile that omitted the .syso).
|
||||||
hIcon, _, _ := procLoadIconW.Call(0, idiApplication)
|
hIcon, _, _ := procLoadIconW.Call(hinst, appIconID)
|
||||||
|
if hIcon == 0 {
|
||||||
|
hIcon, _, _ = procLoadIconW.Call(0, idiApplication)
|
||||||
|
}
|
||||||
|
|
||||||
nid := notifyIconData{
|
nid := notifyIconData{
|
||||||
cbSize: uint32(unsafe.Sizeof(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