A Love Wins Media product

Engineering plans / Proposed

Garage for Windows — plan

Goal: a native Windows Garage with the same shape as the Mac app:

Written against v1.5-beta at 2454039 (2026-09-27), and re-checked against main b82c6d2 and v1.5-beta 839bc39 on 2026-09-29 (details in windows-ui.md §8). This plan supersedes the “native Windows” column in wsl.md, which was written against main and is stale on this branch in places (Python 3.14, sockets, the gRPC token).

Verdict: feasible. Expect roughly 3 months to an MVP and 5–6 months to parity for one engineer; this is a guess until the Phase 0 spikes land.


0. Scope

In scope:

Out of scope (see §7):

1. Where we start (grounding, v1.5-beta)

1.1 What the Mac app is

macOS piece What it does Python it drives
GarageApp (22k lines of Swift) SwiftUI UI, AppState, PostgresService, OperationRunner, FirstRunCoordinator, IngestService via gRPC
PythonXPCService framework GaragePythonEmbed.c (isolated PyConfig, GIL acquire/release), PythonKit, GarageXPCServiceBase / GarageXPCServiceHost (managed services, self-tests, log streaming), GarageSockets, GaragePostgresEndpoint, peer requirement —
GarageXPCService Hosts the GarageService gRPC facade garage_rag.service.server
GarageIngestXPCService Runs ingest, persisting through the facade garage_rag.ingest, db.engine
GarageEmbedXPCService Embed worker garage_rag.embed
GarageMCPServerService Long-lived MCP HTTP server (opt-in on this branch) garage_rag.mcp_server.server
LlamaXPCService + LlamaEngine / LlamaServiceHost / LlamaModelLoader llama.cpp (Metal); NSXPC for the app and handleServerRequest; llama-server HTTP on s/llama —
ModelDownloadXPCService GGUF downloads with SHA-256 checks against pinned presets —
GarageLauncher garage / garage-mcp helper bundles: start the app hidden, read the Keychain, export the endpoints —
GarageUpdater Sparkle (Developer ID), inert in App Store builds —

The Python surface each host touches is small: six garage_rag modules imported, plus two host-provided C function pointers:

ctypes releases the GIL around both calls.

1.2 Endpoint hardening already on this branch

From v1.5-beta-ipc-hardening.md and CLAUDE.md, all of which Windows must match:

1.3 What in garage_rag is not portable

Everything else is portable: path handling normalises separators (walker.py, pathrules.py use as_posix()), and there is no fork.

Where What Windows fix
garage_python/pyproject.toml:89 environments is darwin/arm64 only Add sys_platform == 'win32' and platform_machine == 'AMD64' (and ARM64); repin
config/__init__.py:200, net/egress.py:174, service/client.py:198 A socket path “must start with /” os.path.isabs (or Path.is_absolute)
service/server.py:1555–1598 Socket bind: mkdir(mode=0o700), chmod(0o600), S_ISSOCK, fcntl lock, AF_UNIX staleness probe Windows branch: the host creates the directory with an owner-only ACL; lock with msvcrt.locking; skip the probe or port it (see §2.2)
inference/transport.py → httpx.HTTPTransport(uds=) A llama socket through httpcore needs socket.AF_UNIX, which as far as I know CPython does not expose on Windows (verify) Inside services, use the in-process bridge (§2.4); outside, loopback + token
native/__init__.py:23 Loaded-library lookup is Darwin-only (.dylib) Enumerate loaded modules (EnumProcessModules / GetModuleFileNameW via ctypes) for tesseract*.dll, libpq.dll
libpq.py libpq name set lists .dylib names Done differently: pyproject.toml depends on psycopg[binary] on win32, whose wheel bundles libpq, so psycopg never looks for a system one. Revisit only if the app ships its own libpq.dll from the Postgres build
extract/placeholder.py:83, ingest/materialize.py:66 Placeholder xattrs and the dataless I/O policy (setiopolicy_np) are Darwin-only Cloud Files attributes (§5.2)
extract/imageio.py:115 HEIC through macOS ImageIO A host decode bridge over WIC (§5.3)
extract/messages.py, ingest/conversations.py Apple Messages chat.db Not on Windows; hide the Messages source template
mcp_server/install.py Client targets under ~/Library/Application Support Windows targets (§4.4)
attribute/git.py:51 macOS git-stub check Harmless on Windows; git may be absent, in which case attribution falls back (§5.4)
config/__init__.py:260 pwd in expand_home Already guarded by APP_SANDBOX_CONTAINER_ID, which Windows never sets

