Skip to content

Commit d753e85

Browse files
committed
Add multi-network interface support
1 parent 3fdcb75 commit d753e85

13 files changed

Lines changed: 366 additions & 72 deletions

File tree

AGENTS.md

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,18 +25,24 @@
2525
- 默认端口为 `5421`
2626
- 管理端先输入端口,再点击“启动服务”
2727
- 启动后按钮文案切换为“停止分享”
28-
- 管理端右上角展示客户端二维码和链接复制按钮
28+
- 管理端右上角展示客户端二维码、访问链接复制按钮;如果存在多个可用物理/真实网卡 IP,展示地址下拉选择
2929
- 客户端页面也展示二维码和链接复制按钮
30-
- 二维码和链接使用局域网 IP
30+
- 管理端二维码、展示链接、复制链接、复制文件下载链接都跟随当前选中的访问地址
31+
- 客户端页面的展示链接、复制链接、复制文件下载链接必须使用浏览器当前 URL 的 origin,不能使用服务端默认渲染的 IP 覆盖当前访问地址
32+
- 服务端枚举地址时应过滤回环、链路本地、虚拟网卡、隧道网卡、代理/VPN 类网卡,避免把不可用于局域网访问的地址展示给用户
3133
- 管理端和客户端二维码都支持点击放大预览
3234
- 客户端页面不暴露管理端入口
3335
- 管理端能力只允许服务端本机访问,局域网其他设备不能调用管理操作
36+
- 客户端上传文件 body 限制为 `10G`
37+
- 管理端共享本机文件不做大小限制;下载接口不做业务大小限制,并支持 `Range`/`Accept-Ranges: bytes`
38+
- 下载错误、缺失文件等服务端响应必须使用 UTF-8,避免移动端浏览器乱码
3439
- macOS 桌面端需要提供菜单栏托盘图标
3540
- Windows 桌面端也需要提供系统托盘图标,关闭窗口后保持后台运行,退出逻辑与 macOS 一致
3641
- 点击窗口关闭按钮只隐藏窗口,不直接退出应用
3742
- 关闭窗口后 Dock 中不保留应用图标,只在菜单栏托盘中保持运行
3843
- 菜单栏托盘需要提供单个“启动分享/停止分享”切换入口,并同步窗口内启动状态
3944
- 应用退出必须通过菜单栏托盘菜单中的“退出”
45+
- Windows 系统托盘菜单需要在“检查更新”和“退出”之间提供“关于”,弹窗展示应用名、版本、简介、作者 `Trifolium Wang` 和 GitHub 地址
4046
- macOS DMG 需要附带名为“损坏修复”的可执行文件,用于解除 `/Applications/FileShare.app` 的 quarantine 属性
4147
- 桌面应用需要支持 Tauri updater 自动更新,系统托盘中提供“检查更新”入口
4248
- updater 更新源使用 GitHub Release 中的 `latest-{{target}}-{{arch}}.json`
@@ -50,16 +56,17 @@
5056
- 停止服务
5157
- 发布文本
5258
- 使用系统文件选择器共享本机文件
53-
- 下载文件
54-
- 删除任意共享条目
59+
- 查看文件并在系统资源管理器/Finder 中定位
60+
- 移除任意共享条目
5561
- 复制文本
5662
- 从菜单栏托盘重新显示窗口或退出应用
5763

5864
- 管理端共享文件时:
5965
- 必须通过系统文件选择器选择
6066
- 只登记本机文件路径
6167
- 不复制到临时目录
62-
- 如果原文件被移动或删除,客户端下载会失败
68+
- 如果原文件被移动或删除,共享列表需要标记为失效;管理端“查看”按钮禁用或点击时弹窗提示,Web 端下载按钮禁用
69+
- 管理端列表中如果 Web 端正在下载文件,需要显示下载图标和速率,文字保持简洁,避免影响原有操作
6370

6471
## 客户端行为
6572

@@ -75,6 +82,7 @@
7582
- 文件保存到服务端用户的 `Downloads` 目录
7683
- 尽量保留原始文件名
7784
- 如果重名,自动追加 ` (1)`` (2)` 之类后缀
85+
- 移动端不依赖拖拽;点击选择文件后必须自动上传,不再要求二次点击确认
7886

