Skip to content

Commit ce2fbd9

Browse files
fix(nxm): sanitize + bound the xdg-mime runner, decouple UI probes into shared state (#201)
Fixed the UI freezes when switching between destinations, worst when launched through Steam. - The Linux registrar's `xdg-mime` calls now strip only `LD_PRELOAD` from the child environment (the Steam overlay preload made the query ~2.3 s instead of ~0.1 s). The wait stays plain and synchronous: a wedged desktop helper hangs the probe rather than being masked. - Registration probing is decoupled from the UI lifecycle: a shared `INxmRegistrationState` probes only at startup, on entering Nexus, and after each register/release. Mod-list reloads and navigation-leave paths no longer probe; the shell strip, Mods hint, and DMF prompt read last-known state. - `Unregister` self-guards ownership inside both platform registrars, so callers no longer pre-check and another manager's registration can never be removed. Build + tests green (1866 passed); verified on hardware in normal and Steam launches.
1 parent 847b6a7 commit ce2fbd9

21 files changed

Lines changed: 1097 additions & 419 deletions

AGENTS.md

Lines changed: 81 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -141,12 +141,14 @@ src/ Modificus Curator -- the mod manager app (.NET 10 + Avalonia 12)
141141
the current destination's leave effects first (leaving
142142
Profiles awaits the unsaved-changes three-choice
143143
guard, Cancel/Save-failure keeps the destination
144-
unchanged; leaving Nexus
145-
calls `IntegrationsViewModel.Deactivate` which cancels
146-
the in-flight auth + the shell re-reads nxm status +
147-
reloads the mod list; leaving Settings reloads the mod
148-
list + re-reads the startup-check toggle + refreshes the
149-
app-update notice), then switches the destination, then
144+
unchanged; leaving Nexus
145+
calls `IntegrationsViewModel.Deactivate` which cancels
146+
the in-flight auth + the shell reloads the mod list
147+
(no nxm probe on leave; the registration state
148+
refreshes at Nexus ENTER, its deliberate probe
149+
point); leaving Settings reloads the mod
150+
list + re-reads the startup-check toggle + refreshes the
151+
app-update notice), then switches the destination, then
150152
runs the target's enter effects (Settings calls
151153
`SettingsViewModel.RefreshFromConfig` synchronously;
152154
Nexus awaits `IntegrationsViewModel.RefreshAsync`
@@ -166,7 +168,9 @@ src/ Modificus Curator -- the mod manager app (.NET 10 + Avalonia 12)
166168
(bounded detector handoff, no process handle; the
167169
state clears in all completion/exception paths)), and
168170
the global status strip (running + pending + nxm-handler
169-
+ app-update notice). The hosted page VMs are
171+
+ app-update notice; the nxm indicator mirrors the
172+
shared `INxmRegistrationState`, seeded by its one
173+
startup probe + updated on each publish). The hosted page VMs are
170174
application-lifetime singletons; navigation never calls an
171175
old Window-close Detach path. The active profile is owned
172176
by `IProfileSession`; launch availability derives directly
@@ -396,11 +400,16 @@ src/ Modificus Curator -- the mod manager app (.NET 10 + Avalonia 12)
396400
usable while Darktide runs (only launch + active-profile
397401
changes are blocked); the destination also owns the
398402
explicit `nxm://` handler registration (a "Nexus download
399-
links" section over `INxmHandlerRegistrar`: register
400-
confirms first since it is a system-wide change that can
401-
affect other mod managers; unregister only releases
402-
Curator's own registration); entering the destination
403-
refreshes auth state, leaving cancels in-flight auth via
403+
links" section over `INxmHandlerRegistrar` for the
404+
mutations + the shared `INxmRegistrationState` for the
405+
status: register confirms first since it is a system-wide
406+
change that can affect other mod managers; unregister
407+
delegates straight to the self-guarded registrar, which
408+
releases only Curator's own registration; after either
409+
action one refresh publishes the state to every
410+
consumer); entering the destination
411+
refreshes auth state (one registration probe per
412+
enter), leaving cancels in-flight auth via
404413
`Deactivate`;
405414
the Preferences destination (`PreferencesViewModel` +
406415
`PreferencesView`): theme + font scale + language + the
@@ -455,6 +464,22 @@ src/ Modificus Curator -- the mod manager app (.NET 10 + Avalonia 12)
455464
coordinating IDialogService + IProfileSession + Dispatcher.UIThread) that
456465
replaces the no-op default via DI last-registration-wins, registered after
457466
AddNxm() in CuratorComposition;
467+
the shared `INxmRegistrationState` (ui/Session/, an
468+
application-lifetime singleton): the last-known OS
469+
`nxm://` registration for every UI surface (shell
470+
status strip, Mods empty-state hint, Nexus page, DMF
471+
prompt wording). `RefreshFromOs` is its only writer
472+
+ the UI's only probe: one seed at shell
473+
construction, one per Nexus enter, one after each
474+
register/release action; each publishes `Changed`
475+
(marshaled to the UI thread) so every surface
476+
updates together. All other consumers read
477+
last-known + accept staleness (the OS association is
478+
racy by nature); `ModListViewModel.Reload()` and all
479+
navigation-leave effects perform zero probes;
480+
`DmfPromptService` reads the state + never probes;
481+
only `IntegrationsViewModel` still injects the
482+
registrar (for the register/release mutations);
458483
`UpdateCheckRunner` (ui/Session/) the
459484
UI-layer glue that fires `IUpdateCheckService.CheckAsync`
460485
fire-and-forget on the three automatic triggers
@@ -636,9 +661,11 @@ src/ Modificus Curator -- the mod manager app (.NET 10 + Avalonia 12)
636661
profile that becomes active (no persisted flag: a
637662
fresh ask per profile). Two cases: DMF in the repo
638663
but not the profile -> instant add (case 1); DMF not
639-
in the repo -> a download confirm (the message
640-
tailors to whether Curator owns the `nxm://` handler:
641-
manager-download vs. manual-import guidance); on
664+
in the repo -> a download confirm (the message
665+
tailors to whether Curator owns the `nxm://` handler,
666+
read from the shared `INxmRegistrationState` with no
667+
probe:
668+
manager-download vs. manual-import guidance); on
642669
confirm, premium users get the in-app API download
643670
under a spinner + add, while everyone else (no auth,
644671
regular, or unknown premium state) gets the DMF Nexus
@@ -796,12 +823,11 @@ src/ Modificus Curator -- the mod manager app (.NET 10 + Avalonia 12)
796823
(INexusClient over the v1 REST endpoints with per-request
797824
auth via INexusAuthMessageFactory selector -- ApiKey /
798825
OAuth / None factories, the latter doing 401-reactive
799-
refresh; NexusAuthService the OAuth loopback + API-key
800-
validate + sign-out orchestrator (raises
801-
AuthStateChanged on every persisted method change so
802-
the shell's Integrations flow refreshes the nxm handler
803-
status after the dialog closes; the DMF prompt is
804-
profile-creation-only and does not subscribe); NexusOAuthTokenStore
826+
refresh; NexusAuthService the OAuth loopback + API-key
827+
validate + sign-out orchestrator (raises
828+
AuthStateChanged on every persisted method change; the
829+
DMF prompt is
830+
profile-creation-only and does not subscribe); NexusOAuthTokenStore
805831
owns the OidcClient + token persistence; LoopbackBrowser
806832
the IBrowser impl with an HttpListener on an ephemeral
807833
port; Duende.IdentityModel.OidcClient 7.1.0 for the
@@ -958,12 +984,21 @@ src/ Modificus Curator -- the mod manager app (.NET 10 + Avalonia 12)
958984
default (the real handler is registered via AddSingleton
959985
last-wins, in CuratorComposition after AddNxm()), the OS
960986
scheme-handler registrar
961-
(INxmHandlerRegistrar: WindowsNxmHandlerRegistrar writes
962-
HKCU\Software\Classes\nxm; LinuxNxmHandlerRegistrar writes a .desktop
963-
file + xdg-mime default; AppImage registration atomically copies the
964-
handler to a durable per-user directory + creates a sibling symlink
965-
to $APPIMAGE; startup maintenance refreshes those files only while
966-
Curator owns the active association), + NxmHandlerRelay (the testable core the
987+
(INxmHandlerRegistrar: WindowsNxmHandlerRegistrar writes
988+
HKCU\Software\Classes\nxm; LinuxNxmHandlerRegistrar writes a .desktop
989+
file + xdg-mime default; every xdg-mime invocation runs sanitized:
990+
the child's env is the parent's with ONLY LD_PRELOAD removed
991+
(Steam's overlay preload slows host utilities ~10x; Curator's own
992+
env untouched), while the wait stays plain + synchronous (a hung
993+
desktop helper hangs the probe rather than being masked:
994+
deliberate, fail loud); AppImage registration atomically copies the
995+
handler to a durable per-user directory + creates a sibling symlink
996+
to $APPIMAGE; startup maintenance refreshes those files only while
997+
Curator owns the active association; Unregister is self-guarded on
998+
both platforms: it never removes another program's registration +
999+
touches only Curator's own registration files (a no-op or a
1000+
removal of Curator's own files depending on platform state), so
1001+
callers never pre-check), + NxmHandlerRelay (the testable core the
9671002
handler exe calls: hot-path IPC delivery + cold-start launch+retry,
9681003
UseShellExecute=false on both OSes). AOT-friendly (IsAotCompatible;
9691004
only raw byte/UTF-8 IO in the handler path).
@@ -1045,9 +1080,17 @@ src/ Modificus Curator -- the mod manager app (.NET 10 + Avalonia 12)
10451080
dirty-Profiles-draft navigation cancellation, entering
10461081
Settings rehydrates + leaving Settings runs the mod-list
10471082
+ app-update refresh, entering Integrations refreshes +
1048-
leaving cancels auth + refreshes nxm/mod-list, Launch
1083+
leaving cancels auth + reloads the mod list with zero
1084+
registration refreshes on any leave, exactly one seed
1085+
refresh at shell construction + the strip following a
1086+
shared-state publish, Launch
10491087
CanExecute + execution following
10501088
IProfileSession.ActiveProfileId directly) + the
1089+
NxmRegistrationStateTests (the production shared-state
1090+
contract: unavailable-without-registrar publishes, the
1091+
registrar read on refresh, a probe throw treated as
1092+
not-registered, Changed marshaled through the UI seam)
1093+
+ the
10511094
ShellLaunchAttemptTests (the launch-attempt state via
10521095
deterministic yield + timeout seams: attempt set +
10531096
CanExecute false before the launch service runs, false
@@ -1110,7 +1153,9 @@ src/ Modificus Curator -- the mod manager app (.NET 10 + Avalonia 12)
11101153
browser-open, the new-profile trigger, the
11111154
decline path, the premium in-app download,
11121155
the non-premium/unknown/no-auth browser-open
1113-
regardless of the nxm registrar state, and the
1156+
regardless of the registration state (the
1157+
confirm wording follows the shared state
1158+
with zero probes), and the
11141159
prompt-timing-after-create)
11151160
+ the OnboardingService (already complete no-op,
11161161
Continue persists + skips Integrations, Set up Nexus
@@ -1133,8 +1178,13 @@ src/ Modificus Curator -- the mod manager app (.NET 10 + Avalonia 12)
11331178
Minimized/FullScreen)
11341179
Modificus.Curator.Nxm.Tests/ xUnit tests for the nxm library (parser, framing,
11351180
IPC server resilience, SingleInstanceGuard, router,
1136-
relay helper, standalone + AppImage Linux registrar,
1137-
owned-registration maintenance, AddNxm wiring;
1181+
relay helper, standalone + AppImage Linux registrar
1182+
(incl. the child-env sanitizer dropping exactly
1183+
LD_PRELOAD), owned-registration
1184+
maintenance, the Windows registrar's self-guarded
1185+
unregister (absent no-op / foreign preserved / own
1186+
deleted, via the base-key seam over a temp subkey;
1187+
Windows-gated), AddNxm wiring;
11381188
serialized via DisableTestParallelization since
11391189
real named pipes are an OS-level shared resource)
11401190
docs/ architecture/ + reference/ (src/ per-library API refs + the release strategy reference)

docs/architecture/nxm-scheme-handler.md

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -164,19 +164,25 @@ a status line + a toggle button), not something the app does on startup. The
164164
register path shows a confirmation dialog first: it is a system-wide change
165165
that can take `nxm://` clicks away from Vortex, Mod Organizer 2, Nexus Mod
166166
Manager, or other mod managers, so the user must opt in knowingly. The
167-
unregister path only releases Curator's own registration (it re-checks
168-
`IsRegistered()` before `Unregister()` so it never deletes another program's
169-
handler). The composition root never registers, but after single-instance
170-
ownership succeeds it performs best-effort maintenance of an existing
171-
Curator-owned AppImage registration. Maintenance refreshes the copied handler
172-
and symlink only when the desktop file exists and `xdg-mime query default`
173-
still reports Curator. It never calls `xdg-mime default` or takes ownership from
174-
another manager. `INxmHandlerRegistrar` is resolved lazily by the Integrations
175-
view model + the shell status strip. The
176-
main-window status strip surfaces the current state ("Nexus links: enabled" /
177-
"Nexus links: disabled" / "Nexus links: unavailable") and refreshes when leaving the
178-
Nexus destination. No polling: the OS registration rarely changes
179-
out-of-band.
167+
unregister path releases only Curator's own registration, and the ownership
168+
safety lives inside the registrar, not the caller: on Windows it is a logged
169+
no-op when Curator is not the current handler; on Linux it removes only
170+
Curator's own desktop file and never touches another manager's registration.
171+
Callers never pre-check. The composition root never registers, but after
172+
single-instance ownership succeeds it performs best-effort maintenance of an
173+
existing Curator-owned AppImage registration. Maintenance refreshes the copied
174+
handler and symlink only when the desktop file exists and
175+
`xdg-mime query default` still reports Curator. It never calls
176+
`xdg-mime default` or takes ownership from another manager.
177+
`INxmHandlerRegistrar` is resolved by the Integrations view model (the
178+
register/release mutations) and by the shared UI registration state
179+
(`INxmRegistrationState`, the sole probe surface: one startup seed, one probe
180+
on entering the Nexus destination, and one publish after each register/release
181+
action). The main-window status strip and the Mods empty-state hint consume
182+
that shared state and update via its `Changed` event (the strip surfaces
183+
"Nexus links: enabled" / "Nexus links: disabled" / "Nexus links:
184+
unavailable"). No polling: consumers read last-known state and accept that the
185+
OS registration rarely changes out-of-band.
180186

181187
The packaged handler-exe path is derived from `AppContext.BaseDirectory` plus
182188
the fixed handler assembly name. The handler ships as a sibling of the main

0 commit comments

Comments
 (0)