From 5b4f7a937a0a53d531a8507f17211f71649207bb Mon Sep 17 00:00:00 2001 From: Yuji Tsuchimoto Date: Mon, 18 May 2026 17:41:35 +0900 Subject: [PATCH 1/2] Register app in Start Menu during install --- README.ja.md | 17 ++++-- README.md | 21 +++++--- docs/installer.md | 20 +++++-- docs/release-drafts/v0.3.4.md | 23 ++++++++ src/PriorityGear.Setup/Program.cs | 18 +++++++ src/PriorityGear.Setup/SetupInstallPlan.cs | 8 +++ src/PriorityGear.Setup/StartMenuShortcut.cs | 54 +++++++++++++++++++ .../SetupPlanningTests.cs | 14 +++++ 8 files changed, 159 insertions(+), 16 deletions(-) create mode 100644 docs/release-drafts/v0.3.4.md create mode 100644 src/PriorityGear.Setup/StartMenuShortcut.cs diff --git a/README.ja.md b/README.ja.md index 41aea8b..08dbc06 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.3 System Mode Installer Release +## v0.3.4 System Mode Installer Release -`v0.3.3` は formal System Mode installer 用の次の GitHub release です。`v0.3.1` / `v0.3.2` の installer window が blank progress に見える問題を修正し、起動直後に log path と進行状況を表示します。 +`v0.3.4` は formal System Mode installer 用の次の GitHub release です。Windows の標準に合わせ、install 時に PriorityGear を Start Menu に登録します。 公開 artifact は次です。 ```text -PriorityGear-v0.3.3-win-x64-installer.zip +PriorityGear-v0.3.4-win-x64-installer.zip ``` zip には `PriorityGear.Setup.exe` が含まれます。ダブルクリックして UAC を承認すると PriorityGear を install / update します。この installer は AS IS であり、署名を明示的に追加するまでは unsigned です。 @@ -56,11 +56,17 @@ zip には `PriorityGear.Setup.exe` が含まれます。ダブルクリック 同じ installer artifact をローカルで作成する場合: ```powershell -.\scripts\package-release.ps1 -TagName "v0.3.3" -OutputDirectory ".\artifacts\release-test-v0.3.3" +.\scripts\package-release.ps1 -TagName "v0.3.4" -OutputDirectory ".\artifacts\release-test-v0.3.4" ``` installer は GUI app を配置し、`PriorityGear.Service` を `%ProgramFiles%\PriorityGear\versions` 以下の versioned directory から起動する LocalSystem Windows Service として構成します。`%ProgramData%\PriorityGear\rules.machine.json` と `%ProgramData%\PriorityGear\Logs` は保持します。 +install 後は次から起動します。 + +```text +Start Menu > PriorityGear > PriorityGear +``` + uninstall は次で実行します。 ```text @@ -68,6 +74,7 @@ PriorityGear.Setup.exe --uninstall ``` uninstall は service を停止・削除し、installed program files を削除します。ProgramData は既定で保持します。 +Start Menu shortcut も削除します。 v0.2 検証では、対話ユーザー側の TestTarget、machine-rule monitor path、一時的な LocalSystem-owned `PriorityGear.TestTarget.Service`、targeted service discovery、その安全な temporary service への service-name machine rule が成功済みです。SCM API discovery 後、検証全体はテスト環境で約 8 秒になっています。 @@ -79,7 +86,7 @@ v0.2 の範囲は LocalSystem service の検証用 install/update、status/admin `v0.2.0-preview.1` は System Mode foundation の過去の public prerelease として残ります。 -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 しています。 +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 は install 後の起動導線と documentation が不十分だったため 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 1b2612f..08975b2 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.3 System Mode Installer Release +## v0.3.4 System Mode Installer Release -`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. +`v0.3.4` is the next GitHub release for the formal System Mode installer. It adds the standard Windows launch path by registering PriorityGear in the Start Menu during install. The release artifact is: ```text -PriorityGear-v0.3.3-win-x64-installer.zip +PriorityGear-v0.3.4-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,11 +48,17 @@ 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.3" -OutputDirectory ".\artifacts\release-test-v0.3.3" +.\scripts\package-release.ps1 -TagName "v0.3.4" -OutputDirectory ".\artifacts\release-test-v0.3.4" ``` 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`. +After install, launch the app from: + +```text +Start Menu > PriorityGear > PriorityGear +``` + For uninstall, run: ```text @@ -60,6 +66,7 @@ PriorityGear.Setup.exe --uninstall ``` Uninstall stops and deletes the service and removes installed program files. It preserves ProgramData by default. +It also removes the Start Menu shortcut. The v0.2 verification has confirmed the main service path for an interactive test target, the machine-rule monitor path, a temporary LocalSystem-owned `PriorityGear.TestTarget.Service`, targeted service discovery, and a service-name machine rule for that safe temporary service. After SCM API discovery, the full verification completes in about 8 seconds on the tested Windows 11 machine. @@ -77,17 +84,17 @@ Post-verification state: `PriorityGear.Service` may remain installed/running, te ## Artifacts -### v0.3.3 GitHub Installer +### v0.3.4 GitHub Installer The current GitHub release artifact is: ```text -PriorityGear-v0.3.3-win-x64-installer.zip +PriorityGear-v0.3.4-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. -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. +The installer supports `--install --silent` and `--uninstall --silent`. winget registration is not done in this release; the earlier winget submission was closed because the installed application launch path and documentation were not ready. ### v0.1 User Mode Portable Publish diff --git a/docs/installer.md b/docs/installer.md index 09c384d..7da7508 100644 --- a/docs/installer.md +++ b/docs/installer.md @@ -1,13 +1,13 @@ # PriorityGear Installer -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. +PriorityGear `v0.3.4` is the next formal GitHub release installer path. It follows the Windows desktop convention that installed applications are launched from the Start Menu. ## Artifact The primary release artifact is: ```text -PriorityGear-v0.3.3-win-x64-installer.zip +PriorityGear-v0.3.4-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,11 +17,12 @@ 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.3`; +- installs files under `%ProgramFiles%\PriorityGear\versions\v0.3.4`; - configures `PriorityGear.Service` as LocalSystem; - starts or restarts the service; - confirms the status pipe responds; - confirms the service reports LocalSystem, process identity, and `SeDebugPrivilege` state; +- registers `PriorityGear` in the machine-wide Start Menu; - writes an install/update log under `%ProgramData%\PriorityGear\Logs`. The installer fails explicitly if install or update cannot be completed. @@ -34,6 +35,16 @@ For package-manager automation, use: Silent mode runs without setup UI or message boxes after elevation. The required install/update checks are unchanged. +## Launch + +After install, launch PriorityGear from: + +```text +Start Menu > PriorityGear > PriorityGear +``` + +The Start Menu shortcut opens the installed GUI app from the current version directory under `%ProgramFiles%\PriorityGear\versions`. + ## Uninstall Run: @@ -43,6 +54,7 @@ Run: ``` Uninstall stops and deletes `PriorityGear.Service`, then removes installed program files under `%ProgramFiles%\PriorityGear`. It preserves `%ProgramData%\PriorityGear` by default, including machine rules and logs. +It also removes the Start Menu shortcut. For silent uninstall: @@ -52,7 +64,7 @@ For silent uninstall: ## winget -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. +winget registration is not done in this release. A previous submission was closed because the installed application launch path and documentation were not ready. 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.4.md b/docs/release-drafts/v0.3.4.md new file mode 100644 index 0000000..6eca2a0 --- /dev/null +++ b/docs/release-drafts/v0.3.4.md @@ -0,0 +1,23 @@ +# PriorityGear v0.3.4 - Start Menu installer registration + +`v0.3.4` is a formal GitHub installer patch release after `v0.3.3`. + +## Summary + +- Registers PriorityGear in the machine-wide Start Menu during install. +- The installed application is launched from `Start Menu > PriorityGear > PriorityGear`. +- Removes the Start Menu shortcut during uninstall. +- Keeps the `PriorityGear.Setup.exe` installer distribution. +- Keeps live setup progress and deterministic setup logging from `v0.3.3`. + +## Safety boundary + +- Provided AS IS. +- Unsigned. +- Installs a LocalSystem Windows Service for System Mode. +- Changing process priority can affect system responsiveness and stability. +- No Store, winget, signing, MSI, or MSIX distribution is included in this release. +- Arbitrary shared-host `svchost.exe` mutation remains out of scope. +- GitHub Actions does not run elevated setup. + +winget registration is not included in this release. The previous winget submission was closed because the installed application launch path and documentation were not ready. diff --git a/src/PriorityGear.Setup/Program.cs b/src/PriorityGear.Setup/Program.cs index 7e18ea9..0e8040d 100644 --- a/src/PriorityGear.Setup/Program.cs +++ b/src/PriorityGear.Setup/Program.cs @@ -309,6 +309,7 @@ internal static async Task InstallAsync(SetupInstallPlan pla } WriteUninstallRegistration(plan, log); + CreateStartMenuShortcut(plan, log); log.Section("Final verdict"); log.Info(result.Message); return result; @@ -362,6 +363,7 @@ internal static async Task UninstallAsync(SetupInstallPlan plan, SetupLog log) } DeleteUninstallRegistration(plan, log); + DeleteStartMenuShortcut(plan, log); log.Info($"Preserved data directory: {plan.ProgramDataDirectory}"); log.Info("Delete ProgramData manually only if machine rules and logs are no longer needed."); } @@ -595,6 +597,16 @@ private static void WriteUninstallRegistration(SetupInstallPlan plan, SetupLog l log.Info($"Registered uninstall entry: HKLM\\{keyPath}"); } + private static void CreateStartMenuShortcut(SetupInstallPlan plan, SetupLog log) + { + log.Section("Start Menu registration"); + ShortcutSpec spec = StartMenuShortcut.CreateSpec(plan); + log.Info($"Shortcut path: {spec.ShortcutPath}"); + log.Info($"Shortcut target: {spec.TargetPath}"); + StartMenuShortcut.Create(plan); + log.Info("Start Menu shortcut registered."); + } + private static void DeleteUninstallRegistration(SetupInstallPlan plan, SetupLog log) { string keyPath = $@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{UninstallRegistration.KeyName(plan.Version)}"; @@ -602,6 +614,12 @@ private static void DeleteUninstallRegistration(SetupInstallPlan plan, SetupLog log.Info($"Removed uninstall entry if present: HKLM\\{keyPath}"); } + private static void DeleteStartMenuShortcut(SetupInstallPlan plan, SetupLog log) + { + StartMenuShortcut.Delete(plan); + log.Info($"Removed Start Menu shortcut if present: {plan.StartMenuShortcutPath}"); + } + private sealed class ProductionInstallerExecutor(SetupInstallPlan plan, SetupLog log, string payloadDirectory) : IInstallerExecutor { public void ValidatePayload() diff --git a/src/PriorityGear.Setup/SetupInstallPlan.cs b/src/PriorityGear.Setup/SetupInstallPlan.cs index 05aecb1..0330a5e 100644 --- a/src/PriorityGear.Setup/SetupInstallPlan.cs +++ b/src/PriorityGear.Setup/SetupInstallPlan.cs @@ -15,6 +15,14 @@ public sealed record SetupInstallPlan( { public string SetupExePath => Path.Combine(VersionInstallDirectory, "PriorityGear.Setup.exe"); + public string AppExePath => Path.Combine(VersionInstallDirectory, "PriorityGear.App.exe"); + + public string StartMenuDirectory => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonPrograms), + "PriorityGear"); + + public string StartMenuShortcutPath => Path.Combine(StartMenuDirectory, "PriorityGear.lnk"); + public static SetupInstallPlan Create(string version) { if (!Regex.IsMatch(version, @"^v[0-9]+\.[0-9]+\.[0-9]+$") || diff --git a/src/PriorityGear.Setup/StartMenuShortcut.cs b/src/PriorityGear.Setup/StartMenuShortcut.cs new file mode 100644 index 0000000..94539ed --- /dev/null +++ b/src/PriorityGear.Setup/StartMenuShortcut.cs @@ -0,0 +1,54 @@ +namespace PriorityGear.Setup; + +public static class StartMenuShortcut +{ + public static ShortcutSpec CreateSpec(SetupInstallPlan plan) + { + return new ShortcutSpec( + plan.StartMenuShortcutPath, + plan.AppExePath, + plan.VersionInstallDirectory, + "PriorityGear"); + } + + public static void Create(SetupInstallPlan plan) + { + ShortcutSpec spec = CreateSpec(plan); + Directory.CreateDirectory(Path.GetDirectoryName(spec.ShortcutPath)!); + + Type shellType = Type.GetTypeFromProgID("WScript.Shell") + ?? throw new InvalidOperationException("WScript.Shell is unavailable; cannot create Start Menu shortcut."); + dynamic shell = Activator.CreateInstance(shellType) + ?? throw new InvalidOperationException("Failed to create WScript.Shell."); + dynamic shortcut = shell.CreateShortcut(spec.ShortcutPath); + shortcut.TargetPath = spec.TargetPath; + shortcut.WorkingDirectory = spec.WorkingDirectory; + shortcut.Description = spec.Description; + shortcut.Save(); + + if (!File.Exists(spec.ShortcutPath)) + { + throw new FileNotFoundException($"Start Menu shortcut was not created: {spec.ShortcutPath}", spec.ShortcutPath); + } + } + + public static void Delete(SetupInstallPlan plan) + { + if (File.Exists(plan.StartMenuShortcutPath)) + { + File.Delete(plan.StartMenuShortcutPath); + } + + if (Directory.Exists(plan.StartMenuDirectory) && + !Directory.EnumerateFileSystemEntries(plan.StartMenuDirectory).Any()) + { + Directory.Delete(plan.StartMenuDirectory); + } + } +} + +public sealed record ShortcutSpec( + string ShortcutPath, + string TargetPath, + string WorkingDirectory, + string Description); diff --git a/tests/PriorityGear.Setup.Tests/SetupPlanningTests.cs b/tests/PriorityGear.Setup.Tests/SetupPlanningTests.cs index b26f315..8ac0717 100644 --- a/tests/PriorityGear.Setup.Tests/SetupPlanningTests.cs +++ b/tests/PriorityGear.Setup.Tests/SetupPlanningTests.cs @@ -18,6 +18,8 @@ public void InstallPlanUsesVersionedProgramFilesPath() Assert.Contains(plan.VersionInstallDirectory, plan.ServiceExePath); Assert.Contains("PriorityGear.Setup.exe", plan.SetupExePath); Assert.Contains(plan.VersionInstallDirectory, plan.SetupExePath); + Assert.Contains("PriorityGear.App.exe", plan.AppExePath); + Assert.Contains(plan.VersionInstallDirectory, plan.AppExePath); } [Fact] @@ -128,6 +130,18 @@ public void UninstallRegistrationUsesMachineScopeQuietUninstall() Assert.Equal($"\"{plan.SetupExePath}\" --uninstall --silent", values["QuietUninstallString"]); } + [Fact] + public void StartMenuShortcutTargetsInstalledGuiApp() + { + SetupInstallPlan plan = SetupInstallPlan.Create("v0.3.4"); + ShortcutSpec spec = StartMenuShortcut.CreateSpec(plan); + + Assert.EndsWith(Path.Combine("PriorityGear", "PriorityGear.lnk"), spec.ShortcutPath, StringComparison.OrdinalIgnoreCase); + Assert.Equal(plan.AppExePath, spec.TargetPath); + Assert.Equal(plan.VersionInstallDirectory, spec.WorkingDirectory); + Assert.Equal("PriorityGear", spec.Description); + } + private static string FindRepoRoot() { DirectoryInfo? directory = new(AppContext.BaseDirectory); From c0225d882b93bbf07be764cdce305b2c258c03c0 Mon Sep 17 00:00:00 2001 From: Yuji Tsuchimoto Date: Tue, 19 May 2026 09:25:46 +0900 Subject: [PATCH 2/2] Address Start Menu shortcut review feedback --- src/PriorityGear.Setup/StartMenuShortcut.cs | 99 +++++++++++++++++++-- 1 file changed, 90 insertions(+), 9 deletions(-) diff --git a/src/PriorityGear.Setup/StartMenuShortcut.cs b/src/PriorityGear.Setup/StartMenuShortcut.cs index 94539ed..aa2270c 100644 --- a/src/PriorityGear.Setup/StartMenuShortcut.cs +++ b/src/PriorityGear.Setup/StartMenuShortcut.cs @@ -1,3 +1,6 @@ +using System.Runtime.InteropServices; +using System.Text; + namespace PriorityGear.Setup; public static class StartMenuShortcut @@ -16,15 +19,29 @@ public static void Create(SetupInstallPlan plan) ShortcutSpec spec = CreateSpec(plan); Directory.CreateDirectory(Path.GetDirectoryName(spec.ShortcutPath)!); - Type shellType = Type.GetTypeFromProgID("WScript.Shell") - ?? throw new InvalidOperationException("WScript.Shell is unavailable; cannot create Start Menu shortcut."); - dynamic shell = Activator.CreateInstance(shellType) - ?? throw new InvalidOperationException("Failed to create WScript.Shell."); - dynamic shortcut = shell.CreateShortcut(spec.ShortcutPath); - shortcut.TargetPath = spec.TargetPath; - shortcut.WorkingDirectory = spec.WorkingDirectory; - shortcut.Description = spec.Description; - shortcut.Save(); + object? shellLinkObject = null; + try + { + shellLinkObject = new ShellLink(); + IShellLinkW shellLink = (IShellLinkW)shellLinkObject; + shellLink.SetPath(spec.TargetPath); + shellLink.SetWorkingDirectory(spec.WorkingDirectory); + shellLink.SetDescription(spec.Description); + ((IPersistFile)shellLink).Save(spec.ShortcutPath, remember: true); + } + catch (Exception ex) when (ex is COMException or InvalidCastException or UnauthorizedAccessException or IOException) + { + throw new InvalidOperationException( + $"Failed to create Start Menu shortcut: {spec.ShortcutPath}; target: {spec.TargetPath}", + ex); + } + finally + { + if (shellLinkObject is not null && Marshal.IsComObject(shellLinkObject)) + { + Marshal.FinalReleaseComObject(shellLinkObject); + } + } if (!File.Exists(spec.ShortcutPath)) { @@ -52,3 +69,67 @@ public sealed record ShortcutSpec( string TargetPath, string WorkingDirectory, string Description); + +[ComImport] +[Guid("00021401-0000-0000-C000-000000000046")] +internal sealed class ShellLink; + +[ComImport] +[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] +[Guid("000214F9-0000-0000-C000-000000000046")] +internal interface IShellLinkW +{ + void GetPath([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszFile, int cchMaxPath, IntPtr pfd, uint fFlags); + + void GetIDList(out IntPtr ppidl); + + void SetIDList(IntPtr pidl); + + void GetDescription([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszName, int cchMaxName); + + void SetDescription([MarshalAs(UnmanagedType.LPWStr)] string pszName); + + void GetWorkingDirectory([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszDir, int cchMaxPath); + + void SetWorkingDirectory([MarshalAs(UnmanagedType.LPWStr)] string pszDir); + + void GetArguments([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszArgs, int cchMaxPath); + + void SetArguments([MarshalAs(UnmanagedType.LPWStr)] string pszArgs); + + void GetHotkey(out short pwHotkey); + + void SetHotkey(short wHotkey); + + void GetShowCmd(out int piShowCmd); + + void SetShowCmd(int iShowCmd); + + void GetIconLocation([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszIconPath, int cchIconPath, out int piIcon); + + void SetIconLocation([MarshalAs(UnmanagedType.LPWStr)] string pszIconPath, int iIcon); + + void SetRelativePath([MarshalAs(UnmanagedType.LPWStr)] string pszPathRel, uint dwReserved); + + void Resolve(IntPtr hwnd, uint fFlags); + + void SetPath([MarshalAs(UnmanagedType.LPWStr)] string pszFile); +} + +[ComImport] +[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] +[Guid("0000010b-0000-0000-C000-000000000046")] +internal interface IPersistFile +{ + void GetClassID(out Guid pClassID); + + void IsDirty(); + + void Load([MarshalAs(UnmanagedType.LPWStr)] string pszFileName, uint dwMode); + + void Save([MarshalAs(UnmanagedType.LPWStr)] string pszFileName, [MarshalAs(UnmanagedType.Bool)] bool remember); + + void SaveCompleted([MarshalAs(UnmanagedType.LPWStr)] string pszFileName); + + void GetCurFile([MarshalAs(UnmanagedType.LPWStr)] out string ppszFileName); +}