Windows CI runs only test_postgres.py today. The whole suite has to run there (§8).

2. Architecture

┌────────────────────────── Garage.App (WinUI 3, .NET) ─────────────────────────┐
│ tray icon · NavigationView pages · AppState (MVVM) · PostgresSupervisor       │
│ ServiceManager (Job Object) · gRPC client (GarageService, from garage.proto)  │
└───────┬───────────────┬────────────────┬───────────────┬──────────────┬───────┘
        │ control pipes (gRPC over named pipes, peer = same package)   │
        ▼               ▼                ▼               ▼              ▼
  Garage.Core      Garage.Ingest     Garage.Embed    Garage.Mcp     Garage.Llama
  (CPython:        (CPython:         (CPython:       (CPython:      (native llama.cpp,
  service.server)  ingest)           embed)          mcp_server)    Vulkan/CPU)
        │  GarageService gRPC (token; UDS if gRPC supports it — §2.2)  ▲
        └───────────── inference bridge / model loader (fn ptrs) ──────┘
                                   │
                              Postgres 18 (UDS in LocalState\s, SCRAM)

2.1 Services: per-user processes, not Windows Services

The Service Control Manager is the wrong analogue for XPC. Its services run in session 0 under service accounts, while Garage’s data, the user’s files, OneDrive and credentials belong to the signed-in user. XPC services are per-user, launched by the app, and die with it. The Windows equivalent:

Built so far (windows-ui.md U3): one Garage.Services.exe, run once per service (core, ingest, and mcp while its switch is on) rather than one executable each, each service a Rust library loaded through P/Invoke (garage_service_ffi, garage_ingest_ffi, garage_mcp_ffi) since the Python removal’s stage 4, with ModelServerHost carrying the model servers’ HTTP in place of §2.4’s in-process bridge; services.proto with Ping, status, self tests, logs, the log stream, Shutdown and the ingest calls; Kestrel on named pipes with CurrentUserOnly; the peer check (the app that started the service, or the service’s own folder; the package check waits for the MSIX); the Job Object; and ServiceManager in Garage.App.Core. Not yet: UpdateConfiguration, restart counts, minidumps, PostgresSupervisor and the Credential Locker.

The App Store build on this branch hosts GarageIngestXPCService and GarageXPCService in the app’s own process. The Windows Store has no such constraint on full-trust MSIX processes, so keep them separate; crash isolation is the point.

2.2 The GarageService gRPC endpoint

service/auth.py already gives the right answer for a transport without owner-only sockets. With a token configured, every call is authenticated and config-changing methods are allowed. So:

2.3 Embedding CPython: the PythonKit equivalent

On macOS the stack has three layers:

Decided and built: an owned bridge, Garage.Python (winapp/src/Garage.Python; see winapp/README.md). It differs from the original recommendation in one respect: there is no C shim.

  1. Start-up through PEP 741. 3.14’s PyInitConfig API sets PyConfig options by name (PyInitConfig_SetInt/SetStr/SetStrList, Py_InitializeFromInitConfig), so C# calls python314.dll directly and no struct layout has to be mirrored. The options match GaragePythonEmbed.c’s: isolated, UTF-8 mode, no bytecode, no signal handlers, and an explicit module_search_paths (Lib, DLLs, the bundle’s site-packages, extras) with no landmark search.
  2. site handled explicitly. The interpreter starts with site_import=0, then site.addsitedir(<bundle site-packages>) processes that folder’s .pth files only.
    • On Windows site.main() would also add the home folder and home\Lib\site-packages, which exist, and would let a package installed into the interpreter shadow the bundle’s.
    • The bundle still needs .pth processing: pywin32, a Windows-only dependency the MCP SDK pulls in, puts win32, win32\lib and pythonwin on the path that way.
    • Both were found by the tests.
  3. PythonObject : DynamicObject over an owned PyObject*, as planned:
    • dynamic members, calls with keyword arguments, and indexing (a multi-index passes a tuple);
    • conversions by cast;
    • PythonException with the qualified type, message, traceback and exception object.
    • Every operation takes the GIL itself, re-entrantly. A collected object is decref’d the next time any thread takes the GIL, never on the finalizer thread.
  4. GilScope (Python.Gil(), a ref struct over PyGILState_Ensure/Release) and AllowThreadsScope. There is no dedicated interpreter thread: any thread may call in.
  5. Callbacks are [UnmanagedCallersOnly] methods passed as integers, and Interop.NativeUtf8 plays strdup/free, as planned.

