From aec8b437e77b4bda6eacdf9c989c3b205c151517 Mon Sep 17 00:00:00 2001 From: Yuji Tsuchimoto Date: Sun, 17 May 2026 17:31:22 +0900 Subject: [PATCH 1/2] Fix installer progress and silent mode --- README.ja.md | 10 ++-- README.md | 14 ++--- docs/installer.md | 12 ++-- docs/release-drafts/v0.3.2.md | 44 --------------- docs/release-drafts/v0.3.3.md | 45 +++++++++++++++ .../InstallerStateMachine.cs | 55 ++++++++++++++++--- src/PriorityGear.Setup/Program.cs | 44 +++++++++++++-- src/PriorityGear.Setup/SetupLog.cs | 33 ++++++++--- src/PriorityGear.Setup/SetupStartup.cs | 19 +++++++ .../InstallerStateMachineTests.cs | 42 ++++++++++++++ .../PriorityGear.Setup.Tests/SetupLogTests.cs | 34 ++++++++++++ 11 files changed, 267 insertions(+), 85 deletions(-) delete mode 100644 docs/release-drafts/v0.3.2.md create mode 100644 docs/release-drafts/v0.3.3.md create mode 100644 src/PriorityGear.Setup/SetupStartup.cs create mode 100644 tests/PriorityGear.Setup.Tests/SetupLogTests.cs diff --git a/README.ja.md b/README.ja.md index f5f400a..41aea8b 100644 --- a/README.ja.md +++ b/README.ja.md @@ -41,14 +41,14 @@ dotnet test PriorityGear.slnx --configuration Release --no-build dotnet run --project src/PriorityGear.App/PriorityGear.App.csproj --configuration Release ``` -## v0.3.2 System Mode Installer Release +## v0.3.3 System Mode Installer Release -`v0.3.2` は formal System Mode installer 用の現在の GitHub release です。winget submission に必要な silent installer switches を追加しています。この installer が PriorityGear の通常の GitHub 配布インストール経路になります。 +`v0.3.3` は formal System Mode installer 用の次の GitHub release です。`v0.3.1` / `v0.3.2` の installer window が blank progress に見える問題を修正し、起動直後に log path と進行状況を表示します。 公開 artifact は次です。 ```text -PriorityGear-v0.3.2-win-x64-installer.zip +PriorityGear-v0.3.3-win-x64-installer.zip ``` zip には `PriorityGear.Setup.exe` が含まれます。ダブルクリックして UAC を承認すると PriorityGear を install / update します。この installer は AS IS であり、署名を明示的に追加するまでは unsigned です。 @@ -56,7 +56,7 @@ zip には `PriorityGear.Setup.exe` が含まれます。ダブルクリック 同じ installer artifact をローカルで作成する場合: ```powershell -.\scripts\package-release.ps1 -TagName "v0.3.2" -OutputDirectory ".\artifacts\release-test-v0.3.2" +.\scripts\package-release.ps1 -TagName "v0.3.3" -OutputDirectory ".\artifacts\release-test-v0.3.3" ``` installer は GUI app を配置し、`PriorityGear.Service` を `%ProgramFiles%\PriorityGear\versions` 以下の versioned directory から起動する LocalSystem Windows Service として構成します。`%ProgramData%\PriorityGear\rules.machine.json` と `%ProgramData%\PriorityGear\Logs` は保持します。 @@ -79,7 +79,7 @@ v0.2 の範囲は LocalSystem service の検証用 install/update、status/admin `v0.2.0-preview.1` は System Mode foundation の過去の public prerelease として残ります。 -Store 配布、署名、本番 MSI/MSIX packaging、GUI machine-rule editing、System Mode の active-window priority switching、任意の shared-host mutation、CPU affinity、I/O priority、EcoQoS、Realtime priority UI、driver、telemetry、network、updater は範囲外です。winget package は [microsoft/winget-pkgs#375643](https://github.com/microsoft/winget-pkgs/pull/375643) に submitted ですが、Microsoft validation / merge が完了して `winget search` で見つかるまでは利用可能とは扱いません。 +Store 配布、winget 登録、署名、本番 MSI/MSIX packaging、GUI machine-rule editing、System Mode の active-window priority switching、任意の shared-host mutation、CPU affinity、I/O priority、EcoQoS、Realtime priority UI、driver、telemetry、network、updater は範囲外です。先行 winget submission は installer progress fix のため closed しています。 検証後の状態として、`PriorityGear.Service` は install/running のまま残る場合があります。一時的な `PriorityGear.TestTarget.Service` と temporary machine rules は削除され、`%ProgramData%\PriorityGear\Logs` は残ります。`%ProgramData%\PriorityGear\rules.machine.json` は保持または復元されます。古い version directory cleanup は best-effort です。 diff --git a/README.md b/README.md index 4bef350..1b2612f 100644 --- a/README.md +++ b/README.md @@ -33,14 +33,14 @@ dotnet test PriorityGear.slnx --configuration Release --no-build dotnet run --project src/PriorityGear.App/PriorityGear.App.csproj --configuration Release ``` -## v0.3.2 System Mode Installer Release +## v0.3.3 System Mode Installer Release -`v0.3.2` is the current GitHub release for the formal System Mode installer. It adds silent installer switches needed for winget submission. The installer is the normal GitHub-distributed install path for PriorityGear. +`v0.3.3` is the next GitHub release for the formal System Mode installer. It fixes the `v0.3.1` / `v0.3.2` blank-progress installer window by showing the log path immediately and streaming progress while setup runs. The release artifact is: ```text -PriorityGear-v0.3.2-win-x64-installer.zip +PriorityGear-v0.3.3-win-x64-installer.zip ``` The zip contains `PriorityGear.Setup.exe`. Double-click it and approve UAC to install or update PriorityGear. The installer is AS IS and unsigned unless signing is explicitly added in a later release. @@ -48,7 +48,7 @@ The zip contains `PriorityGear.Setup.exe`. Double-click it and approve UAC to in To build the same installer artifact locally: ```powershell -.\scripts\package-release.ps1 -TagName "v0.3.2" -OutputDirectory ".\artifacts\release-test-v0.3.2" +.\scripts\package-release.ps1 -TagName "v0.3.3" -OutputDirectory ".\artifacts\release-test-v0.3.3" ``` The installer installs the GUI app and configures `PriorityGear.Service` as a LocalSystem Windows Service under a versioned directory below `%ProgramFiles%\PriorityGear\versions`. It preserves `%ProgramData%\PriorityGear\rules.machine.json` and logs under `%ProgramData%\PriorityGear\Logs`. @@ -77,17 +77,17 @@ Post-verification state: `PriorityGear.Service` may remain installed/running, te ## Artifacts -### v0.3.2 GitHub Installer +### v0.3.3 GitHub Installer The current GitHub release artifact is: ```text -PriorityGear-v0.3.2-win-x64-installer.zip +PriorityGear-v0.3.3-win-x64-installer.zip ``` It contains `PriorityGear.Setup.exe` and the service/app/CLI payload needed for install or update after UAC approval. It is not Store, MSI, MSIX, or signed packaging. -For winget submission, the installer supports `--install --silent` and `--uninstall --silent`. The winget package is submitted in [microsoft/winget-pkgs#375643](https://github.com/microsoft/winget-pkgs/pull/375643), but it is not available through `winget search` until Microsoft validation is complete and the PR is merged. +The installer supports `--install --silent` and `--uninstall --silent`. winget registration is not done in this release; the earlier winget submission was closed while this installer progress fix is prepared. ### v0.1 User Mode Portable Publish diff --git a/docs/installer.md b/docs/installer.md index 23ad539..09c384d 100644 --- a/docs/installer.md +++ b/docs/installer.md @@ -1,13 +1,13 @@ # PriorityGear Installer -PriorityGear `v0.3.2` is the current formal GitHub release installer path. It adds the silent installer mode needed for Windows Package Manager / winget submission. +PriorityGear `v0.3.3` is the next formal GitHub release installer path. It fixes the blank-progress setup window by showing startup diagnostics immediately and streaming logs while setup runs. ## Artifact The primary release artifact is: ```text -PriorityGear-v0.3.2-win-x64-installer.zip +PriorityGear-v0.3.3-win-x64-installer.zip ``` The zip contains `PriorityGear.Setup.exe` and a `payload` directory with the GUI app, CLI, and System Mode service binaries. @@ -17,7 +17,7 @@ The zip contains `PriorityGear.Setup.exe` and a `payload` directory with the GUI Double-click `PriorityGear.Setup.exe` and approve UAC. The installer: - requires elevation; -- installs files under `%ProgramFiles%\PriorityGear\versions\v0.3.2`; +- installs files under `%ProgramFiles%\PriorityGear\versions\v0.3.3`; - configures `PriorityGear.Service` as LocalSystem; - starts or restarts the service; - confirms the status pipe responds; @@ -52,11 +52,7 @@ For silent uninstall: ## winget -The installer is submitted for winget distribution as a zip package with nested `PriorityGear.Setup.exe`: - -https://github.com/microsoft/winget-pkgs/pull/375643 - -The winget package is not available until Microsoft validation is complete, the PR is merged, and `winget search` can find it. +winget registration is not done in this release. A previous submission was closed while the installer progress fix is prepared. The package must not be treated as available until a future winget PR is validated, merged, and `winget search` can find it. ## Boundaries diff --git a/docs/release-drafts/v0.3.2.md b/docs/release-drafts/v0.3.2.md deleted file mode 100644 index 59a5038..0000000 --- a/docs/release-drafts/v0.3.2.md +++ /dev/null @@ -1,44 +0,0 @@ -# PriorityGear v0.3.2 - winget-compatible installer mode - -`v0.3.2` is a patch release for the formal GitHub installer. It adds command-line behavior needed for Windows Package Manager / winget submission. - -## Summary - -- Keeps the `PriorityGear.Setup.exe` installer distribution from `v0.3.1`. -- Adds silent installer entrypoints for package-manager use: - - `PriorityGear.Setup.exe --install --silent` - - `PriorityGear.Setup.exe --uninstall --silent` -- Adds `PriorityGear.Setup.exe --help` and `PriorityGear.Setup.exe --version`. -- Adds release artifact metadata describing the nested installer and silent install/uninstall switches. -- Publishes `PriorityGear-v0.3.2-win-x64-installer.zip`. - -## Installer - -`PriorityGear.Setup.exe` remains the normal install/update entrypoint. Double-click installation keeps the existing setup UI and UAC flow. - -For winget and automation, silent mode runs the same production installer state machine without setup UI or message boxes. It still requires elevation for install/update and uninstall. Exit code `0` means success; required install/update or uninstall failures return non-zero. - -The installer installs the GUI app, CLI, and System Mode service payload, configures `PriorityGear.Service` as LocalSystem, uses versioned install directories, and preserves ProgramData rules/logs by default. - -## Safety boundary - -PriorityGear System Mode is privileged software and installs a LocalSystem Windows Service. It changes process priority and may affect system responsiveness or stability. It is provided AS IS. - -The installer is unsigned. Microsoft Store, signing, MSI, and MSIX packaging are not included in this release. - -This release does not expand unsafe service mutation support. Arbitrary shared-host `svchost.exe` priority mutation remains out of scope. - -GitHub Actions builds and packages the installer. GitHub Actions does not run elevated setup. - -## Validation - -Expected validation: - -```powershell -dotnet restore PriorityGear.slnx -dotnet build PriorityGear.slnx --configuration Release --no-restore -dotnet test PriorityGear.slnx --configuration Release --no-build -.\scripts\build-verification-installer.ps1 -.\scripts\package-release.ps1 -TagName "v0.3.2" -OutputDirectory ".\artifacts\release-test-v0.3.2" -.\scripts\inspect-release-artifacts.ps1 -ArtifactDirectory ".\artifacts\release-test-v0.3.2" -TagName "v0.3.2" -``` diff --git a/docs/release-drafts/v0.3.3.md b/docs/release-drafts/v0.3.3.md new file mode 100644 index 0000000..9925130 --- /dev/null +++ b/docs/release-drafts/v0.3.3.md @@ -0,0 +1,45 @@ +# PriorityGear v0.3.3 - installer progress visibility fix + +`v0.3.3` is a patch release for the formal GitHub installer. It fixes the blank installer progress window reported in the `v0.3.1` / `v0.3.2` installer line. + +## Summary + +- Fixes the blank setup window by writing startup diagnostics immediately. +- Streams setup log lines into the installer window as work happens. +- Runs long install/update work on a background task so the form can repaint and stay responsive. +- Flushes setup logs incrementally so failures leave durable diagnostics. +- Keeps silent installer entrypoints: + - `PriorityGear.Setup.exe --install --silent` + - `PriorityGear.Setup.exe --uninstall --silent` +- Publishes `PriorityGear-v0.3.3-win-x64-installer.zip`. + +## Installer + +`PriorityGear.Setup.exe` remains the normal install/update entrypoint. Double-click installation keeps the setup UI and UAC flow. The window now shows the setup start, log path, and mode before long-running installation work begins. + +Silent mode runs the same production installer state machine without setup UI or message boxes. It still requires elevation for install/update and uninstall. Exit code `0` means success; required install/update or uninstall failures return non-zero. + +The installer installs the GUI app, CLI, and System Mode service payload, configures `PriorityGear.Service` as LocalSystem, uses versioned install directories, and preserves ProgramData rules/logs by default. + +## Safety boundary + +PriorityGear System Mode is privileged software and installs a LocalSystem Windows Service. It changes process priority and may affect system responsiveness or stability. It is provided AS IS. + +The installer is unsigned. Microsoft Store, winget registration, signing, MSI, and MSIX packaging are not included in this release. + +This release does not expand unsafe service mutation support. Arbitrary shared-host `svchost.exe` priority mutation remains out of scope. + +GitHub Actions builds and packages the installer. GitHub Actions does not run elevated setup. + +## Validation + +Expected validation: + +```powershell +dotnet restore PriorityGear.slnx +dotnet build PriorityGear.slnx --configuration Release --no-restore +dotnet test PriorityGear.slnx --configuration Release --no-build +.\scripts\build-verification-installer.ps1 +.\scripts\package-release.ps1 -TagName "v0.3.3" -OutputDirectory ".\artifacts\release-test-v0.3.3" +.\scripts\inspect-release-artifacts.ps1 -ArtifactDirectory ".\artifacts\release-test-v0.3.3" -TagName "v0.3.3" +``` diff --git a/src/PriorityGear.Setup/InstallerStateMachine.cs b/src/PriorityGear.Setup/InstallerStateMachine.cs index 952d8ef..b54f4fd 100644 --- a/src/PriorityGear.Setup/InstallerStateMachine.cs +++ b/src/PriorityGear.Setup/InstallerStateMachine.cs @@ -24,6 +24,16 @@ public sealed record InstallerRunResult( IReadOnlyList CompletedSteps, IReadOnlyList Warnings); +public enum InstallerProgressKind +{ + Starting, + Completed, + Failed, + Warning +} + +public sealed record InstallerProgress(InstallerProgressKind Kind, InstallerStep Step, string Message); + public interface IInstallerExecutor { void ValidatePayload(); @@ -46,6 +56,8 @@ public sealed class InstallerStateMachine(SetupInstallPlan plan, IInstallerExecu private readonly List _completedSteps = []; private readonly List _warnings = []; + public event Action? Progress; + public InstallerRunResult InstallOrUpdate() { _completedSteps.Clear(); @@ -59,37 +71,45 @@ public InstallerRunResult InstallOrUpdate() RunRequired(InstallerStep.ConfigureService, executor.ConfigureService); RunRequired(InstallerStep.StartService, executor.StartService); + Emit(InstallerProgressKind.Starting, InstallerStep.VerifyStatusPipe, "Verifying service status pipe."); InstallerStatus status = QueryStatus(); if (!status.ServiceRunning) { - return Fail("Service status reports that the service is not running."); + return Fail(InstallerStep.VerifyStatusPipe, "Service status reports that the service is not running."); } _completedSteps.Add(InstallerStep.VerifyStatusPipe); + Emit(InstallerProgressKind.Completed, InstallerStep.VerifyStatusPipe, "Service status pipe responded."); + Emit(InstallerProgressKind.Starting, InstallerStep.VerifyServiceConfiguration, "Validating service configuration."); if (!string.Equals(status.ConfiguredServiceAccount, "LocalSystem", StringComparison.OrdinalIgnoreCase)) { - return Fail($"Service account is not LocalSystem: {status.ConfiguredServiceAccount}"); + return Fail(InstallerStep.VerifyServiceConfiguration, $"Service account is not LocalSystem: {status.ConfiguredServiceAccount}"); } if (string.IsNullOrWhiteSpace(status.ProcessIdentity)) { - return Fail("Service status did not report process identity."); + return Fail(InstallerStep.VerifyServiceConfiguration, "Service status did not report process identity."); } if (!ServiceBinaryPathMatches(status.ServiceBinaryPath, plan.ServiceExePath)) { - return Fail($"Service binary path does not point at the new version directory: {status.ServiceBinaryPath}"); + return Fail(InstallerStep.VerifyServiceConfiguration, $"Service binary path does not point at the new version directory: {status.ServiceBinaryPath}"); } _completedSteps.Add(InstallerStep.VerifyServiceConfiguration); + Emit(InstallerProgressKind.Completed, InstallerStep.VerifyServiceConfiguration, "Service configuration is valid."); try { + Emit(InstallerProgressKind.Starting, InstallerStep.CleanupOldVersions, "Cleaning old version directories."); executor.CleanupOldVersions(); _completedSteps.Add(InstallerStep.CleanupOldVersions); + Emit(InstallerProgressKind.Completed, InstallerStep.CleanupOldVersions, "Old version cleanup completed."); } catch (Exception ex) { - _warnings.Add($"Old version cleanup failed: {ex.Message}"); + string warning = $"Old version cleanup failed: {ex.Message}"; + _warnings.Add(warning); + Emit(InstallerProgressKind.Warning, InstallerStep.CleanupOldVersions, warning); } return Success("Install/update completed."); @@ -107,8 +127,18 @@ private InstallerStatus QueryStatus() private void RunRequired(InstallerStep step, Action action) { - action(); - _completedSteps.Add(step); + Emit(InstallerProgressKind.Starting, step, $"Starting {step}."); + try + { + action(); + _completedSteps.Add(step); + Emit(InstallerProgressKind.Completed, step, $"Completed {step}."); + } + catch (Exception ex) + { + Emit(InstallerProgressKind.Failed, step, $"Failed {step}: {ex.Message}"); + throw; + } } private InstallerRunResult Fail(string message) @@ -116,11 +146,22 @@ private InstallerRunResult Fail(string message) return new InstallerRunResult(false, message, _completedSteps.ToArray(), _warnings.ToArray()); } + private InstallerRunResult Fail(InstallerStep step, string message) + { + Emit(InstallerProgressKind.Failed, step, message); + return Fail(message); + } + private InstallerRunResult Success(string message) { return new InstallerRunResult(true, message, _completedSteps.ToArray(), _warnings.ToArray()); } + private void Emit(InstallerProgressKind kind, InstallerStep step, string message) + { + Progress?.Invoke(new InstallerProgress(kind, step, message)); + } + public static bool ServiceBinaryPathMatches(string configuredPath, string expectedPath) { string? configuredExe = ExtractExecutablePath(configuredPath); diff --git a/src/PriorityGear.Setup/Program.cs b/src/PriorityGear.Setup/Program.cs index c74661e..f83e01b 100644 --- a/src/PriorityGear.Setup/Program.cs +++ b/src/PriorityGear.Setup/Program.cs @@ -180,13 +180,19 @@ private async Task RunAsync() { string logPath = CreateSetupLogPath(); SetupLog log = new(logPath); + log.LineWritten += AppendLogLine; try { + foreach (string line in SetupStartup.InitialLines(_command, logPath)) + { + log.Info(line); + } + SetupInstallPlan plan = SetupInstallPlan.Create(ReadSetupVersion()); if (_command.Action == SetupCommandAction.Uninstall) { - await UninstallAsync(plan, log); + await Task.Run(async () => await UninstallAsync(plan, log)); ExitCode = 0; Finish("PriorityGear uninstall completed.", log); return; @@ -197,7 +203,7 @@ private async Task RunAsync() log.Info("--verify checks the production install path only. Use PriorityGear.VerificationSetup.exe for the full developer verification harness."); } - InstallerRunResult installResult = await InstallAsync(plan, log); + InstallerRunResult installResult = await Task.Run(async () => await InstallAsync(plan, log)); SetupResult result = installResult.Succeeded ? new SetupResult(true, installResult.Message) : SetupResult.InstallFailed(installResult.Message); @@ -217,6 +223,30 @@ private async Task RunAsync() } } + private void AppendLogLine(string line) + { + if (IsDisposed) + { + return; + } + + void Append() + { + _logBox.AppendText(line + Environment.NewLine); + _logBox.SelectionStart = _logBox.TextLength; + _logBox.ScrollToCaret(); + } + + if (InvokeRequired) + { + BeginInvoke((Action)Append); + } + else + { + Append(); + } + } + internal static async Task InstallAsync(SetupInstallPlan plan, SetupLog log) { log.Section("Environment"); @@ -243,10 +273,11 @@ internal static async Task InstallAsync(SetupInstallPlan pla log.Info($"Preserve machine rules: {planSummary.PreserveMachineRules}"); log.Info($"Preserve logs: {planSummary.PreserveLogs}"); - InstallerRunResult result = new InstallerStateMachine( + InstallerStateMachine stateMachine = new( plan, - new ProductionInstallerExecutor(plan, log, SetupPayload.PayloadDirectory(AppContext.BaseDirectory))) - .InstallOrUpdate(); + new ProductionInstallerExecutor(plan, log, SetupPayload.PayloadDirectory(AppContext.BaseDirectory))); + stateMachine.Progress += progress => log.Info($"{progress.Kind}: {progress.Step}: {progress.Message}"); + InstallerRunResult result = stateMachine.InstallOrUpdate(); foreach (string warning in result.Warnings) { log.Info($"Warning: {warning}"); @@ -333,6 +364,7 @@ private static async Task StopNamedServiceAsync(string serviceName, SetupLog log controller.Refresh(); if (controller.Status is ServiceControllerStatus.Running or ServiceControllerStatus.StartPending) { + log.Info($"Stopping service {serviceName}; timeout=30s."); controller.Stop(); await Task.Run(() => controller.WaitForStatus(ServiceControllerStatus.Stopped, TimeSpan.FromSeconds(30))); log.Info($"Service stopped: {serviceName}"); @@ -418,6 +450,7 @@ private static async Task StartServiceAsync(SetupInstallPlan plan, SetupLog log) controller.Refresh(); if (controller.Status != ServiceControllerStatus.Running) { + log.Info($"Starting service {plan.ServiceName}; timeout=30s."); controller.Start(); await Task.Run(() => controller.WaitForStatus(ServiceControllerStatus.Running, TimeSpan.FromSeconds(30))); } @@ -447,6 +480,7 @@ private static async Task VerifyStatusPipeAsync(SetupLog log) { log.Section("Status pipe"); ServiceResponse last = new() { Succeeded = false, Message = "Status pipe was not attempted." }; + log.Info("Status pipe deadline: 20 attempts, 500ms delay between attempts."); for (int attempt = 1; attempt <= 20; attempt++) { try diff --git a/src/PriorityGear.Setup/SetupLog.cs b/src/PriorityGear.Setup/SetupLog.cs index 12ef3a8..d35fd31 100644 --- a/src/PriorityGear.Setup/SetupLog.cs +++ b/src/PriorityGear.Setup/SetupLog.cs @@ -5,9 +5,12 @@ namespace PriorityGear.Setup; public sealed class SetupLog(string path) { private readonly StringBuilder _content = new(); + private readonly object _sync = new(); public string Path { get; } = path; + public event Action? LineWritten; + public void Info(string message) => Write("INFO", message); public void Fail(string message) => Write("FAIL", message); @@ -16,19 +19,31 @@ public sealed class SetupLog(string path) public void Flush() { - Directory.CreateDirectory(System.IO.Path.GetDirectoryName(Path)!); - File.WriteAllText(Path, _content.ToString(), Encoding.UTF8); + lock (_sync) + { + Directory.CreateDirectory(System.IO.Path.GetDirectoryName(Path)!); + File.WriteAllText(Path, _content.ToString(), Encoding.UTF8); + } } - public override string ToString() => _content.ToString(); + public override string ToString() + { + lock (_sync) + { + return _content.ToString(); + } + } private void Write(string level, string message) { - _content.Append('[') - .Append(DateTimeOffset.Now.ToString("u")) - .Append("] ") - .Append(level) - .Append(": ") - .AppendLine(message); + string line = $"[{DateTimeOffset.Now:u}] {level}: {message}"; + lock (_sync) + { + _content.AppendLine(line); + Directory.CreateDirectory(System.IO.Path.GetDirectoryName(Path)!); + File.AppendAllText(Path, line + Environment.NewLine, Encoding.UTF8); + } + + LineWritten?.Invoke(line); } } diff --git a/src/PriorityGear.Setup/SetupStartup.cs b/src/PriorityGear.Setup/SetupStartup.cs new file mode 100644 index 0000000..25b98c1 --- /dev/null +++ b/src/PriorityGear.Setup/SetupStartup.cs @@ -0,0 +1,19 @@ +namespace PriorityGear.Setup; + +public static class SetupStartup +{ + public static IReadOnlyList InitialLines(SetupCommandLine command, string logPath) + { + return + [ + "PriorityGear Setup started.", + $"Log: {logPath}", + $"Mode: {ModeName(command)}" + ]; + } + + public static string ModeName(SetupCommandLine command) + { + return command.Action == SetupCommandAction.Uninstall ? "uninstall" : "install/update"; + } +} diff --git a/tests/PriorityGear.Setup.Tests/InstallerStateMachineTests.cs b/tests/PriorityGear.Setup.Tests/InstallerStateMachineTests.cs index b384e13..d547f1f 100644 --- a/tests/PriorityGear.Setup.Tests/InstallerStateMachineTests.cs +++ b/tests/PriorityGear.Setup.Tests/InstallerStateMachineTests.cs @@ -164,6 +164,48 @@ public void ReturnedResultDoesNotExposeMutableStepLists() first.CompletedSteps); } + [Fact] + public void SuccessEmitsStepStartAndCompleteInOrder() + { + InstallerStateMachine stateMachine = new(Plan, new FakeInstallerExecutor()); + List progress = []; + stateMachine.Progress += progress.Add; + + InstallerRunResult result = stateMachine.InstallOrUpdate(); + + Assert.True(result.Succeeded); + Assert.Equal(InstallerProgressKind.Starting, progress[0].Kind); + Assert.Equal(InstallerStep.ValidatePayload, progress[0].Step); + Assert.Contains(progress, p => p.Kind == InstallerProgressKind.Completed && p.Step == InstallerStep.CleanupOldVersions); + } + + [Fact] + public void FailureEmitsFailedStepAndStopsLaterCompletion() + { + InstallerStateMachine stateMachine = new(Plan, new FakeInstallerExecutor { ConfigureServiceFailure = "create failed" }); + List progress = []; + stateMachine.Progress += progress.Add; + + InstallerRunResult result = stateMachine.InstallOrUpdate(); + + Assert.False(result.Succeeded); + Assert.Contains(progress, p => p.Kind == InstallerProgressKind.Failed && p.Step == InstallerStep.ConfigureService); + Assert.DoesNotContain(progress, p => p.Kind == InstallerProgressKind.Completed && p.Step == InstallerStep.StartService); + } + + [Fact] + public void OldVersionCleanupFailureEmitsWarningAndSuccess() + { + InstallerStateMachine stateMachine = new(Plan, new FakeInstallerExecutor { CleanupOldVersionsFailure = "locked" }); + List progress = []; + stateMachine.Progress += progress.Add; + + InstallerRunResult result = stateMachine.InstallOrUpdate(); + + Assert.True(result.Succeeded); + Assert.Contains(progress, p => p.Kind == InstallerProgressKind.Warning && p.Step == InstallerStep.CleanupOldVersions); + } + private sealed class FakeInstallerExecutor : IInstallerExecutor { public string? ValidatePayloadFailure { get; init; } diff --git a/tests/PriorityGear.Setup.Tests/SetupLogTests.cs b/tests/PriorityGear.Setup.Tests/SetupLogTests.cs new file mode 100644 index 0000000..dd7ee18 --- /dev/null +++ b/tests/PriorityGear.Setup.Tests/SetupLogTests.cs @@ -0,0 +1,34 @@ +using PriorityGear.Setup; + +namespace PriorityGear.Setup.Tests; + +public sealed class SetupLogTests +{ + [Fact] + public void SetupLogInvokesProgressCallbackAndFlushesImmediately() + { + string path = Path.Combine(Path.GetTempPath(), "PriorityGear.Setup.Tests", Guid.NewGuid().ToString("N"), "setup.log"); + SetupLog log = new(path); + List lines = []; + log.LineWritten += lines.Add; + + log.Info("PriorityGear Setup started."); + + Assert.Single(lines); + Assert.Contains("PriorityGear Setup started.", lines[0]); + Assert.True(File.Exists(path)); + Assert.Contains("PriorityGear Setup started.", File.ReadAllText(path)); + } + + [Fact] + public void InitialLinesStartWithVisibleSetupMessage() + { + IReadOnlyList lines = SetupStartup.InitialLines( + new SetupCommandLine(SetupCommandAction.Install, Silent: false, Verify: false), + @"C:\ProgramData\PriorityGear\Logs\setup-test.log"); + + Assert.Equal("PriorityGear Setup started.", lines[0]); + Assert.Contains("Log:", lines[1]); + Assert.Contains("Mode: install/update", lines[2]); + } +} From a2c34c80264daebcd76a2ff92a2429e49b5387d4 Mon Sep 17 00:00:00 2001 From: Yuji Tsuchimoto Date: Mon, 18 May 2026 13:33:52 +0900 Subject: [PATCH 2/2] Address installer progress review feedback --- .../InstallerStateMachine.cs | 18 +++++++--- src/PriorityGear.Setup/Program.cs | 33 +++++++++++++---- src/PriorityGear.Setup/SetupLog.cs | 35 +++++++++++++++---- .../InstallerStateMachineTests.cs | 1 + .../PriorityGear.Setup.Tests/SetupLogTests.cs | 29 +++++++++++++++ 5 files changed, 99 insertions(+), 17 deletions(-) diff --git a/src/PriorityGear.Setup/InstallerStateMachine.cs b/src/PriorityGear.Setup/InstallerStateMachine.cs index b54f4fd..f0a97c9 100644 --- a/src/PriorityGear.Setup/InstallerStateMachine.cs +++ b/src/PriorityGear.Setup/InstallerStateMachine.cs @@ -114,6 +114,10 @@ public InstallerRunResult InstallOrUpdate() return Success("Install/update completed."); } + catch (InstallerStepException ex) + { + return Fail($"{ex.Step} failed: {ex.InnerException?.Message ?? ex.Message}"); + } catch (Exception ex) { return Fail(ex.Message); @@ -127,17 +131,17 @@ private InstallerStatus QueryStatus() private void RunRequired(InstallerStep step, Action action) { - Emit(InstallerProgressKind.Starting, step, $"Starting {step}."); + Emit(InstallerProgressKind.Starting, step, "Starting."); try { action(); _completedSteps.Add(step); - Emit(InstallerProgressKind.Completed, step, $"Completed {step}."); + Emit(InstallerProgressKind.Completed, step, "Completed."); } catch (Exception ex) { - Emit(InstallerProgressKind.Failed, step, $"Failed {step}: {ex.Message}"); - throw; + Emit(InstallerProgressKind.Failed, step, ex.Message); + throw new InstallerStepException(step, ex); } } @@ -162,6 +166,12 @@ private void Emit(InstallerProgressKind kind, InstallerStep step, string message Progress?.Invoke(new InstallerProgress(kind, step, message)); } + private sealed class InstallerStepException(InstallerStep step, Exception innerException) + : Exception(innerException.Message, innerException) + { + public InstallerStep Step { get; } = step; + } + public static bool ServiceBinaryPathMatches(string configuredPath, string expectedPath) { string? configuredExe = ExtractExecutablePath(configuredPath); diff --git a/src/PriorityGear.Setup/Program.cs b/src/PriorityGear.Setup/Program.cs index f83e01b..7e18ea9 100644 --- a/src/PriorityGear.Setup/Program.cs +++ b/src/PriorityGear.Setup/Program.cs @@ -225,21 +225,41 @@ private async Task RunAsync() private void AppendLogLine(string line) { - if (IsDisposed) + if (IsDisposed || !IsHandleCreated) { return; } void Append() { - _logBox.AppendText(line + Environment.NewLine); - _logBox.SelectionStart = _logBox.TextLength; - _logBox.ScrollToCaret(); + if (IsDisposed || _logBox.IsDisposed) + { + return; + } + + try + { + _logBox.AppendText(line + Environment.NewLine); + _logBox.SelectionStart = _logBox.TextLength; + _logBox.ScrollToCaret(); + } + catch (ObjectDisposedException) + { + } + catch (InvalidOperationException) + { + } } if (InvokeRequired) { - BeginInvoke((Action)Append); + try + { + BeginInvoke((Action)Append); + } + catch (InvalidOperationException) + { + } } else { @@ -276,7 +296,7 @@ internal static async Task InstallAsync(SetupInstallPlan pla InstallerStateMachine stateMachine = new( plan, new ProductionInstallerExecutor(plan, log, SetupPayload.PayloadDirectory(AppContext.BaseDirectory))); - stateMachine.Progress += progress => log.Info($"{progress.Kind}: {progress.Step}: {progress.Message}"); + stateMachine.Progress += progress => log.Info($"{progress.Step} {progress.Kind}: {progress.Message}"); InstallerRunResult result = stateMachine.InstallOrUpdate(); foreach (string warning in result.Warnings) { @@ -698,7 +718,6 @@ private void Finish(string summary, SetupLog log) { log.Info($"Log: {log.Path}"); log.Flush(); - _logBox.Text = log.ToString(); MessageBox.Show( $"{summary}\r\n\r\nLog: {log.Path}", "PriorityGear Setup", diff --git a/src/PriorityGear.Setup/SetupLog.cs b/src/PriorityGear.Setup/SetupLog.cs index d35fd31..06735d9 100644 --- a/src/PriorityGear.Setup/SetupLog.cs +++ b/src/PriorityGear.Setup/SetupLog.cs @@ -19,10 +19,16 @@ public sealed class SetupLog(string path) public void Flush() { - lock (_sync) + try + { + lock (_sync) + { + Directory.CreateDirectory(System.IO.Path.GetDirectoryName(Path)!); + File.WriteAllText(Path, _content.ToString(), Encoding.UTF8); + } + } + catch (Exception ex) when (IsLogIoFailure(ex)) { - Directory.CreateDirectory(System.IO.Path.GetDirectoryName(Path)!); - File.WriteAllText(Path, _content.ToString(), Encoding.UTF8); } } @@ -40,10 +46,27 @@ private void Write(string level, string message) lock (_sync) { _content.AppendLine(line); - Directory.CreateDirectory(System.IO.Path.GetDirectoryName(Path)!); - File.AppendAllText(Path, line + Environment.NewLine, Encoding.UTF8); + try + { + Directory.CreateDirectory(System.IO.Path.GetDirectoryName(Path)!); + File.AppendAllText(Path, line + Environment.NewLine, Encoding.UTF8); + } + catch (Exception ex) when (IsLogIoFailure(ex)) + { + } + } + + try + { + LineWritten?.Invoke(line); + } + catch (Exception ex) when (ex is InvalidOperationException or ObjectDisposedException) + { } + } - LineWritten?.Invoke(line); + private static bool IsLogIoFailure(Exception ex) + { + return ex is IOException or UnauthorizedAccessException or NotSupportedException or System.Security.SecurityException; } } diff --git a/tests/PriorityGear.Setup.Tests/InstallerStateMachineTests.cs b/tests/PriorityGear.Setup.Tests/InstallerStateMachineTests.cs index d547f1f..d54a8d5 100644 --- a/tests/PriorityGear.Setup.Tests/InstallerStateMachineTests.cs +++ b/tests/PriorityGear.Setup.Tests/InstallerStateMachineTests.cs @@ -189,6 +189,7 @@ public void FailureEmitsFailedStepAndStopsLaterCompletion() InstallerRunResult result = stateMachine.InstallOrUpdate(); Assert.False(result.Succeeded); + Assert.Contains("ConfigureService failed", result.Message); Assert.Contains(progress, p => p.Kind == InstallerProgressKind.Failed && p.Step == InstallerStep.ConfigureService); Assert.DoesNotContain(progress, p => p.Kind == InstallerProgressKind.Completed && p.Step == InstallerStep.StartService); } diff --git a/tests/PriorityGear.Setup.Tests/SetupLogTests.cs b/tests/PriorityGear.Setup.Tests/SetupLogTests.cs index dd7ee18..e207cd4 100644 --- a/tests/PriorityGear.Setup.Tests/SetupLogTests.cs +++ b/tests/PriorityGear.Setup.Tests/SetupLogTests.cs @@ -20,6 +20,35 @@ public void SetupLogInvokesProgressCallbackAndFlushesImmediately() Assert.Contains("PriorityGear Setup started.", File.ReadAllText(path)); } + [Fact] + public void SetupLogSubscriberFailureDoesNotCrashLogging() + { + string path = Path.Combine(Path.GetTempPath(), "PriorityGear.Setup.Tests", Guid.NewGuid().ToString("N"), "setup.log"); + SetupLog log = new(path); + log.LineWritten += _ => throw new InvalidOperationException("form disposed"); + + log.Info("PriorityGear Setup started."); + + Assert.True(File.Exists(path)); + Assert.Contains("PriorityGear Setup started.", File.ReadAllText(path)); + } + + [Fact] + public void SetupLogIoFailureDoesNotCrashLogging() + { + string directoryPath = Path.Combine(Path.GetTempPath(), "PriorityGear.Setup.Tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directoryPath); + SetupLog log = new(directoryPath); + List lines = []; + log.LineWritten += lines.Add; + + log.Info("PriorityGear Setup started."); + log.Flush(); + + Assert.Single(lines); + Assert.Contains("PriorityGear Setup started.", log.ToString()); + } + [Fact] public void InitialLinesStartWithVisibleSetupMessage() {