Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,16 +60,17 @@ After editing `.po` files, always run `compile-translations.ps1` to regenerate t
- Batch operations show a separate `ProgressDialog` with progress bar
- Supports drag & drop, Ctrl+V paste (file drops and bitmap clipboard), Delete key to remove

**`src/Sic/SettingsDialog.cs` + `SettingsDialog.Designer.cs`** — Settings form. Flat `TableLayoutPanel` layout with output folder (textbox + browse + clear), language dropdown, OK/Cancel.
**`src/Sic/SettingsDialog.cs` + `SettingsDialog.Designer.cs`** — Settings form. Flat `TableLayoutPanel` layout with output folder (textbox + browse + clear), language dropdown, confirm-exit checkbox, a "check for updates on startup" checkbox, a background update-frequency dropdown, and OK/Cancel. Exposes `UpdatePeriodicCheckChanged` so `MainWindow` can re-arm the background update loop live (without a restart) when the frequency changes.

### Utilities (`src/Sic/Utils/`)

- `Config.cs` — Static config manager using SharpConfig. Reads/writes `%APPDATA%/Oire/Sic/Sic.cfg` with a `[General]` section (Language, OutputFolder, LastInputFolder). Accepts `isGui` parameter to route errors to MessageBox (GUI) or stderr (CLI).
- `Config.cs` — Static config manager using SharpConfig. Reads/writes `%APPDATA%/Oire/Sic/Sic.cfg` with a `[General]` section (Language, OutputFolder, LastInputFolder, ConfirmExitWithQueue, CheckForUpdatesOnStartup, UpdateCheckInterval). Accepts `isGui` parameter to route errors to MessageBox (GUI) or stderr (CLI).
- `FileHelper.cs` — Cloud placeholder detection (OneDrive/SharePoint recall attributes) and image file enumeration with glob patterns.
- `Localization.cs` — Wraps GetText.NET with convenience methods: `_()`, `_n()`, `_p()`, `_pn()` for translations. Loads `.mo` files from the `locale/` folder relative to the executable. Falls back through language parents to `en-US`.
- `Constants/App.cs` — Application metadata, data folder paths (`%APPDATA%/Oire/Sic/`), file extensions.
- `Constants/ExitCode.cs` — CLI exit code constants (`Success`, `Error`, `Canceled`).
- `Constants/Logging.cs` — Log file paths and output templates. Logs go to `%APPDATA%/Oire/Sic/logs/`.
- `Enums/UpdateCheckInterval.cs` — Background update-check frequency (`Daily`, `EveryThreeDays`, `Weekly`, `Monthly`, `Never`). Declared most-frequent-first so `Daily` is the default zero value and the order matches the Settings combo box.

### Key dependencies

Expand Down Expand Up @@ -127,6 +128,8 @@ SIC! is an accessible image format converter primarily aimed at blind and low-co
- Output folder (default: `Converted` subfolder in the data directory)
- Language
- Confirm exit when images are in the queue
- Check for updates on startup (default: enabled) — a single silent check shortly after launch
- Background update-check frequency (`UpdateCheckInterval`: Daily / EveryThreeDays / Weekly / Monthly / Never; default Daily)

