Skip to content

Commit 61eedaa

Browse files
authored
fix(peer): restore terminal control and file downloads (#1666)
1 parent 0c5febe commit 61eedaa

9 files changed

Lines changed: 598 additions & 26 deletions

File tree

docs/architecture/peer-device-mode.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,9 @@ FS) and must not be mixed with Peer Device Mode.
5252
- HostInvoke on the controller is **priority-queued** (max 2 in flight). Session
5353
restore / session-list / dialog / workspace-startup commands outrank background
5454
`git_*` / `ssh_*` / `lsp_*` / `search_*` / FS / canvas / editor RPCs so hydrate
55-
is not starved into relay HTTP 504s.
55+
is not starved into relay HTTP 504s. Terminal commands are always interactive
56+
priority, and one slot is kept free from low-priority background work so input
57+
cannot be trapped behind two slow polling requests.
5658
- While Peer Mode is active, background noise is reduced further:
5759
- controller-local SSH heartbeats and remote-workspace auto-reconnect pause
5860
- Git / FilesPanel window-focus refresh pauses
@@ -69,7 +71,9 @@ FS) and must not be mixed with Peer Device Mode.
6971
return empty or no-op so hydrate does not fail.
7072
- Events: peer agentic projection (and other product events such as terminal /
7173
FS / MCP interaction) fan-out as `RemoteCommand::DeviceEvent` to attached
72-
controllers; controller re-emits the same event names locally.
74+
controllers; controller re-emits the same event names locally. This includes
75+
SSH-backed remote PTY Ready / Data / Exit events created on B, not only B's
76+
local terminal service events.
7377
- CLI Peer Host forwards only turns submitted through Peer Host and linked
7478
child turns. A background-result follow-up inherits ownership only when its
7579
Core-internal metadata identifies the exact tracked parent and source child
@@ -113,6 +117,17 @@ call `pickWorkspaceDirectory()`:
113117
Still use normal `openWorkspace` / create-workspace flows (not SSH
114118
`openRemoteWorkspace` / `WorkspaceKind.Remote`).
115119

120+
## File download ownership
121+
122+
The native save/folder dialog always selects a destination on controller A,
123+
while the workspace source belongs to peer B. A download is therefore a
124+
split-endpoint operation: B returns file bytes through the existing
125+
`GetFileInfo` / `ReadFileChunk` protocol and A writes those chunks through its
126+
local filesystem adapter. Directory downloads enumerate B recursively and
127+
create the corresponding tree on A. Never forward A's selected destination to
128+
B through `export_local_file_to_path`; paths and permissions are host-specific
129+
and may represent a different operating system.
130+
116131
## Ownership
117132

118133
- Desktop host invoke / fan-out: `src/apps/desktop/src/api/peer_host_invoke.rs`,

src/apps/desktop/src/api/clipboard_file_api.rs

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -417,7 +417,7 @@ fn generate_unique_path(path: &Path) -> std::path::PathBuf {
417417
}
418418
}
419419

