Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,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
Expand Down Expand Up @@ -243,7 +244,9 @@ 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. The signed offline-WebView2 MSI remains a fallback.
the accepted package. 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 signed offline-WebView2 MSI remains 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; certification,
Expand Down
7 changes: 7 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -2398,6 +2398,13 @@ and published 18 assets. Microsoft Store run `30382509727` built the exact tag;
after the owner canceled a conflicting portal-created draft, its second attempt
was accepted by Partner Center for asynchronous certification and publication.

**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)
Expand Down
3 changes: 3 additions & 0 deletions TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2003,6 +2003,9 @@ quick-wins from that audit already landed (see ROADMAP changelog).
and certificates were removed and audited clean. Exact Partner Center
identity values, Store upload/certification, and a Store-signed clean-machine
pass remain external gates in `docs/microsoft-store-submission.md`.
- ☑ 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
Expand Down
5 changes: 5 additions & 0 deletions crates/strand-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 16 additions & 0 deletions crates/strand-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,22 @@ pub async fn repo_open(path: String, state: State<'_, AppState>) -> CmdResult<Re
Ok(meta)
}

#[tauri::command(async)]
pub async fn microsoft_store_update_available() -> CmdResult<bool> {
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<RepoMeta> {
run_blocking("meta", move || Ok(Repo::discover(&path)?.meta()?)).await
Expand Down
3 changes: 3 additions & 0 deletions crates/strand-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ mod ai;
mod azdo_helper;
mod commands;
mod hosting;
mod microsoft_store;
mod path_env;
mod pull_requests;
mod state;
Expand Down Expand Up @@ -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,
Expand Down
46 changes: 46 additions & 0 deletions crates/strand-tauri/src/microsoft_store.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
const STRAND_STORE_URI: &str = "ms-windows-store://pdp/?ProductId=9N0JG96LRC4W";

#[cfg(target_os = "windows")]
pub fn update_available() -> Result<bool, String> {
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<bool, String> {
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())
}
11 changes: 11 additions & 0 deletions docs/learnings.md
Original file line number Diff line number Diff line change
Expand Up @@ -2061,3 +2061,14 @@ older `store-submission` action targets unmanaged MSI/EXE. A successful
workflow submission does not mean certification is complete, and the unsigned
`.msixupload` remains a private Actions artifact. Preserve manual build-only
dispatch so packaging can be diagnosed without mutating Partner Center.

**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.
6 changes: 4 additions & 2 deletions docs/microsoft-store-submission.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,8 +179,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
Expand Down
11 changes: 7 additions & 4 deletions scripts/check-msix.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand Down
16 changes: 10 additions & 6 deletions ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
5 changes: 5 additions & 0 deletions ui/src/lib/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 4 additions & 0 deletions ui/src/lib/tauri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ export function errMessage(e: unknown): string {
* frontend never calls `invoke` with a string literal.
*/
export const tauri = {
microsoftStoreUpdateAvailable: () =>
invoke<boolean>('microsoft_store_update_available'),
microsoftStoreOpenProduct: () =>
invoke<void>('microsoft_store_open_product'),
repoInit: (
path: string,
initialBranch: string,
Expand Down
76 changes: 76 additions & 0 deletions ui/src/stores/updates.test.ts
Original file line number Diff line number Diff line change
@@ -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',
});
});
});
21 changes: 20 additions & 1 deletion ui/src/stores/updates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -32,6 +33,7 @@ interface UpdatesState {
total: number | null;
check(): Promise<void>;
downloadAndInstall(): Promise<void>;
openMicrosoftStore(): Promise<void>;
restart(): Promise<void>;
}

Expand All @@ -49,7 +51,15 @@ export const useUpdates = create<UpdatesState>()((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();
Expand Down Expand Up @@ -84,6 +94,15 @@ export const useUpdates = create<UpdatesState>()((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();
},
Expand Down
Loading