## Auto-Updates (NetSparkleUpdater)

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Built with accessibility in mind — screen-reader friendly with proper labels a
- **Filename conflict handling** — always asks: overwrite, rename (`_1` suffix), or skip
- **Cloud file detection** — warns about OneDrive/SharePoint placeholder files that haven't been downloaded yet
- **CLI mode** — headless conversion from the command line, no UI needed
- **Automatic updates** — checks for new versions in the background with Ed25519 signature verification
- **Automatic updates** — checks for new versions in the background with Ed25519 signature verification, with adjustable frequency (or off) in Settings
- **Portable mode** — place an empty `userdata` folder next to `Sic.exe` to keep all data alongside the executable
- **Localized** — English, German, French, Russian, Ukrainian
- **Accessible** — logical tab order, keyboard shortcuts, screen-reader friendly
Expand Down Expand Up @@ -105,6 +105,8 @@ Settings are stored in `%APPDATA%\Oire\Sic\Sic.cfg` (or `userdata\Sic.cfg` in po
- **Output folder** — where converted files are saved (default: `Converted` subfolder in the data directory)
- **Language** — UI language (default: system language)
- **Confirm exit** — warn when closing with images still in the queue (default: enabled)
- **Check for updates on startup** — perform a single silent update check shortly after launch (default: enabled)
- **Check for updates in the background** — how often to check for updates while running: once a day, every 3 days, once a week, once a month, or never (default: once a day)

### Portable Mode

Expand Down
41 changes: 39 additions & 2 deletions src/Sic/MainWindow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ public partial class MainWindow: Form {
private int _clipboardImageCount;
private readonly System.Windows.Forms.Timer _previewDebounceTimer = new() { Interval = 300 };
private readonly ObjectPropertiesStore _localizationStore = new();
private readonly UpdateService? _updateService;
// Created in OnShown (after the window handle exists, which NetSparkle's update UI needs).
// Disposed in MainWindow.Designer's Dispose.
private UpdateService? _updateService;

private sealed record BatchAddResult(List<ImageItem> Items, List<string> Errors, int SkippedPlaceholders);

Expand All @@ -30,11 +32,42 @@ public MainWindow() {
PopulateFormatComboBox();
UpdateMenuState();
UpdatePlaceholderState();
}

/// <summary>
/// Stands up the <see cref="UpdateService"/> once the window handle exists (NetSparkle's
/// update UI needs it) and applies the user's two update preferences: an optional one-shot
/// silent check now, and an optional background loop at the configured frequency. Both are
/// independently toggled in Settings. A check that can't reach the network is logged and
/// ignored — it never interrupts startup.
/// </summary>
protected override void OnShown(EventArgs e) {
base.OnShown(e);
InitializeUpdates();
}

private void InitializeUpdates() {
if (_updateService is not null) {
return;
}

try {
_updateService = new UpdateService();
} catch (Exception ex) {
Log.Error(ex, "Failed to initialize update service");
return;
}

_updateService.ConfigurePeriodicChecks(Config.General.UpdateCheckInterval);

if (Config.General.CheckForUpdatesOnStartup) {
// Fire-and-forget: CheckForUpdatesAsync swallows its own exceptions, so the
// discarded task can never surface a fault. announceNoUpdate: false keeps a
// "you're up to date" or offline result silent — only an available update pops UI.
// Named local instead of `_ = ...` because `_` is the GetText method here
// (using static Localization).
var discard = _updateService.CheckForUpdatesAsync(announceNoUpdate: false);
GC.KeepAlive(discard);
}
}

Expand Down Expand Up @@ -231,6 +264,10 @@ private void SettingsMenuItem_Click(object? sender, EventArgs e) {
using var dialog = new SettingsDialog();
dialog.ShowDialog(this);

if (dialog.UpdatePeriodicCheckChanged) {
_updateService?.ConfigurePeriodicChecks(Config.General.UpdateCheckInterval);
}

if (Config.General.Language != previousLanguage) {
ApplyLocalization();
}
Expand Down Expand Up @@ -623,7 +660,7 @@ private async void CheckForUpdatesMenuItem_Click(object? sender, EventArgs e) {
return;
}

await _updateService.CheckForUpdatesAsync();
await _updateService.CheckForUpdatesAsync(announceNoUpdate: true);
}

private void AboutMenuItem_Click(object? sender, EventArgs e) {
Expand Down
106 changes: 83 additions & 23 deletions src/Sic/Services/UpdateService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using NetSparkleUpdater.SignatureVerifiers;
using NetSparkleUpdater.UI.WinForms;
using Serilog;
using Oire.Sic.Utils.Enums;
using static Oire.Sic.Utils.Localization;
using App = Oire.Sic.Utils.Constants.App;

Expand All @@ -19,6 +20,8 @@ public void PrintMessage(string message, params object[]? arguments) {

internal sealed class UpdateService: IDisposable {
private readonly SparkleUpdater _sparkle;
private UpdateCheckInterval _loopInterval = UpdateCheckInterval.Never;
private bool _loopRunning;
private bool _disposed;

public UpdateService() {
Expand All @@ -29,17 +32,64 @@ public UpdateService() {
TmpDownloadFileNameWithExtension = $"sic-update-{Guid.NewGuid()}.exe",
};

Log.Information("UpdateService: Initialized with appcast URL {Url}", App.AppcastUrl);
}

/// <summary>
/// Starts, stops, or re-times the background update loop to match the user's
/// <c>Config.General.UpdateCheckInterval</c> preference. Idempotent: passing the same
/// interval twice is a no-op. A frequency change restarts the loop with the new period;
/// <see cref="UpdateCheckInterval.Never"/> stops it. The loop does no immediate check on
/// start — the startup check is a separate, independently-toggled concern
/// (see <see cref="CheckForUpdatesAsync"/>).
/// </summary>
public void ConfigurePeriodicChecks(UpdateCheckInterval interval) {
if (interval == _loopInterval) {
return;
}

try {
_sparkle.StartLoop(true, true, TimeSpan.FromHours(24));
// NetSparkle bakes the frequency in at StartLoop time, so a frequency change
// means stop-then-start, not just a re-arm.
if (_loopRunning) {
_sparkle.StopLoop();
_loopRunning = false;
}

if (ToFrequency(interval) is { } frequency) {
_sparkle.StartLoop(doInitialCheck: false, forceInitialCheck: false, frequency);
_loopRunning = true;
Log.Information("UpdateService: Periodic update checks every {Frequency}", frequency);
} else {
Log.Information("UpdateService: Periodic update checks disabled");
}

_loopInterval = interval;
} catch (Exception ex) {
Log.Error(ex, "UpdateService: Failed to start update loop");
Log.Error(ex, "UpdateService: Failed to configure periodic checks to {Interval}", interval);
}

Log.Information("UpdateService: Initialized with appcast URL {Url}", App.AppcastUrl);
}

public async Task CheckForUpdatesAsync() {
Log.Information("UpdateService: Manual update check requested");
/// <summary>Maps an interval to a loop period, or <c>null</c> for
/// <see cref="UpdateCheckInterval.Never"/> (loop disabled).</summary>
private static TimeSpan? ToFrequency(UpdateCheckInterval interval) => interval switch {
UpdateCheckInterval.Daily => TimeSpan.FromDays(1),
UpdateCheckInterval.EveryThreeDays => TimeSpan.FromDays(3),
UpdateCheckInterval.Weekly => TimeSpan.FromDays(7),
UpdateCheckInterval.Monthly => TimeSpan.FromDays(30),
_ => null,
};

/// <summary>
/// Checks the appcast once. When an update is available, the update UI is shown either way.
/// <paramref name="announceNoUpdate"/> controls the quiet outcomes: a manual check
/// (<c>true</c>) reports "up to date" / "previously skipped" / "couldn't check" in a
/// message box; a silent startup check (<c>false</c>) keeps those outcomes to the log only.
/// A failure (no network, server unreachable) is always caught and logged — it never
/// throws, never stops the app, and only shows an error box on a manual check.
/// </summary>
public async Task CheckForUpdatesAsync(bool announceNoUpdate) {
Log.Information("UpdateService: Update check requested (announceNoUpdate={Announce})", announceNoUpdate);

try {
var result = await _sparkle.CheckForUpdatesQuietly();
Expand All @@ -51,30 +101,40 @@ public async Task CheckForUpdatesAsync() {
_sparkle.ShowUpdateNeededUI(result!.Updates);
break;
case UpdateStatus.UpdateNotAvailable:
MessageBox.Show(
_("Your current version is up to date."),
_("Software Update"),
MessageBoxButtons.OK, MessageBoxIcon.Information);
if (announceNoUpdate) {
MessageBox.Show(
_("Your current version is up to date."),
_("Software Update"),
MessageBoxButtons.OK, MessageBoxIcon.Information);
}
break;
case UpdateStatus.UserSkipped:
MessageBox.Show(
_("The latest available update was previously skipped."),
_("Software Update"),
MessageBoxButtons.OK, MessageBoxIcon.Information);
if (announceNoUpdate) {
MessageBox.Show(
_("The latest available update was previously skipped."),
_("Software Update"),
MessageBoxButtons.OK, MessageBoxIcon.Information);
}
break;
case UpdateStatus.CouldNotDetermine:
MessageBox.Show(
_("Unable to check for updates. Please try again later."),
_("Software Update"),
MessageBoxButtons.OK, MessageBoxIcon.Warning);
if (announceNoUpdate) {
MessageBox.Show(
_("Unable to check for updates. Please try again later."),
_("Software Update"),
MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
break;
}
} catch (Exception ex) {
Log.Error(ex, "UpdateService: Update check failed");
MessageBox.Show(
_("Unable to check for updates. Please try again later."),
_("Software Update"),
MessageBoxButtons.OK, MessageBoxIcon.Warning);
// Most commonly a transient network failure. Never fatal: log it and, for a
// manual check, let the user know; a silent startup check stays silent.
Log.Warning(ex, "UpdateService: Update check failed (likely no network)");
if (announceNoUpdate) {
MessageBox.Show(
_("Unable to check for updates. Please try again later."),
_("Software Update"),
MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}
}

Expand Down
64 changes: 56 additions & 8 deletions src/Sic/SettingsDialog.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading