feat(browser): 导入热应用——Cookie 解密注入 + 内核热重启,不再需要重启应用

This commit is contained in:
Rick Ye
2026-09-11 19:00:58 +08:00
parent d0b97f16b2
commit 09cc5bf648
2 changed files with 302 additions and 78 deletions
@@ -392,6 +392,34 @@ export class BrowserController {
return this.settings.imported ?? null return this.settings.imported ?? null
} }
/** 内核级热重启(导入热应用用):销毁全部标签、清空分区存储后端
* (Chromium 释放 leveldb/cookie 数据句柄),返回被销毁标签的 URL 供恢复 */
async restartKernel(): Promise<string[]> {
const urls = [...this.tabs.values()]
.sort((a, b) => a.createdAt - b.createdAt)
.map((t) => t.url)
.filter((u) => /^https?:\/\//.test(u))
this.close()
await this.browserSession.clearStorageData({ storages: ['localstorage', 'cookies', 'indexdb'] })
return urls
}
/** 恢复内核热重启前打开的标签 */
restoreTabs(urls: string[]): void {
for (const url of urls) {
try {
this.newTab(url, false)
} catch (err) {
log.warn('restore tab failed', { url, err: err instanceof Error ? err.message : String(err) })
}
}
}
/** 热注入单条 Cookie(导入路径:源数据经解密后逐条写入分区会话) */
async setCookie(details: Electron.CookiesSetDetails): Promise<void> {
await this.browserSession.cookies.set(details)
}
// ---- 代理设置 ---- // ---- 代理设置 ----
private proxySettings(): BrowserProxySettings { private proxySettings(): BrowserProxySettings {
+269 -73
View File
@@ -1,5 +1,8 @@
import { app } from 'electron' import { app } from 'electron'
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs' import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { createDecipheriv } from 'node:crypto'
import { execFile } from 'node:child_process'
import { DatabaseSync } from 'node:sqlite'
import { homedir } from 'node:os' import { homedir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
import type { BrowserImportInfo, BrowserImportResult, BrowserProfileMeta, BrowserSource } from '@shared/types' import type { BrowserImportInfo, BrowserImportResult, BrowserProfileMeta, BrowserSource } from '@shared/types'
@@ -11,21 +14,18 @@ const log = getLogger('browser-profile')
/** /**
* 本地浏览器配置导入(复用各网站登录态)。 * 本地浏览器配置导入(复用各网站登录态)。
* *
* 实现思路(零解密依赖,纯文件迁移): * 迁移策略(collect → apply 两段式):
* - Local Storage / IndexedDB 在 Chromium 里是明文 leveldb,直接拷入受管分区即可生效。 * 1) collect:源 profile 的 Local Storage(明文 leveldb)与 Cookies 数据库先拷入
* - Cookies 用 AES-GCM 加密,密钥经 OS 加密后存于浏览器根目录 Local State * 暂存目录——此刻把数据抓到手,源浏览器之后锁不锁都无所谓;
* (os_crypt.encrypted_key)。Windows 上该密钥由 DPAPI(用户级)包裹——同一 * Windows 上同时把源 os_crypt 密钥(DPAPI 包裹)移植进本应用 Local State。
* Windows 用户的任意进程都能解开,因此把这份密钥「移植」进本应用的 Local State, * 2) apply 热路径(无需重启应用):
* 本应用内的 Chromium 即可解密拷贝过来的 Cookies(Chrome 127+ 的 app-bound * - Local Storage:文件迁移;分区被本应用 Chromium 锁住时执行「内核热重启」
* 加密 V20 除外,会明确提示不支持)。macOS/Linux 的密钥绑定钥匙串/KWallet, * (销毁全部标签 + clearStorageData 释放存储句柄)后重试,随后自动恢复标签;
* Cookies 无法直接迁移,仅迁移 Local Storage。 * - Cookies:用 DPAPI 解出的密钥自行解密源 Cookies 数据库,逐条经
* * session.cookies.set 热注入(文件替换在进程存活期间永远不可行——
* 两段式迁移(collect → apply): * CookieManager 与内存 CookieJar 都会覆盖它)。
* 1) collect:源 profile → 暂存目录(此刻把数据抓到手,源浏览器之后锁不锁都无所谓); * 3) 兜底:Cookie 无法解密(非 Windows / app-bound V20 / DPAPI 失败)时,
* 2) apply:暂存目录 → 受管分区。分区数据可能被本应用自己的 Chromium 锁住 * Cookies 文件转待办任务(pending),下次启动早期自动落盘生效。
* (本次运行打开过网页,leveldb 句柄即使关掉标签也不释放),此时不硬刚——
* 暂存目录转正为待办任务(pending),applyPendingProfileImport 在下次启动
* 早期(分区尚未被 Chromium 打开)自动完成迁移。
*/ */
/** Chromium 系浏览器的用户数据根目录(按平台) */ /** Chromium 系浏览器的用户数据根目录(按平台) */
@@ -147,8 +147,9 @@ interface SourceKeyInfo {
/** /**
* 读取源浏览器 Local State 里的 os_crypt.encrypted_key 并判断可移植性。 * 读取源浏览器 Local State 里的 os_crypt.encrypted_key 并判断可移植性。
* Windows:前缀 DPAPI 可移植;V20 为 app-bound 加密(Chrome/Edge 127+), * Windows:前缀 DPAPI 可移植(文件兜底路径靠移植它;注入路径用它解密);
* 其他进程无法解开,明确降级。macOS/Linux:密钥在系统钥匙串/KWallet,跳过。 * V20 为 app-bound 加密(Chrome/Edge 127+),明确降级。
* macOS/Linux:密钥在系统钥匙串/KWallet,Cookies 无法解密,仅迁移 Local Storage。
*/ */
function readSourceKey(userDataDir: string): SourceKeyInfo { function readSourceKey(userDataDir: string): SourceKeyInfo {
if (process.platform !== 'win32') { if (process.platform !== 'win32') {
@@ -173,7 +174,47 @@ function readSourceKey(userDataDir: string): SourceKeyInfo {
} }
} }
/** 把源密钥移植进本应用根 Local State(首次会备份原文件) */ /** DPAPI 解出源浏览器的 AES 密钥(PowerShell 调 CryptUnprotectData;用户级,无需管理员) */
function dpapiUnprotectKey(encodedKey: string): Promise<Buffer | null> {
const blob = encodedKey.startsWith('DPAPI') ? encodedKey.slice(5) : encodedKey
const script =
`$d=[Convert]::FromBase64String('${blob}');` +
'Add-Type -AssemblyName System.Security;' +
'[Convert]::ToBase64String([System.Security.Cryptography.ProtectedData]::Unprotect($d,$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser))'
return new Promise((resolve) => {
execFile(
'powershell.exe',
['-NoProfile', '-NonInteractive', '-Command', script],
{ timeout: 20_000 },
(err, stdout) => {
if (err || !stdout.trim()) {
log.warn('dpapi unprotect failed', { err: err instanceof Error ? err.message : String(err ?? 'empty output') })
resolve(null)
return
}
try {
resolve(Buffer.from(stdout.trim(), 'base64'))
} catch {
resolve(null)
}
}
)
})
}
/** 解密单条 Chrome Cookie(v10/v11 均为 AES-256-GCM:3 字节版本 + 12 字节 nonce + 密文 + 16 字节 tag) */
function decryptChromeBlob(encrypted: Buffer, key: Buffer): string {
const version = encrypted.subarray(0, 3).toString('utf8')
if (version !== 'v10' && version !== 'v11') throw new Error(`不支持的 Cookie 加密版本: ${version}`)
const nonce = encrypted.subarray(3, 15)
const tag = encrypted.subarray(encrypted.length - 16)
const data = encrypted.subarray(15, encrypted.length - 16)
const decipher = createDecipheriv('aes-256-gcm', key, nonce)
decipher.setAuthTag(tag)
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8')
}
/** 把源密钥移植进本应用根 Local State(首次会备份原文件;Cookie 文件兜底路径需要它) */
function transplantKey(sourceKey: string): void { function transplantKey(sourceKey: string): void {
const appLocalState = join(app.getPath('userData'), 'Local State') const appLocalState = join(app.getPath('userData'), 'Local State')
const backup = `${appLocalState}.aso-backup` const backup = `${appLocalState}.aso-backup`
@@ -196,6 +237,8 @@ interface CollectedImport {
cookies: boolean cookies: boolean
localStorage: boolean localStorage: boolean
cookieNote?: string cookieNote?: string
/** 已移植源密钥(Cookie 文件兜底路径在重启后由本应用 Chromium 解密需要它) */
transplanted: boolean
} }
/** /**
@@ -215,6 +258,7 @@ function collectFromSource(root: string, sourceProfile: string, staging: string)
let cookies = false let cookies = false
let cookieNote: string | undefined let cookieNote: string | undefined
let transplanted = false
const keyInfo = readSourceKey(root) const keyInfo = readSourceKey(root)
const cookieDb = existsSync(join(sourceProfile, 'Network', 'Cookies')) const cookieDb = existsSync(join(sourceProfile, 'Network', 'Cookies'))
? join(sourceProfile, 'Network', 'Cookies') ? join(sourceProfile, 'Network', 'Cookies')
@@ -224,6 +268,7 @@ function collectFromSource(root: string, sourceProfile: string, staging: string)
if (keyInfo.key && cookieDb) { if (keyInfo.key && cookieDb) {
try { try {
transplantKey(keyInfo.key) transplantKey(keyInfo.key)
transplanted = true
mkdirSync(join(staging, 'Network'), { recursive: true }) mkdirSync(join(staging, 'Network'), { recursive: true })
copyFile(cookieDb, join(staging, 'Network', 'Cookies')) copyFile(cookieDb, join(staging, 'Network', 'Cookies'))
for (const suffix of ['-wal', '-shm']) { for (const suffix of ['-wal', '-shm']) {
@@ -241,26 +286,99 @@ function collectFromSource(root: string, sourceProfile: string, staging: string)
cookieNote = keyInfo.note ?? '源浏览器没有可迁移的 Cookie 数据库。' cookieNote = keyInfo.note ?? '源浏览器没有可迁移的 Cookie 数据库。'
} }
return { cookies, localStorage, cookieNote } return { cookies, localStorage, cookieNote, transplanted }
} }
/** apply:暂存目录 → 受管分区(分区被本应用 Chromium 锁住时抛原始 lock 错误) */ /** apply(LocalStorage):暂存 → 受管分区(分区被本应用 Chromium 锁住时抛原始 lock 错误) */
function applyStagedToPartition(staging: string): void { function applyStagedLocalStorage(staging: string): void {
const target = partitionDir() copyLevelDbDir(join(staging, 'Local Storage', 'leveldb'), join(partitionDir(), 'Local Storage', 'leveldb'))
copyLevelDbDir(join(staging, 'Local Storage', 'leveldb'), join(target, 'Local Storage', 'leveldb')) }
/** apply(Cookies 文件兜底):仅密钥已移植时有效(重启后 Chromium 用移植密钥解密) */
function applyStagedCookies(staging: string): void {
const cookieDb = join(staging, 'Network', 'Cookies') const cookieDb = join(staging, 'Network', 'Cookies')
if (existsSync(cookieDb)) { if (!existsSync(cookieDb)) return
const target = partitionDir()
mkdirSync(join(target, 'Network'), { recursive: true }) mkdirSync(join(target, 'Network'), { recursive: true })
copyFile(cookieDb, join(target, 'Network', 'Cookies')) copyFile(cookieDb, join(target, 'Network', 'Cookies'))
for (const suffix of ['-wal', '-shm']) { for (const suffix of ['-wal', '-shm']) {
if (existsSync(`${cookieDb}${suffix}`)) copyFile(`${cookieDb}${suffix}`, join(target, 'Network', `Cookies${suffix}`)) if (existsSync(`${cookieDb}${suffix}`)) copyFile(`${cookieDb}${suffix}`, join(target, 'Network', `Cookies${suffix}`))
} }
}
type CookieInjectResult =
| { kind: 'ok'; injected: number; failed: number }
| { kind: 'unsupported'; note: string }
/**
* Cookie 热注入:解密暂存 Cookies 数据库并逐条写入分区会话(不碰文件,无需重启)。
* 返回 unsupported 表示当前环境无法解密(非 Windows / 无密钥 / DPAPI 失败)。
*/
async function injectCookiesFromStaging(root: string, staging: string): Promise<CookieInjectResult> {
if (process.platform !== 'win32') {
return { kind: 'unsupported', note: '当前平台 Cookie 密钥绑定系统钥匙串,Cookies 将以文件方式在重启后自动落盘。' }
}
const keyInfo = readSourceKey(root)
if (!keyInfo.key) return { kind: 'unsupported', note: keyInfo.note ?? '无可用 Cookie 密钥,跳过 Cookie 热注入。' }
const key = await dpapiUnprotectKey(keyInfo.key)
if (!key) {
return { kind: 'unsupported', note: 'Cookie 密钥解密失败(DPAPI),Cookies 将以文件方式在重启后自动落盘。' }
}
const db = new DatabaseSync(join(staging, 'Network', 'Cookies'), { readOnly: true })
try {
const rows = db
.prepare('SELECT host_key, name, value, encrypted_value, path, is_secure, is_httponly, expires_utc FROM cookies')
.all()
const controller = getBrowserController()
let injected = 0
let failed = 0
for (const row of rows) {
try {
const r = row as {
host_key: string
name: string
value: string
encrypted_value: Buffer | null
path: string
is_secure: number
is_httponly: number
expires_utc: number
}
let value = r.value
if (!value && r.encrypted_value && r.encrypted_value.length > 3) {
value = decryptChromeBlob(r.encrypted_value, key)
}
if (!value) {
failed++
continue
}
const secure = r.is_secure === 1
const details: Electron.CookiesSetDetails = {
url: `${secure ? 'https' : 'http'}://${r.host_key.replace(/^\./, '')}${r.path || '/'}`,
name: r.name,
value,
domain: r.host_key,
path: r.path || '/',
secure,
httpOnly: r.is_httponly === 1
}
if (r.expires_utc > 0) details.expirationDate = r.expires_utc / 1_000_000 - 11_644_473_600
await controller.setCookie(details)
injected++
} catch {
failed++
}
}
return { kind: 'ok', injected, failed }
} finally {
db.close()
} }
} }
/** /**
* 应用启动早期调用(分区尚未被 Chromium 打开):完成上次因分区被占用 * 应用启动早期调用(分区尚未被 Chromium 打开):完成上次 Cookie 文件兜底
* 而搁置的待办导入。失败则保留待办,下次启动重试。 * 路径搁置的待办导入。失败则保留待办,下次启动重试。
*/ */
export function applyPendingProfileImport(): void { export function applyPendingProfileImport(): void {
const pending = pendingDir() const pending = pendingDir()
@@ -268,7 +386,8 @@ export function applyPendingProfileImport(): void {
if (!existsSync(metaFile)) return if (!existsSync(metaFile)) return
try { try {
const meta = JSON.parse(readFileSync(metaFile, 'utf8')) as BrowserImportInfo const meta = JSON.parse(readFileSync(metaFile, 'utf8')) as BrowserImportInfo
applyStagedToPartition(pending) applyStagedLocalStorage(pending)
applyStagedCookies(pending)
rmSync(pending, { recursive: true, force: true }) rmSync(pending, { recursive: true, force: true })
getBrowserController().setImportedInfo({ ...meta, note: undefined }) getBrowserController().setImportedInfo({ ...meta, note: undefined })
log.info('pending profile import applied', { browser: meta.browser, profile: meta.profile }) log.info('pending profile import applied', { browser: meta.browser, profile: meta.profile })
@@ -284,12 +403,9 @@ export function applyPendingProfileImport(): void {
} }
} }
/** 导入指定浏览器的 profile 配置到受管浏览器分区 */ /** 导入指定浏览器的 profile 配置到受管浏览器分区(热应用:通常无需重启应用) */
export async function importBrowserProfile(sourceId: string, profileDir: string): Promise<BrowserImportResult> { export async function importBrowserProfile(sourceId: string, profileDir: string): Promise<BrowserImportResult> {
const browser = getBrowserController() const browser = getBrowserController()
if (browser.hasTabs) {
throw new Error('请先关闭内置浏览器的所有标签页,再导入浏览器数据。')
}
const root = sourceRoot(sourceId) const root = sourceRoot(sourceId)
if (!root || !existsSync(root)) throw new Error('未找到该浏览器的用户数据目录。') if (!root || !existsSync(root)) throw new Error('未找到该浏览器的用户数据目录。')
// 只接受该根目录下的直接子目录名,拒绝任意路径注入 // 只接受该根目录下的直接子目录名,拒绝任意路径注入
@@ -301,7 +417,7 @@ export async function importBrowserProfile(sourceId: string, profileDir: string)
const sourceName = SOURCE_ROOTS.find((s) => s.id === sourceId)?.name ?? sourceId const sourceName = SOURCE_ROOTS.find((s) => s.id === sourceId)?.name ?? sourceId
const profileName = readProfileName(sourceProfile) const profileName = readProfileName(sourceProfile)
// 1) collect:源 → 暂存目录(源数据先抓到手) // 1) collect:源 → 暂存目录(源数据先抓到手;Cookies 文件作为解密源与兜底)
const staging = join(app.getPath('userData'), 'Partitions', `.import-staging-${Date.now()}`) const staging = join(app.getPath('userData'), 'Partitions', `.import-staging-${Date.now()}`)
let collected: CollectedImport let collected: CollectedImport
try { try {
@@ -311,64 +427,144 @@ export async function importBrowserProfile(sourceId: string, profileDir: string)
rmSync(staging, { recursive: true, force: true }) rmSync(staging, { recursive: true, force: true })
throw err throw err
} }
const info: BrowserImportInfo = {
browser: sourceName,
profile: profileName,
at: new Date().toISOString(),
cookies: collected.cookies,
localStorage: collected.localStorage,
note: collected.cookieNote
}
// 2) apply:暂存 → 受管分区;分区被本应用 Chromium 占用时转待办(重启后自动完成) // 2) Local Storage 热应用:被分区占用时执行内核热重启(销毁标签 + 释放存储句柄)
let restarted = false
try { try {
applyStagedToPartition(staging) applyStagedLocalStorage(staging)
} catch (err) { } catch (err) {
if (!isLockError(err)) { if (!isLockError(err)) {
rmSync(staging, { recursive: true, force: true }) rmSync(staging, { recursive: true, force: true })
throw new Error(`导入写入失败: ${err instanceof Error ? err.message : String(err)}`) throw new Error(`导入写入失败: ${err instanceof Error ? err.message : String(err)}`)
} }
// 占用错误:暂存目录保留,写 meta 后整体 rename 到 pending const urls = await browser.restartKernel()
const deferredNote = '检测到受管浏览器数据正被本应用占用,已计划在重启应用后自动完成导入。' restarted = true
try {
applyStagedLocalStorage(staging)
} catch (err2) {
// 理论不可达(clearStorageData 已释放句柄);兜底:整体转待办,重启应用后完成
const deferredNote = '受管浏览器数据正被占用,已计划在重启应用后自动完成导入。'
mkdirSync(join(app.getPath('userData'), 'Partitions'), { recursive: true }) mkdirSync(join(app.getPath('userData'), 'Partitions'), { recursive: true })
rmSync(pendingDir(), { recursive: true, force: true }) rmSync(pendingDir(), { recursive: true, force: true })
writeFileSync(join(staging, IMPORT_META_FILE), JSON.stringify({ ...info, note: deferredNote }, null, 2), 'utf8') writeFileSync(
join(staging, IMPORT_META_FILE),
JSON.stringify(
{ browser: sourceName, profile: profileName, at: new Date().toISOString(), cookies: collected.cookies, localStorage: true, note: deferredNote },
null,
2
),
'utf8'
)
renameSync(staging, pendingDir()) renameSync(staging, pendingDir())
browser.setImportedInfo({ ...info, note: deferredNote }) browser.setImportedInfo({
log.warn('partition locked, import deferred to next launch', { source: sourceId, profile: dirName }) browser: sourceName,
return { profile: profileName,
ok: true, at: new Date().toISOString(),
deferred: true,
cookies: collected.cookies, cookies: collected.cookies,
localStorage: collected.localStorage, localStorage: true,
cookieNote: deferredNote, note: deferredNote
message: deferredNote })
browser.restoreTabs(urls)
log.warn('partition still locked after kernel restart, deferred', { err: err2 instanceof Error ? err2.message : String(err2) })
return { ok: true, deferred: true, cookies: collected.cookies, localStorage: true, cookieNote: deferredNote, message: deferredNote }
} }
} }
// 3) Cookie:优先解密热注入(无需重启);不可解密时走文件兜底/待办
const inject = await injectCookiesFromStaging(root, staging)
if (inject.kind === 'unsupported' && collected.transplanted) {
// 密钥已移植:Cookie 文件兜底可拷(登录态在应用重启、Chromium 重读密钥后生效)
try {
applyStagedCookies(staging)
} catch (err) {
if (!isLockError(err)) {
rmSync(staging, { recursive: true, force: true })
throw new Error(`导入写入失败: ${err instanceof Error ? err.message : String(err)}`)
}
const deferredNote = 'LocalStorage 已生效;Cookie 数据将在重启应用后自动完成迁移。'
mkdirSync(join(app.getPath('userData'), 'Partitions'), { recursive: true })
rmSync(pendingDir(), { recursive: true, force: true })
writeFileSync(
join(staging, IMPORT_META_FILE),
JSON.stringify(
{ browser: sourceName, profile: profileName, at: new Date().toISOString(), cookies: true, localStorage: true, note: deferredNote },
null,
2
),
'utf8'
)
renameSync(staging, pendingDir())
browser.setImportedInfo({
browser: sourceName,
profile: profileName,
at: new Date().toISOString(),
cookies: true,
localStorage: true,
note: deferredNote
})
if (restarted) browser.restoreTabs([])
log.warn('cookie file locked, deferred to next launch', { source: sourceId, profile: dirName })
return { ok: true, deferred: true, cookies: false, localStorage: true, cookieNote: deferredNote, message: deferredNote }
}
rmSync(staging, { recursive: true, force: true }) rmSync(staging, { recursive: true, force: true })
if (restarted) browser.restoreTabs([])
const parts: string[] = [] browser.setImportedInfo({
if (collected.cookies) parts.push('Cookie(站点登录态)') browser: sourceName,
if (collected.localStorage) parts.push('LocalStorage') profile: profileName,
if (parts.length === 0) { at: new Date().toISOString(),
return { cookies: true,
ok: false, localStorage: collected.localStorage,
cookies: false, note: collected.cookieNote
localStorage: false, })
cookieNote: collected.cookieNote,
message: `未在 ${sourceName}(${dirName})中找到可迁移的登录数据。`
}
}
browser.setImportedInfo(info)
return { return {
ok: true, ok: true,
cookies: collected.cookies, cookies: true,
localStorage: collected.localStorage, localStorage: collected.localStorage,
cookieNote: collected.cookieNote, cookieNote: collected.cookieNote,
message: `已从 ${sourceName} 导入 ${parts.join(' + ')}。${collected.cookieNote ?? ''}${ message: `已从 ${sourceName} 导入 Cookie + LocalStorage${restarted ? '(浏览器内核已热重启)' : ''}。若登录态未生效,重启应用后重试。`
collected.cookies ? '若登录态未生效,重启应用后重试。' : '' }
}` }
rmSync(staging, { recursive: true, force: true })
if (restarted) browser.restoreTabs([])
if (inject.kind === 'ok') {
const cookies = inject.injected > 0
const cookieNote =
inject.failed > 0 ? `Cookie ${inject.injected} 条注入成功,${inject.failed} 条解密失败已跳过。` : undefined
browser.setImportedInfo({
browser: sourceName,
profile: profileName,
at: new Date().toISOString(),
cookies,
localStorage: true,
note: cookieNote
})
return {
ok: true,
cookies,
localStorage: true,
cookieNote,
message: `已从 ${sourceName} 热导入 LocalStorage + Cookie ${inject.injected} 条${
inject.failed > 0 ? `(${inject.failed} 条跳过)` : ''
}(浏览器内核已热重启,无需重启应用)。`
}
}
// 无密钥/不支持:只有 LocalStorage 生效
browser.setImportedInfo({
browser: sourceName,
profile: profileName,
at: new Date().toISOString(),
cookies: false,
localStorage: true,
note: inject.note
})
return {
ok: true,
cookies: false,
localStorage: true,
cookieNote: inject.note,
message: `已从 ${sourceName} 导入 LocalStorage。${inject.note ?? ''}`
} }
} }