From 3e4c7dda3c5dd09848d94e6bdb882ebfc8c2de18 Mon Sep 17 00:00:00 2001
From: JAVCIF <81954917+JAVCIF@users.noreply.github.com>
Date: Thu, 27 Aug 2026 23:38:01 -0500
Subject: [PATCH 1/2] Fix taskbar icon collisions with explicit launcher and
game AppUserModelIDs
---
.github/workflows/release.yml | 20 ++-
CHANGELOG.md | 9 ++
LocaleGameHub/App.xaml.cs | 7 +-
LocaleGameHub/LocaleGameHub.csproj | 2 +-
LocaleGameHub/Services/ShortcutService.cs | 1 +
.../Services/TaskbarIdentityService.cs | 103 ++++++++++++
README.es.md | 20 ++-
README.md | 24 ++-
installer/VNAR.iss | 6 +-
tests/VNAR.ShellTests/Program.cs | 150 ++++++++++++++++++
tests/VNAR.ShellTests/VNAR.ShellTests.csproj | 15 ++
11 files changed, 340 insertions(+), 17 deletions(-)
create mode 100644 LocaleGameHub/Services/TaskbarIdentityService.cs
create mode 100644 tests/VNAR.ShellTests/Program.cs
create mode 100644 tests/VNAR.ShellTests/VNAR.ShellTests.csproj
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 47444e8..68b2e83 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -17,7 +17,7 @@ concurrency:
cancel-in-progress: false
env:
- RELEASE_TAG: v1.0.0-beta.1.1
+ RELEASE_TAG: v1.0.0-beta.1.2
jobs:
build-release:
@@ -35,6 +35,9 @@ jobs:
- name: Restore
run: dotnet restore .\LocaleGameHub\LocaleGameHub.csproj
+ - name: Test Windows taskbar and shortcut identities
+ run: dotnet run --project .\tests\VNAR.ShellTests\VNAR.ShellTests.csproj -c Release
+
- name: Publish self-contained build
shell: pwsh
run: |
@@ -135,9 +138,18 @@ jobs:
run: |
$tag = "${{ env.RELEASE_TAG }}"
$notes = @"
- ## VNAR Beta 1.1
+ ## VNAR Beta 1.2
+
+ Fixes the launcher taskbar icon being replaced by a previously opened game shortcut.
+
+ - Stable, separate taskbar identity for the VNAR library window.
+ - Unique persistent identity for each game shortcut, independent of the admin setting.
+ - Installer shortcuts use the same launcher identity.
+ - Windows regression tests check process identities and saved shortcut properties.
- First public GitHub beta package.
+ Recreate old game shortcuts to add their explicit identity. If an old pinned VNAR
+ icon stays cached, unpin it and pin the running Beta 1.2 launcher again.
+ Existing library data and game launch profiles are unchanged.
### Downloads
- **VNAR-Setup.exe** — self-contained Windows installer.
@@ -156,7 +168,7 @@ jobs:
.\dist\VNAR-Setup.exe `
.\dist\VNAR-Portable-win-x64.zip `
.\dist\SHA256SUMS.txt `
- --title "VNAR Beta 1.1" `
+ --title "VNAR Beta 1.2" `
--notes $notes `
--prerelease --draft `
--target "${{ github.sha }}"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 32a1d21..47db5f1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,14 @@
# Changelog
+## 1.0.0-beta.1.2
+
+- Fix taskbar icon collisions between the VNAR launcher and game shortcuts targeting the same executable.
+- Assign a stable launcher AppUserModelID and a distinct ID per game, before creating windows.
+- Persist game IDs in shortcut properties without changing their icons, targets, or arguments.
+- Set the launcher ID on installer-created shortcuts.
+- Add Windows integration checks for process identity and shortcut persistence, with normal/admin game settings.
+
+
## 1.0.0-beta.1.1
First public GitHub beta package.
diff --git a/LocaleGameHub/App.xaml.cs b/LocaleGameHub/App.xaml.cs
index 46b8bae..0cfed31 100644
--- a/LocaleGameHub/App.xaml.cs
+++ b/LocaleGameHub/App.xaml.cs
@@ -16,10 +16,15 @@ protected override void OnStartup(StartupEventArgs e)
try
{
+ var isGameLaunch = TryGetLaunchGameId(e.Args, out var gameId);
+ // Set the identity before creating any windows, including error dialogs.
+ // Old shortcuts without an explicit AppID cannot claim the launcher UI.
+ TaskbarIdentityService.SetCurrentProcessIdentity(isGameLaunch ? gameId : null);
+
var preferences = new LibraryService();
LocalizationService.SetLanguage(preferences.Settings.Language);
- if (TryGetLaunchGameId(e.Args, out var gameId))
+ if (isGameLaunch)
{
LaunchFromShortcut(preferences, gameId);
Shutdown(0);
diff --git a/LocaleGameHub/LocaleGameHub.csproj b/LocaleGameHub/LocaleGameHub.csproj
index 787878b..7a78c84 100644
--- a/LocaleGameHub/LocaleGameHub.csproj
+++ b/LocaleGameHub/LocaleGameHub.csproj
@@ -7,7 +7,7 @@
enable
VNAR
LocaleGameHub
- 1.0.0-beta.1.1
+ 1.0.0-beta.1.2
app.manifest
Resources\VNAR.ico
diff --git a/LocaleGameHub/Services/ShortcutService.cs b/LocaleGameHub/Services/ShortcutService.cs
index 4918d8e..843d9d3 100644
--- a/LocaleGameHub/Services/ShortcutService.cs
+++ b/LocaleGameHub/Services/ShortcutService.cs
@@ -54,6 +54,7 @@ public static string CreateShortcut(GameEntry game, string shortcutName, string
ReleaseCom(shellObject);
}
+ TaskbarIdentityService.SetShortcutIdentity(shortcutPath, game.Id);
return shortcutPath;
}
diff --git a/LocaleGameHub/Services/TaskbarIdentityService.cs b/LocaleGameHub/Services/TaskbarIdentityService.cs
new file mode 100644
index 0000000..f4b6e49
--- /dev/null
+++ b/LocaleGameHub/Services/TaskbarIdentityService.cs
@@ -0,0 +1,103 @@
+using System.Runtime.InteropServices;
+using System.Runtime.InteropServices.ComTypes;
+
+namespace LocaleGameHub.Services;
+
+///
+/// Prevents Explorer from inferring one shared taskbar identity from VNAR.exe
+/// for the library UI and every "--launch" shortcut.
+///
+public static class TaskbarIdentityService
+{
+ // Keep this stable across versions, and in sync with installer/VNAR.iss.
+ public const string LauncherAppId = "JAVCIF.VNAR.Launcher";
+
+ public static string GetGameAppId(Guid gameId) => $"JAVCIF.VNAR.Game.{gameId:N}";
+
+ public static void SetCurrentProcessIdentity(Guid? gameId = null)
+ {
+ var appId = gameId.HasValue ? GetGameAppId(gameId.Value) : LauncherAppId;
+ Marshal.ThrowExceptionForHR(SetCurrentProcessExplicitAppUserModelID(appId));
+ }
+
+ public static void SetShortcutIdentity(string shortcutPath, Guid gameId)
+ {
+ // WScript.Shell exposes the icon/arguments but not System.AppUserModel.ID.
+ // Reopen the saved link and change only that property, preserving its icon,
+ // target, arguments and any existing Shell Link flags.
+ var linkType = Type.GetTypeFromCLSID(new Guid("00021401-0000-0000-C000-000000000046"), true)!;
+ var link = Activator.CreateInstance(linkType)
+ ?? throw new InvalidOperationException("Could not open the Shell Link property store.");
+
+ try
+ {
+ var file = (IPersistFile)link;
+ file.Load(shortcutPath, 2); // STGM_READWRITE
+ var store = (IPropertyStore)link;
+ var key = new PropertyKey
+ {
+ FormatId = new Guid("9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3"),
+ PropertyId = 5 // PKEY_AppUserModel_ID
+ };
+ var value = new PropVariant
+ {
+ ValueType = 31, // VT_LPWSTR
+ StringValue = Marshal.StringToCoTaskMemUni(GetGameAppId(gameId))
+ };
+
+ try
+ {
+ Marshal.ThrowExceptionForHR(store.SetValue(ref key, ref value));
+ Marshal.ThrowExceptionForHR(store.Commit());
+ file.Save(shortcutPath, true);
+ }
+ finally
+ {
+ Marshal.FreeCoTaskMem(value.StringValue);
+ }
+ }
+ finally
+ {
+ Marshal.FinalReleaseComObject(link);
+ }
+ }
+
+ [DllImport("shell32.dll", CharSet = CharSet.Unicode, ExactSpelling = true)]
+ private static extern int SetCurrentProcessExplicitAppUserModelID(string appId);
+
+ [StructLayout(LayoutKind.Sequential, Pack = 4)]
+ private struct PropertyKey
+ {
+ public Guid FormatId;
+ public uint PropertyId;
+ }
+
+ // PROPVARIANT's union contains a counted pointer: 16 bytes on x86,
+ // 24 on x64. Include that member so the native layout is correct on both.
+ [StructLayout(LayoutKind.Sequential)]
+ private struct CountedPointer
+ {
+ public uint Count;
+ public IntPtr Pointer;
+ }
+
+ [StructLayout(LayoutKind.Explicit)]
+ private struct PropVariant
+ {
+ [FieldOffset(0)] public ushort ValueType;
+ [FieldOffset(8)] public IntPtr StringValue;
+ [FieldOffset(8)] public CountedPointer ArrayValue;
+ }
+
+ [ComImport]
+ [Guid("886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99")]
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ private interface IPropertyStore
+ {
+ [PreserveSig] int GetCount(out uint count);
+ [PreserveSig] int GetAt(uint index, out PropertyKey key);
+ [PreserveSig] int GetValue(ref PropertyKey key, out PropVariant value);
+ [PreserveSig] int SetValue(ref PropertyKey key, ref PropVariant value);
+ [PreserveSig] int Commit();
+ }
+}
diff --git a/README.es.md b/README.es.md
index 61225bb..c9d4265 100644
--- a/README.es.md
+++ b/README.es.md
@@ -7,7 +7,7 @@
Un launcher ligero para Windows enfocado en visual novels, con integración con Locale Emulator, metadatos de VNDB, edición de portadas, navegación por desarrolladores, favoritos, soporte WebP y accesos directos inteligentes.
-[English](README.md) · [Descargar Beta 1.1](https://github.com/JAVCIF/vnar/releases/tag/v1.0.0-beta.1.1)
+[English](README.md) · [Descargar Beta 1.2](https://github.com/JAVCIF/vnar/releases/tag/v1.0.0-beta.1.2)
## Descargas
@@ -16,8 +16,8 @@ Un launcher ligero para Windows enfocado en visual novels, con integración con
| Paquete | Descripción | Descarga |
| --- | --- | --- |
-| Instalador | Instalación autocontenida, acceso en el menú Inicio y acceso de escritorio opcional | [VNAR-Setup.exe](https://github.com/JAVCIF/vnar/releases/download/v1.0.0-beta.1.1/VNAR-Setup.exe) |
-| Portable | Versión autocontenida; descomprime y ejecuta `VNAR.exe` | [VNAR-Portable-win-x64.zip](https://github.com/JAVCIF/vnar/releases/download/v1.0.0-beta.1.1/VNAR-Portable-win-x64.zip) |
+| Instalador | Instalación autocontenida, acceso en el menú Inicio y acceso de escritorio opcional | [VNAR-Setup.exe](https://github.com/JAVCIF/vnar/releases/download/v1.0.0-beta.1.2/VNAR-Setup.exe) |
+| Portable | Versión autocontenida; descomprime y ejecuta `VNAR.exe` | [VNAR-Portable-win-x64.zip](https://github.com/JAVCIF/vnar/releases/download/v1.0.0-beta.1.2/VNAR-Portable-win-x64.zip) |
Locale Emulator **no viene incluido** dentro de VNAR. En el primer arranque puedes seleccionar un `LEProc.exe` existente o dejar que VNAR descargue la versión oficial de Locale Emulator.
@@ -81,9 +81,21 @@ dotnet run --project .\LocaleGameHub\LocaleGameHub.csproj
También se incluyen `build_portable.bat` y `build_small.bat` para compilaciones locales.
+## Iconos de la barra de tareas (Beta 1.2)
+
+VNAR ahora declara su propio AppUserModelID de Windows. Cada acceso de juego tiene
+una identidad distinta y estable para que su icono no se asocie a la ventana del
+launcher. La opción de administrador sigue cambiando únicamente el perfil de
+Locale Emulator.
+
+Los accesos antiguos siguen funcionando. Vuelve a crearlos desde VNAR para añadir
+la nueva identidad. Si Windows conserva el icono de un VNAR anclado anteriormente,
+desancla ese acceso, abre el `VNAR.exe` actualizado y ancla la ventana nueva.
+No necesitas borrar la caché global de iconos ni tus datos.
+
## Estado del proyecto
-Versión pública actual: **Beta 1.1 (`1.0.0-beta.1.1`)**.
+Versión pública actual: **Beta 1.2 (`1.0.0-beta.1.2`)**.
Puedes usar GitHub Issues para reportar errores o proponer mejoras.
diff --git a/README.md b/README.md
index 025a0cc..537c69b 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@
A lightweight Windows launcher for visual novels with Locale Emulator integration, VNDB metadata, cover editing, developer browsing, favorites, WebP support, and smart desktop shortcuts.
-[Español](README.es.md) · [Download Beta 1.1](https://github.com/JAVCIF/vnar/releases/tag/v1.0.0-beta.1.1)
+[Español](README.es.md) · [Download Beta 1.2](https://github.com/JAVCIF/vnar/releases/tag/v1.0.0-beta.1.2)
## Downloads
@@ -16,8 +16,8 @@ A lightweight Windows launcher for visual novels with Locale Emulator integratio
| Package | Description | Download |
| --- | --- | --- |
-| Installer | Self-contained installation with Start Menu integration and optional desktop shortcut | [VNAR-Setup.exe](https://github.com/JAVCIF/vnar/releases/download/v1.0.0-beta.1.1/VNAR-Setup.exe) |
-| Portable | Self-contained portable build; extract and run `VNAR.exe` | [VNAR-Portable-win-x64.zip](https://github.com/JAVCIF/vnar/releases/download/v1.0.0-beta.1.1/VNAR-Portable-win-x64.zip) |
+| Installer | Self-contained installation with Start Menu integration and optional desktop shortcut | [VNAR-Setup.exe](https://github.com/JAVCIF/vnar/releases/download/v1.0.0-beta.1.2/VNAR-Setup.exe) |
+| Portable | Self-contained portable build; extract and run `VNAR.exe` | [VNAR-Portable-win-x64.zip](https://github.com/JAVCIF/vnar/releases/download/v1.0.0-beta.1.2/VNAR-Portable-win-x64.zip) |
Locale Emulator is **not bundled** with VNAR. On first launch, VNAR can use an existing `LEProc.exe` or download the official Locale Emulator release for you.
@@ -87,9 +87,25 @@ dotnet publish .\LocaleGameHub\LocaleGameHub.csproj -c Release -r win-x64 --self
The repository also includes `build_portable.bat` and `build_small.bat` for local Windows builds.
+## Taskbar icons (Beta 1.2)
+
+VNAR now declares its own Windows AppUserModelID. Game shortcuts use a separate,
+stable ID per game, so opening one cannot supply its icon to the VNAR library window.
+The administrator option still only selects the Locale Emulator profile.
+
+Existing shortcuts keep working. Recreate them in VNAR to add the new shortcut
+identity. If an old pinned VNAR icon remains cached, unpin it, open the updated
+`VNAR.exe`, and pin that running window again. No icon-cache reset is required.
+
+Windows shell regression checks:
+
+```powershell
+dotnet run --project .\tests\VNAR.ShellTests\VNAR.ShellTests.csproj -c Release
+```
+
## Project status
-Current public build: **Beta 1.1 (`1.0.0-beta.1.1`)**.
+Current public build: **Beta 1.2 (`1.0.0-beta.1.2`)**.
Bug reports and suggestions are welcome through GitHub Issues.
diff --git a/installer/VNAR.iss b/installer/VNAR.iss
index 57af486..c1b07bc 100644
--- a/installer/VNAR.iss
+++ b/installer/VNAR.iss
@@ -1,5 +1,5 @@
#define MyAppName "VNAR"
-#define MyAppVersion "1.0.0-beta.1.1"
+#define MyAppVersion "1.0.0-beta.1.2"
#define MyAppPublisher "JAVCIF"
#define MyAppExeName "VNAR.exe"
@@ -35,8 +35,8 @@ Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{
Source: "..\dist\portable\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
[Icons]
-Name: "{autoprograms}\VNAR"; Filename: "{app}\{#MyAppExeName}"; WorkingDir: "{app}"
-Name: "{autodesktop}\VNAR"; Filename: "{app}\{#MyAppExeName}"; WorkingDir: "{app}"; Tasks: desktopicon
+Name: "{autoprograms}\VNAR"; Filename: "{app}\{#MyAppExeName}"; WorkingDir: "{app}"; AppUserModelID: "JAVCIF.VNAR.Launcher"
+Name: "{autodesktop}\VNAR"; Filename: "{app}\{#MyAppExeName}"; WorkingDir: "{app}"; Tasks: desktopicon; AppUserModelID: "JAVCIF.VNAR.Launcher"
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,VNAR}"; Flags: nowait postinstall skipifsilent
diff --git a/tests/VNAR.ShellTests/Program.cs b/tests/VNAR.ShellTests/Program.cs
new file mode 100644
index 0000000..62163c5
--- /dev/null
+++ b/tests/VNAR.ShellTests/Program.cs
@@ -0,0 +1,150 @@
+using System;
+using System.IO;
+using System.Runtime.InteropServices;
+using LocaleGameHub.Models;
+using LocaleGameHub.Services;
+
+namespace VNAR.ShellTests;
+
+internal static class Program
+{
+ [STAThread]
+ private static int Main()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "VNAR Shell Tests " + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(root);
+
+ try
+ {
+ var first = new GameEntry { Name = "Normal game", RunAsAdmin = false };
+ var second = new GameEntry { Name = "Admin game", RunAsAdmin = true };
+ var icon = Path.Combine(root, "selected icon.ico");
+ File.Copy(Path.Combine(AppContext.BaseDirectory, "Fixture.ico"), icon);
+
+ // Exercise real shell32 APIs, including resetting an incoming game ID
+ // to the launcher ID. This does not launch games or request elevation.
+ TaskbarIdentityService.SetCurrentProcessIdentity(first.Id);
+ Equal(TaskbarIdentityService.GetGameAppId(first.Id), ReadProcessIdentity(), "first game process");
+ TaskbarIdentityService.SetCurrentProcessIdentity();
+ Equal(TaskbarIdentityService.LauncherAppId, ReadProcessIdentity(), "launcher after first game");
+ TaskbarIdentityService.SetCurrentProcessIdentity(second.Id);
+ Equal(TaskbarIdentityService.GetGameAppId(second.Id), ReadProcessIdentity(), "second game process");
+ TaskbarIdentityService.SetCurrentProcessIdentity();
+ Equal(TaskbarIdentityService.LauncherAppId, ReadProcessIdentity(), "launcher after second game");
+ Console.WriteLine("PASS: explicit process identities remain separate.");
+
+ var normalLink = ShortcutService.CreateShortcut(first, "Normal á", root, icon);
+ var adminLink = ShortcutService.CreateShortcut(second, "Admin かな", root, icon);
+ VerifyShortcut(normalLink, first, icon);
+ VerifyShortcut(adminLink, second, icon);
+ NotEqual(ReadShortcutIdentity(normalLink), ReadShortcutIdentity(adminLink), "different games");
+ NotEqual(TaskbarIdentityService.LauncherAppId, ReadShortcutIdentity(normalLink), "game vs launcher");
+ Equal(TaskbarIdentityService.LauncherAppId, ReadProcessIdentity(), "creating links cannot change the UI identity");
+ Console.WriteLine("PASS: normal/admin game shortcuts persist distinct AppIDs without changing the process identity.");
+
+ // Updating a game's admin preference or display name must not change
+ // the shortcut's grouping identity or turn it into the launcher.
+ first.RunAsAdmin = true;
+ first.Name = "Renamed game";
+ var renamedLink = ShortcutService.CreateShortcut(first, "Renamed admin", root, icon);
+ VerifyShortcut(renamedLink, first, icon);
+ Equal(ReadShortcutIdentity(normalLink), ReadShortcutIdentity(renamedLink), "same game after rename/admin change");
+ Equal(TaskbarIdentityService.LauncherAppId, ReadProcessIdentity(), "identity after later shortcuts");
+ Console.WriteLine("PASS: game identity stays stable across rename/admin setting changes.");
+
+ // An older link can be recreated in place, adding the property while
+ // retaining the intended launch arguments and chosen icon.
+ var replacement = ShortcutService.CreateShortcut(first, "Normal á", root, icon);
+ VerifyShortcut(replacement, first, icon);
+ Console.WriteLine("PASS: shortcut replacement preserves target, arguments, description and icon.");
+ return 0;
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine(ex);
+ return 1;
+ }
+ finally
+ {
+ Directory.Delete(root, recursive: true);
+ }
+ }
+
+ private static void VerifyShortcut(string path, GameEntry game, string icon)
+ {
+ Equal(TaskbarIdentityService.GetGameAppId(game.Id), ReadShortcutIdentity(path), "persisted AppUserModelID");
+
+ object? shellObject = null;
+ object? linkObject = null;
+ try
+ {
+ shellObject = Activator.CreateInstance(Type.GetTypeFromProgID("WScript.Shell")!);
+ dynamic shell = shellObject!;
+ linkObject = shell.CreateShortcut(path);
+ dynamic link = linkObject;
+ Equal(ShortcutService.ResolveVnarExecutable().ToUpperInvariant(),
+ ((string)link.TargetPath).ToUpperInvariant(), "shortcut target");
+ Equal($"--launch {game.Id:D}", (string)link.Arguments, "shortcut arguments");
+ Equal(icon.ToUpperInvariant() + ",0", ((string)link.IconLocation).ToUpperInvariant(), "chosen icon");
+ Equal($"VNAR · {game.Name}", (string)link.Description, "shortcut description");
+ }
+ finally
+ {
+ Release(linkObject);
+ Release(shellObject);
+ }
+ }
+
+ private static string ReadShortcutIdentity(string path)
+ {
+ // Read through Explorer's canonical property name, independently of the
+ // IPropertyStore implementation used to write the link.
+ object? shellObject = null;
+ object? folderObject = null;
+ object? itemObject = null;
+ try
+ {
+ shellObject = Activator.CreateInstance(Type.GetTypeFromProgID("Shell.Application")!);
+ dynamic shell = shellObject!;
+ folderObject = shell.NameSpace(Path.GetDirectoryName(path)!);
+ dynamic folder = folderObject!;
+ itemObject = folder.ParseName(Path.GetFileName(path));
+ dynamic item = itemObject!;
+ return Convert.ToString(item.ExtendedProperty("System.AppUserModel.ID")) ?? string.Empty;
+ }
+ finally
+ {
+ Release(itemObject);
+ Release(folderObject);
+ Release(shellObject);
+ }
+ }
+
+ private static string ReadProcessIdentity()
+ {
+ Marshal.ThrowExceptionForHR(GetCurrentProcessExplicitAppUserModelID(out var pointer));
+ try { return Marshal.PtrToStringUni(pointer) ?? string.Empty; }
+ finally { Marshal.FreeCoTaskMem(pointer); }
+ }
+
+ private static void Equal(string expected, string actual, string label)
+ {
+ if (actual != expected)
+ throw new InvalidOperationException($"{label}: expected '{expected}', got '{actual}'.");
+ }
+
+ private static void NotEqual(string first, string second, string label)
+ {
+ if (first == second)
+ throw new InvalidOperationException($"{label}: unexpected shared identity '{first}'.");
+ }
+
+ private static void Release(object? value)
+ {
+ if (value is not null && Marshal.IsComObject(value))
+ Marshal.FinalReleaseComObject(value);
+ }
+
+ [DllImport("shell32.dll", ExactSpelling = true)]
+ private static extern int GetCurrentProcessExplicitAppUserModelID(out IntPtr appId);
+}
diff --git a/tests/VNAR.ShellTests/VNAR.ShellTests.csproj b/tests/VNAR.ShellTests/VNAR.ShellTests.csproj
new file mode 100644
index 0000000..2c48a53
--- /dev/null
+++ b/tests/VNAR.ShellTests/VNAR.ShellTests.csproj
@@ -0,0 +1,15 @@
+
+
+ Exe
+ net10.0-windows
+ true
+ enable
+ enable
+
+
+
+
+ PreserveNewest
+
+
+
From 20b2ce616593938e683ea147a0354ac286040a0e Mon Sep 17 00:00:00 2001
From: JAVCIF <81954917+JAVCIF@users.noreply.github.com>
Date: Thu, 27 Aug 2026 23:40:00 -0500
Subject: [PATCH 2/2] Keep shell identity test filenames independent of WScript
code pages
---
tests/VNAR.ShellTests/Program.cs | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/tests/VNAR.ShellTests/Program.cs b/tests/VNAR.ShellTests/Program.cs
index 62163c5..74f345e 100644
--- a/tests/VNAR.ShellTests/Program.cs
+++ b/tests/VNAR.ShellTests/Program.cs
@@ -33,8 +33,9 @@ private static int Main()
Equal(TaskbarIdentityService.LauncherAppId, ReadProcessIdentity(), "launcher after second game");
Console.WriteLine("PASS: explicit process identities remain separate.");
- var normalLink = ShortcutService.CreateShortcut(first, "Normal á", root, icon);
- var adminLink = ShortcutService.CreateShortcut(second, "Admin かな", root, icon);
+ // Keep fixture filenames independent of WScript.Shell ANSI code-page limitations.
+ var normalLink = ShortcutService.CreateShortcut(first, "Normal shortcut", root, icon);
+ var adminLink = ShortcutService.CreateShortcut(second, "Admin shortcut", root, icon);
VerifyShortcut(normalLink, first, icon);
VerifyShortcut(adminLink, second, icon);
NotEqual(ReadShortcutIdentity(normalLink), ReadShortcutIdentity(adminLink), "different games");
@@ -54,7 +55,7 @@ private static int Main()
// An older link can be recreated in place, adding the property while
// retaining the intended launch arguments and chosen icon.
- var replacement = ShortcutService.CreateShortcut(first, "Normal á", root, icon);
+ var replacement = ShortcutService.CreateShortcut(first, "Normal shortcut", root, icon);
VerifyShortcut(replacement, first, icon);
Console.WriteLine("PASS: shortcut replacement preserves target, arguments, description and icon.");
return 0;