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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@ Release notes live here rather than beside the version number: the number is one
`Directory.Build.props` and nothing reads prose from there. Newest first, one section per
`MAJOR.MINOR`.

## 1.2

- **`AppIdentity.Claim(appId, displayName)`: an unpackaged Windows app that the shell can name.** A process
without an app id (AppUserModelID) is known by its executable's file name, which the shell cannot turn into an
app, so Windows' media flyout calls its media session "Unknown app". `Claim` gives the process the app id and
makes sure a Start menu shortcut of that name carries it, which is where the shell finds the name and the
icon (Chrome is "Google Chrome" there the same way). The shortcut is written when missing and afterwards only
read: a shortcut whose target is gone (the app was moved) is repointed at the running executable, one whose
target is still there is kept even when another copy runs (a development build does not take over the
installed app's), and a shortcut of the same name without the id is someone else's and left alone. The
outcome comes back as `StartMenuShortcut`; nothing throws for a shell that refuses. The shell's link object is
called by hand (three interfaces, a few vtable slots), so the library takes no COM wrapper dependency. No-op
off Windows.

## 1.1

- **`InstanceGate` is exclusive on Linux and macOS too.** It never was: .NET's named-pipe server
Expand Down
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
(dotnet msbuild -getProperty:VersionMajorMinor), so a release bump is this one line and CI
cannot publish a version the packages disagree with. 1.0 -> 1.1 for additive features,
1.x -> 2.x for breaking API changes. -->
<VersionMajorMinor>1.1</VersionMajorMinor>
<VersionMajorMinor>1.2</VersionMajorMinor>
<!-- Local builds get Major.Minor.0; CI passes -p:Version=Major.Minor.<run><attempt>+<sha>, and
the condition lets it win. -->
<VersionPrefix Condition="'$(VersionPrefix)' == ''">$(VersionMajorMinor).0</VersionPrefix>
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,22 @@ before sending the payload, via `AllowSetForegroundWindow`. Skip this and the ru
its taskbar button and stays behind, which reads as the hand-off silently failing. It is a no-op off
Windows, where focus policy belongs to the compositor.

## A name the shell can show

An app shipped as a zip has no identity of its own: Windows knows its windows by the executable's file name,
and its media flyout calls the app's media session "Unknown app". One call at startup, before the first window,
fixes that:

```csharp
AppIdentity.Claim("MyCompany.Viewer", "Viewer");
```

It gives the process the app id (an AppUserModelID) and makes sure a Start menu shortcut named "Viewer"
carries it, which is where the shell finds the name and the icon, and which also puts the app in Start menu
search. The shortcut is written once and afterwards only read: it follows the app when the executable it points
at is gone, stays with the copy it points at while that is there, and a shortcut of the same name that does not
carry the id is never rewritten. No-op off Windows.

## Failure is never fatal

Every path out of a failed hand-off returns `false` rather than throwing, and `TryClaim` returns
Expand Down
123 changes: 123 additions & 0 deletions src/SharpAstro.AppShell.Tests/AppIdentityTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
using Shouldly;
using Xunit;
using static SharpAstro.AppShell.AppIdentity;

namespace SharpAstro.AppShell.Tests;

/// <summary>
/// The Start menu shortcut that names the app: written when missing, kept while what it points at is there,
/// repointed when that is gone, and never taken from anyone else.
///
/// <para>The policy is a pure function and runs everywhere. The round trips write real shortcuts into a temporary
/// folder through the shell's link object, so they run on Windows only -- the Start menu itself is never touched,
/// nor this process's app id.</para>
/// </summary>
public class AppIdentityTests
{
private const string AppId = "SharpAstro.AppShell.Tests";

[Fact]
public void No_shortcut_is_created()
=> Decide(null, AppId, _ => true).ShouldBe(ShortcutAction.Create);

[Fact]
public void Ours_pointing_at_something_that_is_there_is_kept()
{
// Including a copy other than the one running: a development build must not take the installed app's
// shortcut over.
Decide((AppId, @"C:\Apps\Viewer\viewer.exe"), AppId, _ => true).ShouldBe(ShortcutAction.Keep);
}

[Fact]
public void Ours_pointing_at_nothing_is_repointed()
{
Decide((AppId, @"C:\Old\viewer.exe"), AppId, _ => false).ShouldBe(ShortcutAction.Repoint);
Decide((AppId, null), AppId, _ => true).ShouldBe(ShortcutAction.Repoint);
}

[Fact]
public void Someone_elses_is_left_alone()
{
// A user's own shortcut of the same name (no app id), another app's, and one that could not be read.
Decide((null, @"C:\Apps\Viewer\viewer.exe"), AppId, _ => true).ShouldBe(ShortcutAction.LeaveAlone);
Decide(("Other.App", @"C:\Apps\Viewer\viewer.exe"), AppId, _ => false).ShouldBe(ShortcutAction.LeaveAlone);
Decide((null, null), AppId, _ => false).ShouldBe(ShortcutAction.LeaveAlone);
}

[Fact]
public void A_written_shortcut_carries_the_app_id_and_the_target()
{
if (!OperatingSystem.IsWindows())
{
return;
}

using var dir = new TempDir();
var>
var link = Path.Combine(dir.Path, "Start Menu", "Viewer.lnk");

EnsureShortcut(link, AppId, target).ShouldBe(StartMenuShortcut.Created);

TryReadShortcut(link, out var appId, out var readTarget).ShouldBeTrue();
appId.ShouldBe(AppId);
readTarget.ShouldBe(target, StringCompareShould.IgnoreCase);
EnsureShortcut(link, AppId, dir.File("elsewhere.exe")).ShouldBe(StartMenuShortcut.Present);
}

[Fact]
public void A_shortcut_whose_app_moved_follows_it()
{
if (!OperatingSystem.IsWindows())
{
return;
}

using var dir = new TempDir();
var old = dir.File("old.exe");
var link = Path.Combine(dir.Path, "Viewer.lnk");
EnsureShortcut(link, AppId, old).ShouldBe(StartMenuShortcut.Created);
File.Delete(old);

var moved = dir.File("moved.exe");
EnsureShortcut(link, AppId, moved).ShouldBe(StartMenuShortcut.Repointed);

TryReadShortcut(link, out var appId, out var target).ShouldBeTrue();
appId.ShouldBe(AppId);
target.ShouldBe(moved, StringCompareShould.IgnoreCase);
}

[Fact]
public void Another_apps_shortcut_of_the_same_name_is_not_rewritten()
{
if (!OperatingSystem.IsWindows())
{
return;
}

using var dir = new TempDir();
var theirs = dir.File("theirs.exe");
var link = Path.Combine(dir.Path, "Viewer.lnk");
EnsureShortcut(link, "Other.App", theirs).ShouldBe(StartMenuShortcut.Created);

EnsureShortcut(link, AppId, dir.File("ours.exe")).ShouldBe(StartMenuShortcut.Foreign);

TryReadShortcut(link, out var appId, out var target).ShouldBeTrue();
appId.ShouldBe("Other.App");
target.ShouldBe(theirs, StringCompareShould.IgnoreCase);
}

private sealed class TempDir : IDisposable
{
public string Path { get; } = Directory.CreateTempSubdirectory("appshell-identity-").FullName;

// An empty file standing in for an executable: a shortcut only needs its target to exist.
public string File(string name)
{
var path = System.IO.Path.Combine(Path, name);
System.IO.File.WriteAllBytes(path, []);
return path;
}

public void Dispose() => Directory.Delete(Path, recursive: true);
}
}
Loading
Loading