feat: 优化Microsoft Authenticator数据导入

This commit is contained in:
nap0o committed 2026-03-05 01:36:05 +08:00
1 parent 733972a5ad
commit 0dc02271e3
18 files changed
+667 -103

No files matched your search

+1 -1
View File
@@ -20,4 +20,4 @@
"overrides": {
"esbuild": ">=0.25.0"
}
}
}
+1
View File
@@ -20,6 +20,7 @@ export const CSP_POLICY = {
"'self'",
"'unsafe-inline'",
"'unsafe-eval'",
"'wasm-unsafe-eval'",
"https://static.cloudflareinsights.com",
],
// 图片源: 允许本站、GitHub 头像、NodeLoc 头像
+3
View File
@@ -3,6 +3,9 @@
"name": "2fauth-worker-demo",
"main": "src/app/index.ts",
"compatibility_date": "2024-04-01",
"compatibility_flags": [
"nodejs_compat"
],
// ====== 静态资源托管 ======
"assets": {
"binding": "ASSETS",
+3
View File
@@ -3,6 +3,9 @@
"name": "2fauth-worker-dev",
"main": "src/app/index.ts",
"compatibility_date": "2024-04-01",
"compatibility_flags": [
"nodejs_compat"
],
// ====== 静态资源托管 ======
"assets": {
"binding": "ASSETS",
+3
View File
@@ -3,6 +3,9 @@
"name": "2fauth-worker",
"main": "src/app/index.ts",
"compatibility_date": "2024-04-01",
"compatibility_flags": [
"nodejs_compat"
],
// ====== 静态资源托管 ======
"assets": {
"binding": "ASSETS",
+2 -1
View File
@@ -20,7 +20,8 @@
"scrypt-js": "^3.0.1",
"sql.js": "^1.10.3",
"vue": "^3.5.25",
"vue-router": "^5.0.3"
"vue-router": "^5.0.3",
"wa-sqlite": "^1.0.0"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
+22 -14
View File
@@ -209,10 +209,11 @@ main {
}
.main-content {
padding: 30px 20px;
width: 100%;
background-color: var(--el-bg-color-page);
overflow-y: auto;
/* 原为 auto,修改为保留滚动空间防止因高度变化产生的抖动,或直接将滚动条悬浮覆盖 */
overflow-y: overlay;
scrollbar-gutter: stable;
}
.sidebar-footer {
@@ -428,6 +429,14 @@ main {
letter-spacing: 2px;
line-height: 1;
font-family: monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: clip;
}
.current-code[data-digits="8"] {
font-size: 20px;
letter-spacing: 0px;
}
.next-code {
@@ -436,6 +445,11 @@ main {
font-family: monospace;
}
.next-code[data-digits="8"] {
font-size: 14px;
letter-spacing: 0px;
}
.timer-text {
font-size: 12px;
color: var(--el-text-color-regular);
@@ -674,16 +688,6 @@ main {
align-items: center;
}
.icon-wrapper {
width: 50px;
height: 50px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 15px;
}
.text-info {
flex: 1;
}
@@ -1065,7 +1069,6 @@ main {
}
.icon-wrapper {
margin-bottom: 20px;
background: var(--el-color-primary-light-9);
width: 80px;
height: 80px;
@@ -1073,7 +1076,7 @@ main {
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 20px;
margin-right: 15px;
}
.title {
@@ -1168,8 +1171,13 @@ main {
}
@media (max-width: 768px) {
/* 修复 el-dialog 在移动端固定宽溢出 */
.el-dialog {
width: 90% !important;
}
.tab-card-wrapper {
padding: 20px;
}
}
@@ -59,6 +59,38 @@ export function useDataImport(emitFn) {
batchAccumulatedVault.value = []
batchErrors.value = []
batchCurrentTaskName.value = ''
phoneFactorGroup.value = { main: null, wal: null, shm: null }
}
// PhoneFactor specialized grouping
const phoneFactorGroup = ref({ main: null, wal: null, shm: null })
let phoneFactorTimer = null
const processPhoneFactorGroup = async () => {
const group = { ...phoneFactorGroup.value }
phoneFactorGroup.value = { main: null, wal: null, shm: null } // reset
let groupedCount = 0;
if (group.main) groupedCount++;
if (group.wal) groupedCount++;
if (group.shm) groupedCount++;
// We only proceed if we have at least *something*
if (groupedCount === 0) return
batchCurrentTaskName.value = '构建数据:PhoneFactor 数据集'
try {
// we pass the entire group object to the service
const vault = await dataMigrationService.parseImportData(group, 'phonefactor_group')
batchAccumulatedVault.value.push(...vault)
} catch (err) {
console.error('[PhoneFactor Group] parseImportData failed:', err)
const detail = err && err.message ? err.message : String(err)
batchErrors.value.push(`[PhoneFactor] 解析失败: ${detail}`)
} finally {
batchProcessedJobs.value += groupedCount
importingJobs.value -= groupedCount
}
}
const handleFileUpload = async (uploadFile) => {
@@ -96,6 +128,31 @@ export function useDataImport(emitFn) {
const handleNonImageFile = (file) => {
try {
// First pass check for PhoneFactor by filename, buffer them
const fname = file.name.toLowerCase()
if (fname === 'phonefactor' || fname === 'phonefactor-wal' || fname === 'phonefactor-shm') {
const reader = new FileReader()
reader.onload = (event) => {
const buffer = event.target.result
if (fname === 'phonefactor') phoneFactorGroup.value.main = { name: file.name, buffer }
else if (fname === 'phonefactor-wal') phoneFactorGroup.value.wal = { name: file.name, buffer }
else if (fname === 'phonefactor-shm') phoneFactorGroup.value.shm = { name: file.name, buffer }
// Debounce group execution
if (phoneFactorTimer) clearTimeout(phoneFactorTimer)
phoneFactorTimer = setTimeout(() => {
processPhoneFactorGroup()
}, 500)
}
reader.onerror = () => {
batchErrors.value.push(`[${file.name}] 读取失败`)
batchProcessedJobs.value++
importingJobs.value--
}
reader.readAsArrayBuffer(file)
return
}
currentFile.value = file
const reader = new FileReader()
@@ -114,7 +171,6 @@ export function useDataImport(emitFn) {
return
}
// 如果不是 phonefactor,后续解析需要字符串内容
let contentForParse = buffer
if (detectedType !== 'phonefactor') {
try {
@@ -8,7 +8,11 @@ import { aegisStrategy } from '@/shared/utils/serializers/aegisStrategy'
import protonStrategy from '@/shared/utils/serializers/protonStrategy'
import { migrationError } from '@/shared/utils/errors/migrationError'
import jsQR from 'jsqr'
import initSqlJs from 'sql.js'
import SQLiteESMFactory from 'wa-sqlite/dist/wa-sqlite-async.mjs'
import * as SQLite from 'wa-sqlite'
import { MemoryAsyncVFS } from '@/shared/utils/sqlite/MemoryAsyncVFS'
// 2FAS 加密备份的固定参数(官方格式标准)
const _2FAS_CRYPTO_CONFIG = {
@@ -503,12 +507,16 @@ export const dataMigrationService = {
const allBufs = bufs.slice()
const permutations = [
{ salt: bufs[1], iv: bufs[2], cipher: bufs[0], name: 'bufs[1]=salt, bufs[2]=iv, bufs[0]=cipher' },
{ salt: bufs.length > 1 && bufs[1].length >= 44 ? bufs[1].slice(0, 32) : null,
iv: bufs.length > 1 && bufs[1].length >= 44 ? bufs[1].slice(32, 44) : null,
cipher: bufs[0],
name: 'salt/iv extracted from bufs[1]' },
{ salt: bufs[0].slice(0, 32), iv: bufs[0].slice(32, 44), cipher: bufs[0].slice(44),
name: 'salt/iv extracted from bufs[0]' }
{
salt: bufs.length > 1 && bufs[1].length >= 44 ? bufs[1].slice(0, 32) : null,
iv: bufs.length > 1 && bufs[1].length >= 44 ? bufs[1].slice(32, 44) : null,
cipher: bufs[0],
name: 'salt/iv extracted from bufs[1]'
},
{
salt: bufs[0].slice(0, 32), iv: bufs[0].slice(32, 44), cipher: bufs[0].slice(44),
name: 'salt/iv extracted from bufs[0]'
}
]
for (const perm of permutations) {
@@ -621,95 +629,284 @@ export const dataMigrationService = {
},
/**
* 3.0 解析PhoneFactor SQLite数据库
* @param {ArrayBuffer} dbBuffer - SQLite数据库二进制数据
* 辅助函数:将字节数组编码为 Base32 字符串
* @param {Uint8Array} bytes
* @returns {string}
*/
bytesToBase32(bytes) {
const base32Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
let bits = 0
let value = 0
let output = ''
for (let i = 0; i < bytes.length; i++) {
value = (value << 8) | bytes[i]
bits += 8
while (bits >= 5) {
bits -= 5
output += base32Chars[(value >>> bits) & 31]
}
}
if (bits > 0) {
output += base32Chars[(value << (5 - bits)) & 31]
}
// 添加 padding
while (output.length % 8) {
output += '='
}
return output
},
/**
* 辅助函数:将 Base64 字符串转换为 Base32
* @param {string} base64Str
* @returns {string}
*/
base64ToBase32(base64Str) {
try {
// 解码 Base64 为字节数组
const binaryString = atob(base64Str.trim())
const bytes = new Uint8Array(binaryString.length)
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i)
}
// 转换为 Base32
return this.bytesToBase32(bytes)
} catch (e) {
throw new Error(`Base64 转 Base32 失败: ${e.message}`)
}
},
/**
* 3.0 解析PhoneFactor SQLite数据库 (通过 wa-sqlite 支持 WAL 和多文件)
* @param {ArrayBuffer|Object} inputData - SQLite数据库二进制数据(为旧版兼容) 或者 包含 main/wal/shm 的 group 对象
* @returns {Promise<Object[]>} 标准化的账户列表
* @throws {migrationError}
*/
async parsePhoneFactor(dbBuffer) {
async parsePhoneFactor(inputData) {
console.log('[PhoneFactor] parsePhoneFactor 开始')
let mainBuf = null
let walBuf = null
let shmBuf = null
let vfs = null
try {
// 初始化 sql.js:浏览器端优先从 /sql-wasm.wasm 或 CDN 加载 wasm,Node 环境从 node_modules 加载
let SQL
try {
if (typeof window !== 'undefined') {
try {
SQL = await initSqlJs({ locateFile: file => `/sql-wasm.wasm` })
} catch (e) {
// 如果本地 public 中不存在 wasm,回退到 CDN
SQL = await initSqlJs({ locateFile: file => `https://unpkg.com/sql.js@1.10.3/dist/${file}` })
}
} else {
SQL = await initSqlJs({ locateFile: file => require('path').resolve('node_modules/sql.js/dist/', file) })
}
} catch (e) {
// 最后兜底,直接初始化(可能仍然抛错)
SQL = await initSqlJs()
// Group 提取
if (inputData && inputData.main && inputData.main.buffer) {
mainBuf = new Uint8Array(inputData.main.buffer)
if (inputData.wal && inputData.wal.buffer) walBuf = new Uint8Array(inputData.wal.buffer)
if (inputData.shm && inputData.shm.buffer) shmBuf = new Uint8Array(inputData.shm.buffer)
} else if (inputData instanceof ArrayBuffer || inputData instanceof Uint8Array) {
// 回退:单文件模式
mainBuf = new Uint8Array(inputData)
} else {
throw new migrationError('无法识别传入的 PhoneFactor 数据格式', 'INVALID_PHONEFACTOR_INPUT')
}
const db = new SQL.Database(new Uint8Array(dbBuffer))
console.log('[PhoneFactor] 文件提取完成', {
mainBufLen: mainBuf?.byteLength,
walBufLen: walBuf?.byteLength ?? 0,
shmBufLen: shmBuf?.byteLength ?? 0
})
// 初始化 wa-sqlite
console.log('[PhoneFactor] 调用 SQLiteESMFactory...')
const module = await SQLiteESMFactory()
console.log('[PhoneFactor] SQLiteESMFactory 完成,创建 sqlite3...')
const sqlite3 = SQLite.Factory(module)
console.log('[PhoneFactor] sqlite3 Factory 完成')
// 注册内存虚拟文件系统 (MemoryAsyncVFS)
// wa-sqlite-async build 必须配合 Async VFS 才能正确处理 Asyncify 异步调用
vfs = new MemoryAsyncVFS()
const vfsName = `vfs-${Date.now()}`
vfs.name = vfsName
sqlite3.vfs_register(vfs)
console.log('[PhoneFactor] VFS 注册完成:', vfsName)
// 将文件加载入内存文件系统
const mainPath = 'PhoneFactor'
// Helper to write file to MemoryVFS
const writeFileToVFS = (path, uint8Array) => {
// 使用 slice 确保我们获取的是一个干净的 ArrayBuffer,
// 解决可能存在的 TypedArray view 偏移量问题导致 SQLite 无法识别文件头。
const ab = uint8Array.buffer.slice(uint8Array.byteOffset, uint8Array.byteOffset + uint8Array.byteLength)
vfs.mapNameToFile.set(path, {
name: path,
flags: SQLite.SQLITE_OPEN_CREATE | SQLite.SQLITE_OPEN_READWRITE | SQLite.SQLITE_OPEN_MAIN_DB,
size: uint8Array.byteLength,
data: ab
})
}
writeFileToVFS(mainPath, mainBuf)
// 注入 WAL(如果有真实内容)
const hasRealWal = walBuf && walBuf.byteLength > 0
if (hasRealWal) writeFileToVFS(`${mainPath}-wal`, walBuf)
// ⚠️ 关键:始终注入 SHM(如果存在),不管 WAL 是否为空。
// PhoneFactor 主文件本身可能已处于 WAL 模式(header byte 18 = 2),
// SQLite 初始化时必须能找到 SHM 文件,否则报 SQLITE_CANTOPEN (14)。
if (shmBuf && shmBuf.byteLength > 0) writeFileToVFS(`${mainPath}-shm`, shmBuf)
console.log('[PhoneFactor] 注入文件到 VFS 完成', {
main: mainPath,
hasWal: hasRealWal,
hasShm: !!(shmBuf && shmBuf.byteLength > 0)
})
console.log('[PhoneFactor] 调用 open_v2...')
const db = await sqlite3.open_v2(
mainPath,
SQLite.SQLITE_OPEN_READWRITE | SQLite.SQLITE_OPEN_CREATE,
vfsName
)
console.log('[PhoneFactor] open_v2 成功')
// ⚠️ 关键步骤:处理 WAL 模式(无论 WAL 文件是否为空)
// PhoneFactor 数据库可能已处于 WAL 模式(header byte 18 = 2)。
// 即使 WAL 文件为空(无未提交变更),也必须执行以下两条 PRAGMA 来:
// 1. 获取 EXCLUSIVE 锁,使 SQLite 能在只有 SHM 的情况下正确初始化
// 2. 将 journal 模式切换回 DELETE,触发 checkpoint 并确保数据可读
// 注:对于非 WAL 模式数据库,这两条 PRAGMA 是无害的空操作
console.log('[PhoneFactor] 执行 PRAGMA locking_mode...')
await sqlite3.exec(db, 'PRAGMA locking_mode = EXCLUSIVE;')
console.log('[PhoneFactor] 执行 PRAGMA journal_mode...')
await sqlite3.exec(db, 'PRAGMA journal_mode = DELETE;')
console.log('[PhoneFactor] PRAGMA 完成')
// 检查accounts表是否存在
const tables = db.exec("SELECT name FROM sqlite_master WHERE type='table' AND name='accounts'")
if (tables.length === 0) {
let hasAccountsTable = false
await sqlite3.exec(db, "SELECT name FROM sqlite_master WHERE type='table' AND name='accounts'", (row) => {
hasAccountsTable = true
})
console.log('[PhoneFactor] accounts 表存在:', hasAccountsTable)
if (!hasAccountsTable) {
await sqlite3.close(db)
throw new migrationError('不是有效的Microsoft Authenticator数据文件', 'INVALID_PHONEFACTOR_FILE')
}
// 使用 prepared statement 逐行读取,避免依赖 exec 返回的 columns/values 结构不一致
const vault = []
let skippedEncrypted = 0
let skippedEmpty = 0
let skippedInvalidSecret = 0
const base32Re = /^[A-Z2-7]+=*$/i
try {
const stmt = db.prepare("SELECT name, username, oath_secret_key, encrypted_oath_secret_key FROM accounts WHERE account_type=0")
while (stmt.step()) {
const record = stmt.getAsObject()
const name = record.name
const username = record.username
const oath_secret_key = record.oath_secret_key
const encrypted_oath_secret_key = record.encrypted_oath_secret_key
console.log('[PhoneFactor] 开始提取基础数据...')
// 跳过条件:name 和 username 同时为空
if ((!name || String(name).trim() === '') && (!username || String(username).trim() === '')) {
skippedEmpty++
continue
}
// 不使用 for await (const stmt of ...),规避 Asyncify Generator 的协程挂起 bug
const str = sqlite3.str_new(db, "SELECT name, username, oath_secret_key, encrypted_oath_secret_key, account_type FROM accounts")
let prepared = await sqlite3.prepare_v2(db, sqlite3.str_value(str))
const secret = (oath_secret_key || '').toString().trim()
if (!secret) {
if (encrypted_oath_secret_key && String(encrypted_oath_secret_key).trim() !== '') {
skippedEncrypted++
continue
if (prepared && prepared.stmt) {
const stmt = prepared.stmt
console.log('[PhoneFactor] SQL 预编译成功,开始读取行...')
try {
let rowsRead = 0;
while (await sqlite3.step(stmt) === SQLite.SQLITE_ROW) {
rowsRead++;
if (rowsRead % 5 === 0) console.log(`[PhoneFactor] 已读取 ${rowsRead} 行...`);
const row = sqlite3.row(stmt)
const name = row[0]
const username = row[1]
let oath_secret_key = row[2]
const encrypted_oath_secret_key = row[3]
const account_type = row[4]
// 跳过条件:name 和 username 同时为空
if ((!name || String(name).trim() === '') && (!username || String(username).trim() === '')) {
skippedEmpty++
continue
}
let secret = (oath_secret_key || '').toString().trim()
if (!secret) {
if (encrypted_oath_secret_key && String(encrypted_oath_secret_key).trim() !== '') {
skippedEncrypted++
continue
}
skippedEmpty++
continue
}
let algorithm = 'SHA1'
let digits = 6
// 根据 account_type 处理密钥
try {
if (account_type === 0) {
// account_type=0: Base32 编码
} else if (account_type === 1) {
// account_type=1: Base64 编码,转换为 Base32
secret = this.base64ToBase32(secret)
algorithm = 'SHA1'
digits = 8
} else if (account_type === 2) {
// account_type=2: Base32 编码(小写),转大写
secret = secret.toUpperCase()
algorithm = 'SHA256'
digits = 6
} else {
skippedInvalidSecret++
continue
}
} catch (e) {
console.warn(`Failed to convert secret for account_type=${account_type}:`, e.message)
skippedInvalidSecret++
continue
}
// 验证转换后的密钥是否为有效的 Base32 格式
const normalized = secret.replace(/\s+/g, '').replace(/=+$/, '')
if (!base32Re.test(normalized)) {
if (encrypted_oath_secret_key && String(encrypted_oath_secret_key).trim() !== '') {
skippedEncrypted++
continue
}
skippedInvalidSecret++
continue
}
vault.push({
service: name || 'Unknown Service',
account: username || 'Unknown Account',
secret: secret,
algorithm: algorithm,
digits: digits,
period: 30
})
}
skippedEmpty++
continue
console.log(`[PhoneFactor] 行读取循环结束,总共提取 ${rowsRead} 行`);
} finally {
await sqlite3.finalize(stmt)
}
const normalized = secret.replace(/\s+/g, '').replace(/=+$/, '')
if (!base32Re.test(normalized)) {
if (encrypted_oath_secret_key && String(encrypted_oath_secret_key).trim() !== '') {
skippedEncrypted++
continue
}
skippedEmpty++
continue
}
vault.push({
service: name || 'Unknown Service',
account: username || 'Unknown Account',
secret: secret,
algorithm: 'SHA1',
digits: 6,
period: 30
})
}
stmt.free()
sqlite3.str_finish(str)
console.log('[PhoneFactor] 数据提取完成,关闭 DB...')
} catch (e) {
db.close()
await sqlite3.close(db)
throw new migrationError('解析 PhoneFactor 数据库失败', 'INVALID_PHONEFACTOR_FILE', e)
}
db.close()
await sqlite3.close(db)
// Clean up memory VFS (by clearing the maps, safer than calling xDelete from JS)
try {
if (vfs) {
vfs.mapNameToFile.clear()
vfs.mapIdToFile.clear()
}
} catch (ignore) { }
if (vault.length === 0) {
if (skippedEncrypted > 0) throw new migrationError('PhoneFactor 文件仅包含加密的密钥,无法在前端导入', 'PHONEFACTOR_ONLY_ENCRYPTED')
throw new migrationError('未能从 PhoneFactor 文件中提取到可导入的 TOTP 记录', 'PHONEFACTOR_NO_IMPORTABLE_ROWS')
@@ -721,10 +918,15 @@ export const dataMigrationService = {
console.error('parsePhoneFactor migrationError:', error)
throw error
}
// 打印详细调试信息到控制台,包含底层错误与缓冲长度
// 打印详细调试信息到控制台
try {
const bufLen = (dbBuffer && (dbBuffer.byteLength || dbBuffer.length)) || 0
console.error('parsePhoneFactor failed:', { message: error && error.message, stack: error && error.stack, bufferLength: bufLen, error })
const bufLen = (mainBuf && (mainBuf.byteLength || mainBuf.length)) || 0
console.error('parsePhoneFactor failed:', {
message: error && error.message,
stack: error && error.stack,
bufferLength: bufLen,
error
})
} catch (logErr) {
console.error('parsePhoneFactor failed (logging error):', logErr)
}
@@ -744,8 +946,8 @@ export const dataMigrationService = {
async parseImportData(content, type, password) {
let rawVault = []
// 处理PhoneFactor SQLite数据库
if (type === 'phonefactor') {
// 处理PhoneFactor SQLite数据库 (包括新版的 phonefactor_group)
if (type === 'phonefactor' || type === 'phonefactor_group') {
return await this.parsePhoneFactor(content)
}
@@ -233,7 +233,7 @@ const isIndeterminate = computed(() => {
<style scoped>
.export-groups {
max-width: 600px;
max-width: 100%;
margin: 0 auto;
display: flex;
flex-direction: column;
@@ -6,7 +6,7 @@
<p style="color: var(--el-text-color-secondary);">支持从各类 2FA 软件或本系统的备份文件中恢复数据。</p>
</div>
<div style="max-width: 600px; margin: 0 auto;">
<div style="max-width: 100%; margin: 0 auto;">
<!-- 统一的拖拽上传区域 -->
<el-upload
@@ -51,7 +51,11 @@
</div>
<div class="ms-tip">
<span>Microsoft Authenticator <small>(仅支持 Android)</small></span>
<p>Android 手机导出文件 <code>/data/data/com.azure.authenticator/databases/PhoneFactor</code> 后,直接上传</p>
<p>Android 手机导出以下文件后,同时上传即可导入:
<code>/data/data/com.azure.authenticator/databases/PhoneFactor</code><br />
<code>/data/data/com.azure.authenticator/databases/PhoneFactor-wal</code><br />
<code>/data/data/com.azure.authenticator/databases/PhoneFactor-shm</code>
</p>
</div>
</div>
@@ -2,7 +2,7 @@
<div class="add-vault-wrapper">
<div class="tab-card-wrapper">
<h2 style="text-align: center; margin-bottom: 20px;">⌨️ 手动输入</h2>
<div style="max-width: 600px; margin: 0 auto;">
<div style="max-width: 100%; margin: 0 auto;">
<el-form :model="newVault" label-position="top" :rules="rules" ref="addFormRef" style="padding: 10px 0;">
<el-form-item label="服务名称 (如 Google, GitHub)" prop="service">
<el-input v-model="newVault.service" placeholder="请输入服务名称" />
@@ -2,7 +2,7 @@
<div class="add-vault-wrapper">
<div class="tab-card-wrapper">
<h2 style="text-align: center; margin-bottom: 20px;">📷 扫码添加</h2>
<div style="max-width: 600px; margin: 0 auto;">
<div style="max-width: 100%; margin: 0 auto;">
<div style="text-align: center; margin-bottom: 20px; margin-top: 10px;">
<p style="color: var(--el-text-color-secondary);">请允许浏览器使用摄像头,或直接上传微信/系统截图。</p>
</div>
@@ -57,6 +57,7 @@
direction="ltr"
size="240px"
:with-header="false"
:lock-scroll="false"
>
<div class="drawer-content">
<el-menu
@@ -52,7 +52,7 @@
>
<el-row :gutter="20" v-if="vault.length > 0">
<el-col :xs="24" :sm="12" :md="8" :lg="6" v-for="vaultItem in vault" :key="vaultItem.id" style="margin-bottom: 20px;">
<el-card class="vault-card" :class="{ 'is-selected': selectedIds.includes(vaultItem.id) }" shadow="hover" @click="copyCode(vaultItem)">
<el-card class="vault-card" :class="{ 'is-selected': selectedIds.includes(vaultItem.id) }" shadow="hover">
<div class="card-header">
<div class="service-info">
<el-checkbox :model-value="selectedIds.includes(vaultItem.id)" @change="toggleSelection(vaultItem.id)" @click.stop />
@@ -80,10 +80,10 @@
<p class="vault-name">{{ vaultItem.account?.includes(':') ? vaultItem.account.split(':').pop() : vaultItem.account }}</p>
<div class="code-display-area">
<div class="code-display-area" @click="copyCode(vaultItem)">
<div class="code-left">
<div class="current-code">{{ vaultItem.currentCode || '------' }}</div>
<div class="next-code" v-if="vaultItem.nextCode">
<div class="current-code" :data-digits="vaultItem.digits">{{ vaultItem.currentCode || '------' }}</div>
<div class="next-code" :data-digits="vaultItem.digits" v-if="vaultItem.nextCode">
{{ vaultItem.nextCode }}
</div>
</div>
@@ -69,6 +69,22 @@ export function useVaultList(afterLoadRef = null) {
const merged = flatVault.map(newAcc => {
const existing = vault.value.find(a => a.id === newAcc.id)
if (existing) {
// 检测关键参数变化:digits, secret, algorithm
const hasParameterChange =
existing.digits !== newAcc.digits ||
existing.secret !== newAcc.secret ||
existing.algorithm !== newAcc.algorithm
// 若参数变化,强制清空验证码以触发重新计算
if (hasParameterChange) {
return {
...existing,
...newAcc,
currentCode: '------', // 强制重新计算
forceCompute: true // 标记为需要优先计算
}
}
return { ...existing, ...newAcc } // 保留 currentCode / remaining 等动态字段
}
return {
@@ -82,7 +98,7 @@ export function useVaultList(afterLoadRef = null) {
})
// 两级渲染策略,兼顾「无 ------ 闪烁」与「切换回来秒开」:
// 1. 找出真正的新增项(已有项 currentCode 有值,直接复用,无需重算)
// 1. 找出真正的新增项或参数变化项(已有项 currentCode 有值,直接复用,无需重算)
const newItems = merged.filter(acc => !acc.currentCode || acc.currentCode === '------')
if (newItems.length > 0) {
@@ -0,0 +1,97 @@
// Copyright 2021 Roy T. Hashimoto. All Rights Reserved.
// Copied from wa-sqlite examples and adapted for project use.
import { MemoryVFS } from './MemoryVFS.js';
// Asynchronous memory filesystem. This filesystem requires an Asyncify
// build. It is mainly useful for testing that the Asyncify build is
// working.
export class MemoryAsyncVFS extends MemoryVFS {
name = 'memory-async';
constructor() {
super();
}
async close() {
for (const fileId of this.mapIdToFile.keys()) {
await this.xClose(fileId);
}
}
/**
* @param {string?} name
* @param {number} fileId
* @param {number} flags
* @param {DataView} pOutFlags
* @returns {number}
*/
xOpen(name, fileId, flags, pOutFlags) {
return this.handleAsync(async () => super.xOpen(name, fileId, flags, pOutFlags));
}
/**
* @param {number} fileId
* @returns {number}
*/
xClose(fileId) {
return this.handleAsync(async () => super.xClose(fileId));
}
/**
* @param {number} fileId
* @param {Uint8Array} pData
* @param {number} iOffset
* @returns {number}
*/
xRead(fileId, pData, iOffset) {
return this.handleAsync(async () => super.xRead(fileId, pData, iOffset));
}
/**
* @param {number} fileId
* @param {Uint8Array} pData
* @param {number} iOffset
* @returns {number}
*/
xWrite(fileId, pData, iOffset) {
return this.handleAsync(async () => super.xWrite(fileId, pData, iOffset));
}
/**
* @param {number} fileId
* @param {number} iSize
* @returns {number}
*/
xTruncate(fileId, iSize) {
return this.handleAsync(async () => super.xTruncate(fileId, iSize));
}
/**
* @param {number} fileId
* @param {DataView} pSize64
* @returns {number}
*/
xFileSize(fileId, pSize64) {
return this.handleAsync(async () => super.xFileSize(fileId, pSize64));
}
/**
*
* @param {string} name
* @param {number} syncDir
* @returns {number}
*/
xDelete(name, syncDir) {
return this.handleAsync(async () => super.xDelete(name, syncDir));
}
/**
* @param {string} name
* @param {number} flags
* @param {DataView} pResOut
* @returns {number}
*/
xAccess(name, flags, pResOut) {
return this.handleAsync(async () => super.xAccess(name, flags, pResOut));
}
}
@@ -0,0 +1,169 @@
// Copyright 2021 Roy T. Hashimoto. All Rights Reserved.
// Copied from wa-sqlite examples and adapted for project use.
import * as VFS from 'wa-sqlite/src/VFS.js';
// Memory filesystem. Although this is mainly provided as an example
// for new VFS classes, it seems to be faster than the default filesystem.
export class MemoryVFS extends VFS.Base {
name = 'memory';
// Map of existing files, keyed by filename.
mapNameToFile = new Map();
// Map of open files, keyed by id (sqlite3_file pointer).
mapIdToFile = new Map();
constructor() {
super();
}
close() {
for (const fileId of this.mapIdToFile.keys()) {
this.xClose(fileId);
}
}
/**
* @param {string?} name
* @param {number} fileId
* @param {number} flags
* @param {DataView} pOutFlags
* @returns {number}
*/
xOpen(name, fileId, flags, pOutFlags) {
// Generate a random name if requested.
name = name || Math.floor(Math.random() * Number.MAX_SAFE_INTEGER).toString(36);
let file = this.mapNameToFile.get(name);
if (!file) {
if (flags & VFS.SQLITE_OPEN_CREATE) {
// Create a new file object.
file = {
name,
flags,
size: 0,
data: new ArrayBuffer(0)
};
this.mapNameToFile.set(name, file);
} else {
return VFS.SQLITE_CANTOPEN;
}
}
// Put the file in the opened files map.
this.mapIdToFile.set(fileId, file);
pOutFlags.setInt32(0, flags, true);
return VFS.SQLITE_OK;
}
/**
* @param {number} fileId
* @returns {number}
*/
xClose(fileId) {
const file = this.mapIdToFile.get(fileId);
if (file) {
this.mapIdToFile.delete(fileId);
if (file.flags & VFS.SQLITE_OPEN_DELETEONCLOSE) {
this.mapNameToFile.delete(file.name);
}
}
return VFS.SQLITE_OK;
}
/**
* @param {number} fileId
* @param {Uint8Array} pData
* @param {number} iOffset
* @returns {number}
*/
xRead(fileId, pData, iOffset) {
const file = this.mapIdToFile.get(fileId);
// Clip the requested read to the file boundary.
const bgn = Math.min(iOffset, file.size);
const end = Math.min(iOffset + pData.byteLength, file.size);
const nBytes = end - bgn;
if (nBytes) {
pData.set(new Uint8Array(file.data, bgn, nBytes));
}
if (nBytes < pData.byteLength) {
// Zero unused area of read buffer.
pData.fill(0, nBytes);
return VFS.SQLITE_IOERR_SHORT_READ;
}
return VFS.SQLITE_OK;
}
/**
* @param {number} fileId
* @param {Uint8Array} pData
* @param {number} iOffset
* @returns {number}
*/
xWrite(fileId, pData, iOffset) {
const file = this.mapIdToFile.get(fileId);
if (iOffset + pData.byteLength > file.data.byteLength) {
// Resize the ArrayBuffer to hold more data.
const newSize = Math.max(iOffset + pData.byteLength, 2 * file.data.byteLength);
const data = new ArrayBuffer(newSize);
new Uint8Array(data).set(new Uint8Array(file.data, 0, file.size));
file.data = data;
}
// Copy data.
new Uint8Array(file.data, iOffset, pData.byteLength).set(pData);
file.size = Math.max(file.size, iOffset + pData.byteLength);
return VFS.SQLITE_OK;
}
/**
* @param {number} fileId
* @param {number} iSize
* @returns {number}
*/
xTruncate(fileId, iSize) {
const file = this.mapIdToFile.get(fileId);
// For simplicity we don't make the ArrayBuffer smaller.
file.size = Math.min(file.size, iSize);
return VFS.SQLITE_OK;
}
/**
* @param {number} fileId
* @param {DataView} pSize64
* @returns {number}
*/
xFileSize(fileId, pSize64) {
const file = this.mapIdToFile.get(fileId);
pSize64.setBigInt64(0, BigInt(file.size), true);
return VFS.SQLITE_OK;
}
/**
*
* @param {string} name
* @param {number} syncDir
* @returns {number}
*/
xDelete(name, syncDir) {
this.mapNameToFile.delete(name);
return VFS.SQLITE_OK;
}
/**
* @param {string} name
* @param {number} flags
* @param {DataView} pResOut
* @returns {number}
*/
xAccess(name, flags, pResOut) {
const file = this.mapNameToFile.get(name);
pResOut.setInt32(0, file ? 1 : 0, true);
return VFS.SQLITE_OK;
}
}