Rebuild Portal as a native Windows (WPF) file manager for SSH servers #17

Merged
oss merged 31 commits from feat/native-server-manager into main 2026-11-05 19:29:12 +01:00
Owner

Rebuilds Portal as a native Windows app, replacing the Rust/egui app. It's WPF on .NET 10 with the built-in Fluent theme, and the layout puts the server first. It still manages the server over SSH. The design and the research behind it are in DESIGN.md, README.md and docs/design/portal-prototype.html.

Why these choices

  • WPF over WinUI 3. WPF needs no extra workloads and builds from the CLI. It has a virtualised, sortable DataGrid, and OLE drag-and-drop works with Explorer. WinUI 3 has no grid, its hot reload needs Visual Studio, and its PRI naming broke when the exe was renamed (WindowsAppSDK #6248), which would break the exe → exe.old updater swap.
  • SSH (SFTP plus commands) over SMB, WebDAV, NFS or a custom agent. It is the only option that can change permissions and owners, reach paths owned by root, and run work on the server, without adding a new service to the server. Unraid doesn't give root access over SMB shares, and Authelia blocks non-browser clients.
  • Layout B, "Server first", was picked from three prototyped layouts.

What changed

  • app/Portal.Core: file systems, connecting, transfers, settings and updates.
    • Local and remote file systems. On the server, SSH.NET's SFTP handles listings and file data, and POSIX-quoted sh -s scripts run on the server for rm -rf, chmod/chown -R, cp -a, mv, du, df and find.
    • Connecting checks known_hosts, asks before trusting a new server, and refuses a changed key or an unrecorded key type.
      • Sign-in uses keys (asking for a passphrase if needed), a key file, or a password that is never stored.
      • Accounts limited to SFTP work too.
    • Transfer engine:
      • asks about conflicts: Replace or Merge, Keep both, or Skip, with an option to apply the answer to the rest;
      • writes .portal-part files that are renamed when complete;
      • resumes only its own interrupted part, and only when the source is unchanged;
      • keeps modification times, and keeps the mode of a replaced file;
      • a failed item doesn't stop the others.
    • Symlinks are never followed on writes: deletes, renames, moves, replaces and merges all leave the link's target alone.
    • Settings live in %APPDATA%\portal and are imported from the old app's files.
    • Updater ported from the Rust app.
  • app/Portal (the WPF app):
    • Connect screen.
    • Browser:
      • sidebar with places, pins, disk space and servers;
      • server pane with permission and owner columns, and an optional This PC pane;
      • details pane;
      • breadcrumbs, filter and sorting.
    • Explorer-style selection and keyboard support, drag and drop, and copy and paste.
    • Inline rename, and dialogs for delete, conflicts, permissions (only the changed bits), host keys and settings.
    • Transfer panel, Ctrl+K command and file search, toasts, and edit-on-server.
  • app/Portal.Shots: an offscreen screenshot harness over an in-memory server, with 14 scenes in each theme.
  • .claude/skills:
    • release.sh publishes a single-file portal.exe, with the version in Directory.Build.props.
    • The verify skill is updated, and there is a guest UI suite in verify/suites.
  • Removed: the Rust app, in its own commit (42482c6).

Verification

Check Result
dotnet build Portal.slnx 0 warnings, 0 errors
dotnet test Portal.slnx 171 passed; 17 skipped by design (Docker and live tests)
PORTAL_SSH_TESTS=1 integration tests against two throwaway OpenSSH containers (one SFTP-only) 14/14 passed (185 passed in total, 3 live tests skipped)
PORTAL_LIVE_TESTS=1 updater tests against git.ossalali.com 3/3 passed (run by the agent that ported the updater)
Offscreen screenshots, dark and light Rendered and reviewed by eye
dotnet publish single file portal.exe 78.6 MB, file version 0.5.0.0
Code review: review and taste-review skills, Opus, five rounds Rounds 1–4 BLOCK, each finding fixed; round 5 PASS, and its four minors are fixed in 3875479
Code review: GPT-6.1-Sol, six rounds Rounds 1–5 BLOCK, each finding fixed (81894eb, 0c6ef3b, 6a0dfbd, 0de352c, be8b7de); round 6 PASS at be8b7de, and its one minor is fixed in 10c3ede

The integration tests cover:

  • Host keys: first-connect trust, a declined key, a changed key, an unrecorded key type, and a server that hangs up.
  • Sign-in: password.
  • Transfers: upload, download, replace and merge.
  • Permission changes: only the changed bits are applied.
  • Copy and move on the server: with conflicts.
  • Symlink cases: on accounts with and without a shell, including a link created at the last moment before a file is put in place.

Known limit

On accounts limited to SFTP, a symbolic link that another process creates in the instant between Portal's last check and its SFTP rename would still be followed. SSH.NET resolves paths before renaming and keeps its raw SFTP session internal, so this window can be narrowed but not closed. Accounts that can run commands replace files with mv -f, which has no such window. The README's "Symbolic links" section describes this.

Not verified

  • The live window on the Windows CI VM. The suite (portal-ui) is ready, but its route in Forgejo-VM-MCP is PR #14. After that is merged, run provisioning\guest\install-dotnet-sdk.ps1 in the guests and restart the MCP server. Until then, nothing has driven a real window. The screenshots are offscreen renders.
  • Real servers. Nothing has run against Tower or Hetzner: not transfer speed, Unraid's shfs, root-owned paths, chown to other users, or fail2ban.
  • Explorer integration and updating. Drag and drop with Explorer, and the relaunch after an update, haven't been run.

🤖 Generated with Claude Code

Rebuilds Portal as a native Windows app, replacing the Rust/egui app. It's WPF on .NET 10 with the built-in Fluent theme, and the layout puts the server first. It still manages the server over SSH. The design and the research behind it are in `DESIGN.md`, `README.md` and `docs/design/portal-prototype.html`. ## Why these choices - **WPF over WinUI 3.** WPF needs no extra workloads and builds from the CLI. It has a virtualised, sortable `DataGrid`, and OLE drag-and-drop works with Explorer. WinUI 3 has no grid, its hot reload needs Visual Studio, and its PRI naming broke when the exe was renamed (WindowsAppSDK #6248), which would break the exe → exe.old updater swap. - **SSH (SFTP plus commands) over SMB, WebDAV, NFS or a custom agent.** It is the only option that can change permissions and owners, reach paths owned by root, and run work on the server, without adding a new service to the server. Unraid doesn't give root access over SMB shares, and Authelia blocks non-browser clients. - **Layout B, "Server first",** was picked from three prototyped layouts. ## What changed - **`app/Portal.Core`**: file systems, connecting, transfers, settings and updates. - **Local and remote file systems.** On the server, SSH.NET's SFTP handles listings and file data, and POSIX-quoted `sh -s` scripts run on the server for `rm -rf`, `chmod`/`chown -R`, `cp -a`, `mv`, `du`, `df` and `find`. - **Connecting** checks `known_hosts`, asks before trusting a new server, and refuses a changed key or an unrecorded key type. - Sign-in uses keys (asking for a passphrase if needed), a key file, or a password that is never stored. - Accounts limited to SFTP work too. - **Transfer engine:** - asks about conflicts: Replace or Merge, Keep both, or Skip, with an option to apply the answer to the rest; - writes `.portal-part` files that are renamed when complete; - resumes only its own interrupted part, and only when the source is unchanged; - keeps modification times, and keeps the mode of a replaced file; - a failed item doesn't stop the others. - **Symlinks are never followed on writes:** deletes, renames, moves, replaces and merges all leave the link's target alone. - **Settings** live in `%APPDATA%\portal` and are imported from the old app's files. - **Updater** ported from the Rust app. - **`app/Portal`** (the WPF app): - Connect screen. - Browser: - sidebar with places, pins, disk space and servers; - server pane with permission and owner columns, and an optional This PC pane; - details pane; - breadcrumbs, filter and sorting. - Explorer-style selection and keyboard support, drag and drop, and copy and paste. - Inline rename, and dialogs for delete, conflicts, permissions (only the changed bits), host keys and settings. - Transfer panel, Ctrl+K command and file search, toasts, and edit-on-server. - **`app/Portal.Shots`**: an offscreen screenshot harness over an in-memory server, with 14 scenes in each theme. - **`.claude/skills`**: - `release.sh` publishes a single-file `portal.exe`, with the version in `Directory.Build.props`. - The verify skill is updated, and there is a guest UI suite in `verify/suites`. - **Removed:** the Rust app, in its own commit (`42482c6`). ## Verification | Check | Result | |---|---| | `dotnet build Portal.slnx` | 0 warnings, 0 errors | | `dotnet test Portal.slnx` | 171 passed; 17 skipped by design (Docker and live tests) | | `PORTAL_SSH_TESTS=1` integration tests against two throwaway OpenSSH containers (one SFTP-only) | 14/14 passed (185 passed in total, 3 live tests skipped) | | `PORTAL_LIVE_TESTS=1` updater tests against git.ossalali.com | 3/3 passed (run by the agent that ported the updater) | | Offscreen screenshots, dark and light | Rendered and reviewed by eye | | `dotnet publish` single file | `portal.exe` 78.6 MB, file version 0.5.0.0 | | Code review: `review` and `taste-review` skills, Opus, five rounds | Rounds 1–4 BLOCK, each finding fixed; round 5 PASS, and its four minors are fixed in `3875479` | | Code review: GPT-6.1-Sol, six rounds | Rounds 1–5 BLOCK, each finding fixed (`81894eb`, `0c6ef3b`, `6a0dfbd`, `0de352c`, `be8b7de`); round 6 PASS at `be8b7de`, and its one minor is fixed in `10c3ede` | The integration tests cover: - **Host keys:** first-connect trust, a declined key, a changed key, an unrecorded key type, and a server that hangs up. - **Sign-in:** password. - **Transfers:** upload, download, replace and merge. - **Permission changes:** only the changed bits are applied. - **Copy and move on the server:** with conflicts. - **Symlink cases:** on accounts with and without a shell, including a link created at the last moment before a file is put in place. ## Known limit On accounts limited to SFTP, a symbolic link that another process creates in the instant between Portal's last check and its SFTP rename would still be followed. SSH.NET resolves paths before renaming and keeps its raw SFTP session internal, so this window can be narrowed but not closed. Accounts that can run commands replace files with `mv -f`, which has no such window. The README's "Symbolic links" section describes this. ## Not verified - **The live window on the Windows CI VM.** The suite (`portal-ui`) is ready, but its route in Forgejo-VM-MCP is [PR #14](https://git.ossalali.com/oss/Forgejo-VM-MCP/pulls/14). After that is merged, run `provisioning\guest\install-dotnet-sdk.ps1` in the guests and restart the MCP server. Until then, nothing has driven a real window. The screenshots are offscreen renders. - **Real servers.** Nothing has run against Tower or Hetzner: not transfer speed, Unraid's shfs, root-owned paths, `chown` to other users, or fail2ban. - **Explorer integration and updating.** Drag and drop with Explorer, and the relaunch after an update, haven't been run. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
oss added 22 commits 2026-11-04 01:14:20 +01:00
Three layouts (twin panes, server first, workspace) switchable through
the Tweaks panel, sharing one Fluent-based visual system: connect flow
with host-key trust, breadcrumbs, sortable columns, Explorer-style
selection and keyboard, context menus, conflict and delete dialogs,
permissions editor, transfer queue with retry, and Ctrl+K search.
PosixShell.Quote wraps an argument in single quotes and escapes embedded
quotes as '\''; Join quotes and space-separates a whole argument list.
SshConfig reads ~/.ssh/config with OpenSSH rules: case-insensitive keywords,
Key=value, quoting, comments, Host patterns with *, ? and ! negation, first
value wins, IdentityFile accumulation, skipped Match blocks and Include with
relative, absolute and globbed paths. Resolve expands ~, %d and %h.
KnownHosts reports a server key as Trusted, Unknown or Changed against plain
and hashed entries, [host]:port, wildcards, negation and @revoked, ignoring
@cert-authority. Add appends host entries; Fingerprint gives the OpenSSH
SHA256 form.
Same Forgejo latest-release endpoint and git.ossalali.com https allowlist as
src/update.rs. UpdateService checks for a newer stable release, downloads
portal.exe to .new with size and MZ checks, swaps it in by renaming the
running binary to .old, relaunches with the original arguments and cleans up
leftovers. The HTTP handler, executable path and restart arguments are
injectable; live tests run only with PORTAL_LIVE_TESTS=1.
Core: IFileSystem with local (Recycle Bin deletes) and SSH.NET remote
implementations (SFTP for listings and file data, quoted shell commands
for rm, chmod/chown, du, df, find, cp and mv), a connector that checks
known_hosts and asks before trusting a new server, a transfer engine
with conflict answers, .portal-part temp files, resume and per-item
errors, and JSON settings that import the old Portal's files.

App: connect screen, browser with sidebar, panes, details, transfer
panel, Ctrl+K search, in-window dialogs and toasts. The SSH config,
known_hosts and updater parts are placeholders until the parser branch
lands.
Portal.Shots renders 14 scenes per theme to target/shots-wpf through
RenderTargetBitmap over an in-memory server, with no window. The UI now
talks to IServerSession / IServerFileSystem, which RemoteSession and
RemoteFileSystem implement, so it can run without SSH.

Fixes found in the shots: accent-tinted row selection with an accent
pill, breadcrumbs that show the deepest folders, no details pane when
both panes would be too narrow, a spinner for the active connect step,
quieter progress tracks.
SshIntegrationTests (PORTAL_SSH_TESTS=1) start a throwaway container
with a generated key and cover first-connect trust, a declined and a
changed host key, password sign-in, uploads with mtime and mode kept,
folder upload and download, rename, copy and move on the server,
du, df, search and delete.

Fixes: mode restore after a replace now uses chmod (SSH.NET reads its
mode argument as octal digits), and search falls back to find + stat
on busybox servers without find -printf.
The suite under .claude/skills/verify/suites builds the solution, runs
the unit tests, renders every harness scene in both themes and checks
each PNG, then starts the real portal.exe with a private profile
(PORTAL_CONFIG_DIR, PORTAL_SSH_DIR), drives the connect form through UI
Automation patterns only, captures the window and closes it through
its WindowPattern.
README, a DESIGN.md for the server-first layout, the verify skill for
the new suites, and release.sh that bumps Directory.Build.props,
tests, and publishes the single-file portal.exe. The base version is
the last released 0.5.0, so the next release bumps it.
The WPF app in app/ replaces it: same release asset name and updater
rules, and it imports the old sessions.json and settings.json.
- Permissions apply only the bits the user changed (chmod u+x,g-w...),
  so a recursive owner change no longer resets every mode in the tree.
- A transfer resumes only its own interrupted part file, and only when
  the source is unchanged; leftovers from earlier runs are overwritten.
- Local name conflicts compare case-insensitively.
- Server-side paste and drag-move ask about conflicts like transfers,
  and copies into the same folder are named 'x - Copy'. A file meeting
  a folder of the same name is always asked about, without Replace.
- For a known server, only the recorded host-key types are negotiated,
  and an unrecorded type counts as a changed identity.
- Accounts limited to SFTP connect: the command channel is probed and
  optional, replace uses SFTP rename, delete and chmod fall back to SFTP.
- Disconnecting or switching servers cancels that session's transfers
  and says so; retry errors and unhandled exceptions become toasts.
- Symlink targets resolve in parallel; edit-on-server uploads one at a
  time per file; Open terminal passes -i, escapes ; for wt, and reports
  a missing terminal; breadcrumbs collapse leading folders into '…'.

Tests: PermissionDelta, copy names, stale part files, case conflicts,
file/folder conflicts; integration against Docker now also covers an
SFTP-only account, an unrecorded key type, recursive permission deltas
and server copy/move conflicts (8/8 pass).
- SFTP resolves paths before remove and rename, so on SFTP-only
  accounts deletes, renames and moves now refuse anything that is or
  contains a symbolic link (checked from directory listings, which do
  not follow links) and touch nothing. Accounts with a shell rename and
  move with mv, which acts on the link itself.
- The shell probe retries once before falling back, and an SFTP-only
  session says so in a toast, the status bar and the search palette.
- A known server that no longer offers the recorded key type reads as
  a changed identity instead of a generic key-exchange error.
- Names that differ only in case within one download get separate
  local names instead of overwriting each other.
- Closing a session waits for cancelled transfers to remove their
  partial files before disconnecting.
- A number typed into the chmod box sets exactly that mode on every
  item; ticking boxes still applies only the changed bits.
- Cut and paste keeps the clipboard unless the move went through.

Integration tests (10/10 against Docker) now also cover symlinks on
both account kinds and a server without the recorded key type.
- Replacing onto a symlink replaces the link itself (mv -f) on accounts
  with a shell, and is refused on SFTP-only accounts, so a file outside
  the folder is never overwritten. Edit-on-server writes through a link
  the user opened, since that is the file they edited.
- Shell renames refuse an existing target instead of overwriting it.
- Only SSH.NET's 'No matching host key algorithm' counts as a changed
  identity; dropped or refused connections keep their own message.
- The shell probe also retries after a timeout, and the notice tells
  an SFTP-only account apart from a command channel that didn't open.
- Case twins in one download never both replace the same local file.
- SFTP-only deletes list each parent folder once.

Integration (12/12 against Docker) adds replace onto a link on both
account kinds, a rename onto an existing name, and a server that hangs
up after its banner.
- Every server command is sent to 'sh -s' on standard input, so a fish
  or tcsh login shell only parses 'sh -s' and every script is POSIX sh.
- Uploads replace through SFTP again, with no exec per file. Before any
  byte is sent, the engine checks the destination folder's listing and
  refuses when the target or its part file is a symbolic link, so
  nothing is written through a link or left behind.
- Server-side move refuses a folder (or a link to one) as the target
  instead of moving into it.
- Clearer wording for symlink refusals and for connections that can't
  run commands; README notes that edits of a linked file are written in
  place.
- Server copy refuses a destination that is a symbolic link, and copy
  or move with merge first checks the destination tree for links at
  every path the source has, so nothing changes when one is found.
- Upload merges no longer follow a link to a folder inside the
  destination: the link (and everything the upload would put under it)
  is skipped, as is a file whose target or part file is a link. The
  item finishes and its row names what was skipped.
- The link check reads one listing per folder, shared by the whole
  batch and without resolving link targets.
- The refusal message no longer claims nothing changed.

Integration (13/13 against Docker) adds copy, move and merge onto
links and folders, and an upload merge with a linked folder, a linked
file and a planted part-file link.
- The merge guard checks every path through find -exec, so a name with
  a newline can't slip past it.
- A refused merge names only the entry that wasn't copied or moved,
  and the pane refreshes afterwards to show what was placed.
- Skipped links survive Resume and are named by their path inside the
  upload, with 'and N more' past three.
GPT-6.1-Sol review of PR #17:
- Server names are mapped to Windows-safe names (forbidden characters
  become _, device names get a prefix, trailing dots and spaces go),
  and every local write is checked to stay inside the chosen folder,
  so a server file named ..\x or C:\x can't land elsewhere. The same
  applies to edit-on-server's temp copy.
- Each transfer stages into its own .name.<id>.portal-part file, so a
  resume can only ever continue its own data, and a second transfer
  queued to the same path gets a numbered name instead of racing.
- Edit-on-server saves stage into a fresh uniquely named part file,
  refuse if anything is in the way, and decide at save time whether the
  file is a link (written in place) or not (atomic replace).
GPT-6.1-Sol re-review of PR #17:
- Enqueues run one at a time and choose names against files on disk and
  every unfinished transfer into the same folder, so three queued
  copies of same.txt become same.txt, same (1).txt and same (2).txt.
- While connected, a saved server's address, user, port and sign-in
  can't be edited (the form says to disconnect first); renaming it
  updates the window title and labels at once.
GPT-6.1-Sol re-review 2 of PR #17:
- Unfinished destinations are reserved before the folder is listed, and
  a finished part file is published without overwriting unless the user
  chose Replace: if a file appeared at that name meanwhile, it is kept
  and the transfer lands under a numbered name instead.
- The server's display name follows the saved profile, so a rename
  shows at once in the pane header, details, selection summary, dialogs
  and the card.
- Template comments removed from AssemblyInfo.cs.
GPT-6.1-Sol re-review 3 of PR #17:
- Part files are created with CreateNew, so a link planted at that name
  makes the upload fail instead of writing through it; a leftover part
  that can't be resumed is removed first.
- Replacing publishes with 'mv -f' when commands run (replaces a link
  itself, refuses a folder); SFTP-only accounts re-read the folder's
  links right before the rename. New names still use a plain SFTP
  rename, which fails rather than follows. Edit-on-server publishes the
  same way.
- The transfer row's name is updated only after the file is in place,
  compared against the originally planned path, so a retry can't leave
  it pointing at the wrong numbered file.
- README describes how Portal handles symbolic links, including the
  remaining SFTP-only window.
GPT-6.1-Sol re-review 4 of PR #17: a drop before the first byte left
an empty part that was accepted for resuming, but offset 0 then chose
an exclusive create, which failed on every retry. Whether a part is
resumed is now tracked apart from its size.
GPT-6.1-Sol re-review 5 of PR #17: selecting another item while
"Calculate size" ran let the cancellation reach the app-wide error
handler.
The This PC toggle sat at the far right of the command bar while the
pane opens at the far left of the file area. A 44 px rail now holds the
pane's place while it's closed: click it, or drag server files over it,
to open the pane. A fold button leads the This PC header. Mouse buttons
4 and 5 go back and forward in the pane under the pointer.

Proposal: design/proposals/2026-11-05-this-pc-toggle.html, option C.
A checkout with Windows line endings turned the test's df sample into
CRLF lines, and the mount points kept a trailing carriage return.
The fold button's chevron didn't read as a close button. It now shows
the open/close pane glyph the command bar used before, pointing left;
the rail shows the mirrored glyph, pointing right.
oss merged commit b5682ac9f6 into main 2026-11-05 19:29:12 +01:00
oss deleted branch feat/native-server-manager 2026-11-05 19:29:14 +01:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
oss/Portal!17
No description provided.