ReviewOS

stacks/system-cleaner

Windows support: what it would actually take

#23
Open glennmichael123 opened this 8 days ago ·
8 days ago

Where we actually stand

Nothing declares a platform. package.json has no os field, and the whole repo contains exactly one process.platform check — app/Support/Update/self-update.ts:121. So the app doesn't refuse to run on Windows; it just fails in ways that look like bugs rather than like "unsupported".

The good news first: the shell is not the problem. craft-native@0.0.120 already resolves win32, appends .exe, and carries 114 Windows references in its runtime. The launcher is the only place that hardcodes a POSIX binary name (app/Desktop/launcher.ts:81, path.join(macosDir, 'craft-runtime')). A Windows build of the window is close to free.

The work is all in the cleaning logic, and it's substantial:

Packages, all with macOS-only commands6 of 6
exec/spawn call sites~73 (core 26, monitor 25, clean 14, uninstall 6, disk 2)
Hardcoded macOS path references~280
process.platform guards today1
Clean targets to re-map~84
App screens15

macOS-only commands, by package:

  • clean — diskutil, launchctl, mdfind, defaults, periodic, kextcache, purge, lsof
  • core — launchctl, osascript, sips, sw_vers, system_profiler, sysctl, purge
  • monitor — vm_stat, iostat, pmset, scutil, system_profiler, sysctl, osascript
  • uninstall — launchctl, osascript
  • cli — defaults, osascript, sysctl, purge
  • disk — osascript

The seam that already exists

packages/core/src/paths.ts:187 exports macPaths — 22 keys, every consumer goes through it. That's the natural place to split, and it means the path half of this is a mechanical refactor rather than a rewrite.

The command half has no equivalent seam. There is no exec abstraction; each package shells out directly.

Plan

1. Declare the platform, stop pretending. Add os to package.json and a single platform() helper. Today a Windows user gets command not found surfaced as an empty result — the Quick Clean screen would show 0 B recoverable and look merely disappointing rather than unsupported. That's the worst failure mode and the cheapest to fix.

2. Split macPaths into a platform-dispatched paths module. Same keys, resolved per-OS. The Windows map is genuinely different in kind, not just in spelling:

conceptmacOSWindows
user caches~/Library/Caches%LOCALAPPDATA%\<app>\Cache, %TEMP%
app support~/Library/Application Support%APPDATA%, %LOCALAPPDATA%
installed apps/Applications (bundles)registry Uninstall keys + %ProgramFiles%
trash~/.Trash$Recycle.Bin (per-volume, SID-scoped)
startup itemslaunchctl + LaunchAgentsRun/RunOnce keys, Startup folder, Scheduled Tasks
logs~/Library/LogsEvent Log + per-app %LOCALAPPDATA%

3. Introduce a capability layer for the commands. Each of the ~73 call sites becomes a named capability (diskFree(), memoryPressure(), listStartupItems(), emptyTrash()) with a per-platform implementation. PowerShell/WMI covers most of the monitor package; the Recycle Bin and registry work needs real Windows APIs rather than shelling out.

4. Decide what Windows doesn't get. Some screens have no honest Windows analogue — app bundle uninstall is a registry operation, not a directory delete; .DS_Store cleanup is meaningless; Gatekeeper/notarization has no counterpart. Better to hide a screen than to ship one that silently finds nothing.

5. CI. Every job is ubuntu-latest except the desktop release job (macos-26). Windows needs a windows-latest matrix leg for tests, and a separate packaging path — code signing on Windows is Authenticode, not codesign/notarization.

Open questions

  • Scope of v1. Quick Clean + Disk Usage + Large Files + Duplicates + Developer Junk are all portable in concept. Applications, Startup, Privacy and Maintenance are the macOS-shaped ones. Shipping the first five on Windows is a coherent product; shipping all fifteen is a much longer road.
  • Testing. 38 test suites, and the ones that matter here assert behaviour against a real filesystem. Windows CI will surface path-separator and permission assumptions that macOS and Linux both hide.
  • Does Craft give us a real Windows window? The resolver knows win32, but nobody has built this app against it. That's the first thing to prove, and it's a spike, not a plan.

