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
6 changes: 3 additions & 3 deletions client/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -482,16 +482,16 @@ function startFileGuard(readFile) {
}, 2600);
};
const onClick = (event) => {
if (event.defaultPrevented) return;
const target = event.target;
if (target === null) return;
const el = target.closest("button, a");
if (el === null) return;
if (!looksLikeFilePath(el.textContent)) return;
event.preventDefault();
event.stopImmediatePropagation();
showToast(GUARD_MSG);
};
document.addEventListener("click", onClick, true);
document.addEventListener("click", onClick);
const injectCopyButtons = () => {
const links = document.querySelectorAll("button, a");
links.forEach((el) => {
Expand Down Expand Up @@ -562,7 +562,7 @@ function startFileGuard(readFile) {
};
const disconnectWs = hideWsEntries();
return () => {
document.removeEventListener("click", onClick, true);
document.removeEventListener("click", onClick);
copyObserver.disconnect();
disconnectWs();
if (toastTimer !== null) window.clearTimeout(toastTimer);
Expand Down
22 changes: 15 additions & 7 deletions client/mobile/fileGuard.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
// 移动端文件守卫(issue #17):dsh-web 在手机上点「文件链接」会触发桌面端
// 移动端文件守卫(issue #17,issue #152 修正):dsh-web 在手机上点「文件链接」会触发桌面端
// workspaces.openPath(open <path>),既打不开(文件在电脑上),又会抛
// "path open failed". 这里做两件事:
// 1) 捕获阶段拦截这类激活(点击 / 键盘),改为弹一个提示;
// 1) 应用自己没处理这类激活时(点击 / 键盘)弹一个提示兜底;
// 2) 在每个文件链接旁注入一个「复制」按钮,点它经主机 RPC 读文件正文再写剪贴板。
// 另外隐藏「添加工作区」入口(手机上配工作区无意义)。
// 移植自 dsh-web-mobile(MIT)。
//
// 识别方式:不依赖 dsh-web 的 hash 类名(每次构建都变),只认「文本像文件路径的
// <button>/<a>」——文件链接按钮的文案就是路径(如 lib/proxy.mjs / /Users/.../x.ts)。
//
// 兜底必须晚于应用:dsh-web 是「预览优先」(dsh-context 的文件行先 onPreview、再 onOpen;
// dsh-client-ui-reference 的 openReference 就是 sidebarRight.openResource),
// 它处理完了才轮到我们。所以监听器注册在冒泡阶段 + defaultPrevented 早退。

/** 手机上点击文件时弹出的提示。 */
const GUARD_MSG = '手机上无法直接打开电脑上的文件'
Expand Down Expand Up @@ -100,19 +104,23 @@ export function startFileGuard(
}, 2600)
}

// 捕获阶段拦截文件链接的激活。按钮的键盘激活(Enter/Space)会派发 click,
// 因此只拦 click 即可同时覆盖鼠标与键盘,避免重复处理。
// 兜底拦截,注册在**冒泡**阶段:让 dsh-web 自己的处理器先跑。
// 应用能处理时会 preventDefault(如裸 <a href> 的导航)或 stopPropagation
// (如 dsh-context 文件行的 ev.stopPropagation()),事件到不了这里;
// 只有应用没管、真的会去调桌面端 open 的时候,才弹提示兜底。
// 反过来注册在捕获阶段会连应用的预览一起吞掉(issue #152)。
// 按钮的键盘激活(Enter/Space)会派发 click,因此只拦 click 即可同时覆盖鼠标与键盘。
const onClick = (event: MouseEvent): void => {
if (event.defaultPrevented) return
const target = event.target as HTMLElement | null
if (target === null) return
const el = target.closest('button, a') as HTMLElement | null
if (el === null) return
if (!looksLikeFilePath(el.textContent)) return
event.preventDefault()
event.stopImmediatePropagation()
showToast(GUARD_MSG)
}
document.addEventListener('click', onClick, true)
document.addEventListener('click', onClick)

// 在文件链接旁注入「复制」按钮:点它经主机 RPC 读文件正文再写剪贴板。
// 用 data-mobile-nav-copy 标记已处理的链接,避免重复注入;React 重渲染会
Expand Down Expand Up @@ -191,7 +199,7 @@ export function startFileGuard(
const disconnectWs = hideWsEntries()

return () => {
document.removeEventListener('click', onClick, true)
document.removeEventListener('click', onClick)
copyObserver.disconnect()
disconnectWs()
if (toastTimer !== null) window.clearTimeout(toastTimer)
Expand Down
17 changes: 16 additions & 1 deletion test/file-guard.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,25 @@ test('源码含提示文案与「添加工作区」隐藏文案(产物会被 e
assert.ok(src.includes('添加工作区') && src.includes('Add workspace'), '必须覆盖中英双语的添加工作区入口');
});

test('fileGuard 的兜底晚于应用:冒泡阶段注册 + defaultPrevented 早退(issue #152)', () => {
// 注册必须是冒泡阶段(第三参数缺省/false);捕获阶段会把 dsh-web 自己的预览点击一起吞掉。
assert.ok(
/document\.addEventListener\('click', onClick\)/.test(src),
'必须注册在冒泡阶段:document.addEventListener("click", onClick)',
);
assert.ok(
!/addEventListener\('click', onClick, true\)/.test(src),
'不能再注册在捕获阶段',
);
assert.ok(src.includes('event.defaultPrevented'), '应用已处理(defaultPrevented)时必须让路');
assert.ok(!src.includes('stopImmediatePropagation'), '不再吞事件,让应用自己的处理器先跑');
});

test('打包产物含守卫 + 复制按钮结构标记', () => {
// esbuild 会把单引号规范成双引号、中文转义成 \\uXXXX,故只查 ASCII 结构标记。
assert.ok(/closest\(\s*["']button, a["']\s*\)/.test(bundle), '产物必须保留 closest("button, a") 检测——先跑 node client/build.mjs');
assert.ok(bundle.includes('stopImmediatePropagation'), '产物必须能在捕获阶段阻止桌面 open');
assert.ok(bundle.includes('defaultPrevented'), '产物必须保留 defaultPrevented 早退');
assert.ok(!bundle.includes('stopImmediatePropagation'), '产物不应再吞掉应用自己的点击处理');
assert.ok(bundle.includes('file-guard-toast'), '产物必须含 toast 标记');
assert.ok(bundle.includes('add-workspace'), '产物必须含隐藏添加工作区的逻辑');
// 复制按钮:注入标记 + 经 RPC 读文件(data-mobile-nav-copy 标记已处理链接,避免重复注入)
Expand Down