Spike S1 is done. The tests embed CPython 3.14 and cover:

pythonnet and CSnakes were not needed.

Why not adopt a library outright, as recorded when this was open:

2.4 Inference: the llama service and the bridge

2.5 Postgres

Built (windows-ui.md U3): PostgresSupervisor in Garage.App.Core, the password in Windows Credential Manager (the same store the Credential Locker uses; package scoping comes with the MSIX), loopback TCP on 14824 until spike S2, and Database Reset with relaunch.

2.6 Data location

The App Group container’s analogue is the package’s ApplicationData.Current.LocalFolder (%LOCALAPPDATA%\Packages\<PFN>\LocalState). It is private to the package and shared by every process in it, including the execution-alias launchers.

LocalState\
  garage.json   pgdata\   models\   logs\
  s\            ← owner-only ACL: Postgres socket, gRPC socket or token file, llama token

garage.json stays the one config. ~/.garage.json / ./garage.json keep working for a venv garage. Unpackaged development builds use %LOCALAPPDATA%\Garage, as unentitled Mac builds use ~/Library/Application Support/GarageApp.

2.7 Launchers

garage.exe and garage-mcp.exe are Native AOT C# (fast start matters for stdio MCP clients), exposed as MSIX app execution aliases, so they are on PATH and run with package identity. They do what GarageLauncher does:

2.8 Egress, on the .NET side too

The Python guarantees carry over unchanged: one choke point, the allowlist, communications loopback-only, and the AST test. The C# code has its own outbound traffic (model downloads, update checks), so give it the same shape:

3. UX: the same app in Windows idioms

The information architecture matches the Mac app page for page; the idioms change. The implementation plan for the UI (projects, page-by-page mapping, phases U0–U5, testing) is windows-ui.md.

macOS Windows
Menu-bar extra (MenuBarView) Notification-area icon. Left-click opens a Fluent flyout (service status, quick search, Ask Garage, quick add, “Update Everything”); right-click opens a context menu (Open Garage, Pause maintenance, Quit). WinUI 3 has no tray API: H.NotifyIcon.WinUI or Shell_NotifyIcon
Sidebar ContentView NavigationView (left): Status, Search, Documents, Sources, Models, MCP, Database, Logs. Settings in the navigation footer, not a separate preferences window
Window chrome Mica backdrop, custom title bar, Segoe Fluent Icons, follows light/dark, accent colour and text scaling
PageStatus banners InfoBar (informational / warning / error / success)
DatabaseResetSheet, confirmations ContentDialog
FirstRunView (services → sources → models → MCP clients) A full-window setup assistant with the same four steps and a progress header; TeachingTips after it
SourcePresets templates Windows templates: Documents, Desktop, OneDrive, Dropbox, a code folder; Messages hidden, Mail limited to .eml
Folder access (VolumeAccessService) FolderPicker (no security-scoped bookmarks; a full-trust MSIX reads the user’s files)
Job progress in OperationRunner App notifications (toasts) with progress bars for Update Everything, backfill and enrich-facts, plus in-page progress
Login item MSIX StartupTask, which the user controls in Settings > Apps > Startup
garage on PATH App execution aliases
Keychain Credential Locker (package-scoped)
Sparkle / App Store Microsoft Store, plus an .appinstaller feed for direct download from the same MSIX; the Store build ignores the feed, as App Store builds make Sparkle inert
BugReportView Same flow; attaches logs\ and service self-test output; no OSLog, so OSLogStreamService becomes the services’ log streams
Taskbar Jump list: Search, Add Source, Update Everything
Accessibility Narrator names on every control, full keyboard navigation, high-contrast themes

State management is MVVM with CommunityToolkit.Mvvm ([ObservableProperty], [RelayCommand]), the closest match to AppState’s @Published and the service objects. Page view models reuse the Mac’s decomposition (OperationRunner, IngestService with its queue, cancelAll(), cancel(source:)) so behaviour stays the same across platforms.

