feat: share an album beyond the local network - #1478
Conversation
The tunnel spawn needs a timeout; without one a provider that connects but never answers would hang the caller indefinitely.
Uses the system ssh client rather than a bundled tunnel binary: every desktop platform ships one, which avoids ~40MB in the installer and the problem of pinning a binary published from unversioned URLs. The URL is matched by host suffix rather than by the wording around it, because both providers print a banner containing several unrelated links first. Output keeps being drained after the URL is found, so the ANSI QR code they emit cannot fill the pipe and stall ssh.
Closed from both the window handler and the tray quit item. An orphaned tunnel leaves an album reachable from the internet, which is worse than the orphaned LAN listener the backend already guards against. The child is killed through its handle rather than by matching process names, as kill_process_tree does: an ssh name match would take down the user's own unrelated sessions.
The status command kept handing out a URL after ssh exited, so the dialog would show a dead link as live. Cleared when the event stream ends, and only when the tunnel is still the current one, so a newer tunnel is not cleared by an older one's exit.
A full-width toggle picks where the share is reachable from, and only appears while creating one: switching afterwards would be a different operation. Internet mode starts with the password on. It stays removable, but chat apps fetch links to build previews, and the unlock page is what stops those previews seeing the album. A failed connection undoes the share rather than leaving the user with one they did not ask for.
A user-facing page under Overview, with mermaid diagrams of both paths, since internet mode routes photos through a relay that can read them and that is not something to leave people to discover. The existing backend page stays developer-facing and now points at it.
An info button in both steps, deep-linking to the section for whichever mode is selected. Opening a URL needed a capability the app did not have.
The click swallowed every error, so a missing capability looked like a dead button. The address is now handed over instead, which stays useful whatever stopped the browser from launching.
The opener plugin scopes open_url by URL as well as gating the command, so the bare permission allowed nothing and every call was denied. Scoped to the documentation site rather than all of https, so the app can only ever launch a browser at our own pages.
Reads as part of the heading rather than a stray control at the far edge. The title truncates so a long album name cannot push it out of view.
WalkthroughThe PR adds Internet album sharing through an SSH reverse tunnel. It integrates tunnel lifecycle commands with Tauri and the share dialog, adds lifecycle tests, and documents LAN and Internet sharing. ChangesInternet album sharing
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant User
participant ShareAlbumDialog
participant Tauri
participant TunnelService
participant localhost_run as localhost.run
User->>ShareAlbumDialog: Select Internet sharing
ShareAlbumDialog->>Tauri: Create local share
ShareAlbumDialog->>Tauri: Start tunnel for share port
Tauri->>TunnelService: tunnel_start(port)
TunnelService->>localhost_run: Open SSH reverse tunnel
localhost_run-->>TunnelService: Return HTTPS URL
TunnelService-->>ShareAlbumDialog: Return public URL
ShareAlbumDialog-->>User: Display Internet share URL
Possibly related PRs
Suggested labels: Poem
🚥 Pre-merge checks | ✅ 3 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (3 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 13
🧹 Nitpick comments (2)
frontend/src/types/Share.ts (1)
23-27: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winUse this shared
ShareModetype in the share dialog.
frontend/src/components/Albums/ShareAlbumDialog.tsx:27also declaresShareMode. Remove that local declaration and import this type. Duplicate unions can diverge when a new mode is added.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/types/Share.ts` around lines 23 - 27, Update ShareAlbumDialog to remove its local ShareMode declaration and import the shared ShareMode type from Share.ts, using that imported type wherever the dialog references share modes.Source: Coding guidelines
frontend/src/utils/tunnel.ts (1)
12-19: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd explicit return types to exported IPC wrappers.
The frontend rules require every export and API boundary to be typed. Add return annotations to
startTunnel,stopTunnel, andtunnelStatus.Proposed fix
-export const startTunnel = (port: number): Promise<string> => +export const startTunnel = (port: number): Promise<string> => invoke<string>('tunnel_start', { port }); -export const stopTunnel = (): Promise<void> => invoke<void>('tunnel_stop'); +export const stopTunnel = (): Promise<void> => invoke<void>('tunnel_stop'); -export const tunnelStatus = (): Promise<string | null> => +export const tunnelStatus = (): Promise<string | null> => invoke<string | null>('tunnel_status');🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/utils/tunnel.ts` around lines 12 - 19, Add explicit Promise return-type annotations to the exported IPC wrapper functions startTunnel, stopTunnel, and tunnelStatus, preserving their existing generic result types and behavior.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/backend/backend_python/album-sharing.md`:
- Around line 84-89: Replace the complete token in the example response and its
repeated URL prefix with the literal placeholder <share_token>; if the exposed
value is real, revoke it as well.
In `@docs/overview/sharing-albums.md`:
- Around line 18-19: Update the LAN entries in the sharing overview table and
the confidentiality statements in the LAN-mode section to state that access is
available to any client able to reach the machine and possessing the link,
rather than only Wi-Fi users. Clarify that LAN mode avoids a third-party relay
but is not end-to-end encrypted, while preserving the existing relay-mode
descriptions.
- Around line 3-5: Update the opening summary of the album-sharing documentation
to separate storage from network transit: clarify that photos are not stored or
uploaded by PictoPy while explicitly stating that Internet-mode sharing sends
image data through the relay, which can read it. Keep the local disk source and
share-duration behavior accurate, and align the later storage and relay
descriptions with this distinction.
In `@frontend/src-tauri/Cargo.toml`:
- Line 24: Update the Tokio dependency feature list in Cargo.toml to include the
sync feature alongside macros and time, so tunnel.rs can use
tokio::sync::mpsc::Receiver.
In `@frontend/src-tauri/src/main.rs`:
- Around line 78-80: Add application-level tunnel cleanup at the exit boundary
in main, ensuring services::tunnel::shutdown runs before app.exit for every
normal exit path. Update the existing cleanup calls at
frontend/src-tauri/src/main.rs lines 78-80 and 328-330 as needed so close and
tray-quit handlers remain harmless local no-ops while the centralized exit
cleanup handles termination.
In `@frontend/src-tauri/src/services/tunnel.rs`:
- Around line 253-293: Add controlled-process lifecycle tests alongside the
existing find_url tests, covering child cleanup, state removal after child exit,
startup rollback when launch fails, and stop failure handling. Exercise the
relevant tunnel lifecycle functions with deterministic mock or helper processes,
and assert each path leaves the expected state and propagates failures
correctly.
- Around line 98-110: The read_url function currently parses each command output
chunk independently, so URLs split across events are missed. Maintain a bounded
buffer across Stdout and Stderr events, append each decoded chunk before calling
find_url, and trim older content to enforce the bound; add a test covering a URL
split across multiple events while preserving termination and timeout behavior.
- Around line 180-196: Serialize the complete tunnel lifecycle around the start
flow containing the initial active-state check and `open(&app, provider,
port).await`, using one shared lifecycle state also acquired by `tunnel_stop`
and `shutdown`. Keep the lock/state held or otherwise marked through the full
open-to-install transition so concurrent starts cannot spawn competing children
and stop cannot return before the new `ActiveTunnel` is installed; add coverage
for concurrent starts and stop during startup.
- Around line 211-217: Update tunnel_stop so the active tunnel’s CommandChild
remains stored until child.kill() succeeds, allowing a retry if termination
fails. Avoid consuming or clearing the active entry before the kill operation;
only remove it after successful termination while preserving the existing error
reporting.
In `@frontend/src/components/Albums/ShareAlbumDialog.tsx`:
- Around line 75-105: Split ShareAlbumDialog into focused modules: extract
tunnel state and lifecycle logic into a dedicated hook, and move the creation
form and active-share display into separate components. Update ShareAlbumDialog
to compose these pieces while retaining existing share mutations, documentation
behavior, and UI state transitions.
- Around line 161-166: Update the share-stop flow around tunnelStatus() and
stopTunnel() so cleanup is based on managed tunnel state rather than transient
tunnelUrl availability, including when status lookup is pending or rejects. Do
not swallow stopTunnel failures; propagate the rejection so the dialog does not
report success and the user can retry. Add regression coverage for
unresolved/failed status lookup and failed tunnel shutdown.
- Around line 198-214: Update the ShareAlbumDialog reopening initialization
around tunnelStatus() so a returned tunnel URL sets mode to internet, while an
absent URL preserves LAN mode; ensure the active-share description and help link
use this restored mode. Add a reopening test that confirms an active tunnel
opens the Internet documentation URL.
- Around line 140-146: Update the catch path in ShareAlbumDialog.tsx
(frontend/src/components/Albums/ShareAlbumDialog.tsx#L140-L146) to handle
revokeShare rejection separately, preserving cleanup state and reporting that
revocation remains incomplete instead of claiming the album was not shared;
provide a retry path. Add coverage in ShareAlbumDialog.test.tsx
(frontend/src/components/Albums/__tests__/ShareAlbumDialog.test.tsx#L138-L157)
by mocking revokeShare failure and verifying the incomplete-cleanup message and
retry behavior.
---
Nitpick comments:
In `@frontend/src/types/Share.ts`:
- Around line 23-27: Update ShareAlbumDialog to remove its local ShareMode
declaration and import the shared ShareMode type from Share.ts, using that
imported type wherever the dialog references share modes.
In `@frontend/src/utils/tunnel.ts`:
- Around line 12-19: Add explicit Promise return-type annotations to the
exported IPC wrapper functions startTunnel, stopTunnel, and tunnelStatus,
preserving their existing generic result types and behavior.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: e0564dec-877d-4ebd-964e-564de69ff138
📒 Files selected for processing (12)
docs/backend/backend_python/album-sharing.mddocs/overview/sharing-albums.mdfrontend/src-tauri/Cargo.tomlfrontend/src-tauri/capabilities/migrated.jsonfrontend/src-tauri/src/main.rsfrontend/src-tauri/src/services/mod.rsfrontend/src-tauri/src/services/tunnel.rsfrontend/src/components/Albums/ShareAlbumDialog.tsxfrontend/src/components/Albums/__tests__/ShareAlbumDialog.test.tsxfrontend/src/types/Share.tsfrontend/src/utils/tunnel.tsmkdocs.yml
Two concurrent starts could both find no tunnel, both spawn ssh, and leave the first child running with nothing holding its handle. Start and stop now run under one lock that covers the whole open-and-install transition. Stopping captures the pid before killing, because CommandChild::kill consumes the handle and cannot hand it back for a retry; a failure now names the process still running rather than reporting nothing actionable. tokio::sync was reaching us only through Tauri's own feature selection.
The close handler and the tray item did not cover every way the app can end. RunEvent::Exit is the one path they all pass through, and an ssh child outliving PictoPy leaves an album reachable from the internet.
Stopping a share checked a value the dialog might not have received yet, so a share stopped before the status lookup resolved left the tunnel running, and a failed stop reported success. The lifecycle moves into a hook that asks the owner and lets failures reach the user. Undoing a share whose tunnel could not be opened no longer claims the album was withdrawn when the revoke itself failed, and reopening restores internet mode so the help link and description match the link on screen.
The opening conflated storage with transit, which hid the relay from anyone who stopped reading early. Local sharing was described as fully private when it is plain HTTP and openable by anyone on the network holding the link. The example response also carried a real-looking share token, which is the credential that authorises access to an album.
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/src-tauri/src/services/tunnel.rs`:
- Around line 268-270: The shutdown path around TunnelState::shutdown must
handle a tunnel still starting, not only the child in active: ensure the SSH
child is tracked before URL discovery or coordinate cancellation and waiting
with lifecycle so shutdown cannot complete while startup owns a child. Add an
interleaving test covering shutdown during open/startup, then run cargo fmt and
cargo test from frontend/src-tauri.
In `@frontend/src/hooks/useShareTunnel.ts`:
- Around line 12-52: Add a shared ShareTunnel type in Share.ts describing the
url, isConnecting, refresh, open, and close members returned by useShareTunnel.
Import that type into useShareTunnel and explicitly declare useShareTunnel as
returning ShareTunnel, preserving the existing callback behavior and inferred
member signatures.
- Around line 17-20: Update useShareTunnel’s refresh and tunnel lifecycle
handling to track a revision in a ref, and only apply refresh results while no
later open or stopTunnel cycle has completed. Prevent a delayed null
tunnelStatus response from overwriting the URL set by a successful
open/startTunnel flow, and add a race test covering delayed tunnelStatus with
successful startTunnel.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 174e8707-b956-4b6e-9023-565ccb517488
📒 Files selected for processing (8)
docs/backend/backend_python/album-sharing.mddocs/overview/sharing-albums.mdfrontend/src-tauri/Cargo.tomlfrontend/src-tauri/src/main.rsfrontend/src-tauri/src/services/tunnel.rsfrontend/src/components/Albums/ShareAlbumDialog.tsxfrontend/src/components/Albums/__tests__/ShareAlbumDialog.test.tsxfrontend/src/hooks/useShareTunnel.ts
🚧 Files skipped from review as they are similar to previous changes (5)
- frontend/src-tauri/Cargo.toml
- frontend/src/components/Albums/tests/ShareAlbumDialog.test.tsx
- docs/backend/backend_python/album-sharing.md
- frontend/src/components/Albums/ShareAlbumDialog.tsx
- docs/overview/sharing-albums.md
Exiting while a tunnel was still starting left the child untracked, so nothing could kill it and an album stayed reachable from the internet after PictoPy had gone. The child is now tracked the moment it spawns, with the URL filled in afterwards. The lifecycle moves behind a small trait so it can be exercised without spawning ssh: six tests now cover stopping, a refused kill, forgetting only the current tunnel, and killing a child that never announced anything.
The status query fired when the dialog opens can resolve after a tunnel has been opened, and its stale answer would drop the address just obtained. Results are applied only while no newer open or close has completed. The hook's shape is now declared as ShareTunnel alongside the other share types rather than left to inference.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/src-tauri/src/services/tunnel.rs`:
- Around line 259-262: Update the tunnel lifecycle coordination around
tunnel_start, state.track, and shutdown/state.stop so a shutdown request is
recorded before shutdown returns, and tunnel_start immediately stops any child
obtained after that request instead of announcing it. Preserve normal startup
behavior, and add an interleaving test covering shutdown during spawn_ssh().
In `@frontend/src/hooks/useShareTunnel.ts`:
- Around line 39-66: Serialize the lifecycle operations in useShareTunnel around
open and close so a close request cannot observe a pending start as absent or
allow its late result to be applied. Ensure close ultimately stops any tunnel
started by open and prevents stale state from being published, then add coverage
for close invoked while startTunnel is pending.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: bb8423df-c597-438d-b6ec-841cbce17216
📒 Files selected for processing (5)
frontend/src-tauri/src/services/tunnel.rsfrontend/src/components/Albums/ShareAlbumDialog.tsxfrontend/src/components/Albums/__tests__/ShareAlbumDialog.test.tsxfrontend/src/hooks/useShareTunnel.tsfrontend/src/types/Share.ts
🚧 Files skipped from review as they are similar to previous changes (2)
- frontend/src/components/Albums/tests/ShareAlbumDialog.test.tsx
- frontend/src/components/Albums/ShareAlbumDialog.tsx
Exit could land between ssh being spawned and its handle being tracked: the shutdown found nothing to kill, and the start then installed a child that outlived the application. The refusal is recorded under the same lock the start uses to track, so whichever arrives second kills the process.
Closing while a start was in flight read a status of null, skipped the stop entirely, and then applied a live public address once the slow start answered. The stop is now unconditional, since the owner already holds the child and stopping nothing is a no-op, and a start that finishes after a close shuts its own tunnel down rather than handing it back.
findBy waits one second by default, but the delete mutation retries twice with a 500ms backoff before it reports failure, so the assertion raced the retry schedule and failed under load.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/src/hooks/__tests__/useShareTunnel.test.ts`:
- Around line 1-3: Rename the useShareTunnel test file from the .test.ts
extension to .test.tsx, preserving its existing contents and location under the
__tests__ directory.
- Around line 11-13: Replace the `jest.Mock` casts for `mockStartTunnel`,
`mockStopTunnel`, and `mockTunnelStatus` with `jest.mocked()` wrappers so each
mocked tunnel utility retains its original parameter and return types.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 36336ea4-98b5-4bb6-9bcf-14da6e153152
📒 Files selected for processing (4)
frontend/src-tauri/src/services/tunnel.rsfrontend/src/hooks/__tests__/useShareTunnel.test.tsfrontend/src/hooks/useShareTunnel.tsfrontend/src/pages/__tests__/Album.test.tsx
🚧 Files skipped from review as they are similar to previous changes (2)
- frontend/src/hooks/useShareTunnel.ts
- frontend/src-tauri/src/services/tunnel.rs
Closes #1477
Adds a second mode to the share dialog, chosen while creating a share, that makes an album reachable from outside the local network. Local sharing only works when both people are on the same Wi-Fi, and it fails outright on networks that block their own devices from reaching each other, which is most guest Wi-Fi and some home routers.
Approach
The route is an SSH reverse tunnel opened with the system
sshclient, which every desktop platform already ships. Nothing is bundled and nothing is downloaded, so the installer does not grow and there is no third-party binary to pin. The tunnel forwards the share port, and since the share server already binds to all interfaces and serves/s/{token}, the backend needs no changes at all; tokens, passwords and expiry behave identically in both modes.The child process is owned by the Tauri side and closed from the window handler, the tray quit item, and when the share is stopped. An orphaned tunnel would leave an album reachable from the internet, which is worse than the orphaned local listener the backend already guards against. It is killed through its handle rather than by matching process names, because matching on
sshwould take down the user's own unrelated sessions.The assigned URL is read from the provider's output by host suffix rather than by the wording around it, because the banner it prints first contains several unrelated links. Output keeps being drained afterwards, since the provider also emits a QR code and a full pipe would stall
ssh.What it costs, and how that is handled
The relay terminates TLS, so it handles photos in readable form as they pass. Internet mode is therefore opt-in per share and never the default, the dialog states the trade-off rather than implying the link is private, and an information button links to the documentation.
Password protection is switched on by default in this mode. It stays removable, but chat applications fetch links to build previews without anyone opening them, and the unlock page is what stops those previews seeing the album. A failed connection undoes the share rather than leaving one the user did not ask for.
Not included
Reaching a share through router port forwarding is deliberately out of scope. The host cannot confirm that a port is reachable without something outside trying it, so any such indicator would be a guess.
Testing
Eleven new tests: four in Rust covering the URL parsing against real captured provider output, and seven in the dialog covering the mode toggle, the tunnel lifecycle, the rollback on failure and the documentation link. Full suites at 368 frontend tests and 4 Rust tests passing.
The tunnel transport itself was verified by hand on a network where local sharing provably fails, returning 200 through the public URL with the request arriving locally. The dialog flow has been exercised in the running application only as far as the documentation link; creating an internet share from the app and opening it from outside the network still wants a manual pass before merge.
Summary by CodeRabbit
New Features
Documentation
Bug Fixes