feat(dcauth): 新增常驻托盘客户端与版本显示

Windows 改为单实例托盘常驻,协议唤起只弹一个确认窗口;补充 macOS 后台守护进程
与客户端版本显示。
This commit is contained in:
yml2213
2026-10-21 20:46:25 +08:00
parent 040100f290
commit cb916d7750
12 changed files with 1087 additions and 73 deletions
+66 -13
View File
@@ -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 和杀毒软件误报。
+8 -4
View File
@@ -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
View File
@@ -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 {
+17
View File
@@ -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,
}
}
+22 -4
View File
@@ -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()
}
+330
View File
@@ -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()
}
+13
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+28
View File
@@ -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)
}
}