3 Commits

Author SHA1 Message Date
justin 990610f057 Sync from GitHub: ISCC cwd fix 2026-05-08 13:05:22 -04:00
justin 1ea724cd1f Sync from GitHub main: v0.1.3 (#6)
CI / build (push) Has been cancelled
2026-05-08 11:32:20 -04:00
justin a2bd338839 Wiki sync: stop treating git's stderr as fatal (#5)
Sync Wiki / sync (push) Has been cancelled
CI / build (push) Has been cancelled
2026-05-08 11:21:07 -04:00
13 changed files with 311 additions and 28 deletions
+102
View File
@@ -0,0 +1,102 @@
name: Release (Gitea)
# Lives in .gitea/workflows/ so it runs on Gitea Actions only. The GitHub-side
# release lives in .github/workflows/release.yml.
#
# Triggered automatically on v* tag pushes; can also be invoked manually via
# workflow_dispatch with a version override (useful for testing the runner
# without bumping the project version).
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
version:
description: 'Version to build (e.g. 0.1.4). Defaults to Directory.Build.props.'
required: false
jobs:
build-installer:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Resolve version
id: ver
shell: pwsh
run: |
if ('${{ github.event_name }}' -eq 'push') {
$v = '${{ github.ref_name }}'.TrimStart('v')
} elseif ('${{ inputs.version }}') {
$v = '${{ inputs.version }}'
} else {
[xml]$p = Get-Content Directory.Build.props
$v = $p.Project.PropertyGroup.Version
}
"version=$v" | Out-File $env:GITHUB_OUTPUT -Append
Write-Host "Building version $v"
- name: Restore + test
shell: pwsh
run: |
dotnet restore WebhookServer.sln
dotnet test WebhookServer.sln -c Release
- name: Ensure Inno Setup is installed
shell: pwsh
run: |
if (-not (Get-Command iscc -ErrorAction SilentlyContinue) -and `
-not (Test-Path 'C:\Program Files (x86)\Inno Setup 6\ISCC.exe') -and `
-not (Test-Path 'C:\Program Files\Inno Setup 6\ISCC.exe')) {
choco install innosetup --no-progress -y
}
- name: Build installer
shell: pwsh
run: ./scripts/build-installer.ps1 -VersionOverride ${{ steps.ver.outputs.version }}
- name: Upload installer artifact
uses: actions/upload-artifact@v4
with:
name: WebhookServer-Setup-${{ steps.ver.outputs.version }}
path: dist/WebhookServer-Setup-*.exe
- name: Create Gitea release with installer attached
if: startsWith(github.ref, 'refs/tags/v')
shell: pwsh
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
$version = '${{ steps.ver.outputs.version }}'
$tag = '${{ github.ref_name }}'
$repo = '${{ github.repository }}'
$serverUrl = '${{ github.server_url }}'
$apiBase = "$serverUrl/api/v1/repos/$repo"
$headers = @{ Authorization = "token $env:GITEA_TOKEN" }
# 1. Create the release.
$isPre = $version.StartsWith('0.')
$createBody = @{
tag_name = $tag
name = "Webhook Server $version"
body = "Automated build via Gitea Actions runner."
draft = $false
prerelease = $isPre
} | ConvertTo-Json
$rel = Invoke-RestMethod -Uri "$apiBase/releases" -Method Post `
-Headers $headers -ContentType 'application/json' -Body $createBody
Write-Host "Created release id=$($rel.id) tag=$tag"
# 2. Attach the installer.
$file = Get-Item "dist/WebhookServer-Setup-$version.exe"
$uploadUri = "$apiBase/releases/$($rel.id)/assets?name=$($file.Name)"
Invoke-RestMethod -Uri $uploadUri -Method Post -Headers $headers `
-ContentType 'application/octet-stream' -InFile $file.FullName | Out-Null
Write-Host "Uploaded $($file.Name) ($([math]::Round($file.Length / 1MB, 2)) MB) to $tag"
+1
View File
@@ -5,6 +5,7 @@ on:
branches: [main] branches: [main]
pull_request: pull_request:
branches: [main] branches: [main]
workflow_dispatch:
jobs: jobs:
build: build:
+3
View File
@@ -12,6 +12,9 @@ on:
jobs: jobs:
build-installer: build-installer:
# Gitea reads .github/workflows/ for compatibility, but the create-release
# step uses a GitHub-only action. Skip the whole job on non-GitHub runners.
if: github.server_url == 'https://github.com'
runs-on: windows-latest runs-on: windows-latest
permissions: permissions:
contents: write # needed to create releases / upload assets contents: write # needed to create releases / upload assets
+4
View File
@@ -11,6 +11,10 @@ on:
jobs: jobs:
sync: sync:
# Gitea reads .github/workflows/ for compatibility, but this workflow
# pushes to a GitHub-hosted wiki. Skip on non-GitHub runners; the Gitea
# wiki is synced separately via scripts/sync-wiki.ps1.
if: github.server_url == 'https://github.com'
runs-on: windows-latest runs-on: windows-latest
permissions: permissions:
contents: write contents: write
+1 -1
View File
@@ -1,7 +1,7 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>0.1.2</Version> <Version>0.1.4</Version>
<Authors>Justin Paul</Authors> <Authors>Justin Paul</Authors>
<Company>Justin Paul</Company> <Company>Justin Paul</Company>
<Product>Webhook Server</Product> <Product>Webhook Server</Product>
+46 -3
View File
@@ -53,15 +53,58 @@ if ($LASTEXITCODE -ne 0) { throw 'service publish failed' }
-c $Configuration -r win-x64 --self-contained false -o $publishGui | Out-Host -c $Configuration -r win-x64 --self-contained false -o $publishGui | Out-Host
if ($LASTEXITCODE -ne 0) { throw 'GUI publish failed' } if ($LASTEXITCODE -ne 0) { throw 'GUI publish failed' }
# 2. Compile installer. # 2. Pre-flight: confirm every source path the .iss references exists, and
# surface the longest path so MAX_PATH issues are obvious in the log.
function Show-SourcePath($label, $path, [switch]$Recursive) {
if (-not (Test-Path $path)) { Write-Warning "MISSING $label : $path"; return }
$items = if ($Recursive) {
Get-ChildItem $path -Recurse -File -ErrorAction SilentlyContinue
} else {
Get-ChildItem $path -File -ErrorAction SilentlyContinue
}
$count = ($items | Measure-Object).Count
$longest = ($items | Measure-Object -Maximum -Property { $_.FullName.Length }).Maximum
Write-Host (" {0,-30} files={1,-5} longestPath={2,-5} root={3}" -f $label, $count, $longest, $path)
}
Write-Host ""
Write-Host "--- pre-flight: source paths the .iss will read ---" -ForegroundColor Cyan
Show-SourcePath 'publish\service' $publishSvc -Recursive
Show-SourcePath 'publish\gui' $publishGui -Recursive
Show-SourcePath 'scripts' (Join-Path $repoRoot 'scripts')
Show-SourcePath 'scripts\examples' (Join-Path $repoRoot 'scripts\examples') -Recursive
Show-SourcePath 'docs' (Join-Path $repoRoot 'docs') -Recursive
Show-SourcePath 'resources' (Join-Path $repoRoot 'resources')
Show-SourcePath 'README.md (file)' (Join-Path $repoRoot 'README.md')
$lpe = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' `
-Name LongPathsEnabled -ErrorAction SilentlyContinue).LongPathsEnabled
Write-Host " LongPathsEnabled (HKLM): $lpe"
Write-Host ""
# 3. Compile installer.
$iscc = Find-InnoCompiler $iscc = Find-InnoCompiler
$iss = Join-Path $repoRoot 'installer\webhook-server.iss' $iss = Join-Path $repoRoot 'installer\webhook-server.iss'
$dist = Join-Path $repoRoot 'dist' $dist = Join-Path $repoRoot 'dist'
New-Item -ItemType Directory -Path $dist -Force | Out-Null New-Item -ItemType Directory -Path $dist -Force | Out-Null
Write-Host "Compiling installer with $iscc" Write-Host "Compiling installer with $iscc"
& $iscc "/DAppVersion=$version" $iss # Run ISCC from the .iss directory with just the bare filename. When invoked
if ($LASTEXITCODE -ne 0) { throw 'Inno Setup compile failed' } # with a deeply-nested absolute path on the act-runner host (under
# %SystemRoot%\System32\config\systemprofile\...), ISCC sometimes prints a
# generic "The system cannot find the path specified." before it touches any
# source files. cd-ing first sidesteps it.
$issDir = Split-Path $iss -Parent
$issName = Split-Path $iss -Leaf
Push-Location $issDir
try {
Write-Host " cwd=$issDir"
& $iscc "/DAppVersion=$version" $issName
$exit = $LASTEXITCODE
} finally {
Pop-Location
}
if ($exit -ne 0) { throw "Inno Setup compile failed (exit $exit)" }
$out = Get-Item (Join-Path $dist "WebhookServer-Setup-$version.exe") $out = Get-Item (Join-Path $dist "WebhookServer-Setup-$version.exe")
Write-Host "" Write-Host ""
+20 -8
View File
@@ -44,7 +44,12 @@ param(
[string]$AuthorEmail = 'noreply@jpaul.me' [string]$AuthorEmail = 'noreply@jpaul.me'
) )
$ErrorActionPreference = 'Stop' # Continue (not Stop) because git writes informational messages to stderr
# (CRLF warnings, "remote: Processed N references" etc.) which PowerShell 5.1
# escalates to a script-fatal error under Stop. We check $LASTEXITCODE
# manually after each git call instead.
$ErrorActionPreference = 'Continue'
$repoRoot = Split-Path -Parent $PSScriptRoot $repoRoot = Split-Path -Parent $PSScriptRoot
$docsDir = Join-Path $repoRoot 'docs' $docsDir = Join-Path $repoRoot 'docs'
$workDir = Join-Path $env:TEMP ("webhook-wiki-{0}" -f ([guid]::NewGuid().ToString('N').Substring(0, 8))) $workDir = Join-Path $env:TEMP ("webhook-wiki-{0}" -f ([guid]::NewGuid().ToString('N').Substring(0, 8)))
@@ -98,10 +103,14 @@ function New-Sidebar() {
# 1. Clone the wiki. # 1. Clone the wiki.
Write-Host "Cloning wiki to $workDir..." Write-Host "Cloning wiki to $workDir..."
git clone --quiet $WikiUrl $workDir & git clone --quiet $WikiUrl $workDir 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
throw "git clone failed. Has the wiki been initialized? Visit the repo's Wiki tab and create the first page via the UI before running this script." throw "git clone failed. Has the wiki been initialized? Visit the repo's Wiki tab and create the first page via the UI before running this script."
} }
# Suppress git's CRLF nags for this throwaway clone so they don't become
# "errors" via PowerShell's native-command stderr handling.
& git -C $workDir config core.autocrlf false 2>&1 | Out-Null
& git -C $workDir config core.safecrlf false 2>&1 | Out-Null
try { try {
Push-Location $workDir Push-Location $workDir
@@ -128,16 +137,19 @@ try {
# 4. Sidebar # 4. Sidebar
Set-Content -LiteralPath (Join-Path $workDir '_Sidebar.md') -Value (New-Sidebar) -Encoding utf8 -NoNewline Set-Content -LiteralPath (Join-Path $workDir '_Sidebar.md') -Value (New-Sidebar) -Encoding utf8 -NoNewline
# 5. Commit + push if anything actually changed. # 5. Commit + push if anything actually changed. Drain stderr from each
git add -A # git invocation so PowerShell doesn't treat warnings as errors.
$changes = git status --porcelain & git add -A 2>&1 | Out-Null
$changes = & git status --porcelain 2>&1
if (-not $changes) { if (-not $changes) {
Write-Host "Wiki already up to date." Write-Host "Wiki already up to date."
return return
} }
$sha = git -C $repoRoot rev-parse --short HEAD $sha = & git -C $repoRoot rev-parse --short HEAD 2>&1
git -c "user.name=$AuthorName" -c "user.email=$AuthorEmail" commit -q -m "Sync from docs/ at $sha" & git -c "user.name=$AuthorName" -c "user.email=$AuthorEmail" commit -q -m "Sync from docs/ at $sha" 2>&1 | Out-Null
git push --quiet if ($LASTEXITCODE -ne 0) { throw "git commit failed (exit $LASTEXITCODE)" }
& git push --quiet 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) { throw "git push failed (exit $LASTEXITCODE)" }
Write-Host "Pushed updated wiki." Write-Host "Pushed updated wiki."
} }
finally { Pop-Location } finally { Pop-Location }
+19 -5
View File
@@ -31,6 +31,11 @@
<MenuItem Header="_Export config…" Command="{Binding ExportConfigCommand}"/> <MenuItem Header="_Export config…" Command="{Binding ExportConfigCommand}"/>
<MenuItem Header="Config _Checkpoints…" Command="{Binding ShowConfigCheckpointsCommand}"/> <MenuItem Header="Config _Checkpoints…" Command="{Binding ShowConfigCheckpointsCommand}"/>
<Separator/> <Separator/>
<MenuItem Header="_Minimize to tray"
IsCheckable="True"
IsChecked="{Binding MinimizeToTrayEnabled, Mode=TwoWay}"
ToolTip="When ticked, closing or minimizing the window hides it to the tray and keeps the GUI process alive. Untick to make the X button quit the app."/>
<Separator/>
<MenuItem Header="E_xit" Command="{Binding ExitCommand}"/> <MenuItem Header="E_xit" Command="{Binding ExitCommand}"/>
</MenuItem> </MenuItem>
<MenuItem Header="_Server"> <MenuItem Header="_Server">
@@ -63,17 +68,26 @@
<DataGrid.RowStyle> <DataGrid.RowStyle>
<Style TargetType="DataGridRow"> <Style TargetType="DataGridRow">
<EventSetter Event="MouseDoubleClick" Handler="OnRowDoubleClick"/> <EventSetter Event="MouseDoubleClick" Handler="OnRowDoubleClick"/>
<!-- The ContextMenu lives in its own visual tree (a popup), so
AncestorType=Window doesn't resolve from inside menu items.
Stash MainViewModel on the row's Tag here (still in the
Window's tree), then reach it from the menu via
PlacementTarget.Tag. -->
<Setter Property="Tag" Value="{Binding DataContext, RelativeSource={RelativeSource AncestorType=Window}}"/>
<Setter Property="ContextMenu"> <Setter Property="ContextMenu">
<Setter.Value> <Setter.Value>
<ContextMenu> <ContextMenu>
<MenuItem Header="_Edit…" Command="{Binding DataContext.EditEndpointCommand, RelativeSource={RelativeSource AncestorType=Window}}"/> <MenuItem Header="_Edit…"
<MenuItem Header="_Copy URL" Command="{Binding DataContext.CopyEndpointUrlCommand, RelativeSource={RelativeSource AncestorType=Window}}"/> Command="{Binding PlacementTarget.Tag.EditEndpointCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
<MenuItem Header="_Copy URL"
Command="{Binding PlacementTarget.Tag.CopyEndpointUrlCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
<Separator/> <Separator/>
<MenuItem Header="Toggle _enabled" <MenuItem Header="Toggle _enabled"
Command="{Binding DataContext.ToggleEnabledCommand, RelativeSource={RelativeSource AncestorType=Window}}" Command="{Binding PlacementTarget.Tag.ToggleEnabledCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
CommandParameter="{Binding}"/> CommandParameter="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
<Separator/> <Separator/>
<MenuItem Header="_Delete…" Command="{Binding DataContext.DeleteEndpointCommand, RelativeSource={RelativeSource AncestorType=Window}}"/> <MenuItem Header="_Delete…"
Command="{Binding PlacementTarget.Tag.DeleteEndpointCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
</ContextMenu> </ContextMenu>
</Setter.Value> </Setter.Value>
</Setter> </Setter>
+36 -5
View File
@@ -1,3 +1,4 @@
using System.ComponentModel;
using System.Windows; using System.Windows;
using System.Windows.Controls; using System.Windows.Controls;
using System.Windows.Input; using System.Windows.Input;
@@ -11,26 +12,56 @@ public partial class MainWindow : Window
private readonly TrayIcon _tray; private readonly TrayIcon _tray;
private readonly MainViewModel _vm; private readonly MainViewModel _vm;
/// <summary>
/// Set to true when the user has explicitly asked to quit (File -> Exit or
/// Tray -> Exit). The OnClosing handler reads this to decide whether to
/// actually let the window close or hide it to the tray.
/// </summary>
public bool ExitForReal { get; set; }
public MainWindow() public MainWindow()
{ {
InitializeComponent(); InitializeComponent();
_vm = new MainViewModel(new AdminPipeClient()); _vm = new MainViewModel(new AdminPipeClient());
DataContext = _vm; DataContext = _vm;
_vm.RealExitRequested += OnRealExitRequested;
_tray = new TrayIcon( _tray = new TrayIcon(
resolveMainWindow: () => Application.Current.MainWindow, resolveMainWindow: () => Application.Current.MainWindow,
restartServiceAsync: async () => await new AdminPipeClient().RestartListenerAsync()); restartServiceAsync: async () => await new AdminPipeClient().RestartListenerAsync(),
onExit: OnRealExitRequested);
Loaded += async (_, _) => await _vm.RefreshCommand.ExecuteAsync(null); Loaded += async (_, _) => await _vm.RefreshCommand.ExecuteAsync(null);
StateChanged += OnStateChanged; StateChanged += OnStateChanged;
Closed += (_, _) => _tray.Dispose(); Closing += OnClosing;
}
private void OnClosing(object? sender, CancelEventArgs e)
{
if (ExitForReal || !_vm.MinimizeToTrayEnabled)
{
_tray.Dispose();
return;
}
// Treat the X button / Alt+F4 like a minimize: hide to tray, keep the
// process alive so the tray icon persists.
e.Cancel = true;
Hide();
ShowInTaskbar = false;
}
private void OnRealExitRequested()
{
ExitForReal = true;
Application.Current.Shutdown();
} }
private void OnStateChanged(object? sender, EventArgs e) private void OnStateChanged(object? sender, EventArgs e)
{ {
// Minimize-to-tray: hide the window when the user minimizes; restoring is // Minimize-to-tray: hide the window when the user minimizes IF they've
// via the tray icon's double-click or context menu. // opted in via File -> Minimize to tray. Otherwise behave like a normal
if (WindowState == WindowState.Minimized) // Windows minimize.
if (WindowState == WindowState.Minimized && _vm.MinimizeToTrayEnabled)
{ {
Hide(); Hide();
ShowInTaskbar = false; ShowInTaskbar = false;
@@ -0,0 +1,50 @@
using System.IO;
using System.Text.Json;
namespace WebhookServer.Gui.Services;
/// <summary>
/// Per-user GUI preferences that don't belong in the service-side ServerConfig.
/// Persisted to %APPDATA%\WebhookServer\gui.json. Best-effort: failures to read
/// or write fall back silently to defaults.
/// </summary>
public sealed class GuiSettings
{
/// <summary>
/// When true, the X / Alt+F4 / minimize buttons hide the window to the tray
/// and keep the GUI process alive. When false, X exits the app and minimize
/// behaves like a normal Windows minimize.
/// </summary>
public bool MinimizeToTrayEnabled { get; set; } = true;
private static string FilePath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"WebhookServer",
"gui.json");
public static GuiSettings Load()
{
try
{
if (File.Exists(FilePath))
{
var json = File.ReadAllText(FilePath);
if (!string.IsNullOrWhiteSpace(json))
return JsonSerializer.Deserialize<GuiSettings>(json) ?? new GuiSettings();
}
}
catch { /* fall through to defaults */ }
return new GuiSettings();
}
public void Save()
{
try
{
var dir = Path.GetDirectoryName(FilePath);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
File.WriteAllText(FilePath, JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true }));
}
catch { /* best effort */ }
}
}
+4 -2
View File
@@ -16,11 +16,13 @@ public sealed class TrayIcon : IDisposable
private readonly NotifyIcon _icon; private readonly NotifyIcon _icon;
private readonly Func<Window?> _resolveMainWindow; private readonly Func<Window?> _resolveMainWindow;
private readonly Func<Task> _restartServiceAsync; private readonly Func<Task> _restartServiceAsync;
private readonly Action _onExit;
public TrayIcon(Func<Window?> resolveMainWindow, Func<Task> restartServiceAsync) public TrayIcon(Func<Window?> resolveMainWindow, Func<Task> restartServiceAsync, Action onExit)
{ {
_resolveMainWindow = resolveMainWindow; _resolveMainWindow = resolveMainWindow;
_restartServiceAsync = restartServiceAsync; _restartServiceAsync = restartServiceAsync;
_onExit = onExit;
_icon = new NotifyIcon _icon = new NotifyIcon
{ {
@@ -39,7 +41,7 @@ public sealed class TrayIcon : IDisposable
menu.Items.Add(new ToolStripSeparator()); menu.Items.Add(new ToolStripSeparator());
menu.Items.Add("&Restart service", null, async (_, _) => await _restartServiceAsync().ConfigureAwait(false)); menu.Items.Add("&Restart service", null, async (_, _) => await _restartServiceAsync().ConfigureAwait(false));
menu.Items.Add(new ToolStripSeparator()); menu.Items.Add(new ToolStripSeparator());
menu.Items.Add("E&xit", null, (_, _) => Application.Current.Shutdown()); menu.Items.Add("E&xit", null, (_, _) => _onExit());
return menu; return menu;
} }
@@ -82,8 +82,14 @@ public sealed partial class ConfigCheckpointsViewModel : ObservableObject
{ {
if (Selected is null) return; if (Selected is null) return;
// Capture before the refresh; the ObservableCollection.Clear() in
// RefreshAsync nulls Selected (the original instance is gone from the
// collection so the SelectedItem binding clears).
var fileName = Selected.FileName;
var savedAt = Selected.SavedAt;
var ok = MessageBox.Show( var ok = MessageBox.Show(
$"Roll the configuration back to the checkpoint from {Selected.SavedAt.ToLocalTime():yyyy-MM-dd HH:mm:ss}?\n\nThe current configuration is automatically saved as a new checkpoint first, so you can roll forward again.", $"Roll the configuration back to the checkpoint from {savedAt.ToLocalTime():yyyy-MM-dd HH:mm:ss}?\n\nThe current configuration is automatically saved as a new checkpoint first, so you can roll forward again.",
"Confirm rollback", "Confirm rollback",
MessageBoxButton.OKCancel, MessageBoxButton.OKCancel,
MessageBoxImage.Warning); MessageBoxImage.Warning);
@@ -91,10 +97,10 @@ public sealed partial class ConfigCheckpointsViewModel : ObservableObject
try try
{ {
await _client.RestoreBackupAsync(Selected.FileName).ConfigureAwait(false); await _client.RestoreBackupAsync(fileName).ConfigureAwait(false);
await RefreshAsync().ConfigureAwait(false); await RefreshAsync().ConfigureAwait(false);
Application.Current.Dispatcher.Invoke(() => Application.Current.Dispatcher.Invoke(() =>
StatusMessage = $"Rolled back to {Selected!.FileName}."); StatusMessage = $"Rolled back to {fileName}.");
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -29,17 +29,28 @@ public sealed partial class MainViewModel : ObservableObject
[ObservableProperty] private ServerConfig _serverConfig = new(); [ObservableProperty] private ServerConfig _serverConfig = new();
[ObservableProperty] private string _httpBaseUrl = "http://localhost:8080"; [ObservableProperty] private string _httpBaseUrl = "http://localhost:8080";
[ObservableProperty] private string? _httpsBaseUrl; [ObservableProperty] private string? _httpsBaseUrl;
[ObservableProperty] private bool _minimizeToTrayEnabled;
private readonly DispatcherTimer _logTimer; private readonly DispatcherTimer _logTimer;
private readonly GuiSettings _settings;
public MainViewModel(AdminPipeClient client) public MainViewModel(AdminPipeClient client)
{ {
_client = client; _client = client;
_settings = GuiSettings.Load();
_minimizeToTrayEnabled = _settings.MinimizeToTrayEnabled;
_logTimer = new DispatcherTimer(DispatcherPriority.Background) { Interval = TimeSpan.FromSeconds(3) }; _logTimer = new DispatcherTimer(DispatcherPriority.Background) { Interval = TimeSpan.FromSeconds(3) };
_logTimer.Tick += async (_, _) => await RefreshLogTailAsync(); _logTimer.Tick += async (_, _) => await RefreshLogTailAsync();
_logTimer.Start(); _logTimer.Start();
} }
partial void OnMinimizeToTrayEnabledChanged(bool value)
{
_settings.MinimizeToTrayEnabled = value;
_settings.Save();
}
[RelayCommand] [RelayCommand]
private async Task RefreshAsync() private async Task RefreshAsync()
{ {
@@ -286,10 +297,14 @@ public sealed partial class MainViewModel : ObservableObject
} }
} }
/// <summary>Raised when the user picks File -> Exit. MainWindow flips its
/// ExitForReal flag and shuts down, bypassing the X-hides-to-tray logic.</summary>
public event Action? RealExitRequested;
[RelayCommand] [RelayCommand]
private void Exit() private void Exit()
{ {
Application.Current.Shutdown(); RealExitRequested?.Invoke();
} }
[RelayCommand] [RelayCommand]