7987
## 列表展示要求
8088

@@ -87,17 +95,23 @@
8795
- 文件项显示文件名
8896
- 文件项文件名后面带一个较小的 `🔗` 按钮,用于复制该文件的下载链接
8997
- 管理端操作按钮为横向两个小按钮
90-
- 客户端不显示删除按钮
98+
- 管理端文件主操作是“查看”,用于在本机资源管理器/Finder 中定位原文件
99+
- 管理端移除共享条目按钮文案为“移除”,不是“删除”,因为它只取消分享,不删除磁盘文件
100+
- 客户端不显示移除按钮
101+
- 失效文件需要灰色/删除线弱化展示;Web 端失效文件下载按钮禁用
91102

92103
## UI 约束
93104

94105
- 风格参考 Element UI / Element Plus 的干净后台样式
95106
- 上传文本和上传/共享文件使用弹窗
96-
- 客户端上传文件支持拖拽上传
107+
- 客户端桌面浏览器可以支持拖拽上传,但移动端必须以点击选择文件为主
97108
- 选择文件后自动上传
98109
- “上传文本/发布文本”和“上传文件/共享文件”按钮颜色区分
99110
- 按钮带 emoji 图标
100111
- 复制文件链接按钮点击后不改文案,只允许通过颜色变化给出反馈,避免布局抖动
112+
- 管理端启动分享按钮在未启动时居中显示并带呼吸效果;启动后缩小并移动到右下角,停止分享时反向过渡回中央
113+
- 管理端多地址下拉是辅助控件,视觉上应保持小、轻、低优先级,不能压过二维码和访问链接
114+
- 桌面端内容未超过视口时不应出现整体页面纵向滚动条;移动端保留自然页面滚动
101115

102116
## 技术结构
103117

@@ -109,7 +123,7 @@
109123

110124
- `public/app.js`
111125
- 管理端/客户端共享前端逻辑
112-
- 包含列表渲染、SSE、文本发布、文件上传、下载、删除、复制文本、复制文件链接、二维码放大、Tauri command 调用
126+
- 包含列表渲染、SSE、下载状态 SSE、文本发布、文件上传、下载、移除、复制文本、复制文件链接、二维码放大、Tauri command 调用
113127

114128
- `public/styles.css`
115129
- 管理端与客户端共用样式
@@ -121,7 +135,7 @@
121135

122136
- `src-tauri/src/server.rs`
123137
- Rust HTTP 服务
124-
- 提供客户端页面、HTTP 接口、SSE、文件下载、客户端上传保存、管理端本机访问控制
138+
- 提供客户端页面、HTTP 接口、SSE、下载状态 SSE、Range 文件下载、客户端上传保存、管理端本机访问控制、网卡地址枚举
125139

126140
- 共享列表元数据
127141
- 运行时保存在系统用户数据目录的 `FileShare/items.json`
@@ -148,6 +162,9 @@
148162
- `download_admin_file`
149163
- 管理端通过系统保存面板把共享文件保存到用户选择的位置
150164

165+
- `reveal_admin_file`
166+
- 管理端检查共享文件是否仍存在;存在则在系统资源管理器/Finder 中定位,不存在则返回错误给前端弹窗提示
167+
151168
- `check_for_updates`
152169
- 管理端检查并安装 Tauri updater 更新
153170

@@ -159,27 +176,33 @@
159176
- `/api/events`
160177
- SSE 实时推送列表变化
161178

179+
- `/api/download-events`
180+
- SSE 实时推送 Web 端下载状态,管理端用于展示正在下载和速率
181+
162182
- `/api/text`
163183
- 发布文本
164184
- `source=admin` 时必须来自服务端本机地址
165185

166186
- `/api/upload`
167187
- 客户端上传文件
168188
- 管理端不应走这个接口共享文件
189+
- 请求 body 限制为 `10G`
169190

170191
- `/api/local-file`
171192
- 管理端登记本机文件路径为共享文件
172193
- 只允许服务端本机地址访问
173194

