Release v4.3: speak status line (Tolk) + Logging tab with housekeeping

- New screen-reader hotkey "Speak the RemSound status information" (issue #13):
  reads the status line aloud through the active screen reader via Tolk, fires from
  anywhere (system-wide), unset by default. Built behind an IScreenReaderOutput seam
  so a future build can swap Tolk for Prism on Windows 10+ without touching callers.
  Tolk DLLs vendored under tolk/ and shipped next to the exe.
- New Logging tab in Preferences: Enable logs + Write logs now moved there, plus
  opt-in startup "warn if logs folder exceeds N MB" and "delete logs older than N days",
  and a "Delete all logs" button (Yes/No confirm). New LogMaintenance helper + AppConfig
  settings drive it.
- Manual (readme.html + regenerated MANUAL.md), About changelog and RELEASE_NOTES
  updated in plain English; csproj <Version> bumped to 4.3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-06-19 13:09:06 +01:00
co-authored by Claude Opus 4.8
parent 034a7de632
commit fd5c31740a
20 changed files with 783 additions and 26 deletions
+25
View File
@@ -20,6 +20,31 @@ internal sealed class AboutDialog : Form
/// updates" path.</summary>
private const string ReleaseNotes =
"""
RemSound v4.3
Two additions for screen-reader users, and a
tidier home for logging.
You can now hear the RemSound status line on
demand. In the Keyboard shortcuts dialog set a
key for "Speak the RemSound status information",
and pressing it reads the whole status line aloud
through your screen reader how long you've been
connected, your peers, whether sound is flowing,
and how healthy the link is from anywhere, even
with RemSound in the tray. It's there for the
times your screen reader can't read the status
line itself. Unset to begin with, so you pick the
key.
Logging now has its own tab in Preferences, and it
can keep its own folder tidy: warn you at startup
if the logs folder grows past a size you choose,
automatically delete logs older than a number of
days you set, and a "Delete all logs" button to
clear them out at once. All three are off unless
you turn them on.
RemSound v4.2
Three fixes, one of them a real annoyance gone.
+24
View File
@@ -0,0 +1,24 @@
namespace RemSound.App;
/// <summary>
/// Abstraction over a screen-reader speech backend, so RemSound can speak text straight to whatever
/// screen reader is running (NVDA, JAWS, SAPI, ...). Needed for feedback the screen reader can't
/// otherwise observe — most importantly a GLOBAL hotkey firing while RemSound isn't focused, where
/// NVDA reads nothing on its own.
///
/// Kept as an interface purely so the concrete backend can be swapped later without touching callers.
/// Today the only implementation is <see cref="TolkScreenReaderOutput"/>, which works on every
/// Windows version RemSound supports (including Windows 7). Prism (evaluated 2026-06-19) is the more
/// modern option but hard-requires Windows 10+, so it can't replace Tolk while Win7 is supported — if
/// that changes, add a PrismScreenReaderOutput and pick it per-OS in <see cref="ScreenReader"/>.
/// </summary>
internal interface IScreenReaderOutput
{
/// <summary>Speak <paramref name="text"/> through the active screen reader. <paramref name="interrupt"/>
/// true cuts off whatever it's currently saying. Returns true if the text was handed to a screen
/// reader, false if none is available. Best-effort — never throws.</summary>
bool Speak(string text, bool interrupt = true);
/// <summary>Release the backend. Safe to call more than once.</summary>
void Shutdown();
}
+85
View File
@@ -0,0 +1,85 @@
using RemSound.Core;
namespace RemSound.App;
/// <summary>
/// Housekeeping for the <c>logs\</c> folder, driven by the Logging-tab preferences: report the
/// folder's total size (for the startup over-size warning), prune log files older than N days, and
/// delete every log on demand. All operations are best-effort — a locked or vanished file is
/// skipped, never thrown, because log housekeeping must never stop the app from running.
/// </summary>
internal static class LogMaintenance
{
/// <summary>Total size in bytes of every <c>*.log</c> file in the logs folder. 0 if the folder
/// doesn't exist yet or can't be read.</summary>
public static long LogsFolderSizeBytes()
{
try
{
var dir = AppConfig.LogsDirectory;
if (!Directory.Exists(dir)) return 0;
long total = 0;
foreach (var file in Directory.EnumerateFiles(dir, "*.log"))
{
try { total += new FileInfo(file).Length; } catch { /* vanished mid-scan — skip */ }
}
return total;
}
catch { return 0; }
}
/// <summary>Delete <c>*.log</c> files last written more than <paramref name="days"/> days ago.
/// The currently-open log (<paramref name="activeLogPath"/>) is always spared even if the clock
/// makes it look old. Returns how many files were deleted.</summary>
public static int PruneLogsOlderThan(int days, string? activeLogPath)
{
if (days < 1) return 0;
var deleted = 0;
try
{
var dir = AppConfig.LogsDirectory;
if (!Directory.Exists(dir)) return 0;
var cutoff = DateTime.Now.AddDays(-days);
foreach (var file in Directory.EnumerateFiles(dir, "*.log"))
{
if (IsSamePath(file, activeLogPath)) continue;
try
{
if (File.GetLastWriteTime(file) < cutoff)
{
File.Delete(file);
deleted++;
}
}
catch { /* locked / in use — leave it */ }
}
}
catch { /* folder enumeration failed — give up quietly */ }
return deleted;
}
/// <summary>Delete every <c>*.log</c> file except the one currently being written
/// (<paramref name="activeLogPath"/>, which is held open and can't be removed anyway). Returns
/// how many files were deleted.</summary>
public static int DeleteAllLogs(string? activeLogPath)
{
var deleted = 0;
try
{
var dir = AppConfig.LogsDirectory;
if (!Directory.Exists(dir)) return 0;
foreach (var file in Directory.EnumerateFiles(dir, "*.log"))
{
if (IsSamePath(file, activeLogPath)) continue;
try { File.Delete(file); deleted++; }
catch { /* locked / in use — leave it */ }
}
}
catch { /* give up quietly */ }
return deleted;
}
private static bool IsSamePath(string a, string? b) =>
!string.IsNullOrEmpty(b)
&& string.Equals(Path.GetFullPath(a), Path.GetFullPath(b), StringComparison.OrdinalIgnoreCase);
}
+70 -1
View File
@@ -716,7 +716,10 @@ public sealed class MainForm : Form
() => SendRemoteControl(RemoteControlKind.SystemVolumeUp, 0),
() => SendRemoteControl(RemoteControlKind.SystemVolumeDown, 0),
() => SendRemoteControl(RemoteControlKind.SystemMuteToggle, 0),
ShowQuickProfileSwitch);
ShowQuickProfileSwitch,
// Speak the status line aloud through the active screen reader (issue #13). Screen-reader
// specific; the global hotkey is unset by default (the user binds it in Keyboard shortcuts).
SpeakStatusLine);
// Pipe hotkey controller diagnostics into the main log so we can see, e.g.,
// "capture send-system-volume-down: OK = Ctrl+Shift+Alt+J" and
// "register send-system-volume-down: FAILED = Ctrl+Shift+Alt+J (Win32 error 1409:
@@ -1235,6 +1238,7 @@ public sealed class MainForm : Form
}
hotkeyController.Dispose();
ScreenReader.Shutdown();
trayController.Dispose();
logFile.Dispose();
};
@@ -1379,11 +1383,54 @@ public sealed class MainForm : Form
if (IsDisposed) return;
MaybeShowWhatsNewAfterUpdate();
if (IsDisposed) return;
MaybeRunLogHousekeeping();
if (IsDisposed) return;
MaybeWarnAboutRealtekAsio();
if (IsDisposed) return;
MaybeWarnMicBlockedOnStartup();
}
/// <summary>Startup log-folder housekeeping driven by the Logging-tab preferences. Both steps are
/// opt-in (off by default): first prune logs older than the configured age, then warn if the
/// folder still exceeds the configured size. Best-effort — failures never block launch.</summary>
private void MaybeRunLogHousekeeping()
{
if (IsDisposed) return;
AppConfig cfg;
try { cfg = AppConfig.Load(); }
catch { return; }
if (cfg.PruneOldLogs)
{
var removed = LogMaintenance.PruneLogsOlderThan(cfg.PruneOldLogsDays, logFile.Path);
if (removed > 0)
logFile.Event($"log housekeeping: pruned {removed} log(s) older than {cfg.PruneOldLogsDays} day(s)");
}
if (cfg.WarnIfLogsFolderExceeds)
{
var bytes = LogMaintenance.LogsFolderSizeBytes();
var limitBytes = (long)cfg.LogsFolderWarnThresholdMb * 1024 * 1024;
if (bytes > limitBytes)
{
var mb = bytes / (1024.0 * 1024.0);
logFile.Event($"log housekeeping: logs folder {mb:0.#} MB exceeds {cfg.LogsFolderWarnThresholdMb} MB threshold — warning user");
var page = new TaskDialogPage
{
Caption = "RemSound",
Heading = "Logs folder is getting large",
Text = $"The RemSound logs folder is using about {mb:0} MB, which is over your {cfg.LogsFolderWarnThresholdMb} MB warning size.\n\n"
+ "You can clear old logs from Preferences, on the Logging tab.",
Icon = TaskDialogIcon.Warning,
Buttons = { TaskDialogButton.OK },
AllowCancel = true,
};
try { ForegroundDialog.Show(owner => TaskDialog.ShowDialog(owner, page)); }
catch (Exception ex) { logFile.Event($"log housekeeping: warn dialog failed: {ex.GetType().Name}: {ex.Message}"); }
}
}
}
/// <summary>If the user opted in (<see cref="AppConfig.ShowWhatsNewAfterUpdate"/>) and the
/// running version changed since the last launch we recorded, open the About box once so
/// they see what changed in the update just installed. Always records the current version
@@ -2242,6 +2289,13 @@ public sealed class MainForm : Form
UpdateDiagnosticsGate();
},
writeLogsNow: () => logFile.Event("user requested write logs now"),
deleteAllLogs: () =>
{
// Spare the log we're currently writing (it's held open and can't be removed anyway).
var removed = LogMaintenance.DeleteAllLogs(logFile.Path);
logFile.Event($"user deleted all logs from Preferences ({removed} file(s) removed)");
return removed;
},
checkForUpdatesNow: () => CheckForUpdatesManually(),
onUpdateFrequencyChanged: ApplyUpdateCheckTimer,
applyUpnpEnabled: enabled =>
@@ -3130,6 +3184,21 @@ public sealed class MainForm : Form
statusReadout.Text = text;
}
/// <summary>Speak the current connection status line aloud through the active screen reader, via
/// Tolk (<see cref="ScreenReader"/>). Answers issue #13: NVDA sometimes can't see the status
/// readout ("no status line found"), so this reads it on demand. Triggered only by a user-set
/// global hotkey (screen-reader only, unset by default) — being a system-wide hotkey it works from
/// anywhere, whether or not RemSound is focused. Newlines become ". " so the multi-line readout
/// speaks as a sentence rather than running together.</summary>
private void SpeakStatusLine()
{
var text = statusReadout.Text;
text = string.IsNullOrWhiteSpace(text)
? "No status information available."
: text.Replace("\r\n", ". ").Replace("\n", ". ");
ScreenReader.Speak(text);
}
private string ComputeStatusText()
{
// Compute byte-rates from delta since last sample. First call has no baseline so
+28 -1
View File
@@ -31,6 +31,10 @@ internal sealed class MainFormHotkeyController : IDisposable
private readonly Action sendSystemVolumeDown;
private readonly Action sendSystemMuteToggle;
private readonly Action quickProfileSwitch;
// Speak the connection status line aloud through the active screen reader (Tolk). Screen-reader
// specific (issue #13); unset by default. Global so it reads the status even when RemSound isn't
// focused — the case NVDA can't otherwise cover.
private readonly Action speakStatusLine;
private Form? owner;
private HotkeyInfo sendMuteHotkey;
private HotkeyInfo receiveMuteHotkey;
@@ -45,6 +49,7 @@ internal sealed class MainFormHotkeyController : IDisposable
private HotkeyInfo systemVolumeDownHotkey;
private HotkeyInfo systemMuteToggleHotkey;
private HotkeyInfo quickProfileSwitchHotkey;
private HotkeyInfo speakStatusLineHotkey;
private GlobalHotkey? sendMuteGlobalHotkey;
private GlobalHotkey? receiveMuteGlobalHotkey;
private GlobalHotkey? trayGlobalHotkey;
@@ -58,6 +63,7 @@ internal sealed class MainFormHotkeyController : IDisposable
private GlobalHotkey? systemVolumeDownGlobalHotkey;
private GlobalHotkey? systemMuteToggleGlobalHotkey;
private GlobalHotkey? quickProfileSwitchGlobalHotkey;
private GlobalHotkey? speakStatusLineGlobalHotkey;
/// <summary>Optional log sink. MainForm wires this to <c>logFile.Event(...)</c> so each
/// hotkey change writes a clear trail of "user opened capture", "captured X", "registered X
@@ -87,7 +93,8 @@ internal sealed class MainFormHotkeyController : IDisposable
Action sendSystemVolumeUp,
Action sendSystemVolumeDown,
Action sendSystemMuteToggle,
Action quickProfileSwitch)
Action quickProfileSwitch,
Action speakStatusLine)
{
this.settingsStore = settingsStore;
this.toggleSend = toggleSend;
@@ -103,6 +110,7 @@ internal sealed class MainFormHotkeyController : IDisposable
this.sendSystemVolumeDown = sendSystemVolumeDown;
this.sendSystemMuteToggle = sendSystemMuteToggle;
this.quickProfileSwitch = quickProfileSwitch;
this.speakStatusLine = speakStatusLine;
sendMuteHotkey = settingsStore.LoadSendMuteHotkey();
receiveMuteHotkey = settingsStore.LoadReceiveMuteHotkey();
trayHotkey = settingsStore.LoadTrayHotkey();
@@ -116,6 +124,7 @@ internal sealed class MainFormHotkeyController : IDisposable
systemVolumeDownHotkey = settingsStore.LoadSystemVolumeDownHotkey();
systemMuteToggleHotkey = settingsStore.LoadSystemMuteToggleHotkey();
quickProfileSwitchHotkey = settingsStore.LoadQuickProfileSwitchHotkey();
speakStatusLineHotkey = settingsStore.LoadSpeakStatusLineHotkey();
}
public void Initialize(Form ownerForm)
@@ -134,6 +143,7 @@ internal sealed class MainFormHotkeyController : IDisposable
systemVolumeDownGlobalHotkey = new GlobalHotkey(ownerForm);
systemMuteToggleGlobalHotkey = new GlobalHotkey(ownerForm);
quickProfileSwitchGlobalHotkey = new GlobalHotkey(ownerForm);
speakStatusLineGlobalHotkey = new GlobalHotkey(ownerForm);
sendMuteGlobalHotkey.Pressed += () => InvokeOnOwner(toggleSend);
receiveMuteGlobalHotkey.Pressed += () => InvokeOnOwner(toggleReceive);
trayGlobalHotkey.Pressed += () => InvokeOnOwner(toggleTray);
@@ -147,6 +157,7 @@ internal sealed class MainFormHotkeyController : IDisposable
systemVolumeDownGlobalHotkey.Pressed += () => InvokeOnOwner(sendSystemVolumeDown);
systemMuteToggleGlobalHotkey.Pressed += () => InvokeOnOwner(sendSystemMuteToggle);
quickProfileSwitchGlobalHotkey.Pressed += () => InvokeOnOwner(quickProfileSwitch);
speakStatusLineGlobalHotkey.Pressed += () => InvokeOnOwner(speakStatusLine);
RegisterSendMuteHotkey();
RegisterReceiveMuteHotkey();
RegisterTrayHotkey();
@@ -160,6 +171,7 @@ internal sealed class MainFormHotkeyController : IDisposable
RegisterSystemVolumeDownHotkey();
RegisterSystemMuteToggleHotkey();
RegisterQuickProfileSwitchHotkey();
RegisterSpeakStatusLineHotkey();
}
public void ShowKeyboardShortcutsDialog(IWin32Window dialogOwner)
@@ -269,6 +281,7 @@ internal sealed class MainFormHotkeyController : IDisposable
list.Items.Add($"Send Windows global volume down to peers: {systemVolumeDownHotkey}");
list.Items.Add($"Send Windows global mute toggle to peers: {systemMuteToggleHotkey}");
list.Items.Add($"Quick profile switch (open a list of all profiles): {quickProfileSwitchHotkey}");
list.Items.Add($"Speak the RemSound status information from anywhere (screen reader only): {speakStatusLineHotkey}");
if (prev >= 0 && prev < list.Items.Count)
{
list.SelectedIndex = prev;
@@ -306,6 +319,7 @@ internal sealed class MainFormHotkeyController : IDisposable
case 10: ChangeSystemVolumeDownHotkey(dialog); break;
case 11: ChangeSystemMuteToggleHotkey(dialog); break;
case 12: ChangeQuickProfileSwitchHotkey(dialog); break;
case 13: ChangeSpeakStatusLineHotkey(dialog); break;
default: return;
}
RefreshList();
@@ -338,6 +352,7 @@ internal sealed class MainFormHotkeyController : IDisposable
case 10: ApplyUnset("send-system-volume-down", h => systemVolumeDownHotkey = h, RegisterSystemVolumeDownHotkey, settingsStore.SaveSystemVolumeDownHotkey); break;
case 11: ApplyUnset("send-system-mute-toggle", h => systemMuteToggleHotkey = h, RegisterSystemMuteToggleHotkey, settingsStore.SaveSystemMuteToggleHotkey); break;
case 12: ApplyUnset("quick-profile-switch", h => quickProfileSwitchHotkey = h, RegisterQuickProfileSwitchHotkey, settingsStore.SaveQuickProfileSwitchHotkey); break;
case 13: ApplyUnset("speak-status-line", h => speakStatusLineHotkey = h, RegisterSpeakStatusLineHotkey, settingsStore.SaveSpeakStatusLineHotkey); break;
default: return;
}
RefreshList();
@@ -418,6 +433,7 @@ internal sealed class MainFormHotkeyController : IDisposable
systemVolumeDownGlobalHotkey?.Dispose();
systemMuteToggleGlobalHotkey?.Dispose();
quickProfileSwitchGlobalHotkey?.Dispose();
speakStatusLineGlobalHotkey?.Dispose();
}
public HotkeyInfo SendMuteHotkey => sendMuteHotkey;
@@ -433,6 +449,7 @@ internal sealed class MainFormHotkeyController : IDisposable
public HotkeyInfo SystemVolumeDownHotkey => systemVolumeDownHotkey;
public HotkeyInfo SystemMuteToggleHotkey => systemMuteToggleHotkey;
public HotkeyInfo QuickProfileSwitchHotkey => quickProfileSwitchHotkey;
public HotkeyInfo SpeakStatusLineHotkey => speakStatusLineHotkey;
/// <summary>Open the capture dialog, log what came back, and (on a successful capture)
/// run <paramref name="apply"/> with the captured hotkey. Centralises the boilerplate
@@ -571,6 +588,13 @@ internal sealed class MainFormHotkeyController : IDisposable
settingsStore.SaveQuickProfileSwitchHotkey(h);
});
private void ChangeSpeakStatusLineHotkey(IWin32Window dialogOwner) => ChangeHotkey(dialogOwner, "speak-status-line", h =>
{
speakStatusLineHotkey = h;
RegisterSpeakStatusLineHotkey();
settingsStore.SaveSpeakStatusLineHotkey(h);
});
// Hotkeys come in two flavours and need different Windows-side registration:
// * Toggle hotkeys (mute, tray show/hide) — re-firing on hold would flip state back
// and forth. Registered with MOD_NOREPEAT (allowRepeat=false). One press, one fire.
@@ -599,6 +623,9 @@ internal sealed class MainFormHotkeyController : IDisposable
// Quick profile switch is a one-shot (press → open the popup); MOD_NOREPEAT (the default) keeps
// a held key from re-opening it repeatedly.
private void RegisterQuickProfileSwitchHotkey() => RegisterIfSet(quickProfileSwitchGlobalHotkey, quickProfileSwitchHotkey, "quick profile switch");
// Speak status line is a one-shot (press → read the status aloud once); MOD_NOREPEAT (the default)
// keeps a held key from re-triggering the speech over and over.
private void RegisterSpeakStatusLineHotkey() => RegisterIfSet(speakStatusLineGlobalHotkey, speakStatusLineHotkey, "speak status line");
private void RegisterIfSet(GlobalHotkey? globalHotkey, HotkeyInfo hotkey, string description, bool allowRepeat = false)
{
+152 -3
View File
@@ -293,6 +293,60 @@ internal sealed class PreferencesDialog : Form
AutoSize = true,
};
// --- Logging tab: log-folder housekeeping (2026-06-19). All machine-local (AppConfig). The two
// spinners are greyed out until their enabling checkbox is ticked. Mnemonics on this tab: L, W,
// S, M, D, Y, A — all distinct (tab scope is per-page, so reuse elsewhere is fine). ---
private readonly AccessibleCheckBox warnIfLogsExceedBox = new()
{
Text = "Warn at startup if the logs folder is larger than (Alt+&S)",
AccessibleName = "Warn at startup if the logs folder is larger than",
AutoSize = true,
};
private readonly NumericUpDown logsSizeLimitBox = new()
{
Minimum = 1,
Maximum = 100000,
Increment = 10,
Value = 100,
Width = 90,
AccessibleName = "Warn when the logs folder is larger than this many megabytes (Alt+M)",
};
private readonly MnemonicLabel logsSizeUnitLabel = new()
{
Text = "&megabytes",
AutoSize = true,
Anchor = AnchorStyles.Left,
Padding = new Padding(6, 6, 0, 0),
};
private readonly AccessibleCheckBox pruneOldLogsBox = new()
{
Text = "Delete logs older than (Alt+&D)",
AccessibleName = "Delete logs older than",
AutoSize = true,
};
private readonly NumericUpDown pruneDaysBox = new()
{
Minimum = 1,
Maximum = 30,
Increment = 1,
Value = 14,
Width = 70,
AccessibleName = "Delete logs older than this many days (Alt+Y)",
};
private readonly MnemonicLabel pruneDaysUnitLabel = new()
{
Text = "da&ys old",
AutoSize = true,
Anchor = AnchorStyles.Left,
Padding = new Padding(6, 6, 0, 0),
};
private readonly Button deleteAllLogsButton = new()
{
Text = "Delete &all logs",
AccessibleName = "Delete all logs",
AutoSize = true,
};
// Startup behaviour (moved here from the Options-menu StartupBehaviourDialog, 2026-06-13). These
// live on their own tab; their Alt-letters are isolated per tab so reusing M/A/P/L is fine.
private readonly AccessibleCheckBox startMinimisedBox = new()
@@ -353,6 +407,7 @@ internal sealed class PreferencesDialog : Form
Func<bool> getLoggingEnabled,
Action<bool> applyLoggingEnabled,
Action writeLogsNow,
Func<int> deleteAllLogs,
Action checkForUpdatesNow,
Action onUpdateFrequencyChanged,
Action<bool> applyUpnpEnabled,
@@ -585,6 +640,70 @@ internal sealed class PreferencesDialog : Form
writeLogsNowButton.Click += (_, _) => writeLogsNow();
// --- Logging housekeeping (machine-local; each control writes through to AppConfig on
// change, like the Update-settings controls above). The two spinners follow their
// checkbox's enabled state so they're only editable when the feature is on. ---
warnIfLogsExceedBox.Checked = cfgForLoad.WarnIfLogsFolderExceeds;
logsSizeLimitBox.Value = Math.Clamp(cfgForLoad.LogsFolderWarnThresholdMb, (int)logsSizeLimitBox.Minimum, (int)logsSizeLimitBox.Maximum);
logsSizeLimitBox.Enabled = warnIfLogsExceedBox.Checked;
logsSizeUnitLabel.MnemonicTarget = logsSizeLimitBox;
warnIfLogsExceedBox.CheckedChanged += (_, _) =>
{
var cfg = AppConfig.Load();
cfg.WarnIfLogsFolderExceeds = warnIfLogsExceedBox.Checked;
TrySaveConfig(cfg);
logsSizeLimitBox.Enabled = warnIfLogsExceedBox.Checked;
};
logsSizeLimitBox.ValueChanged += (_, _) =>
{
var cfg = AppConfig.Load();
cfg.LogsFolderWarnThresholdMb = (int)logsSizeLimitBox.Value;
TrySaveConfig(cfg);
};
pruneOldLogsBox.Checked = cfgForLoad.PruneOldLogs;
pruneDaysBox.Value = Math.Clamp(cfgForLoad.PruneOldLogsDays, (int)pruneDaysBox.Minimum, (int)pruneDaysBox.Maximum);
pruneDaysBox.Enabled = pruneOldLogsBox.Checked;
pruneDaysUnitLabel.MnemonicTarget = pruneDaysBox;
pruneOldLogsBox.CheckedChanged += (_, _) =>
{
var cfg = AppConfig.Load();
cfg.PruneOldLogs = pruneOldLogsBox.Checked;
TrySaveConfig(cfg);
pruneDaysBox.Enabled = pruneOldLogsBox.Checked;
};
pruneDaysBox.ValueChanged += (_, _) =>
{
var cfg = AppConfig.Load();
cfg.PruneOldLogsDays = (int)pruneDaysBox.Value;
TrySaveConfig(cfg);
};
deleteAllLogsButton.Click += (_, _) =>
{
var confirm = new TaskDialogPage
{
Caption = "RemSound",
Heading = "Delete all logs?",
Text = "This permanently deletes every log file in the logs folder except the one currently in use. This cannot be undone.",
Icon = TaskDialogIcon.Warning,
Buttons = { TaskDialogButton.Yes, TaskDialogButton.No },
DefaultButton = TaskDialogButton.No,
AllowCancel = true,
};
if (TaskDialog.ShowDialog(this, confirm) != TaskDialogButton.Yes) return;
var removed = deleteAllLogs();
var done = new TaskDialogPage
{
Caption = "RemSound",
Heading = "Logs deleted",
Text = removed == 1 ? "Deleted 1 log file." : $"Deleted {removed} log files.",
Icon = TaskDialogIcon.Information,
Buttons = { TaskDialogButton.OK },
};
TaskDialog.ShowDialog(this, done);
};
closeButton.Click += (_, _) => Close();
// Wire up the Startup behaviour tab (moved here from the old Options-menu dialog).
@@ -650,16 +769,46 @@ internal sealed class PreferencesDialog : Form
startupListPanel.Controls.Add(startupProfileListLabel);
startupListPanel.Controls.Add(startupProfileList);
// Four tabs, accessible (QuietTabControl) like the main window. Ctrl+Tab / arrows on the
// Logging tab rows: each spinner sits with its unit label on its own indented row beneath the
// checkbox that enables it (number box first, then the "megabytes"/"days old" unit label —
// matching the natural "...larger than [100] megabytes" reading order).
var logsSizeRow = new FlowLayoutPanel
{
AutoSize = true,
Dock = DockStyle.Fill,
FlowDirection = FlowDirection.LeftToRight,
WrapContents = false,
Padding = new Padding(20, 2, 0, 0),
};
logsSizeRow.Controls.Add(logsSizeLimitBox);
logsSizeRow.Controls.Add(logsSizeUnitLabel);
var pruneDaysRow = new FlowLayoutPanel
{
AutoSize = true,
Dock = DockStyle.Fill,
FlowDirection = FlowDirection.LeftToRight,
WrapContents = false,
Padding = new Padding(20, 2, 0, 0),
};
pruneDaysRow.Controls.Add(pruneDaysBox);
pruneDaysRow.Controls.Add(pruneDaysUnitLabel);
// Five tabs, accessible (QuietTabControl) like the main window. Ctrl+Tab / arrows on the
// strip switch tabs; the active page's controls are the next tab stops. The control itself
// is a field (declared above) so OnShown can focus it when the dialog opens.
// is a field (declared above) so OnShown can focus it when the dialog opens. Logging is its
// own tab (2026-06-19); the two logging controls moved off the General tab to lead it.
tabs.TabPages.Add(MakeTab("General",
browseProfilesFolderButton, acceptRemoteVolumeBox, upnpEnabledBox, upnpStatusLabel, loggingBox, writeLogsNowButton));
browseProfilesFolderButton, acceptRemoteVolumeBox, upnpEnabledBox, upnpStatusLabel));
tabs.TabPages.Add(MakeTab("Audio cues", cueGroup));
tabs.TabPages.Add(MakeTab("Startup behaviour",
startMinimisedBox, startWithUserBox, startWithProfileBox, startupListPanel));
tabs.TabPages.Add(MakeTab("Update settings",
checkForUpdatesOnStartupBox, freqRow, checkForUpdatesNowButton, silentlyInstallUpdatesBox, showWhatsNewAfterUpdateBox));
tabs.TabPages.Add(MakeTab("Logging",
loggingBox, writeLogsNowButton,
warnIfLogsExceedBox, logsSizeRow,
pruneOldLogsBox, pruneDaysRow,
deleteAllLogsButton));
var buttons = new FlowLayoutPanel
{
+22 -1
View File
@@ -14,7 +14,7 @@
tag_name on the latest GitHub release; bump it on every public release. The
AssemblyVersion / FileVersion default to this value, and Assembly.GetName().Version
is what the About dialog and the updater both read. -->
<Version>4.2</Version>
<Version>4.3</Version>
</PropertyGroup>
<ItemGroup>
@@ -79,6 +79,17 @@
<Link>readme.html</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<!-- Tolk screen-reader output DLLs (vendored 2026-06-19). Native libraries P/Invoked by
TolkScreenReaderOutput so RemSound can speak the status line aloud on demand (issue #13) —
needed because a GLOBAL hotkey fires while RemSound isn't focused, where NVDA won't read
anything itself. Tolk.dll loads its two helper DLLs from the same folder, so all three ship
flat next to the exe. Copied on build AND publish; the EnsureTolkDllsPublished target below
makes the publish copy marker-independent (the same belt-and-braces the cue WAVs use). -->
<Content Include="..\..\tolk\Tolk.dll;..\..\tolk\nvdaControllerClient64.dll;..\..\tolk\SAAPI64.dll;..\..\tolk\LICENSE-Tolk.txt">
<Link>%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
</ItemGroup>
<!-- Guarantee the cue WAVs land in a PUBLISHED build. The Content items above use
@@ -97,4 +108,14 @@
DestinationFolder="$(PublishDir)default sounds"
SkipUnchangedFiles="false" />
</Target>
<!-- Same marker-independent guarantee for the Tolk DLLs: always copy them into a PUBLISHED build
so a release zip can never ship without the screen-reader libraries the "speak status line"
feature needs. -->
<Target Name="EnsureTolkDllsPublished" AfterTargets="Publish">
<ItemGroup>
<_TolkFiles Include="..\..\tolk\Tolk.dll;..\..\tolk\nvdaControllerClient64.dll;..\..\tolk\SAAPI64.dll;..\..\tolk\LICENSE-Tolk.txt" />
</ItemGroup>
<Copy SourceFiles="@(_TolkFiles)" DestinationFolder="$(PublishDir)" SkipUnchangedFiles="false" />
</Target>
</Project>
+45
View File
@@ -0,0 +1,45 @@
namespace RemSound.App;
/// <summary>
/// Process-wide screen-reader speech. Used for feedback the screen reader can't otherwise observe —
/// chiefly the "speak the status line" hotkey (GitHub issue #13), which must read aloud even when the
/// global hotkey fires while RemSound isn't the focused window.
///
/// Holds a single backend, created once on first use. Today that is always Tolk, which works on every
/// Windows version RemSound supports — including Windows 7. The interface seam exists so a future build
/// can choose a different backend per OS without changing any caller: branch inside
/// <see cref="CreateBackend"/>. (Prism is the modern successor to Tolk but requires Windows 10+, so it
/// can't be the default while Win7 is supported — see <see cref="IScreenReaderOutput"/>.)
/// </summary>
internal static class ScreenReader
{
private static readonly object sync = new();
private static IScreenReaderOutput? backend;
private static IScreenReaderOutput Backend
{
get { lock (sync) { return backend ??= CreateBackend(); } }
}
private static IScreenReaderOutput CreateBackend()
{
// Win7-safe default. To adopt Prism on Windows 10+ later, branch on Environment.OSVersion
// here and return a PrismScreenReaderOutput when the OS is >= Windows 10 — callers below stay
// exactly as they are.
return new TolkScreenReaderOutput();
}
/// <summary>Speak text through the active screen reader (best-effort; silent if none is running).
/// Returns true if it reached a screen reader.</summary>
public static bool Speak(string text, bool interrupt = true) => Backend.Speak(text, interrupt);
/// <summary>Release the backend on app shutdown. Safe to call when nothing was ever spoken.</summary>
public static void Shutdown()
{
lock (sync)
{
backend?.Shutdown();
backend = null;
}
}
}
+1 -1
View File
@@ -335,7 +335,7 @@ internal static class SelfTest
("Recording settings", () => new RecordingSettingsDialog(new RecordingSettings())),
("Preferences", () => new PreferencesDialog(
new RemSoundSettingsStore("RemSound"), null,
() => false, _ => { }, () => { }, () => { }, () => { }, _ => { },
() => false, _ => { }, () => { }, () => 0, () => { }, () => { }, _ => { },
() => (default(RouterMappingStatus), (IPEndPoint?)null, ""),
_ => { }, _ => { })),
};
@@ -0,0 +1,70 @@
using System.Runtime.InteropServices;
namespace RemSound.App;
/// <summary>
/// Tolk-backed <see cref="IScreenReaderOutput"/>. Tolk (https://github.com/dkager/tolk, LGPL-3.0) is a
/// screen-reader abstraction DLL that auto-detects whichever reader is running (NVDA, JAWS, Window-Eyes,
/// SuperNova, System Access, ZoomText) and falls back to SAPI, then routes speech to it. The native
/// <c>Tolk.dll</c> plus its helpers (<c>nvdaControllerClient64.dll</c>, <c>SAAPI64.dll</c>) ship flat
/// next to the exe (see the .csproj Content items) — Tolk.dll loads those helpers from the same folder.
///
/// Loaded lazily on the first <see cref="Speak"/> and never re-attempted once it fails, so a missing DLL
/// or absent screen reader just means silence, never an exception. All entry points are wrapped in
/// try/catch and guarded by a lock, so it's safe to call from any thread (e.g. a hotkey callback).
/// </summary>
internal sealed class TolkScreenReaderOutput : IScreenReaderOutput
{
private readonly object sync = new();
private bool loadAttempted;
private bool loaded;
public bool Speak(string text, bool interrupt = true)
{
if (string.IsNullOrWhiteSpace(text)) return false;
if (!EnsureLoaded()) return false;
try { return Tolk_Output(text, interrupt); }
catch { return false; }
}
public void Shutdown()
{
lock (sync)
{
if (!loaded) return;
try { Tolk_Unload(); } catch { /* best-effort */ }
loaded = false;
loadAttempted = false;
}
}
/// <summary>Load Tolk once. Succeeds only if Tolk loads AND reports a working speech channel, so a
/// machine with the DLLs present but no screen reader running stays silent instead of half-init'd.</summary>
private bool EnsureLoaded()
{
lock (sync)
{
if (loaded) return true;
if (loadAttempted) return false; // already tried and failed — don't spam load attempts
loadAttempted = true;
try { loaded = Tolk_Load() && Tolk_HasSpeech(); }
catch { loaded = false; }
return loaded;
}
}
[DllImport("Tolk.dll", CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool Tolk_Load();
[DllImport("Tolk.dll")]
private static extern void Tolk_Unload();
[DllImport("Tolk.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool Tolk_HasSpeech();
[DllImport("Tolk.dll", CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool Tolk_Output(string text, [MarshalAs(UnmanagedType.Bool)] bool interrupt);
}