feat(dcauth): 新增常驻托盘客户端与版本显示
Windows 改为单实例托盘常驻,协议唤起只弹一个确认窗口;补充 macOS 后台守护进程 与客户端版本显示。
This commit is contained in:
+66
-13
@@ -1,38 +1,84 @@
|
||||
# DCAuth Windows 客户端
|
||||
# DCAuth 客户端
|
||||
|
||||
三个系统共用的设备认证客户端,通过 `dcauth://` 协议被浏览器唤起。
|
||||
|
||||
## 运行方式
|
||||
|
||||
- **Windows**:GUI 常驻程序。启动后进入系统托盘,通过协议唤起时只出现一个登录确认窗口;成功后在托盘气泡中提示,不再连续弹多个消息框。
|
||||
- **macOS**:后台常驻进程,使用系统图形对话框确认登录。重复执行命令会转发给已有进程,不会叠加实例。
|
||||
|
||||
## 构建
|
||||
|
||||
Windows x64:
|
||||
|
||||
```bash
|
||||
cd apps/dcauth
|
||||
GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "-H windowsgui -s -w" -o dist/DCAuth.exe .
|
||||
x86_64-w64-mingw32-windres --codepage=65001 versioninfo.rc \
|
||||
-O coff -o versioninfo_windows_amd64.syso
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 \
|
||||
go build -trimpath -ldflags "-H windowsgui -s -w" \
|
||||
-o dist/DCAuth.exe .
|
||||
```
|
||||
|
||||
ARM64:
|
||||
版本号统一在 `main.go` 的 `clientVersion` 和 `versioninfo.rc` 中维护。`DCAuth.exe` 右键“属性 → 详细信息”会显示文件版本和产品版本,托盘菜单、设备信息及确认窗口也会显示当前版本。
|
||||
|
||||
Windows ARM64:
|
||||
|
||||
```bash
|
||||
GOOS=windows GOARCH=arm64 go build -trimpath -ldflags "-H windowsgui -s -w" -o dist/DCAuth-arm64.exe .
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 \
|
||||
go build -trimpath -ldflags "-H windowsgui -s -w" \
|
||||
-o dist/DCAuth-arm64.exe .
|
||||
```
|
||||
|
||||
## 首次运行
|
||||
macOS 通用二进制:
|
||||
|
||||
直接双击 exe 会:
|
||||
```bash
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 \
|
||||
go build -trimpath -ldflags "-s -w" -o /tmp/DCAuth-macos-arm64 .
|
||||
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 \
|
||||
go build -trimpath -ldflags "-s -w" -o /tmp/DCAuth-macos-amd64 .
|
||||
|
||||
lipo -create -output dist/DCAuth-macos \
|
||||
/tmp/DCAuth-macos-arm64 /tmp/DCAuth-macos-amd64
|
||||
|
||||
chmod +x dist/DCAuth-macos
|
||||
codesign --force --sign - dist/DCAuth-macos
|
||||
```
|
||||
|
||||
## Windows 首次运行
|
||||
|
||||
直接运行一次 `DCAuth.exe`:
|
||||
|
||||
- 注册 `dcauth://` 协议到当前 Windows 用户。
|
||||
- 生成 Ed25519 设备密钥,使用 Windows DPAPI 加密保存在 `%LOCALAPPDATA%\DCAuth\device.key`。
|
||||
- 显示设备码和机器码,并复制到剪贴板。
|
||||
- 进入系统托盘后台运行,不弹设备信息窗口。
|
||||
|
||||
托盘操作:
|
||||
|
||||
- 双击图标:查看设备码和机器码,并复制到剪贴板。
|
||||
- 右键菜单:查看设备码、退出 DCAuth。
|
||||
- 浏览器登录:只弹出一个确认窗口;点击“确定”后显示成功气泡,点击“取消”则拒绝。
|
||||
- 再次唤起协议:第二个进程会把请求转交给常驻进程,然后立即退出。
|
||||
|
||||
## macOS 开发调试
|
||||
|
||||
Mac 上不需要安装 Windows exe,也不需要注册自定义协议。登录页在开发模式会显示一条 `dcauth://...` 链接,复制后执行:
|
||||
登录页在开发模式会显示一条 `dcauth://...` 链接。复制后在终端执行:
|
||||
|
||||
```bash
|
||||
cd apps/dcauth
|
||||
go run . "dcauth://authorize?app=order_site&challenge_id=...&challenge=...&api=http%3A%2F%2Flocalhost%3A5173"
|
||||
./dist/DCAuth-macos "dcauth://authorize?app=order_site&challenge_id=...&challenge=...&api=http%3A%2F%2Flocalhost"
|
||||
```
|
||||
|
||||
客户端会提示确认,然后直接调用本地 Vite 代理的 `/api/dcauth/authorize`,完成本机签名。
|
||||
首次执行会启动后台进程并显示图形确认框;命令本身会立即返回。后续协议请求会转发给这个后台进程。
|
||||
|
||||
停止后台进程:
|
||||
|
||||
```bash
|
||||
./dist/DCAuth-macos --exit
|
||||
```
|
||||
|
||||
macOS 当前仍是开发版:私钥明文保存在用户配置目录的 `DCAuth/device.key`,且浏览器不会自动拉起程序,需要从登录页复制命令执行。
|
||||
|
||||
如果只是测后台业务,不想每次都走短信和设备验证,可以在“后台用户”里把测试账号设为 **技术测试**。该账号只使用账号密码登录;正式账号仍必须走短信 + DCAuth。
|
||||
|
||||
@@ -44,7 +90,7 @@ go run . "dcauth://authorize?app=order_site&challenge_id=...&challenge=...&api=h
|
||||
dcauth://authorize?app=order_site&challenge_id=...&challenge=...&api=https%3A%2F%2Forder.khhao.com
|
||||
```
|
||||
|
||||
客户端校验 app 和 api 白名单后,弹出确认框,签名并提交到:
|
||||
客户端校验 app 和 api 白名单后,弹出一次确认框,签名并提交到:
|
||||
|
||||
```text
|
||||
POST /api/dcauth/authorize
|
||||
@@ -60,11 +106,18 @@ POST /api/dcauth/authorize
|
||||
| `live_hub` | `https://live.khhao.com` |
|
||||
| `affiliate_dash` | `https://skin.khhao.com` |
|
||||
|
||||
本地开发入口已内置在 `main.go` 的 `appRegistry` 中,包括 `http://localhost`、`http://127.0.0.1`、`http://192.168.10.6` 及对应 Vite 端口。
|
||||
本地开发入口已内置在 `main.go` 的 `appRegistry` 中,包括 `http://localhost`、`http://localhost/`、`http://127.0.0.1`、`http://192.168.10.6` 及对应 Vite 端口。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "-H windowsgui -s -w" -o dist/DCAuth.exe .
|
||||
```
|
||||
|
||||
## 安全说明
|
||||
|
||||
- 私钥不离开本机,使用 DPAPI 保护。
|
||||
- 私钥不离开本机,Windows 使用 DPAPI 保护。
|
||||
- 服务端只保存公钥和机器码哈希/脱敏值。
|
||||
- 机器码采集主板 UUID、MachineGuid、主板/BIOS/系统盘序列号、CPU ID,不上传原始序列号。
|
||||
- 正式分发前建议使用代码签名证书,避免 SmartScreen 和杀毒软件误报。
|
||||
|
||||
@@ -26,13 +26,15 @@ $disk = Get-CimInstance Win32_DiskDrive | Where-Object { $_.Index -eq 0 } | Sele
|
||||
systemDiskSerial = [string]$disk.SerialNumber
|
||||
} | ConvertTo-Json -Compress
|
||||
`
|
||||
output, err := exec.Command(
|
||||
command := exec.Command(
|
||||
"powershell.exe",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy", "Bypass",
|
||||
"-Command", script,
|
||||
).Output()
|
||||
)
|
||||
hideCommandWindow(command)
|
||||
output, err := command.Output()
|
||||
if err == nil {
|
||||
var payload map[string]string
|
||||
if json.Unmarshal([]byte(strings.TrimSpace(string(output))), &payload) == nil {
|
||||
@@ -54,9 +56,11 @@ $disk = Get-CimInstance Win32_DiskDrive | Where-Object { $_.Index -eq 0 } | Sele
|
||||
}
|
||||
|
||||
func readMachineGUID() string {
|
||||
output, err := exec.Command(
|
||||
command := exec.Command(
|
||||
"reg", "query", `HKLM\SOFTWARE\Microsoft\Cryptography`, "/v", "MachineGuid",
|
||||
).Output()
|
||||
)
|
||||
hideCommandWindow(command)
|
||||
output, err := command.Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
+2
-40
@@ -15,7 +15,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const clientVersion = "1.0.0"
|
||||
const clientVersion = "1.1.0"
|
||||
|
||||
type appConfig struct {
|
||||
DisplayName string
|
||||
@@ -77,45 +77,7 @@ type authorizeRequest struct {
|
||||
}
|
||||
|
||||
func main() {
|
||||
registerProtocol()
|
||||
|
||||
identity, err := loadOrCreateIdentity()
|
||||
if err != nil {
|
||||
showError("DCAuth 初始化失败", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
protocolArg := findProtocolArg(os.Args[1:])
|
||||
if protocolArg == "" {
|
||||
text := fmt.Sprintf(
|
||||
"设备码:%s\n机器码:%s\n\n设备码用于识别本机,机器码用于管理员核对。\n请从后台登录页点击“验证本机”完成登录。",
|
||||
identity.deviceID,
|
||||
identity.machineCode,
|
||||
)
|
||||
copyToClipboard(identity.deviceID + "\n" + identity.machineCode)
|
||||
showInfo("DCAuth 设备信息", text+"\n\n设备码和机器码已复制到剪贴板。")
|
||||
return
|
||||
}
|
||||
|
||||
target, err := parseAuthorizeURL(protocolArg)
|
||||
if err != nil {
|
||||
showError("DCAuth 协议无效", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
app := appRegistry[target.App]
|
||||
if !confirm(
|
||||
app.DisplayName+" · 本机登录确认",
|
||||
fmt.Sprintf("是否确认使用本机设备登录「%s」?\n\n设备码:%s\n机器码:%s", app.DisplayName, identity.deviceID, identity.machineCode),
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := submitAuthorization(identity, target); err != nil {
|
||||
showError("本机验证失败", err.Error())
|
||||
return
|
||||
}
|
||||
showInfo("本机验证成功", "已确认本机设备,请回到浏览器继续登录。")
|
||||
runClient(findProtocolArg(os.Args[1:]))
|
||||
}
|
||||
|
||||
type authorizeTarget struct {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
const createNoWindow = 0x08000000
|
||||
|
||||
func hideCommandWindow(command *exec.Cmd) {
|
||||
command.SysProcAttr = &syscall.SysProcAttr{
|
||||
HideWindow: true,
|
||||
CreationFlags: createNoWindow,
|
||||
}
|
||||
}
|
||||
@@ -19,8 +19,26 @@ func registerProtocol() {
|
||||
}
|
||||
command := `"` + executable + `" "%1"`
|
||||
|
||||
_ = exec.Command("reg", "add", `HKCU\Software\Classes\dcauth`, "/ve", "/d", "URL:DCAuth Protocol", "/f").Run()
|
||||
_ = exec.Command("reg", "add", `HKCU\Software\Classes\dcauth`, "/v", "URL Protocol", "/d", "", "/f").Run()
|
||||
_ = exec.Command("reg", "add", `HKCU\Software\Classes\dcauth\DefaultIcon`, "/ve", "/d", executable+",0", "/f").Run()
|
||||
_ = exec.Command("reg", "add", `HKCU\Software\Classes\dcauth\shell\open\command`, "/ve", "/d", command, "/f").Run()
|
||||
runHiddenCommand(
|
||||
"reg", "add", `HKCU\Software\Classes\dcauth`,
|
||||
"/ve", "/d", "URL:DCAuth Protocol", "/f",
|
||||
)
|
||||
runHiddenCommand(
|
||||
"reg", "add", `HKCU\Software\Classes\dcauth`,
|
||||
"/v", "URL Protocol", "/d", "", "/f",
|
||||
)
|
||||
runHiddenCommand(
|
||||
"reg", "add", `HKCU\Software\Classes\dcauth\DefaultIcon`,
|
||||
"/ve", "/d", executable+",0", "/f",
|
||||
)
|
||||
runHiddenCommand(
|
||||
"reg", "add", `HKCU\Software\Classes\dcauth\shell\open\command`,
|
||||
"/ve", "/d", command, "/f",
|
||||
)
|
||||
}
|
||||
|
||||
func runHiddenCommand(name string, args ...string) {
|
||||
command := exec.Command(name, args...)
|
||||
hideCommandWindow(command)
|
||||
_ = command.Run()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
daemonShow = "__show__"
|
||||
daemonExit = "__exit__"
|
||||
)
|
||||
|
||||
func runClient(protocolArg string) {
|
||||
socketPath, err := darwinSocketPath()
|
||||
if err != nil {
|
||||
showError("DCAuth 启动失败", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if hasCommandLineArg("--exit") {
|
||||
if forwardToDarwinDaemon(socketPath, daemonExit) {
|
||||
return
|
||||
}
|
||||
showError("DCAuth 未运行", "当前没有正在运行的 DCAuth 后台进程。")
|
||||
return
|
||||
}
|
||||
|
||||
message := protocolArg
|
||||
if message == "" {
|
||||
message = daemonShow
|
||||
}
|
||||
if forwardToDarwinDaemon(socketPath, message) {
|
||||
return
|
||||
}
|
||||
if os.Getenv("DCAUTH_BACKGROUND") == "1" {
|
||||
runDarwinDaemon(protocolArg)
|
||||
return
|
||||
}
|
||||
if err := startDarwinDaemon(protocolArg); err != nil {
|
||||
showError("DCAuth 启动失败", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func runDarwinDaemon(protocolArg string) {
|
||||
lockPath, socketPath, err := darwinDaemonPaths()
|
||||
if err != nil {
|
||||
showError("DCAuth 启动失败", err.Error())
|
||||
return
|
||||
}
|
||||
lockFile, acquired, err := acquireDaemonLock(lockPath)
|
||||
if err != nil {
|
||||
showError("DCAuth 启动失败", err.Error())
|
||||
return
|
||||
}
|
||||
if !acquired {
|
||||
message := protocolArg
|
||||
if message == "" {
|
||||
message = daemonShow
|
||||
}
|
||||
if !waitForDarwinDaemon(socketPath, message, 10*time.Second) {
|
||||
showError("DCAuth 启动失败", "已有后台进程正在启动,但暂时无法连接。")
|
||||
}
|
||||
return
|
||||
}
|
||||
defer lockFile.Close()
|
||||
|
||||
identity, err := loadOrCreateIdentity()
|
||||
if err != nil {
|
||||
showError("DCAuth 初始化失败", err.Error())
|
||||
return
|
||||
}
|
||||
_ = os.Remove(socketPath)
|
||||
listener, err := net.Listen("unix", socketPath)
|
||||
if err != nil {
|
||||
showError("DCAuth 启动失败", fmt.Sprintf("无法创建后台通信通道:%v", err))
|
||||
return
|
||||
}
|
||||
defer listener.Close()
|
||||
defer os.Remove(socketPath)
|
||||
_ = os.Chmod(socketPath, 0o600)
|
||||
|
||||
requests := make(chan string, 16)
|
||||
go acceptDarwinRequests(listener, requests)
|
||||
|
||||
if protocolArg != "" {
|
||||
handleDarwinAuthorization(identity, protocolArg)
|
||||
} else {
|
||||
notifyDarwin("DCAuth v"+clientVersion, "DCAuth 已在后台运行。首次打开会更新协议绑定。")
|
||||
}
|
||||
|
||||
for request := range requests {
|
||||
switch request {
|
||||
case daemonExit:
|
||||
return
|
||||
case daemonShow:
|
||||
showDarwinDeviceInfo(identity)
|
||||
default:
|
||||
handleDarwinAuthorization(identity, request)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func acceptDarwinRequests(listener net.Listener, requests chan<- string) {
|
||||
for {
|
||||
connection, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
scanner := bufio.NewScanner(conn)
|
||||
scanner.Buffer(make([]byte, 1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
request := strings.TrimSpace(scanner.Text())
|
||||
if request != "" {
|
||||
requests <- request
|
||||
}
|
||||
}
|
||||
}(connection)
|
||||
}
|
||||
}
|
||||
|
||||
func handleDarwinAuthorization(identity identity, raw string) {
|
||||
target, err := parseAuthorizeURL(raw)
|
||||
if err != nil {
|
||||
showError("DCAuth 协议无效", err.Error())
|
||||
return
|
||||
}
|
||||
app := appRegistry[target.App]
|
||||
if !confirmDarwin(
|
||||
"DCAuth v"+clientVersion+" · "+app.DisplayName+" 本机登录确认",
|
||||
fmt.Sprintf(
|
||||
"是否允许使用本机设备登录「%s」?\n\n客户端版本:v%s\n设备码:%s\n机器码:%s\n\n点击“允许登录”完成验证,点击“取消”拒绝。",
|
||||
app.DisplayName,
|
||||
clientVersion,
|
||||
identity.deviceID,
|
||||
identity.machineCode,
|
||||
),
|
||||
) {
|
||||
return
|
||||
}
|
||||
if err := submitAuthorization(identity, target); err != nil {
|
||||
showError("本机验证失败", err.Error())
|
||||
return
|
||||
}
|
||||
notifyDarwin("DCAuth v"+clientVersion+" 验证成功", "已确认本机设备,请返回浏览器继续登录。")
|
||||
}
|
||||
|
||||
func showDarwinDeviceInfo(identity identity) {
|
||||
copyToClipboard(identity.deviceID + "\n" + identity.machineCode)
|
||||
showInfo(
|
||||
"DCAuth v"+clientVersion+" 设备信息",
|
||||
fmt.Sprintf(
|
||||
"版本:v%s\n\n设备码:%s\n机器码:%s\n\nDCAuth 正在后台运行。浏览器登录时只会打开一个确认窗口。\n设备码和机器码已复制到剪贴板。",
|
||||
clientVersion,
|
||||
identity.deviceID,
|
||||
identity.machineCode,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func startDarwinDaemon(protocolArg string) error {
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法定位 DCAuth 程序:%w", err)
|
||||
}
|
||||
args := make([]string, 0, 1)
|
||||
if protocolArg != "" {
|
||||
args = append(args, protocolArg)
|
||||
}
|
||||
command := exec.Command(executable, args...)
|
||||
command.Env = append(os.Environ(), "DCAUTH_BACKGROUND=1")
|
||||
command.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
|
||||
logPath, err := darwinLogPath()
|
||||
if err == nil {
|
||||
if logFile, openErr := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600); openErr == nil {
|
||||
defer logFile.Close()
|
||||
command.Stdout = logFile
|
||||
command.Stderr = logFile
|
||||
}
|
||||
}
|
||||
if err := command.Start(); err != nil {
|
||||
return fmt.Errorf("无法启动后台进程:%w", err)
|
||||
}
|
||||
return command.Process.Release()
|
||||
}
|
||||
|
||||
func acquireDaemonLock(path string) (*os.File, bool, error) {
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("无法创建后台锁文件:%w", err)
|
||||
}
|
||||
if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
|
||||
_ = file.Close()
|
||||
if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) {
|
||||
return nil, false, nil
|
||||
}
|
||||
return nil, false, fmt.Errorf("无法锁定后台进程:%w", err)
|
||||
}
|
||||
return file, true, nil
|
||||
}
|
||||
|
||||
func forwardToDarwinDaemon(socketPath, message string) bool {
|
||||
connection, err := net.DialTimeout("unix", socketPath, 250*time.Millisecond)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer connection.Close()
|
||||
_ = connection.SetWriteDeadline(time.Now().Add(time.Second))
|
||||
_, err = io.WriteString(connection, message+"\n")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func waitForDarwinDaemon(socketPath, message string, timeout time.Duration) bool {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if forwardToDarwinDaemon(socketPath, message) {
|
||||
return true
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func darwinDaemonPaths() (string, string, error) {
|
||||
directory, err := darwinDataDir()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return filepath.Join(directory, "daemon.lock"), filepath.Join(directory, "dcauth.sock"), nil
|
||||
}
|
||||
|
||||
func darwinSocketPath() (string, error) {
|
||||
_, socketPath, err := darwinDaemonPaths()
|
||||
return socketPath, err
|
||||
}
|
||||
|
||||
func darwinLogPath() (string, error) {
|
||||
directory, err := darwinDataDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(directory, "dcauth.log"), nil
|
||||
}
|
||||
|
||||
func darwinDataDir() (string, error) {
|
||||
base, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("无法定位用户配置目录:%w", err)
|
||||
}
|
||||
directory := filepath.Join(base, "DCAuth")
|
||||
if err := os.MkdirAll(directory, 0o700); err != nil {
|
||||
return "", fmt.Errorf("无法创建 DCAuth 配置目录:%w", err)
|
||||
}
|
||||
return directory, nil
|
||||
}
|
||||
|
||||
func hasCommandLineArg(target string) bool {
|
||||
for _, arg := range os.Args[1:] {
|
||||
if strings.EqualFold(strings.TrimSpace(arg), target) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func showInfo(title, text string) {
|
||||
_, _ = runAppleScript(
|
||||
"display dialog " + appleScriptString(text) +
|
||||
" with title " + appleScriptString(title) +
|
||||
" buttons {\"好\"} default button \"好\" with icon note",
|
||||
)
|
||||
}
|
||||
|
||||
func showError(title, text string) {
|
||||
_, _ = runAppleScript(
|
||||
"display alert " + appleScriptString(title) +
|
||||
" message " + appleScriptString(text) +
|
||||
" as critical buttons {\"好\"} default button \"好\"",
|
||||
)
|
||||
}
|
||||
|
||||
func confirmDarwin(title, text string) bool {
|
||||
output, err := runAppleScript(
|
||||
"display dialog " + appleScriptString(text) +
|
||||
" with title " + appleScriptString(title) +
|
||||
" buttons {\"取消\", \"允许登录\"} default button \"允许登录\" cancel button \"取消\" with icon caution",
|
||||
)
|
||||
return err == nil && strings.Contains(output, "允许登录")
|
||||
}
|
||||
|
||||
func notifyDarwin(title, text string) {
|
||||
_, _ = runAppleScript(
|
||||
"display notification " + appleScriptString(text) +
|
||||
" with title " + appleScriptString(title),
|
||||
)
|
||||
}
|
||||
|
||||
func runAppleScript(statement string) (string, error) {
|
||||
output, err := exec.Command("osascript", "-e", statement).CombinedOutput()
|
||||
return strings.TrimSpace(string(output)), err
|
||||
}
|
||||
|
||||
func appleScriptString(text string) string {
|
||||
replacer := strings.NewReplacer(
|
||||
"\\", "\\\\",
|
||||
"\"", "\\\"",
|
||||
"\r", "",
|
||||
"\n", "\\n",
|
||||
)
|
||||
return `"` + replacer.Replace(text) + `"`
|
||||
}
|
||||
|
||||
func copyToClipboard(text string) {
|
||||
command := exec.Command("pbcopy")
|
||||
command.Stdin = strings.NewReader(text)
|
||||
_ = command.Run()
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAppleScriptStringEscapesDialogText(t *testing.T) {
|
||||
got := appleScriptString("第一行\n第二行 \"确认\" \\ DCAuth")
|
||||
want := `"第一行\n第二行 \"确认\" \\ DCAuth"`
|
||||
if got != want {
|
||||
t.Fatalf("appleScriptString() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
+39
-1
@@ -1,4 +1,4 @@
|
||||
//go:build !windows
|
||||
//go:build !windows && !darwin
|
||||
|
||||
package main
|
||||
|
||||
@@ -7,6 +7,44 @@ import (
|
||||
"os"
|
||||
)
|
||||
|
||||
func runClient(protocolArg string) {
|
||||
identity, err := loadOrCreateIdentity()
|
||||
if err != nil {
|
||||
showError("DCAuth 初始化失败", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if protocolArg == "" {
|
||||
text := fmt.Sprintf(
|
||||
"DCAuth v%s\n设备码:%s\n机器码:%s\n\n设备码用于识别本机,机器码用于管理员核对。\n请从后台登录页点击“验证本机”完成登录。",
|
||||
clientVersion,
|
||||
identity.deviceID,
|
||||
identity.machineCode,
|
||||
)
|
||||
copyToClipboard(identity.deviceID + "\n" + identity.machineCode)
|
||||
showInfo("DCAuth v"+clientVersion+" 设备信息", text+"\n\n设备码和机器码已复制到剪贴板。")
|
||||
return
|
||||
}
|
||||
|
||||
target, err := parseAuthorizeURL(protocolArg)
|
||||
if err != nil {
|
||||
showError("DCAuth 协议无效", err.Error())
|
||||
return
|
||||
}
|
||||
app := appRegistry[target.App]
|
||||
if !confirm(
|
||||
app.DisplayName+" · 本机登录确认",
|
||||
fmt.Sprintf("是否确认使用本机设备登录「%s」?\n\n设备码:%s\n机器码:%s", app.DisplayName, identity.deviceID, identity.machineCode),
|
||||
) {
|
||||
return
|
||||
}
|
||||
if err := submitAuthorization(identity, target); err != nil {
|
||||
showError("本机验证失败", err.Error())
|
||||
return
|
||||
}
|
||||
showInfo("本机验证成功", "已确认本机设备,请回到浏览器继续登录。")
|
||||
}
|
||||
|
||||
func showInfo(title, text string) {
|
||||
fmt.Printf("[%s]\n%s\n", title, text)
|
||||
}
|
||||
|
||||
+551
-10
@@ -3,6 +3,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"syscall"
|
||||
"unicode/utf16"
|
||||
"unsafe"
|
||||
@@ -14,6 +16,8 @@ const (
|
||||
mbIconError = 0x00000010
|
||||
mbIconQuestion = 0x00000020
|
||||
mbIconInformation = 0x00000040
|
||||
mbSetForeground = 0x00010000
|
||||
mbTopMost = 0x00040000
|
||||
idOK = 1
|
||||
)
|
||||
|
||||
@@ -22,18 +26,539 @@ const (
|
||||
gmemMoveable = 0x0002
|
||||
)
|
||||
|
||||
const (
|
||||
wmNull = 0x0000
|
||||
wmDestroy = 0x0002
|
||||
wmClose = 0x0010
|
||||
wmCopyData = 0x004A
|
||||
wmCommand = 0x0111
|
||||
wmLButtonUp = 0x0202
|
||||
wmLButtonDblClk = 0x0203
|
||||
wmRButtonUp = 0x0205
|
||||
wmContextMenu = 0x007B
|
||||
wmApp = 0x8000
|
||||
)
|
||||
|
||||
const (
|
||||
trayCallbackMessage = wmApp + 1
|
||||
showWindowMessage = wmApp + 2
|
||||
copyDataMagic = 0x44434131
|
||||
)
|
||||
|
||||
const (
|
||||
trayMenuDeviceInfo = 1001
|
||||
trayMenuExit = 1002
|
||||
)
|
||||
|
||||
const (
|
||||
nimAdd = 0x00000000
|
||||
nimModify = 0x00000001
|
||||
nimDelete = 0x00000002
|
||||
)
|
||||
|
||||
const (
|
||||
nifMessage = 0x00000001
|
||||
nifIcon = 0x00000002
|
||||
nifTip = 0x00000004
|
||||
nifInfo = 0x00000010
|
||||
)
|
||||
|
||||
const (
|
||||
niifInfo = 0x00000001
|
||||
)
|
||||
|
||||
const (
|
||||
mfString = 0x00000000
|
||||
mfGrayed = 0x00000001
|
||||
mfSeparator = 0x00000800
|
||||
tpmRight = 0x00000002
|
||||
tpmReturn = 0x00000100
|
||||
)
|
||||
|
||||
const (
|
||||
errorAlreadyExists = syscall.Errno(183)
|
||||
errorClassAlreadyReg = syscall.Errno(1410)
|
||||
)
|
||||
|
||||
const (
|
||||
windowClassName = "DCAuthHiddenWindow"
|
||||
windowTitle = "DCAuth"
|
||||
mutexName = `Local\DCAuth.OrderSite`
|
||||
authorizationMutexName = `Local\DCAuth.OrderSite.Authorization`
|
||||
)
|
||||
|
||||
type point struct {
|
||||
X int32
|
||||
Y int32
|
||||
}
|
||||
|
||||
type message struct {
|
||||
HWnd uintptr
|
||||
Message uint32
|
||||
WParam uintptr
|
||||
LParam uintptr
|
||||
Time uint32
|
||||
Pt point
|
||||
Private uint32
|
||||
}
|
||||
|
||||
type wndClassEx struct {
|
||||
CbSize uint32
|
||||
Style uint32
|
||||
LpfnWndProc uintptr
|
||||
CbClsExtra int32
|
||||
CbWndExtra int32
|
||||
HInstance uintptr
|
||||
HIcon uintptr
|
||||
HCursor uintptr
|
||||
HbrBackground uintptr
|
||||
LpszMenuName *uint16
|
||||
LpszClassName *uint16
|
||||
HIconSm uintptr
|
||||
}
|
||||
|
||||
type copyDataStruct struct {
|
||||
DwData uintptr
|
||||
CbData uint32
|
||||
LpData uintptr
|
||||
}
|
||||
|
||||
type guid struct {
|
||||
Data1 uint32
|
||||
Data2 uint16
|
||||
Data3 uint16
|
||||
Data4 [8]byte
|
||||
}
|
||||
|
||||
type notifyIconData struct {
|
||||
CbSize uint32
|
||||
HWnd uintptr
|
||||
UID uint32
|
||||
UFlags uint32
|
||||
UCallbackMessage uint32
|
||||
HIcon uintptr
|
||||
SzTip [128]uint16
|
||||
DwState uint32
|
||||
DwStateMask uint32
|
||||
SzInfo [256]uint16
|
||||
UVersion uint32
|
||||
SzInfoTitle [64]uint16
|
||||
DwInfoFlags uint32
|
||||
GuidItem guid
|
||||
HBalloonIcon uintptr
|
||||
}
|
||||
|
||||
var (
|
||||
user32DLL = syscall.NewLazyDLL("user32.dll")
|
||||
messageBoxW = user32DLL.NewProc("MessageBoxW")
|
||||
openClip = user32DLL.NewProc("OpenClipboard")
|
||||
closeClip = user32DLL.NewProc("CloseClipboard")
|
||||
emptyClip = user32DLL.NewProc("EmptyClipboard")
|
||||
setClipData = user32DLL.NewProc("SetClipboardData")
|
||||
user32DLL = syscall.NewLazyDLL("user32.dll")
|
||||
shell32DLL = syscall.NewLazyDLL("shell32.dll")
|
||||
|
||||
messageBoxW = user32DLL.NewProc("MessageBoxW")
|
||||
openClip = user32DLL.NewProc("OpenClipboard")
|
||||
closeClip = user32DLL.NewProc("CloseClipboard")
|
||||
emptyClip = user32DLL.NewProc("EmptyClipboard")
|
||||
setClipData = user32DLL.NewProc("SetClipboardData")
|
||||
registerClassExW = user32DLL.NewProc("RegisterClassExW")
|
||||
createWindowExW = user32DLL.NewProc("CreateWindowExW")
|
||||
defWindowProcW = user32DLL.NewProc("DefWindowProcW")
|
||||
getMessageW = user32DLL.NewProc("GetMessageW")
|
||||
translateMessage = user32DLL.NewProc("TranslateMessage")
|
||||
dispatchMessageW = user32DLL.NewProc("DispatchMessageW")
|
||||
postQuitMessage = user32DLL.NewProc("PostQuitMessage")
|
||||
destroyWindow = user32DLL.NewProc("DestroyWindow")
|
||||
findWindowW = user32DLL.NewProc("FindWindowW")
|
||||
sendMessageW = user32DLL.NewProc("SendMessageW")
|
||||
postMessageW = user32DLL.NewProc("PostMessageW")
|
||||
createPopupMenu = user32DLL.NewProc("CreatePopupMenu")
|
||||
appendMenuW = user32DLL.NewProc("AppendMenuW")
|
||||
trackPopupMenu = user32DLL.NewProc("TrackPopupMenu")
|
||||
destroyMenu = user32DLL.NewProc("DestroyMenu")
|
||||
setForegroundWindow = user32DLL.NewProc("SetForegroundWindow")
|
||||
getCursorPos = user32DLL.NewProc("GetCursorPos")
|
||||
loadIconW = user32DLL.NewProc("LoadIconW")
|
||||
shellNotifyIconW = shell32DLL.NewProc("Shell_NotifyIconW")
|
||||
|
||||
globalAlloc = kernel32DLL.NewProc("GlobalAlloc")
|
||||
globalLock = kernel32DLL.NewProc("GlobalLock")
|
||||
globalUnlock = kernel32DLL.NewProc("GlobalUnlock")
|
||||
createMutexW = kernel32DLL.NewProc("CreateMutexW")
|
||||
getModuleW = kernel32DLL.NewProc("GetModuleHandleW")
|
||||
sleep = kernel32DLL.NewProc("Sleep")
|
||||
)
|
||||
|
||||
var (
|
||||
mainWindow uintptr
|
||||
instanceMutex uintptr
|
||||
trayData notifyIconData
|
||||
trayAdded bool
|
||||
runtimeIdentity identity
|
||||
authorizationActive bool
|
||||
windowProcedure = syscall.NewCallback(dcauthWindowProc)
|
||||
)
|
||||
|
||||
func runClient(protocolArg string) {
|
||||
runtime.LockOSThread()
|
||||
firstInstance, err := acquireSingleInstance()
|
||||
if err != nil {
|
||||
showError("DCAuth 启动失败", err.Error())
|
||||
return
|
||||
}
|
||||
if !firstInstance {
|
||||
if authorizationInProgress() {
|
||||
return
|
||||
}
|
||||
if forwardToRunningInstance(protocolArg) {
|
||||
return
|
||||
}
|
||||
showError("DCAuth 启动失败", "已有 DCAuth 实例正在启动,但暂时无法连接,请稍后重试。")
|
||||
return
|
||||
}
|
||||
|
||||
registerProtocol()
|
||||
runtimeIdentity, err = loadOrCreateIdentity()
|
||||
if err != nil {
|
||||
showError("DCAuth 初始化失败", err.Error())
|
||||
return
|
||||
}
|
||||
if err := createHiddenWindow(); err != nil {
|
||||
showError("DCAuth 启动失败", err.Error())
|
||||
return
|
||||
}
|
||||
if err := addTrayIcon(); err != nil {
|
||||
showError("DCAuth 启动失败", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if protocolArg != "" {
|
||||
handleAuthorization(protocolArg)
|
||||
} else {
|
||||
showTrayBalloon("DCAuth v"+clientVersion+" 已在后台运行", "双击托盘图标可查看版本、设备码和机器码。")
|
||||
}
|
||||
|
||||
runMessageLoop()
|
||||
removeTrayIcon()
|
||||
}
|
||||
|
||||
func acquireSingleInstance() (bool, error) {
|
||||
namePtr, _ := syscall.UTF16PtrFromString(mutexName)
|
||||
handle, _, callErr := createMutexW.Call(0, 0, uintptr(unsafe.Pointer(namePtr)))
|
||||
runtime.KeepAlive(namePtr)
|
||||
if handle == 0 {
|
||||
return false, fmt.Errorf("无法创建单实例锁:%v", callErr)
|
||||
}
|
||||
if errno, ok := callErr.(syscall.Errno); ok && errno == errorAlreadyExists {
|
||||
_ = syscall.CloseHandle(syscall.Handle(handle))
|
||||
return false, nil
|
||||
}
|
||||
instanceMutex = handle
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func authorizationInProgress() bool {
|
||||
handle, acquired, err := acquireNamedMutex(authorizationMutexName)
|
||||
if err != nil || !acquired {
|
||||
if handle != 0 {
|
||||
_ = syscall.CloseHandle(syscall.Handle(handle))
|
||||
}
|
||||
return err == nil && !acquired
|
||||
}
|
||||
_ = syscall.CloseHandle(syscall.Handle(handle))
|
||||
return false
|
||||
}
|
||||
|
||||
func acquireNamedMutex(name string) (uintptr, bool, error) {
|
||||
namePtr, _ := syscall.UTF16PtrFromString(name)
|
||||
handle, _, callErr := createMutexW.Call(0, 0, uintptr(unsafe.Pointer(namePtr)))
|
||||
runtime.KeepAlive(namePtr)
|
||||
if handle == 0 {
|
||||
return 0, false, fmt.Errorf("无法创建互斥锁:%v", callErr)
|
||||
}
|
||||
if errno, ok := callErr.(syscall.Errno); ok && errno == errorAlreadyExists {
|
||||
return handle, false, nil
|
||||
}
|
||||
return handle, true, nil
|
||||
}
|
||||
|
||||
func forwardToRunningInstance(protocolArg string) bool {
|
||||
for attempt := 0; attempt < 100; attempt++ {
|
||||
if postToRunningInstance(protocolArg) {
|
||||
return true
|
||||
}
|
||||
sleep.Call(100)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func postToRunningInstance(protocolArg string) bool {
|
||||
classPtr, _ := syscall.UTF16PtrFromString(windowClassName)
|
||||
titlePtr, _ := syscall.UTF16PtrFromString(windowTitle)
|
||||
hwnd, _, _ := findWindowW.Call(
|
||||
uintptr(unsafe.Pointer(classPtr)),
|
||||
uintptr(unsafe.Pointer(titlePtr)),
|
||||
)
|
||||
runtime.KeepAlive(classPtr)
|
||||
runtime.KeepAlive(titlePtr)
|
||||
if hwnd == 0 {
|
||||
return false
|
||||
}
|
||||
if protocolArg == "" {
|
||||
postMessageW.Call(hwnd, showWindowMessage, 0, 0)
|
||||
return true
|
||||
}
|
||||
|
||||
encoded, _ := syscall.UTF16FromString(protocolArg)
|
||||
data := copyDataStruct{
|
||||
DwData: copyDataMagic,
|
||||
CbData: uint32(len(encoded) * 2),
|
||||
LpData: uintptr(unsafe.Pointer(&encoded[0])),
|
||||
}
|
||||
result, _, _ := sendMessageW.Call(
|
||||
hwnd,
|
||||
wmCopyData,
|
||||
0,
|
||||
uintptr(unsafe.Pointer(&data)),
|
||||
)
|
||||
runtime.KeepAlive(encoded)
|
||||
return result != 0
|
||||
}
|
||||
|
||||
func createHiddenWindow() error {
|
||||
instance, _, _ := getModuleW.Call(0)
|
||||
if instance == 0 {
|
||||
return fmt.Errorf("无法读取程序实例句柄")
|
||||
}
|
||||
icon, _, _ := loadIconW.Call(0, 32512)
|
||||
|
||||
classNamePtr, _ := syscall.UTF16PtrFromString(windowClassName)
|
||||
class := wndClassEx{
|
||||
CbSize: uint32(unsafe.Sizeof(wndClassEx{})),
|
||||
LpfnWndProc: windowProcedure,
|
||||
HInstance: instance,
|
||||
HIcon: icon,
|
||||
LpszClassName: classNamePtr,
|
||||
HIconSm: icon,
|
||||
}
|
||||
result, _, callErr := registerClassExW.Call(uintptr(unsafe.Pointer(&class)))
|
||||
runtime.KeepAlive(classNamePtr)
|
||||
if result == 0 {
|
||||
if errno, ok := callErr.(syscall.Errno); !ok || errno != errorClassAlreadyReg {
|
||||
return fmt.Errorf("无法注册窗口类:%v", callErr)
|
||||
}
|
||||
}
|
||||
|
||||
titlePtr, _ := syscall.UTF16PtrFromString(windowTitle)
|
||||
const wsExToolWindow = 0x00000080
|
||||
hwnd, _, callErr := createWindowExW.Call(
|
||||
wsExToolWindow,
|
||||
uintptr(unsafe.Pointer(classNamePtr)),
|
||||
uintptr(unsafe.Pointer(titlePtr)),
|
||||
0,
|
||||
0, 0, 0, 0,
|
||||
0,
|
||||
0,
|
||||
instance,
|
||||
0,
|
||||
)
|
||||
if hwnd == 0 {
|
||||
return fmt.Errorf("无法创建 DCAuth 后台窗口:%v", callErr)
|
||||
}
|
||||
runtime.KeepAlive(classNamePtr)
|
||||
runtime.KeepAlive(titlePtr)
|
||||
mainWindow = hwnd
|
||||
return nil
|
||||
}
|
||||
|
||||
func runMessageLoop() {
|
||||
var msg message
|
||||
for {
|
||||
result, _, _ := getMessageW.Call(
|
||||
uintptr(unsafe.Pointer(&msg)),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
if int32(result) <= 0 {
|
||||
return
|
||||
}
|
||||
translateMessage.Call(uintptr(unsafe.Pointer(&msg)))
|
||||
dispatchMessageW.Call(uintptr(unsafe.Pointer(&msg)))
|
||||
}
|
||||
}
|
||||
|
||||
func dcauthWindowProc(hwnd, msg, wParam, lParam uintptr) uintptr {
|
||||
switch uint32(msg) {
|
||||
case wmCopyData:
|
||||
data := (*copyDataStruct)(unsafe.Pointer(lParam))
|
||||
if data != nil && data.DwData == copyDataMagic && data.CbData > 0 && data.LpData != 0 {
|
||||
count := int(data.CbData / 2)
|
||||
raw := syscall.UTF16ToString(unsafe.Slice((*uint16)(unsafe.Pointer(data.LpData)), count))
|
||||
handleAuthorization(raw)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
case showWindowMessage:
|
||||
showDeviceInfo()
|
||||
return 0
|
||||
case trayCallbackMessage:
|
||||
switch uint32(lParam) {
|
||||
case wmLButtonDblClk:
|
||||
showDeviceInfo()
|
||||
case wmRButtonUp, wmContextMenu:
|
||||
showTrayMenu(hwnd)
|
||||
}
|
||||
return 0
|
||||
case wmClose:
|
||||
destroyWindow.Call(hwnd)
|
||||
return 0
|
||||
case wmDestroy:
|
||||
removeTrayIcon()
|
||||
postQuitMessage.Call(0)
|
||||
return 0
|
||||
}
|
||||
result, _, _ := defWindowProcW.Call(hwnd, msg, wParam, lParam)
|
||||
return result
|
||||
}
|
||||
|
||||
func addTrayIcon() error {
|
||||
icon, _, _ := loadIconW.Call(0, 32512)
|
||||
trayData = notifyIconData{
|
||||
HWnd: mainWindow,
|
||||
UID: 1,
|
||||
UFlags: nifMessage | nifIcon | nifTip,
|
||||
UCallbackMessage: trayCallbackMessage,
|
||||
HIcon: icon,
|
||||
}
|
||||
trayData.CbSize = uint32(unsafe.Sizeof(trayData))
|
||||
copyUTF16(trayData.SzTip[:], "DCAuth v"+clientVersion+" - 本机设备认证(后台运行)")
|
||||
result, _, _ := shellNotifyIconW.Call(
|
||||
nimAdd,
|
||||
uintptr(unsafe.Pointer(&trayData)),
|
||||
)
|
||||
if result == 0 {
|
||||
return fmt.Errorf("无法创建系统托盘图标")
|
||||
}
|
||||
trayAdded = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeTrayIcon() {
|
||||
if !trayAdded {
|
||||
return
|
||||
}
|
||||
shellNotifyIconW.Call(nimDelete, uintptr(unsafe.Pointer(&trayData)))
|
||||
trayAdded = false
|
||||
}
|
||||
|
||||
func showTrayBalloon(title, text string) {
|
||||
if !trayAdded {
|
||||
return
|
||||
}
|
||||
info := trayData
|
||||
info.UFlags |= nifInfo
|
||||
info.UVersion = 10000
|
||||
info.DwInfoFlags = niifInfo
|
||||
copyUTF16(info.SzInfoTitle[:], title)
|
||||
copyUTF16(info.SzInfo[:], text)
|
||||
shellNotifyIconW.Call(nimModify, uintptr(unsafe.Pointer(&info)))
|
||||
}
|
||||
|
||||
func showTrayMenu(hwnd uintptr) {
|
||||
menu, _, _ := createPopupMenu.Call()
|
||||
if menu == 0 {
|
||||
return
|
||||
}
|
||||
defer destroyMenu.Call(menu)
|
||||
|
||||
statusText := mustUTF16("DCAuth v" + clientVersion + " · 后台运行中")
|
||||
deviceText := mustUTF16("查看设备码")
|
||||
exitText := mustUTF16("退出 DCAuth")
|
||||
appendMenuW.Call(menu, mfString|mfGrayed, 0, uintptr(unsafe.Pointer(statusText)))
|
||||
appendMenuW.Call(menu, mfString, trayMenuDeviceInfo, uintptr(unsafe.Pointer(deviceText)))
|
||||
appendMenuW.Call(menu, mfSeparator, 0, 0)
|
||||
appendMenuW.Call(menu, mfString, trayMenuExit, uintptr(unsafe.Pointer(exitText)))
|
||||
runtime.KeepAlive(statusText)
|
||||
runtime.KeepAlive(deviceText)
|
||||
runtime.KeepAlive(exitText)
|
||||
|
||||
var cursor point
|
||||
if ok, _, _ := getCursorPos.Call(uintptr(unsafe.Pointer(&cursor))); ok == 0 {
|
||||
return
|
||||
}
|
||||
setForegroundWindow.Call(hwnd)
|
||||
command, _, _ := trackPopupMenu.Call(
|
||||
menu,
|
||||
tpmRight|tpmReturn,
|
||||
uintptr(cursor.X),
|
||||
uintptr(cursor.Y),
|
||||
0,
|
||||
hwnd,
|
||||
0,
|
||||
)
|
||||
postMessageW.Call(hwnd, wmNull, 0, 0)
|
||||
|
||||
switch command {
|
||||
case trayMenuDeviceInfo:
|
||||
showDeviceInfo()
|
||||
case trayMenuExit:
|
||||
destroyWindow.Call(hwnd)
|
||||
}
|
||||
}
|
||||
|
||||
func showDeviceInfo() {
|
||||
text := fmt.Sprintf(
|
||||
"版本:v%s\n\n设备码:%s\n机器码:%s\n\nDCAuth 正在后台运行。浏览器登录时只会打开一个确认窗口。\n设备码和机器码已复制到剪贴板。",
|
||||
clientVersion,
|
||||
runtimeIdentity.deviceID,
|
||||
runtimeIdentity.machineCode,
|
||||
)
|
||||
copyToClipboard(runtimeIdentity.deviceID + "\n" + runtimeIdentity.machineCode)
|
||||
showInfo("DCAuth v"+clientVersion+" 设备信息", text)
|
||||
}
|
||||
|
||||
func handleAuthorization(raw string) {
|
||||
if authorizationActive {
|
||||
showTrayBalloon("DCAuth v"+clientVersion, "已有一次登录确认正在进行,请先完成当前窗口。")
|
||||
return
|
||||
}
|
||||
authorizationActive = true
|
||||
defer func() { authorizationActive = false }()
|
||||
|
||||
authHandle, acquired, err := acquireNamedMutex(authorizationMutexName)
|
||||
if err != nil {
|
||||
showError("DCAuth 验证失败", err.Error())
|
||||
return
|
||||
}
|
||||
if !acquired {
|
||||
_ = syscall.CloseHandle(syscall.Handle(authHandle))
|
||||
showTrayBalloon("DCAuth v"+clientVersion, "已有一次登录确认正在进行,请先完成当前窗口。")
|
||||
return
|
||||
}
|
||||
defer syscall.CloseHandle(syscall.Handle(authHandle))
|
||||
|
||||
target, err := parseAuthorizeURL(raw)
|
||||
if err != nil {
|
||||
showError("DCAuth 协议无效", err.Error())
|
||||
return
|
||||
}
|
||||
app := appRegistry[target.App]
|
||||
if !confirm(
|
||||
"DCAuth v"+clientVersion+" · "+app.DisplayName+" 本机登录确认",
|
||||
fmt.Sprintf(
|
||||
"是否允许使用本机设备登录「%s」?\n\n客户端版本:v%s\n设备码:%s\n机器码:%s\n\n点击“确定”允许本次登录,点击“取消”拒绝。",
|
||||
app.DisplayName,
|
||||
clientVersion,
|
||||
runtimeIdentity.deviceID,
|
||||
runtimeIdentity.machineCode,
|
||||
),
|
||||
) {
|
||||
return
|
||||
}
|
||||
if err := submitAuthorization(runtimeIdentity, target); err != nil {
|
||||
showError("本机验证失败", err.Error())
|
||||
return
|
||||
}
|
||||
showTrayBalloon("DCAuth v"+clientVersion+" 验证成功", "已确认本机设备,请返回浏览器继续登录。")
|
||||
}
|
||||
|
||||
func showInfo(title, text string) {
|
||||
showMessage(title, text, mbOK|mbIconInformation)
|
||||
}
|
||||
@@ -50,21 +575,37 @@ func showMessage(title, text string, flags uint) int {
|
||||
titlePtr, _ := syscall.UTF16PtrFromString(title)
|
||||
textPtr, _ := syscall.UTF16PtrFromString(text)
|
||||
result, _, _ := messageBoxW.Call(
|
||||
0,
|
||||
mainWindow,
|
||||
uintptr(unsafe.Pointer(textPtr)),
|
||||
uintptr(unsafe.Pointer(titlePtr)),
|
||||
uintptr(flags),
|
||||
uintptr(flags|mbSetForeground|mbTopMost),
|
||||
)
|
||||
runtime.KeepAlive(titlePtr)
|
||||
runtime.KeepAlive(textPtr)
|
||||
return int(result)
|
||||
}
|
||||
|
||||
func copyUTF16(destination []uint16, text string) {
|
||||
encoded := utf16.Encode([]rune(text))
|
||||
if len(encoded) >= len(destination) {
|
||||
encoded = encoded[:len(destination)-1]
|
||||
}
|
||||
copy(destination, encoded)
|
||||
destination[len(encoded)] = 0
|
||||
}
|
||||
|
||||
func mustUTF16(text string) *uint16 {
|
||||
value, _ := syscall.UTF16PtrFromString(text)
|
||||
return value
|
||||
}
|
||||
|
||||
func copyToClipboard(text string) {
|
||||
opened, _, _ := openClip.Call(0)
|
||||
opened, _, _ := openClip.Call(mainWindow)
|
||||
if opened == 0 {
|
||||
return
|
||||
}
|
||||
defer closeClip.Call()
|
||||
emptyClip.Call()
|
||||
emptyClip.Call(mainWindow)
|
||||
|
||||
encoded := utf16.Encode([]rune(text))
|
||||
encoded = append(encoded, 0)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
1 VERSIONINFO
|
||||
FILEVERSION 1,1,0,0
|
||||
PRODUCTVERSION 1,1,0,0
|
||||
FILEFLAGSMASK 0x3fL
|
||||
FILEFLAGS 0x0L
|
||||
FILEOS 0x40004L
|
||||
FILETYPE 0x1L
|
||||
FILESUBTYPE 0x0L
|
||||
BEGIN
|
||||
BLOCK "StringFileInfo"
|
||||
BEGIN
|
||||
BLOCK "040904b0"
|
||||
BEGIN
|
||||
VALUE "CompanyName", "Order Site"
|
||||
VALUE "FileDescription", "DCAuth device authentication client"
|
||||
VALUE "FileVersion", "1.1.0.0"
|
||||
VALUE "InternalName", "DCAuth"
|
||||
VALUE "LegalCopyright", "Copyright (C) 2026 Order Site"
|
||||
VALUE "OriginalFilename", "DCAuth.exe"
|
||||
VALUE "ProductName", "DCAuth"
|
||||
VALUE "ProductVersion", "1.1.0"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
BEGIN
|
||||
VALUE "Translation", 0x0409, 1200
|
||||
END
|
||||
END
|
||||
Binary file not shown.
@@ -25,6 +25,9 @@ type LoginForm = {
|
||||
smsCode: string
|
||||
}
|
||||
|
||||
const isMacPlatform =
|
||||
typeof navigator !== 'undefined' && /Macintosh|Mac OS X|iPhone|iPad/i.test(navigator.userAgent)
|
||||
|
||||
const brandFeatures = [
|
||||
{
|
||||
icon: <DashboardOutlined />,
|
||||
@@ -54,6 +57,7 @@ export default function AdminLoginPage() {
|
||||
const [waiting, setWaiting] = useState(false)
|
||||
const [devLaunchUrl, setDevLaunchUrl] = useState('')
|
||||
const timerRef = useRef<number | null>(null)
|
||||
const submittingRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -94,6 +98,8 @@ export default function AdminLoginPage() {
|
||||
}
|
||||
|
||||
async function submitLogin(values: LoginForm) {
|
||||
if (submittingRef.current) return
|
||||
submittingRef.current = true
|
||||
setLoading(true)
|
||||
setWaiting(false)
|
||||
try {
|
||||
@@ -105,7 +111,7 @@ export default function AdminLoginPage() {
|
||||
})
|
||||
setPhoneMasked(prepared.data.phoneMasked)
|
||||
setWaiting(true)
|
||||
setDevLaunchUrl(import.meta.env.DEV ? prepared.data.launchUrl : '')
|
||||
setDevLaunchUrl(import.meta.env.DEV && isMacPlatform ? prepared.data.launchUrl : '')
|
||||
message.info('已发起本机验证,请在弹出的 DCAuth 窗口中确认')
|
||||
launchProtocol(prepared.data.launchUrl)
|
||||
|
||||
@@ -121,6 +127,7 @@ export default function AdminLoginPage() {
|
||||
message.error(error instanceof Error ? error.message : '登录失败')
|
||||
}
|
||||
} finally {
|
||||
submittingRef.current = false
|
||||
setLoading(false)
|
||||
setWaiting(false)
|
||||
setDevLaunchUrl('')
|
||||
@@ -128,12 +135,14 @@ export default function AdminLoginPage() {
|
||||
}
|
||||
|
||||
async function submitLegacyLogin() {
|
||||
if (submittingRef.current) return
|
||||
let values: Pick<LoginForm, 'username' | 'password'>
|
||||
try {
|
||||
values = await form.validateFields(['username', 'password'])
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
submittingRef.current = true
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await loginAdmin({
|
||||
@@ -146,6 +155,7 @@ export default function AdminLoginPage() {
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '账号密码登录失败')
|
||||
} finally {
|
||||
submittingRef.current = false
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user