Skip to content

Commit 54ee04f

Browse files
committed
Network: Add host discovery
- Rust-side changes based on host-discovery.md, implementing Bonjour discovery. - Svelte-side changes based on host-discovery.md. Added NetworkBrowser.svelte - Update docs: host-discovery.md based on new decisions / nuances - It is super fast and super sleek!
1 parent e1244e3 commit 54ee04f

23 files changed

Lines changed: 1568 additions & 106 deletions

docs/features/network-smb/host-discovery.md

Lines changed: 64 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ How Rusty Commander discovers SMB hosts on the local network.
55
## Overview
66

77
Network hosts are discovered automatically using **Bonjour** (Apple's implementation of mDNS/DNS-SD). When an
8-
SMB-capable device advertises itself on the local network, it appears in the volume selector under "Network".
8+
SMB-capable device advertises itself on the local network, it appears in the Network browser view.
99

1010
## How Bonjour works
1111

@@ -43,90 +43,103 @@ Delegate receives callbacks as services appear/disappear
4343
NSNetService objects (one per discovered host)
4444
4545
46-
resolve() each to get IP address + port
46+
hostname/IP derived from service name (lazy resolution)
4747
```
4848

4949
### Challenges
5050

51-
| Challenge | Solution |
52-
| ---------------------- | --------------------------------------------------------------------------------------------------------- |
53-
| **Delegate pattern** | Create a Rust struct that implements `NSNetServiceBrowserDelegate` using `objc2`'s `declare_class!` macro |
54-
| **Async/streaming** | Services appear over time; use Rust channels or async streams to propagate updates |
55-
| **Service resolution** | Each `NSNetService` needs `resolve()` call for IP/hostname (another async operation) |
56-
| **Run loop** | Bonjour callbacks require a run loop; use Tauri's main thread or dedicated run loop |
51+
| Challenge | Solution |
52+
| ---------------------- | -------------------------------------------------------------------------------------------------------- |
53+
| **Delegate pattern** | Create a Rust struct that implements `NSNetServiceBrowserDelegate` using `objc2`'s `define_class!` macro |
54+
| **Async/streaming** | Services appear over time; use Tauri events to propagate updates |
55+
| **Service resolution** | Hostname derived from service name, IP resolved via DNS on hover/demand |
56+
| **Run loop** | Bonjour callbacks require a run loop; use Tauri's main thread |
5757

5858
### Lifecycle
5959

6060
1. **App startup**: Start `NSNetServiceBrowser` listening for `_smb._tcp.local`
6161
2. **Continuous**: Receive callbacks as hosts appear/disappear on network
62-
3. **Cache results**: Keep discovered hosts in memory for instant volume selector display
63-
4. **Volume selector opened**: Show cached hosts immediately; continue updating as new hosts found
62+
3. **Cache results**: Keep discovered hosts in memory for instant display
63+
4. **Network view opened**: Show cached hosts immediately; continue updating as new hosts found
6464

6565
### Lazy resolution
6666

67-
Resolution (getting IP/hostname from `NSNetService`) adds ~50–200 ms latency per host. To keep discovery snappy:
67+
Resolution (getting IP/hostname) is deferred until needed to keep discovery fast:
6868

6969
- **On discovery**: Store host name only (from mDNS announcement)
70-
- **On hover (500 ms debounce)**: Resolve that specific host
70+
- **On hover**: Resolve that specific host's hostname and IP
7171
- **On navigate**: Resolve if not already cached
7272
- **Cache**: Keep resolved addresses in memory
7373

74-
This way the volume selector populates instantly with host names, and resolution happens just-in-time.
74+
This way the network browser populates instantly with host names, and resolution happens just-in-time.
7575

7676
### Host disappearance
7777

7878
Bonjour proactively notifies when hosts stop advertising (via `netServiceBrowser:didRemoveService:`).
7979

8080
**UI behavior when a host disappears:**
8181

82-
- **In volume selector**: Remove host from list, keep cursor at same index (or last item if was last)
83-
- **In file pane browsing network hosts**: Remove disappeared host, handle gracefully like any deleted item
84-
85-
### Prefetching for known hosts
86-
87-
After discovery settles (~3 seconds), prefetch share information for hosts in `knownNetworkShares`:
88-
89-
```
90-
Discovery complete
91-
→ For each known host that was discovered:
92-
→ Queue share enumeration (parallel, cap at 10)
93-
→ Cache results for instant display when user navigates
94-
```
95-
96-
This provides instant response for servers the user has connected to before, without probing unknown hosts.
82+
- **In Network browser**: Remove host from list, adjust selection
83+
- **During connection**: Show appropriate error if mid-operation
9784

9885
## UX behavior
9986

100-
### In the volume selector
87+
### Volume selector
88+
89+
The volume selector shows a single "Network" item under the Network section:
10190

10291
```
10392
📁 Favorites
10493
└── Documents
10594
└── Downloads
10695
📁 Macintosh HD
10796
📁 External Drive
108-
📶 Network ← Click to expand or show inline
109-
└── David's M1 MBP
110-
└── Naspolya
111-
└── PI
112-
└── (Searching...) ← While initial discovery in progress
97+
🌐 Network ← Click to open Network browser
98+
```
99+
100+
### Network browser view
101+
102+
When user clicks "Network" in the volume selector, the pane switches to the Network browser view:
103+
104+
```
105+
┌────────────────────────────────────────────────────────────┐
106+
│ Name │ IP address │ Hostname │ Status │
107+
├────────────────────────────────────────────────────────────┤
108+
│ 🖥️ David's MacBook │ 192.168.1.10 │ macbook.local │ — │
109+
│ 🖥️ NAS-Server │ 192.168.1.50 │ nas.local │ — │
110+
│ 🖥️ Office-PC │ — │ — │ — │
111+
│ │
112+
│ Searching... (if discovery in progress) │
113+
└────────────────────────────────────────────────────────────┘
113114
```
114115

115-
### Progress indication
116+
**Columns:**
117+
118+
- **Name**: Host's advertised name from Bonjour
119+
- **IP address**: Resolved IP (on hover/demand), or "—" if not yet resolved
120+
- **Hostname**: Derived `.local` hostname
121+
- **Shares**: Share count (future, currently "—")
122+
- **Status**: Connection status (future, currently "—")
123+
124+
**Keyboard navigation:**
125+
126+
- Arrow Up/Down: Navigate between hosts
127+
- Enter: Select host (future: show shares)
128+
- Backspace: No-op (can't go "up" from network root)
129+
130+
**Back/Forward navigation:**
116131

117-
- **While searching**: Subtle spinner or "Searching..." text
118-
- **Hosts appear incrementally**: Each host shows up as discovered (streaming UX)
119-
- **After ~3–5 seconds**: Consider discovery "complete" but keep listening for changes
132+
- Works as expected: going Back returns to previous file view
133+
- Coming Forward again returns to Network browser
120134

121135
## Network change detection
122136

123-
Bonjour automatically handles network changes, but we enhance this:
137+
Bonjour automatically handles network changes:
124138

125-
| Event | Detection | Action |
126-
| ------------------------ | ----------------------- | ---------------------- |
127-
| Host starts advertising | Bonjour callback | Add to list |
128-
| Host stops advertising | Bonjour callback | Remove from list |
129-
| Network interface change | `SCNetworkReachability` | Pause/resume discovery |
139+
| Event | Detection | Action |
140+
| ----------------------- | ---------------- | ---------------- |
141+
| Host starts advertising | Bonjour callback | Add to list |
142+
| Host stops advertising | Bonjour callback | Remove from list |
130143

131144
## What Bonjour doesn't cover
132145

@@ -156,16 +169,13 @@ Discovery is lightweight enough to start immediately at app launch.
156169

157170
## Testing
158171

159-
### Unit tests
172+
### Backend unit tests
160173

161-
- Mock `NSNetServiceBrowser` to simulate host discovery
162-
- Test callback handling for service found/lost events
163-
- Test caching and deduplication logic
164-
- Test lazy resolution caching
174+
- `test_service_name_to_hostname`: Tests hostname derivation from service names
175+
- `test_service_name_to_id`: Tests ID generation from service names
176+
- `test_network_host_serialization`: Tests JSON serialization of NetworkHost
165177

166-
### Integration tests
178+
### Frontend tests
167179

168-
- Verify hosts appear in volume list when discovered
169-
- Test host disappearance when service goes offline
170-
- Test behavior when no hosts found (empty "Network" section)
171-
- Test prefetching triggers for known hosts
180+
- `network-hosts.test.ts`: Tests network host type interfaces and event handling logic
181+
- Integration tests verify NetworkBrowser renders correctly and handles events

docs/features/network-smb/task-list.md

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
All implementation tasks for network SMB support, organized by area.
44

5+
See [index.md](./index.md) for an overview of this whole feature. It's a helpful starting point.
6+
57
## Legend
68

79
- ⬜ Not started
@@ -17,22 +19,22 @@ See [host-discovery.md](./host-discovery.md) for details.
1719

1820
### Backend (Rust)
1921

20-
- **1.1** Create `network` module in `src-tauri/src/`
21-
- **1.2** Implement `NSNetServiceBrowserDelegate` using `objc2` `declare_class!`
22-
- **1.3** Create `BonjourDiscovery` struct that manages the browser lifecycle
23-
- **1.4** Implement service resolution (get IP/hostname from `NSNetService`)
24-
- **1.5** Create Tauri commands: `start_network_discovery`, `get_discovered_hosts`
25-
- **1.6** Add event emission for real-time host updates to frontend
26-
- **1.7** Start discovery at app initialization (in `lib.rs`)
27-
- **1.8** Add unit tests with mocked Bonjour responses
22+
- **1.1** Create `network` module in `src-tauri/src/`
23+
- **1.2** Implement `NSNetServiceBrowserDelegate` using `objc2` `define_class!`
24+
- **1.3** Create `BonjourDiscovery` struct that manages the browser lifecycle
25+
- **1.4** Implement service resolution (lazy hostname generation + DNS lookup)
26+
- **1.5** Create Tauri commands: `list_network_hosts`, `get_network_discovery_state`, `resolve_host`
27+
- **1.6** Add event emission for real-time host updates to frontend
28+
- **1.7** Start discovery at app initialization (in `lib.rs`)
29+
- **1.8** Add unit tests for hostname conversion and serialization
2830

2931
### Frontend (Svelte)
3032

31-
- **1.9** Add "Network" section to volume selector
32-
- **1.10** Subscribe to host discovery events from backend
33-
- **1.11** Display discovered hosts with appropriate icons
34-
- **1.12** Show "Searching..." indicator during initial discovery
35-
- **1.13** Add frontend tests for network host display
33+
- **1.9** Add "Network" section to volume selector
34+
- **1.10** Subscribe to host discovery events from backend
35+
- **1.11** Display discovered hosts with appropriate icons
36+
- **1.12** Show "Searching..." indicator during initial discovery
37+
- **1.13** Add frontend tests for network host display
3638

3739
---
3840

eslint.config.js

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,11 @@ export default tseslint.config(
2929
prettierConfig,
3030
...tseslint.configs.strictTypeChecked.map((config) => ({
3131
...config,
32-
files: ['**/*.{ts,tsx,svelte}'],
32+
files: ['**/*.{ts,tsx,svelte.ts,svelte}'],
3333
})),
3434
...svelte.configs['flat/recommended'],
3535
{
36-
files: ['**/*.{ts,tsx}'],
36+
files: ['**/*.{ts,tsx,svelte.ts}'],
3737
plugins: {
3838
'@typescript-eslint': tseslint.plugin,
3939
prettier,
@@ -110,6 +110,47 @@ export default tseslint.config(
110110
'prettier/prettier': 'error',
111111
},
112112
},
113+
{
114+
// Svelte 5 runes files (.svelte.ts) - TypeScript with Svelte runes support
115+
files: ['**/*.svelte.ts'],
116+
plugins: {
117+
'@typescript-eslint': tseslint.plugin,
118+
prettier,
119+
},
120+
languageOptions: {
121+
parser: tseslint.parser,
122+
ecmaVersion: 'latest',
123+
sourceType: 'module',
124+
globals: {
125+
...globals.browser,
126+
...globals.node,
127+
...globals.es2021,
128+
},
129+
parserOptions: {
130+
projectService: true,
131+
tsconfigRootDir: import.meta.dirname,
132+
},
133+
},
134+
rules: {
135+
'prettier/prettier': 'error',
136+
// Type safety rules - same as main TypeScript files
137+
'@typescript-eslint/no-unused-vars': 'error',
138+
'@typescript-eslint/no-unsafe-assignment': 'error',
139+
'@typescript-eslint/no-unsafe-call': 'error',
140+
'@typescript-eslint/no-unsafe-member-access': 'error',
141+
'@typescript-eslint/no-unsafe-return': 'error',
142+
// Async/Promise safety
143+
'@typescript-eslint/no-floating-promises': 'error',
144+
'@typescript-eslint/await-thenable': 'error',
145+
'@typescript-eslint/no-misused-promises': 'error',
146+
'@typescript-eslint/require-await': 'error',
147+
// No any
148+
'@typescript-eslint/no-explicit-any': 'error',
149+
'no-console': 'warn',
150+
// Complexity limit
151+
complexity: ['error', { max: 15 }],
152+
},
153+
},
113154
{
114155
files: ['**/*.svelte'],
115156
plugins: {

knip.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"$schema": "https://unpkg.com/knip@5/schema.json",
3-
"ignore": ["src/lib/icon-cache.ts", "src/routes/+layout.ts", "src/lib/benchmark.ts"],
3+
"ignore": ["src/lib/icon-cache.ts", "src/routes/+layout.ts", "src/lib/benchmark.ts", "src/lib/tauri-commands.ts"],
44
"ignoreDependencies": ["@tauri-apps/cli", "@testing-library/svelte", "@sveltejs/adapter-static"],
55
"ignoreExportsUsedInFile": true,
66
"project": ["src/**/*.{ts,svelte}"],

src-tauri/Cargo.lock

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

src-tauri/Cargo.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ bincode2 = "2"
3939
tauri-plugin-drag = "2.1.0"
4040
tauri-plugin-fs = "2.4.4"
4141
alphanumeric-sort = "1.5"
42+
tokio = { version = "1.49.0", features = ["rt"] }
4243
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
4344
tauri-plugin-window-state = "2"
4445

@@ -48,10 +49,10 @@ core-services = "1.0.0"
4849
icns = "0.3.1"
4950
plist = "1.8.0"
5051
urlencoding = "2.1.3"
51-
objc2 = "0.6"
52+
objc2 = { version = "0.6", features = ["std"] }
5253
objc2-foundation = { version = "0.3", features = [
5354
"NSURL", "NSString", "NSDictionary", "NSDate", "NSArray", "NSValue", "NSError",
54-
"NSFileManager"
55+
"NSFileManager", "NSNetServices", "NSRunLoop"
5556
] }
5657

5758
[dev-dependencies]

src-tauri/src/commands/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ pub mod file_system;
44
pub mod font_metrics;
55
pub mod icons;
66
#[cfg(target_os = "macos")]
7+
pub mod network;
8+
#[cfg(target_os = "macos")]
79
pub mod sync_status;
810
pub mod ui;
911
#[cfg(target_os = "macos")]

0 commit comments

Comments
 (0)