From ee07f1f47fab610d8f71e2d2beb31f4a8a28d5c0 Mon Sep 17 00:00:00 2001 From: wangzihao Date: Tue, 1 Sep 2026 12:11:51 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(device-link):=20=E9=80=82=E9=85=8D=20p?= =?UTF-8?q?luginData=20=E4=B8=8E=E5=AE=BF=E4=B8=BB=E7=94=9F=E5=91=BD?= =?UTF-8?q?=E5=91=A8=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基于 0.1.7 保留扫码、安全存储和附件另存能力,接入 ZTools 3.2 pluginData 与生命周期兼容,并保留 2.4–3.1 回退。 AI-Co-Authored-By: Codex --- plugins/device-link/CHANGELOG.md | 12 ++++ plugins/device-link/README.md | 2 + plugins/device-link/package-lock.json | 4 +- plugins/device-link/package.json | 2 +- plugins/device-link/public/plugin.json | 2 +- .../public/preload/core/credential-storage.js | 55 ++++++++++++--- .../public/preload/core/host-compat.js | 67 +++++++++++++++++++ .../public/preload/core/repository.js | 10 ++- .../device-link/public/preload/services.js | 22 +++++- plugins/device-link/src/App.vue | 27 +++++++- .../src/composables/useDeviceLink.ts | 9 ++- plugins/device-link/src/lib/ztoolsCompat.ts | 54 +++++++++++++++ plugins/device-link/src/styles.css | 21 ++++++ plugins/device-link/src/types.ts | 2 + .../tests/credential-storage.test.cjs | 40 +++++++++++ .../device-link/tests/host-compat.test.cjs | 56 ++++++++++++++++ plugins/device-link/tests/repository.test.cjs | 37 ++++++++++ .../tests/services-host-gate.test.cjs | 63 +++++++++++++++++ .../device-link/tests/ztools-compat.test.cjs | 38 +++++++++++ 19 files changed, 500 insertions(+), 23 deletions(-) create mode 100644 plugins/device-link/public/preload/core/host-compat.js create mode 100644 plugins/device-link/src/lib/ztoolsCompat.ts create mode 100644 plugins/device-link/tests/host-compat.test.cjs create mode 100644 plugins/device-link/tests/services-host-gate.test.cjs create mode 100644 plugins/device-link/tests/ztools-compat.test.cjs diff --git a/plugins/device-link/CHANGELOG.md b/plugins/device-link/CHANGELOG.md index 5a329d0f..f795f1ce 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 保持原 `userData/device-link` 的 `local:v2` 密钥与旧密钥种子,已有设备凭据可在 2.4–3.1 与 3.2 之间往返使用;同时只读兼容早期 3.2 写入 `pluginData` 的密钥。旧绝对路径附件继续可读、可清理,不在启动时复制。 +- 适配插件内 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..b1a66871 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 的新附件与传输临时文件写入插件专属数据目录;既有数据库中的旧附件仍按原 `userData` 绝对路径读取和清理,不在启动时批量复制。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/repository.js b/plugins/device-link/public/preload/core/repository.js index f62f7411..166f82ec 100644 --- a/plugins/device-link/public/preload/core/repository.js +++ b/plugins/device-link/public/preload/core/repository.js @@ -32,9 +32,10 @@ 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, ...(options.attachmentRoots || []).map((root) => path.join(root, 'attachments'))] fs.mkdirSync(attachmentsDir, { recursive: true }) fs.mkdirSync(transfersDir, { recursive: true }) @@ -108,8 +109,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..9538e1d1 100644 --- a/plugins/device-link/public/preload/services.js +++ b/plugins/device-link/public/preload/services.js @@ -9,6 +9,7 @@ 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 { clearMessageHistory, removeMessageFromHistory } = require('./core/history') const { createRepository } = require('./core/repository') const { CHUNK_SIZE, createDeviceLinkServer } = require('./core/server') @@ -25,10 +26,16 @@ 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 { dataDir, legacyDataDir } = resolveDataDirectories(ztools) fs.mkdirSync(dataDir, { recursive: true }) -const repository = createRepository(ztools.db.promises, dataDir) +const repository = createRepository(ztools.db.promises, dataDir, { attachmentRoots: [legacyDataDir] }) let server = null const SHARED_CONVERSATION_ID = 'shared' @@ -64,13 +71,21 @@ 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(), + // Use the 2.4–3.1 key path as the local:v2 write key, so a downgrade can + // decrypt new credentials. Keep pluginData as a read-only fallback for + // credentials written by early 3.2 builds before this compatibility fix. + localKeyDataDir: legacyDataDir, + fallbackLocalKeyDataDirs: dataDir === legacyDataDir ? [] : [dataDir], }) async function getSettingsRecord() { @@ -461,3 +476,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(() => {