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
3 changes: 2 additions & 1 deletion extension/src/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ async function fetchStatus() {
try {
const res = await fetch(STATUS_URL, { signal: ctrl.signal, cache: 'no-store', credentials: 'omit' });
const json = res.ok ? await res.json() : null;
if (!json || typeof json !== 'object' || json.app !== 'HomePodCast') {
// 'Akoustra': the app's coming name (2026-10-30), so this extension keeps working if the app ever reports it.
if (!json || typeof json !== 'object' || (json.app !== 'HomePodCast' && json.app !== 'Akoustra')) {
lastGood = null;
return { running: false };
}
Expand Down
8 changes: 8 additions & 0 deletions extension/test/background.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ test('a slow answer keeps the app\'s last status for 2.5 s; a refused connection
assert.deepEqual(await b.status(), { running: false });
});

test('the app is recognised under its coming name too, nothing else', async () => {
const b = background();
b.answer({ body: { ...app, app: 'Akoustra' } }); b.at(0);
assert.equal((await b.status()).running, true);
b.answer({ body: { ...app, app: 'Other' } }); b.at(600);
assert.deepEqual(await b.status(), { running: false });
});

test('answers are cached for 500 ms', async () => {
const b = background();
b.answer({ body: app }); b.at(0);
Expand Down
3 changes: 2 additions & 1 deletion modules/VideoWindow/StatusDelay.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ public static LocalStatus Read(string json)
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (root.GetProperty("app").GetString() != "HomePodCast") throw new InvalidDataException("Unexpected status service.");
// "Akoustra": the app's coming name (2026-10-30), so this module keeps working if the app ever reports it.
if (root.GetProperty("app").GetString() is not ("HomePodCast" or "Akoustra")) throw new InvalidDataException("Unexpected status service.");
string? version = root.TryGetProperty("version", out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
if (!root.GetProperty("streaming").GetBoolean()) return new(0, version);
int value = root.GetProperty("videoDelayMs").GetInt32();
Expand Down
18 changes: 13 additions & 5 deletions src/Update/GitHubReleases.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,16 @@ internal static class GitHubReleases
public const string ApiUrl = "https://api.github.com/repos/qiyinxi/HomePodCast/releases?per_page=20";
public const string ReleasesPage = "https://github.com/qiyinxi/HomePodCast/releases";

/// <summary>
/// The repository under its name today and under the one it is to get (2026-10-30: the app is to be renamed Akoustra).
/// After a rename GitHub still answers <see cref="ApiUrl"/> (a redirect) but lists the files under the new name, and
/// every version that accepts only the old one then finds no update at all: this one takes either.
/// </summary>
internal static readonly string[] RepositoryPaths = ["/qiyinxi/HomePodCast", "/qiyinxi/Akoustra"];

/// <summary>Only files and pages of this repository are downloaded or opened, whatever the response says.</summary>
internal const string DownloadPrefix = ReleasesPage + "/download/";
internal static readonly string[] DownloadPrefixes = [.. RepositoryPaths.Select(p => "https://github.com" + p + "/releases/download/")];
private static readonly string[] PagePrefixes = [.. RepositoryPaths.Select(p => "https://github.com" + p + "/releases/")];
private const string PagePrefix = ReleasesPage + "/";

public static readonly TimeSpan Timeout = TimeSpan.FromSeconds(15);
Expand Down Expand Up @@ -104,18 +112,18 @@ public static List<Release> Parse(string json, ReleaseFeed feed)
if (r.ValueKind != JsonValueKind.Object || Bool(r, "draft")) continue;
var tag = Str(r, "tag_name");
if (ReleaseVersion.Parse(tag) is not { } version) continue;
var page = Str(r, "html_url") is { } url && url.StartsWith(PagePrefix, StringComparison.Ordinal) && ReleaseNotes.CanOpen(url)
var page = Str(r, "html_url") is { } url && PagePrefixes.Any(p => url.StartsWith(p, StringComparison.Ordinal)) && ReleaseNotes.CanOpen(url)
? url
: PagePrefix + "tag/" + Uri.EscapeDataString(tag!);
list.Add(new Release(version, tag!, page, Bool(r, "prerelease") || version.IsPrerelease, Msi(r, feed.DownloadPrefix),
list.Add(new Release(version, tag!, page, Bool(r, "prerelease") || version.IsPrerelease, Msi(r, feed),
Str(r, "body") ?? "", DateTimeOffset.TryParse(Str(r, "published_at"), System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AssumeUniversal, out var published) ? published : null));
}
return list;
}

/// <summary>The release's x64 MSI, once it is fully uploaded; one from another place is ignored.</summary>
private static ReleaseAsset? Msi(JsonElement release, string downloadPrefix)
private static ReleaseAsset? Msi(JsonElement release, ReleaseFeed feed)
{
if (!release.TryGetProperty("assets", out var assets) || assets.ValueKind != JsonValueKind.Array) return null;
foreach (var a in assets.EnumerateArray())
Expand All @@ -124,7 +132,7 @@ public static List<Release> Parse(string json, ReleaseFeed feed)
var url = Str(a, "browser_download_url");
if (name == null || url == null || !name.EndsWith("-x64.msi", StringComparison.OrdinalIgnoreCase)) continue;
if (Str(a, "state") is { } state && state != "uploaded") continue;
if (!url.StartsWith(downloadPrefix, StringComparison.Ordinal)) continue;
if (!feed.Downloads(url)) continue;
long size = a.TryGetProperty("size", out var s) && s.TryGetInt64(out var n) ? n : 0;
return new ReleaseAsset(name, url, size, UpdatePackage.Sha256FromDigest(Str(a, "digest")));
}
Expand Down
14 changes: 8 additions & 6 deletions src/Update/ReleaseFeed.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,26 @@ namespace HomePodCast.Update;
/// </summary>
internal sealed class ReleaseFeed
{
private ReleaseFeed(string apiUrl, string downloadPrefix, bool followRedirects)
private ReleaseFeed(string apiUrl, IReadOnlyList<string> downloadPrefixes, bool followRedirects)
{
ApiUrl = apiUrl;
DownloadPrefix = downloadPrefix;
DownloadPrefixes = downloadPrefixes;
FollowRedirects = followRedirects;
}

/// <summary>The release list (one GET).</summary>
public string ApiUrl { get; }

/// <summary>An MSI is downloaded only from a URL that starts with this, whatever the list says.</summary>
public string DownloadPrefix { get; }
/// <summary>An MSI is downloaded only from a URL that starts with one of these, whatever the list says.</summary>
public IReadOnlyList<string> DownloadPrefixes { get; }

public bool Downloads(string url) => DownloadPrefixes.Any(p => url.StartsWith(p, StringComparison.Ordinal));

/// <summary>GitHub answers a download with a redirect to its file host; a loopback feed's files never leave the PC.</summary>
public bool FollowRedirects { get; }

/// <summary>The repository's releases on GitHub: what the app checks.</summary>
public static ReleaseFeed GitHub { get; } = new(GitHubReleases.ApiUrl, GitHubReleases.DownloadPrefix, followRedirects: true);
public static ReleaseFeed GitHub { get; } = new(GitHubReleases.ApiUrl, GitHubReleases.DownloadPrefixes, followRedirects: true);

/// <summary>Only CI can substitute a loopback list for GUI smoke. No persistent feed setting.</summary>
internal static ReleaseFeed ForGuiSmoke(string[] args, bool ci)
Expand All @@ -45,7 +47,7 @@ public static ReleaseFeed Loopback(string? url)
{
if (!IsLoopbackUrl(url, out var uri))
throw new ArgumentException($"--feed must be an http://127.0.0.1 or http://localhost URL (a fake release list on this PC), not \"{url}\"");
return new ReleaseFeed(uri.AbsoluteUri, uri.GetLeftPart(UriPartial.Authority) + "/", followRedirects: false);
return new ReleaseFeed(uri.AbsoluteUri, [uri.GetLeftPart(UriPartial.Authority) + "/"], followRedirects: false);
}

private static readonly string[] LoopbackHosts = ["127.0.0.1", "localhost"];
Expand Down
7 changes: 5 additions & 2 deletions src/Update/ReleaseNotes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,16 @@ public static string ForLanguage(string body, string language)
return language is "zh-CN" or "zh-TW" ? zh : en;
}

/// <summary>Only HTTPS pages in this exact repository. No credentials, ports, escapes or lookalike hosts.</summary>
/// <summary>
/// Only HTTPS pages in this exact repository, under either of its names (<see cref="GitHubReleases.RepositoryPaths"/>).
/// No credentials, ports, escapes or lookalike hosts.
/// </summary>
public static bool CanOpen(string? url)
{
if (string.IsNullOrEmpty(url) || url.Any(c => char.IsControl(c) || char.IsWhiteSpace(c) || c is '\\' or '%')) return false;
return Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.Scheme == "https" && uri.Host == "github.com"
&& uri.IsDefaultPort && uri.UserInfo.Length == 0
&& (uri.AbsolutePath == "/qiyinxi/HomePodCast" || uri.AbsolutePath.StartsWith("/qiyinxi/HomePodCast/", StringComparison.Ordinal));
&& GitHubReleases.RepositoryPaths.Any(p => uri.AbsolutePath == p || uri.AbsolutePath.StartsWith(p + "/", StringComparison.Ordinal));
}

/// <summary>The small Markdown subset used by announcements. Unsupported syntax stays inert text.</summary>
Expand Down
22 changes: 19 additions & 3 deletions src/Update/UpdateInstaller.cs
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,20 @@ internal sealed record Helper(int ProcessId, string Msi, string MsiLog, string R
{
/// <summary>The install folder, without a trailing backslash (msiexec reads <c>\"</c> as an escaped quote).</summary>
public string InstallDir => Path.TrimEndingDirectorySeparator(Path.GetDirectoryName(Exe) ?? "");

/// <summary>The process the helper waits for: this exe's own name, whatever the exe is called.</summary>
public string ProcessName => Path.GetFileNameWithoutExtension(Exe);

/// <summary>Started instead of <see cref="Exe"/> when the update put it into the install folder.</summary>
public string RenamedExe => Path.Combine(InstallDir, RenamedExeName);
}

/// <summary>
/// The exe's name from 2.0 on (2026-10-30: the app is to be renamed Akoustra, exe included). An update from this
/// version to 2.0 removes HomePodCast.exe, so the helper starts the renamed exe when the install left one.
/// </summary>
internal const string RenamedExeName = "Akoustra.exe";

/// <summary>Starts the helper (non-elevated, no window). It does nothing until this process has exited.</summary>
public static void StartHelper(Helper helper)
{
Expand All @@ -119,7 +131,8 @@ public static void StartHelper(Helper helper)
WorkingDirectory = Path.GetTempPath(), // not the update folder: it is removed on the next start
}) ?? throw new InvalidOperationException("PowerShell did not start");
Log.Info($"update: helper started (PowerShell, pid {p.Id}); once pid {helper.ProcessId} has exited it runs " +
$"msiexec /i \"{helper.Msi}\" INSTALLFOLDER=\"{helper.InstallDir}\" through UAC, then starts {helper.Exe}");
$"msiexec /i \"{helper.Msi}\" INSTALLFOLDER=\"{helper.InstallDir}\" through UAC, then starts {helper.Exe} " +
$"({RenamedExeName} instead if the update installed it)");
}

/// <summary>powershell.exe arguments: the script as -EncodedCommand, so no command line parses a path (as in Firewall).</summary>
Expand All @@ -141,6 +154,8 @@ internal static string HelperVariables(Helper h)
$installDir = {Firewall.PowerShellLiteral(h.InstallDir)}
$result = {Firewall.PowerShellLiteral(h.Result)}
$exe = {Firewall.PowerShellLiteral(h.Exe)}
$appName = {Firewall.PowerShellLiteral(h.ProcessName)}
$renamedExe = {Firewall.PowerShellLiteral(h.RenamedExe)}
$appArgs = @({string.Join(", ", args.Select(Firewall.PowerShellLiteral))})
""".ReplaceLineEndings("\n"); // the same script whatever line endings the checkout gave this file (CI: CRLF)
}
Expand All @@ -150,13 +165,13 @@ internal static string HelperVariables(Helper h)
/// msiexec elevated (UAC) with a progress bar and no reboot, into the folder the app runs from (a major upgrade
/// would otherwise go to the default folder), with a verbose log. msiexec's exit code, or 1223 when UAC was
/// declined, or "timeout" when the app never quit, goes to the result file; then the app starts again from this
/// non-elevated PowerShell, so it runs as the user.
/// non-elevated PowerShell, so it runs as the user: the renamed exe if the update installed one (<see cref="RenamedExeName"/>).
/// </summary>
internal static string HelperScript(Helper h) => HelperVariables(h) + "\n" + HelperSteps;

private static readonly string HelperSteps = $$"""
$ErrorActionPreference = 'Stop'
$app = Get-Process -Id $appPid -ErrorAction SilentlyContinue | Where-Object { $_.ProcessName -eq 'HomePodCast' }
$app = Get-Process -Id $appPid -ErrorAction SilentlyContinue | Where-Object { $_.ProcessName -eq $appName }
if ($app -and -not $app.WaitForExit({{QuitTimeoutMs}})) {
Set-Content -LiteralPath $result -Value 'timeout' -Encoding Ascii
exit 1
Expand All @@ -177,6 +192,7 @@ exit 1
if ($e -is [System.ComponentModel.Win32Exception]) { $code = $e.NativeErrorCode } else { $code = 'error: ' + $e.Message }
}
Set-Content -LiteralPath $result -Value $code -Encoding UTF8
if (Test-Path -LiteralPath $renamedExe -PathType Leaf) { $exe = $renamedExe }
Start-Process -FilePath $exe -ArgumentList $appArgs -WorkingDirectory $installDir
""".ReplaceLineEndings("\n");

Expand Down
5 changes: 5 additions & 0 deletions tests/HomePodCast.Tests/ReleaseNotesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ public void A_truncated_list_says_that_earlier_changes_are_on_the_release_page()
[InlineData("https://github.com/qiyinxi/HomePodCast/../other", false)]
[InlineData("https://github.com/qiyinxi/HomePodCast/%2f..%2fother", false)]
[InlineData("https://github.com/qiyinxi/HomePodCast/\n", false)]
[InlineData("https://github.com/qiyinxi/Akoustra", true)] // the repository's coming name
[InlineData("https://github.com/qiyinxi/Akoustra/releases/tag/v2.0.0", true)]
[InlineData("https://github.com/qiyinxi/Akoustra.evil/releases", false)]
[InlineData("https://github.com/qiyinxi/Akoustra/../other", false)]
[InlineData("https://github.com/someone/Akoustra", false)]
[InlineData("javascript:alert(1)", false)]
[InlineData("file:///C:/Windows/notepad.exe", false)]
public void Only_this_repositorys_https_links_can_open(string url, bool allowed) => Assert.Equal(allowed, ReleaseNotes.CanOpen(url));
Expand Down
5 changes: 3 additions & 2 deletions tests/HomePodCast.Tests/UpdateSelfTestTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public void Loopback_accepts_http_on_127_0_0_1_and_localhost(string url, string
{
var feed = ReleaseFeed.Loopback(url);
Assert.Equal(api, feed.ApiUrl);
Assert.Equal(downloads, feed.DownloadPrefix);
Assert.Equal([downloads], feed.DownloadPrefixes);
Assert.False(feed.FollowRedirects);
}

Expand Down Expand Up @@ -60,7 +60,8 @@ public void Loopback_refuses_everything_else_with_a_clear_error(string? url)
public void The_app_uses_githubs_feed_unchanged()
{
Assert.Equal("https://api.github.com/repos/qiyinxi/HomePodCast/releases?per_page=20", ReleaseFeed.GitHub.ApiUrl);
Assert.Equal("https://github.com/qiyinxi/HomePodCast/releases/download/", ReleaseFeed.GitHub.DownloadPrefix);
Assert.Equal(["https://github.com/qiyinxi/HomePodCast/releases/download/", "https://github.com/qiyinxi/Akoustra/releases/download/"],
ReleaseFeed.GitHub.DownloadPrefixes); // today's name and the one the repository is to get
Assert.True(ReleaseFeed.GitHub.FollowRedirects); // GitHub's downloads redirect to its file host
var byDefault = GitHubReleases.Parse(ReleaseSelectionTests.Fixture);
var github = GitHubReleases.Parse(ReleaseSelectionTests.Fixture, ReleaseFeed.GitHub);
Expand Down
Loading
Loading