Files
DevPack/internal/platform/windows.go
2026-03-06 14:29:43 +08:00

357 lines
9.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package platform
import (
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"unsafe"
"github.com/user/devpack/internal/logging"
"golang.org/x/sys/windows/registry"
)
// WindowsPlatform Windows 平台实现
type WindowsPlatform struct {
homeDir string
}
// NewWindowsPlatform 创建 Windows 平台实例
func NewWindowsPlatform() *WindowsPlatform {
return &WindowsPlatform{
homeDir: os.Getenv("USERPROFILE"),
}
}
func (p *WindowsPlatform) OS() string { return "windows" }
func (p *WindowsPlatform) Arch() string { return os.Getenv("PROCESSOR_ARCHITECTURE") }
func (p *WindowsPlatform) HomeDir() string {
return p.homeDir
}
func (p *WindowsPlatform) ConfigDir() string {
if dir := os.Getenv("APPDATA"); dir != "" {
return dir
}
return p.homeDir + "\\AppData\\Roaming"
}
func (p *WindowsPlatform) DataDir() string {
if dir := os.Getenv("LOCALAPPDATA"); dir != "" {
return dir
}
return p.homeDir + "\\AppData\\Local"
}
func (p *WindowsPlatform) GetEnvVar(key string) string {
return os.Getenv(key)
}
func (p *WindowsPlatform) SetEnvVar(ctx context.Context, key, value string) error {
cmd := exec.CommandContext(ctx, "setx", key, value)
return cmd.Run()
}
func (p *WindowsPlatform) AddToPath(ctx context.Context, dir string) error {
currentPath := os.Getenv("PATH")
newPath := dir + ";" + currentPath
return p.SetEnvVar(ctx, "PATH", newPath)
}
func (p *WindowsPlatform) IsAdmin() bool {
// 检查 Windows 上是否以管理员身份运行
cmd := exec.Command("net", "session")
err := cmd.Run()
return err == nil
}
func (p *WindowsPlatform) PackageManagers() []string {
var pms []string
if _, err := exec.LookPath("scoop"); err == nil {
pms = append(pms, "scoop")
}
if _, err := exec.LookPath("choco"); err == nil {
pms = append(pms, "chocolatey")
}
if _, err := exec.LookPath("winget"); err == nil {
pms = append(pms, "winget")
}
return pms
}
func (p *WindowsPlatform) DefaultShell() string {
if _, err := exec.LookPath("pwsh"); err == nil {
return "pwsh" // PowerShell 7+ 版本
}
return "powershell" // Windows PowerShell 5.1
}
// InstallFont 安装字体到用户级字体目录并注册到注册表
// 支持 .ttf (TrueType) 和 .otf (OpenType) 格式
func (p *WindowsPlatform) InstallFont(ctx context.Context, fontPath string) error {
log := logging.G()
// 验证源字体文件是否存在且可访问
info, err := os.Stat(fontPath)
if err != nil {
log.Errorf("无法访问字体文件: %v", err)
return fmt.Errorf("无法访问字体文件: %w", err)
}
if info.IsDir() {
return fmt.Errorf("字体路径是目录而非文件: %s", fontPath)
}
// 检查字体格式是否受支持
fontName := filepath.Base(fontPath)
ext := filepath.Ext(fontName)
var regSuffix string
switch ext {
case ".ttf":
regSuffix = " (TrueType)"
case ".otf":
regSuffix = " (OpenType)"
default:
log.Errorf("不支持的字体格式: %s", ext)
return fmt.Errorf("不支持的字体格式: %s仅支持 .ttf 和 .otf", ext)
}
// 目标路径为用户级字体目录
// C:\Users\<User>\AppData\Local\Microsoft\Windows\Fonts
destDir := filepath.Join(os.Getenv("LOCALAPPDATA"), "Microsoft", "Windows", "Fonts")
log.Debugf("正在安装字体: %s 到 %s", fontPath, destDir)
// 确保目标目录存在
if err := os.MkdirAll(destDir, 0755); err != nil {
return fmt.Errorf("创建字体目录失败: %w", err)
}
// 检查字体是否已安装
destPath := filepath.Join(destDir, fontName)
if _, err := os.Stat(destPath); err == nil {
log.Infof("字体已存在,跳过安装: %s", destPath)
return nil
}
// 复制字体文件到目标路径
if err := copyFile(fontPath, destPath); err != nil {
return fmt.Errorf("复制字体文件失败: %w", err)
}
// 写注册表以注册字体(用户级,无需管理员权限)
key, _, err := registry.CreateKey(
registry.CURRENT_USER,
`Software\Microsoft\Windows NT\CurrentVersion\Fonts`,
registry.ALL_ACCESS,
)
if err != nil {
// 注册表写入失败时清理已复制的字体文件
os.Remove(destPath)
return fmt.Errorf("打开字体注册表失败: %w", err)
}
defer key.Close()
// 注册表键名:去掉扩展名的字体名 + 类型后缀
baseName := fontName[:len(fontName)-len(ext)]
regName := baseName + regSuffix
if err := key.SetStringValue(regName, destPath); err != nil {
os.Remove(destPath)
return fmt.Errorf("注册字体失败: %w", err)
}
log.Infof("字体安装成功: %s", fontName)
return nil
}
// copyFile 复制文件,失败时自动清理目标文件
func copyFile(src, dst string) error {
input, err := os.Open(src)
if err != nil {
return err
}
defer input.Close()
output, err := os.Create(dst)
if err != nil {
return err
}
_, err = io.Copy(output, input)
// 先关闭文件确保数据写入磁盘
closeErr := output.Close()
if err != nil {
os.Remove(dst) // 复制失败,清理残留文件
return err
}
if closeErr != nil {
os.Remove(dst)
return closeErr
}
return nil
}
// ---------- RunAsAdmin 相关定义 ----------
// shellExecuteInfoW 对应 Windows SHELLEXECUTEINFOW 结构体
type shellExecuteInfoW struct {
cbSize uint32
fMask uint32
hwnd uintptr
lpVerb *uint16
lpFile *uint16
lpParameters *uint16
lpDirectory *uint16
nShow int32
hInstApp uintptr
lpIDList uintptr
lpClass *uint16
hkeyClass uintptr
dwHotKey uint32
hIconOrMonitor uintptr
hProcess uintptr
}
const (
// SEE_MASK_NOCLOSEPROCESS 使 ShellExecuteEx 返回进程句柄
_SEE_MASK_NOCLOSEPROCESS = 0x00000040
// SW_HIDE 隐藏窗口,避免控制台窗口闪烁
_SW_HIDE = 0
)
var (
modShell32 = syscall.NewLazyDLL("shell32.dll")
modKernel32 = syscall.NewLazyDLL("kernel32.dll")
procShellExecuteExW = modShell32.NewProc("ShellExecuteExW")
procGetExitCodeProcess = modKernel32.NewProc("GetExitCodeProcess")
)
// RunAsAdmin 以管理员权限运行命令
// 在 Windows 上通过 UAC 对话框请求提升权限
// 如果当前已具有管理员权限,则直接执行命令
func (p *WindowsPlatform) RunAsAdmin(ctx context.Context, name string, args ...string) error {
log := logging.G()
// 如果已经是管理员身份,直接执行
if p.IsAdmin() {
log.Debugf("当前已具有管理员权限,直接执行: %s", name)
cmd := exec.CommandContext(ctx, name, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// 解析可执行文件的完整路径,确保 ShellExecuteEx 能找到目标程序
exePath, err := exec.LookPath(name)
if err != nil {
return fmt.Errorf("找不到可执行文件 %s: %w", name, err)
}
log.Debugf("请求 UAC 管理员权限运行: %s %v", exePath, args)
// 构建参数字符串,对包含空格的参数自动加引号
paramStr := buildWindowsArgString(args)
// 将字符串转换为 UTF-16 指针
verbPtr, _ := syscall.UTF16PtrFromString("runas")
filePtr, err := syscall.UTF16PtrFromString(exePath)
if err != nil {
return fmt.Errorf("可执行文件路径包含非法字符: %w", err)
}
// 构建 SHELLEXECUTEINFOW 结构体
sei := &shellExecuteInfoW{
fMask: _SEE_MASK_NOCLOSEPROCESS,
lpVerb: verbPtr,
lpFile: filePtr,
nShow: _SW_HIDE,
}
if paramStr != "" {
paramPtr, err := syscall.UTF16PtrFromString(paramStr)
if err != nil {
return fmt.Errorf("命令参数包含非法字符: %w", err)
}
sei.lpParameters = paramPtr
}
sei.cbSize = uint32(unsafe.Sizeof(*sei))
// 调用 ShellExecuteExW 触发 UAC 提权对话框
ret, _, callErr := procShellExecuteExW.Call(uintptr(unsafe.Pointer(sei)))
if ret == 0 {
return fmt.Errorf("请求管理员权限失败 (用户可能拒绝了 UAC 提示): %w", callErr)
}
// 等待提升权限的进程完成
if sei.hProcess != 0 {
handle := syscall.Handle(sei.hProcess)
defer syscall.CloseHandle(handle)
// 在 goroutine 中等待进程退出,以支持 context 取消
type waitResult struct {
exitCode uint32
err error
}
done := make(chan waitResult, 1)
go func() {
event, e := syscall.WaitForSingleObject(handle, syscall.INFINITE)
if e != nil {
done <- waitResult{err: fmt.Errorf("等待进程完成失败: %w", e)}
return
}
if event != 0 { // WAIT_OBJECT_0 = 0
done <- waitResult{err: fmt.Errorf("WaitForSingleObject 返回异常: 0x%X", event)}
return
}
// 获取进程退出码
var exitCode uint32
r, _, e := procGetExitCodeProcess.Call(
uintptr(handle),
uintptr(unsafe.Pointer(&exitCode)),
)
if r == 0 {
done <- waitResult{err: fmt.Errorf("获取进程退出码失败: %v", e)}
return
}
done <- waitResult{exitCode: exitCode}
}()
select {
case <-ctx.Done():
return ctx.Err()
case result := <-done:
if result.err != nil {
return result.err
}
if result.exitCode != 0 {
return fmt.Errorf("管理员命令执行失败,退出码: %d", result.exitCode)
}
}
}
log.Debugf("管理员命令执行完成: %s", name)
return nil
}
// buildWindowsArgString 将参数列表构建为 Windows 命令行参数字符串
// 对包含空格或特殊字符的参数自动添加双引号
func buildWindowsArgString(args []string) string {
if len(args) == 0 {
return ""
}
quoted := make([]string, len(args))
for i, arg := range args {
if arg == "" || strings.ContainsAny(arg, " \t\"") {
arg = `"` + strings.ReplaceAll(arg, `"`, `\"`) + `"`
}
quoted[i] = arg
}
return strings.Join(quoted, " ")
}