174195
- `/api/items/:id/download`
175196
- 下载文件
197+
- 支持 `Range` 请求,响应主动包含 `Accept-Ranges: bytes`
198+
- 文件不存在时返回 UTF-8 错误响应
176199

177200
- `/api/items/:id`
178-
- 管理端删除共享条目
201+
- 管理端移除共享条目
179202
- 只允许服务端本机地址访问
180203

181204
- `/api/share-info`
182-
- 返回客户端访问链接和二维码
205+
- 返回客户端访问链接、二维码和可用访问地址列表
183206
- Web 客户端页面使用
184207

185208
- `/api/client-info`
@@ -190,6 +213,7 @@
190213

191214
- 管理端相关接口必须校验请求来源是否为服务端本机地址
192215
- 客户端只能做普通访问、上传文本、上传文件、下载文件、复制文本
216+
- 管理端共享文件不走 HTTP 上传接口,避免引入不必要的大小限制和复制语义
193217
- 这是一个面向可信局域网的工具,不包含登录、鉴权、HTTPS、病毒扫描或公网暴露能力
194218

195219
## 开发注意事项

README.md

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44

55
项目分成三层:
66

7-
- 管理端:Tauri 桌面 GUI,负责启动/停止局域网共享、选择本机文件、下载文件、托盘控制
8-
- 内嵌服务:Rust HTTP 服务,负责客户端页面、接口、SSE、文件下载、客户端上传保存和元数据持久化
7+
- 管理端:Tauri 桌面 GUI,负责启动/停止局域网共享、选择本机文件、查看本机文件、移除共享条目、托盘控制
8+
- 内嵌服务:Rust HTTP 服务,负责客户端页面、接口、SSE、下载状态、Range 文件下载、客户端上传保存和元数据持久化
99
- 客户端:局域网 Web 页面,负责上传文本、上传文件、下载文件、复制文本和复制下载链接
1010

1111
管理端可以共享本机文件和发布文本;客户端可以上传文件到服务端用户的 `Downloads` 目录,也可以发布文本。双方的共享列表会实时同步。
@@ -50,6 +50,17 @@ npm run dev
5050

5151
桌面端在 macOS 菜单栏和 Windows 系统托盘中提供后台入口,关闭窗口后只隐藏到后台,不直接退出。
5252

53+
如果本机存在多个可用于局域网访问的网卡地址,管理端右上角会显示地址下拉。切换地址后,管理端二维码、展示链接、复制链接以及列表里的文件下载链接都会同步使用选中的地址。Web 客户端不会显示这个下拉,它会使用浏览器当前访问地址生成展示链接和复制链接。
54+
55+
共享文件说明:
56+
57+
- 管理端共享文件只登记本机路径,不复制文件内容,也不走上传接口。
58+
- 客户端上传文件会保存到服务端当前用户的 `Downloads` 目录,重名时自动追加序号。
59+
- 客户端选择文件后会自动上传,不需要再次点击确认。
60+
- 上传接口 body 限制为 `10G`;管理端共享本机文件不做大小限制。
61+
- 下载接口不做业务大小限制,并支持 `Range``Accept-Ranges: bytes`
62+
- 如果管理端共享的源文件被移动或删除,列表会标记为失效;Web 端下载按钮会禁用,管理端查看时会提示源文件不存在。
63+
5364
开发模式说明:
5465

5566
- 当前前端静态资源位于 `public/`
@@ -59,9 +70,11 @@ npm run dev
5970
## 接口说明
6071

6172
- `/``/client.html` 都会返回客户端页面
62-
- `/api/share-info` 返回局域网客户端访问链接和二维码
73+
- `/api/share-info` 返回局域网客户端访问链接、二维码和可用访问地址列表
6374
- `/api/local-file``DELETE /api/items/:id` 只允许服务端本机访问,供管理端使用
6475
- `/api/client-info` 是本机管理信息接口,当前管理端前端不直接依赖它
76+
- `/api/download-events` 通过 SSE 返回正在下载的文件和速率,供管理端列表展示下载状态
77+
- `/api/items/:id/download` 支持普通下载和 Range 下载
6578

