Skip to content

Commit 4e43620

Browse files
committed
release: v0.1.19
1 parent 3a5e9db commit 4e43620

11 files changed

Lines changed: 768 additions & 48 deletions

File tree

CHANGELOG.en.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ All notable changes to `ii` are documented here. The default Chinese version is
66

77
Nothing yet.
88

9+
## 0.1.19 - 2026-07-31
10+
11+
### Added
12+
13+
- Added `ii web [directory]` for temporary LAN recursive directory browsing, normal file responses, and multi-file uploads; it supports `--token` and `--path`, and prints a QR code plus all IPv4 LAN URLs in the terminal.
14+
- Added `ii send ... --web --path <dir>` to write web uploads directly into a chosen directory; relative paths are based on the startup directory, while the default remains `./ii/`.
15+
916
## 0.1.18 - 2026-07-30
1017

1118
### Added

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@
66

77
暂无。
88

9+
## 0.1.19 - 2026-07-31
10+
11+
### 新增
12+
13+
- 新增 `ii web [目录]`:临时提供局域网递归目录浏览、普通文件访问与多文件上传;支持 `--token``--path`,终端输出二维码与全部 IPv4 LAN URL。
14+
- `ii send ... --web --path <目录>` 可指定网页上传文件直接写入的目录;相对路径按启动目录解析,未指定时仍写入 `./ii/`
15+
916
## 0.1.18 - 2026-07-30
1017

1118
### 新增

Cargo.lock

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

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ exclude = ["vendor/iroh", "vendor/suppaftp"]
44
resolver = "3"
55

66
[workspace.package]
7-
version = "0.1.18"
7+
version = "0.1.19"
88
edition = "2024"
99
rust-version = "1.91"
1010
license = "MIT"

README.en.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323

2424
- The sender serves one successful receive by default, then exits
2525
- It briefly probes for a usable path through complex networks, with S3, WebDAV, FTP, and SFTP backend options
26-
- `ii send <file-or-folder> --web` opens a temporary LAN download page
26+
- `ii send <file-or-folder> --web` opens a temporary LAN download page, while `ii web [directory]` browses a LAN directory
2727
- Receives resume automatically by default
2828
- Existing files with the same MD5 are skipped
2929
- Folders can be sent directly
@@ -131,9 +131,14 @@ Open a temporary LAN download page:
131131
ii send .\video.mp4 --web
132132
ii send .\my-folder --web
133133
ii send .\video.mp4 --web --token A1b2C3d4E5f6G7h8
134+
ii send .\video.mp4 --web --path .\uploads
135+
ii web
136+
ii web .\shared --token A1b2C3d4E5f6G7h8 --path .\uploads
134137
```
135138

136-
The command displays a QR code directly above the primary LAN URL for opening the download page, then lists the remaining physical and virtual adapter IPv4 URLs under `other:`. The QR code at the top of that page points directly to `/download` for phone downloads. The page can also upload multiple files into `./ii/` under the directory where the command started; directory uploads are not supported. Folders download as `.tar` archives. Press `Ctrl+C` to stop the server. Optional `--token <value>` adds a path access token to the page, download, and upload URLs; it must be 16 to 128 ASCII letters, digits, `-`, or `_`, and omitting it keeps the unprotected URLs. This mode has no account authentication and is intended only for short-lived, trusted LAN sharing.
139+
The command displays a QR code directly above the primary LAN URL for opening the download page, then lists the remaining physical and virtual adapter IPv4 URLs under `other:`. The QR code at the top of that page points directly to `/download` for phone downloads. The page can also upload multiple files into `./ii/` under the directory where the command started. Use `--path <dir>` to write directly into a different directory; relative paths are based on the startup directory, and the directory is created on the first upload. Directory uploads are not supported. Folders download as `.tar` archives. Press `Ctrl+C` to stop the server. Optional `--token <value>` adds a path access token to the page, download, and upload URLs; it must be 16 to 128 ASCII letters, digits, `-`, or `_`, and omitting it keeps the unprotected URLs. This mode has no account authentication and is intended only for short-lived, trusted LAN sharing.
140+
141+
Without a path, `ii web` serves the directory where the command starts; with a path, it accepts only an existing directory. Its page provides recursive nginx-style directory browsing, normal file responses, and multi-file uploads. The terminal still shows the QR code, primary IPv4 LAN URL, and `other:` adapter URLs, but the directory page has no QR code. `--token` and `--path` follow the same rules as `ii send ... --web`; `-p` does not apply to `ii web`.
137142

138143
Prefer local network paths and avoid public relays:
139144

README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525

2626
自动找路 / 局域网优先 / 可公网中继或 `--s3` / `--webdav` / `--ftp` / `--sftp` 中继
2727

28-
也可用 `ii send <文件或文件夹> --web` 临时开一个局域网下载页
28+
也可用 `ii send <文件或文件夹> --web` 临时开下载页,或用 `ii web [目录]` 浏览局域网目录
2929

3030
断点续收 / 秒传跳过 / 冲突覆盖 / 支持传完清理中转
3131

@@ -134,9 +134,14 @@ ii recv ii1k7v...x9a --stdout > project.tar.gz
134134
ii send .\video.mp4 --web
135135
ii send .\my-folder --web
136136
ii send .\video.mp4 --web --token A1b2C3d4E5f6G7h8
137+
ii send .\video.mp4 --web --path .\uploads
138+
ii web
139+
ii web .\shared --token A1b2C3d4E5f6G7h8 --path .\uploads
137140
```
138141

