diff --git a/main.js b/main.js index 9de1f5ba..1807e585 100644 --- a/main.js +++ b/main.js @@ -11,12 +11,14 @@ const { nativeTheme, protocol, screen, + systemPreferences, } = require("electron"); const path = require("path"); const isDev = require("electron-is-dev"); const Store = require("electron-store"); const log = require("electron-log/main"); const os = require("os"); +const { execFile } = require("child_process"); const store = new Store(); const fs = require("fs"); const JSZip = require("jszip"); @@ -41,6 +43,199 @@ let syncUtilCache = {}; let pickerUtilCache = {}; let downloadRequest = null; +const runPowerShellScript = (script) => { + return new Promise((resolve, reject) => { + const encodedCommand = Buffer.from(script, "utf16le").toString("base64"); + execFile( + "powershell.exe", + [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", + encodedCommand, + ], + { + windowsHide: true, + timeout: 30000, + maxBuffer: 1024 * 1024, + }, + (error, stdout, stderr) => { + if (error) { + reject( + new Error((stderr || stdout || error.message || "").trim() || "") + ); + return; + } + resolve((stdout || "").trim()); + } + ); + }); +}; + +const getWindowsHelloScript = (mode, message = "") => { + const escapedMessage = message.replace(/'/g, "''"); + return ` +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Runtime.WindowsRuntime + +function Invoke-WinRtAsync { + param( + [Parameter(Mandatory = $true)] $Operation, + [Parameter(Mandatory = $true)] [Type[]] $ResultTypes + ) + + $method = [System.WindowsRuntimeSystemExtensions].GetMethods() | + Where-Object { + $_.Name -eq 'AsTask' -and + $_.IsGenericMethodDefinition -and + $_.GetGenericArguments().Count -eq $ResultTypes.Count -and + $_.GetParameters().Count -eq 1 + } | + Select-Object -First 1 + + if (-not $method) { + throw 'Unable to bridge Windows Runtime async operation.' + } + + $genericMethod = $method.MakeGenericMethod($ResultTypes) + $task = $genericMethod.Invoke($null, @($Operation)) + return $task.GetAwaiter().GetResult() +} + +$verifier = [Windows.Security.Credentials.UI.UserConsentVerifier, Windows.Security.Credentials.UI, ContentType = WindowsRuntime] +$availability = Invoke-WinRtAsync -Operation ($verifier::CheckAvailabilityAsync()) -ResultTypes @([Windows.Security.Credentials.UI.UserConsentVerifierAvailability]) + +if ('${mode}' -eq 'check') { + [Console]::Out.Write((@{ + available = ($availability.ToString() -eq 'Available') + status = $availability.ToString() + } | ConvertTo-Json -Compress)) + exit 0 +} + +if ($availability.ToString() -ne 'Available') { + [Console]::Out.Write((@{ + success = $false + code = 'Unavailable' + status = $availability.ToString() + } | ConvertTo-Json -Compress)) + exit 0 +} + +$result = Invoke-WinRtAsync -Operation ($verifier::RequestVerificationAsync('${escapedMessage}')) -ResultTypes @([Windows.Security.Credentials.UI.UserConsentVerificationResult]) + +[Console]::Out.Write((@{ + success = ($result.ToString() -eq 'Verified') + code = $result.ToString() + status = $availability.ToString() +} | ConvertTo-Json -Compress)) +`.trim(); +}; + +const getBiometricCapability = async () => { + if (process.platform === "darwin") { + const available = + typeof systemPreferences.canPromptTouchID === "function" && + systemPreferences.canPromptTouchID(); + return { + available, + provider: "Touch ID", + platform: process.platform, + status: available ? "Available" : "Unavailable", + }; + } + + if (process.platform === "win32") { + try { + const output = await runPowerShellScript(getWindowsHelloScript("check")); + const result = output ? JSON.parse(output) : {}; + return { + available: !!result.available, + provider: "Windows Hello", + platform: process.platform, + status: result.status || "Unavailable", + }; + } catch (error) { + return { + available: false, + provider: "Windows Hello", + platform: process.platform, + status: "Error", + error: error instanceof Error ? error.message : String(error), + }; + } + } + + return { + available: false, + provider: "Biometric", + platform: process.platform, + status: "Unsupported", + }; +}; + +const promptBiometricAuth = async (promptMessage = "Authenticate") => { + if (process.platform === "darwin") { + const available = + typeof systemPreferences.canPromptTouchID === "function" && + systemPreferences.canPromptTouchID(); + if (!available) { + return { + success: false, + code: "Unavailable", + provider: "Touch ID", + }; + } + + try { + await systemPreferences.promptTouchID(promptMessage); + return { + success: true, + code: "Verified", + provider: "Touch ID", + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + success: false, + code: /cancel/i.test(message) ? "Canceled" : "Failed", + provider: "Touch ID", + }; + } + } + + if (process.platform === "win32") { + try { + const output = await runPowerShellScript( + getWindowsHelloScript("verify", promptMessage) + ); + const result = output ? JSON.parse(output) : {}; + return { + success: !!result.success, + code: + result.code === "Unavailable" && result.status + ? result.status + : result.code || "Error", + provider: "Windows Hello", + }; + } catch (error) { + return { + success: false, + code: "Error", + provider: "Windows Hello", + }; + } + } + + return { + success: false, + code: "Unsupported", + provider: "Biometric", + }; +}; + // Discord Rich Presence setup let discordRPCClient = null; let discordRPCReady = false; @@ -935,6 +1130,12 @@ const createMainWin = () => { ipcMain.handle("get-store-value", async (event, config) => { return store.get(config.key); }); + ipcMain.handle("get-biometric-capability", async () => { + return await getBiometricCapability(); + }); + ipcMain.handle("prompt-biometric-auth", async (event, config) => { + return await promptBiometricAuth(config?.message); + }); ipcMain.handle("reset-reader-position", async (event) => { store.delete("windowX"); diff --git a/src/assets/locales/zh-CN.json b/src/assets/locales/zh-CN.json index 3c87cdd9..9425de4d 100644 --- a/src/assets/locales/zh-CN.json +++ b/src/assets/locales/zh-CN.json @@ -1033,6 +1033,7 @@ "PINs do not match, please try again": "PIN 不匹配,请重试", "Incorrect password": "密码错误", "Incorrect PIN": "PIN 错误", + "Biometric": "生物识别", "Select protection method": "选择保护方法", "Enter your current password": "输入您当前的密码", "Enter new password": "输入新密码", @@ -1046,6 +1047,15 @@ "Protection method": "保护方法", "Use a custom password to protect the app": "使用自定义密码保护应用程序", "Use a 6-digit PIN to protect the app": "使用 6 位数 PIN 保护应用程序", + "Use Touch ID or Windows Hello to protect the app": "使用 Touch ID 或 Windows Hello 保护应用程序", + "Use biometric authentication to unlock the app": "使用生物识别解锁应用程序", + "Verify with biometrics": "使用生物识别验证", + "Authenticating with biometrics...": "正在进行生物识别验证...", + "Biometric authentication failed, please try again": "生物识别验证失败,请重试", + "Biometric authentication is not available on this device": "此设备当前不可用生物识别验证", + "Authenticate to unlock the app": "验证以解锁应用", + "Authenticate to change protection settings": "验证以更改保护设置", + "Authenticate to enable biometric protection": "验证以启用生物识别保护", "No local dictionaries imported yet": "未导入任何本地词典", "Due to browser security restrictions, you may not be able to use this data source properly. If you encounter any issues, you can resolve them by installing our browser extension.": "由于浏览器的安全限制,您可能无法正常使用此数据源。如果您遇到任何问题,可以通过安装我们的浏览器扩展来解决。", "Install extension": "安装扩展", diff --git a/src/components/protection/component.tsx b/src/components/protection/component.tsx index a6ae55bb..f4c8d1ce 100644 --- a/src/components/protection/component.tsx +++ b/src/components/protection/component.tsx @@ -1,7 +1,12 @@ import React from "react"; import "./protection.css"; import { TokenService } from "../../assets/lib/kookit-extra-browser.min"; -import { verifyPassword, verifyPin } from "../../utils/protectionUtil"; +import { + getBiometricErrorMessage, + promptBiometricAuth, + verifyPassword, + verifyPin, +} from "../../utils/protectionUtil"; import { vexPasswordInputAsync } from "../../utils/common"; import toast from "react-hot-toast"; import i18n from "../../i18n"; @@ -12,6 +17,8 @@ interface ProtectionOverlayState { method: string; pinValue: string; pinError: boolean; + biometricError: string; + isAuthenticating: boolean; } class ProtectionOverlay extends React.Component<{}, ProtectionOverlayState> { @@ -24,6 +31,8 @@ class ProtectionOverlay extends React.Component<{}, ProtectionOverlayState> { method: "", pinValue: "", pinError: false, + biometricError: "", + isAuthenticating: false, }; } @@ -31,8 +40,10 @@ class ProtectionOverlay extends React.Component<{}, ProtectionOverlayState> { const method = (await TokenService.getToken("protection_method")) || ""; if (method) { this.setState({ isVisible: true, method }, () => { - if (method !== "pin") { + if (method === "password") { this.startPasswordAuth(); + } else if (method === "biometric") { + this.startBiometricAuth(); } }); } @@ -61,6 +72,27 @@ class ProtectionOverlay extends React.Component<{}, ProtectionOverlayState> { this.setState({ isVisible: false }); }; + startBiometricAuth = async () => { + if (this.state.isAuthenticating) return; + + this.setState({ + isAuthenticating: true, + biometricError: "", + }); + const result = await promptBiometricAuth( + i18n.t("Authenticate to unlock the app") + ); + if (result.success) { + this.setState({ isVisible: false, isAuthenticating: false }); + return; + } + + this.setState({ + isAuthenticating: false, + biometricError: getBiometricErrorMessage(result.code, i18n.t.bind(i18n)), + }); + }; + waitForPin = (): Promise => { return new Promise((resolve) => { this.pinResolve = resolve; @@ -91,7 +123,14 @@ class ProtectionOverlay extends React.Component<{}, ProtectionOverlayState> { }; render() { - const { isVisible, method, pinValue, pinError } = this.state; + const { + isVisible, + method, + pinValue, + pinError, + biometricError, + isAuthenticating, + } = this.state; if (!isVisible) return null; if (method === "pin") { @@ -169,6 +208,32 @@ class ProtectionOverlay extends React.Component<{}, ProtectionOverlayState> { ); } + if (method === "biometric") { + return ( +
+
+
+ {i18n.t("Use biometric authentication to unlock the app")} +
+ {biometricError && ( +
{biometricError}
+ )} + +
+
+ ); + } + return
; } } diff --git a/src/components/protection/protection.css b/src/components/protection/protection.css index 5328f9f0..d409011d 100644 --- a/src/components/protection/protection.css +++ b/src/components/protection/protection.css @@ -34,6 +34,17 @@ min-width: 280px; } +.biometric-auth-container { + background: var(--background-color-1, #fff); + border-radius: 16px; + padding: 32px 28px 24px; + display: flex; + flex-direction: column; + align-items: center; + min-width: 320px; + box-shadow: 0 18px 40px rgba(0, 0, 0, 0.08); +} + .pin-keypad-title { font-size: 16px; font-weight: 500; @@ -125,3 +136,34 @@ .pin-key-cancel:hover { opacity: 0.7; } + +.biometric-auth-button { + margin-top: 8px; + min-width: 220px; + min-height: 44px; + border: none; + border-radius: 999px; + background: var(--primary-color, rgba(75, 75, 75, 1)); + color: #fff; + font-size: 14px; + font-weight: 500; + cursor: pointer; + padding: 0 18px; + transition: + opacity 0.12s, + transform 0.12s; +} + +.biometric-auth-button:hover { + opacity: 0.9; +} + +.biometric-auth-button:active { + transform: scale(0.98); +} + +.biometric-auth-button:disabled { + opacity: 0.7; + cursor: not-allowed; + transform: none; +} diff --git a/src/containers/settings/moreSetting/component.tsx b/src/containers/settings/moreSetting/component.tsx index f73597f2..05b8f4c8 100644 --- a/src/containers/settings/moreSetting/component.tsx +++ b/src/containers/settings/moreSetting/component.tsx @@ -5,6 +5,10 @@ import toast from "react-hot-toast"; import { TokenService } from "../../../assets/lib/kookit-extra-browser.min"; import { clearProtection, + getBiometricCapability, + getBiometricErrorMessage, + promptBiometricAuth, + setProtectionBiometric, verifyPassword, verifyPin, setProtectionPassword, @@ -18,6 +22,7 @@ class MoreSetting extends React.Component { super(props); this.state = { protectionMethod: "", + biometricAvailable: false, isLoading: true, pinInputMode: "none", pinValue: "", @@ -27,8 +32,15 @@ class MoreSetting extends React.Component { } async componentDidMount() { - const method = (await TokenService.getToken("protection_method")) || ""; - this.setState({ protectionMethod: method, isLoading: false }); + const [method, biometricCapability] = await Promise.all([ + TokenService.getToken("protection_method"), + getBiometricCapability(), + ]); + this.setState({ + protectionMethod: method || "", + biometricAvailable: biometricCapability.available, + isLoading: false, + }); } showPinKeypad = (mode: "setup" | "verify"): Promise => { @@ -109,12 +121,20 @@ class MoreSetting extends React.Component { const ok = await verifyPin(pin as string); if (!ok) toast.error(this.props.t("Incorrect PIN")); return ok; + } else if (protectionMethod === "biometric") { + const result = await promptBiometricAuth( + i18n.t("Authenticate to change protection settings") + ); + if (!result.success) { + toast.error(getBiometricErrorMessage(result.code, this.props.t)); + } + return result.success; } return true; }; handleToggleProtection = async () => { - const { protectionMethod } = this.state; + const { protectionMethod, biometricAvailable } = this.state; if (protectionMethod) { const verified = await this.verifyCurrentMethod(); if (!verified) return; @@ -126,6 +146,9 @@ class MoreSetting extends React.Component { { value: "password", label: "Password" }, { value: "pin", label: "PIN" }, ]; + if (biometricAvailable) { + methodOptions.push({ value: "biometric", label: "Biometric" }); + } const method = await vexSelectAsync( "Select protection method", methodOptions @@ -162,6 +185,23 @@ class MoreSetting extends React.Component { await setProtectionPin(pin as string); this.setState({ protectionMethod: "pin" }); toast.success(this.props.t("Change successful")); + } else if (method === "biometric") { + if (!this.state.biometricAvailable) { + toast.error( + this.props.t("Biometric authentication is not available on this device") + ); + return; + } + const result = await promptBiometricAuth( + i18n.t("Authenticate to enable biometric protection") + ); + if (!result.success) { + toast.error(getBiometricErrorMessage(result.code, this.props.t)); + return; + } + await setProtectionBiometric(); + this.setState({ protectionMethod: "biometric" }); + toast.success(this.props.t("Change successful")); } }; @@ -248,8 +288,10 @@ class MoreSetting extends React.Component { } render() { - const { protectionMethod, isLoading } = this.state; + const { protectionMethod, biometricAvailable, isLoading } = this.state; const isEnabled = !!protectionMethod; + const showBiometricOption = + biometricAvailable || protectionMethod === "biometric"; if (isLoading) { return null; @@ -301,6 +343,9 @@ class MoreSetting extends React.Component { > + {showBiometricOption && ( + + )}

@@ -310,6 +355,9 @@ class MoreSetting extends React.Component { {protectionMethod === "pin" && ( Use a 6-digit PIN to protect the app )} + {protectionMethod === "biometric" && ( + Use Touch ID or Windows Hello to protect the app + )}

)} diff --git a/src/containers/settings/moreSetting/interface.tsx b/src/containers/settings/moreSetting/interface.tsx index 50e4473d..5b606bd7 100644 --- a/src/containers/settings/moreSetting/interface.tsx +++ b/src/containers/settings/moreSetting/interface.tsx @@ -6,6 +6,7 @@ export interface MoreSettingProps extends RouteComponentProps { export interface MoreSettingState { protectionMethod: string; + biometricAvailable: boolean; isLoading: boolean; pinInputMode: "none" | "setup-enter" | "setup-confirm" | "verify"; pinValue: string; diff --git a/src/utils/protectionUtil.ts b/src/utils/protectionUtil.ts index 5d6d0c3b..8e7dd821 100644 --- a/src/utils/protectionUtil.ts +++ b/src/utils/protectionUtil.ts @@ -2,6 +2,27 @@ import { TokenService } from "../assets/lib/kookit-extra-browser.min"; declare var window: any; +export interface BiometricCapability { + available: boolean; + provider: string; + platform: string; + status?: string; +} + +export interface BiometricAuthResult { + success: boolean; + code: string; + provider?: string; +} + +const isElectronRenderer = () => { + return ( + typeof window !== "undefined" && + typeof window.require === "function" && + !!window.require("electron")?.ipcRenderer + ); +}; + export async function sha256(text: string): Promise { const encoder = new TextEncoder(); const data = encoder.encode(text); @@ -28,6 +49,63 @@ export async function verifyPin(input: string): Promise { return hash === stored; } +export async function getBiometricCapability(): Promise { + if (!isElectronRenderer()) { + return { + available: false, + provider: "Biometric", + platform: "web", + status: "Unsupported", + }; + } + + return await window + .require("electron") + .ipcRenderer.invoke("get-biometric-capability"); +} + +export async function promptBiometricAuth( + message: string +): Promise { + if (!isElectronRenderer()) { + return { + success: false, + code: "Unsupported", + provider: "Biometric", + }; + } + + return await window.require("electron").ipcRenderer.invoke( + "prompt-biometric-auth", + { + message, + } + ); +} + +export function getBiometricErrorMessage( + code: string, + t: (title: string) => string +): string { + if (code === "Canceled" || code === "Cancelled") { + return t("Authentication required to access the app"); + } + + if ( + [ + "Unavailable", + "Unsupported", + "DeviceNotPresent", + "NotConfiguredForUser", + "DisabledByPolicy", + ].includes(code) + ) { + return t("Biometric authentication is not available on this device"); + } + + return t("Biometric authentication failed, please try again"); +} + export async function setProtectionPassword(password: string): Promise { const hash = await sha256(password); await TokenService.deleteToken("protection_pin"); @@ -42,6 +120,12 @@ export async function setProtectionPin(pin: string): Promise { await TokenService.setToken("protection_method", "pin"); } +export async function setProtectionBiometric(): Promise { + await TokenService.deleteToken("protection_password"); + await TokenService.deleteToken("protection_pin"); + await TokenService.setToken("protection_method", "biometric"); +} + export async function clearProtection(): Promise { await TokenService.deleteToken("protection_method"); await TokenService.deleteToken("protection_password");