diff --git a/apps/dcauth/README.md b/apps/dcauth/README.md
index f6fe7765..46d2ded8 100644
--- a/apps/dcauth/README.md
+++ b/apps/dcauth/README.md
@@ -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 和杀毒软件误报。
diff --git a/apps/dcauth/hardware_windows.go b/apps/dcauth/hardware_windows.go
index d9698f34..9b05d5d4 100644
--- a/apps/dcauth/hardware_windows.go
+++ b/apps/dcauth/hardware_windows.go
@@ -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 ""
}
diff --git a/apps/dcauth/main.go b/apps/dcauth/main.go
index e982f478..6d0677c4 100644
--- a/apps/dcauth/main.go
+++ b/apps/dcauth/main.go
@@ -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 {
diff --git a/apps/dcauth/process_windows.go b/apps/dcauth/process_windows.go
new file mode 100644
index 00000000..7352d180
--- /dev/null
+++ b/apps/dcauth/process_windows.go
@@ -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,
+ }
+}
diff --git a/apps/dcauth/registry_windows.go b/apps/dcauth/registry_windows.go
index e2361921..2421c7eb 100644
--- a/apps/dcauth/registry_windows.go
+++ b/apps/dcauth/registry_windows.go
@@ -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()
}
diff --git a/apps/dcauth/ui_darwin.go b/apps/dcauth/ui_darwin.go
new file mode 100644
index 00000000..a970e4eb
--- /dev/null
+++ b/apps/dcauth/ui_darwin.go
@@ -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()
+}
diff --git a/apps/dcauth/ui_darwin_test.go b/apps/dcauth/ui_darwin_test.go
new file mode 100644
index 00000000..d93436bd
--- /dev/null
+++ b/apps/dcauth/ui_darwin_test.go
@@ -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)
+ }
+}
diff --git a/apps/dcauth/ui_other.go b/apps/dcauth/ui_other.go
index af193d24..39b3882d 100644
--- a/apps/dcauth/ui_other.go
+++ b/apps/dcauth/ui_other.go
@@ -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)
}
diff --git a/apps/dcauth/ui_windows.go b/apps/dcauth/ui_windows.go
index bfae5d01..9d80dfff 100644
--- a/apps/dcauth/ui_windows.go
+++ b/apps/dcauth/ui_windows.go
@@ -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)
diff --git a/apps/dcauth/versioninfo.rc b/apps/dcauth/versioninfo.rc
new file mode 100644
index 00000000..74755237
--- /dev/null
+++ b/apps/dcauth/versioninfo.rc
@@ -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
diff --git a/apps/dcauth/versioninfo_windows_amd64.syso b/apps/dcauth/versioninfo_windows_amd64.syso
new file mode 100644
index 00000000..2278b81d
Binary files /dev/null and b/apps/dcauth/versioninfo_windows_amd64.syso differ
diff --git a/apps/frontend/src/pages/admin/AdminLoginPage.tsx b/apps/frontend/src/pages/admin/AdminLoginPage.tsx
index c94349d7..967923e3 100644
--- a/apps/frontend/src/pages/admin/AdminLoginPage.tsx
+++ b/apps/frontend/src/pages/admin/AdminLoginPage.tsx
@@ -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: ,
@@ -54,6 +57,7 @@ export default function AdminLoginPage() {
const [waiting, setWaiting] = useState(false)
const [devLaunchUrl, setDevLaunchUrl] = useState('')
const timerRef = useRef(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
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)
}
}