139-
命令行会在主局域网 URL 上方显示进入下载页的二维码,并在 `other:` 下列出其余物理和虚拟网卡的 IPv4 URL;下载页顶部的二维码则直达 `/download`,方便手机扫码下载。网页也可以一次上传多个文件,接收文件写到启动命令当前目录的 `./ii/`,不支持上传目录。目录会下载为 `.tar`。按 `Ctrl+C` 关闭服务。可选 `--token <value>` 为网页、下载和上传加路径访问令牌,令牌只能是 16 到 128 个 ASCII 字母、数字、`-``_`;不带时保持无令牌 URL。该模式没有账号鉴权,只适合临时、可信的局域网。
142+
命令行会在主局域网 URL 上方显示进入下载页的二维码,并在 `other:` 下列出其余物理和虚拟网卡的 IPv4 URL;下载页顶部的二维码则直达 `/download`,方便手机扫码下载。网页也可以一次上传多个文件,默认写到启动命令当前目录的 `./ii/`;可用 `--path <目录>` 改为直接写入指定目录,相对路径仍以启动目录为基准,目录会在首次上传时创建。不支持上传目录。目录会下载为 `.tar`。按 `Ctrl+C` 关闭服务。可选 `--token <value>` 为网页、下载和上传加路径访问令牌,令牌只能是 16 到 128 个 ASCII 字母、数字、`-``_`;不带时保持无令牌 URL。该模式没有账号鉴权,只适合临时、可信的局域网。
143+
144+
`ii web` 不带路径时服务启动命令的当前目录;带路径时只接受已有目录。网页提供 nginx 风格的递归目录浏览、普通文件访问和多文件上传,终端输出同样有二维码、主 IPv4 LAN URL 与 `other:` 网卡 URL,但目录网页不显示二维码。`--token``--path` 的规则和 `ii send ... --web` 相同,`-p` 不适用于 `ii web`
140145

141146
局域网优先,不走公网中继:
142147

SIZE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ All measurements use `cargo build -p ii --release --locked` followed by UPX
2828
| Web-share other IPv4 URL list | 5,979,648 | 2,160,640 | +1,024 | `cargo test -p ii --locked` (77 tests), isolated `cargo build -p ii --release --locked`, UPX `5.1.0 --best --lzma`, `upx -t` |
2929
| Web-share bidirectional file upload | 5,992,960 | 2,166,784 | +5,632 | `cargo test -p ii --locked` (78 tests), `cargo build -p ii --release --locked`, UPX `5.1.0 --best --lzma`, `upx -t` |
3030
| Web-share path access token | 6,001,664 | 2,169,344 | +2,560 | `cargo test -p ii --locked` (83 tests), `cargo build -p ii --release --locked`, UPX `5.1.0 --best --lzma`, `upx -t` |
31+
| Web upload path and `ii web` directory service | 6,038,016 | 2,180,608 | +11,264 | `cargo test -p ii --locked` (92 tests), `cargo build -p ii --release --locked`, UPX `5.1.0 --best --lzma`, `upx -t` |
3132

3233
Equivalent release rebuilds have varied by up to `1,536` UPX bytes; the latest
3334
measurement is recorded with the pinned `5.1.0` packer.

ii.md

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@
44

55
## 一句话
66

7-
`ii send` 发,`ii recv` 收,`ii relay` 管中继,`ii doctor` 查问题,`ii version` 看版本。
7+
`ii send` 发,`ii recv` 收,`ii web` 开局域网目录,`ii relay` 管中继,`ii doctor` 查问题,`ii version` 看版本。
88

99
## 命令总览
1010

