From da509cc39ec890425ce6f03f9bbf9e330e3aabbf Mon Sep 17 00:00:00 2001 From: Daniels-Main Date: Thu, 30 Jul 2026 18:10:13 +0200 Subject: [PATCH 1/2] fix: surface Microsoft Store updates --- Cargo.lock | 1 + README.md | 11 ++-- ROADMAP.md | 7 ++ TASKS.md | 3 + crates/strand-tauri/Cargo.toml | 5 ++ crates/strand-tauri/src/commands.rs | 16 +++++ crates/strand-tauri/src/main.rs | 3 + crates/strand-tauri/src/microsoft_store.rs | 46 +++++++++++++ docs/learnings.md | 11 ++++ docs/microsoft-store-submission.md | 6 +- scripts/check-msix.mjs | 11 ++-- ui/src/App.tsx | 16 +++-- ui/src/lib/i18n.ts | 5 ++ ui/src/lib/tauri.ts | 4 ++ ui/src/stores/updates.test.ts | 76 ++++++++++++++++++++++ ui/src/stores/updates.ts | 21 +++++- ui/src/views/settings/UpdatesSection.tsx | 32 +++++++++ website/docs/getting-started.md | 6 +- website/docs/settings.md | 6 +- 19 files changed, 265 insertions(+), 21 deletions(-) create mode 100644 crates/strand-tauri/src/microsoft_store.rs create mode 100644 ui/src/stores/updates.test.ts diff --git a/Cargo.lock b/Cargo.lock index a50d0e8..2a43998 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5965,6 +5965,7 @@ dependencies = [ "tracing-subscriber", "url", "uuid", + "windows", "windows-sys 0.61.2", "zeroize", "zip", diff --git a/README.md b/README.md index 966ad1a..76daffe 100644 --- a/README.md +++ b/README.md @@ -220,7 +220,8 @@ and the live Diff settings preview. Windows/Linux), open the repository or a chosen file in your editor, open a terminal, a configurable startup space, settings (⌘,) for appearance / terminal / diff / git / hosting / integrations / AI, consistent - keyboard-native dropdowns, and in-app updates. + keyboard-native dropdowns, and update checks for both direct and Microsoft + Store installations. - **AI commit messages** — suggest subject + body from staged changes (or all unstaged changes when nothing is staged) via your ChatGPT subscription (Codex CLI, `gpt-5.6-luna`) or Claude Code CLI @@ -249,9 +250,11 @@ Store engineering has a verified packaged-classic MSIX with production identity `Danielss.strand`. Publishing a GitHub release builds the exact tag and submits its unsigned `.msixupload` to Store product `9N0JG96LRC4W` through Microsoft's Store Developer CLI; Partner Center signs -the accepted package, and production Store signing is complete. The standalone -GitHub MSI remains unsigned; the certificate-backed offline-WebView2 MSI -workflow is only a fallback. +the accepted package, and production Store signing is complete. Store installs +check Microsoft's native package-update API on launch, notify when an update is +available, and hand installation back to the Store. The standalone GitHub MSI +remains unsigned; the certificate-backed offline-WebView2 MSI workflow is only +a fallback. Listing copy, privacy and user-content policies, in-product inappropriate- content reporting, and release credentials are configured. The first automated submission was accepted by Partner Center on 2026-07-28; Store signing was diff --git a/ROADMAP.md b/ROADMAP.md index 5db6e38..785165a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2452,6 +2452,13 @@ file actions, command-palette destinations, plus pointer and keyboard pane resizing are interactive. Desktop and 900/680 px browser passes found no runtime warnings or horizontal overflow. +**DAN-35 Microsoft Store update discovery shipped (2026-07-30):** MSIX +installs now query Windows' native Store package-update API after launch and +from Settings → Updates. An available update produces an in-app notification +and a keyboard-reachable **Open Microsoft Store** handoff while Microsoft Store +retains ownership of download and installation. Direct installs keep using the +signed GitHub Releases updater. + --- ## Cross-cutting tracks (run in parallel with all milestones) diff --git a/TASKS.md b/TASKS.md index 8312b7d..e9145bc 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2045,6 +2045,9 @@ quick-wins from that audit already landed (see ROADMAP changelog). identity values, Store upload/certification, and a Store-signed clean-machine pass were the external gates in `docs/microsoft-store-submission.md`; production Store signing is now complete and clean-machine validation remains. +- ☑ Notify Microsoft Store installs when an update is available + (`microsoft_store_update_available`, launch toast, and Settings check / + **Open Microsoft Store** handoff; DAN-35, 2026-07-30). - ☑ Automate GitHub release submission to Microsoft Store (release-published trigger, production identity, official Store Developer CLI action, protected environment credential contract, and manual build-only recovery in diff --git a/crates/strand-tauri/Cargo.toml b/crates/strand-tauri/Cargo.toml index 1e34274..630771f 100644 --- a/crates/strand-tauri/Cargo.toml +++ b/crates/strand-tauri/Cargo.toml @@ -53,6 +53,11 @@ portable-pty = "0.9" libc = "0.2" [target.'cfg(windows)'.dependencies] +windows = { version = "0.61", features = [ + "Foundation", + "Services_Store", + "System", +] } windows-sys = { version = "0.61", features = [ "Win32_Foundation", "Win32_System_JobObjects", diff --git a/crates/strand-tauri/src/commands.rs b/crates/strand-tauri/src/commands.rs index 2d3a4e8..ae44edf 100644 --- a/crates/strand-tauri/src/commands.rs +++ b/crates/strand-tauri/src/commands.rs @@ -144,6 +144,22 @@ pub async fn repo_open(path: String, state: State<'_, AppState>) -> CmdResult CmdResult { + run_blocking("Microsoft Store update check", || { + crate::microsoft_store::update_available().map_err(|message| CmdError { message }) + }) + .await +} + +#[tauri::command(async)] +pub async fn microsoft_store_open_product() -> CmdResult<()> { + run_blocking("open Microsoft Store", || { + crate::microsoft_store::open_product().map_err(|message| CmdError { message }) + }) + .await +} + #[tauri::command(async)] pub async fn repo_meta(path: String) -> CmdResult { run_blocking("meta", move || Ok(Repo::discover(&path)?.meta()?)).await diff --git a/crates/strand-tauri/src/main.rs b/crates/strand-tauri/src/main.rs index ae0c386..25b28de 100644 --- a/crates/strand-tauri/src/main.rs +++ b/crates/strand-tauri/src/main.rs @@ -4,6 +4,7 @@ mod ai; mod azdo_helper; mod commands; mod hosting; +mod microsoft_store; mod path_env; mod pull_requests; mod state; @@ -73,6 +74,8 @@ fn main() { .manage(state::AppState::default()) .invoke_handler(tauri::generate_handler![ commands::repo_open, + commands::microsoft_store_update_available, + commands::microsoft_store_open_product, commands::repo_terminal_create, commands::terminal_write, commands::terminal_resize, diff --git a/crates/strand-tauri/src/microsoft_store.rs b/crates/strand-tauri/src/microsoft_store.rs new file mode 100644 index 0000000..01c1b08 --- /dev/null +++ b/crates/strand-tauri/src/microsoft_store.rs @@ -0,0 +1,46 @@ +const STRAND_STORE_URI: &str = "ms-windows-store://pdp/?ProductId=9N0JG96LRC4W"; + +#[cfg(target_os = "windows")] +pub fn update_available() -> Result { + use windows::Services::Store::StoreContext; + + let context = StoreContext::GetDefault() + .map_err(|error| format!("Could not connect to Microsoft Store: {error}"))?; + let updates = context + .GetAppAndOptionalStorePackageUpdatesAsync() + .and_then(|operation| operation.get()) + .map_err(|error| format!("Could not check Microsoft Store updates: {error}"))?; + + updates + .Size() + .map(|count| count > 0) + .map_err(|error| format!("Could not read Microsoft Store updates: {error}")) +} + +#[cfg(not(target_os = "windows"))] +pub fn update_available() -> Result { + Err("Microsoft Store updates are only available on Windows".into()) +} + +#[cfg(target_os = "windows")] +pub fn open_product() -> Result<(), String> { + use windows::Foundation::Uri; + use windows::System::Launcher; + + let uri = Uri::CreateUri(&STRAND_STORE_URI.into()) + .map_err(|error| format!("Could not prepare Microsoft Store link: {error}"))?; + let launched = Launcher::LaunchUriAsync(&uri) + .and_then(|operation| operation.get()) + .map_err(|error| format!("Could not open Microsoft Store: {error}"))?; + + if launched { + Ok(()) + } else { + Err("Windows could not open Microsoft Store".into()) + } +} + +#[cfg(not(target_os = "windows"))] +pub fn open_product() -> Result<(), String> { + Err("Microsoft Store is only available on Windows".into()) +} diff --git a/docs/learnings.md b/docs/learnings.md index f3ac1e5..9fab977 100644 --- a/docs/learnings.md +++ b/docs/learnings.md @@ -2114,3 +2114,14 @@ base detection needs an equal-tip fallback. Prefer the primary branch at the same tip; do not treat arbitrary equal-tip siblings as parents because they may have been created from the target later. An explicitly named reflog parent still wins. + +**Store-owned MSIX updates still need in-product discovery (2026-07-30).** +Keep the direct GitHub updater disabled for `VITE_DISTRIBUTION=msix`, but query +`Windows.Services.Store.StoreContext.GetAppAndOptionalStorePackageUpdatesAsync` +after launch and on an explicit check. Microsoft throttles that API to one +fresh check per 30 minutes and ten per 24 hours, so one delayed launch check is +enough; repeated calls may return cached status. Strand may notify and open the +Store product page, but Microsoft Store remains responsible for downloading, +signing, and installing the package. The API requires package identity, so +browser and unpackaged development runs can verify the UI/state contract but +not a real availability response. diff --git a/docs/microsoft-store-submission.md b/docs/microsoft-store-submission.md index 2e9411a..a42e93c 100644 --- a/docs/microsoft-store-submission.md +++ b/docs/microsoft-store-submission.md @@ -180,8 +180,10 @@ desktop app for Windows 11 and uses the operating system's WebView2 runtime. Strand installs no driver or NT service. It reads and writes repositories only after the user opens or clones them. Network operations are user initiated and delegated to system Git or the user's GitHub/Azure tooling. -Microsoft Store manages updates for this installation; Strand's direct -GitHub-Releases updater is disabled in the MSIX build. The app has no product +Microsoft Store manages installation of updates; Strand checks the native +Store package-update API on launch, notifies when an update is available, and +opens product `9N0JG96LRC4W` for installation. Strand's direct GitHub-Releases +updater is disabled in the MSIX build. The app has no product telemetry. Optional crash reporting opens a pre-filled GitHub issue that the user reviews and submits. Optional live generative AI features use the user's separately installed OpenAI Codex CLI or Claude Code CLI to draft commit diff --git a/scripts/check-msix.mjs b/scripts/check-msix.mjs index 20f76bd..278ee8d 100644 --- a/scripts/check-msix.mjs +++ b/scripts/check-msix.mjs @@ -69,11 +69,14 @@ for (const fragment of [ if (!updatesStore.includes('UPDATES_MANAGED_BY_STORE')) { fail('update store must recognize Store-managed MSIX updates'); } -if (!app.includes('if (!isTauri() || UPDATES_MANAGED_BY_STORE) return;')) { - fail('launch auto-update check must be disabled for MSIX'); +if (!app.includes('if (!UPDATES_MANAGED_BY_STORE && !updateAutoCheck) return;')) { + fail('launch update check must always run for MSIX'); } -if (!updatesSection.includes("t('updates.managedByStore')")) { - fail('Updates settings must explain Store-managed updates'); +if (!updatesStore.includes('microsoftStoreUpdateAvailable')) { + fail('update store must query Microsoft Store for MSIX updates'); +} +if (!updatesSection.includes("t('updates.openStore')")) { + fail('Updates settings must hand available Store updates to Microsoft Store'); } for (const fragment of [ diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 2222545..1dab91d 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1006,20 +1006,24 @@ export function App() { else root.style.removeProperty('--accent-h'); }, [activeAccentHue]); - // Update auto-check on launch (Settings → Updates). Delayed a few seconds - // so it never competes with cold-start work, and soft-fails quietly — the - // update endpoint may not be reachable. One-shot by design: prefs read at - // fire time, not subscribed. + // Update check on launch (Settings → Updates). Delayed a few seconds so it + // never competes with cold-start work, and soft-fails quietly. Direct + // installs respect the auto-check preference; Store installs always ask the + // Store API once so an available Store-owned update is not invisible. useEffect(() => { - if (!isTauri() || UPDATES_MANAGED_BY_STORE) return; + if (!isTauri()) return; const timer = setTimeout(() => { const { updateAutoCheck, updateAutoInstall } = useSettings.getState(); - if (!updateAutoCheck) return; + if (!UPDATES_MANAGED_BY_STORE && !updateAutoCheck) return; void (async () => { const updates = useUpdates.getState(); await updates.check(); const { status, version } = useUpdates.getState(); if (status !== 'available') return; + if (UPDATES_MANAGED_BY_STORE) { + showToast('A Strand update is available in Microsoft Store — see Settings → Updates'); + return; + } if (updateAutoInstall) { await updates.downloadAndInstall(); if (useUpdates.getState().status === 'ready') { diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index c5d6a8e..bdb1f93 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -99,6 +99,11 @@ export const en = { 'updates.installAutomatically': 'Download and install automatically', 'updates.restartHint': 'Updates apply on the next restart; Strand never restarts itself.', 'updates.managedByStore': 'Updates for this installation are managed by Microsoft Store.', + 'updates.storeCurrent': 'Microsoft Store reports that Strand is up to date.', + 'updates.storeAvailable': 'A Strand update is available from Microsoft Store.', + 'updates.storeError': 'Couldn’t check Microsoft Store for updates.', + 'updates.storeErrorReason': 'Couldn’t check Microsoft Store for updates ({reason}).', + 'updates.openStore': 'Open Microsoft Store', 'settings.title': 'Settings', 'settings.sections': 'Settings sections', 'settings.done': 'Done', diff --git a/ui/src/lib/tauri.ts b/ui/src/lib/tauri.ts index eec97bc..5cc509b 100644 --- a/ui/src/lib/tauri.ts +++ b/ui/src/lib/tauri.ts @@ -108,6 +108,10 @@ export function errMessage(e: unknown): string { * frontend never calls `invoke` with a string literal. */ export const tauri = { + microsoftStoreUpdateAvailable: () => + invoke('microsoft_store_update_available'), + microsoftStoreOpenProduct: () => + invoke('microsoft_store_open_product'), repoInit: ( path: string, initialBranch: string, diff --git a/ui/src/stores/updates.test.ts b/ui/src/stores/updates.test.ts new file mode 100644 index 0000000..5606ceb --- /dev/null +++ b/ui/src/stores/updates.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + checkDirect: vi.fn(), + openMicrosoftStore: vi.fn(), + relaunch: vi.fn(), + storeUpdateAvailable: vi.fn(), +})); + +vi.mock('@tauri-apps/plugin-updater', () => ({ + check: mocks.checkDirect, +})); + +vi.mock('@tauri-apps/plugin-process', () => ({ + relaunch: mocks.relaunch, +})); + +vi.mock('../lib/tauri', () => ({ + errMessage: (error: unknown) => ( + error && typeof error === 'object' && 'message' in error + ? String(error.message) + : String(error) + ), + tauri: { + microsoftStoreOpenProduct: mocks.openMicrosoftStore, + microsoftStoreUpdateAvailable: mocks.storeUpdateAvailable, + }, +})); + +describe('Microsoft Store updates', () => { + beforeEach(() => { + vi.resetModules(); + vi.stubEnv('VITE_DISTRIBUTION', 'msix'); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('surfaces an available update and opens its Store product page', async () => { + mocks.storeUpdateAvailable.mockResolvedValue(true); + mocks.openMicrosoftStore.mockResolvedValue(undefined); + + const { useUpdates } = await import('./updates'); + await useUpdates.getState().check(); + + expect(useUpdates.getState().status).toBe('available'); + await useUpdates.getState().openMicrosoftStore(); + expect(mocks.openMicrosoftStore).toHaveBeenCalledOnce(); + expect(mocks.checkDirect).not.toHaveBeenCalled(); + }); + + it('reports when Microsoft Store has no update', async () => { + mocks.storeUpdateAvailable.mockResolvedValue(false); + + const { useUpdates } = await import('./updates'); + await useUpdates.getState().check(); + + expect(useUpdates.getState().status).toBe('upToDate'); + }); + + it('keeps native Store diagnostics readable', async () => { + mocks.storeUpdateAvailable.mockRejectedValue({ + message: 'Microsoft Store is unavailable', + }); + + const { useUpdates } = await import('./updates'); + await useUpdates.getState().check(); + + expect(useUpdates.getState()).toMatchObject({ + status: 'error', + error: 'Microsoft Store is unavailable', + }); + }); +}); diff --git a/ui/src/stores/updates.ts b/ui/src/stores/updates.ts index 0299a84..178c1f7 100644 --- a/ui/src/stores/updates.ts +++ b/ui/src/stores/updates.ts @@ -3,6 +3,7 @@ import { check, type Update } from '@tauri-apps/plugin-updater'; import { create } from 'zustand'; import { UPDATES_MANAGED_BY_STORE } from '../lib/distribution'; +import { errMessage, tauri } from '../lib/tauri'; /** * App-update state (Settings → Updates + the launch auto-check). One store so @@ -32,6 +33,7 @@ interface UpdatesState { total: number | null; check(): Promise; downloadAndInstall(): Promise; + openMicrosoftStore(): Promise; restart(): Promise; } @@ -49,7 +51,15 @@ export const useUpdates = create()((set, get) => ({ async check() { if (UPDATES_MANAGED_BY_STORE) { pending = null; - set({ status: 'upToDate', version: null, notes: null, error: null }); + const { status } = get(); + if (status === 'checking') return; + set({ status: 'checking', version: null, notes: null, error: null }); + try { + const available = await tauri.microsoftStoreUpdateAvailable(); + set({ status: available ? 'available' : 'upToDate' }); + } catch (e) { + set({ status: 'error', error: errMessage(e) }); + } return; } const { status } = get(); @@ -84,6 +94,15 @@ export const useUpdates = create()((set, get) => ({ } }, + async openMicrosoftStore() { + if (!UPDATES_MANAGED_BY_STORE) return; + try { + await tauri.microsoftStoreOpenProduct(); + } catch (e) { + set({ status: 'error', error: errMessage(e) }); + } + }, + async restart() { await relaunch(); }, diff --git a/ui/src/views/settings/UpdatesSection.tsx b/ui/src/views/settings/UpdatesSection.tsx index 38822d7..518e742 100644 --- a/ui/src/views/settings/UpdatesSection.tsx +++ b/ui/src/views/settings/UpdatesSection.tsx @@ -23,6 +23,7 @@ export function UpdatesSection() { const total = useUpdates((s) => s.total); const checkNow = useUpdates((s) => s.check); const downloadAndInstall = useUpdates((s) => s.downloadAndInstall); + const openMicrosoftStore = useUpdates((s) => s.openMicrosoftStore); const restart = useUpdates((s) => s.restart); const [current, setCurrent] = useState(null); @@ -32,6 +33,14 @@ export function UpdatesSection() { const inTauri = isTauri(); if (UPDATES_MANAGED_BY_STORE) { + const storeStatusLine = + status === 'checking' ? t('updates.checking') + : status === 'upToDate' ? t('updates.storeCurrent') + : status === 'available' ? t('updates.storeAvailable') + : status === 'error' + ? error ? t('updates.storeErrorReason', { reason: error }) : t('updates.storeError') + : null; + return (
@@ -40,10 +49,33 @@ export function UpdatesSection() { {inTauri ? `Strand ${current ?? '…'}` : t('updates.browserPreview')} + {status === 'available' ? ( + + ) : ( + + )}

{t('updates.managedByStore')}

+ {storeStatusLine && ( +

+ {storeStatusLine} +

+ )}
); diff --git a/website/docs/getting-started.md b/website/docs/getting-started.md index 277bf27..6f6f5cd 100644 --- a/website/docs/getting-started.md +++ b/website/docs/getting-started.md @@ -114,8 +114,10 @@ always apply on the next restart — Strand never restarts itself. Update packages are cryptographically signed. The in-app updater covers the macOS app, direct Windows MSI installs, and the -Linux AppImage. Microsoft Store MSIX installs update through Microsoft Store; -Linux `.deb` and `.rpm` installs update through their package manager. +Linux AppImage. Microsoft Store MSIX installs check Store availability on +launch, notify you when an update exists, and open the Strand Store page for +installation. Linux `.deb` and `.rpm` installs update through their package +manager. ## Settings diff --git a/website/docs/settings.md b/website/docs/settings.md index 6eddfdf..0ffac7f 100644 --- a/website/docs/settings.md +++ b/website/docs/settings.md @@ -180,8 +180,10 @@ outputs, or sensitive classifications. The in-app updater covers the macOS app, direct Windows MSI installs, and the Linux AppImage; `.deb` and `.rpm` installs are not covered — update them by downloading the new release from GitHub Releases. Microsoft Store MSIX -installations instead show **Updates for this installation are managed by -Microsoft Store** and hide the direct update controls. +installations instead check Microsoft Store on launch and from this section. +When an update is available, Strand notifies you and offers **Open Microsoft +Store**; Microsoft Store remains responsible for downloading and installing +the package. ## Privacy From 12412f65e016a140390145e057a222548fb7630e Mon Sep 17 00:00:00 2001 From: Daniels-Main Date: Thu, 30 Jul 2026 18:20:59 +0200 Subject: [PATCH 2/2] fix: gate Store URI to Windows --- crates/strand-tauri/src/microsoft_store.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/strand-tauri/src/microsoft_store.rs b/crates/strand-tauri/src/microsoft_store.rs index 01c1b08..8c629bb 100644 --- a/crates/strand-tauri/src/microsoft_store.rs +++ b/crates/strand-tauri/src/microsoft_store.rs @@ -1,3 +1,4 @@ +#[cfg(target_os = "windows")] const STRAND_STORE_URI: &str = "ms-windows-store://pdp/?ProductId=9N0JG96LRC4W"; #[cfg(target_os = "windows")]