feat: implement biometric authentication support with Windows Hello and Touch ID

This commit is contained in:
troyeguo
2026-05-15 08:35:42 +08:00
parent 37c0dc5488
commit 07aff91b66
7 changed files with 458 additions and 7 deletions

201
main.js
View File

@@ -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");

View File

@@ -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": "安装扩展",

View File

@@ -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<string> => {
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 (
<div className="protection-overlay">
<div className="biometric-auth-container">
<div className="pin-keypad-title">
{i18n.t("Use biometric authentication to unlock the app")}
</div>
{biometricError && (
<div className="pin-error-msg">{biometricError}</div>
)}
<button
className="biometric-auth-button"
onClick={this.startBiometricAuth}
disabled={isAuthenticating}
>
{isAuthenticating ? (
<Trans>Authenticating with biometrics...</Trans>
) : (
<Trans>Verify with biometrics</Trans>
)}
</button>
</div>
</div>
);
}
return <div className="protection-overlay" />;
}
}

View File

@@ -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;
}

View File

@@ -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<MoreSettingProps, MoreSettingState> {
super(props);
this.state = {
protectionMethod: "",
biometricAvailable: false,
isLoading: true,
pinInputMode: "none",
pinValue: "",
@@ -27,8 +32,15 @@ class MoreSetting extends React.Component<MoreSettingProps, MoreSettingState> {
}
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<string | false> => {
@@ -109,12 +121,20 @@ class MoreSetting extends React.Component<MoreSettingProps, MoreSettingState> {
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<MoreSettingProps, MoreSettingState> {
{ 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<MoreSettingProps, MoreSettingState> {
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<MoreSettingProps, MoreSettingState> {
}
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<MoreSettingProps, MoreSettingState> {
>
<option value="password">{this.props.t("Password")}</option>
<option value="pin">{this.props.t("PIN")}</option>
{showBiometricOption && (
<option value="biometric">{this.props.t("Biometric")}</option>
)}
</select>
</div>
<p className="setting-option-subtitle">
@@ -310,6 +355,9 @@ class MoreSetting extends React.Component<MoreSettingProps, MoreSettingState> {
{protectionMethod === "pin" && (
<Trans>Use a 6-digit PIN to protect the app</Trans>
)}
{protectionMethod === "biometric" && (
<Trans>Use Touch ID or Windows Hello to protect the app</Trans>
)}
</p>
</>
)}

View File

@@ -6,6 +6,7 @@ export interface MoreSettingProps extends RouteComponentProps<any> {
export interface MoreSettingState {
protectionMethod: string;
biometricAvailable: boolean;
isLoading: boolean;
pinInputMode: "none" | "setup-enter" | "setup-confirm" | "verify";
pinValue: string;

View File

@@ -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<string> {
const encoder = new TextEncoder();
const data = encoder.encode(text);
@@ -28,6 +49,63 @@ export async function verifyPin(input: string): Promise<boolean> {
return hash === stored;
}
export async function getBiometricCapability(): Promise<BiometricCapability> {
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<BiometricAuthResult> {
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<void> {
const hash = await sha256(password);
await TokenService.deleteToken("protection_pin");
@@ -42,6 +120,12 @@ export async function setProtectionPin(pin: string): Promise<void> {
await TokenService.setToken("protection_method", "pin");
}
export async function setProtectionBiometric(): Promise<void> {
await TokenService.deleteToken("protection_password");
await TokenService.deleteToken("protection_pin");
await TokenService.setToken("protection_method", "biometric");
}
export async function clearProtection(): Promise<void> {
await TokenService.deleteToken("protection_method");
await TokenService.deleteToken("protection_password");