diff --git a/plugins/device-link/CHANGELOG.md b/plugins/device-link/CHANGELOG.md index 5a329d0f..6a6c6aca 100644 --- a/plugins/device-link/CHANGELOG.md +++ b/plugins/device-link/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [0.1.8] - 2026-09-01 + +### Added + +- 适配 ZTools 3.2:附件、传输临时文件和本地凭据密钥使用插件专属 `pluginData`;首次启动会校验迁移旧文件、重写数据库路径并删除旧目录。保留附件另存为入口与按消息 ID、附件 ID 打开的精确定位。 + +### Changed + +- 本地加密 fallback 保持旧密钥种子以解密既有 `local:` 凭据,并将 `local:v2` 密钥迁入 `pluginData`。ZTools 2.4–3.1 直接安装仍使用原目录,完成迁移后不保证降级的数据可见性。 +- 适配插件内 ESC 直接隐藏:隐藏时只重置瞬态 UI,不停止后台传输服务;Tab 焦点闭环保持有效。 +- 低于 ZTools 2.4 或真实宿主无法提供可信版本时,只显示升级提示;preload 保持惰性,不加载状态、不订阅事件,也不启动局域网服务。 + ## [0.1.7] - 2026-08-28 ### Added diff --git a/plugins/device-link/README.md b/plugins/device-link/README.md index c8172431..70e1922e 100644 --- a/plugins/device-link/README.md +++ b/plugins/device-link/README.md @@ -2,6 +2,8 @@ 以聊天会话的方式连接 macOS、Windows、Linux 与手机浏览器,实时分享文本、链接、图片和任意格式文件。 +> 需要 ZTools 2.4.0 或更高版本。ZTools 3.2.0 首次启动会把旧附件、传输临时文件和本地凭据密钥校验迁移到插件专属数据目录,重写数据库中的附件路径并删除旧目录。2.4–3.1 直接安装继续使用原目录;完成迁移后不保证降级的数据可见性。 + ## 当前能力 - 桌面端聊天式会话与移动端自适应 Web 客户端。 diff --git a/plugins/device-link/package-lock.json b/plugins/device-link/package-lock.json index 45651601..738f2fd4 100644 --- a/plugins/device-link/package-lock.json +++ b/plugins/device-link/package-lock.json @@ -1,12 +1,12 @@ { "name": "device-link", - "version": "0.1.7", + "version": "0.1.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "device-link", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@noble/ciphers": "^1.3.0", "@noble/hashes": "^1.8.0", diff --git a/plugins/device-link/package.json b/plugins/device-link/package.json index 42c7f2f0..55be8fd9 100644 --- a/plugins/device-link/package.json +++ b/plugins/device-link/package.json @@ -1,6 +1,6 @@ { "name": "device-link", - "version": "0.1.7", + "version": "0.1.8", "private": true, "type": "module", "description": "以聊天方式安全连接电脑与手机,实时分享文本、链接、图片和文件。", diff --git a/plugins/device-link/public/plugin.json b/plugins/device-link/public/plugin.json index 21558498..055fc647 100644 --- a/plugins/device-link/public/plugin.json +++ b/plugins/device-link/public/plugin.json @@ -4,7 +4,7 @@ "title": "设备互联", "description": "以聊天方式安全连接电脑与手机,实时分享文本、链接、图片和文件", "author": "harris", - "version": "0.1.7", + "version": "0.1.8", "main": "index.html", "preload": "preload/services.js", "logo": "logo.svg", diff --git a/plugins/device-link/public/preload/core/credential-storage.js b/plugins/device-link/public/preload/core/credential-storage.js index 5b1caaa3..ab326635 100644 --- a/plugins/device-link/public/preload/core/credential-storage.js +++ b/plugins/device-link/public/preload/core/credential-storage.js @@ -52,20 +52,35 @@ function encryptAesGcm(value, key) { return Buffer.concat([nonce, cipher.getAuthTag(), body]).toString('base64') } -function createCredentialStorage({ dataDir, safeStorage, legacyKey }) { +function createCredentialStorage({ + dataDir, + safeStorage, + legacyKey, + localKeyDataDir = dataDir, + fallbackLocalKeyDataDirs = [], +}) { if (!dataDir) throw new TypeError('dataDir is required') - const keyPath = path.join(dataDir, 'credential-key-v2') + if (!localKeyDataDir) throw new TypeError('localKeyDataDir is required') + const keyPath = path.join(localKeyDataDir, 'credential-key-v2') + const fallbackKeyPaths = [...new Set(fallbackLocalKeyDataDirs + .filter((directory) => typeof directory === 'string' && directory) + .map((directory) => path.join(directory, 'credential-key-v2')))] + .filter((candidate) => candidate !== keyPath) let cachedLocalKey = null - function readLocalKey() { - const stat = fs.lstatSync(keyPath) + function readLocalKeyAt(candidate) { + const stat = fs.lstatSync(candidate) if (!stat.isFile() || stat.isSymbolicLink()) throw unavailable('本机设备授权密钥路径无效') - const key = fs.readFileSync(keyPath) + const key = fs.readFileSync(candidate) if (key.length !== KEY_BYTES) throw unavailable('本机设备授权密钥无效') - try { fs.chmodSync(keyPath, 0o600) } catch {} + try { fs.chmodSync(candidate, 0o600) } catch {} return key } + function readLocalKey() { + return readLocalKeyAt(keyPath) + } + function localKey() { if (cachedLocalKey) return cachedLocalKey try { @@ -79,9 +94,9 @@ function createCredentialStorage({ dataDir, safeStorage, legacyKey }) { } const generated = crypto.randomBytes(KEY_BYTES) - const temporaryPath = path.join(dataDir, `.credential-key-${process.pid}-${crypto.randomBytes(8).toString('hex')}.tmp`) + const temporaryPath = path.join(localKeyDataDir, `.credential-key-${process.pid}-${crypto.randomBytes(8).toString('hex')}.tmp`) try { - fs.mkdirSync(dataDir, { recursive: true }) + fs.mkdirSync(localKeyDataDir, { recursive: true }) const descriptor = fs.openSync(temporaryPath, 'wx', 0o600) try { let offset = 0 @@ -112,6 +127,19 @@ function createCredentialStorage({ dataDir, safeStorage, legacyKey }) { return cachedLocalKey } + function localKeysForUnseal() { + const keys = [localKey()] + for (const fallbackPath of fallbackKeyPaths) { + try { + keys.push(readLocalKeyAt(fallbackPath)) + } catch (error) { + if (error?.code === 'ENOENT') continue + throw error + } + } + return keys + } + function seal(value) { if (!value) return '' const plain = String(value) @@ -160,7 +188,16 @@ function createCredentialStorage({ dataDir, safeStorage, legacyKey }) { } if (sealed.startsWith(LOCAL_V2_PREFIX)) { try { - return decryptAesGcm(sealed.slice(LOCAL_V2_PREFIX.length), localKey()) + const encoded = sealed.slice(LOCAL_V2_PREFIX.length) + let lastError + for (const key of localKeysForUnseal()) { + try { + return decryptAesGcm(encoded, key) + } catch (error) { + lastError = error + } + } + throw lastError || invalid() } catch (error) { if (error?.code === 'CREDENTIAL_BACKEND_UNAVAILABLE' || error?.code === 'CREDENTIAL_INVALID') throw error throw invalid(error) diff --git a/plugins/device-link/public/preload/core/host-compat.js b/plugins/device-link/public/preload/core/host-compat.js new file mode 100644 index 00000000..9cc14340 --- /dev/null +++ b/plugins/device-link/public/preload/core/host-compat.js @@ -0,0 +1,67 @@ +'use strict' + +const path = require('node:path') + +const MINIMUM_VERSION = [2, 4, 0] + +function parseHostVersion(value) { + if (typeof value !== 'string') return null + const match = /^v?(\d+)\.(\d+)(?:\.(\d+))?([+-][0-9A-Za-z.-]+)?$/u.exec(value.trim()) + if (!match) return null + const parts = [match[1], match[2], match[3] || '0'].map((part) => Number.parseInt(part, 10)) + if (parts.some((part) => !Number.isSafeInteger(part))) return null + return { parts, prerelease: Boolean(match[4]?.startsWith('-')) } +} + +function isBelowMinimumVersion(parsed) { + for (let index = 0; index < MINIMUM_VERSION.length; index += 1) { + if (parsed.parts[index] === MINIMUM_VERSION[index]) continue + return parsed.parts[index] < MINIMUM_VERSION[index] + } + return parsed.prerelease +} + +function detectHostCompatibility(ztools) { + if (ztools === undefined) { + return { mode: 'browser-preview', requiresUpgrade: false, reason: 'browser-preview' } + } + let value + try { + if (typeof ztools?.getAppVersion !== 'function') { + return { mode: 'upgrade-required', requiresUpgrade: true, reason: 'version-unavailable' } + } + value = ztools.getAppVersion() + } catch { + return { mode: 'upgrade-required', requiresUpgrade: true, reason: 'version-unavailable' } + } + const version = typeof value === 'string' ? value.trim() : '' + const parsed = parseHostVersion(version) + if (!parsed) return { mode: 'upgrade-required', requiresUpgrade: true, reason: 'version-invalid' } + if (isBelowMinimumVersion(parsed)) { + return { mode: 'upgrade-required', version, requiresUpgrade: true, reason: 'below-minimum' } + } + return { mode: 'supported', version, requiresUpgrade: false, reason: 'supported' } +} + +function pathFromHost(ztools, name) { + try { + const value = ztools?.getPath?.(name) + return typeof value === 'string' && value.trim() ? value : '' + } catch { + return '' + } +} + +function resolveDataDirectories(ztools) { + const userData = pathFromHost(ztools, 'userData') + if (!userData) throw new Error('ZTools userData path is unavailable') + const legacyDataDir = path.join(userData, 'device-link') + const pluginDataDir = pathFromHost(ztools, 'pluginData') + return { + dataDir: pluginDataDir || legacyDataDir, + legacyDataDir, + usingPluginData: Boolean(pluginDataDir), + } +} + +module.exports = { detectHostCompatibility, resolveDataDirectories } diff --git a/plugins/device-link/public/preload/core/plugin-data-migration.js b/plugins/device-link/public/preload/core/plugin-data-migration.js new file mode 100644 index 00000000..a79abc24 --- /dev/null +++ b/plugins/device-link/public/preload/core/plugin-data-migration.js @@ -0,0 +1,110 @@ +'use strict' + +const crypto = require('node:crypto') +const fs = require('node:fs') +const path = require('node:path') + +const MESSAGE_PREFIX = 'device-link:message:' +const EARLY_KEY_FALLBACK_DIR = '.credential-key-fallback-early32' + +function failedResult(result) { + return Boolean(result && typeof result === 'object' && (result.error === true || typeof result.error === 'string' || result.ok === false || (Number.isFinite(Number(result.status)) && Number(result.status) >= 400))) +} + +function digest(filePath) { + return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex') +} + +function safeTree(root) { + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + const candidate = path.join(root, entry.name) + const stat = fs.lstatSync(candidate) + if (stat.isSymbolicLink()) throw new TypeError('设备互联数据迁移不允许符号链接') + if (stat.isDirectory()) safeTree(candidate) + else if (!stat.isFile()) throw new TypeError('设备互联数据迁移只允许普通文件和目录') + } +} + +function copyAndVerify(source, destination) { + fs.mkdirSync(destination, { recursive: true }) + for (const entry of fs.readdirSync(source, { withFileTypes: true })) { + const from = path.join(source, entry.name) + const to = path.join(destination, entry.name) + if (!fs.existsSync(to)) fs.cpSync(from, to, { recursive: true, force: false, errorOnExist: true }) + const sourceStat = fs.lstatSync(from) + const destinationStat = fs.lstatSync(to) + if (sourceStat.isDirectory() !== destinationStat.isDirectory()) return false + if (sourceStat.isDirectory()) { + if (!copyAndVerify(from, to)) return false + } else if (!sourceStat.isFile() || !destinationStat.isFile() + || sourceStat.size !== destinationStat.size || digest(from) !== digest(to)) return false + } + return true +} + +function inside(root, candidate) { + const relative = path.relative(root, candidate) + return relative && !relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative) +} + +function migratedAttachmentPath(filePath, legacyDataDir, pluginDataDir) { + if (typeof filePath !== 'string') return filePath + const absolute = path.resolve(filePath) + const legacyAttachments = path.join(path.resolve(legacyDataDir), 'attachments') + if (!inside(legacyAttachments, absolute)) return filePath + return path.join(pluginDataDir, 'attachments', path.relative(legacyAttachments, absolute)) +} + +async function migrateMessagePaths(db, legacyDataDir, pluginDataDir) { + const result = await db.allDocs(MESSAGE_PREFIX) + if (failedResult(result)) throw new Error('设备互联迁移读取数据库失败') + const docs = Array.isArray(result) ? result : result?.rows?.map((row) => row.doc).filter(Boolean) || [] + for (const doc of docs) { + if (!doc || doc.type !== 'device-link-message') continue + const attachments = (doc.attachments || []).map((attachment) => ({ + ...attachment, + path: migratedAttachmentPath(attachment.path, legacyDataDir, pluginDataDir), + })) + if (attachments.every((attachment, index) => attachment.path === doc.attachments[index]?.path)) continue + const putResult = await db.put({ ...doc, attachments }) + if (failedResult(putResult)) throw new Error('设备互联迁移更新附件路径失败') + } +} + +function preparePluginDataMigration(db, pluginDataDir, legacyDataDir) { + if (!pluginDataDir || path.resolve(pluginDataDir) === path.resolve(legacyDataDir)) { + return { dataDir: legacyDataDir, ready: Promise.resolve(), usingPluginData: false } + } + try { + if (!fs.existsSync(legacyDataDir)) { + fs.mkdirSync(pluginDataDir, { recursive: true }) + return { dataDir: pluginDataDir, ready: Promise.resolve(), usingPluginData: true } + } + safeTree(legacyDataDir) + const legacyKey = path.join(legacyDataDir, 'credential-key-v2') + const pluginKey = path.join(pluginDataDir, 'credential-key-v2') + if (fs.existsSync(legacyKey) && fs.existsSync(pluginKey) && digest(legacyKey) !== digest(pluginKey)) { + const fallbackDir = path.join(pluginDataDir, EARLY_KEY_FALLBACK_DIR) + const fallbackKey = path.join(fallbackDir, 'credential-key-v2') + fs.mkdirSync(fallbackDir, { recursive: true }) + fs.copyFileSync(pluginKey, fallbackKey) + if (digest(pluginKey) !== digest(fallbackKey)) throw new Error('早期 3.2 凭据密钥备份校验失败') + fs.copyFileSync(legacyKey, pluginKey) + if (digest(legacyKey) !== digest(pluginKey)) throw new Error('旧凭据密钥迁移校验失败') + } + if (!copyAndVerify(legacyDataDir, pluginDataDir)) { + return { dataDir: legacyDataDir, ready: Promise.resolve(), usingPluginData: false } + } + } catch { + return { dataDir: legacyDataDir, ready: Promise.resolve(), usingPluginData: false } + } + + const ready = migrateMessagePaths(db, legacyDataDir, pluginDataDir).then(() => { + fs.rmSync(legacyDataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) + if (fs.existsSync(legacyDataDir)) throw new Error('旧设备互联数据目录清理失败') + fs.writeFileSync(path.join(pluginDataDir, '.device-link-plugin-data-migration-v1.json'), JSON.stringify({ version: 1, completedAt: new Date().toISOString() })) + }) + return { dataDir: pluginDataDir, ready, usingPluginData: true } +} + +module.exports = { EARLY_KEY_FALLBACK_DIR, migratedAttachmentPath, migrateMessagePaths, preparePluginDataMigration } diff --git a/plugins/device-link/public/preload/core/repository.js b/plugins/device-link/public/preload/core/repository.js index f62f7411..c4e84ff9 100644 --- a/plugins/device-link/public/preload/core/repository.js +++ b/plugins/device-link/public/preload/core/repository.js @@ -32,13 +32,16 @@ function storageError(operation, result) { return error } -function createRepository(db, dataDir) { +function createRepository(db, dataDir, options = {}) { const attachmentsDir = path.join(dataDir, 'attachments') const transfersDir = path.join(dataDir, 'transfers') + const attachmentRoots = [attachmentsDir] + const ready = Promise.resolve(options.ready) fs.mkdirSync(attachmentsDir, { recursive: true }) fs.mkdirSync(transfersDir, { recursive: true }) async function allDocs(prefix) { + await ready if (typeof db.allDocs === 'function') { const result = await db.allDocs(prefix) if (isFailedResult(result)) throw storageError('读取', result) @@ -51,6 +54,7 @@ function createRepository(db, dataDir) { attachmentsDir, transfersDir, async get(id) { + await ready try { const result = await db.get(id) if (isNotFound(result)) return null @@ -62,12 +66,14 @@ function createRepository(db, dataDir) { } }, async put(doc) { + await ready const current = await this.get(doc._id) const result = await db.put(current?._rev ? { ...doc, _rev: current._rev } : doc) if (isFailedResult(result)) throw storageError('写入', result) return result }, async remove(id) { + await ready const current = await this.get(id) if (!current) return false const result = await db.remove(current) @@ -108,8 +114,11 @@ function createRepository(db, dataDir) { const current = await this.get(`${MESSAGE_PREFIX}${id}`) for (const attachment of current?.attachments || []) { if (!attachment.path) continue - const relative = path.relative(attachmentsDir, path.resolve(attachment.path)) - if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) { + const isOwnedAttachment = attachmentRoots.some((root) => { + const relative = path.relative(root, path.resolve(attachment.path)) + return relative && !relative.startsWith('..') && !path.isAbsolute(relative) + }) + if (isOwnedAttachment) { try { await fs.promises.rm(attachment.path, { force: true }) } catch {} } } diff --git a/plugins/device-link/public/preload/services.js b/plugins/device-link/public/preload/services.js index f5aab266..5bc21796 100644 --- a/plugins/device-link/public/preload/services.js +++ b/plugins/device-link/public/preload/services.js @@ -9,6 +9,8 @@ const { clipboard, nativeImage, safeStorage, shell, webUtils } = require('electr const { saveAttachmentFile } = require('./core/attachment') const { createCredentialStorage } = require('./core/credential-storage') const { resolveDroppedFilePaths } = require('./core/drop') +const { detectHostCompatibility, resolveDataDirectories } = require('./core/host-compat') +const { EARLY_KEY_FALLBACK_DIR, preparePluginDataMigration } = require('./core/plugin-data-migration') const { clearMessageHistory, removeMessageFromHistory } = require('./core/history') const { createRepository } = require('./core/repository') const { CHUNK_SIZE, createDeviceLinkServer } = require('./core/server') @@ -25,10 +27,20 @@ const { const { runWebDavSync } = require('./core/webdav') const ztools = window.ztools -const dataDir = path.join(ztools.getPath('userData'), 'device-link') +const hostCompatibility = detectHostCompatibility(ztools) +if (hostCompatibility.requiresUpgrade) { + // Keep the preload inert so the renderer can show its upgrade-only view even + // when an older/invalid host lacks APIs used by the full service layer. + window.deviceLink = Object.freeze({}) +} else { +const resolvedDirectories = resolveDataDirectories(ztools) +const legacyDataDir = resolvedDirectories.legacyDataDir +const migration = preparePluginDataMigration(ztools.db.promises, resolvedDirectories.dataDir, legacyDataDir) +migration.ready.catch((error) => console.error('[device-link] pluginData migration failed', error)) +const dataDir = migration.dataDir fs.mkdirSync(dataDir, { recursive: true }) -const repository = createRepository(ztools.db.promises, dataDir) +const repository = createRepository(ztools.db.promises, dataDir, { ready: migration.ready }) let server = null const SHARED_CONVERSATION_ID = 'shared' @@ -64,13 +76,18 @@ function emit(type, data) { function fallbackKey() { const nativeId = typeof ztools.getNativeId === 'function' ? ztools.getNativeId() : os.hostname() - return crypto.createHash('sha256').update(`device-link-local:${nativeId}:${dataDir}`).digest() + // Keep the original key seed even after moving files to pluginData. Existing + // local: records live in the ZTools database and must survive both upgrade + // and downgrade without silently invalidating encrypted credentials. + return crypto.createHash('sha256').update(`device-link-local:${nativeId}:${legacyDataDir}`).digest() } const { seal, unseal } = createCredentialStorage({ dataDir, safeStorage, legacyKey: fallbackKey(), + localKeyDataDir: dataDir, + fallbackLocalKeyDataDirs: dataDir === legacyDataDir ? [] : [path.join(dataDir, EARLY_KEY_FALLBACK_DIR)], }) async function getSettingsRecord() { @@ -461,3 +478,4 @@ window.deviceLink = { return () => window.removeEventListener('device-link:event', listener) }, } +} diff --git a/plugins/device-link/src/App.vue b/plugins/device-link/src/App.vue index 6c279963..d2505075 100644 --- a/plugins/device-link/src/App.vue +++ b/plugins/device-link/src/App.vue @@ -8,6 +8,7 @@ import MessageList from './components/MessageList.vue' import PairingDialog from './components/PairingDialog.vue' import SettingsPanel from './components/SettingsPanel.vue' import { useDeviceLink } from './composables/useDeviceLink' +import { detectZToolsHostCompatibility } from './lib/ztoolsCompat' const pairingOpen = ref(false) const settingsOpen = ref(false) @@ -23,7 +24,10 @@ const moreMenu = ref(null) const dropActive = ref(false) const dropCount = ref(0) let dragDepth = 0 -const link = useDeviceLink() +const ztoolsCompatibility = detectZToolsHostCompatibility(window.ztools) +const ztoolsVersion = ztoolsCompatibility.version +const upgradeRequired = ztoolsCompatibility.requiresUpgrade +const link = useDeviceLink({ enabled: !upgradeRequired && Boolean(window.deviceLink) }) const filteredMessages = computed(() => { const query = searchQuery.value.trim().toLocaleLowerCase() @@ -152,9 +156,21 @@ function handleKeyDown(event: KeyboardEvent) { } } +function resetTransientUi() { + // Do not stop the preload server: backgroundRunning is intentional. Only UI + // layers are reset so 3.2's ESC direct-hide and later re-entry are stable. + closeMore() + closeSearch() + pairingOpen.value = false + settingsOpen.value = false + historyClearOpen.value = false + resetDropState() +} + onMounted(() => { document.addEventListener('pointerdown', handlePointerDown) document.addEventListener('keydown', handleKeyDown) + window.ztools?.onPluginOut?.(resetTransientUi) }) onUnmounted(() => { @@ -164,7 +180,14 @@ onUnmounted(() => {