357 lines
9.3 KiB
Go
357 lines
9.3 KiB
Go
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, " ")
|
||
}
|