diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..2294832 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,76 @@ +name: Build + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + workflow_dispatch: + +jobs: + build-app: + name: Build app (sanity check) + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Build WPF app + run: dotnet build InterlinedList/InterlinedList.csproj -c Release -r win-x64 + + build-msi: + name: Build MSI installer + runs-on: windows-latest + needs: build-app + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Publish app (installer payload) + run: dotnet publish InterlinedList/InterlinedList.csproj -c Release -r win-x64 --self-contained -p:PublishSingleFile=false + + - name: Build MSI + run: dotnet build installer/InterlinedList.Installer.wixproj -c Release + + - name: Upload MSI artifact + uses: actions/upload-artifact@v4 + with: + name: InterlinedList-Setup-msi + path: installer/bin/**/*.msi + if-no-files-found: error + + build-msix: + name: Build MSIX (Store package) + runs-on: windows-latest + needs: build-app + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - uses: microsoft/setup-msbuild@v2 + + - name: Build MSIX package + run: >- + msbuild InterlinedList.Package/InterlinedList.Package.wapproj + /restore + /p:Configuration=Release + /p:Platform=x64 + /p:AppxBundlePlatforms=x64 + /p:AppxBundle=Always + /p:UapAppxPackageBuildMode=StoreUpload + + - name: Upload MSIX artifact + uses: actions/upload-artifact@v4 + with: + name: InterlinedList-Store-package + path: InterlinedList.Package/AppPackages/** + if-no-files-found: error diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..95a9f9c --- /dev/null +++ b/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "microsoft-learn": { + "type": "http", + "url": "https://learn.microsoft.com/api/mcp" + }, + "nuget": { + "command": "dotnet", + "args": ["dnx", "NuGet.Mcp.Server", "--source", "https://api.nuget.org/v3/index.json", "--yes"] + } + } +} diff --git a/CLAUDE.md b/CLAUDE.md index 46c00c8..dd11034 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,24 +28,222 @@ InterlinedList.slnx InterlinedList/ InterlinedList.csproj (net10.0-windows, UseWPF=true) app.manifest (Windows 10+ DPI awareness + UAC) - App.xaml / App.xaml.cs (App entry point, theme switching) - MainWindow.xaml (Shell: custom title bar, nav, feed, right rail) - MainWindow.xaml.cs (Code-behind: clock, nav, window chrome) + App.xaml / App.xaml.cs (Entry point: theme switching + login/session orchestration) + LoginWindow.xaml / .xaml.cs (Email/password gate, shown when no session restores) + MainWindow.xaml (Shell: custom title bar, left nav, center ContentControl, right rail) + MainWindow.xaml.cs (Code-behind: clock, nav → view switching, window chrome) Resources/ Palette.xaml (Invariant brand colors) Theme.Light.xaml (Light semantic brushes) Theme.Dark.xaml (Dark semantic brushes) - Models/ - Post.cs (Post data model) + Models/ (Wire types matching the real API JSON — see API integration below) + Services/ + ApiConfig.cs (Base URL: https://interlinedlist.com/) + InterlinedApiClient.cs (Core: HTTP/JSON plumbing + auth/user/messages/dig/notifications) + InterlinedApiClient.Lists.cs (partial class: Lists domain) + InterlinedApiClient.Documents.cs (partial class: Documents domain) + InterlinedApiClient.Organizations.cs (partial class: Organizations domain) + InterlinedApiClient.Search.cs (partial class: cross-resource search) + InterlinedApiClient.CrossPost.cs (partial class: linked-identity/OAuth helpers) + InterlinedApiException.cs + CredentialStore.cs (DPAPI-encrypted sync-token persistence) + SessionService.cs (login/logout/restore, exposes CurrentUser) + AppServices.cs (process-lifetime singletons: Api, Session) + ViewModels/ (CommunityToolkit.Mvvm ObservableObject + [RelayCommand]) + LoginViewModel.cs, FeedViewModel.cs, MessageItemViewModel.cs, + NotificationsViewModel.cs, NotificationItemViewModel.cs, ProfileSummaryViewModel.cs, + ListsViewModel.cs, DocumentsViewModel.cs, OrganizationsViewModel.cs, + SearchViewModel.cs, ConnectedAccountsViewModel.cs + Views/ (Self-contained UserControls; each owns its ViewModel — + `DataContext = new XyzViewModel(AppServices.Session)` in its + own constructor, not injected by MainWindow) + FeedView, ListsView, DocumentsView, OrganizationsView, SearchView, ConnectedAccountsView +installer/ + InterlinedList.Installer.wixproj (WiX v5 SDK project → classic .msi) + Package.wxs (product/feature/shortcut definition) + License.rtf (placeholder EULA for WixUI_Minimal) +InterlinedList.Package/ + InterlinedList.Package.wapproj (Windows Application Packaging Project → MSIX) + Package.appxmanifest (Store identity, visual elements, capabilities) + Images/ (tile/splash assets generated from brand-kit logo) ``` +## API integration + +The app talks to the real InterlinedList backend at `https://interlinedlist.com` +(154-endpoint REST API, OpenAPI spec at `/api/openapi.json`) — there is no mock +data layer. Auth is a long-lived bearer token from `POST /api/auth/sync-token` +(the same mechanism the `il-sync` CLI and other native clients use — no cookie +jar), persisted DPAPI-encrypted via `CredentialStore`. The token is +long-lived, so treat `%LocalAppData%\InterlinedList\session.dat` as a standing +credential — but note the server **does** expose session management: +`GET /api/user/sessions` lists a user's active sync tokens and +`DELETE /api/user/sessions/{id}` revokes one (both accept the bearer token — +verified live 2026-07-31). An earlier revision of this file claimed no revoke +endpoint existed; that is no longer true. + +Covered now (greatly expanded in the 2026-07-31 parity build-out): +login/session restore, paginated **feed** with compose (text + **image +attachments**, cross-post toggles), Dig/Undig, **replies/threads**, **edit/ +delete** own posts, **report** posts, and **click-through to author profiles**; +notifications tray with **mark-one-read / delete-one / mark-all**; **Direct +Messages** (recipient list + thread + send); **People** (profile lookup, +follow/unfollow, follow-request approve/reject, a user's messages, +**block/mute/report**); **Lists** (browse/create/delete, freeform JSON data +rows with **row edit + delete** — no schema/column editor, see below); +**Documents** (root docs, templates, create/edit/delete + **folder CRUD / +new-doc-in-folder**); **Organizations** (browse + create + **full member +management**: add via search, change role, remove, edit/delete org); +**Settings** (profile edit, avatar-from-URL, email change, notification +preferences, blocked/muted management, **API-session list + revoke**, **CSV +data export**); unified **Search**; and **Connected Accounts** (Bluesky/ +Mastodon/LinkedIn/Twitter linking + cross-post toggles). Still not built: +Stripe billing UI, register/forgot-password, GitHub issue sync (endpoints work +but the test account has no GitHub linked), per-list schema/column definitions, +LinkedIn per-page posting targets, scheduled-post UI (the service supports +`scheduledAt`), media *video* upload, list watchers/sharing, document sharing/ +collaborators, Materialize ("Create from…"), and account deletion UI (the +service method exists, intentionally unsurfaced). + +**Load-bearing constraints discovered by live-probing the API — don't +"fix" these without re-verifying, they're not bugs in this app:** + +1. **A few endpoints only accept cookie-session auth, not the bearer + sync-token.** Re-probed live 2026-07-31 with the test account: + `GET /api/user/engagement` and `GET/PUT /api/user/dashboard-layout` return + `401` with a valid bearer token (Stripe billing + some `/api/auth/*` session + flows are the same shape). A native bearer-token client structurally can't + get a cookie session, so those are either browser-handoff (like OAuth) or + out of scope. **Correction to an earlier claim:** `GET + /api/organizations/{id}/members`, `GET /api/linkedin/targets`, and + `GET /api/linkedin/posting-targets` were *previously* documented here as + `401`-walled, but as of 2026-07-31 they return `200` with the bearer token — + member-management and LinkedIn per-page targeting **are** buildable now. + (Member *mutations* — POST/PUT/DELETE — still need live write-verification.) +2. **The per-provider `GET /api/auth/{provider}/status` endpoints are a red + herring** — they report whether the *server* has that OAuth integration + configured, not whether *this user* has linked it. The real per-user link + state is `GET /api/user/identities` (works fine with the bearer token), + which is what `ConnectedAccountsViewModel` actually uses. + +**Write-path caution, same pattern throughout:** `PostMessageAsync`/ +`DigAsync`/`UndigAsync`/`AddListRowAsync`/`CreateOrganizationAsync`/ +`RemoveIdentityAsync` don't parse their response bodies — some were +deliberately never exercised live (creating an org, disconnecting a linked +identity) to avoid mutating shared test infrastructure, so callers re-fetch +from a `GET` afterward rather than trusting a typed write response. Lists' +schema/column DSL (`PUT /api/lists/{id}/schema`) was only partially reverse +engineered and is **not implemented** — data rows work fine schema-less +(confirmed live), so that's the supported path. If you pick up any of this, +verify the actual response shape against a real (test) account before typing +it strictly, and prefer read-after-write over trusting an unverified envelope. + +Left nav maps to real views now: **Feed**, **Messages** (Direct Messages), +**Lists**, **Documents**, **Organizations**, **People** (profiles + follow), +**Search** (`MainWindow.xaml.cs` `NavItem_Click` swaps a `ContentControl` via a +small per-tag cache in `_views`), **Accounts** (Connected Accounts), +**Settings**, and **Alerts** (right-rail toggle, not a center view). Feed/search +cards open a profile in the People tab via the `Navigator` hub +(`Services/Navigator.cs`) → `MainWindow.OpenProfile`. + +## Packaging & distribution + +Two independent, parallel packaging tracks — both wrap the same +`InterlinedList/InterlinedList.csproj` build, neither depends on the other: + +**MSI (`installer/`)** — WiX Toolset v5 (SDK-style, NuGet-restored via +`WixToolset.Sdk`). It's a **two-step build, not one**: publish the app first, +then build the installer — + +```sh +dotnet publish InterlinedList/InterlinedList.csproj -c Release -r win-x64 --self-contained -p:PublishSingleFile=false +dotnet build installer/InterlinedList.Installer.wixproj -c Release +``` + +WiX then harvests everything under +`InterlinedList/bin/Release/net10.0-windows/win-x64/publish/**` into the MSI via +a `` glob (no manual harvesting/heat step). **A single-command +`BeforeTargets="Build"` auto-publish target was tried and removed** — WiX's +file harvesting runs before that hook ever fires, confirmed empirically in CI +(the publish directory was still missing when harvesting ran), so don't +reintroduce that pattern without verifying it actually executes. Produces +`InterlinedList-Setup.msi` for direct download/side-loading, Start Menu +shortcut, per-machine install under Program Files. **Before shipping:** +replace `installer/License.rtf` with the real EULA. + +**MSIX (`InterlinedList.Package/`)** — classic Desktop Bridge "Windows +Application Packaging Project" (`.wapproj`), the standard route for putting +an existing Win32/.NET desktop app into the Microsoft Store or sideloaded +MSIX. This project type is **not** `dotnet build`-able — its targets come +from `Microsoft.DesktopBridge.props/.targets`, installed with Visual Studio's +"Universal Windows Platform development" workload, not a NuGet package. It +builds fine headlessly with classic MSBuild once that workload is present +(confirmed in CI — see below); you don't need the VS IDE itself, just its +installed build tools. Command (matches what CI runs): + +```powershell +msbuild InterlinedList.Package/InterlinedList.Package.wapproj /restore ` + /p:Configuration=Release /p:Platform=x64 /p:AppxBundlePlatforms=x64 ` + /p:AppxBundle=Always /p:UapAppxPackageBuildMode=StoreUpload +``` + +`UapAppxPackageBuildMode=StoreUpload` produces an unsigned `.msixupload` +bundle meant to be uploaded directly to Partner Center — Partner Center signs +it during ingestion, so **no code-signing certificate is needed for Store +submission** (you would need one for direct sideloading instead, a different +`UapAppxPackageBuildMode`). **Before Store submission:** replace the +placeholder `Publisher` value in `Package.appxmanifest` with the identity +reserved in Partner Center (VS's "Associate App with the Store" wizard will +rewrite `Identity`/`Properties` for you if you do it from the IDE instead). + +**`TargetPlatformVersion` must match a UAP SDK actually installed on the +build machine** — this isn't a fixed "latest is fine" choice. Different +machines (and different GitHub Actions runner image versions over time) have +different SDKs installed; check what's present rather than assuming +(`Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\Platforms\UAP"`) if a +future SDK-not-found error (`APPX3217`) shows up after a runner image update. + +Image assets in `InterlinedList.Package/Images/` were generated from +`brand-kit/logo/logo-icon-master.png` with transparent padding (tiles) and a +teal-deep `#0C2C3A` background (splash) — regenerate them the same way if the +mark changes, don't hand-edit the PNGs. + +## Continuous integration + +`.github/workflows/build.yml` runs on every push/PR to `main`/`dev` (and via +manual `workflow_dispatch`), on `windows-latest`, with three jobs: + +- **`build-app`** — fast sanity-check `dotnet build` of the WPF app alone; the + other two jobs `needs:` this one so a trivial compile break fails fast + instead of waiting on a much slower packaging build. +- **`build-msi`** — publishes the app, then builds the WiX MSI, uploads + `InterlinedList-Setup-msi` as a workflow artifact. +- **`build-msix`** — adds `microsoft/setup-msbuild` (locates VS's MSBuild) + then builds the `.wapproj` directly (see above), uploads + `InterlinedList-Store-package` (the AppxBundle + `.msixupload`) as an + artifact. + +Both packaging jobs were debugged against real CI runs, not assumptions — +three real, non-obvious issues surfaced and are fixed in the current state +(don't reintroduce them): +1. `Package.wxs` declared `ARPNOMODIFY` itself, which collides with the same + property already set by the `WixUI_Minimal` wixlib (`WIX0091` duplicate + symbol) — removed. +2. The app project needs `win-x64` + declared (not just passed via `-r win-x64` on the CLI) — the MSIX + packaging project triggers a *nested* publish of it as a `ProjectReference` + that needs the RID available at restore time (`NETSDK1047` otherwise). +3. `TargetPlatformVersion` in the `.wapproj` must match an SDK actually + installed on the runner (see above) — it drifts as GitHub updates runner + images, so a future image update could reintroduce this failure. + ## Windows-specific rules - App icon: `brand-kit/icons/windows/InterlinedList.ico` (set via ApplicationIcon in .csproj) - Title bar: `WindowChrome` (custom chrome, native resize); deep-teal `#0C2C3A` - Window controls: right-aligned min / max / close; close highlights red on hover - Card corners: 4px (`CornerRadius="4"`) -- Post card left edge: 4px wide, colored by stream type (teal / green / amber) +- Post card left edge: 4px wide; teal by default, amber once you've Dug that message (there's no server-side "stream type" to color by — see API integration) - Dark mode: read from HKCU registry at launch + listen via `SystemEvents.UserPreferenceChanged` ## Developing on macOS diff --git a/InterlinedList.Package/Images/SplashScreen.png b/InterlinedList.Package/Images/SplashScreen.png new file mode 100644 index 0000000..0cd9a2e Binary files /dev/null and b/InterlinedList.Package/Images/SplashScreen.png differ diff --git a/InterlinedList.Package/Images/Square150x150Logo.png b/InterlinedList.Package/Images/Square150x150Logo.png new file mode 100644 index 0000000..a5a6237 Binary files /dev/null and b/InterlinedList.Package/Images/Square150x150Logo.png differ diff --git a/InterlinedList.Package/Images/Square44x44Logo.png b/InterlinedList.Package/Images/Square44x44Logo.png new file mode 100644 index 0000000..459e2fe Binary files /dev/null and b/InterlinedList.Package/Images/Square44x44Logo.png differ diff --git a/InterlinedList.Package/Images/StoreLogo.png b/InterlinedList.Package/Images/StoreLogo.png new file mode 100644 index 0000000..c7a7ff4 Binary files /dev/null and b/InterlinedList.Package/Images/StoreLogo.png differ diff --git a/InterlinedList.Package/InterlinedList.Package.wapproj b/InterlinedList.Package/InterlinedList.Package.wapproj new file mode 100644 index 0000000..64d38e3 --- /dev/null +++ b/InterlinedList.Package/InterlinedList.Package.wapproj @@ -0,0 +1,48 @@ + + + + 08FF27C8-1F81-4A8F-B4B4-F7471E74710A + + 10.0.26100.0 + 10.0.19041.0 + 15 + en-US + AppPackages\ + Always + x64 + 1 + OnApplicationRun + False + False + + Debug + x64 + {C7167F0D-BC9F-4E6E-AFE1-012C56B48DB5};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} + ..\InterlinedList\InterlinedList.csproj + + + + + + + Designer + + + + + + + + + + + + + InterlinedList + + + + + diff --git a/InterlinedList.Package/Package.appxmanifest b/InterlinedList.Package/Package.appxmanifest new file mode 100644 index 0000000..64f65c3 --- /dev/null +++ b/InterlinedList.Package/Package.appxmanifest @@ -0,0 +1,46 @@ + + + + + + + + + InterlinedList + InterlinedList + Images\StoreLogo.png + + + + + + + + + + + + + + + + + + + + + + diff --git a/InterlinedList.slnx b/InterlinedList.slnx index d2e75cc..5bdfd62 100644 --- a/InterlinedList.slnx +++ b/InterlinedList.slnx @@ -1,3 +1,5 @@ + + diff --git a/InterlinedList/App.xaml b/InterlinedList/App.xaml index 5d5d3ec..8b77ff3 100644 --- a/InterlinedList/App.xaml +++ b/InterlinedList/App.xaml @@ -1,7 +1,6 @@ + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> diff --git a/InterlinedList/App.xaml.cs b/InterlinedList/App.xaml.cs index 0e0e3a0..3cd7ae9 100644 --- a/InterlinedList/App.xaml.cs +++ b/InterlinedList/App.xaml.cs @@ -1,3 +1,4 @@ +using InterlinedList.Services; using Microsoft.Win32; using System.Windows; @@ -8,11 +9,45 @@ public partial class App : Application private const string ThemeRegistryKey = @"HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"; - protected override void OnStartup(StartupEventArgs e) + protected override async void OnStartup(StartupEventArgs e) { base.OnStartup(e); ApplyTheme(IsSystemDarkMode()); SystemEvents.UserPreferenceChanged += OnUserPreferenceChanged; + + var restored = await AppServices.Session.TryRestoreSessionAsync(); + if (restored) + ShowMainWindow(); + else + ShowLoginWindow(); + } + + // ── Login/session orchestration ──────────────────────────────────────────── + // No StartupUri: which window appears first depends on whether a saved + // session restores successfully, so both windows are shown manually. + + private void ShowLoginWindow() + { + var login = new LoginWindow(); + login.LoginSucceeded += (_, _) => + { + ShowMainWindow(); + login.Close(); + }; + MainWindow = login; + login.Show(); + } + + private void ShowMainWindow() + { + var main = new MainWindow(); + main.LoggedOut += (_, _) => + { + main.Close(); + ShowLoginWindow(); + }; + MainWindow = main; + main.Show(); } protected override void OnExit(ExitEventArgs e) diff --git a/InterlinedList/InterlinedList.csproj b/InterlinedList/InterlinedList.csproj index fa67ea2..8538c94 100644 --- a/InterlinedList/InterlinedList.csproj +++ b/InterlinedList/InterlinedList.csproj @@ -3,6 +3,10 @@ WinExe net10.0-windows + + win-x64 enable enable true @@ -14,4 +18,8 @@ InterlinedList + + + + diff --git a/InterlinedList/LoginWindow.xaml b/InterlinedList/LoginWindow.xaml new file mode 100644 index 0000000..916a5c5 --- /dev/null +++ b/InterlinedList/LoginWindow.xaml @@ -0,0 +1,220 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/InterlinedList/Views/PeopleView.xaml.cs b/InterlinedList/Views/PeopleView.xaml.cs new file mode 100644 index 0000000..b0449e6 --- /dev/null +++ b/InterlinedList/Views/PeopleView.xaml.cs @@ -0,0 +1,24 @@ +using System.Windows.Controls; +using InterlinedList.ViewModels; + +namespace InterlinedList.Views; + +public partial class PeopleView : UserControl +{ + private readonly ProfileViewModel _vm; + + public PeopleView() + { + InitializeComponent(); + _vm = new ProfileViewModel(Services.AppServices.Session); + DataContext = _vm; + _ = _vm.LoadCommand.ExecuteAsync(null); + } + + /// Open a specific user's profile (used by shell navigation from a feed/search card). + public void LoadProfile(string username) + { + _vm.LookupUsername = username; + _ = _vm.LoadProfileCommand.ExecuteAsync(null); + } +} diff --git a/InterlinedList/Views/SearchView.xaml b/InterlinedList/Views/SearchView.xaml new file mode 100644 index 0000000..58e3087 --- /dev/null +++ b/InterlinedList/Views/SearchView.xaml @@ -0,0 +1,207 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +