Skip to content

feat(gui): amuleGUI remote-to-local path mappings (#843) - #854

Merged
got3nks merged 4 commits into
amule-org:masterfrom
LSalami:amulegui-path-mappings
Aug 8, 2026
Merged

feat(gui): amuleGUI remote-to-local path mappings (#843)#854
got3nks merged 4 commits into
amule-org:masterfrom
LSalami:amulegui-path-mappings

Conversation

@LSalami

@LSalami LSalami commented Aug 8, 2026

Copy link
Copy Markdown

Closes #843.

Problem

FileLaunch::Open()/Reveal() — "open this download", "show in file manager" — silently disable themselves (FileLaunch::GetAvailability()) whenever the daemon-reported path doesn't exist verbatim on amuleGUI's own machine. That's always true for a genuinely remote daemon, unless the exact same absolute path happens to be independently mounted. This adds a user-configurable table of (remote prefix → local prefix) mappings — e.g. the daemon's /downloads/incoming reachable locally as D:\Downloads\aMule\incoming via a Samba mount — so those actions work against a remote daemon whose filesystem is otherwise reachable some other way.

Design

Storage is genuinely GUI-local. CPreferences::PathMapping / Load|SavePathMappings() read and write wxConfigBase::Get() directly (already resolves to remote.conf under CLIENT_GUI), never through LoadAllItems()/SaveAllItems()'s Cfg_Base walk, and never added to CPreferencesRem's m_exchange_send_selected_prefs/m_exchange_recv_selected_prefs — so this never round-trips over EC. Two existing "list of items" precedents looked reusable but weren't: Categories and the shared-dirs editor both look locally persisted but are actually daemon-owned, silently overwritten by the next EC pull. This takes SaveCats()'s per-row wxConfigBase group shape without its EC-backed data source.

Applied in FileLaunch::ResolvePath(), the single choke point every Open/Reveal/availability check already goes through (six call sites across DownloadListCtrl.cpp and SharedFilesCtrl.cpp, none touched). ApplyPathMapping() does plain string-prefix substitution on the raw daemon path before it becomes a CPathCPath has no notion of a second machine's separator convention, so the remote side is compared and rewritten as a string, never parsed as this host's path syntax. First matching prefix in the user's list order wins (no implicit longest-prefix-match).

New "Path Mappings" Preferences page, CLIENT_GUI-only, modelled on the existing shared-dirs list editor's widget shape (wxListCtrl + text entry + Add/Remove) but without its EC round-trip/dirty-flag/session machinery, which exists only to survive a background daemon refresh that cannot happen to a purely local list.

Known gap

Developed and tested on macOS. The substitution itself is separator-agnostic pure string logic, and mingw-w64 CI confirms the Windows build compiles, but an actual Samba-mounted drive-letter/UNC mapping has not been exercised end-to-end. Flagging this explicitly rather than claiming full cross-platform verification.

Verification

  • amule (CLIENT_GUI off) and amulegui (CLIENT_GUI on) build clean from a fresh CMake configure — confirms the new page/storage compile out entirely on the monolithic side
  • Pinned clang-format v18 clean
  • Tier-1 (whole tree) and Tier-2 (changed lines vs upstream/master) clang-tidy clean via the local CI replica — Tier-1 caught and I fixed one real hit: an unconditional CPath copy in ResolvePath() that was wasted work on the non-CLIENT_GUI build, where the mapping branch never runs

@got3nks

got3nks commented Aug 8, 2026

Copy link
Copy Markdown

Verified the design claims hold: path mappings never reach CEC_Prefs_Packet or the m_exchange_* sets and are read/written straight through wxConfigBase, so they genuinely stay GUI-local; and ResolvePath() really is the single choke point — GetAvailability, CanOpen, CanReveal, Open and Reveal all route through it, so the availability decision and the action use the same translated path. Builds clean here on macOS (amule, amulegui, amuled), no warnings from project sources.

Three things to fix, the first two in the same few lines of ApplyPathMapping().

1. A trailing separator on the remote prefix silently corrupts every path it maps. The substitution is localPrefix.GetRaw() + remotePath.Mid(remotePrefix.length()), and CPath's constructor does not strip trailing separators (StripSeparators is used in JoinPaths/IsSameAs/StartsWith, never in the ctor). So remote /downloads/ with local /Volumes/dl gives /Volumes/dl + incoming/f.avi = /Volumes/dlincoming/f.avi. That is the most likely combination through this UI rather than a corner case: wxDirDialog::GetPath() never returns a trailing separator, so Browse always supplies a local prefix without one, while a user describing a remote directory naturally types one. Because availability is FileExists() on the mapped path, the result is that Open/Show stay greyed — the exact symptom the feature removes — with nothing indicating the mapping is malformed.

Fix: strip trailing separators from both prefixes where they are accepted (OnPathMappingAdd and LoadPathMappings), and let the remainder supply the separator.

2. The prefix match has no boundary check. remotePath.StartsWith(mapping.remotePrefix) means a mapping for /mnt/data also matches /mnt/data-old/f.avi and rewrites it to /Volumes/data-old/f.avi. Usually that path does not exist and the entry just disappears; if it happens to exist, it opens the wrong file.

Fix: require the character after the prefix to be a separator, or the whole string to match.

3. The explanatory paragraph does not use the panel width. itemHint->Wrap( 380 ) in muuli_wdr.cpp is a one-shot text transformation, not a layout constraint: it bakes hard line breaks at 380 pixels measured once at construction, so Expand() stretches the control while the text keeps its original breaks — hence the empty right margin. It is also not FromDIP()-scaled although the font is, so it reads narrower on HiDPI, and it never re-flows even though the dialog has wxRESIZE_BORDER. It is the only Wrap() call in muuli_wdr.cpp.

Fix: drop the Wrap() from muuli_wdr.cpp (that file is layout construction; the other pages keep logic out of it) and wrap from the panel's real client width in PrefsUnifiedDlg, re-applied on size. FromDIP(380) alone fixes only the HiDPI half.

Minor: OnPathMappingAdd returns silently on empty or duplicate input, so the button reads as dead. The fields keep their text, so it is recoverable, but a short message or a disabled Add would be clearer.

The known gap you flagged is the right call — the substitution is separator-agnostic string logic, and (1) above is the part that actually needs a Windows-shaped path to shake out.

@got3nks

got3nks commented Aug 8, 2026

Copy link
Copy Markdown

#859 has landed on master, and it adds two helpers your path-mappings editor should use for its local-prefix column. Rebasing onto master will bring them in.

Background: writing GetPrintable() into a cell and reading it back as a CPath is not a round trip. On macOS wxConvFileName normalises to NFD, so a precomposed accented character comes back decomposed — same characters, different bytes, and a path a byte-exact filesystem does not have. HarvestSharedDirsList() had the same shape, which is what #859 fixed; the helpers came out of it file-local precisely so this editor could use them too.

They sit in an anonymous namespace above PopulateSharedDirsList(), so your functions see them with no declaration needed. Three changes:

Header, next to m_sharedDirRowPaths:

std::vector<CPath> m_pathMappingRowPaths;

PopulatePathMappingList() — clear the store after DeleteAllItems(), and record the path instead of writing its display form:

list->DeleteAllItems();
m_pathMappingRowPaths.clear();
...
list->InsertItem(row, mapping.remotePrefix);
SetListRowPath(list, row, 1, mapping.localPrefix, m_pathMappingRowPaths);

HarvestPathMappingList() — the whole wxListItem block goes away:

mapping.remotePrefix = list->GetItemText(row);   // unchanged, see below
mapping.localPrefix = GetListRowPath(list, row, m_pathMappingRowPaths);

OnPathMappingAdd():

const long row = list->InsertItem(list->GetItemCount(), remote);
SetListRowPath(list, row, 1, CPath(local), m_pathMappingRowPaths);

remotePrefix needs none of this and should stay exactly as it is: it is the daemon's path as an opaque wxString, never wrapped in a CPath, so it already round-trips byte for byte — and your duplicate check comparing those strings is right for the same reason.

The three points from the earlier review still stand independently of this.

LSalami added 2 commits August 8, 2026 13:06
FileLaunch::Open()/Reveal() silently disable themselves whenever the
daemon-reported path doesn't exist verbatim on amuleGUI's own machine,
which is always true for a genuinely remote daemon unless the exact
same absolute path happens to be independently mounted. Lets the user
configure a table of remote->local path-prefix mappings (e.g. the
daemon's /downloads/incoming reachable here as
D:\Downloads\aMule\incoming via a Samba mount) so those actions work
against a remote daemon whose filesystem is otherwise reachable.

Storage is genuinely GUI-local: CPreferences::PathMapping /
Load|SavePathMappings() read and write wxConfigBase::Get() directly
(which already resolves to remote.conf under CLIENT_GUI), never
through LoadAllItems()/SaveAllItems()'s Cfg_Base walk and never added
to CPreferencesRem's m_exchange_send_selected_prefs /
m_exchange_recv_selected_prefs -- so this never round-trips over EC.
Two existing "list of items" precedents looked reusable but weren't:
Categories and the shared-dirs editor both look locally persisted but
are actually daemon-owned, silently overwritten by the next EC pull.
Took SaveCats()'s per-row wxConfigBase group shape without its
EC-backed data source.

Applied in FileLaunch::ResolvePath(), the single choke point every
Open/Reveal/availability check already goes through (six call sites
across DownloadListCtrl.cpp and SharedFilesCtrl.cpp, none touched).
ApplyPathMapping() does plain string-prefix substitution on the raw
daemon path before it becomes a CPath -- CPath has no notion of a
second machine's separator convention, so the remote side is compared
and rewritten as a string, never parsed as this host's path syntax.
First matching prefix in the user's list order wins (no implicit
longest-prefix-match).

New "Path Mappings" Preferences page, CLIENT_GUI-only, modelled on the
existing shared-dirs list editor's widget shape (wxListCtrl + text
entry + Add/Remove) but without its EC round-trip/dirty-flag/session
machinery, which exists only to survive a background daemon refresh
that cannot happen to a purely local list.

Known gap: developed and tested on macOS. The substitution itself is
separator-agnostic pure string logic, and mingw-w64 CI confirms the
Windows build compiles, but an actual Samba-mounted drive-letter/UNC
mapping has not been exercised end-to-end this round.

Verified: amule (CLIENT_GUI off) and amulegui (CLIENT_GUI on) build
clean from a fresh CMake configure; pinned clang-format v18 clean;
Tier-1 clang-tidy clean via the local CI replica against upstream/master
(caught and fixed one real hit: an unconditional CPath copy in
ResolvePath() that's wasted work on the non-CLIENT_GUI build, where
the mapping branch never runs).
…ase onto master

got3nks's review on amule-org#854 (github.com/amule-org/pull/854):

1. A trailing separator on the remote prefix silently corrupted every
   path it mapped: the substitution is localPrefix + remainder, and
   CPath's constructor does not strip trailing separators, so remote
   "/downloads/" + local "/Volumes/dl" produced
   "/Volumes/dlincoming/f.avi" instead of "/Volumes/dl/incoming/f.avi".
   Browse never supplies a trailing separator (wxDirDialog::GetPath()
   doesn't return one) but a user describing a remote *directory*
   naturally types one, so this was the likely path through the UI, not
   a corner case. Fixed by stripping trailing separators from both
   prefixes wherever they're accepted: OnPathMappingAdd() (entry) and
   LoadPathMappings() (an existing config saved before this fix, or
   hand-edited).
2. ApplyPathMapping()'s prefix match had no boundary check: a mapping for
   "/mnt/data" also matched "/mnt/data-old/f.avi". Fixed by requiring the
   character after the prefix to be a separator (either convention, since
   the daemon's OS isn't known here) or the whole string to match.
3. The explanatory paragraph above the mapping list didn't use the panel
   width: muuli_wdr.cpp's one-shot Wrap(380) baked fixed line breaks at
   construction while the sizer's Expand() only stretched the control,
   leaving an empty right margin, and never re-flowed on resize (the
   dialog has wxRESIZE_BORDER) or DPI. Fixed by dropping the Wrap() from
   muuli_wdr.cpp (layout construction only, per that file's convention)
   and re-wrapping from the control's own real, DPI-scaled client width
   in PrefsUnifiedDlg -- once in PopulatePathMappingList() and again on
   every resize via a runtime Bind(wxEVT_SIZE) (size events don't
   propagate through the static event table the way command events do,
   so this can't be a wxDECLARE_EVENT_TABLE() row).
   Minor: OnPathMappingAdd() also now tells the user why nothing happened
   on empty/duplicate input instead of silently no-opping.

Also rebased onto current master to pick up amule-org#859, which fixed the same
"cell text is not a round-trippable CPath" bug (macOS NFD-normalisation
mismatch) in the shared-dirs editor this one was modelled on, and
extracted SetListRowPath()/GetListRowPath() for exactly this reuse.
Path-mapping's local-prefix column now goes through them instead of
GetPrintable()/CPath(text) round-tripping by hand.

Verified: rebuilt amule, amuled and amulegui locally, all clean.
po/ regenerated -- msgid delta +13/-0 (11 carried over from the original
amule-org#843 strings the rebase's po/ conflict resolution reset to upstream, plus
2 new validation-message strings from this fix).
@LSalami
LSalami force-pushed the amulegui-path-mappings branch from 32c56e8 to 41e29a3 Compare August 8, 2026 11:37
@LSalami

LSalami commented Aug 8, 2026

Copy link
Copy Markdown
Author

All three, plus the minor, addressed:

  1. Trailing separator — stripped at both places prefixes are accepted: OnPathMappingAdd() and LoadPathMappings() (the latter so an already-saved or hand-edited config gets cleaned up too, not just new entries).
  2. Prefix boundaryApplyPathMapping() now requires the character right after the matched prefix to be a separator (either convention, since the daemon's OS isn't known here) or the whole string to match exactly.
  3. Wrap() width — dropped the fixed Wrap(380) from muuli_wdr.cpp; PrefsUnifiedDlg now wraps from the control's own real client width, once in PopulatePathMappingList() and again on every resize via a runtime Bind(wxEVT_SIZE) (size events don't route through the static event table by id the way command events do). This also folds in the HiDPI half for free, since it's reading the actual scaled width rather than a hardcoded value.
  4. Minor — empty/duplicate Add now tells the user why instead of no-opping silently.

Also rebased onto current master and switched the local-prefix column over to #859's SetListRowPath()/GetListRowPath(), as you suggested.

Rebuilt amule/amuled/amulegui locally, all clean. po/ regenerated (msgid delta +13/-0: the 11 original #843 strings the rebase's po/ conflict resolution reset to upstream, plus 2 new validation-message strings). Local clang-tidy CI replica against upstream/master: Tier-1 only the 13 known pre-existing warnings, Tier-2 clean.

The re-wrap added for the review's layout point read the paragraph's own
width and rewrote the paragraph from inside that paragraph's size handler.
wxStaticText::SetLabel() resizes the control to fit its new label, so the
handler fed itself: restoring the unwrapped text to re-flow made the control
briefly as wide as the whole sentence, that width came back as another size
event, and the two alternated until the stack was gone (EXC_BAD_ACCESS,
"excessive recursion", inside SetLabel under NSView setFrameSize).

Bound on the page instead. A page's width is set by the dialog and is
unmoved by anything its children do, so the input to the wrap no longer
depends on what the wrap changes and the loop is gone structurally rather
than by hoping the widths converge. Re-wrapping is additionally skipped when
the width has not moved, so a height-only relayout costs nothing, and
page->Layout() runs afterwards so the list follows the paragraph's new
height.

Two things the earlier version could not do, now fixed with it:

Wrap() only ever inserts breaks -- it re-reads the current label and treats
newlines already in it as hard -- so wrapping in place could narrow the text
but never rejoin it. Widening the dialog left the paragraph at its old
narrow width, which is the empty right margin the review was about, just
reached by resizing instead of baked in. The unwrapped text is kept and
restored before each wrap.

muuli_wdr.cpp wraps once again at construction, which the fix had dropped.
An unwrapped wxStaticText reports its whole single line as its best width
and the sizer turns that into the page's minimum, so the dialog opened as
wide as the sentence. It is bounded there and re-flowed from here; the bound
is parent->FromDIP(380) rather than a raw pixel count, which was the part
that read cramped on a HiDPI display.

Also adds the include for StripSeparators, which Preferences.cpp had been
getting transitively.

Tested on macOS: the page opens at a sane width, the paragraph fills it, and
it re-flows both wider and narrower under a continuous drag.
@got3nks

got3nks commented Aug 8, 2026

Copy link
Copy Markdown

Checked all three fixes against the code and they're right — the strip at load as well as at entry is the part I'd have missed, since it repairs a config written before the fix. I've pushed one commit on top rather than send you round again for it (0341954b).

It reworks the hint re-flow. Bound on the paragraph, the handler fed itself: wxStaticText::SetLabel() resizes the control to fit its label, so restoring the unwrapped text made it briefly as wide as the whole sentence, that width arrived as another size event, and the two alternated until the stack went — EXC_BAD_ACCESS, "excessive recursion", inside SetLabel. It's now bound on the page, whose width the dialog sets and the label can't influence, so there's no feedback path rather than a guard hoping the widths settle.

Two more from the same area. Wrap() only inserts breaks and treats existing ones as hard, so wrapping in place could narrow but never rejoin — widening left the old narrow width, i.e. the margin this started from. The unwrapped text is kept and restored before each wrap. And dropping the construction-time Wrap() made the page's minimum width the whole sentence, since an unwrapped wxStaticText reports its single line as its best width; that's back, at parent->FromDIP(380) so it scales.

Also added the <common/Path.h> include StripSeparators was getting transitively.

Tested on macOS: opens at a sane width, fills it, re-flows both ways under a continuous drag.

One thing left alone: stripping means a prefix of just / collapses to empty and is rejected, so the daemon's root can't be mapped. Mapping /home instead works, and special-casing it seemed worse than the limitation.

…ions

Two gaps the trailing-separator trim left, both about the mapping's two
halves coming from machines that need not agree on a separator.

The trim used StripSeparators(), which consults *this* host's separator set.
On a POSIX build that set has no backslash, so a Windows daemon's "D:\dl\"
kept its trailing separator and the join ran the halves together -- exactly
the corruption the trim exists to prevent, alive in the mirror direction (a
Linux or macOS amulegui against a Windows daemon). The remote prefix is now
trimmed by CPreferences::TrimRemotePrefix(), which accepts either convention
because the daemon's OS is not knowable here -- the same reason the prefix
boundary test already accepts either character.

And the remainder spliced onto the local prefix keeps the daemon's
separators, so a POSIX daemon feeds '/' into a path about to be handed to
Win32. Most of Win32 takes that, but "explorer /select," -- which is what
Reveal() runs, and the reason this feature exists -- is the fussy one.
Normalised on Windows only: '/' cannot occur in a Windows filename, whereas
a backslash is an ordinary character in a POSIX one, so the mirror rewrite
would corrupt names instead of fixing separators.

Verified on Windows that Win32 resolves mixed separators (Test-Path on
"C:\dir/sub/f.txt" is true), so availability and Open were already fine
there; this is for Reveal and for the POSIX-client-Windows-daemon direction.
@got3nks

got3nks commented Aug 8, 2026

Copy link
Copy Markdown

One more commit (7c1fd5dc), on the cross-OS half of the mapping — the known gap in your description.

The trailing-separator trim used StripSeparators(), which consults this host's separator set. On a POSIX build that set has no backslash, so a Windows daemon's D:\dl\ kept its trailing separator and the join ran the halves together — the corruption the trim exists to prevent, alive in the mirror direction. It now goes through CPreferences::TrimRemotePrefix(), which accepts either convention, on the same grounds as your boundary test: the daemon's OS is not knowable here.

And the remainder spliced onto the local prefix keeps the daemon's separators, so a POSIX daemon feeds / into a path bound for Win32. Normalised on Windows only — / cannot appear in a Windows filename, whereas a backslash is ordinary in a POSIX one, so the mirror rewrite would corrupt names rather than fix separators.

Checked on a Windows VM with a real SMB share mapped to a drive letter: Test-Path is true for UNC, mapped-drive, and mixed forms alike, so availability and Open were already working there — this is for explorer /select,, which is the fussy one and the reason Reveal exists. UNC is unaffected by the rewrite, since only forward slashes are touched and the leading \\ stays. Built on Windows too, so the new #ifdef __WINDOWS__ branch has actually been through a compiler rather than only CI.

@got3nks
got3nks merged commit ac30b13 into amule-org:master Aug 8, 2026
14 checks passed
got3nks added a commit to LSalami/amule that referenced this pull request Aug 8, 2026
Catalogs taken from master, which amule-org#854 regenerated after the previous
merge; this branch's strings are re-added by the regeneration below.
LSalami added a commit to LSalami/amule that referenced this pull request Aug 10, 2026
…red Files (amule-org#843 follow-up)

Feedback from ghysler on amule-org#843 after PR amule-org#854 merged (github.com/amule-org/issues/843):

1. The Path Mappings preferences page shared "prefs_directories" with the
   Directories page -- same icon for two different pages in the sidebar.
   Added a dedicated prefs_pathmapping.svg/.png: the same folder
   construction as prefs_directories.svg, recoloured to a network-blue
   palette so the two read as distinct at a glance, with a small
   sync-arrows badge for the "mapped elsewhere" idea. Regenerated
   icon_data.c via embed_icons.py.
2. Double-clicking a completed file in the Shared Files panel opened the
   file-details modal instead of launching it; right-click -> "Open the
   file" already worked, and the Downloads panel's double-click already
   launches. CSharedFilesCtrl::OnItemActivated unconditionally called
   ShowFileDetailDialog() -- it never had the CDownloadListCtrl-style
   open/preview gate to begin with. Now mirrors both
   CDownloadListCtrl::OnItemActivated (read the row off the event rather
   than touching the selection, so a double-click doesn't discard a
   multi-selection) and the same gate already used by this file's own
   MP_VIEW context-menu entry: a finished file of any type opens, an
   unfinished one (CSharedFileList also carries PS_READY part files) only
   once enough of the media is on disk to play. Anything else still opens
   the details modal, unchanged from before.

Verified: rebuilt amule, amuled and amulegui locally, all clean. Rendered
the new SVG standalone at 16x16 and 128x128 to confirm it's legible and
visually distinct from prefs_directories at both sizes. Wasn't able to
verify the icon live in a running Preferences dialog this round -- the
local amuleGUI/amuled test pairing hit an EC auth issue unrelated to this
change (a hand-edited test daemon password hash, not a real deployment
concern) that wasn't worth chasing further for a one-line icon-name swap;
happy to redo that check if wanted before merge.
got3nks pushed a commit that referenced this pull request Aug 10, 2026
…red Files and completed Downloads (#868)

Follow-up to #843, from ghysler's feedback after #854 merged.

The Path Mappings preferences page shared prefs_directories' icon with the
Directories page -- the same icon for two different sidebar entries. It now
has its own prefs_pathmapping.svg/.png: the same folder construction, in a
network-blue palette so the two read as distinct at a glance, badged with a
double-headed arrow for the "mapped elsewhere" idea. The badge went through
two rounds: a pair of curved sync arrows is the conventional drawing, but at
16x16 the disc is barely 7px across and two separate arcs inside it render as
a featureless blob at any stroke weight, so the glyph is a single shaft with a
head at each end, white on amber. icon_data.c regenerated via embed_icons.py.

Double-clicking a completed file in Shared Files opened the file-details modal
instead of launching it, where right-click -> "Open the file" already worked
and the Downloads panel already launched. CSharedFilesCtrl::OnItemActivated
called ShowFileDetailDialog() unconditionally -- it never had a launch gate at
all -- and now reads the row off the event rather than touching the selection,
so a double-click no longer discards a multi-selection.

Both panels ended up on one rule: !IsPartFile() || PreviewAvailable(). A
completed file opens whatever type it is; an unfinished one opens only once
enough of the media is on disk to play, which is PreviewAvailable()'s own
test. Downloads moved to match rather than Shared Files being narrowed,
because the media restriction there came entirely from PreviewAvailable() and
made a double-click mean two different things on the same file: a download
that has just completed is listed in both panels at once.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

aMuleGUI - Remote to local path mappings

2 participants