4. Python changes

  1. Lockfile: Windows environments in pyproject.toml; ./tools/repin; re-verify the macOS Bazel build, since @pypi reads the same lock. Expect Windows-only packages to appear: pip install -e garage_python on Windows 3.14 already pulls in pywin32, through the MCP SDK. Every dependency installs from wheels there today.
  2. Paths and sockets:
    • absolute-path checks via os.path.isabs;
    • a Windows branch for the socket bind in service/server.py (ACL set by the host, msvcrt lock, no S_ISSOCK);
    • GARAGE_LLAMA_TOKEN in inference/transport.py.
  3. Native lookup: native.loaded_library on Windows (module enumeration), and libpq.dll in libpq.py.
  4. mcp-install Windows targets:
    • %APPDATA%\Claude\claude_desktop_config.json;
    • ~\.claude.json;
    • the VS Code / Cline paths under %APPDATA%\Code\User\...;
    • the stdio command is the garage-mcp execution alias (%LOCALAPPDATA%\Microsoft\WindowsApps\garage-mcp.exe), with no database URL, as on macOS.
    • Done in U2 (windows-ui.md): the %APPDATA% targets (Claude Desktop, Cline in Cursor and VS Code, Zed), and platform-aware versions of the three tests below. Still to do: register the garage-mcp execution alias once the MSIX exists (§2.7).
    • Three tests in tests/test_mcp_install.py assumed POSIX and failed on Windows. They passed on Linux CI:
      • test_non_executable_launcher_is_ignored depends on the Unix executable bit;
      • test_sandboxed_service_targets_the_account_home imports pwd;
      • test_unsandboxed_targets_follow_home sets HOME, which Path.home() ignores on Windows in favour of USERPROFILE.

      Make them platform-aware alongside the Windows targets.

  5. Placeholders and HEIC: §5.
  6. Windows CI: the whole suite (§8).

5. Platform features

5.1 Extraction parity

Feature Windows
Text, Markdown, code, PDF, Office, .eml As-is
Images via Tesseract The app ships its own codec-less Tesseract built with MSVC, never a user-installed one (§5.5); tessdata beside the DLL; found through native.loaded_library
HEIC/HEIF §5.3
.emlx, Messages chat.db Not on Windows
Outlook .msg/.pst Follow-up (§7); would be communication

5.2 Cloud placeholders (OneDrive, Dropbox, iCloud for Windows)

All three use the Cloud Files API. Python exposes os.stat().st_file_attributes on Windows, so is_placeholder checks FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS (0x400000), FILE_ATTRIBUTE_RECALL_ON_OPEN (0x40000) and FILE_ATTRIBUTE_OFFLINE (0x1000) without opening the file.

On macOS the pipeline thread also turns the dataless I/O policy off, so that only the materialize thread can trigger a download. Whether Windows has a per-thread equivalent is an open question. RtlSetThreadPlaceholderCompatibilityMode changes how placeholders are presented, not whether reading hydrates them. Until research says otherwise:

5.3 HEIC through the host

Same licensing constraint as the Mac: no libheif (LGPL/GPL, HEVC patents). Windows decodes HEIC through WIC, but only with the HEIF Image Extensions and the HEVC Video Extensions (a paid Store item that is often missing) installed. Reuse the bridge pattern:

5.4 Git attribution

5.5 Tesseract without GPL code

The problem. The Tesseract most Windows users have is UB Mannheim’s build: the one Tesseract’s docs point to, winget install UB-Mannheim.TesseractOCR, and what the README’s CLI quickstart installs. It is built with MSYS2/MinGW and installs about 50 DLLs beside libtesseract-5.dll (Apache-2.0), among them:

The installer carries no license text for any of them; its only license file is Tesseract’s (doc\LICENSE). Using that build on your own machine is fine, and the CLI may keep loading it: Garage does not distribute it. The app must never ship it. Redistributing libjbig in the MSIX would put GPL obligations on the package, and the rest would need notices nobody supplied.

The rule: the app ships its own Tesseract, built the way the macOS app’s is, and loads only that. ext/leptonica/BUILD.bazel and ext/tesseract/BUILD.bazel already define a build whose only licenses are Apache-2.0 and BSD-2-Clause. Windows repeats it with MSVC and CMake from the same pins (ext/leptonica/leptonica.MODULE.bazel, ext/tesseract/tesseract.MODULE.bazel, fetched by tools/windows/fetch_ext.py, which already maps leptonica and tesseract to those files):