1111
```text
12-
ii send [<path>] [--name <name>] [-t] [-c] [-o <path>] [--web [--token <value>] | --s3 | --webdav | --ftp | --sftp] [--profile <name>] [-d] [-p] [--local] [--relay <https-url> [-k]] [--no-relay]
12+
ii send [<path>] [--name <name>] [-t] [-c] [-o <path>] [--web [--token <value>] [--path <dir>] | --s3 | --webdav | --ftp | --sftp] [--profile <name>] [-d] [-p] [--local] [--relay <https-url> [-k]] [--no-relay]
13+
ii web [<目录>] [--token <value>] [--path <目录>]
1314
ii recv <ticket> [-o <dir>] [--stdout] [--overwrite] [--resume] [--local] [--trace]
1415
ii relay (--public <https-url> | --tls <domain> --cert <path> --key <path>) [-H <bind-port>]
1516
ii doctor
@@ -78,11 +79,14 @@ tar czf - .\project | ii send --name project.tar.gz
7879
如果文件已存在,会覆盖。这个 `-o` 属于 `ii send`,不影响 `ii recv -o <dir>` 的保存目录语义。
7980

8081
`--web`
81-
: 在局域网内临时开放一个无账号鉴权 HTTP 下载页。执行后会在主 URL 上方展示进入下载页的二维码,随后在 `other:` 下列出其余物理和虚拟网卡的 IPv4 URL;下载页顶部二维码直达 `/download`。网页可一次上传多个文件,接收文件写到启动命令当前目录的 `./ii/`,不支持上传目录。按 `Ctrl+C` 停止服务。文件直接下载;文件夹会按原目录名打包为 `.tar` 下载。它不生成 ticket,不能和 `-c``-o``--s3``--webdav``--ftp``--sftp``--local``--relay``--no-relay` 同时使用。
82+
: 在局域网内临时开放一个无账号鉴权 HTTP 下载页。执行后会在主 URL 上方展示进入下载页的二维码,随后在 `other:` 下列出其余物理和虚拟网卡的 IPv4 URL;下载页顶部二维码直达 `/download`。网页可一次上传多个文件,默认写到启动命令当前目录的 `./ii/`,不支持上传目录。按 `Ctrl+C` 停止服务。文件直接下载;文件夹会按原目录名打包为 `.tar` 下载。它不生成 ticket,不能和 `-c``-o``--s3``--webdav``--ftp``--sftp``--local``--relay``--no-relay` 同时使用。
8283

8384
`--token <value>`
8485
: 仅和 `--web` 同用,把网页、下载和上传 URL 固定到 `/<value>/` 路径下;遗漏或写错路径会返回 `404``value` 必须为 16 到 128 个 ASCII 字母、数字、`-``_`。不提供时仍使用原来的无令牌 URL。
8586

87+
`--path <dir>`
88+
: 仅和 `--web` 同用,指定网页上传文件直接写入的目录。相对路径以启动命令当前目录为基准;目录在首次上传时创建。不提供时仍写入当前目录的 `./ii/``-p` 仍是 FTP/SFTP/WebDAV 的便携 ticket 参数。
89+
8690
`--local`
8791
: 只走局域网优先路径,不走公网发现,不走公网 relay。
8892

@@ -134,6 +138,20 @@ tar czf - .\project | ii send --name project.tar.gz
134138
- 指定 `--relay https://...` 后,当前发送会强制走 HTTPS relay-only,不使用默认公网 relay。
135139
- 手工证书 relay 不带 `-k`;自签 relay 必须带 `-k`
136140

141+
## `ii web`
142+
143+
```powershell
144+
ii web
145+
ii web .\shared
146+
ii web .\shared --token A1b2C3d4E5f6G7h8 --path .\uploads
147+
```
148+
149+
`ii web` 不带路径时服务启动命令当前目录;带路径时必须是已有目录,文件路径会报错。网页提供 nginx 风格的递归目录浏览:目录可继续进入、`../` 返回父目录、每项显示名称、修改时间和大小;普通文件直接响应,不强制下载。网页顶部可一次上传多个独立文件,不支持上传目录。
150+
151+
命令行会在主 IPv4 LAN URL 上方打印根页二维码,并在 `other:` 下打印其他物理和虚拟网卡 URL;网页不显示二维码。按 `Ctrl+C` 关闭服务。
152+
153+
`--token <value>` 把目录、文件和上传 URL 固定到 `/<value>/` 下;遗漏或写错返回 `404`。令牌必须为 16 到 128 个 ASCII 字母、数字、`-``_``--path <目录>` 指定网页上传文件直接写入的目录;相对路径按启动目录解析,首次上传才创建目录,未指定时写入启动目录下的 `./ii/``-p` 不适用于 `ii web`,仍仅用于 WebDAV、FTP、SFTP 的便携 ticket。
154+
137155
## `ii recv`
138156

