Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions plugins/device-link/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 2 additions & 0 deletions plugins/device-link/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

以聊天会话的方式连接 macOS、Windows、Linux 与手机浏览器,实时分享文本、链接、图片和任意格式文件。

> 需要 ZTools 2.4.0 或更高版本。ZTools 3.2.0 首次启动会把旧附件、传输临时文件和本地凭据密钥校验迁移到插件专属数据目录,重写数据库中的附件路径并删除旧目录。2.4–3.1 直接安装继续使用原目录;完成迁移后不保证降级的数据可见性。

## 当前能力

- 桌面端聊天式会话与移动端自适应 Web 客户端。
Expand Down
4 changes: 2 additions & 2 deletions plugins/device-link/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion plugins/device-link/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "device-link",
"version": "0.1.7",
"version": "0.1.8",
"private": true,
"type": "module",
"description": "以聊天方式安全连接电脑与手机,实时分享文本、链接、图片和文件。",
Expand Down
2 changes: 1 addition & 1 deletion plugins/device-link/public/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
55 changes: 46 additions & 9 deletions plugins/device-link/public/preload/core/credential-storage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
67 changes: 67 additions & 0 deletions plugins/device-link/public/preload/core/host-compat.js
Original file line number Diff line number Diff line change
@@ -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 }
110 changes: 110 additions & 0 deletions plugins/device-link/public/preload/core/plugin-data-migration.js
Original file line number Diff line number Diff line change
@@ -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 }
15 changes: 12 additions & 3 deletions plugins/device-link/public/preload/core/repository.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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 {}
}
}
Expand Down
Loading
Loading