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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Removed

- **Orphaned `scrcpy` dependency dropped from the Android Devices module.** `AndroidDevicesModule` declared `scrcpy` (GitHub-sourced, `Genymobile/scrcpy`) as a `ModuleDependency`, but nothing in the app has invoked `scrcpy.exe` since the ws-scrcpy-web sidecar replaced the native-scrcpy mirror in Phase 8a (2026-04-11). Impact: (1) drops a per-dependency-sync GitHub releases API call; (2) removing `scripts/dependencies/fetch-scrcpy.ps1` (auto-discovered by `stage-seed.ps1`) stops bundling the ~40 MB `scrcpy-win64` payload — `scrcpy.exe` + SDL/ffmpeg DLLs + `scrcpy-server` + a duplicate `adb.exe` — into every MSI; (3) `SeedHydrator` now prunes a pre-existing `<dataRoot>\dependencies\scrcpy\` on launch (best-effort, logged) so existing installs reclaim the space on upgrade. Synthetic test fixtures that used `"scrcpy"` as a dependency name were renamed to `"fake-tool"`. README dependency tables updated to match.

### Fixed

- **`release.yml` publish-job download-artifact path** — added explicit `name: windows-final` + `path: artifacts/windows-final/` to the `actions/download-artifact@v8.0.1` step. **Symptom:** v1.1.0's release.yml run completed with all 3 jobs green, but the published v1.1.0 GitHub Release contained only `SHA256SUMS` (195 bytes); the MSI + nupkg + releases.stable.json were missing despite being present in the workflow artifact. **Root cause:** `actions/download-artifact@v8` with no `name:` filter and `path: artifacts/` flattens a single artifact's files directly into `artifacts/` (no auto-subdirectory), but the `softprops/action-gh-release@v3.0.0` globs below targeted `artifacts/windows-final/*.msi` / `*.nupkg` / `releases.<channel>.json` — zero matches, silent skip per softprops's "Pattern '...' does not match any files" advisory. Drift introduced when Dependabot bumped download-artifact (PR #4, v4 → v8) on 2026-05-18 — v4 may have behaved differently for single-artifact downloads; either way, the explicit `name:` + nested `path:` is the deterministic fix that also future-proofs against any version-specific behavior. **Recovery:** v1.1.0 release patched 2026-05-19 via manual `gh run download` + `gh release upload` of the original artifact bytes (SHAs verified against existing SHA256SUMS); no rebuild required, original Sigstore attestation unchanged + still verifies against the patched MSI.
Expand Down
5 changes: 2 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Control Menu replaces a collection of PowerShell scripts with a cross-platform w
- **Cameras** &mdash; View LTS/Hikvision CCTV cameras via [go2rtc](https://github.com/AlexxIT/go2rtc) RTSP-to-browser streaming. Configurable camera count with encrypted credential storage. go2rtc is auto-installed and updated via the dependency manager.
- **Jellyfin Media Server** &mdash; Database date updates, cast & crew image refresh (background worker with resume support), Docker container management, automated backups with configurable retention
- **Utilities** &mdash; Image-to-ICO icon conversion (PNG, JPG, BMP, GIF, WEBP, TIFF via SkiaSharp) with native file picker, Windows Zone.Identifier file unblocker
- **Dependency Management** &mdash; Auto-installs and updates ADB, scrcpy, sqlite3, and go2rtc to a self-contained `dependencies/` folder. Configurable install paths per tool. Version checks via GitHub API and direct URL scraping. Services are automatically stopped before binary updates and restarted after. Docker and ws-scrcpy-web are externally managed (configured in Settings → General).
- **Dependency Management** &mdash; Auto-installs and updates ADB, sqlite3, and go2rtc to a self-contained `dependencies/` folder. Configurable install paths per tool. Version checks via GitHub API and direct URL scraping. Services are automatically stopped before binary updates and restarted after. Docker and ws-scrcpy-web are externally managed (configured in Settings → General).

## Features

Expand Down Expand Up @@ -95,7 +95,7 @@ tests/ControlMenu.Tests/
| SkiaSharp for images | Cross-platform replacement for System.Drawing.Common |
| ws-scrcpy-web via iframe | Screen mirroring without native scrcpy binary dependency |
| File System Access API | Native OS file dialogs for icon converter (Chrome/Edge) |
| Self-contained dependencies | 5 auto-managed tools in `dependencies/`; 2 external (Docker, ws-scrcpy-web) |
| Self-contained dependencies | 3 auto-managed tools in `dependencies/`; 2 external (Docker, ws-scrcpy-web) |

## Dependencies

Expand All @@ -105,7 +105,6 @@ Control Menu manages two types of dependencies:
| Tool | Source | Purpose |
|------|--------|---------|
| ADB | Google (DirectUrl) | Android device management |
| scrcpy | GitHub (Genymobile/scrcpy) | Screen mirroring server binary |
| sqlite3 | sqlite.org (DirectUrl) | Jellyfin database operations |
| go2rtc | GitHub (AlexxIT/go2rtc) | RTSP-to-browser camera streaming |

Expand Down
23 changes: 0 additions & 23 deletions scripts/dependencies/fetch-scrcpy.ps1

This file was deleted.

1 change: 0 additions & 1 deletion scripts/stage-seed.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
# Inputs: scripts/dependencies/fetch-*.ps1 (each downloads + verifies + stages)
# Output: publish/seed/dependencies/
# platform-tools/ adb.exe + DLLs
# scrcpy/ scrcpy.exe + libs
# sqlite3/ sqlite3.exe
# go2rtc/ go2rtc.exe
#
Expand Down
45 changes: 42 additions & 3 deletions src/ControlMenu.Common/Seeding/SeedHydrator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,27 @@ namespace ControlMenu.Common.Seeding;
/// </summary>
public static class SeedHydrator
{
public record Result(int Copied, int Skipped);
public record Result(int Copied, int Skipped, int Pruned);

/// <summary>
/// Dependencies that were previously seeded/installed but have since been
/// removed from the app. The dependency manager never cleans a removed dep's
/// directory, so <see cref="Hydrate"/> prunes them from
/// <c>&lt;dataRoot&gt;\dependencies\</c> on launch to reclaim disk space.
/// Best-effort — a delete failure (locked file, permissions) is swallowed so
/// it can never block launch; the next launch retries.
/// </summary>
private static readonly string[] RetiredLeaves = ["scrcpy"];

public static Result Hydrate(string currentDir, string targetDependenciesDir)
{
// Prune retired deps first so existing installs reclaim the space even in
// dev mode (no seed/), where the hydration loop below early-returns.
var pruned = PruneRetiredLeaves(targetDependenciesDir);

var seedRoot = Path.Combine(currentDir, "seed", "dependencies");
if (!Directory.Exists(seedRoot))
return new Result(0, 0);
return new Result(0, 0, pruned);

Directory.CreateDirectory(targetDependenciesDir);

Expand All @@ -42,7 +56,32 @@ public static Result Hydrate(string currentDir, string targetDependenciesDir)
copied++;
}

return new Result(copied, skipped);
return new Result(copied, skipped, pruned);
}

private static int PruneRetiredLeaves(string targetDependenciesDir)
{
if (!Directory.Exists(targetDependenciesDir))
return 0;

var pruned = 0;
foreach (var leaf in RetiredLeaves)
{
var dir = Path.Combine(targetDependenciesDir, leaf);
if (!Directory.Exists(dir))
continue;
try
{
Directory.Delete(dir, recursive: true);
pruned++;
}
catch
{
// Best-effort: a locked file or permission issue must never block
// launch. The next launch retries.
}
}
return pruned;
}

private static void CopyTree(string source, string dest)
Expand Down
12 changes: 0 additions & 12 deletions src/ControlMenu/Modules/AndroidDevices/AndroidDevicesModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,18 +32,6 @@ public class AndroidDevicesModule : IToolModule
: "https://dl.google.com/android/repository/platform-tools_r{version}-linux.zip",
InstallPath = Path.Combine(DepsRoot, "platform-tools")
},
new ModuleDependency
{
Name = "scrcpy",
ExecutableName = "scrcpy",
VersionCommand = "scrcpy --version",
VersionPattern = @"scrcpy ([\d.]+)",
SourceType = UpdateSourceType.GitHub,
GitHubRepo = "Genymobile/scrcpy",
ProjectHomeUrl = "https://github.com/Genymobile/scrcpy",
AssetPattern = @"scrcpy-win64-v[\d.]+\.zip",
InstallPath = Path.Combine(DepsRoot, "scrcpy")
},
// node was a CM dependency when Managed-mode spawned the ws-scrcpy-web
// node process. External-mode-only refactor removed all process-spawn
// code; node is no longer invoked anywhere in CM. Declaration removed
Expand Down
4 changes: 2 additions & 2 deletions src/ControlMenuLauncher/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@
// 3. Single-instance guard. Acquired AFTER hook dispatch (hooks are
// short-lived single-shots that legitimately race with a running
// instance). Mirrors main.rs:110-133.
var mutexName = SingleInstance.CurrentMutexName();

Check warning on line 75 in src/ControlMenuLauncher/Program.cs

View workflow job for this annotation

GitHub Actions / build-and-test

This call site is reachable on all platforms. 'SingleInstance.CurrentMutexName()' is only supported on: 'windows'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1416)

Check warning on line 75 in src/ControlMenuLauncher/Program.cs

View workflow job for this annotation

GitHub Actions / build-and-test

This call site is reachable on all platforms. 'SingleInstance.CurrentMutexName()' is only supported on: 'windows'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1416)
var instance = SingleInstance.Acquire(mutexName);
if (instance is null)
{
Expand All @@ -87,9 +87,9 @@
try
{
var hydrate = SeedHydrator.Hydrate(paths.GetCurrentDir(), paths.GetDependenciesDir());
if (hydrate.Copied > 0 || hydrate.Skipped > 0)
if (hydrate.Copied > 0 || hydrate.Skipped > 0 || hydrate.Pruned > 0)
{
LauncherLogger.Info($"seed hydrate: copied={hydrate.Copied} skipped={hydrate.Skipped}");
LauncherLogger.Info($"seed hydrate: copied={hydrate.Copied} skipped={hydrate.Skipped} pruned={hydrate.Pruned}");
}
}
catch (Exception ex)
Expand Down
34 changes: 30 additions & 4 deletions tests/ControlMenu.Common.Tests/Seeding/SeedHydratorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,38 @@ public void Hydrate_PartialOverlap_OnlyMissingLeavesCopied()
[Fact]
public void Hydrate_NestedDirectoriesInSeed_AreCopiedRecursively()
{
Seed("scrcpy", "scrcpy.exe", "EXE");
Seed("scrcpy", "lib/sub/data.bin", "NESTED");
Seed("fake-tool", "fake-tool.exe", "EXE");
Seed("fake-tool", "lib/sub/data.bin", "NESTED");

SeedHydrator.Hydrate(_currentDir, _depsDir);

Assert.True(File.Exists(Path.Combine(_depsDir, "scrcpy", "lib", "sub", "data.bin")));
Assert.Equal("NESTED", File.ReadAllText(Path.Combine(_depsDir, "scrcpy", "lib", "sub", "data.bin")));
Assert.True(File.Exists(Path.Combine(_depsDir, "fake-tool", "lib", "sub", "data.bin")));
Assert.Equal("NESTED", File.ReadAllText(Path.Combine(_depsDir, "fake-tool", "lib", "sub", "data.bin")));
}

[Fact]
public void Hydrate_PrunesRetiredScrcpyLeaf_FromDataRoot()
{
// An existing install hydrated scrcpy/ before it was retired. Hydrate must
// delete it on launch so the ~40MB is reclaimed.
var scrcpyDir = Path.Combine(_depsDir, "scrcpy");
Directory.CreateDirectory(Path.Combine(scrcpyDir, "lib"));
File.WriteAllText(Path.Combine(scrcpyDir, "scrcpy.exe"), "OLD");
File.WriteAllText(Path.Combine(scrcpyDir, "lib", "SDL2.dll"), "OLD");

var result = SeedHydrator.Hydrate(_currentDir, _depsDir);

Assert.Equal(1, result.Pruned);
Assert.False(Directory.Exists(scrcpyDir));
}

[Fact]
public void Hydrate_NoRetiredLeaf_PrunesNothing()
{
Seed("platform-tools", "adb.exe", "ADB");

var result = SeedHydrator.Hydrate(_currentDir, _depsDir);

Assert.Equal(0, result.Pruned);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,13 @@ public class AndroidDevicesModuleTests
public void Icon_IsPhoneIcon() => Assert.Equal("bi-phone", _module.Icon);

[Fact]
public void Dependencies_IncludesAdbAndScrcpy()
public void Dependencies_IncludesAdb_AndNotScrcpy()
{
var deps = _module.Dependencies.ToList();
Assert.Contains(deps, d => d.Name == "adb");
Assert.Contains(deps, d => d.Name == "scrcpy");
// scrcpy was removed — the native client has been orphaned since the
// ws-scrcpy-web sidecar replaced it (Phase 8a). Guard re-introduction.
Assert.DoesNotContain(deps, d => d.Name == "scrcpy");
}

[Fact]
Expand Down Expand Up @@ -99,14 +101,6 @@ public void AdbDependency_HasCorrectVersionCommand()
Assert.Equal("adb", adb.ExecutableName);
}

[Fact]
public void ScrcpyDependency_HasGitHubSource()
{
var scrcpy = _module.Dependencies.First(d => d.Name == "scrcpy");
Assert.Equal(UpdateSourceType.GitHub, scrcpy.SourceType);
Assert.Equal("Genymobile/scrcpy", scrcpy.GitHubRepo);
}

private static IServiceProvider BuildServiceProviderWithTypes(params DeviceType[] typesPresent)
{
var services = new ServiceCollection();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,31 +225,31 @@ public async Task GetUpdateAvailableCountAsync_ReturnsCorrectCount()
[Fact]
public async Task CheckDependencyAsync_GitHub_DetectsUpdateAvailable()
{
var (installDir, localExe) = MakeLocalInstall("scrcpy-install", "scrcpy");
var (installDir, localExe) = MakeLocalInstall("fake-tool-install", "fake-tool");
var module = new FakeModule("android-module", "Android",
[
new ModuleDependency
{
Name = "scrcpy",
ExecutableName = "scrcpy",
VersionCommand = "scrcpy --version",
VersionPattern = @"scrcpy ([\d.]+)",
Name = "fake-tool",
ExecutableName = "fake-tool",
VersionCommand = "fake-tool --version",
VersionPattern = @"fake-tool ([\d.]+)",
SourceType = UpdateSourceType.GitHub,
GitHubRepo = "Genymobile/scrcpy",
GitHubRepo = "example/fake-tool",
InstallPath = installDir
}
]);

_mockExecutor.Setup(e => e.ExecuteAsync(localExe, "--version", null, default))
.ReturnsAsync(new CommandResult(0, "scrcpy 3.3.2", "", false));
.ReturnsAsync(new CommandResult(0, "fake-tool 3.3.2", "", false));

var depId = Guid.NewGuid();
using var setupDb = _dbFactory.CreateDbContext();
setupDb.Dependencies.Add(new Dependency
{
Id = depId,
ModuleId = "android-module",
Name = "scrcpy",
Name = "fake-tool",
SourceType = UpdateSourceType.GitHub,
Status = DependencyStatus.UpToDate,
InstalledVersion = "3.3.2"
Expand Down
12 changes: 6 additions & 6 deletions tests/ControlMenu.Tests/Services/DependencyScanTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,12 @@ public async Task ScanForDependenciesAsync_ReportsNotFoundWhenMissing()
[
new ModuleDependency
{
Name = "scrcpy",
ExecutableName = "scrcpy",
VersionCommand = "scrcpy --version",
VersionPattern = @"scrcpy ([\d.]+)",
Name = "fake-tool",
ExecutableName = "fake-tool",
VersionCommand = "fake-tool --version",
VersionPattern = @"fake-tool ([\d.]+)",
SourceType = UpdateSourceType.GitHub,
ProjectHomeUrl = "https://github.com/Genymobile/scrcpy"
ProjectHomeUrl = "https://github.com/example/fake-tool"
}
]);

Expand All @@ -106,7 +106,7 @@ public async Task ScanForDependenciesAsync_ReportsNotFoundWhenMissing()
Assert.Single(results);
var result = results[0];
Assert.False(result.Found);
Assert.Equal("scrcpy", result.Name);
Assert.Equal("fake-tool", result.Name);
Assert.Equal("Not found", result.Source);
Assert.Null(result.Version);
Assert.Null(result.Path);
Expand Down
Loading