139157
### 用法

src/cli.rs

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ pub struct Cli {
1010
#[derive(Debug)]
1111
pub enum Command {
1212
Send(SendArgs),
13+
Web(WebArgs),
1314
Recv(RecvArgs),
1415
Relay(RelayArgs),
1516
Doctor,
@@ -31,13 +32,21 @@ pub struct SendArgs {
3132
pub sftp: bool,
3233
pub web: bool,
3334
pub web_token: Option<String>,
35+
pub web_upload_dir: Option<PathBuf>,
3436
pub portable_webdav: bool,
3537
pub local: bool,
3638
pub relay: Option<iroh::RelayUrl>,
3739
pub accept_self_signed_relay: bool,
3840
pub no_relay: bool,
3941
}
4042

43+
#[derive(Debug, Clone, Default)]
44+
pub struct WebArgs {
45+
pub dir: Option<PathBuf>,
46+
pub web_token: Option<String>,
47+
pub web_upload_dir: Option<PathBuf>,
48+
}
49+
4150
#[derive(Debug, Clone)]
4251
pub struct RecvArgs {
4352
pub ticket: String,
@@ -137,6 +146,7 @@ where
137146
let rest = args.split_off(1);
138147
let command = match command.as_str() {
139148
"send" => Command::Send(parse_send(rest)?),
149+
"web" => Command::Web(parse_web(rest)?),
140150
"recv" => Command::Recv(parse_recv(rest)?),
141151
"relay" => Command::Relay(parse_relay(rest)?),
142152
"doctor" => reject_extra("doctor", rest).map(|_| Command::Doctor)?,
@@ -158,6 +168,7 @@ fn parse_send(args: Vec<String>) -> Result<SendArgs, ParseAction> {
158168
Some(("profile", value)) => out.profile = Some(value.to_string()),
159169
Some(("relay", value)) => out.relay = Some(parse_relay_url(value)?),
160170
Some(("token", value)) => out.web_token = Some(value.to_string()),
171+
Some(("path", value)) => out.web_upload_dir = Some(PathBuf::from(value)),
161172
Some((flag, _)) => {
162173
return Err(ParseAction::error(format!("unknown option `--{flag}`")));
163174
}
@@ -175,6 +186,7 @@ fn parse_send(args: Vec<String>) -> Result<SendArgs, ParseAction> {
175186
"--sftp" => out.sftp = true,
176187
"--web" => out.web = true,
177188
"--token" => out.web_token = Some(iter.value("--token")?),
189+
"--path" => out.web_upload_dir = Some(PathBuf::from(iter.value("--path")?)),
178190
"-p" => out.portable_webdav = true,
179191
"--local" => out.local = true,
180192
"--relay" => out.relay = Some(parse_relay_url(&iter.value("--relay")?)?),
@@ -196,6 +208,44 @@ fn parse_send(args: Vec<String>) -> Result<SendArgs, ParseAction> {
196208
Ok(out)
197209
}
198210

211+
fn parse_web(args: Vec<String>) -> Result<WebArgs, ParseAction> {
212+
let mut out = WebArgs::default();
213+
let mut iter = ArgsIter::new(args);
214+
215+
while let Some(arg) = iter.next() {
216+
match split_long_value(&arg) {
217+
Some(("token", value)) => out.web_token = Some(value.to_string()),
218+
Some(("path", value)) => out.web_upload_dir = Some(PathBuf::from(value)),
219+
Some((flag, _)) => {
220+
return Err(ParseAction::error(format!("unknown option `--{flag}`")));
221+
}
222+
None => match arg.as_str() {
223+
"-h" | "--help" => return Err(ParseAction::help(WEB_HELP)),
224+
"--token" => out.web_token = Some(iter.value("--token")?),
225+
"--path" => out.web_upload_dir = Some(PathBuf::from(iter.value("--path")?)),
226+
_ if arg.starts_with('-') => {
227+
return Err(ParseAction::error(format!("unknown option `{arg}`")));
228+
}
229+
_ => {
230+
if out.dir.replace(PathBuf::from(&arg)).is_some() {
231+
return Err(ParseAction::error("web accepts only one directory"));
232+
}
233+
}
234+
},
235+
}
236+
}
237+
238+
if let Some(token) = out.web_token.as_deref()
239+
&& !is_valid_web_token(token)
240+
{
241+
return Err(ParseAction::error(
242+
"--token must contain 16 to 128 ASCII letters, digits, '-' or '_'",
243+
));
244+
}
245+
246+
Ok(out)
247+
}
248+
199249
fn parse_recv(args: Vec<String>) -> Result<RecvArgs, ParseAction> {
200250
let mut ticket = None;
201251
let mut out_dir = None;
@@ -350,6 +400,9 @@ fn validate_send(args: &SendArgs) -> Result<(), ParseAction> {
350400
if args.web_token.is_some() && !args.web {
351401
return Err(ParseAction::error("--token requires --web"));
352402
}
403+
if args.web_upload_dir.is_some() && !args.web {
404+
return Err(ParseAction::error("--path requires --web"));
405+
}
353406
if let Some(token) = args.web_token.as_deref()
354407
&& !is_valid_web_token(token)
355408
{
@@ -473,6 +526,7 @@ ii file transfer
473526
474527
Usage:
475528
ii send [options] [path]
529+
ii web [directory] [--token <value>] [--path <dir>]
476530
ii recv [options] <ticket>
477531
ii relay [options]
478532
ii doctor
@@ -494,6 +548,7 @@ Options:
494548
--sftp
495549
--web
496550
--token <value>
551+
--path <dir>
497552
-p
498553
-d
499554
--profile <name>
@@ -503,6 +558,15 @@ Options:
503558
--no-relay
504559
";
505560

561+
const WEB_HELP: &str = "\
562+
Usage:
563+
ii web [directory] [--token <value>] [--path <dir>]
564+
565+
Options:
566+
--token <value>
567+
--path <dir>
568+
";
569+
506570
const RECV_HELP: &str = "\
507571
Usage:
508572
ii recv [options] <ticket>
@@ -646,6 +710,67 @@ mod tests {
646710
}
647711
}
648712

713+
#[test]
714+
fn send_accepts_web_upload_path() {
715+
for args in [
716+
vec!["ii", "send", "file.txt", "--web", "--path", "uploads"],
717+
vec!["ii", "send", "file.txt", "--web", "--path=uploads"],
718+
] {
719+
let cli = Cli::parse_from(args);
720+
match cli.command {
721+
Command::Send(args) => {
722+
assert_eq!(args.web_upload_dir, Some(PathBuf::from("uploads")));
723+
}
724+
_ => panic!("expected send command"),
725+
}
726+
}
727+
}
728+
729+
#[test]
730+
fn web_accepts_directory_token_and_upload_path() {
731+
let token = "A1b2C3d4E5f6G7h8";
732+
for args in [
733+
vec!["ii", "web"],
734+
vec!["ii", "web", "shared", "--token", token, "--path", "uploads"],
735+
vec![
736+
"ii",
737+
"web",
738+
"shared",
739+
"--token=A1b2C3d4E5f6G7h8",
740+
"--path=uploads",
741+
],
742+
] {
743+
let cli = Cli::parse_from(args);
744+
match cli.command {
745+
Command::Web(args) => {
746+
if args.dir.is_some() {
747+
assert_eq!(args.dir, Some(PathBuf::from("shared")));
748+
assert_eq!(args.web_token.as_deref(), Some(token));
749+
assert_eq!(args.web_upload_dir, Some(PathBuf::from("uploads")));
750+
}
751+
}
752+
_ => panic!("expected web command"),
753+
}
754+
}
755+
}
756+
757+
#[test]
758+
fn web_rejects_invalid_options_and_multiple_directories() {
759+
for args in [
760+
vec!["ii", "web", "first", "second"],
761+
vec!["ii", "web", "-p"],
762+
vec!["ii", "web", "--token", "too-short"],
763+
vec!["ii", "web", "--token", "A1b2C3d4E5f6G7h!"],
764+
vec!["ii", "web", "--token"],
765+
vec!["ii", "web", "--path"],
766+
] {
767+
assert!(matches!(
768+
parse_args(args),
769+
Err(ParseAction::Print { code: 2, .. })
770+
));
771+
}
772+
}
773+
649774
#[test]
650775
fn web_token_accepts_hyphen_and_underscore() {
651776
assert!(is_valid_web_token("A1b2C3d4E5f6G_-h"));
@@ -681,6 +806,14 @@ mod tests {
681806
}
682807
}
683808

809+
#[test]
810+
fn send_rejects_web_upload_path_without_web() {
811+
assert!(matches!(
812+
parse_args(["ii", "send", "file.txt", "--path", "uploads"]),
813+
Err(ParseAction::Print { code: 2, .. })
814+
));
815+
}
816+
684817
#[test]
685818
fn send_accepts_ftp_and_sftp_storage_options() {
686819
let ftp = Cli::parse_from(["ii", "send", "--ftp", "-p", "-d", "file.txt"]);

0 commit comments

Comments
 (0)