Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af81ba050a
|
||
|
|
181950b73a
|
||
|
|
34b6aba0ff
|
||
|
|
80e00b3ed1
|
||
|
|
600825030a
|
||
|
|
6919d3ce4a
|
No files matched your search
@@ -30,7 +30,7 @@ and isolated boundary evidence in ADR-0005. Explorer and installed lifecycle
|
||||
acceptance remain required; literal process arguments alone are not association
|
||||
proof.
|
||||
|
||||
Store at most20 successful absolute paths in the private AppKit settings file,
|
||||
Store at most20 successful absolute paths in the private settings file,
|
||||
deduplicated with Windows path comparison. Update only after a parsed snapshot
|
||||
commits. Clear history removes entries without deleting files or closing readers.
|
||||
Keep paths out of activity logs/telemetry. Each preference save merges only fields
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
# ADR-0006: Settings file protocol
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-11-10
|
||||
|
||||
## Context
|
||||
|
||||
One `settings.json` per edition is shared by every reader window and by every
|
||||
process that uses the same data folder: an installed copy and a portable copy, or
|
||||
two builds started from different folders, are separate processes over one file.
|
||||
|
||||
AppKit's `SettingsStore` returns defaults whenever the file cannot be read and writes
|
||||
a whole snapshot through one fixed temporary name with no lock. With real files and
|
||||
real processes this lost data in two ways: a load during a sharing violation returned
|
||||
defaults that the next save wrote back, and two processes saving in turn erased each
|
||||
other's changes.
|
||||
|
||||
The files beside `settings.json` are now a contract between application versions that
|
||||
may run at the same time, so the protocol is recorded here.
|
||||
|
||||
## Decision
|
||||
|
||||
`AppSettingsStore` in the core library owns the file. AppKit still defines the shell
|
||||
preferences (`ShellSettings`); only persistence moved.
|
||||
|
||||
**Change, not snapshot.** `Update` takes a function from the stored settings to the
|
||||
new settings. It runs under the lock against what is on disk at that moment, so a
|
||||
caller cannot write back a stale copy of a value it did not change.
|
||||
|
||||
**Lock.** `settings.json.lock` is opened with no sharing for the duration of an
|
||||
update. It is never deleted: deleting a lock file leaves a window in which a second
|
||||
opener is refused with access denied. The operating system releases the handle if
|
||||
the process dies. A lock that stays held for every attempt refuses the update.
|
||||
|
||||
**Read for an update.** The file is opened for reading while denying writers, and
|
||||
that handle is held until the rename. A program rewriting the file in place therefore
|
||||
reads as busy, not as invalid content, and none can start between the read and the
|
||||
rename. An empty file is waited on for the same attempts, because a program that
|
||||
clears a file before rewriting it leaves it empty for a moment.
|
||||
|
||||
**Read for display.** The same open is tried first. If another handle has write
|
||||
access, the file is read beside it instead of waiting, since such a handle is usually
|
||||
idle. Content read that way which does not parse is retried and, if it never parses,
|
||||
reported as unreadable rather than corrupt: it cannot be judged while it can still be
|
||||
written. An empty file reads as defaults at once.
|
||||
|
||||
**What a read can find.**
|
||||
|
||||
| Found | Used for display | On update |
|
||||
| --- | --- | --- |
|
||||
| No file | Defaults | The file is created |
|
||||
| Empty file | Defaults | Replaced after the wait; nothing kept aside |
|
||||
| Valid settings | The stored values | Replaced |
|
||||
| Some invalid properties | Valid properties kept, invalid ones as defaults | Replaced; a copy of the original is kept aside |
|
||||
| Not settings at all | Defaults | Replaced; a copy of the original is kept aside, unless it is over 16 MiB |
|
||||
| Unreadable or being written | Defaults | Refused; nothing is written |
|
||||
|
||||
A trailing comma, a comment and a quoted number are valid: they are what a hand edit
|
||||
commonly leaves behind. Text the JSON reader cannot decode, such as an unpaired
|
||||
surrogate escape, makes its property invalid. Repair works per top-level property:
|
||||
one bad entry in a list costs that list. A file over 16 MiB is not loaded.
|
||||
|
||||
**Write.** The new content is written to `settings.json.<32 hex digits>.tmp` and
|
||||
flushed to disk. That file is then renamed over `settings.json` in one step with the
|
||||
Windows rename that replaces an open file (`FileRenameInfoEx` with POSIX semantics).
|
||||
At no point is there no `settings.json`: a reader sees the old file or the new one,
|
||||
and a process killed at any moment leaves one of them. Handles that were open on the
|
||||
old file and allow deletion go on reading it. `File.Replace` is not used because it
|
||||
is two renames with the file missing between them, and `File.Move` with overwrite is
|
||||
not used because it refuses while any handle is open on the destination.
|
||||
|
||||
The rename is given the path in the form Windows takes as written, so a data folder
|
||||
whose path is longer than 260 characters, or whose name is not well-formed UTF-16,
|
||||
behaves like any other.
|
||||
|
||||
On a volume that does not implement that rename, the store closes its own read
|
||||
handle and falls back to `File.Move` with overwrite. If the file did not exist when
|
||||
it was read, the temporary file is renamed into place without overwriting. A failed
|
||||
update leaves the stored file as it was and removes its temporary file.
|
||||
|
||||
**Kept-aside copies.** Before invalid content is replaced it is copied to a temporary
|
||||
file. Once the save has succeeded the copy is renamed to `settings.json.corrupt`, or
|
||||
to the first free of `settings.json.corrupt-2` to `settings.json.corrupt-9`; the last
|
||||
name is overwritten once all exist. A save that fails removes its temporary copy and
|
||||
leaves every earlier copy as it was, and the invalid file is still in place. The copy
|
||||
is a courtesy for recovering a hand edit, with these limits:
|
||||
|
||||
- a file over 16 MiB is replaced without a copy, since it was never this
|
||||
application's settings and copying it would stall the window;
|
||||
- a copy that cannot be written, for example on a full disk, or cannot be given its
|
||||
name, is removed and reported, and the save goes ahead, because refusing would
|
||||
block every later change too;
|
||||
- a process killed between the save and the rename of the copy loses that copy.
|
||||
|
||||
Nothing reads the copies back. They contain whatever the file contained, including
|
||||
recent-document paths, so clearing recent files deletes them.
|
||||
|
||||
**Abandoned temporary files.** A process killed between its write and its rename
|
||||
leaves a temporary file, and possibly a temporary copy. The next update, holding the
|
||||
lock, removes files that match this store's own name pattern and no others.
|
||||
|
||||
**Waiting.** Only a sharing violation is waited out: at most 20 attempts, 50 ms
|
||||
apart, for the lock, the read and the rename. Access denied is treated as permanent
|
||||
and reported at once, so a read-only profile does not stall a window. The fallback
|
||||
move is the exception: it answers access denied while any handle is open on the
|
||||
file, a reader's included, so there access denied is waited out as well, unless the
|
||||
file carries the read-only attribute.
|
||||
|
||||
**Reporting.** Faults are reported by kind with the exception type and HRESULT. No
|
||||
path and no operating-system message text reaches the log.
|
||||
|
||||
## Consequences
|
||||
|
||||
- An update is a synchronous call on the window thread. It normally takes a few
|
||||
milliseconds and at most about one second per contended step.
|
||||
- The JSON written is byte-compatible with earlier releases, so a downgrade reads it.
|
||||
- The rename needs Windows 10 version 1607 or later and a file system that supports
|
||||
it; NTFS does. Other volumes use the fallback, which is not safe against a kill
|
||||
between closing the file and the move completing.
|
||||
- An earlier release running against the same data folder still has its own defect:
|
||||
it takes no lock, and when a read fails it falls back to defaults and may save
|
||||
them. This store cannot prevent that. It also makes that release's saves fail more
|
||||
often while both run, because the earlier release cannot replace a file this store
|
||||
has open. Run one version per data folder.
|
||||
- The store keeps no in-memory copy. Windows hold their own view of the settings and
|
||||
merge only the fields they changed (ADR-0004).
|
||||
|
||||
## Options considered
|
||||
|
||||
- **A named mutex instead of a lock file.** Rejected: a session-local name does not
|
||||
cover two sessions of one user on the same profile, and a global name can be
|
||||
created first by another account on the machine.
|
||||
- **Renaming the invalid file away instead of copying it.** Rejected: between that
|
||||
rename and the new file arriving there is no settings file, and a kill there loses
|
||||
the profile.
|
||||
- **Fixing AppKit's store instead.** Still worthwhile for other applications, but the
|
||||
read-modify-write API is a different shape, and this application needed the fix
|
||||
without waiting for a package release.
|
||||
@@ -34,7 +34,7 @@ physical directory. It does not authorize native commands or remote resource acc
|
||||
| Remote resources | Denied by CSP and resource interception |
|
||||
| External links | HTTP(S) only, actual browser user gesture, external Windows handler; no document navigation outside current page |
|
||||
| Heading navigation | Labels extracted after sanitation; authored identity attributes stripped; generated numeric targets only. Fixed host operations reveal/jump/collapse on the current intercepted page; authored scripts, messages and host objects remain disabled |
|
||||
| Settings / logs | AppKit sanitation; per-user data root; log operational events without document content or paths |
|
||||
| Settings / logs | AppKit sanitation; per-user data root; log operational events without document content or paths. Settings changes are read-modify-write under a lock file beside `settings.json` and are renamed into place in one step, so the file is never absent; a file that is unreadable or being written by another program is never overwritten, a copy of invalid content is kept aside under a bounded set of names and removed when recent files are cleared, a file over 16 MiB is not loaded, and faults are logged by kind, exception type and HRESULT only (ADR-0006) |
|
||||
| Updates | Repository HTTPS feed, Velopack integrity checks and production signing preflight |
|
||||
| Repository / CI | Protected main and release tags; producer-bound checks; no untrusted PR checkout with privileged review token |
|
||||
|
||||
|
||||
@@ -73,6 +73,8 @@ Documents are rendered locally. Authored scripts and remote images are blocked;
|
||||
- **Open in editor is disabled:** hover for the reason, then configure an installed editor in Settings.
|
||||
- **The viewer is not a default option:** use Setup rather than a portable copy, then reopen Windows Default Apps.
|
||||
- **The app does not start:** confirm Microsoft Edge WebView2 Runtime is installed and inspect the activity logs if available.
|
||||
- **A setting could not be saved:** another program is using `settings.json`, or the file or folder is read-only. Nothing is overwritten while the file cannot be read; try the change again.
|
||||
- **Settings were edited by hand:** a trailing comma, a comment or a quoted number is accepted; the app rewrites the file in its own format on the next save, so comments are not kept. A syntax error such as a missing comma or quote makes the whole file unreadable: every setting starts from its default, and the next save keeps a copy of your file as described below. A setting whose value is not valid is ignored and the other settings are kept. When either happens, the next save keeps a copy of the file as it was, named `settings.json.corrupt` (then `.corrupt-2` up to `.corrupt-9`). Those copies contain everything the file contained; **File → Recent files → Clear recent files** deletes them.
|
||||
|
||||
For help, use [Discussions](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/discussions). Report reproducible problems through [Issues](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/issues/new/choose), including **Help → About** version, Windows version, and relevant sanitized logs. Report vulnerabilities privately using [SECURITY.md](../SECURITY.md).
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
using KofTwentyTwo.AppKit;
|
||||
using KofTwentyTwo.AppKit.Logging;
|
||||
using KofTwentyTwo.AppKit.Settings;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core;
|
||||
@@ -73,7 +72,7 @@ public static class AppIdentity
|
||||
|
||||
|
||||
/// <summary>Loads and saves <see cref="AppSettings"/> at <see cref="AppPaths.SettingsFile"/>.</summary>
|
||||
public static SettingsStore<AppSettings> SettingsStore { get; } = new(Paths.SettingsFile, AppSettingsContext.Default.AppSettings);
|
||||
public static AppSettingsStore SettingsStore { get; } = new(Paths.SettingsFile);
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -202,8 +202,9 @@ public sealed class AppSettings : ShellSettings
|
||||
_ = Themes.ReaderTheme.ParseCompatible(json);
|
||||
return true;
|
||||
}
|
||||
catch(JsonException)
|
||||
catch(Exception exception) when(exception is JsonException or InvalidOperationException)
|
||||
{
|
||||
// A string the JSON reader cannot decode, such as an unpaired surrogate escape, is invalid content too.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,17 @@
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core;
|
||||
|
||||
/// <summary>Source-generated JSON serializer for <see cref="AppSettings"/>, so trimmed builds need no reflection.</summary>
|
||||
[JsonSourceGenerationOptions(WriteIndented = true)]
|
||||
/// <summary>
|
||||
/// Source-generated JSON serializer for <see cref="AppSettings"/>, so trimmed builds need no reflection.
|
||||
/// Reading tolerates what a hand edit commonly leaves behind: a trailing comma, a comment, a quoted number.
|
||||
/// </summary>
|
||||
[JsonSourceGenerationOptions(WriteIndented = true, AllowTrailingCommas = true, ReadCommentHandling = JsonCommentHandling.Skip, NumberHandling = JsonNumberHandling.AllowReadingFromString)]
|
||||
[JsonSerializable(typeof(AppSettings))]
|
||||
public sealed partial class AppSettingsContext : JsonSerializerContext
|
||||
{
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core;
|
||||
|
||||
/// <summary>
|
||||
/// The settings file shared by every window and every process of one edition. A change is
|
||||
/// applied to the latest stored settings under a cross-process lock and renamed into place in
|
||||
/// one step, so two instances cannot erase each other's change and the file is never absent.
|
||||
/// A file that cannot be read, or that another program is writing, is never treated as empty;
|
||||
/// content that is invalid while nobody is writing it is repaired where possible and a copy of
|
||||
/// the original is kept aside. ADR-0006 describes the files involved.
|
||||
/// </summary>
|
||||
public sealed class AppSettingsStore
|
||||
{
|
||||
/// <summary>Attempts for the lock, a read, and the final rename before the operation gives up.</summary>
|
||||
public const int MaximumAttempts = 20;
|
||||
|
||||
/// <summary>A larger file is not this application's settings and is never loaded into memory.</summary>
|
||||
public const int MaximumBytes = 16 * 1024 * 1024;
|
||||
|
||||
/// <summary>Copies of invalid files kept aside before the last name is reused.</summary>
|
||||
public const int MaximumKeptAside = 9;
|
||||
|
||||
/// <summary>The pause between attempts while another handle is in the way.</summary>
|
||||
public static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(50);
|
||||
|
||||
private const int SharingViolation = unchecked((int)0x80070020);
|
||||
private static readonly JsonDocumentOptions s_lenient = new() { AllowTrailingCommas = true, CommentHandling = JsonCommentHandling.Skip };
|
||||
private readonly Action<SettingsFault, Exception?> _report;
|
||||
private readonly Action<TimeSpan> _wait;
|
||||
private readonly Func<string, string, bool> _renameOver;
|
||||
|
||||
|
||||
|
||||
/// <summary>Creates the store; faults go to <paramref name="report"/>, and tests supply <paramref name="wait"/> to count attempts instead of sleeping.</summary>
|
||||
public AppSettingsStore(string path, Action<SettingsFault, Exception?>? report = null, Action<TimeSpan>? wait = null)
|
||||
: this(path, report, wait, FileSwap.TryRenameOver)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Creates the store over a given rename, so tests can stand in for a volume that cannot rename over an open file.</summary>
|
||||
internal AppSettingsStore(string path, Action<SettingsFault, Exception?>? report, Action<TimeSpan>? wait, Func<string, string, bool> renameOver)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
Path = System.IO.Path.GetFullPath(path);
|
||||
_report = report ?? ((_, _) => { });
|
||||
_wait = wait ?? Thread.Sleep;
|
||||
_renameOver = renameOver;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The settings file, as an absolute path.</summary>
|
||||
public string Path { get; }
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Reads the stored settings for display. A missing, empty, invalid or unreadable file yields
|
||||
/// defaults and is left untouched. A handle that another program merely holds with write
|
||||
/// access does not delay the read.
|
||||
/// </summary>
|
||||
public AppSettings Load()
|
||||
{
|
||||
_ = TryRead(forUpdate: false, out AppSettings settings, out _);
|
||||
return settings;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Applies one change to the latest stored settings and saves the result. Returns the saved
|
||||
/// settings, or null when nothing was written: the lock stayed busy, the file could not be
|
||||
/// read or was being written by another program, or the write failed. The stored file is
|
||||
/// unchanged in every null case.
|
||||
/// </summary>
|
||||
public AppSettings? Update(Func<AppSettings, AppSettings> change)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(change);
|
||||
FileStream? stored = null;
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(Path)!);
|
||||
using FileStream? gate = Acquire();
|
||||
if(gate is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
// The read handle denies writers until the rename, so the content judged here is the content replaced.
|
||||
SettingsRead read = TryRead(forUpdate: true, out AppSettings current, out stored);
|
||||
if(read == SettingsRead.Unreadable)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
AppSettings edited = change(current);
|
||||
edited.Sanitize();
|
||||
RemoveAbandonedTemporaryFiles();
|
||||
string? copy = read is SettingsRead.Corrupt or SettingsRead.Repaired ? CopyAside(stored!) : null;
|
||||
Replace(JsonSerializer.SerializeToUtf8Bytes(edited, AppSettingsContext.Default.AppSettings), stored, copy);
|
||||
if(copy is not null)
|
||||
{
|
||||
KeepAside(copy);
|
||||
}
|
||||
return edited;
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_report(SettingsFault.WriteFailed, exception);
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
stored?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Removes the copies of invalid files that saves have kept aside. They hold whatever the
|
||||
/// file held, including the recent-document history, so clearing that history removes them.
|
||||
/// </summary>
|
||||
public void DeleteKeptAside()
|
||||
{
|
||||
for(int slot = 1; slot <= MaximumKeptAside; slot++)
|
||||
{
|
||||
TryDelete(KeptAsideName(slot));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Holds an exclusive handle beside the settings file; the operating system releases it if the
|
||||
/// process dies. A lock that stays held, or cannot be opened for another I/O reason, is busy.
|
||||
/// </summary>
|
||||
private FileStream? Acquire()
|
||||
{
|
||||
try
|
||||
{
|
||||
FileStream? gate = null;
|
||||
Retry(() => gate = new FileStream(Path + ".lock", FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None));
|
||||
return gate;
|
||||
}
|
||||
catch(IOException exception)
|
||||
{
|
||||
_report(SettingsFault.Busy, exception);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Reads the file, waiting out other handles a bounded number of times, and reports what it
|
||||
/// found. For an update the open handle is returned so writers stay out until the rename.
|
||||
/// </summary>
|
||||
private SettingsRead TryRead(bool forUpdate, out AppSettings settings, out FileStream? stored)
|
||||
{
|
||||
settings = new AppSettings();
|
||||
stored = null;
|
||||
SettingsRead? read = null;
|
||||
for(int attempt = 1; read is null; attempt++)
|
||||
{
|
||||
if(attempt > 1)
|
||||
{
|
||||
_wait(RetryDelay);
|
||||
}
|
||||
read = Attempt(forUpdate, attempt == MaximumAttempts, ref settings, ref stored);
|
||||
}
|
||||
if(read is SettingsRead.Corrupt or SettingsRead.Repaired)
|
||||
{
|
||||
_report(read == SettingsRead.Corrupt ? SettingsFault.Corrupt : SettingsFault.Repaired, null);
|
||||
}
|
||||
settings.Sanitize();
|
||||
return read.Value;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// One read. Returns null to try again: another handle is in the way, the file is empty while
|
||||
/// a save is waiting for whoever cleared it, or the content read beside a writer did not parse.
|
||||
/// On the last attempt it always answers.
|
||||
/// </summary>
|
||||
private SettingsRead? Attempt(bool forUpdate, bool last, ref AppSettings settings, ref FileStream? stored)
|
||||
{
|
||||
FileStream? stream = null;
|
||||
bool keep = false;
|
||||
try
|
||||
{
|
||||
stream = Open(forUpdate, out bool writersExcluded);
|
||||
SettingsRead read = Parse(stream, ref settings);
|
||||
if(read == SettingsRead.Empty && forUpdate && !last)
|
||||
{
|
||||
// A program that clears the file before rewriting it is about to write.
|
||||
return null;
|
||||
}
|
||||
if(read == SettingsRead.Corrupt && !writersExcluded)
|
||||
{
|
||||
// Content read while another program can write may simply be half written.
|
||||
return last ? Unreadable(null) : null;
|
||||
}
|
||||
keep = forUpdate;
|
||||
stored = keep ? stream : null;
|
||||
return read;
|
||||
}
|
||||
catch(Exception exception) when(exception is FileNotFoundException or DirectoryNotFoundException)
|
||||
{
|
||||
return SettingsRead.Missing;
|
||||
}
|
||||
catch(IOException exception) when(IsTransient(exception) && !last)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return Unreadable(exception);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if(!keep)
|
||||
{
|
||||
stream?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Reports a file whose content could not be established and answers that it must not be replaced.</summary>
|
||||
private SettingsRead Unreadable(Exception? exception)
|
||||
{
|
||||
_report(SettingsFault.Unreadable, exception);
|
||||
return SettingsRead.Unreadable;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Opens the file denying writers, so a program rewriting it in place reads as busy rather
|
||||
/// than as invalid content. A read for display falls back to reading beside a handle that has
|
||||
/// write access, because such a handle is usually idle; the caller then knows the content may
|
||||
/// be half written.
|
||||
/// </summary>
|
||||
private FileStream Open(bool forUpdate, out bool writersExcluded)
|
||||
{
|
||||
writersExcluded = true;
|
||||
try
|
||||
{
|
||||
return new FileStream(Path, FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete);
|
||||
}
|
||||
catch(IOException exception) when(IsTransient(exception) && !forUpdate)
|
||||
{
|
||||
writersExcluded = false;
|
||||
return new FileStream(Path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Reads at most <see cref="MaximumBytes"/> and classifies the content; an invalid property costs that property, never the whole profile.</summary>
|
||||
private static SettingsRead Parse(FileStream stream, ref AppSettings settings)
|
||||
{
|
||||
if(stream.Length == 0)
|
||||
{
|
||||
return SettingsRead.Empty;
|
||||
}
|
||||
if(stream.Length > MaximumBytes)
|
||||
{
|
||||
return SettingsRead.Corrupt;
|
||||
}
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 4096, leaveOpen: true);
|
||||
string json = reader.ReadToEnd();
|
||||
try
|
||||
{
|
||||
if(JsonSerializer.Deserialize(json, AppSettingsContext.Default.AppSettings) is { } loaded)
|
||||
{
|
||||
settings = loaded;
|
||||
return SettingsRead.Loaded;
|
||||
}
|
||||
}
|
||||
catch(Exception exception) when(IsInvalidContent(exception))
|
||||
{
|
||||
if(Salvage(json) is { } repaired)
|
||||
{
|
||||
settings = repaired;
|
||||
return SettingsRead.Repaired;
|
||||
}
|
||||
}
|
||||
return SettingsRead.Corrupt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Keeps every stored property that is valid on its own and drops the ones that are not; null when the file is not a JSON object at all.</summary>
|
||||
private static AppSettings? Salvage(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
if(JsonNode.Parse(json, documentOptions: s_lenient) is not JsonObject stored)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
foreach(string name in stored.Select(property => property.Key).ToArray())
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = new JsonObject { [name] = stored[name]?.DeepClone() }.Deserialize(AppSettingsContext.Default.AppSettings);
|
||||
}
|
||||
catch(Exception exception) when(IsInvalidContent(exception))
|
||||
{
|
||||
stored.Remove(name);
|
||||
}
|
||||
}
|
||||
return stored.Deserialize(AppSettingsContext.Default.AppSettings);
|
||||
}
|
||||
catch(Exception exception) when(IsInvalidContent(exception) || exception is ArgumentException)
|
||||
{
|
||||
// Not JSON, or an object that repeats a property name.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// What the JSON reader throws for content that is not valid settings: a syntax or type error,
|
||||
/// or text it cannot decode, such as an unpaired surrogate escape.
|
||||
/// </summary>
|
||||
private static bool IsInvalidContent(Exception exception) => exception is JsonException or InvalidOperationException;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Copies an invalid file to a temporary name before it is replaced and returns that name.
|
||||
/// The copy is a courtesy, so the save goes ahead without one, with the reason reported and
|
||||
/// null returned, when the file is past the size bound (it was never this application's
|
||||
/// settings) or the copy cannot be written. A copy left behind by a save that never finished
|
||||
/// is removed with the other abandoned temporary files.
|
||||
/// </summary>
|
||||
private string? CopyAside(FileStream stored)
|
||||
{
|
||||
if(stored.Length > MaximumBytes)
|
||||
{
|
||||
_report(SettingsFault.NotKeptAside, null);
|
||||
return null;
|
||||
}
|
||||
string temporary = TemporaryName();
|
||||
try
|
||||
{
|
||||
using(var copy = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None))
|
||||
{
|
||||
stored.Position = 0;
|
||||
stored.CopyTo(copy);
|
||||
copy.Flush(flushToDisk: true);
|
||||
}
|
||||
return temporary;
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
TryDelete(temporary);
|
||||
_report(SettingsFault.NotKeptAside, exception);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Gives the copy of a replaced invalid file its lasting name: the first free kept-aside name,
|
||||
/// or the last one once all are taken. This happens after the save, so a save that fails
|
||||
/// never disturbs an earlier copy. The copy is a courtesy: one that cannot be placed is
|
||||
/// removed and reported, and the save stands.
|
||||
/// </summary>
|
||||
private void KeepAside(string copy)
|
||||
{
|
||||
int slot = 1;
|
||||
while(slot < MaximumKeptAside && (File.Exists(KeptAsideName(slot)) || Directory.Exists(KeptAsideName(slot))))
|
||||
{
|
||||
slot++;
|
||||
}
|
||||
try
|
||||
{
|
||||
File.Move(copy, KeptAsideName(slot), overwrite: true);
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
TryDelete(copy);
|
||||
_report(SettingsFault.NotKeptAside, exception);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The kept-aside names: <c>.corrupt</c>, then <c>.corrupt-2</c> to <c>.corrupt-9</c>.</summary>
|
||||
private string KeptAsideName(int slot) => slot == 1 ? Path + ".corrupt" : Path + ".corrupt-" + slot.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
|
||||
|
||||
/// <summary>A name beside the settings file that only this store uses, so an abandoned one can be recognized and removed.</summary>
|
||||
private string TemporaryName() => Path + "." + Guid.NewGuid().ToString("N") + ".tmp";
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Writes beside the file and flushes to disk, then renames over it in one step. A failure
|
||||
/// leaves the stored file as it was, and neither the temporary file nor the copy of the
|
||||
/// invalid original behind.
|
||||
/// </summary>
|
||||
private void Replace(byte[] content, FileStream? stored, string? copy)
|
||||
{
|
||||
string temporary = TemporaryName();
|
||||
bool replaced = false;
|
||||
try
|
||||
{
|
||||
using(var stream = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None))
|
||||
{
|
||||
stream.Write(content);
|
||||
stream.Flush(flushToDisk: true);
|
||||
}
|
||||
if(stored is null)
|
||||
{
|
||||
// No overwrite: a file that appeared since the read is not this instance's to replace.
|
||||
File.Move(temporary, Path);
|
||||
}
|
||||
else
|
||||
{
|
||||
bool renamed = false;
|
||||
Retry(() => renamed = _renameOver(temporary, Path));
|
||||
if(!renamed)
|
||||
{
|
||||
// This volume cannot rename over an open file, so the file is closed first. This
|
||||
// move is refused while any handle is open on the file, a reader's included.
|
||||
stored.Dispose();
|
||||
Retry(() => File.Move(temporary, Path, overwrite: true), IsBlockedByAHandle);
|
||||
}
|
||||
}
|
||||
replaced = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if(!replaced)
|
||||
{
|
||||
TryDelete(temporary);
|
||||
if(copy is not null)
|
||||
{
|
||||
TryDelete(copy);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A writer that died before its rename left a temporary file with this store's own name pattern; under the lock every such file is abandoned.</summary>
|
||||
private void RemoveAbandonedTemporaryFiles()
|
||||
{
|
||||
string prefix = System.IO.Path.GetFileName(Path) + ".";
|
||||
foreach(string file in Directory.EnumerateFiles(System.IO.Path.GetDirectoryName(Path)!, prefix + "*.tmp"))
|
||||
{
|
||||
string name = System.IO.Path.GetFileName(file);
|
||||
if(name.Length == prefix.Length + 36 && Guid.TryParseExact(name.AsSpan(prefix.Length, 32), "N", out _))
|
||||
{
|
||||
TryDelete(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Repeats an operation only while another handle is in the way, a bounded number of times;
|
||||
/// the last failure is thrown. <paramref name="inTheWay"/> says which failures mean that:
|
||||
/// a sharing violation unless the operation reports an open handle differently.
|
||||
/// </summary>
|
||||
private void Retry(Action operation, Func<Exception, bool>? inTheWay = null)
|
||||
{
|
||||
inTheWay ??= IsTransient;
|
||||
for(int attempt = 1; ; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
operation();
|
||||
return;
|
||||
}
|
||||
catch(Exception exception) when(attempt < MaximumAttempts && inTheWay(exception))
|
||||
{
|
||||
_wait(RetryDelay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A sharing violation: another handle on the file that is expected to go away. Access denied is not transient.</summary>
|
||||
private static bool IsTransient(Exception exception) => exception is IOException && exception.HResult == SharingViolation;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// What a move over the settings file fails with while another handle is open on it. Unlike
|
||||
/// an open or a rename, that move answers access denied, which therefore has to be waited
|
||||
/// out. A read-only file gives the same answer for good, so it is told apart by its attribute.
|
||||
/// </summary>
|
||||
private bool IsBlockedByAHandle(Exception exception)
|
||||
=> IsTransient(exception) || (exception is UnauthorizedAccessException && !new FileInfo(Path).IsReadOnly);
|
||||
|
||||
|
||||
|
||||
/// <summary>Removes a file if it is there and can be removed; a folder with that name, or a file in use, is left.</summary>
|
||||
private static void TryDelete(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Cleanup only: the operation this was part of has already succeeded or failed.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Replaces a file by renaming another over it in a single step. The destination is never
|
||||
/// absent, not even for an instant, and the rename works while the destination is open through
|
||||
/// handles that allow deletion; those handles go on reading the content they opened.
|
||||
/// <see cref="File.Move(string, string, bool)"/> refuses when any handle is open on the
|
||||
/// destination, and <see cref="File.Replace(string, string, string?)"/> is two renames with the
|
||||
/// destination missing between them, so neither can do this.
|
||||
/// </summary>
|
||||
internal static partial class FileSwap
|
||||
{
|
||||
private const uint DeleteAccess = 0x00010000;
|
||||
private const uint ShareAll = 0x00000007;
|
||||
private const uint OpenExisting = 3;
|
||||
private const int FileRenameInfoEx = 22;
|
||||
private const uint ReplaceIfExists = 0x1;
|
||||
private const uint PosixSemantics = 0x2;
|
||||
private const int AccessDenied = 5;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Renames <paramref name="source"/> over <paramref name="destination"/>. Returns false when the
|
||||
/// volume cannot rename over an open file, as file systems other than NTFS may not. Throws
|
||||
/// <see cref="IOException"/> with the Windows error as its HRESULT when the rename is refused,
|
||||
/// for example by a handle on the destination that does not allow deletion, and
|
||||
/// <see cref="UnauthorizedAccessException"/> when access is denied.
|
||||
/// </summary>
|
||||
public static bool TryRenameOver(string source, string destination)
|
||||
{
|
||||
using SafeFileHandle handle = CreateFile(Verbatim(source), DeleteAccess, ShareAll, 0, OpenExisting, 0, 0);
|
||||
if(handle.IsInvalid)
|
||||
{
|
||||
throw Failure(Marshal.GetLastPInvokeError());
|
||||
}
|
||||
// FILE_RENAME_INFO: flags, root directory handle, name length in bytes, then the name itself.
|
||||
// The name is copied as stored: a file name may hold text that is not well-formed UTF-16.
|
||||
byte[] name = MemoryMarshal.AsBytes(Verbatim(destination).AsSpan()).ToArray();
|
||||
int lengthOffset = 2 * IntPtr.Size;
|
||||
int nameOffset = lengthOffset + sizeof(int);
|
||||
byte[] rename = new byte[nameOffset + name.Length + sizeof(char)];
|
||||
BitConverter.TryWriteBytes(rename, ReplaceIfExists | PosixSemantics);
|
||||
BitConverter.TryWriteBytes(rename.AsSpan(lengthOffset), name.Length);
|
||||
name.CopyTo(rename, nameOffset);
|
||||
if(SetFileInformationByHandle(handle, FileRenameInfoEx, rename, rename.Length))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
return IsUnsupported(error) ? false : throw Failure(error);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The form of a full path that Windows takes as written, without the 260-character limit the
|
||||
/// plain form has in a process that has not opted in to long paths. The managed file calls add
|
||||
/// it themselves; a direct Windows call has to.
|
||||
/// </summary>
|
||||
internal static string Verbatim(string fullPath)
|
||||
{
|
||||
if(fullPath.StartsWith(@"\\?\", StringComparison.Ordinal) || fullPath.StartsWith(@"\\.\", StringComparison.Ordinal))
|
||||
{
|
||||
return fullPath;
|
||||
}
|
||||
return fullPath.StartsWith(@"\\", StringComparison.Ordinal) ? string.Concat(@"\\?\UNC\", fullPath.AsSpan(2)) : @"\\?\" + fullPath;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The errors a volume answers with when it does not implement this kind of rename: invalid function, not supported, invalid parameter.</summary>
|
||||
internal static bool IsUnsupported(int error) => error is 1 or 50 or 87;
|
||||
|
||||
|
||||
|
||||
/// <summary>The exception a managed file operation would throw for the same Windows error.</summary>
|
||||
private static Exception Failure(int error)
|
||||
{
|
||||
string message = new Win32Exception(error).Message;
|
||||
return error == AccessDenied ? new UnauthorizedAccessException(message) : new IOException(message, unchecked((int)0x80070000) | error);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Opens the file to be renamed with delete access, which a managed file stream cannot request.</summary>
|
||||
[LibraryImport("kernel32.dll", EntryPoint = "CreateFileW", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)]
|
||||
private static partial SafeFileHandle CreateFile(string path, uint access, uint share, nint security, uint disposition, uint flags, nint template);
|
||||
|
||||
|
||||
|
||||
/// <summary>Applies the rename to the open file.</summary>
|
||||
[LibraryImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static partial bool SetFileInformationByHandle(SafeFileHandle file, int informationClass, byte[] information, int size);
|
||||
}
|
||||
@@ -8,6 +8,8 @@
|
||||
<RuntimeIdentifiers>win-x86;win-x64;win-arm64</RuntimeIdentifiers>
|
||||
<!-- Include the SDK tool dependency in the lock used by single-file CLI publishing. -->
|
||||
<EnableSingleFileAnalyzer>true</EnableSingleFileAnalyzer>
|
||||
<!-- Source-generated P/Invoke marshalling (FileSwap) compiles to unsafe code. -->
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core;
|
||||
|
||||
/// <summary>Why the settings file could not be used as stored; reported without paths or operating-system message text.</summary>
|
||||
public enum SettingsFault
|
||||
{
|
||||
/// <summary>The file does not hold settings at all; defaults are used and the next save keeps a copy of the file aside.</summary>
|
||||
Corrupt,
|
||||
/// <summary>Some stored properties were invalid and read as their defaults; the next save keeps a copy of the original aside.</summary>
|
||||
Repaired,
|
||||
/// <summary>The file could not be read, or another program was writing it; it is left untouched and nothing is written over it.</summary>
|
||||
Unreadable,
|
||||
/// <summary>Another instance kept the settings lock for every attempt; the change was not saved.</summary>
|
||||
Busy,
|
||||
/// <summary>The change could not be written; the stored file is unchanged.</summary>
|
||||
WriteFailed,
|
||||
/// <summary>The invalid original could not be copied aside; the change was saved without the copy.</summary>
|
||||
NotKeptAside,
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core;
|
||||
|
||||
/// <summary>What reading the settings file found; only <see cref="Unreadable"/> forbids writing over it.</summary>
|
||||
internal enum SettingsRead
|
||||
{
|
||||
/// <summary>Valid stored settings.</summary>
|
||||
Loaded,
|
||||
/// <summary>No file yet; defaults apply and the first save creates it.</summary>
|
||||
Missing,
|
||||
/// <summary>A file with no content, as a program that clears a file before rewriting it leaves it; defaults apply and a save replaces it.</summary>
|
||||
Empty,
|
||||
/// <summary>Valid properties were kept and invalid ones dropped; a save keeps a copy of the original aside.</summary>
|
||||
Repaired,
|
||||
/// <summary>Not settings at all; defaults apply and a save keeps a copy of the file aside.</summary>
|
||||
Corrupt,
|
||||
/// <summary>The content is unknown, so it must not be replaced.</summary>
|
||||
Unreadable,
|
||||
}
|
||||
@@ -107,12 +107,14 @@ public partial class App
|
||||
|
||||
|
||||
|
||||
/// <summary>Records a successfully committed path through the latest store snapshot, then refreshes every recent menu.</summary>
|
||||
/// <summary>Records a successfully committed path in the latest stored history under the settings lock, then refreshes every recent menu.</summary>
|
||||
internal bool RecordRecentDocument(string path)
|
||||
{
|
||||
Core.AppSettings settings = AppIdentity.SettingsStore.Load();
|
||||
settings.RecentFiles = Core.Documents.RecentDocuments.Record(settings.RecentFiles, path);
|
||||
bool saved = AppIdentity.SettingsStore.Save(settings);
|
||||
bool saved = AppIdentity.SettingsStore.Update(settings =>
|
||||
{
|
||||
settings.RecentFiles = Core.Documents.RecentDocuments.Record(settings.RecentFiles, path);
|
||||
return settings;
|
||||
}) is not null;
|
||||
RefreshRecentMenus();
|
||||
return saved;
|
||||
}
|
||||
@@ -122,9 +124,16 @@ public partial class App
|
||||
/// <summary>Clears only the local history, leaving open documents, reading preferences and files intact.</summary>
|
||||
internal bool ClearRecentDocuments()
|
||||
{
|
||||
Core.AppSettings settings = AppIdentity.SettingsStore.Load();
|
||||
settings.RecentFiles = [];
|
||||
bool saved = AppIdentity.SettingsStore.Save(settings);
|
||||
bool saved = AppIdentity.SettingsStore.Update(settings =>
|
||||
{
|
||||
settings.RecentFiles = [];
|
||||
return settings;
|
||||
}) is not null;
|
||||
if(saved)
|
||||
{
|
||||
// Copies of an invalid settings file kept beside it would still list the history.
|
||||
AppIdentity.SettingsStore.DeleteKeptAside();
|
||||
}
|
||||
RefreshRecentMenus();
|
||||
return saved;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
using KofTwentyTwo.AppKit;
|
||||
using KofTwentyTwo.AppKit.Logging;
|
||||
using KofTwentyTwo.AppKit.Settings;
|
||||
using Velopack.Locators;
|
||||
|
||||
|
||||
@@ -31,8 +30,8 @@ internal static class AppIdentity
|
||||
|
||||
|
||||
|
||||
/// <summary>Each edition owns its own persisted preferences and recent document list.</summary>
|
||||
public static SettingsStore<AppSettings> SettingsStore { get; } = new(Paths.SettingsFile, AppSettingsContext.Default.AppSettings);
|
||||
/// <summary>Each edition owns its own persisted preferences and recent document list; faults are logged by kind, never with a path or message text.</summary>
|
||||
public static Core.AppSettingsStore SettingsStore { get; } = new(Paths.SettingsFile, ReportSettingsFault);
|
||||
|
||||
|
||||
|
||||
@@ -56,6 +55,12 @@ internal static class AppIdentity
|
||||
|
||||
|
||||
|
||||
/// <summary>A settings fault is an operational event: the kind, exception type and HRESULT identify it without disclosing the profile.</summary>
|
||||
private static void ReportSettingsFault(Core.SettingsFault fault, Exception? exception)
|
||||
=> App.Log.Warning(FormattableString.Invariant($"Settings file fault: {fault}; exception={exception?.GetType().Name ?? "none"}; HRESULT=0x{exception?.HResult ?? 0:X8}."));
|
||||
|
||||
|
||||
|
||||
/// <summary>Daily activity files use the edition's own path and identity prefix.</summary>
|
||||
public static IActivityLog CreateLog() => FileActivityLog.ForApp(Info, Paths);
|
||||
}
|
||||
@@ -48,8 +48,8 @@ public sealed partial class MainWindow
|
||||
Core.AppSettings draft = Core.SettingsChanges.Copy(before);
|
||||
draft.ConfiguredEditors = choices;
|
||||
draft.ExternalEditor = choices.FirstOrDefault()?.Executable ?? "";
|
||||
Core.AppSettings merged = Core.SettingsChanges.Merge(AppIdentity.SettingsStore.Load(), before, draft);
|
||||
if(!AppIdentity.SettingsStore.Save(merged))
|
||||
Core.AppSettings? merged = AppIdentity.SettingsStore.Update(current => Core.SettingsChanges.Merge(current, before, draft));
|
||||
if(merged is null)
|
||||
{
|
||||
StatusText.Text = "Could not save editor configuration.";
|
||||
return;
|
||||
|
||||
@@ -19,9 +19,12 @@ public sealed partial class MainWindow
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Core.AppSettings settings = AppIdentity.SettingsStore.Load();
|
||||
settings.Print = options.Sanitize();
|
||||
if(!AppIdentity.SettingsStore.Save(settings))
|
||||
Core.AppSettings? settings = AppIdentity.SettingsStore.Update(current =>
|
||||
{
|
||||
current.Print = options.Sanitize();
|
||||
return current;
|
||||
});
|
||||
if(settings is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -61,8 +61,8 @@ public sealed partial class MainWindow
|
||||
draft.ContentWidthEnabled = appearance.LimitWidth;
|
||||
draft.ContentWidthPx = appearance.WidthPixels;
|
||||
draft.GlobalCss = appearance.GlobalCss;
|
||||
Core.AppSettings merged = Core.SettingsChanges.Merge(AppIdentity.SettingsStore.Load(), before, draft);
|
||||
if(!AppIdentity.SettingsStore.Save(merged))
|
||||
Core.AppSettings? merged = AppIdentity.SettingsStore.Update(current => Core.SettingsChanges.Merge(current, before, draft));
|
||||
if(merged is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -79,14 +79,16 @@ public sealed partial class MainWindow
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Core.AppSettings settings = AppIdentity.SettingsStore.Load();
|
||||
settings.ShowSplashScreen = showSplash;
|
||||
settings.SplashMilliseconds = milliseconds;
|
||||
settings.CheckForUpdatesOnStartup = checkUpdates;
|
||||
settings.ShowOutlineByDefault = showOutline;
|
||||
settings.ShowSourceByDefault = showSource;
|
||||
settings.Sanitize();
|
||||
if(!AppIdentity.SettingsStore.Save(settings))
|
||||
Core.AppSettings? settings = AppIdentity.SettingsStore.Update(current =>
|
||||
{
|
||||
current.ShowSplashScreen = showSplash;
|
||||
current.SplashMilliseconds = milliseconds;
|
||||
current.CheckForUpdatesOnStartup = checkUpdates;
|
||||
current.ShowOutlineByDefault = showOutline;
|
||||
current.ShowSourceByDefault = showSource;
|
||||
return current;
|
||||
});
|
||||
if(settings is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -120,10 +122,13 @@ public sealed partial class MainWindow
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Core.AppSettings settings = AppIdentity.SettingsStore.Load();
|
||||
settings.WrapOutlineEntries = wrap;
|
||||
settings.ShowOutlineDepthMarkers = depthMarkers;
|
||||
if(!AppIdentity.SettingsStore.Save(settings))
|
||||
Core.AppSettings? settings = AppIdentity.SettingsStore.Update(current =>
|
||||
{
|
||||
current.WrapOutlineEntries = wrap;
|
||||
current.ShowOutlineDepthMarkers = depthMarkers;
|
||||
return current;
|
||||
});
|
||||
if(settings is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -56,8 +56,8 @@ public sealed partial class MainWindow
|
||||
/// <summary>Failed persistence does not apply an unsaved theme draft; sibling fields untouched by this edit retain their latest values.</summary>
|
||||
private async Task<bool> CommitThemeDraftAsync(Core.AppSettings before, Core.AppSettings edited)
|
||||
{
|
||||
Core.AppSettings merged = Core.SettingsChanges.Merge(AppIdentity.SettingsStore.Load(), before, edited);
|
||||
if(!AppIdentity.SettingsStore.Save(merged))
|
||||
Core.AppSettings? merged = AppIdentity.SettingsStore.Update(current => Core.SettingsChanges.Merge(current, before, edited));
|
||||
if(merged is null)
|
||||
{
|
||||
StatusText.Text = "Could not save document themes.";
|
||||
return false;
|
||||
|
||||
@@ -522,8 +522,7 @@ public sealed partial class MainWindow : Window, IDisposable
|
||||
/// <summary>Saves the settings and reports a failed save in the status line.</summary>
|
||||
private void Save()
|
||||
{
|
||||
Core.AppSettings merged = Core.SettingsChanges.Merge(AppIdentity.SettingsStore.Load(), _lastSavedSettings, _settings);
|
||||
if(AppIdentity.SettingsStore.Save(merged))
|
||||
if(AppIdentity.SettingsStore.Update(current => Core.SettingsChanges.Merge(current, _lastSavedSettings, _settings)) is not null)
|
||||
{
|
||||
_lastSavedSettings = Core.SettingsChanges.Copy(_settings);
|
||||
}
|
||||
|
||||
@@ -90,8 +90,8 @@ public class AppSettingsTests
|
||||
public void SaveAndLoad_KeepsTheTheme()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
var store = new KofTwentyTwo.AppKit.Settings.SettingsStore<AppSettings>(temp.File("settings.json"), AppSettingsContext.Default.AppSettings);
|
||||
Assert.True(store.Save(new AppSettings { Theme = "Dark", DocumentTheme = "GitHub Light", Frontmatter = "Metadata", UserThemes = [Themes.ThemeCatalog.Find("GitHub Light").Json] }));
|
||||
var store = new AppSettingsStore(temp.File("settings.json"));
|
||||
Assert.NotNull(store.Update(_ => new AppSettings { Theme = "Dark", DocumentTheme = "GitHub Light", Frontmatter = "Metadata", UserThemes = [Themes.ThemeCatalog.Find("GitHub Light").Json] }));
|
||||
Assert.Equal("Dark", store.Load().Theme);
|
||||
Assert.Equal("GitHub Light", store.Load().DocumentTheme);
|
||||
Assert.Equal(Documents.FrontmatterMode.Metadata, store.Load().FrontmatterKind);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -76,21 +76,24 @@ public sealed class EditorConfigurationTests
|
||||
|
||||
|
||||
|
||||
/// <summary>Atomic AppKit persistence retains named IDs, order and folder mode using the trimmed serializer.</summary>
|
||||
/// <summary>Atomic persistence retains named IDs, order and folder mode using the trimmed serializer.</summary>
|
||||
[Fact]
|
||||
public void SaveAndLoad_EditorList_PreservesIdentityOrderAndMode()
|
||||
{
|
||||
using var temporary = new TempDirectory();
|
||||
var store = new KofTwentyTwo.AppKit.Settings.SettingsStore<AppSettings>(temporary.File("settings.json"), AppSettingsContext.Default.AppSettings);
|
||||
var store = new AppSettingsStore(temporary.File("settings.json"));
|
||||
var first = new EditorConfiguration(Guid.NewGuid(), "Folder editor", @"C:\Editor\folder.exe", true);
|
||||
var second = new EditorConfiguration(Guid.NewGuid(), "File editor", @"C:\Editor\file.exe");
|
||||
Assert.True(store.Save(new AppSettings { ConfiguredEditors = [first, second], ExternalEditor = @"C:\Other\old.exe" }));
|
||||
Assert.NotNull(store.Update(_ => new AppSettings { ConfiguredEditors = [first, second], ExternalEditor = @"C:\Other\old.exe" }));
|
||||
AppSettings loaded = store.Load();
|
||||
Assert.Equal(new[] { first, second }, loaded.ConfiguredEditors);
|
||||
Assert.Equal(first.Executable, loaded.ExternalEditor);
|
||||
loaded.ConfiguredEditors = [];
|
||||
loaded.ExternalEditor = "";
|
||||
Assert.True(store.Save(loaded));
|
||||
Assert.NotNull(store.Update(settings =>
|
||||
{
|
||||
settings.ConfiguredEditors = [];
|
||||
settings.ExternalEditor = "";
|
||||
return settings;
|
||||
}));
|
||||
Assert.Empty(store.Load().ConfiguredEditors);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core.Tests;
|
||||
|
||||
/// <summary>Renaming one real file over another, with other real handles open on the destination.</summary>
|
||||
public sealed class FileSwapTests
|
||||
{
|
||||
/// <summary>A full path is given the form Windows takes as written: a drive path, a network path, and paths that already have it.</summary>
|
||||
[Theory]
|
||||
[InlineData(@"C:\data\settings.json", @"\\?\C:\data\settings.json")]
|
||||
[InlineData(@"\\server\share\settings.json", @"\\?\UNC\server\share\settings.json")]
|
||||
[InlineData(@"\\?\C:\data\settings.json", @"\\?\C:\data\settings.json")]
|
||||
[InlineData(@"\\?\UNC\server\share\settings.json", @"\\?\UNC\server\share\settings.json")]
|
||||
[InlineData(@"\\.\C:\data\settings.json", @"\\.\C:\data\settings.json")]
|
||||
public void Verbatim_FullPath_IsPrefixedOnce(string path, string expected)
|
||||
{
|
||||
Assert.Equal(expected, FileSwap.Verbatim(path));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The rename replaces the destination and removes the source name.</summary>
|
||||
[Fact]
|
||||
public void TryRenameOver_ExistingDestination_ReplacesIt()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
File.WriteAllText(temp.File("stored.json"), "old");
|
||||
File.WriteAllText(temp.File("new.tmp"), "new");
|
||||
Assert.True(FileSwap.TryRenameOver(temp.File("new.tmp"), temp.File("stored.json")));
|
||||
Assert.Equal("new", File.ReadAllText(temp.File("stored.json")));
|
||||
Assert.False(File.Exists(temp.File("new.tmp")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A reader that allows deletion does not stop the rename, and goes on reading the content it opened.</summary>
|
||||
[Fact]
|
||||
public void TryRenameOver_DestinationOpenByAReaderThatAllowsDeletion_ReplacesItUnderTheReader()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
File.WriteAllText(temp.File("stored.json"), "old content");
|
||||
File.WriteAllText(temp.File("new.tmp"), "new");
|
||||
using(var reader = new StreamReader(new FileStream(temp.File("stored.json"), FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete)))
|
||||
{
|
||||
Assert.True(FileSwap.TryRenameOver(temp.File("new.tmp"), temp.File("stored.json")));
|
||||
Assert.Equal("new", File.ReadAllText(temp.File("stored.json")));
|
||||
Assert.Equal("old content", reader.ReadToEnd());
|
||||
}
|
||||
Assert.Equal(["stored.json"], Directory.GetFiles(temp.Path).Select(file => Path.GetFileName(file)!), StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A handle that does not allow deletion refuses the rename with a sharing violation, and both files stay as they were.</summary>
|
||||
[Fact]
|
||||
public void TryRenameOver_DestinationOpenWithoutDeleteSharing_IsASharingViolation()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
File.WriteAllText(temp.File("stored.json"), "old");
|
||||
File.WriteAllText(temp.File("new.tmp"), "new");
|
||||
using(new FileStream(temp.File("stored.json"), FileMode.Open, FileAccess.Read, FileShare.Read))
|
||||
{
|
||||
IOException refused = Assert.Throws<IOException>(() => FileSwap.TryRenameOver(temp.File("new.tmp"), temp.File("stored.json")));
|
||||
Assert.Equal(unchecked((int)0x80070020), refused.HResult);
|
||||
}
|
||||
Assert.Equal("old", File.ReadAllText(temp.File("stored.json")));
|
||||
Assert.Equal("new", File.ReadAllText(temp.File("new.tmp")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A read-only destination is access denied, as it is for a managed move.</summary>
|
||||
[Fact]
|
||||
public void TryRenameOver_ReadOnlyDestination_IsAccessDenied()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
File.WriteAllText(temp.File("stored.json"), "old");
|
||||
File.WriteAllText(temp.File("new.tmp"), "new");
|
||||
File.SetAttributes(temp.File("stored.json"), FileAttributes.ReadOnly);
|
||||
try
|
||||
{
|
||||
Assert.Throws<UnauthorizedAccessException>(() => FileSwap.TryRenameOver(temp.File("new.tmp"), temp.File("stored.json")));
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.SetAttributes(temp.File("stored.json"), FileAttributes.Normal);
|
||||
}
|
||||
Assert.Equal("old", File.ReadAllText(temp.File("stored.json")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A source that does not exist is reported with the Windows error for a missing file.</summary>
|
||||
[Fact]
|
||||
public void TryRenameOver_MissingSource_IsAnIoFailure()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
File.WriteAllText(temp.File("stored.json"), "old");
|
||||
IOException missing = Assert.Throws<IOException>(() => FileSwap.TryRenameOver(temp.File("absent.tmp"), temp.File("stored.json")));
|
||||
Assert.Equal(unchecked((int)0x80070002), missing.HResult);
|
||||
Assert.Equal("old", File.ReadAllText(temp.File("stored.json")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A destination that does not exist yet is simply created by the rename.</summary>
|
||||
[Fact]
|
||||
public void TryRenameOver_NoDestinationYet_RenamesTheSource()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
File.WriteAllText(temp.File("new.tmp"), "new");
|
||||
Assert.True(FileSwap.TryRenameOver(temp.File("new.tmp"), temp.File("stored.json")));
|
||||
Assert.Equal("new", File.ReadAllText(temp.File("stored.json")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Only the errors a volume gives for an unimplemented rename mean "use another way"; every other error is a failure.</summary>
|
||||
[Theory]
|
||||
[InlineData(1, true)]
|
||||
[InlineData(50, true)]
|
||||
[InlineData(87, true)]
|
||||
[InlineData(0, false)]
|
||||
[InlineData(2, false)]
|
||||
[InlineData(5, false)]
|
||||
[InlineData(32, false)]
|
||||
[InlineData(17, false)]
|
||||
public void IsUnsupported_WindowsError_SeparatesAnUnimplementedRenameFromAFailure(int error, bool unsupported)
|
||||
=> Assert.Equal(unsupported, FileSwap.IsUnsupported(error));
|
||||
}
|
||||
Reference in new issue
Block a user