Skip to content
Merged
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
4 changes: 4 additions & 0 deletions desktop/frontend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,10 @@ function setProgress(data: Record<string, any>): void {
}

function finishProgress(data: Record<string, any>): void {
// 旧批次的完成事件不应清空新批次状态:校验 sendId 匹配当前批次,
// 否则发送中再发起的新批次会被旧批次的 transfer-finished 错误终结
// (清空 activeSendID、隐藏取消按钮)。迟到的旧事件直接丢弃。
if (data.sendId && data.sendId !== activeSendID) return;
const succeeded = Number(data.succeeded || 0);
const total = Number(data.total || 0);
window.clearTimeout(recvIdleTimer);
Expand Down
36 changes: 34 additions & 2 deletions mobile/android/app/src/main/kotlin/com/rexvane/inkhole/Exporter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,27 @@ object Exporter {
private const val EXPORT_BUFFER = 1024 * 1024
private val exportLock = Any()

/**
* 清理上次导出在 insert 后、update 前被中断留下的 IS_PENDING=1 孤儿记录。
* 进程被杀时 catch 块跑不到,这些记录会永久残留并占用下载列表,故在
* 启动时扫描 Download/InkHole/ 下所有 IS_PENDING=1 的记录删除。只清本应用
* 目录(RELATIVE_PATH 前缀匹配),避免误删其他应用的 pending 文件。
*/
fun cleanupPendingOrphans(ctx: Context) {
if (Build.VERSION.SDK_INT < 29) return
try {
val prefix = "${publicInboxRelativePath()}/%"
ctx.contentResolver.delete(
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
"${MediaStore.MediaColumns.IS_PENDING}=1" +
" AND ${MediaStore.MediaColumns.RELATIVE_PATH} LIKE ?",
arrayOf(prefix),
)
} catch (_: Exception) {
// 清理失败不阻塞启动;孤儿会在下次启动重试清理。
}
}

/** 导出结果:display 为对用户展示的落点(如 Download/InkHole/xx)。 */
data class Outcome(val name: String, val location: String)

Expand All @@ -51,7 +72,14 @@ object Exporter {
synchronized(exportLock) {
if (src.isDirectory) {
val target = createUniqueDirectory(root, src.name)
copyTreeInto(ctx, src, target)
try {
copyTreeInto(ctx, src, target)
} catch (error: Exception) {
// 部分拷贝失败时回滚已创建的子树,避免在用户目录残留半截
// 文件夹;私有源不删(数据不丢),回传异常走默认回退路径。
target.delete()
throw error
}
src.deleteRecursively()
return Outcome(target.name ?: src.name, label)
}
Expand Down Expand Up @@ -173,6 +201,7 @@ object Exporter {
}
val publicRoot = uniqueMediaStoreFolderName(ctx, src.name)
val inserted = ArrayList<Uri>(files.size)
var publishedCount = 0
try {
for (file in files) {
val relative = file.relativeTo(src).invariantSeparatorsPath
Expand Down Expand Up @@ -207,11 +236,14 @@ object Exporter {
put(MediaStore.MediaColumns.IS_PENDING, 0)
}, null, null)
if (published <= 0) throw IOException("无法发布下载文件夹")
publishedCount++
}
src.deleteRecursively()
return@synchronized Outcome(publicRoot, "Download/InkHole")
} catch (_: Exception) {
inserted.forEach { uri ->
// 只回滚未发布的(从 publishedCount 开始的),保留已发布到
// Download/InkHole 的文件,避免用户看到文件先出现再消失。
for (uri in inserted.drop(publishedCount)) {
try { ctx.contentResolver.delete(uri, null, null) } catch (_: Exception) {}
}
return@synchronized Outcome(src.name, "")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class MainActivity : FlutterActivity() {
super.onCreate(savedInstanceState)
startLanService()
cleanupShareCache()
exportExecutor.execute { Exporter.cleanupPendingOrphans(this) }
handleIncomingIntent(intent)
}

Expand Down
36 changes: 28 additions & 8 deletions mobile/lib/core/inkhole_core.dart
Original file line number Diff line number Diff line change
Expand Up @@ -367,12 +367,21 @@ class InkHoleCore {
final id = _nextRequest++;
final completer = Completer<dynamic>();
_pending[id] = completer;
_commands!.send(<String, dynamic>{
'operation': 'call',
'id': id,
'method': method,
'params': params,
});
try {
_commands!.send(<String, dynamic>{
'operation': 'call',
'id': id,
'method': method,
'params': params,
});
} catch (error) {
// SendPort 已关(worker isolate 被外部杀掉):Completer 必须显式清理,
// 否则永远留在 _pending 里导致 map 缓慢膨胀。
_pending.remove(id);
completer.completeError(
StateError('InkHole core channel is closed: $error'),
);
}
final result = await completer.future;
if (result == null) return <String, dynamic>{};
if (result is Map) return Map<String, dynamic>.from(result);
Expand All @@ -385,8 +394,19 @@ class InkHoleCore {
final id = _nextRequest++;
final completer = Completer<dynamic>();
_pending[id] = completer;
commands.send(<String, dynamic>{'operation': 'shutdown', 'id': id});
await completer.future;
try {
commands.send(<String, dynamic>{'operation': 'shutdown', 'id': id});
} catch (_) {
// SendPort 已关,直接走强制清理。
}
// worker 的命令处理是串行的:若正阻塞在 in-flight native 调用(如大文件
// lan.send),shutdown 命令排队等待,completer 永不完成。给一个超时,
// 超时后强杀 isolate 兜底,避免 close() 永久挂起。
try {
await completer.future.timeout(const Duration(seconds: 3));
} on TimeoutException {
// 优雅关闭超时,强制释放。
}
_receive?.close();
_isolate?.kill(priority: Isolate.immediate);
_isolate = null;
Expand Down
23 changes: 18 additions & 5 deletions mobile/lib/home_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ class _HomePageState extends State<HomePage> {
rethrow;
}
}
_applyLanStartResult(result);
await _applyLanStartResult(result);
}

Future<Map<String, dynamic>> _lanStartCall() async {
Expand All @@ -245,15 +245,20 @@ class _HomePageState extends State<HomePage> {
});
}

void _applyLanStartResult(Map<String, dynamic> result) {
Future<void> _applyLanStartResult(Map<String, dynamic> result) async {
_sessionId = result['session_id']?.toString();
_actualPort = asInt(result['port']);
_identityPrivate = result['identity_private']?.toString();
final String? identityPrivate = _identityPrivate;
if (identityPrivate != null) {
unawaited(
_secureStorage.write(key: 'identity_private', value: identityPrivate),
);
// 设备身份(TLS 证书 + ed25519 私钥)必须落盘后才能算启动成功;否则
// 首启崩溃或安全存储写入失败会导致下次冷启生成全新身份,对端固定指纹
// 失配(SSH 配对断裂、被当新设备)。await 而非 unawaited,失败时提示。
try {
await _secureStorage.write(key: 'identity_private', value: identityPrivate);
} catch (_) {
_setStatus('身份保存失败,重启后可能需要重新配对');
}
}
if (_sshEnabled) unawaited(_startSsh());
}
Expand Down Expand Up @@ -393,6 +398,10 @@ class _HomePageState extends State<HomePage> {
_cancelledSends.clear();
});
case 'lan.peers':
// 旧会话残余的 peers 快照会污染设备列表(尤其 _restartLan 期间),
// 只处理当前会话事件;_sessionId 为 null(重启中)时也忽略。
final String? peersSession = data['session_id']?.toString();
if (peersSession != null && peersSession != _sessionId) return;
final List<dynamic> values =
data['peers'] as List<dynamic>? ?? const <dynamic>[];
if (!mounted) return;
Expand All @@ -412,6 +421,10 @@ class _HomePageState extends State<HomePage> {
}
});
case 'lan.status':
// 与 lan.peers 同理:重启期间(_sessionId 为 null)忽略旧会话状态,
// 避免旧状态文字短暂覆盖"设置已生效"等新提示。
final String? statusSession = data['session_id']?.toString();
if (statusSession != null && statusSession != _sessionId) return;
_setStatus(data['message']?.toString() ?? '局域网状态已更新');
case 'lan.progress':
final String key = data['send_id']?.toString() ??
Expand Down
2 changes: 1 addition & 1 deletion mobile/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: inkhole_mobile
description: InkHole mobile client backed by the shared Rust transport core.
publish_to: "none"
version: 2.0.7+8
version: 2.0.8+9
environment:
sdk: ">=3.5.0 <4.0.0"
flutter: ">=3.24.0"
Expand Down
6 changes: 3 additions & 3 deletions rust/Cargo.lock

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

2 changes: 1 addition & 1 deletion rust/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ members = [
resolver = "3"

[workspace.package]
version = "2.0.7"
version = "2.0.8"
edition = "2024"
rust-version = "1.93"
license = "MIT"
Expand Down
2 changes: 1 addition & 1 deletion rust/apps/inkhole-desktop/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "墨洞 InkHole",
"version": "2.0.7",
"version": "2.0.8",
"identifier": "com.rexvane.inkhole",
"build": {
"beforeDevCommand": "node inkhole-desktop/scripts/frontend.mjs dev",
Expand Down
23 changes: 20 additions & 3 deletions rust/crates/inkhole-core/src/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -982,11 +982,28 @@ fn apply_probe_outcome(
&& record.peer.hosts.contains(&key.address.ip().to_string())
})
.is_some_and(|record| {
if record.farewelled {
return true;
}
let failures = record.failures.entry(key.address).or_default();
*failures = failures.saturating_add(1);
if record.farewelled {
// 收到 goodbye 后,只要每个已知 host 都已探测失败过一次
// 即淘汰;但必须等所有 host 都失败,否则多宿主对端首个地址
// 失败即移除会导致列表闪烁,健康地址稍后成功只能重新插入,
// 极端下被 MAX_DISCOVERED_PEERS 拒绝而永久丢失。成功探针
// 会把 farewelled 清零恢复正常。
return record.peer.hosts.iter().all(|host| {
host.parse::<IpAddr>()
.ok()
.map(|ip| {
record
.failures
.get(&SocketAddr::new(ip, record.peer.port))
.copied()
.unwrap_or(0)
> 0
})
.unwrap_or(true)
});
}
record.peer.hosts.iter().all(|host| {
host.parse::<IpAddr>()
.ok()
Expand Down
19 changes: 16 additions & 3 deletions rust/crates/inkhole-core/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -626,9 +626,15 @@ impl JsonService {
"closing stale LAN sessions before starting a new one"
);
}
close_ssh_relays(stale_relays).await?;
// 关闭旧会话/中继的错误不应阻断创建新会话:旧会话已从 state 移除,
// Drop 兜底会 cancel + close_immediately 释放端口;这里仅记录警告。
if let Err(error) = close_ssh_relays(stale_relays).await {
tracing::warn!(%error, "failed to close a stale SSH relay during lan.start");
}
for session in stale_sessions {
session.close().await?;
if let Err(error) = session.close().await {
tracing::warn!(%error, "failed to close a stale LAN session during lan.start");
}
}
params.instance_id = params.instance_id.trim().to_ascii_lowercase();
if params.peer_name.trim().is_empty()
Expand Down Expand Up @@ -1903,9 +1909,16 @@ fn derive_transfer_id(
.map(|elapsed| elapsed.as_millis() as u64)
.unwrap_or_default();
let mut hasher = blake3::Hasher::new();
// Windows 文件系统不区分大小写,同一文件可能以不同大小写路径发起发送;
// 归一化为小写再哈希,保证断点续传复用同一 transfer_id(与
// normalize_wormhole_paths 的 eq_ignore_ascii_case 去重逻辑一致)。
#[cfg(windows)]
let source_bytes = source.to_string_lossy().to_ascii_lowercase();
#[cfg(not(windows))]
let source_bytes = source.to_string_lossy();
for field in [
instance_id.as_bytes(),
source.to_string_lossy().as_bytes(),
source_bytes.as_bytes(),
target.as_bytes(),
] {
hasher.update(&(field.len() as u64).to_le_bytes());
Expand Down
9 changes: 9 additions & 0 deletions rust/crates/inkhole-core/src/transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,15 @@ pub async fn send_file(
sent += read as u64;
emit_progress(&options, &offer, sent);
}
// 源文件在传输期间被追加写入会导致接收端拿到旧快照且 blake3 校验仍通过
// (校验基于原始大小),发送方误报成功。读完声明大小后再读 1 字节确认 EOF,
// 与文件夹路径一致(见 send_folder_stream 尾部检查)。
let mut extra = [0_u8; 1];
if file.read(&mut extra).await? != 0 {
return Err(CoreError::InvalidTransfer(
"source file grew while sending".into(),
));
}
send.finish()
.map_err(|error| CoreError::Protocol(format!("finish QUIC stream: {error}")))?;
let response: TransferResponse =
Expand Down
Loading