Suggested order

Prove the Craft window on Windows first — if that doesn't work, everything above is moot. Then the platform declaration (step 1), because it converts a confusing failure into an honest one for whatever ships next.

8 days ago

Spike result: it runs

The non-blocking test-windows leg is in CI now (55539c9). First run on windows-latest:

stepresult
bun install✅
bun run build:scripts✅
./buddy migrate:regenerate✅
platform layer tests✅ 14/14
full suite❌ 293 pass, 11 fail

PLATFORM resolves to windows and the capability map comes back all-false, as intended.

That is a much better starting point than the plan assumed. Bun, the build pipeline and the migration generator all work unmodified, and buddy runs fine under Git Bash with shell: bash. 293 of 304 tests already pass on Windows with zero Windows implementation — because most of the suite tests logic, not the OS.

The 11 failures

Three categories, and the prediction in the plan held: path separators and permission semantics, neither of which the macOS or Linux legs could catch.

POSIX path assumptions (3) — the real signal.

  • isPathSafe > rejects /Applications root but not the prefix check for /Applications/<App>
  • bulkDelete > refuses paths outside the home directory
  • bulkDelete > refuses paths inside sensitive directories

These are the safety gates, and they are written against /Applications, /System and POSIX prefixes. On Windows those paths do not exist, so the guards neither match nor protect anything. This is the thing to fix first — ahead of any cleaning feature. A delete-safety check that silently passes everything is worse than one that is absent, and isPathSafe currently gates /delete-path, /shred-paths and bulkDelete.

