The InterlinedList Windows client (interlinedlist-windows-app, C#/WPF/.NET 10) is a
feature-broad interactive client, but documents live only inside the app — there is
no way to keep a local folder of .md files in sync so an external tool like
Obsidian can open, edit, and organize them. We want the same
"Dropbox/OneDrive-style" background sync that the sibling repo
CompositeCode/interlinedlist-synchronization
already implements — and that repo already ships a mature, MIT-licensed, ~165-test
Windows tray agent (InterlinedSync) on the identical C#/.NET/WPF stack. Rather than
build from scratch, we vendor that engine + tray into this repo, unify it with this
app's auth/packaging, add folder-tree mirroring (the reference's one real gap), and
wire it so installing the Windows client also installs and auto-starts the sync
utility in the system tray.
Outcome: after install, a tray icon runs in the background at login, watches a local
folder (default %USERPROFILE%\Documents\InterlinedList), and keeps it bidirectionally
in sync with the user's InterlinedList documents — folders mirrored as subdirectories —
using the token the user already signed in with in the main app.
- Approach = Vendor + unify. Port
InterlinedSync's engine + tray into two new in-repo projects; rewire it to this app's DPAPI token + API client; ship via the existing WiX MSI + MSIX tracks (not Inno Setup). - Background model = login-launched tray (per-user, autostart via HKCU
Run/ MSIXStartupTask). No NT Windows Service (session-0 can't show a tray icon or read the per-user DPAPI token / per-user folder). - Folder layout = mirror the server folder tree into real subdirectories, fully bidirectional (including moves between folders). This is net-new work on top of the vendored engine, which is flat today.
The request's macOS phrasing maps to Windows as: menu bar → system tray (notification area); background service → login-launched background tray process.
interlinedlist-windows-app/
InterlinedList/ (existing WPF app — mostly untouched)
Services/InterlinedApiClient.Documents.cs (+ add ONE delta-sync method)
Services/CredentialStore.cs (reused as-is: shared DPAPI token)
InterlinedList.Sync.Core/ (NEW — platform-neutral engine library, net10.0)
Sync/SyncEngine.cs (vendored: BackgroundService pull+push loops)
Sync/SyncStateRepository.cs (vendored: SQLite state.db)
Sync/ConflictResolver.cs (vendored: .conflict-<ts>.md)
FileSystem/FileMapper.cs (vendored + EXTENDED for folder tree)
FileSystem/FileSystemWatcherService.cs (vendored: *.md watcher + debounce)
Abstractions/ (IDocumentSyncClient, ICredentialSource, IFileMapper, ...)
Adapters/ApiDocumentSyncClient.cs (NEW: wraps InterlinedApiClient)
InterlinedList.Sync/ (NEW — tray host .exe, net10.0-windows, WinExe)
App / DI generic host / TrayIconController / Settings + Onboarding windows
Startup/RegistryAutoStartManager.cs (vendored: HKCU Run)
InterlinedList.Sync.Core.Tests/ (NEW — xUnit, folder-tree + engine tests)
installer/ Package.wxs (EXTENDED: bundle sync exe + autostart)
InterlinedList.Package/Package.appxmanifest (EXTENDED: 2nd app + windows.startupTask)
.github/workflows/build.yml (EXTENDED: build/test/publish sync projects)
Vendored from interlinedlist-synchronization/windows (keep) |
Replaced/adapted to unify with this repo |
|---|---|
SyncEngine (pull loop + push channel consumer, BackgroundService) |
— |
SyncStateRepository + state.db schema (docs/folders/sync_metadata) |
— (kept; extended for folder paths) |
ConflictResolver (4-way truth table, conflict-copy naming) |
— |
FileSystemWatcherService (*.md, 500 ms debounce, atomic writes) |
— |
FileMapper (sanitization, reserved-name handling) |
Extended for folderId→subdir mapping |
TrayIconController / TrayMenuBuilder (Hardcodet.NotifyIcon.Wpf) |
— |
RegistryAutoStartManager (HKCU Run) |
— |
InterlinedListClient (its own HTTP client) |
Replace with adapter over this repo's InterlinedApiClient |
CredentialManager (PasswordVault com.interlinedlist.sync) |
Replace with this repo's DPAPI CredentialStore (session.dat) |
| Replace with existing WiX MSI + MSIX |
Because the reference is DI/interface-driven (ICredentialStore, IInterlinedListClient,
IFileMapper, IFileWatcher, IConflictResolver, ISyncStateRepository,
IAutoStartManager), swapping the two seams above is low-risk.
The endpoint exists server-side (confirmed by the sibling repo's live-validated
API_CONTRACT.md and by the macOS client already calling it); this Windows client just
lacks a method. Add to InterlinedList/Services/InterlinedApiClient.Documents.cs:
// GET /api/documents/sync[?lastSyncAt=<ISO-8601 "O">]
public Task<DocumentSyncResponse> GetDocumentSyncAsync(DateTimeOffset? lastSyncAt, CancellationToken ct = default);New wire models (in InterlinedList/Models/), because the existing DocumentSummary
omits the tombstone field:
record DocumentSyncResponse(DateTimeOffset? LastSyncAt, IReadOnlyList<DocumentDelta> Documents, IReadOnlyList<DocumentFolder> Folders);
record DocumentDelta(string Id, string Title, string? Content, string? FolderId,
DateTimeOffset UpdatedAt, DateTimeOffset? DeletedAt); // DeletedAt != null ⇒ tombstonePush path reuses existing methods (no POST /documents/sync batch needed — the
reference agent pushes via individual writes, which this repo already has):
CreateDocumentAsync, UpdateDocumentAsync, DeleteDocumentAsync,
CreateDocumentFolderAsync, RenameDocumentFolderAsync, DeleteDocumentFolderAsync,
CreateDocumentInFolderAsync.
- The tray host resolves the bearer token via this repo's
CredentialStore.LoadToken()(DPAPICurrentUserscope → a separate process running as the same user can decrypt%LocalAppData%\InterlinedList\session.dat). - Provide
ICredentialSourceinSync.Coreimplemented by a tiny adapter overCredentialStore(both read/write the samesession.dat), replacing the reference's PasswordVault store. - If no token is present (utility launched before the user ever signed in), the tray
shows a lightweight Onboarding window (ported from the reference
OnboardingViewModel) that mints a sync-token viaPOST /api/auth/sync-tokenand writes it back through the sameCredentialStore.SaveToken(), so main app and utility stay in lockstep. ApiDocumentSyncClientadapter wiresInterlinedApiClient.AccessTokenfrom the shared token and exposesGetDocumentSyncAsync+ the push methods to the engine.
The vendored FileMapper is flat (syncFolder / sanitize(title).md). Extend it and the
engine so folderId maps to real subdirectories, bidirectionally.
State (state.db) additions: ensure a folders(id PK, parent_id, name, local_path)
table; keep documents(id, local_path, ...). Migration adds local_path to folders.
Server → local:
- Apply folder deltas first: build the folder path for each folder by walking
parent_idto root, sanitizing each segment; upsertfolders.local_path;Directory.CreateDirectory. On a folder rename/move (name or parent changed), move the existing subtree (Directory.Move) and update descendantlocal_paths. - For each document delta: compute target path =
syncFolder / folders[doc.FolderId].local_path / sanitize(title).md(root whenFolderId == null). If the storedlocal_pathdiffers (title rename or folder change),File.Movethe existing file to the new path; else write content. Tombstone (DeletedAt != null) → delete the file + row.
Local → server (via FileSystemWatcher):
- File created under a subdirectory → resolve/create the server folder chain
(
CreateDocumentFolderAsyncfor any missing segment, cached infolders), thenCreateDocumentInFolderAsync(orCreateDocumentAsyncat root). - File moved to a different subdirectory (rename event across dirs) → treat as
folder change:
UpdateDocumentAsyncwith the newfolderId(create folder chain if needed). Filename-only rename → title update. - File modified →
UpdateDocumentAsync(SHA-256 dedupe as in the reference). - File deleted →
DeleteDocumentAsync. - Conservative folder-mutation policy for v1: creating/renaming a subfolder locally is honored lazily (folders are created on the server when a document lands in them; a local folder rename is applied when a contained doc's move is detected). Deleting a folder locally does NOT delete the server folder (avoids destructive surprises from editor churn) — only file deletions delete documents. Document this in Settings help.
Conflict handling unchanged: server wins the canonical filename; the diverged local
copy is preserved as <stem>.conflict-<yyyyMMddTHHmmss>.md and excluded from the watch
scan (reference ConflictResolver + FileMapper.GetConflictPath).
Deletion reconciliation: delta tombstones are unreliable per API_CONTRACT.md, so
run a full GET /api/documents + GetDocumentFoldersAsync reconcile every N cycles
(e.g. every 20th poll, ~10 min at 30 s) to catch "seen-then-absent" deletions and
folder-tree drift definitively.
- Host:
Microsoft.Extensions.Hostinggeneric host;SyncEngineregistered viaAddHostedService; hosted inside a minimal WPFApp(no main window; tray only). - Tray:
Hardcodet.NotifyIcon.WpfTaskbarIcondriven byTrayMenuBuilder(declarative, unit-testable) → menu: Sign in… / Signed in as {user}, Open Sync Folder, Sync Now, Pause/Resume, Settings, Exit. Status icons (tray-idle/syncing/paused/error) swap onSyncState(Idle/Syncing/Paused/Error/ SignedOut/Offline/AuthExpired). Reuse this repo's brand assets for the icons (Strata teal/amber) generated frombrand-kit/logo. - Autostart toggle:
RegistryAutoStartManagerwritesHKCU\Software\Microsoft\Windows\CurrentVersion\Run\InterlinedListSync. Installer sets it once at install; the Settings toggle manages it thereafter (single source of truth to avoid double-registration — installer only writes if the value is absent). - Settings window: sync folder (
OpenFolderDialog), poll interval (default 30 s, min 10 s), start-at-login toggle, notification toggles. Prefs JSON at%APPDATA%\InterlinedList\sync\appsettings.json; state at...\sync\state.db; logs at%LOCALAPPDATA%\InterlinedList\sync\logs\(Serilog, 7-day rolling). - Resilience (vendored): 401→
AuthExpiredpause; offline pause viaNetworkChange.NetworkAvailabilityChanged; 429 honored viaRetry-After+ jittered exponential backoff.
MSI (WiX installer/Package.wxs + .wixproj):
- Publish the Sync tray exe alongside the app. Add a second publish of
InterlinedList.Sync.csproj(self-containedwin-x64) into async\subfolder of the app publish dir, then a new<ComponentGroup Id="SyncFiles">harvesting$(PublishDir)\sync\**intoINSTALLFOLDER\sync. - Autostart component (copy the existing
ApplicationShortcutpattern, new GUID):Add<Component Id="SyncAutoStart" Directory="INSTALLFOLDER"> <RegistryValue Root="HKCU" Key="Software\Microsoft\Windows\CurrentVersion\Run" Name="InterlinedListSync" Type="string" Value="[INSTALLFOLDER]sync\InterlinedList.Sync.exe" KeyPath="yes" /> </Component>
SyncFiles+SyncAutoStartto the main<Feature>. Optional Start-Menu shortcut "InterlinedList Sync".
MSIX (InterlinedList.Package):
- Add
<ProjectReference Include="..\InterlinedList.Sync\InterlinedList.Sync.csproj">to the.wapproj(triggers nested publish → the Sync csproj MUST declare<RuntimeIdentifiers>win-x64</RuntimeIdentifiers>to avoid the knownNETSDK1047, per CLAUDE.md). - Add a second
<Application Id="InterlinedListSync" Executable="...InterlinedList.Sync.exe" EntryPoint="Windows.FullTrustApplication">and a startup extension:<Extensions> <uap5:Extension Category="windows.startupTask" Executable="InterlinedList.Sync.exe" EntryPoint="Windows.FullTrustApplication"> <uap5:StartupTask TaskId="InterlinedListSyncStartup" Enabled="true" DisplayName="InterlinedList Sync" /> </uap5:Extension> </Extensions>
- Keep
TargetPlatformVersionmatching the runner's installed UAP SDK (CLAUDE.md constraint — do not bump blindly).
build-app: alsodotnet buildInterlinedList.Sync.Core+InterlinedList.Syncanddotnet test InterlinedList.Sync.Core.Tests.build-msi: add a publish step forInterlinedList.Sync.csprojintosync\before the WiX build.build-msix: no change beyond theProjectReference(nested publish handles it).- Add
INTERLINEDLIST_EMAIL/_PASSWORDsecret-gated integration test job (skips when unset), mirroring the existing contract-test convention.
New projects
InterlinedList.Sync.Core/(net10.0 library): vendoredSync/,FileSystem/,Abstractions/,Adapters/ApiDocumentSyncClient.cs, credential adapter.InterlinedList.Sync/(net10.0-windowsWinExe,<RuntimeIdentifiers>win-x64): tray host, DI host, tray/menu, Settings + Onboarding windows,RegistryAutoStartManager, brand tray icons.InterlinedList.Sync.Core.Tests/(xUnit + Moq +System.IO.Abstractions.TestingHelpers): port relevant reference tests plus new folder-tree mapping/move/conflict tests.
Modified
InterlinedList/Services/InterlinedApiClient.Documents.cs— addGetDocumentSyncAsync.InterlinedList/Models/— addDocumentSyncResponse,DocumentDelta.InterlinedList.slnx— add the three new projects.installer/Package.wxs(+.wixproj) —SyncFilesgroup +SyncAutoStartcomponent.InterlinedList.Package/Package.appxmanifest(+.wapproj) — 2nd app + startupTask + ProjectReference..github/workflows/build.yml— build/test/publish the new projects.CLAUDE.md— document the new sync utility, its projects, and packaging seams.
Dependencies added: Hardcodet.NotifyIcon.Wpf, Microsoft.Extensions.Hosting,
Microsoft.Data.Sqlite, Serilog, Polly (matching the reference's set;
CommunityToolkit.Mvvm already present).
- Scaffold + engine (flat, end-to-end): create the three projects; vendor the
engine; write
ApiDocumentSyncClient+GetDocumentSyncAsync; DPAPI credential adapter. Prove server↔local.mdsync flat with the test account. - Folder-tree materialization: extend
FileMapper+SyncEngine+state.dbforfolderId↔subdirectory, both directions incl. moves; conservative local-folder-delete policy; deletion reconciliation pass. - Tray host UX: Hardcodet tray + menu + status icons, Settings + Onboarding windows, autostart toggle, notifications.
- Packaging + CI: WiX
SyncFiles/SyncAutoStart, MSIX 2nd app +startupTask,build.ymlupdates. - Tests, live verification, docs: unit + integration tests; manual E2E on Windows;
update
CLAUDE.md.
Unit (cross-platform, run on macOS/CI): dotnet test InterlinedList.Sync.Core.Tests
— cover FileMapper folder-path/sanitization/reserved-names; server→local folder create
/rename/move; local→server folder resolution + doc move-between-folders; conflict-copy
naming; tombstone + reconcile; SHA dedupe.
Integration (env-gated, live API, test account from .env): first-sync pulls all
docs into the mirrored tree; delta after edit; push after local edit; 429 backoff.
Skips when INTERLINEDLIST_EMAIL/_PASSWORD unset (matches existing contract-test policy).
Manual E2E (must run on Windows — app is Windows-only):
- Build + install the MSI (or deploy the MSIX); confirm the tray icon appears at login
and
HKCU\...\Run\InterlinedListSyncis set. - Sign in via the main app → confirm the tray shows "Signed in as {user}" (shared token, no second login).
- On interlinedlist.com, create a doc inside a folder → it appears as
Documents\InterlinedList\<Folder>\<title>.md. - Edit the file in Obsidian → change appears on the server; move it to another
subfolder in Obsidian → the doc's
folderIdupdates server-side. - Force a conflict (edit both sides between polls) → server copy wins; a
<stem>.conflict-<ts>.mdis written locally. - Delete a doc on the server → the local file disappears within a reconcile cycle.
dotnet publishthe app self-containedwin-x64still succeeds (what MSI harvests).
- Unreliable delta tombstones → mitigated by the periodic full reconcile.
- Local folder deletes intentionally do NOT cascade to server folder deletion in v1 (destructive-churn guard) — called out in Settings/help.
- Two independent API consumers (main app view + tray engine) is fine — bearer auth
is stateless; they keep separate
state.db/UI and never contend (no shared SQLite). - .NET retarget net9→net10 is trivial; package versions to match.
- MSIX nested publish needs
<RuntimeIdentifiers>win-x64on the Sync csproj (NETSDK1047) and aTargetPlatformVersionmatching the runner's UAP SDK. - Cross-platform authoring: keep
Sync.Coreplatform-neutral (net10.0) so it builds /tests on macOS; the tray host (WPF/Hardcodet) is Windows-target only (as today).
- Real Windows toast notifications (reference stubs them) — start with tray tooltip + log.
- Non-document sync (lists/orgs/feed) — the utility is documents-only by design.
- Offline write queue beyond the in-memory push channel.
- Code-signing certs (MSI/MSIX ship unsigned; Partner Center signs MSIX on ingestion).