6679
## 构建打包
6780

@@ -84,9 +97,9 @@ GitHub Actions 发布前,需要在仓库 Settings -> Secrets and variables ->
8497

8598
- macOS Apple Silicon `.dmg`
8699
- macOS Intel `.dmg`
87-
- Windows x86 安装包
100+
- Windows x64 安装包
88101
- 自动更新所需的更新包、签名文件和 `latest-*.json`
89102

90103
构建完成后,产物会上传到对应的 GitHub Release。
91104

92-
已安装的桌面应用可以通过系统托盘菜单里的“检查更新”获取新版本。局域网 Web 客户端不需要单独更新,刷新页面即可使用当前桌面应用内置的新客户端页面。
105+
已安装的桌面应用可以通过系统托盘菜单里的“检查更新”获取新版本。Windows 系统托盘菜单还提供“关于”弹窗,展示版本、作者和项目地址。局域网 Web 客户端不需要单独更新,刷新页面即可使用当前桌面应用内置的新客户端页面。

docs/images/文件共享.png

43.1 KB
Loading

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "fileshare",
3-
"version": "1.0.5",
3+
"version": "1.0.6",
44
"private": true,
55
"type": "module",
66
"scripts": {

public/admin.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ <h1>FileShare</h1>
2929
<div id="clientQr" class="client-qr"></div>
3030
</button>
3131
<div class="client-link-box">
32+
<label class="address-picker">
33+
<span>访问地址</span>
34+
<select id="clientAddressSelect" class="client-address-select" aria-label="选择访问地址"></select>
35+
</label>
3236
<span id="clientUrlText">加载客户端链接</span>
3337
<button id="copyClientUrl" class="nav-link" type="button">复制链接</button>
3438
</div>

public/app.js

Lines changed: 89 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
isTauri: false,
1010
serverRunning: false,
1111
shareInfo: null,
12+
selectedAddressUrl: '',
1213
statusTimer: null,
1314
adminFileSharing: false,
1415
adminDragDropBound: false,
@@ -47,12 +48,42 @@
4748

4849
function getDownloadUrl(item) {
4950
const path = `/api/items/${item.id}/download`;
50-
if (state.shareInfo?.url) {
51-
return new URL(path, new URL(state.shareInfo.url).origin).toString();
51+
const selectedUrl = state.role === 'admin' ? currentShareUrl() : window.location.origin;
52+
if (selectedUrl) {
53+
return new URL(path, new URL(selectedUrl).origin).toString();
5254
}
5355
return new URL(path, window.location.origin).toString();
5456
}
5557

58+
function shareAddresses(info = state.shareInfo) {
59+
if (!info) return [];
60+
const addresses = Array.isArray(info.addresses) ? info.addresses : [];
61+
if (addresses.length) return addresses;
62+
return info.url ? [{ ip: info.ip || new URL(info.url).hostname, url: info.url, qr: info.qr }] : [];
63+
}
64+
65+
function currentShareAddress() {
66+
const addresses = shareAddresses();
67+
if (!addresses.length) return null;
68+
return addresses.find((item) => item.url === state.selectedAddressUrl) || addresses[0];
69+
}
70+
71+
function currentShareUrl() {
72+
return currentShareAddress()?.url || state.shareInfo?.url || '';
73+
}
74+
75+
function clientShareAddress() {
76+
const currentUrl = window.location.origin;
77+
const match = shareAddresses().find((item) => {
78+
try {
79+
return new URL(item.url).origin === currentUrl;
80+
} catch (_) {
81+
return false;
82+
}
83+
});
84+
return { url: currentUrl, qr: match?.qr || state.shareInfo?.qr };
85+
}
86+
5687
async function request(url, options = {}) {
5788
const response = await fetch(`${state.apiBase}${url}`, options);
5889
if (!response.ok) {
@@ -185,22 +216,54 @@
185216
const qr = $('clientQr');
186217
const text = $('clientUrlText');
187218
const copy = $('copyClientUrl');
188-
if (qr) {
189-
qr.innerHTML = info.qr;
190-
}
191-
if (text) {
192-
text.textContent = info.url;
193-
text.title = info.url;
219+
const select = $('clientAddressSelect');
220+
const addressPicker = select?.closest('.address-picker');
221+
const addresses = shareAddresses(info);
222+
if (state.role === 'admin' && select) {
223+
const previousUrl = state.selectedAddressUrl;
224+
const selected = addresses.find((item) => item.url === previousUrl)
225+
|| addresses[0]
226+
|| null;
227+
state.selectedAddressUrl = selected?.url || '';
228+
select.innerHTML = addresses.map((item) => {
229+
const label = item.ip || new URL(item.url).hostname;
230+
return `<option value="${escapeHtml(item.url)}"${item.url === state.selectedAddressUrl ? ' selected' : ''}>${escapeHtml(label)}</option>`;
231+
}).join('');
232+
if (addressPicker) addressPicker.hidden = addresses.length <= 1;
233+
select.onchange = () => {
234+
state.selectedAddressUrl = select.value;
235+
renderShareAddress();
236+
render();
237+
};
238+
} else {
239+
state.selectedAddressUrl = '';
194240
}
241+
renderShareAddress();
195242
if (copy) {
196243
copy.onclick = async () => {
197-
const copied = await copyText(info.url);
244+
const copied = await copyText(state.role === 'admin' ? currentShareUrl() : window.location.origin);
198245
copy.textContent = copied ? '已复制' : '复制失败';
199246
setTimeout(() => { copy.textContent = '复制链接'; }, 1200);
200247
};
201248
}
202249
}
203250

251+
function renderShareAddress() {
252+
const address = state.role === 'admin'
253+
? currentShareAddress()
254+
: clientShareAddress();
255+
const qr = $('clientQr');
256+
const text = $('clientUrlText');
257+
if (!address) return;
258+
if (qr) {
259+
qr.innerHTML = address.qr;
260+
}
261+
if (text) {
262+
text.textContent = address.url;
263+
text.title = address.url;
264+
}
265+
}
266+
204267
function resetServerUi() {
205268
state.serverRunning = false;
206269
state.apiBase = '';
@@ -219,8 +282,15 @@
219282
document.body.classList.add('server-stopped');
220283
$('clientQr') && ($('clientQr').innerHTML = '');
221284
$('clientUrlText') && ($('clientUrlText').textContent = '服务未启动');
285+
const select = $('clientAddressSelect');
286+
if (select) {
287+
select.innerHTML = '';
288+
const addressPicker = select.closest('.address-picker');
289+
if (addressPicker) addressPicker.hidden = true;
290+
}
222291
$('status') && ($('status').textContent = '未启动');
223292
state.shareInfo = null;
293+
state.selectedAddressUrl = '';
224294
setServerControlsStopped();
225295
}
226296

@@ -543,16 +613,20 @@
543613
if (!button || !dialog || !preview || !urlText || !copyButton) return;
544614

545615
button.addEventListener('click', () => {
546-
if (!state.shareInfo?.qr) return;
547-
preview.innerHTML = state.shareInfo.qr;
548-
urlText.textContent = state.shareInfo.url;
549-
urlText.title = state.shareInfo.url;
616+
const address = state.role === 'admin'
617+
? currentShareAddress()
618+
: clientShareAddress();
619+
if (!address?.qr) return;
620+
preview.innerHTML = address.qr;
621+
urlText.textContent = address.url;
622+
urlText.title = address.url;
550623
dialog.showModal();
551624
});
552625

553626
copyButton.addEventListener('click', async () => {
554-
if (!state.shareInfo?.url) return;
555-
const copied = await copyText(state.shareInfo.url);
627+
const url = state.role === 'admin' ? currentShareUrl() : window.location.origin;
628+
if (!url) return;
629+
const copied = await copyText(url);
556630
copyButton.textContent = copied ? '已复制' : '复制失败';
557631
setTimeout(() => { copyButton.textContent = '复制链接'; }, 1200);
558632
});

0 commit comments

Comments
 (0)