420-
fn copy_directory_recursive(source: &Path, target: &Path) -> Result<(), String> {
420+
pub(crate) fn copy_directory_recursive(source: &Path, target: &Path) -> Result<(), String> {
421421
std::fs::create_dir_all(target).map_err(|e| format!("Failed to create directory: {}", e))?;
422422

423423
for entry in
@@ -441,7 +441,8 @@ fn copy_directory_recursive(source: &Path, target: &Path) -> Result<(), String>
441441
#[cfg(test)]
442442
mod tests {
443443
use super::{
444-
decode_file_uri, generate_unique_path, parse_clipboard_path_segments, parse_uri_list,
444+
copy_directory_recursive, decode_file_uri, generate_unique_path,
445+
parse_clipboard_path_segments, parse_uri_list,
445446
};
446447
use std::path::Path;
447448

@@ -519,4 +520,32 @@ mod tests {
519520
vec!["/tmp/a.txt".to_string(), "/tmp/b.txt".to_string()]
520521
);
521522
}
523+
524+
#[test]
525+
fn copy_directory_recursive_copies_nested_binary_files() {
526+
let root = std::env::temp_dir().join(format!(
527+
"bitfun-directory-copy-test-{}",
528+
uuid::Uuid::new_v4()
529+
));
530+
let source = root.join("source");
531+
let target = root.join("target");
532+
std::fs::create_dir_all(source.join("nested")).expect("create source directory");
533+
std::fs::write(source.join("root.bin"), [0_u8, 255, 128]).expect("write root file");
534+
std::fs::write(source.join("nested").join("child.txt"), b"child")
535+
.expect("write nested file");
536+
537+
copy_directory_recursive(&source, &target).expect("copy directory recursively");
538+
539+
assert_eq!(
540+
std::fs::read(target.join("root.bin")).expect("read copied root file"),
541+
[0_u8, 255, 128]
542+
);
543+
assert_eq!(
544+
std::fs::read(target.join("nested").join("child.txt"))
545+
.expect("read copied nested file"),
546+
b"child"
547+
);
548+
549+
std::fs::remove_dir_all(root).expect("remove test directory");
550+
}
522551
}

src/apps/desktop/src/api/commands.rs

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2944,7 +2944,7 @@ pub async fn rename_file(
29442944
.await
29452945
}
29462946

2947-
/// Copy a local file to another local path (binary-safe). Used for export and drag-upload into local workspaces.
2947+
/// Copy a local file or directory to another local path (binary-safe).
29482948
#[tauri::command]
29492949
pub async fn export_local_file_to_path(request: ExportLocalFileRequest) -> Result<(), String> {
29502950
let src = request.source_path;
@@ -2956,7 +2956,54 @@ pub async fn export_local_file_to_path(request: ExportLocalFileRequest) -> Resul
29562956
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
29572957
}
29582958
}
2959-
std::fs::copy(&src, &dst).map_err(|e| e.to_string())?;
2959+
let src_path = Path::new(&src);
2960+
let src_metadata = std::fs::metadata(src_path).map_err(|e| {
2961+
format!(
2962+
"Failed to inspect export source '{}': {e}",
2963+
src_path.display()
2964+
)
2965+
})?;
2966+
if src_metadata.is_dir() {
2967+
let canonical_source = std::fs::canonicalize(src_path).map_err(|e| {
2968+
format!(
2969+
"Failed to resolve export source '{}': {e}",
2970+
src_path.display()
2971+
)
2972+
})?;
2973+
let destination_parent = dst_path
2974+
.parent()
2975+
.filter(|parent| !parent.as_os_str().is_empty())
2976+
.unwrap_or_else(|| Path::new("."));
2977+
let canonical_parent = std::fs::canonicalize(destination_parent).map_err(|e| {
2978+
format!(
2979+
"Failed to resolve export destination '{}': {e}",
2980+
destination_parent.display()
2981+
)
2982+
})?;
2983+
let resolved_destination = if dst_path.exists() {
2984+
std::fs::canonicalize(dst_path).map_err(|e| {
2985+
format!(
2986+
"Failed to resolve existing export destination '{}': {e}",
2987+
dst_path.display()
2988+
)
2989+
})?
2990+
} else {
2991+
canonical_parent.join(
2992+
dst_path
2993+
.file_name()
2994+
.ok_or_else(|| "Export destination has no directory name".to_string())?,
2995+
)
2996+
};
2997+
if resolved_destination == canonical_source
2998+
|| resolved_destination.starts_with(&canonical_source)
2999+
{
3000+
return Err("Cannot export a directory into itself".to_string());
3001+
}
3002+
super::clipboard_file_api::copy_directory_recursive(src_path, dst_path)?;
3003+
} else {
3004+
std::fs::copy(src_path, dst_path)
3005+
.map_err(|e| format!("Failed to copy export file: {e}"))?;
3006+
}
29603007
Ok::<(), String>(())
29613008
})
29623009
.await

src/apps/desktop/src/api/terminal_api.rs

Lines changed: 60 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,29 @@ async fn is_remote_session(session_id: &str) -> bool {
344344
false
345345
}
346346

