From aea407612d4b9e86a77752d2faf11fa4a2619875 Mon Sep 17 00:00:00 2001 From: Snowflake Date: Wed, 24 Jun 2026 03:16:40 +0800 Subject: [PATCH] Init --- go.mod | 11 +++++ go.sum | 9 ++++ logger/logger.go | 104 +++++++++++++++++++++++++++++++++++++++ win/dbgsole/dbgsole.go | 52 ++++++++++++++++++++ win/perm/perm.go | 96 ++++++++++++++++++++++++++++++++++++ win/trayicon/trayicon.go | 103 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 375 insertions(+) create mode 100644 go.mod create mode 100644 go.sum create mode 100644 logger/logger.go create mode 100644 win/dbgsole/dbgsole.go create mode 100644 win/perm/perm.go create mode 100644 win/trayicon/trayicon.go diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a681c4b --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module gitee.com/snoware/swlib + +go 1.26.4 + +require github.com/rs/zerolog v1.35.1 + +require ( + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + golang.org/x/sys v0.29.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4a6197e --- /dev/null +++ b/go.sum @@ -0,0 +1,9 @@ +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= +github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/logger/logger.go b/logger/logger.go new file mode 100644 index 0000000..4abb0d3 --- /dev/null +++ b/logger/logger.go @@ -0,0 +1,104 @@ +// Copyright (c) 2026 S.A. (SNOWARE). Written for SCU Team. +// Licensed under MulanPubL-2.0. +// You may obtain a copy at: http://license.coscl.org.cn/MulanPubL-2.0 +// THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND. + +// Package logger 基于 zerolog 的日志初始化,支持控制台输出(可读格式)+ 文件输出(JSON 结构化)。 +// 日志目录优先级:exe 同目录 log/ → %APPDATA%/SRemoteConnectClient/log/ → 当前工作目录 log/ → 仅控制台输出。 +package logger + +import ( + "io" + "os" + "path/filepath" + "time" + + "github.com/rs/zerolog" +) + +// Log 包级全局日志实例,供外部包使用。 +var Log zerolog.Logger + +// Init 初始化全局日志。 +// 尝试在 exe 目录下创建 log/,若无写权限(多用户安装场景)则回退到 %APPDATA%。 +// 日志按日期命名,控制台使用可读格式(带颜色),文件使用 JSON 结构化格式。 +func Init() { + consoleWriter := zerolog.ConsoleWriter{ + Out: os.Stdout, + TimeFormat: "2006-01-02 15:04:05", + NoColor: false, + } + + logFile := openLogFile() + if logFile == nil { + // 连 %APPDATA% 都写不了,仅控制台输出 + Log = zerolog.New(consoleWriter). + With().Timestamp().Caller().Logger(). + Level(zerolog.DebugLevel) + return + } + + multi := io.MultiWriter(consoleWriter, logFile) + Log = zerolog.New(multi). + With().Timestamp().Caller().Logger(). + Level(zerolog.DebugLevel) +} + +// openLogFile 按优先级尝试打开日志文件。 +// 返回 nil 表示无法创建日志文件,此时程序仅输出到控制台。 +func openLogFile() *os.File { + logDir, err := resolveLogDir() + if err != nil { + return nil + } + + today := time.Now().Format("2006-01-02") + logPath := filepath.Join(logDir, today+".log") + + f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + return nil + } + return f +} + +// resolveLogDir 解析日志目录。 +// 优先使用 exe 同目录下的 log/;若不可写则使用 %APPDATA%/SRemoteConnectClient/log/。 +// [PORTBARR] +func resolveLogDir() (string, error) { + // 1. 尝试 exe 同目录 + exePath, _ := os.Executable() + if exePath != "" { + dir := filepath.Join(filepath.Dir(exePath), "log") + if err := os.MkdirAll(dir, 0755); err == nil { + // 验证确实可写 + testFile := filepath.Join(dir, ".write_test") + if f, err := os.Create(testFile); err == nil { + f.Close() + os.Remove(testFile) + return dir, nil + } + } + } + + // 2. 回退到 %APPDATA%/SRemoteConnectClient/log + appData := os.Getenv("APPDATA") + if appData == "" { + appData = os.Getenv("LOCALAPPDATA") + } + if appData != "" { + dir := filepath.Join(appData, "SRemoteConnectClient", "log") + if err := os.MkdirAll(dir, 0755); err == nil { + return dir, nil + } + } + + // 3. 最后尝试当前工作目录 + cwd, _ := os.Getwd() + dir := filepath.Join(cwd, "log") + if err := os.MkdirAll(dir, 0755); err == nil { + return dir, nil + } + + return "", os.ErrPermission +} diff --git a/win/dbgsole/dbgsole.go b/win/dbgsole/dbgsole.go new file mode 100644 index 0000000..49ae029 --- /dev/null +++ b/win/dbgsole/dbgsole.go @@ -0,0 +1,52 @@ +// Copyright (c) 2026 S.A. (SNOWARE). Written for SCU Team. +// Licensed under MulanPubL-2.0. +// You may obtain a copy at: http://license.coscl.org.cn/MulanPubL-2.0 +// THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND. + +// console_windows 模块:通过 WinAPI(GetConsoleWindow + ShowWindowAsync)控制控制台窗口的显示/隐藏。 +package console + +import ( + "syscall" + + "github.com/rs/zerolog/log" +) + +// 预加载 WinAPI 所需的 DLL 和函数。 +var ( + kernel32 = syscall.NewLazyDLL("kernel32.dll") + user32 = syscall.NewLazyDLL("user32.dll") + getConsoleWindow = kernel32.NewProc("GetConsoleWindow") + showWindowAsync = user32.NewProc("ShowWindowAsync") +) + +// ShowWindow 常量。 +const ( + SW_HIDE = 0 // 隐藏窗口 + SW_SHOW = 5 // 显示窗口 +) + +// cachedConsoleHwnd 在 initConsoleHwnd() 中缓存,避免 systray.Run() 后 GetConsoleWindow 返回 0。 +var cachedConsoleHwnd uintptr + +// initConsoleHwnd 在 main() 中提前调用,缓存控制台窗口句柄。 +func initConsoleHwnd() { + hwnd, _, _ := getConsoleWindow.Call() + cachedConsoleHwnd = hwnd +} + +// hideConsoleWindow 隐藏控制台窗口。若无控制台则无操作。 +func hideConsoleWindow() { + if cachedConsoleHwnd != 0 { + showWindowAsync.Call(cachedConsoleHwnd, SW_HIDE) + log.Debug().Msg("控制台窗口已隐藏") + } +} + +// showConsoleWindow 显示控制台窗口。若无控制台则无操作。 +func showConsoleWindow() { + if cachedConsoleHwnd != 0 { + showWindowAsync.Call(cachedConsoleHwnd, SW_SHOW) + log.Debug().Msg("控制台窗口已显示") + } +} diff --git a/win/perm/perm.go b/win/perm/perm.go new file mode 100644 index 0000000..2eb10aa --- /dev/null +++ b/win/perm/perm.go @@ -0,0 +1,96 @@ +// Copyright (c) 2026 S.A. (SNOWARE). Written for SCU Team. +// Licensed under MulanPubL-2.0. +// You may obtain a copy at: http://license.coscl.org.cn/MulanPubL-2.0 +// THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND. + +// Package winperm 提供 Windows UAC 管理员权限检测与提权。 +package perm + +import ( + "os" + "syscall" + "unsafe" + + "gitee.com/snoware/swlib/logger" +) + +// Permission 权限管理器,封装管理员检测和提权操作。 +type Permission struct{} + +// 预加载 shell32.dll 中的 Windows API。 +var ( + shell32 = syscall.NewLazyDLL("shell32.dll") + isUserAnAdmin = shell32.NewProc("IsUserAnAdmin") + shellExecuteW = shell32.NewProc("ShellExecuteW") +) + +// NewPermission 创建 Permission 实例。 +func NewPermission() *Permission { + return &Permission{} +} + +// HasUAC 调用 IsUserAnAdmin 检查当前进程是否以管理员权限运行。 +// 返回 true 表示已拥有管理员权限。 +func (p *Permission) HasUAC() bool { + ret, _, _ := isUserAnAdmin.Call() + hasAdmin := ret != 0 + logger.Log.Debug().Bool("has_admin", hasAdmin).Msg("检查管理员权限") + return hasAdmin +} + +// RunExeAsAdmin 通过 ShellExecuteW("runas") 以管理员身份启动指定的可执行文件。 +// +// 参数 exe: 目标可执行文件的绝对路径。 +// 成功时当前进程应退出,由新的管理员进程接管。 +func (p *Permission) RunExeAsAdmin(exe string) error { + logger.Log.Info().Str("exe", exe).Msg("尝试以管理员身份运行") + + verb := "runas" + cwd, _ := os.Getwd() + + verbPtr, _ := syscall.UTF16PtrFromString(verb) + exePtr, _ := syscall.UTF16PtrFromString(exe) + cwdPtr, _ := syscall.UTF16PtrFromString(cwd) + + var showCmd int32 = 1 // SW_NORMAL + + ret, _, _ := shellExecuteW.Call( + 0, + uintptr(unsafe.Pointer(verbPtr)), + uintptr(unsafe.Pointer(exePtr)), + 0, + uintptr(unsafe.Pointer(cwdPtr)), + uintptr(showCmd), + ) + + if ret <= 32 { + err := syscall.GetLastError() + logger.Log.Error().Err(err).Int32("ret", int32(ret)).Msg("管理员提权失败") + return err + } + + logger.Log.Info().Msg("管理员提权成功,当前进程将退出") + return nil +} + +// GetSelf 获取当前可执行文件的绝对路径。 +func (p *Permission) GetSelf() (string, error) { + exe, err := os.Executable() + if err != nil { + logger.Log.Error().Err(err).Msg("获取自身可执行文件路径失败") + return "", err + } + logger.Log.Debug().Str("exe_path", exe).Msg("获取自身路径") + return exe, nil +} + +// RunSelfAsAdmin 以管理员身份重新执行自身。 +// 返回当前可执行文件路径和可能的错误。 +func (p *Permission) RunSelfAsAdmin() (string, error) { + exe, err := p.GetSelf() + if err != nil { + return "", err + } + err = p.RunExeAsAdmin(exe) + return exe, err +} diff --git a/win/trayicon/trayicon.go b/win/trayicon/trayicon.go new file mode 100644 index 0000000..389345b --- /dev/null +++ b/win/trayicon/trayicon.go @@ -0,0 +1,103 @@ +// Copyright (c) 2026 S.A. (SNOWARE). Written for SCU Team. +// Licensed under MulanPubL-2.0. +// You may obtain a copy at: http://license.coscl.org.cn/MulanPubL-2.0 +// THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND. + +// trayicon_windows 模块:托盘图标加载,将 PNG 解码、缩放并包装为 Windows ICO 格式。 +// getlantern/systray 在 Windows 上存在 GDI+ → CreateIconIndirect 兼容性 bug, +// 解法是将 PNG 包装成 ICO 格式(现代 Windows 原生支持 ICO 内嵌 PNG)。 +package main + +import ( + "bytes" + "encoding/binary" + "image" + "image/png" + "os" + "path/filepath" + + "github.com/rs/zerolog/log" +) + +// loadIcon 加载托盘图标。 +// 优先从 image.png 文件读取,回退到内嵌 iconData。 +// 解码 PNG → 缩放到 32x32 → 重新编码 → 包装为 ICO 格式返回。 +func loadIcon(iconData []byte) []byte { + raw := iconData + + exePath, err := os.Executable() + if err == nil { + iconFile := filepath.Join(filepath.Dir(exePath), "image.png") + if data, err := os.ReadFile(iconFile); err == nil { + raw = data + log.Debug().Str("file", iconFile).Msg("从文件读取图标") + } + } + + img, err := png.Decode(bytes.NewReader(raw)) + if err != nil { + log.Warn().Err(err).Msg("PNG 解码失败") + return nil + } + + icon := resizeIcon(img, 32, 32) + + var pngBuf bytes.Buffer + if err := png.Encode(&pngBuf, icon); err != nil { + log.Warn().Err(err).Msg("PNG 重新编码失败") + return nil + } + + ico := pngToICO(pngBuf.Bytes(), 32, 32) + log.Debug().Int("size", len(ico)).Msg("托盘图标已生成 (ICO 32x32)") + return ico +} + +// pngToICO 将 PNG 字节数据包装为 Windows ICO 文件格式。 +// ICO 结构:6 字节文件头 + 16 字节目录项 + PNG 图像数据。 +func pngToICO(pngData []byte, w, h int) []byte { + var buf bytes.Buffer + + // ICO 文件头 + binary.Write(&buf, binary.LittleEndian, uint16(0)) // 保留 + binary.Write(&buf, binary.LittleEndian, uint16(1)) // 类型: ICO + binary.Write(&buf, binary.LittleEndian, uint16(1)) // 图标数量 + + // 处理 256 边界的宽度/高度 + width := uint8(w) + if w >= 256 { + width = 0 + } + height := uint8(h) + if h >= 256 { + height = 0 + } + + // ICO 目录项 + binary.Write(&buf, binary.LittleEndian, width) + binary.Write(&buf, binary.LittleEndian, height) + binary.Write(&buf, binary.LittleEndian, uint8(0)) + binary.Write(&buf, binary.LittleEndian, uint8(0)) + binary.Write(&buf, binary.LittleEndian, uint16(1)) + binary.Write(&buf, binary.LittleEndian, uint16(32)) + binary.Write(&buf, binary.LittleEndian, uint32(len(pngData))) + binary.Write(&buf, binary.LittleEndian, uint32(22)) + + buf.Write(pngData) + return buf.Bytes() +} + +// resizeIcon 使用最近邻插值将图像缩放到指定尺寸。 +// 返回 *image.RGBA 避免与原图共享底层数据。 +func resizeIcon(src image.Image, w, h int) *image.RGBA { + dst := image.NewRGBA(image.Rect(0, 0, w, h)) + sb := src.Bounds() + for y := range h { + for x := range w { + sx := sb.Min.X + x*sb.Dx()/w + sy := sb.Min.Y + y*sb.Dy()/h + dst.Set(x, y, src.At(sx, sy)) + } + } + return dst +}