Enforcement: checks that fail the build, so GPL code cannot come back unnoticed.

  1. An import allowlist for the Tesseract DLL.
    • A windows.yaml step reads the built DLL’s imports (dumpbin /dependents, or pefile from Python) and fails on any DLL outside a short allowlist: the api-ms-win-* sets, KERNEL32, the other system DLLs the first build actually needs, VCRUNTIME140, VCRUNTIME140_1 and MSVCP140.
    • A codec, libtiff or a MinGW runtime appearing fails the job. Record the exact allowlist from the first green build.
  2. A package audit when the MSIX is built.
    • List every DLL in the package, and fail on any that is not mapped to a NATIVE_COMPONENTS entry (or to a NuGet package in the notices).
    • Also fail on any name in a denylist: libjbig*, libgcc_s*, libstdc++*, libwinpthread*, *pango*, *cairo*, libglib*, libgio*.
    • This covers every future native dependency, not only Tesseract.
  3. Loader order.
    • The host loads the bundled DLL by path at start-up, as PythonXPCService.framework links libtesseract on macOS. native.loaded_library on Windows (§1.3) finds it, and extract/tesseract.py checks that first.
    • _find_windows_library, the search for UB Mannheim’s libtesseract-5.dll added for the CLI, stays a CLI-only fallback. Inside the app, set GARAGE_BUNDLED_NATIVES=1 in the services’ environment and have tesseract.py skip that search when it is set.
    • A missing bundled DLL then fails loudly rather than silently loading a user-installed build. This is about predictable behaviour: loading a user’s own copy is not redistribution.
    • Confirm the DLL’s name from the first MSVC build (CMake names it tesseract<major><minor>.dll there, e.g. tesseract55.dll), and add it to the lookup’s patterns.

What stays as it is: the README’s CLI quickstart keeps winget install UB-Mannheim.TesseractOCR. Users install that build themselves, and Garage neither ships nor modifies it.

6. Build and distribution

7. Out of scope and follow-ups

8. Testing and CI

9. Phases

Phase Work Estimate
0 — Spikes S1: Garage.Python hosting 3.14 and running create_grpc_server with the GIL released, including an [UnmanagedCallersOnly] callback (done, §2.3). S2: gRPC core unix: on Windows; httpcore UDS on Windows; libpq drive-letter socket directory; socket-path length under LocalState. S3: WinUI 3 tray + window + single instance + StartupTask. S4: llama.cpp Vulkan/CPU build and known answers. S5: MSIX execution aliases reading LocalState and the Credential Locker from a stdio client’s spawn 2 weeks
1 — Portable garage_rag §1.3 table, §4, §5.2; the whole suite on Windows CI 2–3 weeks
2 — Service platform services.proto, GarageServiceHost, named pipes + peer check, Job Object, PostgresSupervisor, Credential Locker, Garage.Core / Garage.Ingest / Garage.Embed 3–4 weeks
3 — MVP UI Tray flyout, Status, Search, Sources (with Update Everything), MCP (stdio registration, HTTP opt-in), launchers. First usable build 3–4 weeks
4 — Inference Garage.Llama (engine, control pipe, bridge, loopback HTTP + token), downloads via BITS with SHA-256 against the catalog, the Models page 3 weeks
5 — Parity Documents, Database (reset/relaunch), Logs, first-run assistant, bug report, HEIC bridge, toasts, jump list 3–4 weeks
6 — Ship MSIX signing, .appinstaller feed, Store submission, notices, docs (docs/support/windows.md, troubleshooting: Defender, Git, HEVC extension) 2 weeks

10. Decisions

Decided:

  1. The Python bridge: an owned layer, Garage.Python (§2.3), built and tested.
  2. The build system: the .NET 10 SDK for winapp/, with Bazel (rules_dotnet) building every project but the WinUI app and its UI tests (§6).

Still open:

  1. Distribution: Store plus .appinstaller from one MSIX (recommended), or unpackaged with Velopack. Unpackaged loses the Credential Locker’s package scoping, execution aliases and LocalState, which several of the choices above lean on.
  2. Architectures: whether arm64 ships in the first release or follows.