diff --git a/.github/workflows/windows-ci.yml b/.github/workflows/windows-ci.yml index d500e48..d173102 100644 --- a/.github/workflows/windows-ci.yml +++ b/.github/workflows/windows-ci.yml @@ -27,6 +27,9 @@ jobs: dotnet-version: 10.0.x - name: Test core library run: dotnet test tests/Meridian.Core.Tests/Meridian.Core.Tests.csproj --configuration Release + - name: Test portable ZIP packaging + shell: pwsh + run: ./scripts/test-packaging.ps1 app-build: name: Build Windows 11 (${{ matrix.architecture }}) @@ -66,6 +69,9 @@ jobs: New-Item -ItemType Directory -Force -Path $out | Out-Null $publishPath = (Resolve-Path $publish).Path $outputPath = (Resolve-Path $out).Path + foreach ($state in @('meridian-portable.flag', 'data')) { + if (Test-Path -LiteralPath (Join-Path $publishPath $state)) { throw "Installer input contains portable state: $state" } + } $iscc = "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe" & $iscc "packaging/Meridian.iss" "/DPublishDir=$publishPath" "/DOutputDir=$outputPath" "/DAllowedArch=${{ matrix.inno_arch }}" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } @@ -78,6 +84,13 @@ jobs: if-no-files-found: error retention-days: 14 + - name: Mark portable publish output + shell: pwsh + run: | + $publish = "src/Meridian.App/bin/${{ matrix.architecture }}/Release/net10.0-windows10.0.22000.0/${{ matrix.runtime }}/publish" + $marker = Join-Path $publish 'meridian-portable.flag' + [IO.File]::Open($marker, [IO.FileMode]::CreateNew).Dispose() + - name: Upload portable publish output uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: diff --git a/README.md b/README.md index e4437e4..fac2ed2 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ These features are **implemented on the main development branch**. They are not | **Favorites groups** | Sidebar favorites can be filed into named groups: right-click a favorite → **Move to group** to file it, create a group, or move it back to the ungrouped favorites. Group headers collapse and expand (remembered between sessions); right-click a header to create, rename, or delete a group — deleting a group never deletes its favorites, which return to the ungrouped list. Favorites saved before groups existed simply appear ungrouped. | | **Folder tree** | Each pane has an optional folder tree (the **Tree** toggle beside the layout buttons): a ~260px column rooted at the current drive, folders only, loaded as you expand it. It follows the pane — expanding to and selecting the current folder after every navigation — selecting a folder in the tree opens it in the pane, and it accepts drops like the other targets. On/off is remembered per pane. | | **Drive inspection and eject** | Right-click a sidebar drive → **Inspect drive…** for its volume label, type, file system, capacity, and free space, exactly as Windows reports them. Removable drives also offer **Eject**, which asks Windows to safely remove the drive and reports in plain words when a program is still using it. | -| **Live folder refresh** | An open folder refreshes itself when its contents change on disk — a file saved by an editor, created by another program, renamed, or deleted — without pressing F5. Watching is done by the bundled **WatchKit** library (`watch_kit.dll`, shipped beside `Meridian.exe`), using native Windows change notifications with an automatic polling fallback; changes are coalesced, so a burst of activity refreshes the pane once. If the library is unavailable (including the ARM64 build, which carries no x64 DLL), folders simply behave as before, with manual refresh. While a transfer job is running, watch refreshes stand down and the panes refresh when the job finishes, as they already did. | +| **Live folder refresh** | Windows directory notifications refresh open folders when files are created, saved, renamed, or deleted. Bursts share a refresh, and an overflow triggers a reload because some changes may have been missed. If notifications are unavailable, press F5 to refresh manually. Watch refreshes stand down while a transfer runs, and affected panes refresh when it finishes. | | **Image thumbnails** | In Grid and Tiles views, thumbnails for BMP, PPM, PNG, and JPEG files are decoded by the bundled **ThumbKit** library (`thumb_kit.dll`, shipped beside `Meridian.exe`) and cached on disk under `%LOCALAPPDATA%\Meridian\thumbcache`, so they survive restarts. Every other format — and any file ThumbKit cannot decode, such as a progressive JPEG — uses the Windows shell thumbnails exactly as before. | | **Breadcrumb path bar** | Each pane's path is a breadcrumb bar: one button per level from the drive root (or network share) down to the current folder — click any level to jump there. Clicking the empty stretch of the bar, or pressing `Ctrl+L`, swaps in a text box holding the full path: Enter opens it, Escape returns to the breadcrumbs. Long folder names compress with an ellipsis and show their full path as a tooltip. Segments accept drops (onto that level's folder); the bar's empty stretch drops into the current folder. | | **Preview block** | Each pane has a preview block at its bottom, replacing the old right-side details panel: a large preview of the selected item (an image preview, the opening of a text file, or a folder treatment with its measured size and counts), its name, type, created and modified dates, and size, plus photo details and the "Opens in" app where they exist. With nothing selected it describes the current folder. Its header line always shows the folder's item count and total size — folders count only once measured, never walked just for the line — and the selection's count and size while anything is selected. Each pane's block collapses on its own chevron and remembers its state; a details panel you had open becomes both blocks expanded. | @@ -196,7 +196,7 @@ See the full [innovation-first roadmap](docs/ROADMAP.md). - **Windows displays an unknown-publisher warning?** The preview installer is unsigned. Verify the repository and build provenance before deciding whether to run it; don't disable system-wide security protections. - **App does not start?** Confirm Windows 11 build 22000+, choose the architecture matching your device, extract the *entire* portable artifact before launching `Meridian.exe`, and include the OS build, architecture, artifact run URL, and exact error when reporting a bug. - **A file operation fails?** Check source/destination access and collisions. Meridian intentionally refuses known overwrites; after a partial batch, inspect the destination before retrying. Do not delete a `.meridian-*.partial` path before determining whether it contains recoverable data. -- **Portable settings?** The current workspace file is stored under `%LOCALAPPDATA%\\Meridian\\workspace.json`; a completely self-contained portable settings mode is not yet verified. +- **Portable workspaces?** Extract the portable ZIP to a writable folder. Its `meridian-portable.flag` keeps session and named workspace files in the sibling `data` folder. Other preferences and diagnostics still use per-user locations. See [known limitations and verification status](docs/VERIFICATION_REPORT.md), [security reporting](docs/SECURITY.md), and [contributing instructions](docs/CONTRIBUTING.md). @@ -273,7 +273,7 @@ Automated builds are not a substitute for testing actual devices, Windows shell - **Copied or moved something?** Check Transfer History in the Transfers strip — many finished copies and moves can be undone from there while their results are unchanged. Renames have no undo; inspect both locations before manually reversing one. - **Copy failed?** Check for `.meridian-*.partial` leftovers; verify contents and that no operation is using them before removing anything. - **Startup failed?** Check `%LOCALAPPDATA%\Meridian\last-error.log` if present and keep the complete published folder, including `Meridian.pri`. -- **Settings/privacy:** `%LOCALAPPDATA%\Meridian\workspace.json` stores paths, bookmark/tab labels and active-tab selection. Portable copies also use this location. Diagnostics can contain paths; see the [user guide](docs/USER_GUIDE.md#local-data-and-privacy). +- **Settings/privacy:** Session state stores paths, bookmark/tab labels and active-tab selection. Installed and unmarked copies use `%LOCALAPPDATA%\Meridian`. The marked portable ZIP keeps session and named workspaces in its sibling `data` folder. Diagnostics can contain paths. See the [user guide](docs/USER_GUIDE.md#local-data-and-privacy). Uninstalling Meridian does **not** reverse file operations. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 170d035..ddd25d8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -30,7 +30,7 @@ Workspace tabs are paired left/right folder locations; switching tabs refreshes ## Persistence and diagnostics -`src/Meridian.App/Services/WorkspaceStore.cs` stores `%LOCALAPPDATA%\Meridian\workspace.json`: left/right locations, bookmark names/paths, paired tab names/locations and active-tab index. Drop Shelf, sort/filter settings, durable operation history and alternate layouts are not included. Installed and portable copies share this per-user location. +`src/Meridian.App/Services/WorkspaceStore.cs` stores `workspace.json`: left/right locations, bookmark names/paths, paired tab names/locations and active-tab index. Drop Shelf, sort/filter settings, durable operation history and alternate layouts are not included. Installed and unmarked copies use `%LOCALAPPDATA%\Meridian`. For a marked portable ZIP, workspace storage routes session and named workspace files to the sibling `data` folder. It validates bounded legacy input before copying, publishes without replacing an existing file, and records one receipt per file. Other stores keep their existing paths. Writes serialize to a unique temporary sibling, then replace the preferences file. This preference replacement is separate from no-overwrite user file operations. A semaphore serializes writes in one store instance, not across processes. Missing/malformed/unreadable state falls back where handled; unavailable tab paths can also produce navigation errors. UI persistence is best-effort, without a versioned migration database or cross-process transaction guarantee. Loading is capped at 1 MiB, JSON depth 16, 128 tabs and 512 bookmarks; invalid entries, relative paths, control characters and excessive labels are rejected before UI restoration. Saving validates the snapshot and encoded size before replacing the old file; save warnings are retained by tab/bookmark actions. Tests compile the actual UI-independent workspace-store source into the core test project and use isolated files; these are not interactive UI tests. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f6753e9..631558b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- Folder panes use filesystem change notifications and combine bursts into a refresh. If live watching is unavailable, use Refresh to reload the folder. +- The portable ZIP keeps session and named workspace files beside the app under `data`. On first use it copies valid legacy files when the corresponding portable files are absent. + - Batch rename with preview and undo: select multiple files or folders and rename them all at once from the Rename command or F2. Every new name is checked before anything is renamed, collisions are flagged in the preview, and the whole batch runs as a transfer job in the Transfers queue — progress shows in the strip, ordinary item errors do not stop the others; a persistence or recovery failure stops the batch, and every finished rename can be undone from Transfer History. - Color tags: mark files and folders with a colored indicator (red, orange, yellow, green, blue, purple, gray or none) from the right-click menu in either pane. Tags persist across sessions and travel with the item row wherever it is shown. diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index 024b3a0..7d66402 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -3,7 +3,7 @@ ## Operating system - **Windows 11, build 22000 or newer.** Windows 10 is not supported: Meridian uses WinUI 3 windowing and APIs that assume the Windows 11 baseline. -- **Architectures:** x64 and ARM64. Both are built by CI; the x64 build is the one exercised by the published startup smoke test. On ARM64, the bundled WatchKit/ThumbKit libraries are x64 binaries and are not loaded — folder watching falls back to polling and thumbnails come from the Windows shell, with no other feature difference. +- **Architectures:** CI builds x64 and ARM64. The published startup smoke test exercises x64. Live folder refresh uses Windows notifications through .NET on either architecture, with manual refresh available if the watcher fails. ARM64 uses Windows shell thumbnails because the bundled ThumbKit DLL is x64. ARM64 runtime acceptance remains open. - **Privileges:** none. The installer and the program run per-user with no administrator rights. ## Files and locations diff --git a/docs/FEATURES.md b/docs/FEATURES.md index bac1f47..036333e 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -8,7 +8,7 @@ What Meridian does today, on the current release. The [user guide](USER_GUIDE.md - **Columns layout** — an optional Miller-columns view per pane, alongside Details, Grid, and Tiles. - **Breadcrumb path bar** — click any level to jump there, `Ctrl+L` or a click on the empty stretch to type a path instead; segments accept drops. - **Home page** — drives with usage bars and the standard Places, shown per pane. -- **Live folder refresh** — open folders update themselves when their contents change on disk (WatchKit, with a polling fallback). +- **Live folder refresh** — Windows directory notifications update open folders. Press F5 where notifications are unavailable. - **Image thumbnails** in Grid and Tiles views (ThumbKit for BMP/PPM/PNG/JPEG, the Windows shell for everything else), cached on disk. - **Listing controls** — per-pane filter and sort; find in pane (`Ctrl+F`) with a match-position readout; toggles for file extensions and hidden items; hide rules by name pattern (`*.tmp`, `.bak`, `Thumbs.db`) from Settings. - **Header readouts** — the active pane's path and its drive's free space, plus a clipboard chip naming what the clipboard holds for pasting. diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 5c83ebd..034bf6e 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -16,11 +16,15 @@ Run the Setup program. It installs per-user into `%LOCALAPPDATA%\Programs\Meridi ## Portable -Extract the zip anywhere and run `Meridian.exe`. Extract the *whole* zip: `Meridian.exe` needs the files beside it (including `watch_kit.dll` and `thumb_kit.dll`) in the same folder. +Extract the whole zip and run `Meridian.exe` from the extracted folder. Keep the supplied files together, including `thumb_kit.dll` in the x64 build. + +Extract the release ZIP or the CI portable artifact to a writable folder and keep `meridian-portable.flag` beside the executable. Manual developer publish output is unmarked and uses the installed workspace location. ## Your data -Settings, the saved workspace, scripts, the thumbnail cache, and logs live under `%LOCALAPPDATA%\Meridian` for both the installed and portable builds. See [MIGRATION](MIGRATION.md) for what happens to it across versions. +Marked portable builds keep session and named workspaces in the sibling `data` folder. Installed and unmarked builds keep both files under `%LOCALAPPDATA%\Meridian`. Settings, scripts, the thumbnail cache, and logs use their existing per-user locations in either mode. See [MIGRATION](MIGRATION.md) for how to preserve this data across versions. + +Run one Meridian process per portable folder when saving workspaces. Workspace writes are coordinated within one process. Separate processes can overwrite one another's later edits, as they can with installed state. ## Uninstall diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 189b276..a6b8c9a 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -9,9 +9,11 @@ Upgrading is installing the new build over the old one (or replacing the portabl - **Scripts** in `%LOCALAPPDATA%\Meridian\Scripts` are yours: the bundled starter pack is seeded only when the folder is first created and is never overwritten by an upgrade. The KK starter file `KK\Export\CusPSVars.xml` is created only when missing. - **Caches** (folder sizes, thumbnails) are disposable by design; any version may rebuild them. +For the marked portable ZIP, keep the sibling `data` folder when updating or moving the application. Each workspace file can be imported once from the previous per-user location if the corresponding portable file is missing and the legacy file is valid. Meridian keeps the original legacy bytes and does not replace existing portable workspace files during import. Keep the migration receipts beside the workspace files to preserve the import decision across resets. + ## Moving to a new PC -Copy `%LOCALAPPDATA%\Meridian` to the same place on the new PC to bring settings, workspace, and scripts along. Paths that do not exist on the new machine are skipped gracefully. +Copy the marked portable application and its sibling `data` folder together to move its workspace files. Copy `%LOCALAPPDATA%\Meridian` to the same place on the new PC to bring settings, scripts, and installed workspace files along. Paths that do not exist on the new machine are skipped gracefully. ## Downgrading diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 4024cbf..02d5cbe 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -6,7 +6,7 @@ Startup writes a milestone log to `%LOCALAPPDATA%\Meridian\startup.log`; each li ## A folder does not refresh by itself -Live refresh uses the bundled WatchKit library with a polling fallback. While a transfer is running, refreshes pause and the panes update when the job finishes. Press `F5` to refresh manually at any time. +Live refresh uses Windows directory notifications. If a folder cannot be watched or its watcher stops, press `F5` to refresh manually. During a transfer, the affected panes refresh when the job finishes. ## Windows warns about an unknown publisher diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 7251612..3dbf728 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -63,7 +63,9 @@ Foresight, Consequence Engine contracts and Storage Reclaimer inventory/planning | `%LOCALAPPDATA%\Meridian\last-error.log` | Most recent successfully recorded unhandled/startup exception as bounded JSON (128 KiB maximum), with UTC timestamp and truncation indicators. Replaced on the next successful record; failed logging can leave an older record. Not a full operation journal. Can include local paths | | In memory | Drop Shelf and transient operation results; not restored after restart | -Portable and installed copies use the same per-user state location. There is no implemented telemetry or mandatory account. Local/UNC paths can cause normal Windows network filesystem access, and opening a file delegates to its configured application; offline-first does not mean every OS or external handler is network-isolated. Review diagnostics before sharing them. Close Meridian and back up `workspace.json` before removing it to reset saved state. Uninstall does not reverse file operations or promise removal of per-user preferences/logs. +The portable ZIP contains `meridian-portable.flag` beside the executable. Keep it in place and extract to a writable folder. Meridian saves `workspace.json` and `named-workspaces.json` under the sibling `data` directory. On first use, it can copy valid legacy workspace files from the per-user location when the corresponding portable file is absent. It leaves the original files in place. Keep the migration receipts in `data` to prevent reimporting legacy state after a reset. If state is unreadable or the folder is blocked, Meridian reports the problem and keeps workspace writes in the portable location. Back up unreadable state before repairing it. Installed builds, developer builds, and unmarked copies continue to use the per-user workspace location. + +Other portable settings, caches, and diagnostics continue to use their existing locations. Meridian has no telemetry or mandatory account. Local/UNC paths can cause normal Windows network filesystem access, and opening a file delegates to its configured application. Review diagnostics before sharing them. Close Meridian and back up `workspace.json` before removing it to reset saved state. Uninstall does not reverse file operations or promise removal of per-user preferences or logs. ## Troubleshooting and recovery diff --git a/scripts/package.ps1 b/scripts/package.ps1 index de120e2..1bf2318 100644 --- a/scripts/package.ps1 +++ b/scripts/package.ps1 @@ -15,6 +15,10 @@ if ($relativeOutput -eq '.' -or (![IO.Path]::IsPathRooted($relativeOutput) -and foreach ($required in @('Meridian.exe', 'Meridian.pri')) { if (!(Test-Path -LiteralPath (Join-Path $publish $required) -PathType Leaf)) { throw "Missing published file: $required" } } +$portableMarker = 'meridian-portable.flag' +foreach ($state in @($portableMarker, 'data')) { + if (Test-Path -LiteralPath (Join-Path $publish $state)) { throw "Publish directory contains portable state: $state" } +} $installerName = "Meridian-Setup-win-$Architecture.exe" $zipName = "Meridian-Portable-win-$Architecture.zip" $manifestName = "SHA256SUMS-win-$Architecture.txt" @@ -29,6 +33,9 @@ $stagedZip = Join-Path $output ('.meridian-package-' + [guid]::NewGuid().ToStrin $stagedManifest = Join-Path $output ('.meridian-checksums-' + [guid]::NewGuid().ToString('N') + '.tmp') try { [IO.Compression.ZipFile]::CreateFromDirectory($publish, $stagedZip) + $archive = [IO.Compression.ZipFile]::Open($stagedZip, [IO.Compression.ZipArchiveMode]::Update) + try { $null = $archive.CreateEntry($portableMarker) } + finally { $archive.Dispose() } $lines = @( (Get-FileHash -LiteralPath $installer -Algorithm SHA256).Hash.ToLowerInvariant() + ' ' + $installerName (Get-FileHash -LiteralPath $stagedZip -Algorithm SHA256).Hash.ToLowerInvariant() + ' ' + $zipName diff --git a/scripts/test-packaging.ps1 b/scripts/test-packaging.ps1 index 4bc8259..227c646 100644 --- a/scripts/test-packaging.ps1 +++ b/scripts/test-packaging.ps1 @@ -18,14 +18,25 @@ $hidden = Join-Path $publish '.hidden.txt' [IO.File]::SetAttributes($hidden, [IO.FileAttributes]::Hidden) $installer = Join-Path $output 'Meridian-Setup-win-x64.exe' [IO.File]::WriteAllText($installer, 'fixture installer') +$blockedState = New-Item -ItemType Directory -Path (Join-Path $publish 'data') +try { + Assert-Refused { & $packageScript -PublishDirectory $publish -OutputDirectory $output -Architecture x64 } + if (Test-Path -LiteralPath (Join-Path $output 'Meridian-Portable-win-x64.zip')) { throw 'Archive includes portable user state.' } +} finally { + Remove-Item -LiteralPath $blockedState.FullName +} & $packageScript -PublishDirectory $publish -OutputDirectory $output -Architecture x64 $archive = [IO.Compression.ZipFile]::OpenRead((Join-Path $output 'Meridian-Portable-win-x64.zip')) try { - if ($archive.Entries.Count -ne 3 -or !$archive.GetEntry('.hidden.txt')) { throw 'ZIP omitted published content.' } + if ($archive.Entries.Count -ne 4 -or !$archive.GetEntry('.hidden.txt')) { throw 'ZIP omitted published content.' } + $marker = $archive.GetEntry('meridian-portable.flag') + if (!$marker -or $marker.Length -ne 0) { throw 'ZIP portable marker missing or not empty.' } $reader = [IO.StreamReader]::new($archive.GetEntry('.hidden.txt').Open()) try { if ($reader.ReadToEnd() -ne 'hidden payload') { throw 'ZIP payload mismatch.' } } finally { $reader.Dispose() } } finally { $archive.Dispose() } +if (Test-Path -LiteralPath (Join-Path $publish 'meridian-portable.flag')) { throw 'Portable marker leaked to installer input.' } +if ([IO.File]::ReadAllText($installer) -ne 'fixture installer') { throw 'Installer payload was changed.' } $manifest = Join-Path $output 'SHA256SUMS-win-x64.txt' $validManifest = [IO.File]::ReadAllText($manifest) Assert-Refused { & $packageScript -PublishDirectory $publish -OutputDirectory $output -Architecture x64 } diff --git a/src/Meridian.App/MainWindow.Watching.cs b/src/Meridian.App/MainWindow.Watching.cs index 254b621..ef1df3f 100644 --- a/src/Meridian.App/MainWindow.Watching.cs +++ b/src/Meridian.App/MainWindow.Watching.cs @@ -5,8 +5,8 @@ namespace Meridian; /// -/// The window's half of live folder watching (WatchKit). The mechanics — -/// draining, deciding, debouncing — live in +/// The window's half of live folder watching. Notification filtering and +/// debouncing live in /// and Core; this partial owns only the per-pane bookkeeping: a watch exists /// for exactly the folder a pane is showing, created and moved by /// navigation, stopped at Home and at window close, and its refresh goes @@ -29,22 +29,18 @@ public sealed partial class MainWindow /// what a watch-triggered refresh itself looks like when it lands /// here. Called on every committed navigation. /// - /// The restart itself is not created inline: WatchKit's create builds - /// its baseline snapshot synchronously — a recursive walk of the whole - /// watched tree — and on a folder whose tree holds very many files - /// that walk held the UI thread (and the still-black window) for close - /// to a minute. Watching is an enhancement, never a requirement, so - /// the start runs in the background and only the finished service is - /// attached here, if it is still the start the pane is waiting for. + /// Opening a directory handle can block on a remote path, so the + /// start stays in the background and the finished service attaches + /// only if the pane is still waiting for it. private void UpdatePaneWatch(PaneState pane) { - if (pane.IsHome || string.IsNullOrWhiteSpace(pane.CurrentPath)) + if (pane.IsHome || pane.IsRecycleBin || string.IsNullOrWhiteSpace(pane.CurrentPath)) { StopPaneWatch(pane); return; } - if (pane.Watch is { } existing && + if (pane.Watch is { IsDisposed: false } existing && string.Equals(existing.Folder, pane.CurrentPath, StringComparison.OrdinalIgnoreCase)) return; @@ -66,25 +62,28 @@ private void StartPaneWatchInBackground(PaneState pane, string folder) { var generation = pane.WatchStartGeneration; var dispatcher = DispatcherQueue; + var includeDescendants = pane.Layout == PaneLayout.Columns; _ = Task.Run(() => { var service = FolderWatchService.TryStart( folder, dispatcher, - includeDescendants: () => pane.Layout == PaneLayout.Columns, - refreshDue: () => OnWatchRefreshDue(pane)); + includeDescendants, + refreshDue: watch => OnWatchRefreshDue(pane, watch)); if (service is null) return; // Watching unavailable: nothing to attach or dispose. var started = service; var enqueued = dispatcher.TryEnqueue(() => { if (_windowClosed || generation != pane.WatchStartGeneration || + started.IsDisposed || pane.Watch is not null || !string.Equals(pane.CurrentPath, folder, StringComparison.OrdinalIgnoreCase)) { started.Dispose(); return; } + started.IncludeDescendants = pane.Layout == PaneLayout.Columns; pane.Watch = started; StartupLog.Milestone("watch-started:" + pane.Name); }); @@ -111,9 +110,10 @@ private static void StopPaneWatch(PaneState pane) /// writing into real folders — the transfers code already refreshes the /// affected panes when the job settles, so watch refreshes stand down /// instead of re-reading folders mid-copy. - private void OnWatchRefreshDue(PaneState pane) + private void OnWatchRefreshDue(PaneState pane, FolderWatchService watch) { - if (pane.IsHome || pane.Watch is not { } watch || + if (_windowClosed || pane.IsHome || pane.IsRecycleBin || watch.IsDisposed || + !ReferenceEquals(pane.Watch, watch) || !string.Equals(watch.Folder, pane.CurrentPath, StringComparison.OrdinalIgnoreCase)) return; if (_transfers.ActiveJob is { State: TransferJobState.Running }) return; diff --git a/src/Meridian.App/MainWindow.Workspaces.cs b/src/Meridian.App/MainWindow.Workspaces.cs index e34c63f..31ff6cf 100644 --- a/src/Meridian.App/MainWindow.Workspaces.cs +++ b/src/Meridian.App/MainWindow.Workspaces.cs @@ -32,9 +32,9 @@ private async Task ShowWorkspacesAsync() foreach (var entry in entries) list.Items.Add(entry.Name); var details = new TextBlock { - Text = entries.Count == 0 + Text = _portableNamedWorkspaceError ?? (entries.Count == 0 ? "No workspaces yet. Save the current pair of folders below to reopen them in one step later." - : "Pick a workspace to see the folders it restores.", + : "Pick a workspace to see the folders it restores."), TextWrapping = TextWrapping.Wrap, Foreground = ThemeBrush("MeridianDialogLabelBrush") }; @@ -70,11 +70,52 @@ private async Task ShowWorkspacesAsync() NamedWorkspace? Picked() => list.SelectedIndex >= 0 && list.SelectedIndex < entries.Count ? entries[list.SelectedIndex] : null; + var changePending = false; + async Task ChangeWorkspaceAsync(Func change) + { + if (!_workspaceStorage.IsPortable) + { + await change(); + return true; + } + if (_portableNamedWorkspaceError is { } startupError) + { + details.Text = startupError + " Restart Meridian after repairing the portable state."; + return false; + } + if (changePending) return false; + + changePending = true; + saveButton.IsEnabled = false; + deleteButton.IsEnabled = false; + list.IsEnabled = false; + dialog.IsPrimaryButtonEnabled = false; + try + { + await _workspaceStorage.PrepareAsync(WorkspaceStorage.NamedName, NamedWorkspaceStore.IsPortableStateValid); + await change(); + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException) + { + details.Text = "Portable workspaces could not be saved. Check the data folder permissions. " + ex.Message; + return false; + } + finally + { + changePending = false; + list.IsEnabled = true; + saveButton.IsEnabled = true; + deleteButton.IsEnabled = Picked() is not null; + dialog.IsPrimaryButtonEnabled = Picked() is not null; + } + } + list.SelectionChanged += (_, _) => { var picked = Picked(); - dialog.IsPrimaryButtonEnabled = picked is not null; - deleteButton.IsEnabled = picked is not null; + dialog.IsPrimaryButtonEnabled = !changePending && picked is not null; + deleteButton.IsEnabled = !changePending && picked is not null; if (picked is not null) details.Text = DescribeWorkspace(picked); }; deleteButton.Click += async (_, _) => @@ -82,7 +123,7 @@ private async Task ShowWorkspacesAsync() var picked = Picked(); if (picked is null) return; var index = list.SelectedIndex; - await _namedWorkspaceStore.RemoveAsync(picked.Name); + if (!await ChangeWorkspaceAsync(() => _namedWorkspaceStore.RemoveAsync(picked.Name))) return; entries = _namedWorkspaceStore.Entries; list.Items.RemoveAt(index); dialog.IsPrimaryButtonEnabled = false; @@ -107,7 +148,7 @@ private async Task ShowWorkspacesAsync() nameBox.Text.Trim(), leftPath, rightPath, _left.Layout.ToString(), _right.Layout.ToString(), ActivePaneIsRight: ReferenceEquals(_activePane, _right)); - await _namedWorkspaceStore.UpsertAsync(saved); + if (!await ChangeWorkspaceAsync(() => _namedWorkspaceStore.UpsertAsync(saved))) return; entries = _namedWorkspaceStore.Entries; list.Items.Clear(); foreach (var entry in entries) list.Items.Add(entry.Name); diff --git a/src/Meridian.App/MainWindow.xaml.cs b/src/Meridian.App/MainWindow.xaml.cs index f58f15c..008abc9 100644 --- a/src/Meridian.App/MainWindow.xaml.cs +++ b/src/Meridian.App/MainWindow.xaml.cs @@ -36,7 +36,10 @@ public sealed partial class MainWindow : Window private readonly PaneState _left; private readonly PaneState _right; - private readonly WorkspaceStore _workspaceStore = new(); + private readonly WorkspaceStorage _workspaceStorage = new( + AppContext.BaseDirectory, Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)); + private readonly WorkspaceStore _workspaceStore; + private string? _portableSessionError; private readonly SettingsStore _settingsStore = new(); private readonly Windows.UI.ViewManagement.UISettings _uiSettings = new(); private ThemePreference _themePreference = ThemePreference.Dark; @@ -59,7 +62,8 @@ public sealed partial class MainWindow : Window private readonly SavedSearchStore _savedSearchStore = new(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Meridian", "saved-searches.json")); private readonly ClosedTabStore _closedTabStore = new(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Meridian", "closed-tabs.json")); private readonly FileMetadataStore _fileMetadataStore = new(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Meridian", "file-metadata.json")); - private readonly NamedWorkspaceStore _namedWorkspaceStore = new(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Meridian", "named-workspaces.json")); + private readonly NamedWorkspaceStore _namedWorkspaceStore; + private string? _portableNamedWorkspaceError; private readonly FolderSizeCache _folderSizeCache = new(); private readonly FolderSizeStore _folderSizeStore = new(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Meridian", "folder-sizes.json")); private readonly ThumbnailCache _thumbnailCache = new(); @@ -134,6 +138,8 @@ public sealed partial class MainWindow : Window public MainWindow() { + _workspaceStore = new WorkspaceStore(_workspaceStorage.SessionPath); + _namedWorkspaceStore = new NamedWorkspaceStore(_workspaceStorage.NamedPath, _workspaceStorage.IsPortable); StartupLog.Begin(); InitializeComponent(); SubscribeFirstFrameMilestone(); @@ -250,7 +256,20 @@ private async Task InitializeAsync(string documents, string downloads) SetStatus("Saved Capsules could not be loaded; saving is disabled to preserve the original file · " + ex.Message, StatusSeverity.Error); } StartupLog.Milestone("capsules-loaded"); - var saved = await _workspaceStore.LoadAsync(); + WorkspaceSnapshot? saved = null; + try + { + if (_workspaceStorage.IsPortable) + { + var state = await _workspaceStorage.PrepareAsync(WorkspaceStorage.SessionName, WorkspaceStore.IsPortableStateValid); + if (state is { } bytes) saved = WorkspaceStore.ParsePortableState(bytes); + } + else saved = await _workspaceStore.LoadAsync(); + } + catch (IOException ex) + { + _portableSessionError = ex.Message; + } _bookmarks.Clear(); foreach (var bookmark in saved?.Bookmarks ?? []) _bookmarks.Add(bookmark); // Favorites groups ride along in the same file. A file written before @@ -288,7 +307,19 @@ private async Task InitializeAsync(string documents, string downloads) await _savedSearchStore.LoadAsync(); await _closedTabStore.LoadAsync(); await _fileMetadataStore.LoadAsync(); - await _namedWorkspaceStore.LoadAsync(); + try + { + if (_workspaceStorage.IsPortable) + { + var state = await _workspaceStorage.PrepareAsync(WorkspaceStorage.NamedName, NamedWorkspaceStore.IsPortableStateValid); + if (state is { } bytes) _namedWorkspaceStore.LoadPortableState(bytes); + } + else await _namedWorkspaceStore.LoadAsync(); + } + catch (IOException ex) + { + _portableNamedWorkspaceError = ex.Message; + } _folderViewsLoaded = true; await _recentsStore.LoadAsync(); // Last launch's measured sizes seed the cache before either pane @@ -314,6 +345,8 @@ private async Task InitializeAsync(string documents, string downloads) } await PersistSessionAsync(); + if (_portableNamedWorkspaceError is { } namedError) + SetStatus(namedError, StatusSeverity.Error); StartupLog.Milestone("initialized"); // The window is up; only now do folder-size walks begin, so they no @@ -334,8 +367,14 @@ private async Task InitializeAsync(string documents, string downloads) private async Task PersistSessionAsync() { if (_restoringSession) return false; + if (_portableSessionError is { } startupError) + { + SetStatus(startupError + " Restart Meridian after repairing the portable state.", StatusSeverity.Error); + return false; + } try { + await _workspaceStorage.PrepareAsync(WorkspaceStorage.SessionName, WorkspaceStore.IsPortableStateValid); await _workspaceStore.SaveAsync(new WorkspaceSnapshot( _left.CurrentPath, _right.CurrentPath, _bookmarks.ToArray(), Tabs: null, ActiveTabIndex: 0, @@ -629,6 +668,7 @@ private void ApplyPaneMode(PaneState pane) // The bin renders in the standard list whatever the layout setting: // its entries are not a folder chain. var columns = !home && !pane.IsRecycleBin && pane.Layout == PaneLayout.Columns; + if (pane.Watch is { } watch) watch.IncludeDescendants = columns; pane.HomeView.Visibility = home ? Visibility.Visible : Visibility.Collapsed; pane.List.Visibility = !home && !columns ? Visibility.Visible : Visibility.Collapsed; pane.ColumnsView.Visibility = columns ? Visibility.Visible : Visibility.Collapsed; @@ -3368,11 +3408,8 @@ private sealed class PaneState(string name, TextBox pathBox, TextBox filterBox, public string CurrentPath { get; set; } = string.Empty; public CancellationTokenSource? ReadCancellation { get; set; } - /// The live WatchKit watch over the pane's current folder, - /// or null when watching is off — at Home (never watched), before the - /// first navigation, or when WatchKit is unavailable. Owned by the - /// window (MainWindow.Watching.cs), which restarts it on navigation - /// and disposes it at window close. + /// The watch for the pane's current folder, owned by the window. + /// Navigation replaces it and closing the window disposes it. public FolderWatchService? Watch { get; set; } /// Bumped whenever the pane's watch is stopped or its diff --git a/src/Meridian.App/Meridian.App.csproj b/src/Meridian.App/Meridian.App.csproj index 9c90e22..7ece197 100644 --- a/src/Meridian.App/Meridian.App.csproj +++ b/src/Meridian.App/Meridian.App.csproj @@ -7,7 +7,7 @@ trueNonetrue true enableenable - + true @@ -19,14 +19,8 @@ publish output is what the installer and portable zip package, so this is what ships the pack. --> PreserveNewest - + - PreserveNewest PreserveNewest diff --git a/src/Meridian.App/Native/WatchKitNative.cs b/src/Meridian.App/Native/WatchKitNative.cs deleted file mode 100644 index 0822fce..0000000 --- a/src/Meridian.App/Native/WatchKitNative.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System.Runtime.InteropServices; - -namespace Meridian.Native; - -/// -/// The WatchKit P/Invoke surface — the only place in the app that names -/// watch_kit.dll. Signatures are transcribed from vendor/watchkit/watchkit.h -/// (v0.2.0) and cover only the calls the folder-watch service uses: -/// create / poll events / status / close, plus the string free and the -/// error reader those calls require. -/// -/// SAFETY — the ownership rules every caller must keep (from the header): -/// - A watcher handle comes from and is released ONLY -/// by , exactly once. The kit joins its background -/// thread inside wa_close; anything through the handle after wa_close is -/// use-after-close, so callers serialize all calls and the close on one -/// lock and never touch the handle afterwards. -/// - Every JSON string the kit hands out through an out parameter is -/// caller-owned and must be released with -/// exactly once. is the exception: it returns -/// thread-local storage owned by the kit — read it immediately on the -/// thread that made the failing call, never free it, never store it. -/// - String inputs are marshaled as NUL-terminated UTF-8 copies that live -/// only for the duration of the call; the kit parses them synchronously -/// and keeps no pointer (the config is consumed by wa_create itself). -/// All entry points are called defensively by FolderWatchService: a missing -/// or unloadable DLL surfaces as an exception at the call site, which the -/// service catches and turns into "watching is off". -/// -internal static partial class WatchKitNative -{ - private const string DllName = "watch_kit.dll"; - - /// wa_create: builds the baseline snapshot synchronously and - /// starts the background thread. Returns 0 (WA_OK) with a handle, or a - /// WA_ERR_* code with the handle null. - [LibraryImport(DllName, EntryPoint = "wa_create", StringMarshalling = StringMarshalling.Utf8)] - internal static partial int Create(string configJson, out nint watcher); - - /// wa_poll_events: consumes pending events with seq greater - /// than (maxEvents 0 = no limit) and writes - /// a caller-owned JSON string to . - [LibraryImport(DllName, EntryPoint = "wa_poll_events")] - internal static partial int PollEvents(nint watcher, ulong sinceSeq, uint maxEvents, out nint json); - - /// wa_status: counters and the backend actually running, as a - /// caller-owned JSON string. - [LibraryImport(DllName, EntryPoint = "wa_status")] - internal static partial int Status(nint watcher, out nint json); - - /// wa_close: signals and JOINS the background thread, then - /// frees the handle. A null handle is a no-op in the kit; callers still - /// guard, so a zero here means a bug in the caller, not the kit. - [LibraryImport(DllName, EntryPoint = "wa_close")] - internal static partial void Close(nint watcher); - - /// wa_free_string: releases a caller-owned string from the - /// kit. Only ever called with a pointer the kit produced. - [LibraryImport(DllName, EntryPoint = "wa_free_string")] - internal static partial void FreeString(nint value); - - /// wa_last_error: the calling thread's last error message, as - /// a borrowed pointer — do NOT pass this to - /// . - [LibraryImport(DllName, EntryPoint = "wa_last_error")] - internal static partial nint LastError(); -} diff --git a/src/Meridian.App/Services/FolderWatchService.cs b/src/Meridian.App/Services/FolderWatchService.cs index 0a38e3b..bc2228b 100644 --- a/src/Meridian.App/Services/FolderWatchService.cs +++ b/src/Meridian.App/Services/FolderWatchService.cs @@ -1,290 +1,147 @@ -using System.Runtime.InteropServices; -using Meridian.Native; +using System.Diagnostics; using Microsoft.UI.Dispatching; namespace Meridian.Services; -/// -/// One pane's live folder watch, backed by WatchKit. A service instance owns -/// one watcher over one folder (created with backend "auto": native -/// ReadDirectoryChangesW on Windows, the kit's own polling fallback when -/// that cannot run). A timer drains the kit's event queue, the pure Core -/// logic decides whether a batch can change the pane's listing -/// () and when a burst has settled enough to act -/// on it (), and the refresh itself is handed to -/// the UI thread, which reloads the pane through the same path manual -/// refresh uses. -/// -/// Failure honesty: watching is an enhancement, never a requirement. If the -/// DLL is missing or unloadable (the ARM64 build carries no x64 DLL), if -/// the kit refuses the folder, or if anything in the drain loop breaks, the -/// watch simply stops — the app behaves exactly as it did before watching -/// existed, with manual refresh. Nothing in here can throw into the app: -/// every native call is wrapped, and every failure is recorded in the local -/// failure log and swallowed. -/// +/// Owns one native folder notification subscription and an event-triggered debounce timer. internal sealed class FolderWatchService : IDisposable { - /// How often the drain timer polls the kit. The kit's native - /// backend notices changes immediately; this only bounds how long a - /// noticed change waits before the debounce clock sees it. - private const int DrainTickMilliseconds = 100; - - private readonly object _nativeGate = new(); + private readonly object _gate = new(); private readonly WatchDebouncer _debouncer = new(); - private readonly Func _includeDescendants; - private readonly Action _refreshDue; + private readonly FileSystemWatcher _watcher; + private readonly Timer _timer; private readonly DispatcherQueue _dispatcher; - private Timer? _timer; - private nint _handle; - private ulong _lastSeq; - private bool _disposed; + private readonly Action _refreshDue; + private volatile bool _disposed; + private bool _refreshQueued; - private FolderWatchService(nint handle, string folder, DispatcherQueue dispatcher, Func includeDescendants, Action refreshDue) + private FolderWatchService(FileSystemWatcher watcher, string folder, DispatcherQueue dispatcher, + bool includeDescendants, Action refreshDue) { - _handle = handle; + _watcher = watcher; Folder = folder; _dispatcher = dispatcher; - _includeDescendants = includeDescendants; + IncludeDescendants = includeDescendants; _refreshDue = refreshDue; + _timer = new Timer(OnDebounceElapsed); } - /// The folder this watch covers — the exact string the watcher - /// was created for, so the window can tell a stale watch from a current - /// one. public string Folder { get; } + public volatile bool IncludeDescendants; + public bool IsDisposed => _disposed; - /// The backend WatchKit reports actually running ("native" or - /// "polling"), read once at start for diagnostics; null when the status - /// call itself failed. Never used to decide anything — the kit's - /// fallback rules make the running backend correct by construction. - public string? RunningBackend { get; private set; } - - /// - /// Starts a watch over , or returns null when - /// watching is unavailable for any reason. May be called on any - /// thread — and in practice is called on a background thread, because - /// the kit's create call builds its baseline snapshot synchronously (a - /// recursive walk of the whole watched tree) and must never sit on the - /// UI thread. Nothing in creation touches UI objects: the config is - /// pure string building, the native create is a plain library call - /// whose error text is thread-local to the calling thread, the failure - /// log is stateless, and the drain timer runs on threadpool threads. - /// The dispatcher is only captured here, never used during creation; - /// is always marshaled through it, and - /// ownership of the returned service is attached by the caller on the - /// UI thread. - /// - public static FolderWatchService? TryStart(string folder, DispatcherQueue dispatcher, Func includeDescendants, Action refreshDue) + public static FolderWatchService? TryStart(string folder, DispatcherQueue dispatcher, + bool includeDescendants, Action refreshDue) { - ArgumentNullException.ThrowIfNull(dispatcher); - ArgumentNullException.ThrowIfNull(includeDescendants); - ArgumentNullException.ThrowIfNull(refreshDue); - - nint handle = nint.Zero; + FileSystemWatcher? watcher = null; FolderWatchService? service = null; try { - var config = WatchKitSpec.BuildConfigJson(folder, WatchKitSpec.BackendAuto); - // SAFETY: the config string is a marshaled copy alive only for - // this call, which the kit parses synchronously (see - // WatchKitNative). On success the handle is owned by the - // returned service; on any failure it is closed here, once. - var result = WatchKitNative.Create(config, out handle); - if (result != 0 || handle == nint.Zero) - { - FailureLog.TryRecord(new InvalidOperationException( - $"WatchKit refused to watch \"{folder}\" (code {result}): {ReadLastError()}")); - return null; - } - - service = new FolderWatchService(handle, folder, dispatcher, includeDescendants, refreshDue); - handle = nint.Zero; // ownership moved to the service - service.QueryRunningBackend(); - service._timer = new Timer(service.OnDrainTick, null, DrainTickMilliseconds, DrainTickMilliseconds); + watcher = new FileSystemWatcher(folder); + // Layout can change without navigation. Watch the tree and filter each + // notification against the mode published by the UI thread. + watcher.IncludeSubdirectories = true; + watcher.NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName + | NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.Attributes + | NotifyFilters.CreationTime; + service = new FolderWatchService(watcher, folder, dispatcher, includeDescendants, refreshDue); + watcher.Created += service.OnChanged; + watcher.Changed += service.OnChanged; + watcher.Deleted += service.OnChanged; + watcher.Renamed += service.OnChanged; + watcher.Error += service.OnError; + watcher.EnableRaisingEvents = true; return service; } - catch (Exception ex) when (ex is DllNotFoundException or BadImageFormatException or EntryPointNotFoundException) - { - // No usable watch_kit.dll beside the exe (ARM64 build, damaged - // install): watching is off, the app is unchanged. - FailureLog.TryRecord(ex); - service?.Dispose(); - return null; - } catch (Exception ex) { + if (service is not null) + service.Dispose(); + else + watcher?.Dispose(); FailureLog.TryRecord(ex); - service?.Dispose(); - return null; - } - finally - { - if (service is null && handle != nint.Zero) - { - try { WatchKitNative.Close(handle); } - catch (Exception) { /* a failed cleanup must not replace the real outcome */ } - } + return null; // Manual refresh remains available. } } - /// Reads wa_status once so diagnostics can say which backend is - /// really running. Best-effort: any failure leaves - /// null and the watch running. - private void QueryRunningBackend() + private void OnChanged(object sender, FileSystemEventArgs e) { - try - { - // SAFETY: serialized with every other native call on - // _nativeGate; the returned string is caller-owned and freed - // exactly once, in the finally. - string json; - nint jsonPointer = nint.Zero; - lock (_nativeGate) - { - if (_handle == nint.Zero || WatchKitNative.Status(_handle, out jsonPointer) != 0) return; - } - - try - { - json = Marshal.PtrToStringUTF8(jsonPointer) ?? string.Empty; - } - finally - { - if (jsonPointer != nint.Zero) WatchKitNative.FreeString(jsonPointer); - } + if (WatchRefresh.AffectsListing(Folder, e.FullPath, (e as RenamedEventArgs)?.OldFullPath, IncludeDescendants)) + NoteChange(); + } - RunningBackend = WatchStatus.Parse(json).Backend; - } - catch (Exception) + private void OnError(object sender, ErrorEventArgs e) + { + var error = e.GetException(); + if (error is InternalBufferOverflowException) { - RunningBackend = null; + // Some names were lost. Reload the listing even if there is no matching path. + NoteChange(); + return; } + Dispose(); + FailureLog.TryRecord(error); } - /// The drain loop: poll the kit, advance the sequence cursor, - /// feed relevant batches to the debouncer, and dispatch at most one - /// refresh when the debouncer says the burst has settled. Any failure - /// stops the watch quietly — a half-broken watcher that keeps a folder - /// half-fresh is worse than none. - private void OnDrainTick(object? state) + private void NoteChange() { - var due = false; - lock (_nativeGate) + lock (_gate) { - if (_disposed || _handle == nint.Zero) return; - try - { - // SAFETY: this lock serializes every native call on the - // handle, and Dispose takes the same lock before closing, - // so no call can overlap or follow wa_close. The events - // string is caller-owned: copied out, then freed once. - var result = WatchKitNative.PollEvents(_handle, _lastSeq, maxEvents: 0, out var jsonPointer); - if (result != 0) - { - FailLocked(new InvalidOperationException( - $"WatchKit event poll failed for \"{Folder}\" (code {result}): {ReadLastError()}")); - return; - } - - string json; - try - { - json = Marshal.PtrToStringUTF8(jsonPointer) ?? string.Empty; - } - finally - { - if (jsonPointer != nint.Zero) WatchKitNative.FreeString(jsonPointer); - } + if (_disposed) return; + var wasPending = _debouncer.HasPending; + var now = MonotonicNow(); + _debouncer.NoteChange(now); + if (!wasPending) + Schedule(_debouncer.DelayUntilDue(now)!.Value); + } + } - var batch = WatchEventBatch.Parse(json); - _lastSeq = batch.NextSeq; - var now = DateTimeOffset.UtcNow; - if (batch.Events.Count > 0 && WatchRefresh.AffectsListing(Folder, batch.Events, _includeDescendants())) - _debouncer.NoteChange(now); - if (_debouncer.IsDue(now)) - { - _debouncer.MarkFired(); - due = true; - } - } - catch (Exception ex) + private void OnDebounceElapsed(object? state) + { + lock (_gate) + { + if (_disposed || _debouncer.DelayUntilDue(MonotonicNow()) is not { } delay) return; + if (delay > TimeSpan.Zero) { - FailLocked(ex); + Schedule(delay); return; } + _debouncer.MarkFired(); + if (_refreshQueued) return; + _refreshQueued = true; } - if (!due) return; - try - { - // The callback re-validates everything on the UI thread (the - // pane may have navigated or the window may be closing), and - // the disposed check keeps a queued callback from acting for a - // watch that has already been torn down. - _dispatcher.TryEnqueue(() => - { - if (!_disposed) _refreshDue(); - }); - } - catch (Exception) - { - // The dispatcher is gone (window closing): nothing to refresh. - } + if (!_dispatcher.TryEnqueue(RefreshOnDispatcher)) + Dispose(); } - /// Stops the watch after a native or parse failure. Caller must - /// hold _nativeGate. Records the failure, then performs the same - /// teardown as Dispose — the window still holds this instance and will - /// dispose it later, which is a no-op by then. - private void FailLocked(Exception exception) + private void RefreshOnDispatcher() { - FailureLog.TryRecord(exception); - _disposed = true; - _timer?.Dispose(); - _timer = null; - if (_handle != nint.Zero) + lock (_gate) { - try { WatchKitNative.Close(_handle); } - catch (Exception) { /* teardown must never throw */ } - _handle = nint.Zero; + if (_disposed) return; + _refreshQueued = false; } + _refreshDue(this); } - /// The kit's thread-local error text for the call that just - /// failed on THIS thread. The pointer is borrowed (never freed); it is - /// copied into a managed string immediately. - private static string ReadLastError() - { - try - { - var pointer = WatchKitNative.LastError(); - return pointer == nint.Zero ? string.Empty : Marshal.PtrToStringUTF8(pointer) ?? string.Empty; - } - catch (Exception) - { - return string.Empty; - } - } + private void Schedule(TimeSpan delay) => + _timer.Change(TimeSpan.FromMilliseconds(Math.Ceiling(delay.TotalMilliseconds)), Timeout.InfiniteTimeSpan); + + private static DateTimeOffset MonotonicNow() => + DateTimeOffset.UnixEpoch + Stopwatch.GetElapsedTime(0); public void Dispose() { - lock (_nativeGate) + lock (_gate) { if (_disposed) return; _disposed = true; - _timer?.Dispose(); - _timer = null; - if (_handle != nint.Zero) - { - // SAFETY: every other native call holds this same lock for - // its whole duration and checks _disposed under it first, - // so no call is in flight now and none can start after — - // wa_close's thread join cannot race a poll. - try { WatchKitNative.Close(_handle); } - catch (Exception) { /* window teardown must never throw */ } - _handle = nint.Zero; - } + _timer.Dispose(); } + // A callback may already be in flight. It checks _disposed before arming + // the timer, and an already queued dispatcher callback checks it too. + try { _watcher.Dispose(); } + catch (Exception ex) { FailureLog.TryRecord(ex); } } } diff --git a/src/Meridian.App/Services/WorkspaceStore.cs b/src/Meridian.App/Services/WorkspaceStore.cs index edbce1c..f92d514 100644 --- a/src/Meridian.App/Services/WorkspaceStore.cs +++ b/src/Meridian.App/Services/WorkspaceStore.cs @@ -4,8 +4,7 @@ namespace Meridian.Services; /// /// Persists the single session's pane paths and layout names, plus bookmark -/// labels/paths. State is stored in the user's local application data -/// directory; the in-memory shelf is excluded. +/// labels/paths. The in-memory shelf is excluded. /// Files written while the app had workspace tabs also carry tab names/paths /// and an active tab index; those fields stay readable so /// can migrate the file — the active tab becomes the one session and the rest @@ -32,6 +31,12 @@ internal WorkspaceStore(string? filePath = null) Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Meridian", "workspace.json")); } + internal static WorkspaceSnapshot? ParsePortableState(ReadOnlyMemory bytes) => + JsonSerializer.Deserialize(bytes.Span, JsonOptions) is { } snapshot && IsValid(snapshot) + ? snapshot : null; + + internal static bool IsPortableStateValid(ReadOnlyMemory bytes) => ParsePortableState(bytes) is not null; + /// /// Resolves the one session a saved workspace represents. When the file /// carries workspace tabs (written by 0.3.x), the tab that was active @@ -177,3 +182,143 @@ internal sealed record BookmarkSnapshot(string Name, string Path); /// every bookmark reads as ungrouped. /// internal sealed record BookmarkGroupSnapshot(string Name, string[]? Paths = null, bool Collapsed = false); + +/// Resolves and initializes the two portable workspace files. +internal sealed class WorkspaceStorage +{ + internal const string MarkerName = "meridian-portable.flag"; + internal const string SessionName = "workspace.json"; + internal const string NamedName = "named-workspaces.json"; + private readonly string _markerPath; + private readonly string _legacyDirectory; + private readonly string _workspaceDirectory; + private readonly Exception? _markerDetectionError; + + internal WorkspaceStorage(string applicationDirectory, string localApplicationData) + { + _markerPath = Path.Combine(applicationDirectory, MarkerName); + _legacyDirectory = Path.Combine(localApplicationData, "Meridian"); + try { IsPortable = AttributesOrMissing(_markerPath) is not null; } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException) + { + // An inaccessible marker cannot safely select the installed store. + IsPortable = true; + _markerDetectionError = ex; + } + _workspaceDirectory = IsPortable ? Path.Combine(applicationDirectory, "data") : _legacyDirectory; + } + + internal bool IsPortable { get; } + internal string SessionPath => Path.Combine(_workspaceDirectory, SessionName); + internal string NamedPath => Path.Combine(_workspaceDirectory, NamedName); + + internal async Task?> PrepareAsync(string fileName, Func, bool> validate) + { + if (!IsPortable) return null; + var destination = Path.Combine(_workspaceDirectory, fileName); + var receipt = destination + ".migration-complete"; + byte[]? state = null; + try + { + if (_markerDetectionError is not null) + throw new IOException("The portable marker could not be inspected. Restart after checking its permissions.", _markerDetectionError); + if (!RegularFileExists(_markerPath)) + throw new IOException("The portable marker is missing."); + CheckDirectory(); + if (RegularFileExists(destination)) + { + state = await ReadValidatedAsync(destination, validate); + } + else if (!RegularFileExists(receipt)) + { + var source = Path.Combine(_legacyDirectory, fileName); + if (RegularFileExists(source)) + { + var bytes = await ReadValidatedAsync(source, validate); + Directory.CreateDirectory(_workspaceDirectory); + CheckDirectory(); + var temporary = destination + "." + Guid.NewGuid().ToString("N") + ".tmp"; + var ownsTemporary = false; + try + { + await using (var stream = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + { + ownsTemporary = true; + await stream.WriteAsync(bytes); + await stream.FlushAsync(); + } + try + { + File.Move(temporary, destination, overwrite: false); + ownsTemporary = false; + state = bytes; + } + catch (IOException) when (RegularFileExists(destination)) + { + state = await ReadValidatedAsync(destination, validate); + } + } + finally + { + try { if (ownsTemporary) File.Delete(temporary); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { } + } + } + } + + if (!RegularFileExists(receipt)) + { + Directory.CreateDirectory(_workspaceDirectory); + CheckDirectory(); + try { using var stream = new FileStream(receipt, FileMode.CreateNew, FileAccess.Write, FileShare.None); } + catch (IOException) when (RegularFileExists(receipt)) { } + } + if (state is null && RegularFileExists(destination)) + state = await ReadValidatedAsync(destination, validate); + return state is null ? null : JsonContent(state); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or System.Security.SecurityException) + { + throw new IOException( + $"Portable {fileName} is unavailable. Check the data folder permissions and back up any unreadable workspace file before repairing it. {ex.Message}", + ex); + } + } + + private void CheckDirectory() + { + if (AttributesOrMissing(_workspaceDirectory) is { } attributes && + ((attributes & FileAttributes.Directory) == 0 || (attributes & FileAttributes.ReparsePoint) != 0)) + throw new IOException($"The portable data path must be an ordinary directory: {_workspaceDirectory}"); + } + + private static bool RegularFileExists(string path) + { + if (AttributesOrMissing(path) is not { } attributes) return false; + if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0) + throw new IOException($"Expected an ordinary file: {path}"); + return true; + } + + private static FileAttributes? AttributesOrMissing(string path) + { + try { return File.GetAttributes(path); } + catch (FileNotFoundException) { return null; } + catch (DirectoryNotFoundException) { return null; } + } + + private static async Task ReadValidatedAsync(string path, Func, bool> validate) + { + await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, useAsync: true); + if (stream.Length > WorkspaceStore.MaximumStateBytes) + throw new IOException($"Workspace state exceeds 1 MiB: {path}"); + var bytes = new byte[(int)stream.Length]; + await stream.ReadExactlyAsync(bytes); + if (stream.ReadByte() != -1 || !validate(JsonContent(bytes))) + throw new IOException($"Workspace state is invalid: {path}"); + return bytes; + } + + private static ReadOnlyMemory JsonContent(ReadOnlyMemory bytes) => + bytes.Span.StartsWith((ReadOnlySpan)[0xef, 0xbb, 0xbf]) ? bytes[3..] : bytes; +} diff --git a/src/Meridian.Core/Services/NamedWorkspaces.cs b/src/Meridian.Core/Services/NamedWorkspaces.cs index ce57aff..1080987 100644 --- a/src/Meridian.Core/Services/NamedWorkspaces.cs +++ b/src/Meridian.Core/Services/NamedWorkspaces.cs @@ -33,12 +33,17 @@ public sealed class NamedWorkspaceStore private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; private readonly string _filePath; + private readonly bool _reportSaveFailure; + private readonly SemaphoreSlim _writeLock = new(1, 1); private readonly List _entries = []; // most recently saved first - public NamedWorkspaceStore(string filePath) + public NamedWorkspaceStore(string filePath) : this(filePath, reportSaveFailure: false) { } + + public NamedWorkspaceStore(string filePath, bool reportSaveFailure) { ArgumentException.ThrowIfNullOrWhiteSpace(filePath); _filePath = Path.GetFullPath(filePath); + _reportSaveFailure = reportSaveFailure; } public IReadOnlyList Entries => _entries; @@ -47,6 +52,12 @@ public NamedWorkspaceStore(string filePath) public static bool IsValidName(string? name) => !string.IsNullOrWhiteSpace(name) && name.Trim().Length <= MaximumNameLength; + public static bool IsPortableStateValid(ReadOnlyMemory bytes) => + JsonSerializer.Deserialize>(bytes.Span, JsonOptions) is { Count: <= MaximumEntries } entries && + entries.All(entry => entry is not null && IsValidName(entry.Name) && + (!string.IsNullOrWhiteSpace(entry.LeftPath) || !string.IsNullOrWhiteSpace(entry.RightPath))) && + entries.Select(entry => entry.Name.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).Count() == entries.Count; + public async Task LoadAsync(CancellationToken cancellationToken = default) { _entries.Clear(); @@ -55,19 +66,7 @@ public async Task LoadAsync(CancellationToken cancellationToken = default) if (!File.Exists(_filePath)) return; var json = await File.ReadAllTextAsync(_filePath, cancellationToken); - var loaded = JsonSerializer.Deserialize>(json, JsonOptions) ?? []; - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var entry in loaded) - { - if (entry is null || !IsValidName(entry.Name)) - continue; - if (string.IsNullOrWhiteSpace(entry.LeftPath) && string.IsNullOrWhiteSpace(entry.RightPath)) - continue; // a workspace with nowhere to go is not one - if (!seen.Add(entry.Name.Trim())) - continue; - _entries.Add(entry); - } - TrimToCap(); + LoadEntries(JsonSerializer.Deserialize>(json, JsonOptions) ?? []); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or NotSupportedException) { @@ -75,6 +74,24 @@ public async Task LoadAsync(CancellationToken cancellationToken = default) } } + public void LoadPortableState(ReadOnlyMemory bytes) => + LoadEntries(JsonSerializer.Deserialize>(bytes.Span, JsonOptions) ?? []); + + private void LoadEntries(IEnumerable loaded) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + _entries.Clear(); + foreach (var entry in loaded) + { + if (entry is null || !IsValidName(entry.Name) || + (string.IsNullOrWhiteSpace(entry.LeftPath) && string.IsNullOrWhiteSpace(entry.RightPath)) || + !seen.Add(entry.Name.Trim())) + continue; + _entries.Add(entry); + if (_entries.Count == MaximumEntries) break; + } + } + /// Adds a workspace, replacing any with the same name (ignoring /// case), and moves it to the front. Saves quietly. public async Task UpsertAsync(NamedWorkspace workspace, CancellationToken cancellationToken = default) @@ -85,44 +102,72 @@ public async Task UpsertAsync(NamedWorkspace workspace, CancellationToken cancel if (string.IsNullOrWhiteSpace(workspace.LeftPath) && string.IsNullOrWhiteSpace(workspace.RightPath)) throw new ArgumentException("A workspace needs at least one pane folder.", nameof(workspace)); var trimmed = workspace with { Name = workspace.Name.Trim() }; - _entries.RemoveAll(entry => string.Equals(entry.Name, trimmed.Name, StringComparison.OrdinalIgnoreCase)); - _entries.Insert(0, trimmed); - TrimToCap(); - await SaveQuietlyAsync(cancellationToken); + await _writeLock.WaitAsync(cancellationToken); + try + { + var proposed = _entries.Where(entry => !string.Equals(entry.Name, trimmed.Name, StringComparison.OrdinalIgnoreCase)) + .Prepend(trimmed).Take(MaximumEntries).ToArray(); + await ApplyAsync(proposed, cancellationToken); + } + finally { _writeLock.Release(); } } /// Removes the named workspace when present. Saves quietly. public async Task RemoveAsync(string name, CancellationToken cancellationToken = default) { - if (_entries.RemoveAll(entry => string.Equals(entry.Name, name, StringComparison.OrdinalIgnoreCase)) == 0) - return; - await SaveQuietlyAsync(cancellationToken); + await _writeLock.WaitAsync(cancellationToken); + try + { + var proposed = _entries.Where(entry => !string.Equals(entry.Name, name, StringComparison.OrdinalIgnoreCase)).ToArray(); + if (proposed.Length != _entries.Count) + await ApplyAsync(proposed, cancellationToken); + } + finally { _writeLock.Release(); } } public NamedWorkspace? Find(string name) => _entries.FirstOrDefault(entry => string.Equals(entry.Name, name, StringComparison.OrdinalIgnoreCase)); - private void TrimToCap() - { - while (_entries.Count > MaximumEntries) - _entries.RemoveAt(_entries.Count - 1); - } - - private async Task SaveQuietlyAsync(CancellationToken cancellationToken) + private async Task ApplyAsync(NamedWorkspace[] proposed, CancellationToken cancellationToken) { + if (!_reportSaveFailure) ReplaceEntries(proposed); + string? tempPath = null; try { var directory = Path.GetDirectoryName(_filePath); if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory); - var tempPath = _filePath + ".tmp"; - var json = JsonSerializer.Serialize(_entries, JsonOptions); - await File.WriteAllTextAsync(tempPath, json, cancellationToken); + var bytes = JsonSerializer.SerializeToUtf8Bytes(proposed, JsonOptions); + if (_reportSaveFailure && bytes.Length > 1024 * 1024) + throw new IOException("Named workspaces exceed the 1 MiB saved-state limit."); + var candidate = _filePath + "." + Guid.NewGuid().ToString("N") + ".tmp"; + await using (var stream = new FileStream(candidate, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + { + tempPath = candidate; + await stream.WriteAsync(bytes, cancellationToken); + await stream.FlushAsync(cancellationToken); + } File.Move(tempPath, _filePath, overwrite: true); + tempPath = null; + if (_reportSaveFailure) ReplaceEntries(proposed); } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException) + catch (Exception ex) when (!_reportSaveFailure && (ex is IOException or UnauthorizedAccessException or NotSupportedException)) { // Saving is a convenience; browsing must continue if it fails. } + finally + { + if (tempPath is not null) + { + try { File.Delete(tempPath); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { } + } + } + } + + private void ReplaceEntries(IEnumerable entries) + { + _entries.Clear(); + _entries.AddRange(entries); } } diff --git a/src/Meridian.Core/Services/WatchDebouncer.cs b/src/Meridian.Core/Services/WatchDebouncer.cs index aa702c5..3cfa36b 100644 --- a/src/Meridian.Core/Services/WatchDebouncer.cs +++ b/src/Meridian.Core/Services/WatchDebouncer.cs @@ -1,8 +1,7 @@ namespace Meridian.Services; /// -/// The burst-coalescing clock behind live folder refresh. WatchKit already -/// coalesces per path; this debouncer coalesces over time: while changes +/// The burst-coalescing clock behind live folder refresh. While changes /// keep arriving, the refresh waits for a short quiet gap so a burst (an /// unzip, a build, a bulk copy) costs one listing reload instead of dozens — /// but a maximum wait caps the delay so a folder under continuous churn @@ -52,10 +51,16 @@ public void NoteChange(DateTimeOffset now) /// True when the pending refresh should fire at /// : the burst has been quiet for the quiet /// period, or the pending window has reached the maximum wait. - public bool IsDue(DateTimeOffset now) + public bool IsDue(DateTimeOffset now) => DelayUntilDue(now) == TimeSpan.Zero; + + /// Delay to the next one-shot refresh, or null when idle. + public TimeSpan? DelayUntilDue(DateTimeOffset now) { - if (_firstPendingAt is not { } first || _lastChangeAt is not { } last) return false; - return now - last >= _quietPeriod || now - first >= _maximumWait; + if (_firstPendingAt is not { } first || _lastChangeAt is not { } last) return null; + var quietRemaining = _quietPeriod - (now - last); + var maximumRemaining = _maximumWait - (now - first); + var remaining = quietRemaining < maximumRemaining ? quietRemaining : maximumRemaining; + return remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero; } /// Clears the pending window after the refresh fires (or when diff --git a/src/Meridian.Core/Services/WatchRefresh.cs b/src/Meridian.Core/Services/WatchRefresh.cs index 8cea57f..6d87a17 100644 --- a/src/Meridian.Core/Services/WatchRefresh.cs +++ b/src/Meridian.Core/Services/WatchRefresh.cs @@ -1,8 +1,8 @@ namespace Meridian.Services; /// -/// Decides whether a batch of WatchKit events can change what a pane shows -/// for its current folder. WatchKit watches recursively, but a standard pane +/// Decides whether folder notifications can change what a pane shows +/// for its current folder. The watch is recursive, but a standard pane /// listing shows only direct children — so a change two levels down cannot /// alter it, and refreshing for one would be pure churn. The Miller-columns /// layout does show descendant folders, so it opts into descendant matches. @@ -10,8 +10,7 @@ namespace Meridian.Services; /// unit-tested. /// /// Comparisons are Windows-flavoured: case-insensitive, and tolerant of the -/// separator a path arrives with, because the kit echoes paths in the form -/// its snapshot walk produced and the pane's path comes from Windows APIs. +/// separator a notification path arrives with. /// public static class WatchRefresh { @@ -28,25 +27,34 @@ public static bool AffectsListing(string? paneFolder, IEnumerable ev foreach (var change in events) { - if (Touches(change.Path) || (change.OldPath is not null && Touches(change.OldPath))) + if (Touches(folder, change.Path, includeDescendants) || Touches(folder, change.OldPath, includeDescendants)) return true; } return false; + } - bool Touches(string candidate) - { - if (string.IsNullOrWhiteSpace(candidate)) return false; - var path = Normalize(candidate); - if (path.Length == 0) return false; - if (string.Equals(path, folder, StringComparison.OrdinalIgnoreCase)) return true; - var parent = ParentOf(path); - if (parent is not null && string.Equals(parent, folder, StringComparison.OrdinalIgnoreCase)) return true; - return includeDescendants - && path.Length > folder.Length - && path.StartsWith(folder, StringComparison.OrdinalIgnoreCase) - && path[folder.Length] == '\\'; - } + /// Tests both paths of a notification without constructing a batch. + public static bool AffectsListing(string? paneFolder, string path, string? oldPath, bool includeDescendants) + { + if (string.IsNullOrWhiteSpace(paneFolder)) return false; + var folder = Normalize(paneFolder); + return folder.Length > 0 + && (Touches(folder, path, includeDescendants) || Touches(folder, oldPath, includeDescendants)); + } + + private static bool Touches(string folder, string? candidate, bool includeDescendants) + { + if (string.IsNullOrWhiteSpace(candidate)) return false; + var path = Normalize(candidate); + if (path.Length == 0) return false; + if (string.Equals(path, folder, StringComparison.OrdinalIgnoreCase)) return true; + var parent = ParentOf(path); + if (parent is not null && string.Equals(parent, folder, StringComparison.OrdinalIgnoreCase)) return true; + return includeDescendants + && path.Length > folder.Length + && path.StartsWith(folder, StringComparison.OrdinalIgnoreCase) + && path[folder.Length] == '\\'; } /// One comparable form for a path: forward slashes become diff --git a/tests/Meridian.Core.Tests/PortableWorkspaceStorageTests.cs b/tests/Meridian.Core.Tests/PortableWorkspaceStorageTests.cs new file mode 100644 index 0000000..a53e31d --- /dev/null +++ b/tests/Meridian.Core.Tests/PortableWorkspaceStorageTests.cs @@ -0,0 +1,121 @@ +using Meridian.Services; + +namespace Meridian.Core.Tests; + +public sealed class PortableWorkspaceStorageTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "meridian-portable-tests-" + Guid.NewGuid().ToString("N")); + private string App => Path.Combine(_root, "app"); + private string Local => Path.Combine(_root, "local"); + private string Legacy => Path.Combine(Local, "Meridian"); + + public PortableWorkspaceStorageTests() + { + Directory.CreateDirectory(App); + Directory.CreateDirectory(Legacy); + } + + [Fact] + public async Task UnmarkedApplicationKeepsTheExistingPerUserPath() + { + var storage = new WorkspaceStorage(App, Local); + Assert.False(storage.IsPortable); + Assert.Equal(Path.Combine(Legacy, WorkspaceStorage.SessionName), storage.SessionPath); + Assert.Equal(Path.Combine(Legacy, WorkspaceStorage.NamedName), storage.NamedPath); + await storage.PrepareAsync(WorkspaceStorage.SessionName, _ => throw new InvalidOperationException()); + Assert.False(Directory.Exists(Path.Combine(App, "data"))); + } + + [Fact] + public async Task PortableMigrationPreservesBytesAndDoesNotImportAgainAfterDeletion() + { + var storage = MarkPortable(); + var legacySession = Path.Combine(Legacy, WorkspaceStorage.SessionName); + var legacyNamed = Path.Combine(Legacy, WorkspaceStorage.NamedName); + var session = new byte[] { 0xef, 0xbb, 0xbf }.Concat("{ \"Bookmarks\": [] }"u8.ToArray()).ToArray(); + var named = "[]\n"u8.ToArray(); + await File.WriteAllBytesAsync(legacySession, session); + await File.WriteAllBytesAsync(legacyNamed, named); + + await Task.WhenAll( + storage.PrepareAsync(WorkspaceStorage.SessionName, WorkspaceStore.IsPortableStateValid), + storage.PrepareAsync(WorkspaceStorage.SessionName, WorkspaceStore.IsPortableStateValid), + storage.PrepareAsync(WorkspaceStorage.NamedName, NamedWorkspaceStore.IsPortableStateValid)); + + Assert.Equal(session, await File.ReadAllBytesAsync(storage.SessionPath)); + Assert.Equal(named, await File.ReadAllBytesAsync(storage.NamedPath)); + Assert.Equal(session, await File.ReadAllBytesAsync(legacySession)); + Assert.Equal(named, await File.ReadAllBytesAsync(legacyNamed)); + File.Delete(storage.SessionPath); + await MarkPortable().PrepareAsync(WorkspaceStorage.SessionName, WorkspaceStore.IsPortableStateValid); + Assert.False(File.Exists(storage.SessionPath)); + Assert.Empty(Directory.EnumerateFiles(Path.GetDirectoryName(storage.SessionPath)!, "*.tmp")); + } + + [Fact] + public async Task ExistingInvalidPortableStateIsPreservedInsteadOfUsingValidLegacyState() + { + var storage = MarkPortable(); + await File.WriteAllTextAsync(Path.Combine(Legacy, WorkspaceStorage.NamedName), "[]"); + Directory.CreateDirectory(Path.GetDirectoryName(storage.NamedPath)!); + await File.WriteAllTextAsync(storage.NamedPath, "damaged"); + + await Assert.ThrowsAsync(() => + storage.PrepareAsync(WorkspaceStorage.NamedName, NamedWorkspaceStore.IsPortableStateValid)); + + Assert.Equal("damaged", await File.ReadAllTextAsync(storage.NamedPath)); + Assert.False(File.Exists(storage.NamedPath + ".migration-complete")); + } + + [Fact] + public async Task OversizedLegacyStateIsNotCopiedOrMarkedComplete() + { + var storage = MarkPortable(); + var legacy = Path.Combine(Legacy, WorkspaceStorage.SessionName); + await File.WriteAllBytesAsync(legacy, new byte[WorkspaceStore.MaximumStateBytes + 1]); + + await Assert.ThrowsAsync(() => + storage.PrepareAsync(WorkspaceStorage.SessionName, WorkspaceStore.IsPortableStateValid)); + + Assert.False(File.Exists(storage.SessionPath)); + Assert.False(File.Exists(storage.SessionPath + ".migration-complete")); + Assert.Equal(WorkspaceStore.MaximumStateBytes + 1L, new FileInfo(legacy).Length); + } + + [Fact] + public async Task PortableDataPathBlockedByFileDoesNotFallBackToLegacy() + { + var storage = MarkPortable(); + await File.WriteAllTextAsync(Path.Combine(App, "data"), "occupied"); + var legacy = Path.Combine(Legacy, WorkspaceStorage.SessionName); + await File.WriteAllTextAsync(legacy, "{}"); + + await Assert.ThrowsAsync(() => + storage.PrepareAsync(WorkspaceStorage.SessionName, WorkspaceStore.IsPortableStateValid)); + + Assert.Equal("{}", await File.ReadAllTextAsync(legacy)); + Assert.Equal("occupied", await File.ReadAllTextAsync(Path.Combine(App, "data"))); + } + + [Fact] + public async Task ConcurrentPortableNamedSavesPreserveBothEntries() + { + var storage = MarkPortable(); + var store = new NamedWorkspaceStore(storage.NamedPath, reportSaveFailure: true); + await Task.WhenAll( + store.UpsertAsync(new("First", App, "", "Standard", "Standard", false)), + store.UpsertAsync(new("Second", Local, "", "Standard", "Standard", false))); + + var reloaded = new NamedWorkspaceStore(storage.NamedPath); + await reloaded.LoadAsync(); + Assert.Equal(new[] { "First", "Second" }, reloaded.Entries.Select(entry => entry.Name).Order()); + } + + private WorkspaceStorage MarkPortable() + { + File.WriteAllText(Path.Combine(App, WorkspaceStorage.MarkerName), ""); + return new WorkspaceStorage(App, Local); + } + + public void Dispose() => Directory.Delete(_root, recursive: true); +} diff --git a/tests/Meridian.Core.Tests/WatchDebouncerTests.cs b/tests/Meridian.Core.Tests/WatchDebouncerTests.cs index cccbd6d..7e6e3e7 100644 --- a/tests/Meridian.Core.Tests/WatchDebouncerTests.cs +++ b/tests/Meridian.Core.Tests/WatchDebouncerTests.cs @@ -49,6 +49,21 @@ public void AContinuousBurstStillFiresAtTheMaximumWait() Assert.True(debouncer.IsDue(Start + TimeSpan.FromMilliseconds(2000))); } + [Fact] + public void OneShotDelayUsesTheEarlierDeadlineAndDisarmsAfterRefresh() + { + var debouncer = NewDebouncer(); + Assert.Null(debouncer.DelayUntilDue(Start)); + debouncer.NoteChange(Start); + Assert.Equal(TimeSpan.FromMilliseconds(300), debouncer.DelayUntilDue(Start)); + var justBeforeMaximum = Start + TimeSpan.FromMilliseconds(1950); + debouncer.NoteChange(justBeforeMaximum); + Assert.Equal(TimeSpan.FromMilliseconds(50), debouncer.DelayUntilDue(justBeforeMaximum)); + Assert.Equal(TimeSpan.Zero, debouncer.DelayUntilDue(Start + TimeSpan.FromSeconds(2))); + debouncer.MarkFired(); + Assert.Null(debouncer.DelayUntilDue(Start + TimeSpan.FromSeconds(2))); + } + [Fact] public void MarkFiredClearsThePendingWindow() { diff --git a/vendor/watchkit/PROVENANCE.md b/vendor/watchkit/PROVENANCE.md index e81fe97..174355b 100644 --- a/vendor/watchkit/PROVENANCE.md +++ b/vendor/watchkit/PROVENANCE.md @@ -1,5 +1,7 @@ # WatchKit — vendored provenance +Historical integration record. Meridian now uses Windows folder notifications through .NET and does not package this DLL. + - **Kit:** WatchKit (folder change and cache coordinator; JSON C ABI over `watch_kit.dll`) - **Version:** v0.2.0 - **Source release:** https://github.com/SeanShaffer88/WatchKit/releases/tag/v0.2.0