347+
fn emit_terminal_event(app_handle: &AppHandle, event: &TerminalEvent) -> bool {
348+
let event_name = "terminal_event";
349+
let local_emit_succeeded = match app_handle.emit(event_name, event) {
350+
Ok(()) => true,
351+
Err(error) => {
352+
warn!("Failed to emit terminal event: {}", error);
353+
false
354+
}
355+
};
356+
if let Ok(payload) = serde_json::to_value(event) {
357+
super::remote_connect_api::maybe_fanout_peer_ui_event(event_name, payload);
358+
}
359+
local_emit_succeeded
360+
}
361+
362+
fn remote_terminal_signal_bytes(signal: &str) -> Option<&'static [u8]> {
363+
match signal.trim().to_ascii_uppercase().as_str() {
364+
"SIGINT" | "INT" => Some(&[0x03]),
365+
"SIGTSTP" | "TSTP" => Some(&[0x1a]),
366+
_ => None,
367+
}
368+
}
369+
347370
async fn spawn_remote_pty_session(
348371
app: &AppHandle,
349372
terminal_manager: &bitfun_core::service::remote_ssh::RemoteTerminalManager,
@@ -384,8 +407,8 @@ async fn spawn_remote_pty_session(
384407
let app_handle = app.clone();
385408
let sid = session_id.clone();
386409
tokio::spawn(async move {
387-
let _ = app_handle.emit(
388-
"terminal_event",
410+
emit_terminal_event(
411+
&app_handle,
389412
&TerminalEvent::Ready {
390413
session_id: sid.clone(),
391414
pid: 0,
@@ -397,14 +420,13 @@ async fn spawn_remote_pty_session(
397420
match rx.recv().await {
398421
Ok(data) => {
399422
let text = String::from_utf8_lossy(&data).to_string();
400-
if let Err(e) = app_handle.emit(
401-
"terminal_event",
423+
if !emit_terminal_event(
424+
&app_handle,
402425
&TerminalEvent::Data {
403426
session_id: sid.clone(),
404427
data: text,
405428
},
406429
) {
407-
warn!("Failed to emit remote terminal event: {}", e);
408430
break;
409431
}
410432
}
@@ -421,8 +443,8 @@ async fn spawn_remote_pty_session(
421443
}
422444
}
423445

424-
let _ = app_handle.emit(
425-
"terminal_event",
446+
emit_terminal_event(
447+
&app_handle,
426448
&TerminalEvent::Exit {
427449
session_id: sid,
428450
exit_code: Some(0),
@@ -699,7 +721,22 @@ pub async fn terminal_signal(
699721
state: State<'_, TerminalState>,
700722
) -> Result<(), String> {
701723
if is_remote_session(&request.session_id).await {
702-
// Remote terminals don't support signal yet
724+
let signal_data = remote_terminal_signal_bytes(&request.signal).ok_or_else(|| {
725+
format!(
726+
"Unsupported remote terminal signal: {}",
727+
request.signal.trim()
728+
)
729+
})?;
730+
let remote_manager =
731+
get_remote_workspace_manager().ok_or("Remote workspace manager not available")?;
732+
let terminal_manager = remote_manager
733+
.get_terminal_manager()
734+
.await
735+
.ok_or("Remote terminal manager not available")?;
736+
terminal_manager
737+
.write(&request.session_id, signal_data)
738+
.await
739+
.map_err(|e| format!("Failed to send remote terminal signal: {}", e))?;
703740
return Ok(());
704741
}
705742

@@ -913,13 +950,21 @@ pub fn start_terminal_event_loop(terminal_state: TerminalState, app_handle: AppH
913950
let mut rx = api.subscribe_events();
914951

915952
while let Some(event) = rx.recv().await {
916-
let event_name = "terminal_event";
917-
if let Err(e) = app_handle.emit(event_name, &event) {
918-
warn!("Failed to emit terminal event: {}", e);
919-
}
920-
if let Ok(payload) = serde_json::to_value(&event) {
921-
crate::api::remote_connect_api::maybe_fanout_peer_ui_event(event_name, payload);
922-
}
953+
emit_terminal_event(&app_handle, &event);
923954
}
924955
});
925956
}
957+
958+
#[cfg(test)]
959+
mod tests {
960+
use super::remote_terminal_signal_bytes;
961+
962+
#[test]
963+
fn maps_supported_remote_terminal_signals_to_control_bytes() {
964+
assert_eq!(remote_terminal_signal_bytes("SIGINT"), Some(&[0x03][..]));
965+
assert_eq!(remote_terminal_signal_bytes("int"), Some(&[0x03][..]));
966+
assert_eq!(remote_terminal_signal_bytes("SIGTSTP"), Some(&[0x1a][..]));
967+
assert_eq!(remote_terminal_signal_bytes("tstp"), Some(&[0x1a][..]));
968+
assert_eq!(remote_terminal_signal_bytes("SIGTERM"), None);
969+
}
970+
}

src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@ describe('peerInvokePriorityFor', () => {
3333
expect(peerInvokePriorityFor('get_system_info')).toBe('high');
3434
});
3535

36+
it('ranks all terminal commands high', () => {
37+
expect(peerInvokePriorityFor('terminal_create')).toBe('high');
38+
expect(peerInvokePriorityFor('terminal_write')).toBe('high');
39+
expect(peerInvokePriorityFor('terminal_resize')).toBe('high');
40+
expect(peerInvokePriorityFor('terminal_signal')).toBe('high');
41+
});
42+
3643
it('ranks git/ssh/editor/fs/search noise low', () => {
3744
expect(peerInvokePriorityFor('git_is_repository')).toBe('low');
3845
expect(peerInvokePriorityFor('ssh_is_connected')).toBe('low');
@@ -90,6 +97,75 @@ describe('PeerDeviceTransportAdapter queue', () => {
9097
'ssh_is_connected',
9198
]);
9299
});
100+
101+
it('reserves one concurrency slot for terminal work', async () => {
102+
const started: string[] = [];
103+
const firstLowGate = createDeferred<void>();
104+
105+
const deviceRpc = vi.fn(async (_target: string, commandJson: string) => {
106+
const parsed = JSON.parse(commandJson) as { command: string };
107+
started.push(parsed.command);
108+
if (parsed.command === 'git_is_repository') {
109+
await firstLowGate.promise;
110+
}
111+
return JSON.stringify({
112+
resp: 'host_invoke_result',
113+
ok: true,
114+
value: true,
115+
});
116+
});
117+
118+
const adapter = new PeerDeviceTransportAdapter('peer-1', deviceRpc, {}, 2);
119+
await adapter.connect();
120+
121+
const low1 = adapter.request('git_is_repository', {
122+
request: { repositoryPath: '/a' },
123+
});
124+
const low2 = adapter.request('ssh_is_connected', { connectionId: 'ssh-x' });
125+
await Promise.resolve();
126+
expect(started).toEqual(['git_is_repository']);
127+
128+
const terminal = adapter.request('terminal_write', {
129+
request: { sessionId: 't1', data: 'pwd\r' },
130+
});
131+
await terminal;
132+
expect(started).toEqual(['git_is_repository', 'terminal_write']);
133+
134+
firstLowGate.resolve();
135+
await Promise.all([low1, low2]);
136+
expect(started).toEqual([
137+
'git_is_repository',
138+
'terminal_write',
139+
'ssh_is_connected',
140+
]);
141+
});
142+
143+
it('sends split-endpoint file reads as direct peer commands', async () => {
144+
const deviceRpc = vi.fn(async (_target: string, commandJson: string) => {
145+
const parsed = JSON.parse(commandJson) as { cmd: string; path: string };
146+
expect(parsed).toEqual({
147+
cmd: 'get_file_info',
148+
path: '/peer/report.bin',
149+
session_id: null,
150+
});
151+
return JSON.stringify({
152+
resp: 'file_info',
153+
name: 'report.bin',
154+
size: 4,
155+
mime_type: 'application/octet-stream',
156+
});
157+
});
158+
const adapter = new PeerDeviceTransportAdapter('peer-1', deviceRpc);
159+
160+
const response = await adapter.requestPeerCommand({
161+
cmd: 'get_file_info',
162+
path: '/peer/report.bin',
163+
session_id: null,
164+
});
165+
166+
expect(response.resp).toBe('file_info');
167+
expect(deviceRpc).toHaveBeenCalledTimes(1);
168+
});
93169
});
94170

95171
function createDeferred<T>() {

0 commit comments

Comments
 (0)