Harness portability (6) — real but shallow.

  • 4 × client-scripts — public/*.js glob matching against backslash paths
  • execSyncResult > captures stderr on failure — shell differences
  • Framework7 icons > ships no emoji — likely line endings

Ours (1), already fixed in 4b81042: a test matched a literal fixture-pkg/node_modules instead of using path.join.

What this changes about the plan

Step 1 of the original plan (declare the platform, stop pretending) is done — 0fce3ad and a553aa2. The capability map is live and unsupported screens now render a reason instead of an empty table.

The revised order from here:

  1. Make isPathSafe platform-aware. It is the single gate every destructive route passes through, and on Windows it currently has no opinion. This is safety work, not feature work, and it blocks everything else.
  2. Fix the 6 harness failures so the leg produces a clean signal and can eventually become required.
  3. Then the Windows CLEAN_TARGETS table and the capability flips, one feature at a time.

The spike's main finding is that the shell and the toolchain are not the obstacle. The obstacle is that the safety layer is written in POSIX.

8 days ago

The suite is green on Windows: 322/322

Same count as macOS. The leg is now blocking (3c82b27).

To be clear about what that does and does not mean: the app does not work on Windows. Every capability flag is still false and no cleaning feature has a Windows implementation. What passes is the logic — and the safety layer, which is the part that matters most for what comes next.

What the spike actually bought

Eleven failures on the first run. Four were real defects, and three of those were in isPathSafe, the single gate /delete-path, /shred-paths and bulkDelete all pass through.

Its sensitive-segment scan split on /. A Windows path is backslash-separated, so the split returned the whole path as one segment and matched nothing. The directories survived on an exact-path match; their contents did not:

C:\Users\glenn\.ssh                 refused (protected)
C:\Users\glenn\.ssh\id_rsa          *** ALLOWED ***
C:\Users\glenn\.aws\credentials     *** ALLOWED ***
C:\Users\glenn\.gnupg\secring.gpg   *** ALLOWED ***

Private keys, AWS credentials and GPG secrets, deletable through the gate whose entire job is refusing exactly that. Git for Windows puts keys in ~/.ssh like everywhere else, so this was not hypothetical — it was simply never looked at.

Fixed in 7928983, along with case-folded comparison (C:\WINDOWS and C:\Windows are one directory) and a properly enumerated set of Windows system roots. The POSIX roots had been protecting those by accident, via an unrelated "outside the profile" check.

The remaining seven were the suite assuming POSIX — glob separators, cmd.exe versus sh, /etc/hosts not existing, and Windows file-locking semantics that made a teardown throw EBUSY because SQLite releases its lock a moment after close() returns. All of those had been wrong for the life of the project and invisible, because nothing was looking.

Where this leaves the plan

Done:

  • Prove the toolchain — bun install, build:scripts, buddy and the migration generator all work unmodified under Git Bash
  • Declare the platform and stop pretending (0fce3ad, a553aa2)
  • Make the delete-safety gate platform-aware (7928983)
  • Green, blocking CI (1f0edff, 9693827, 3c82b27)

Next, in order:

  1. The Windows CLEAN_TARGETS table. The ~84 macOS targets have no counterpart; Windows needs its own — %TEMP%, %LOCALAPPDATA% app caches, Windows Update leftovers, Prefetch, per-browser caches — then cleanTargets can flip true.
  2. Disk scan and Developer Junk. Both are pure filesystem work and already pass their logic tests here; they mostly need the capability flags flipped and a careful read of what getDirSize shells out to.
  3. The Craft window itself. Still unproven. craft-native resolves win32 and appends .exe, but nobody has built this app against it, and that is a separate spike from the agent.

The honest summary: the obstacle was never the toolchain. It was that the safety layer was written in POSIX, and now it is not.

8 days ago

There is a Windows .exe

Built on windows-latest, 39.9 MB, uploaded as a CI artifact on every push. Running it there:

> system-cleaner-windows-x64.exe --version
system-cleaner/0.3.5 win32-x64 bun-v1.4.2

> system-cleaner-windows-x64.exe clean
Quick Clean is not implemented on Windows yet.
See /stacks/system-cleaner/issues/23 for what is implemented where.
(exit 1)

Preview, not a release asset. Every capability flag is false, so every cleaning command refuses. Shipping it as a download would promise something the app does not do. What it is for is guarding the toolchain: it compiles on every push and catches the next change that assumes a POSIX path.

The blocker was never the toolchain

scripts/build-binaries.ts targeted bun-darwin-arm64 only, and its comment explained why: the CLI reads ~/Library, /Applications and df -k, so a Windows build "would compile and then fail on the first command". That was correct, and it stayed correct for exactly as long as the CLI had no idea what platform it was on.

The desktop app got the capability map in 0fce3ad. The CLI never did. A Windows binary would have run every command, swallowed the missing launchctl and df -k, and reported 0 B reclaimable — and in a binary there is no UI to explain that "nothing" meant "I cannot see". refuseUnsupported now runs before dispatch. With that true, the build is worth making.

Three things that had to be learned the hard way

Bun does publish Windows runtimes. Cross-compiling failed locally only because this machine runs 1.4.3 while the newest published stable is 1.4.2, so there was no bun-windows-x64-v1.4.3 to download. The bundle step succeeded — 68 modules — which is what showed the module graph was already fine. Building on the Windows runner, which installs stable, sidesteps it entirely.

zip is not on windows-latest. The failure was quiet: spawnSync returns status: null when it cannot spawn, so the error read "exited with undefined" after the binary had compiled successfully. PowerShell's Compress-Archive ships with the runner. The error message now says "spawn failed" rather than "undefined" when a tool is simply absent.

Neither is unzip. The smoke test originally extracted the archive, which would have repeated the same assumption one commit later. --keep leaves the binary beside the archive so nothing downstream needs an extractor.

State

  • Toolchain proven — bun install, build:scripts, buddy, migrations
  • Platform + capability layer, desktop (0fce3ad) and CLI (1d8a917)
  • Delete-safety gate platform-aware (7928983)
  • Suite green and blocking on Windows — 322/322
  • CLI .exe builds, runs and refuses honestly (4d44457)
  • Windows CLEAN_TARGETS, so cleanTargets can turn true
  • diskScan / developerJunk — pure filesystem work, flags and a check of what getDirSize shells out to
  • The desktop .exe. Still zero. craft-native resolves win32 and appends .exe, but nobody has built this app against it, and that is a separate spike from the agent — plus Authenticode signing, which is not codesign/notarization.

Sign in to comment on this issue.