# dsh-photo2dsh-by-lan English | [中文](README.zh.md) **手机拍照 → 局域网 → 这台 PC 的文件夹 → Agent 直接可读。** 手机端用系统自带能力(iPad/iPhone「快捷指令」、Android「HTTP Shortcuts」)**一键**把照片 POST 到 PC; 插件负责接收、落盘到**你自己指定的目录**、弹 Windows 通知。**不需要云、不需要飞书/微信、不需要装手机 App。** 配置界面就在 DSH 侧栏 **插件(Plugins)→ 本包页面**里。 --- ## 亮点 - **手机一键** —— iOS 快捷指令或 Android HTTP Shortcuts 直接 POST 照片,手机侧不用装任何 App。 - **落地目录你说了算** —— 在插件面板里改;照片立刻出现在那个目录。 - **Agent 直接可读** —— 落地目录就是普通目录,DSH 的 Agent 可以立刻读取/分析。 - **到达即通知** —— 每张照片弹一条 Windows 通知(可点击跳回)。 - **不依赖云、不用 IM、不装手机 App、不改 `app.asar`。** - **可选 token 闸门** —— 默认关闭;关闭时以"局域网"为信任边界(见第 6 节)。 - **离线验证台** —— 50 条断言,含我们真机上踩过的每一种故障。 ## 1. 安装 ### 方式 A:按包名(推荐,可迁移) ```powershell dsh plugin --profile desktop add dsh-photo2dsh-by-lan ``` `dsh plugin` 会自动把本 bundle 记进 profile 的 `dsh.profile.bundles`,**不需要手工编辑 profile 文件**。 ### 方式 B:从本地源码装 ```powershell dsh plugin --profile desktop add file:D:\path\to\dsh-photo2dsh-by-lan ``` > ⚠️ **两种方式只选一种**。若 profile 里已经有指向本插件绝对路径的 `insert` 行,再按包名装一次会出现两行同名 entry。 ### 装完之后:给端口放行一次防火墙 这是**唯一需要管理员权限**的步骤。默认端口 8787: ```powershell netsh advfirewall firewall add rule name="DSH Photo2DSH" dir=in action=allow protocol=TCP localport=8787 profile=any ``` 撤销: ```powershell netsh advfirewall firewall delete rule name="DSH Photo2DSH" ``` > ⚠️ **`profile=any` 不能省**。省略 `Profile=` 字段才是"对所有网络类别生效"; > 若只写 `profile=public` 而你的网络被 Windows 判成"专用",入站包会被**静默丢弃**, > 手机端表现为"服务器已停止响应"(超时),极难定位。这是本项目实测踩过的坑。 > > 顺带一提:**别指望借用系统已有的放行端口**。实测全机唯一"不限程序 + 不限类别"的 TCP 端口 > 被 `http.sys` 独占(`EACCES`),抢不过来。 装完**重启 DSH**(客户端半边需要重新装配)。 --- ## 2. 在插件面板里配置 侧栏 **插件(Plugins)** → 本包页面 → 配置卡片: | 配置项 | 说明 | |---|---| | **落地目录** | 照片落在哪里。会在此目录下自动建 `<日期>\` 子目录 | | **端口** | 默认 8787。**改完要给新端口重新放行防火墙** | | **单文件上限(MB)** | 默认 64 | | **收到照片后弹 Windows 通知** | 默认开 | | **每张照片写一张元数据卡(.json)** | 默认开。**嫌目录里文件翻倍就关掉**——见 §4 | | **只接受局域网来源** | 默认开。非私网地址的上传一律 `403`;关掉 token 时它是唯一来源闸门 | | **要求 token** | 默认开,**强烈建议保持开启**(理由见下) | | **手机要连的 IP** | 默认「自动」:枚举本机网卡、跳过 Hyper-V/VMware/WSL 等虚拟网卡、优先 192.168 网段。选错可手动指定 | | 手机端要填的地址 | 只读,点「复制」直接拷走 | | token | 只读 | > **为什么落地目录建议填"当前会话工作区内的目录"**:DSH 的文件沙箱在 `workspace-write` 下只允许写工作区。 > 落在工作区外,宿主仍能写(插件用的是原生 fs),但 **Agent 用工具读它可能受限**。 --- ## 3. 手机端配置 ### iPad / iPhone(快捷指令)—— 四步,**第 3 步最容易漏** 1. 新建快捷指令,加 **「选择照片」**(或「拍摄照片」)。 2. 加 **「转换图像」** → 「转换为」选 **JPEG**、质量 80%。 - 不加这步也能用,但实测同一张 8MP 图会从 ~1.5 MB 变成 **~12 MB**(快捷指令按无损 PNG 递送)。 3. 加 **「获取 URL 内容」**: - URL 填配置卡片里复制的那串(形如 `http://192.168.x.x:8787/u/`); - 点「显示更多」→ 方法 **POST** → 请求体 **文件**; - ⚠️ **把「文件」栏的值改指「转换后的图像」** —— 漏这步就继续发原图,转换动作等于白加; - 最后加一个 **「快速查看」** 看返回。 4. 运行。返回是**中文短句**,一眼能看懂: ```text ✅ 照片已收到 格式:JPEG(1.63 MB) 文件:20261003232612-4d2b993e-image.jpg 时间:2026-10-03 23:26:12 已存入 PC 的收件箱文件夹 ``` > **首次访问局域网时 iPadOS 会弹「允许访问本地网络设备」——必须允许**。 > 这与防火墙无关,是 iOS 独立的一道闸;拒绝后请求会被系统直接掐断。 想让相册里"只传我想传的那几张":快捷指令 **ⓘ → 打开「在分享表单中显示」→ 接收类型勾「图像」**, 之后在照片 App 里选图 → 分享 → 本快捷指令。 ### Android(HTTP Shortcuts) 同一个 URL、同样的 POST + 文件体即可。协议两端完全一致,不另设。 --- ## 4. 数据怎么落 ```text <你配置的目录>\ └─ 2026-10-03\ ├─ 20261003232612-4d2b993e-image.jpg ← 本地时间戳-内容哈希前8位-清洗后的原名 └─ 20261003232612-4d2b993e-image.json ← sidecar:原名/备注/时间/字节数/sha256/格式/来源 ``` - **文件名带内容哈希前 8 位**,所以肉眼就能核对两张图是否同一张。 - **同秒同名不会互相覆盖**:落盘用 `link()`,目标已存在就自动追加 `-1`、`-2`。 (`rename` 会静默覆盖——实测同一秒发多张 `image.jpg` 会只剩一张且毫无提示,这条是专门修的。) - **只认魔术字节**,不信任 `Content-Type`:实测快捷指令送来时头写 `image/jpeg`、字节却是 PNG。 - **那张 `.json` 是可关的**。它记的是文件名给不出来的东西:哪台设备发的、原始文件名、备注、`sha256`、嗅探所得格式。 嫌目录里文件翻倍,就在面板里取消勾选「**每张照片写一张元数据卡**」——之后目录里只剩照片本身。 这些元数据即使在开关打开时也**不会**影响照片落盘成功(写元数据失败只记日志)。 --- ## 5. 故障排查 | 现象 | 原因 | 处理 | |---|---|---| | 手机显示"服务器已停止响应"(超时) | 入站包被防火墙静默丢弃 | 按第 1 节加防火墙规则;**必须带 `profile=any`** | | 返回 `❌ 上传失败:地址里缺少正确的 token` | URL 少了 `/u/` 这一段 | 从配置卡片重新复制完整地址 | | 返回 `❌ 这不是一张能识别的图片` | 发出去的确实不是图片字节 | 检查快捷指令里「文件」栏是否真的选上了图像变量 | | 返回里写着"仍是 PNG" | 「转换图像」没接到「文件」栏 | 见第 3 节第 3 步 | | 面板里看不到本包页面 | 那一行 Loader 被关闭了,或 profile 未重启 | 官方契约:客户端半边挂在"说明符恰为包名"的那一行上,**关掉那一行卡片就一起消失** | | 端口启动失败 | 端口被别的程序占用 | 面板里换一个端口,并给新端口放行防火墙 | 日志:`$DSH_HOME\photo2dsh-by-lan.log` 配置:`$DSH_HOME\photo2dsh-by-lan.json` --- ## 6. 安全边界 默认配置(`requireToken: true` + `lanOnly: true`): - **只监听局域网**(`0.0.0.0:`),不放公网。 - **token 走 URL 路径**,128 bit 随机、首次启动自动生成并持久化(这就是"可迁移"——换台机器不用手配)。 - **`401` 不会回显正确的 token**:那等于把它送给任何打错地址的人。 - **设置页(`GET /`)只对 `127.0.0.1` 开放**:它含 token,不能让局域网随便看。 - **`/health` 不含 token**(`uploadUrl` 里也含 token,所以被一并剥掉)。 ### 不想用 token?可以,但两道闸门要一起上 在面板里把 **「要求 token」关掉** 即可(地址就变回 `http://:/`,快捷指令里更好填)。但另外两道得在: | 闸门 | 挡什么 | 默认 | |---|---|---| | **只接受局域网来源**(`lanOnly`) | 非私网地址(公网)的上传一律 `403` | ✅ 开 | | **拒跨源 `Origin`** | **网页跳板**:你浏览的恶意网页借你的浏览器 POST 过来 | ✅ **永远生效,无开关** | > ⚠️ **第二道不能省,也不能用第一道代替。** 网页跳板走的是**你自己的浏览器**,服务端看到的来源是 `127.0.0.1` —— 在"局域网/私网"的定义里完全合法。只有 `Origin` 头能识别它:浏览器跨源请求**一定带** `Origin`,而 iOS 快捷指令这类原生客户端**根本不带**。 **关掉 token 后仍然成立的保护**(与 token 无关):路径清洗(防目录穿越)、只收图片(`415`)、体积上限(`413`)、落盘用 `link()` 防覆盖。 也就是说最坏情况是"局域网里的设备往你的收件箱灌垃圾图片",**不可能写到别的目录、不可能覆盖已有文件、不可能放可执行内容**。 **仍然建议**:只在自有可信网络(家里/办公室)里关。咖啡馆、酒店、合租的 Wi-Fi 别关。 --- ## 7. 已知限制 - **仅 Windows**(通知走 Windows PowerShell 5.1 的 WinRT;`package.json` 已声明 `os: ["win32"]`)。 - 通知横幅上的应用名显示为 **Windows PowerShell**。要显示成自己的名字需要往 `HKCU\Software\Classes\AppUserModelId` 注册 AUMID。 - **不做**:云中转 / 飞书微信通道 / 界面里的照片浏览器 / 自动把消息注入会话 / 自动清理。 - 卸载时**不会**删除你已收到的照片(那是你的数据)。 --- ## 8. 开发 ```powershell # 离线验证台(50 条,含全部真机故障复现用例) node test/photo2dsh.test.mjs ``` 架构: | 文件 | 职责 | |---|---| | `core.mjs` | 纯逻辑(命名/白名单/魔术字节/配置文档解析),无 I/O,可离线测 | | `receiver.mjs` | **宽容的原始 TCP 接收**(不用 `node:http`,理由见文件头注释) | | `store.mjs` | `link()` 原子落盘 + sidecar | | `notify.mjs` + `scripts/toast.ps1` | Windows Toast(PS 5.1;脚本纯 ASCII 以绕开 BOM 坑) | | `index.mjs` | 宿主接线:配置分层、receiver 生命周期、`/api/photo2dsh-by-lan.config` 路由 | | `client.js` | 客户端半边:注册进官方槽位 `plugins.bundle.config` 的配置卡片 |