diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8ef43e..55e385b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,6 +98,12 @@ jobs: - name: Verify release security policy run: pnpm release:check-security + - name: Verify Microsoft Store package policy + run: pnpm store:check + + - name: Verify Microsoft Store MSIX policy + run: pnpm run store:msix:check + - name: Typecheck run: pnpm --filter ./ui exec tsc --noEmit diff --git a/.github/workflows/microsoft-store-msix.yml b/.github/workflows/microsoft-store-msix.yml new file mode 100644 index 0000000..879ad91 --- /dev/null +++ b/.github/workflows/microsoft-store-msix.yml @@ -0,0 +1,67 @@ +name: Microsoft Store MSIX candidate + +on: + workflow_dispatch: + inputs: + tag: + description: 'Existing release tag to package (for example v1.1.1).' + required: true + identity_name: + description: 'Exact Package/Identity/Name from Partner Center.' + required: true + publisher: + description: 'Exact Package/Identity/Publisher from Partner Center.' + required: true + publisher_display_name: + description: 'Exact Package/Properties/PublisherDisplayName from Partner Center.' + required: true + +jobs: + build: + runs-on: windows-latest + permissions: + contents: read + env: + VERSION: ${{ inputs.tag }} + MSIX_IDENTITY_NAME: ${{ inputs.identity_name }} + MSIX_PUBLISHER: ${{ inputs.publisher }} + MSIX_PUBLISHER_DISPLAY_NAME: ${{ inputs.publisher_display_name }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag }} + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + key: microsoft-store-msix-x64 + + - run: pnpm install --frozen-lockfile + - run: node scripts/check-release-version.mjs + - run: pnpm release:check-security + - run: pnpm run store:msix:check + + - name: Build unsigned Store MSIX + shell: pwsh + run: | + ./scripts/build-msix.ps1 ` + -IdentityName $env:MSIX_IDENTITY_NAME ` + -Publisher $env:MSIX_PUBLISHER ` + -PublisherDisplayName $env:MSIX_PUBLISHER_DISPLAY_NAME ` + -StoreSubmission + + - uses: actions/upload-artifact@v4 + with: + name: strand-microsoft-store-msix-${{ inputs.tag }} + path: | + target/msix/dist/*.msix + target/msix/dist/*.msixupload + if-no-files-found: error diff --git a/.github/workflows/microsoft-store.yml b/.github/workflows/microsoft-store.yml new file mode 100644 index 0000000..f43ebc3 --- /dev/null +++ b/.github/workflows/microsoft-store.yml @@ -0,0 +1,111 @@ +name: Microsoft Store candidate + +on: + workflow_dispatch: + inputs: + tag: + description: 'Existing release tag to package (for example v1.1.1).' + required: true + publish_asset: + description: 'Upload the verified Store MSI to the existing GitHub release.' + type: boolean + default: false + +jobs: + build: + runs-on: windows-latest + permissions: + contents: write + env: + VERSION: ${{ inputs.tag }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag }} + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + key: microsoft-store-x64 + + - run: pnpm install --frozen-lockfile + - run: node scripts/check-release-version.mjs + - run: pnpm release:check-security + - run: pnpm store:check + + - name: Import Windows publisher certificate + shell: pwsh + env: + WINDOWS_CERTIFICATE_BASE64: ${{ secrets.WINDOWS_CERTIFICATE_BASE64 }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + run: | + $bytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE_BASE64) + $flags = [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::PersistKeySet ` + -bor [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::UserKeySet + $cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new( + $bytes, + $env:WINDOWS_CERTIFICATE_PASSWORD, + $flags + ) + if (-not $cert.HasPrivateKey) { + throw 'The Windows publisher certificate has no private key.' + } + $store = [System.Security.Cryptography.X509Certificates.X509Store]::new('My', 'CurrentUser') + $store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite) + try { + $store.Add($cert) + } finally { + $store.Close() + } + "WINDOWS_CERTIFICATE_THUMBPRINT=$($cert.Thumbprint)" >> $env:GITHUB_ENV + + - name: Prepare signing configuration + run: > + node scripts/prepare-microsoft-store-config.mjs + crates/strand-tauri/tauri.microsoftstore.conf.json + target/microsoft-store/tauri.microsoftstore.conf.json + + - name: Build signed offline Store MSI + run: > + pnpm tauri build + --config target/microsoft-store/tauri.microsoftstore.conf.json + --ci + + - name: Verify publisher and updater signatures + shell: pwsh + run: | + $version = '${{ inputs.tag }}'.TrimStart('v') + $msi = "target/release/bundle/msi/Strand_${version}_x64_en-US.msi" + ./scripts/verify-microsoft-store-package.ps1 ` + -MsiPath $msi ` + -ExecutablePath target/release/strand.exe + pnpm release:check-updater-signatures + + New-Item -ItemType Directory -Force target/microsoft-store/dist | Out-Null + $asset = "target/microsoft-store/dist/Strand_${version}_x64_en-US_store.msi" + Copy-Item -LiteralPath $msi -Destination $asset + Copy-Item -LiteralPath "$msi.sig" -Destination "$asset.sig" + "STORE_ASSET=$asset" >> $env:GITHUB_ENV + + - uses: actions/upload-artifact@v4 + with: + name: strand-microsoft-store-${{ inputs.tag }} + path: target/microsoft-store/dist/* + if-no-files-found: error + + - name: Upload versioned package URL asset + if: inputs.publish_asset + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: gh release upload '${{ inputs.tag }}' $env:STORE_ASSET "$env:STORE_ASSET.sig" diff --git a/Cargo.lock b/Cargo.lock index dcc2c8c..5a59986 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5888,7 +5888,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "strand-azdo" -version = "1.1.0" +version = "1.1.1" dependencies = [ "base64 0.22.1", "dirs", @@ -5910,7 +5910,7 @@ dependencies = [ [[package]] name = "strand-azdo-protocol" -version = "1.1.0" +version = "1.1.1" dependencies = [ "serde", "serde_json", @@ -5920,7 +5920,7 @@ dependencies = [ [[package]] name = "strand-core" -version = "1.1.0" +version = "1.1.1" dependencies = [ "git2", "gix", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "strand-tauri" -version = "1.1.0" +version = "1.1.1" dependencies = [ "base64 0.22.1", "flate2", diff --git a/Cargo.toml b/Cargo.toml index c6b3ac8..b9bddc3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ ] [workspace.package] -version = "1.1.0" +version = "1.1.1" edition = "2021" license = "AGPL-3.0-or-later" authors = ["Daniel Schwarz"] diff --git a/README.md b/README.md index 4093db3..b034b89 100644 --- a/README.md +++ b/README.md @@ -235,7 +235,14 @@ real-world repositories daily. The annotated `v1.0.0` tag and draft release artifacts now exist; the updater-signature gates passed, macOS was notarized, and Linux AppImages are keyless-signed with Sigstore. Public release remains held for Windows publisher signing and the real macOS/GNOME/KDE candidate -runs. See the +runs. Microsoft Store engineering now has a verified packaged-classic MSIX +path: a development-identity package registered and launched from WindowsApps, +Store builds disable the direct updater in favor of Store-managed updates, and +the workflow produces an unsigned `.msixupload` for Partner Center to sign. +The signed offline-WebView2 MSI remains a fallback. Listing copy, privacy and +user-content policies, in-product inappropriate-content reporting, and +screenshots are prepared; the real Partner Center MSIX identity, trademark +approval, and Store certification remain owner gates. See the [`1.0 parity audit`](./docs/git-client-1.0-audit.md), [`ROADMAP.md`](./ROADMAP.md), [`release checklist`](./docs/release-checklist.md), and [`TASKS.md`](./TASKS.md). @@ -272,6 +279,7 @@ strand/ │ ├── strand-azdo-protocol/ # Shared optional-helper JSON contract │ ├── strand-azdo/ # Azure DevOps Server REST helper CLI │ └── strand-tauri/ # Tauri 2 app shell + IPC commands +├── packaging/ # Store/distribution manifests assembled around release binaries ├── ui/ # Vite + React + TypeScript frontend ├── website/ # strand.danielss.dev: landing page + user guide (website/docs/, no build step) ├── docs/ # design notes, perf baseline, packaging diff --git a/ROADMAP.md b/ROADMAP.md index b546fcd..a297d7e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2101,6 +2101,38 @@ tag itself is annotated but not cryptographically signed because this checkout has no tag-signing identity. This owner-authorized push does not close the Windows publisher, trademark, real-platform smoke, or public-release gates. +**Microsoft Store engineering path prepared (2026-07-24):** Strand now has a +separate x64 Store MSI flavor so GitHub's normal installer stays lean while the +Store candidate embeds WebView2 for fully offline silent installation. A manual +exact-tag workflow imports the external publisher PFX, requires timestamped +Authenticode on both the executable and MSI, rechecks the established updater +key, and can attach an immutable versioned Store asset. Partner Center copy, +live-generative-AI disclosure, public privacy and user-content policies, +certification notes, and an in-product inappropriate-content reporting route +plus four sanitized +2160×1380 screenshots are checked in. The local 1.1.1 engineering +build produced a 221.8 MB offline MSI; signature inspection confirmed that +both the MSI and embedded executable remain intentionally unpromoted and +unsigned. Partner Center reservation, the real publisher identity/secrets, +trademark/privacy approval, and clean +install/update/uninstall certification remain external gates. + +**Microsoft Store MSIX path verified (2026-07-25):** The preferred Store route +is now an x64 packaged-classic MSIX assembled with MakeAppx from Strand's +release executable and parameterized Partner Center identity. The MSIX build +uses a compile-time distribution channel to disable the direct GitHub updater +and show Store-managed updates instead. A fresh 17,656,211-byte 1.1.1 +development package passed full MakeAppx validation, was copied and signed with +a temporary test certificate, registered as +`dev.danielss.strand.msix.test_1.1.1.0_x64__94yh9fqhspgzm`, and launched a +responsive Strand window from the protected WindowsApps location. Cleanup +removed the exact package, process, and certificate from every touched store. +The manual **Microsoft Store MSIX candidate** workflow now requires the exact +Partner Center Name, Publisher, and PublisherDisplayName and emits an unsigned +`.msix` plus `.msixupload` for Microsoft to sign. The existing MSI path remains +a CA-certificate-dependent fallback. Product creation, real identity, upload, +and Store certification remain external gates. + --- ## 1.1+ — Post-1.0 diff --git a/TASKS.md b/TASKS.md index 878eb51..e7d9265 100644 --- a/TASKS.md +++ b/TASKS.md @@ -1972,7 +1972,28 @@ quick-wins from that audit already landed (see ROADMAP changelog). `STRAND` class-9 registrations exist in the US and EU for Signify lighting- control software/equipment, plus other related software marks; owner/counsel clearance or a rename decision is required before this row can close. -- ☐ Reserve `dev.danielss.strand` IDs in macOS App Store + Microsoft Store +- ☐ Reserve `dev.danielss.strand` in the macOS App Store; create **Strand** as + an MSIX/PWA product in Microsoft Partner Center and copy its exact Product + identity values +- ☑ Prepare the preferred Microsoft Store MSIX submission path + (`AppxManifest.xml.in`, `build-msix.ps1`, MSIX distribution channel, + `.msixupload` workflow, and local WindowsApps registration/launch proof; + 2026-07-25). The fresh 1.1.1 development package passed full MakeAppx + validation, registered with a temporary test signature, and launched a + responsive Strand window from its real package identity. The test package + 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`. +- ☑ Prepare the Microsoft Store MSI/EXE fallback path + (`tauri.microsoftstore.conf.json`, `microsoft-store.yml`, fail-closed package + checks, Partner Center copy, privacy and user-content policies, in-product + inappropriate-content reporting, and four sanitized listing screenshots; + 2026-07-25). A local + 1.1.1 Store-format MSI build passed the repository gates; Authenticode + inspection correctly remains `NotSigned`. External Partner Center name + reservation, publisher certificate, trademark/privacy approval, and + clean-install certification remain open in + `docs/microsoft-store-submission.md`. - ☑ Create GitHub org / repo + decide visibility (`danielss-dev/strand`, made public 2026-06-12 — AGPL-3.0 LICENSE + COMMERCIAL.md at root) - ☐ Social handles (X, Mastodon) diff --git a/crates/strand-tauri/tauri.conf.json b/crates/strand-tauri/tauri.conf.json index 5471c19..fbb9b6c 100644 --- a/crates/strand-tauri/tauri.conf.json +++ b/crates/strand-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Strand", - "version": "1.1.0", + "version": "1.1.1", "identifier": "dev.danielss.strand", "build": { "frontendDist": "../../ui/dist", diff --git a/crates/strand-tauri/tauri.microsoftstore.conf.json b/crates/strand-tauri/tauri.microsoftstore.conf.json new file mode 100644 index 0000000..b50bd91 --- /dev/null +++ b/crates/strand-tauri/tauri.microsoftstore.conf.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "bundle": { + "targets": ["msi"], + "publisher": "Daniel Schwarz Campos", + "windows": { + "digestAlgorithm": "sha256", + "timestampUrl": "http://timestamp.digicert.com", + "webviewInstallMode": { + "type": "offlineInstaller", + "silent": true + } + } + } +} diff --git a/docs/learnings.md b/docs/learnings.md index 65bda29..fd41e38 100644 --- a/docs/learnings.md +++ b/docs/learnings.md @@ -1965,3 +1965,35 @@ metadata plus echoed stdin containing repository paths, prompts, and patches. Preserve only bounded single-line diagnostics; classify known failures from the diagnostic prefix before any prompt boundary, and replace every unclassified transcript with a stable Strand-authored recovery hint. + +**The Microsoft Store MSI is a separate, fail-closed release flavor +(2026-07-24).** Tauri's supported Store path links Partner Center to an +unpackaged MSI; do not improvise an unverified MSIX conversion. Keep the normal +GitHub MSI lean and merge `tauri.microsoftstore.conf.json` only for the Store +candidate so WebView2 is embedded and installed silently. The submitted URL is +immutable and version-bearing. Require both trust layers before upload: +timestamped Authenticode on `strand.exe` plus the MSI, and Tauri updater +signatures matching embedded key `84FCBFD2A981CE5D`. Store metadata must +explicitly disclose the optional live-generative-AI drafts, link the public +privacy notice, and keep a keyboard-reachable in-product route for reporting +inappropriate provider, user-generated, or generated content; keyboard support +alone is not evidence for claiming an audited accessibility standard. +When Strand displays or sends Git-hosted user content, Store policy 11.12 also +requires public user-content guidelines. Keep +`website/docs/content-guidelines.md` linked from the documentation manifest and +public site alongside the privacy notice, and keep reporting/removal guidance +aligned with the actual in-product reporting path. + +**The verified packaged-classic MSIX is the preferred Microsoft Store route +(2026-07-25), superseding the earlier prohibition on an unverified conversion.** +Tauri still does not generate MSIX, but Microsoft's documented manual +packaging path is now proven for Strand: MakeAppx completed semantic validation, +a temporary test-signed package registered, and Windows launched the real +WindowsApps executable through its AUMID. Keep the manifest identity fully +parameterized and require the exact Partner Center `Name`, `Publisher`, and +`PublisherDisplayName`; never submit the development `.test` identity. Build +MSIX with `VITE_DISTRIBUTION=msix` so Microsoft Store owns updates and the +direct GitHub updater is neither checked nor offered. The Store candidate +workflow must emit an unsigned `.msixupload` for Partner Center to sign—do not +reintroduce a CA certificate requirement into this route. Retain the signed, +offline-WebView2 MSI workflow only as a fallback. diff --git a/docs/microsoft-store-submission.md b/docs/microsoft-store-submission.md new file mode 100644 index 0000000..8da191c --- /dev/null +++ b/docs/microsoft-store-submission.md @@ -0,0 +1,284 @@ +# Microsoft Store submission + +This is the source of truth for Strand's Microsoft Store listing and candidate +package. The preferred route is an x64 MSIX packaged-classic desktop app. +Tauri 2 does not emit MSIX directly, so `scripts/build-msix.ps1` assembles the +release executable, manifest, and Store assets with Microsoft's MakeAppx tool. +The older MSI/EXE workflow remains available as a fallback, but it requires an +external CA-backed Windows code-signing certificate. + +## Before opening the submission + +- Create **Strand** as an **MSIX or PWA app** in Partner Center. An existing + **EXE or MSI app** product cannot supply the package identity required by + this MSIX. +- Open **Product management → Product identity** and copy these three values + exactly: `Package/Identity/Name`, `Package/Identity/Publisher`, and + `Package/Properties/PublisherDisplayName`. Do not use + `dev.danielss.strand` unless Partner Center actually assigned that value. +- Complete owner/counsel review of the open Strand trademark gate and approve + the factual privacy notice and user-content guidelines at + `https://strand.danielss.dev/docs/?page=privacy` and + `https://strand.danielss.dev/docs/?page=content-guidelines`. +- Do not obtain or upload a Windows publisher certificate for this route. + Partner Center signs the accepted MSIX during certification. + +## Build and package + +Run the **Microsoft Store MSIX candidate** workflow with an existing version +tag and the three exact Product identity values. The workflow: + +1. checks out and version-checks the exact tag; +2. validates Strand's release and MSIX policies; +3. builds the app with the direct Tauri updater disabled in favor of + Store-managed updates; +4. creates an x64 packaged-classic, medium-integrity, full-trust MSIX; +5. validates the manifest and payload with MakeAppx; and +6. uploads both `Strand_.0_x64.msix` and the recommended + `.msixupload` wrapper as a workflow artifact. + +The resulting package is intentionally unsigned. Upload the `.msixupload` +artifact to Partner Center; Microsoft signs the package after certification. +Do not attach this unsigned Store artifact to a public GitHub release. + +For a local development build with a non-Store identity: + +```powershell +pnpm run store:msix:check +pnpm run store:msix:build +``` + +The development artifact is written to `target/msix/dist/`. It is suitable for +manifest inspection and test-signing only. `-StoreSubmission` fails closed +unless explicit non-development identity and publisher values are supplied. + +The older **Microsoft Store candidate** workflow is the MSI/EXE fallback. It: + +1. checks out and version-checks the exact tag; +2. validates Strand's release and Store policies; +3. imports the publisher certificate; +4. builds an x64 MSI with the silent offline WebView2 installer; +5. requires valid, timestamped Authenticode signatures on both `strand.exe` + and the MSI; +6. verifies the Tauri updater signature against embedded key + `84FCBFD2A981CE5D`; and +7. uploads a workflow artifact named + `Strand__x64_en-US_store.msi`. + +That fallback still requires `WINDOWS_CERTIFICATE_BASE64`, +`WINDOWS_CERTIFICATE_PASSWORD`, and the Tauri updater signing secrets. Select +`publish_asset` only after its checks pass. It attaches the immutable Store MSI +to the matching GitHub release, producing this versioned HTTPS URL: + +```text +https://github.com/danielss-dev/strand/releases/download/v/Strand__x64_en-US_store.msi +``` + +Do not replace the bytes at a submitted URL. Publish a new versioned asset and +update the Partner Center submission. + +Before an MSI/EXE fallback certification, install its workflow artifact on a +clean Windows 11 x64 machine through: + +```powershell +msiexec.exe /i .\Strand__x64_en-US_store.msi /qn /norestart +``` + +Verify launch, repository open/clone, update, uninstall, and absence of a +WebView2 download during installation. Then run Microsoft Defender over the +MSI and installed directory. The Store supplies `/qn` for MSI packages. This +installer-specific step does not apply to the MSIX route. + +## Partner Center fields + +### Availability + +- Markets: choose only after trademark/legal approval. Do not silently default + to every market while that gate is open. +- Discoverability: **Available through link** for the first certification + flight; switch to **Available in Microsoft Store** only after the clean + install/update/uninstall pass. +- Pricing: **Free**. Strand has no feature gates or license-key purchase in the + app. Organizations remain responsible for complying with AGPL-3.0 or + obtaining the separately offered commercial license. +- Free trial: not applicable. + +### Properties + +- Category: **Developer tools** +- Product accesses personal information: **Yes**. Strand can display local + repository content and connected Git-host data, and an optional + user-reviewed crash report can contain repository paths. +- Privacy policy: + `https://strand.danielss.dev/docs/?page=privacy` +- Website: `https://strand.danielss.dev` +- Support: `https://github.com/danielss-dev/strand/issues` +- User-generated content guidelines: + `https://strand.danielss.dev/docs/?page=content-guidelines` +- Non-Microsoft drivers or NT services: **No** +- Tested to meet accessibility guidelines: **Do not claim this yet.** Strand + is keyboard-operable, but no external conformance audit is recorded. +- Pen and ink: **No** +- Minimum system: Windows 11, x64; keyboard or pointing device. + +Certification notes: + +```text +Strand is a local-first Git client. The submitted x64 MSIX is a packaged-classic +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 +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 +messages and pull-request text only after an explicit action. Every draft is +editable and is never committed or submitted automatically. Inappropriate +provider, user-generated, or generated content can be reported from Settings > +Privacy or the command palette. Reporting opens a pre-filled GitHub issue that +the user reviews and submits; nothing is sent automatically. +``` + +### Package + +- Upload: the `.msixupload` artifact from **Microsoft Store MSIX candidate** +- Architecture: **x64** (declared by the package) +- Minimum OS: **Windows 11, version 21H2 / build 22000** +- Language: **English (United States)** +- Runtime behavior: **packagedClassicApp**, **mediumIL**, `runFullTrust` +- Updates: **Microsoft Store managed** + +The first submission is x64-only. Add a separately built arm64 package rather +than marking this package neutral. + +### Age ratings + +Answer the questionnaire in Partner Center. Strand itself contains no mature +content, advertising, gambling, or commerce. It can display user-controlled +repository files, commit messages, and pull-request content from connected +services; disclose that user-generated content capability rather than treating +the bundled UI as the only possible content. + +## English (United States) listing + +Product name: + +```text +Strand +``` + +Short description: + +```text +A fast, keyboard-first Git client for reviewing changes, shaping commits, and +working across repositories without losing context. +``` + +Description: + +```text +Strand is a fast, friendly desktop Git client built for the way developers work +now: local changes, AI-assisted edits, pull requests, terminals, and repository +history in one focused workspace. + +Review changes with whole-file context and syntax-aware diffs. Shape clean +commits with precise staging, history, blame, reflog, rebase, conflict, stash, +branch, tag, submodule, and worktree tools. Open GitHub and Azure DevOps pull +requests without leaving the app. Keep files and repository-scoped terminals +beside the diff you are reviewing. + +Strand is local-first and performance-first. It has no product telemetry, no +account of its own, no feature gates, and no license-key prompts. Git +credentials, SSH agents, commit signing, hooks, and supported provider CLIs +remain under your existing system configuration. + +Optional live generative AI can draft a commit message or pull-request text +through your separately installed OpenAI Codex CLI or Claude Code CLI. It runs +only when you ask, uses your provider account, produces an editable draft, and +never commits or submits the result automatically. Report inappropriate output +from **Settings → Privacy → Report inappropriate content…** or the command +palette. Strand opens a pre-filled GitHub issue that the user reviews and +submits; nothing is sent automatically. + +Keyboard navigation and the command palette make the common path fast, while +every major action remains available to pointer users. Light, dark, density, +font, diff, integration, terminal, and AI-provider settings adapt the workspace +without turning it into a full IDE. + +Strand is open source under AGPL-3.0 and free for individuals. Organizations +can comply with the AGPL or obtain the separately offered commercial license. +``` + +App features: + +- Fast local repository status, history, and diff browsing +- Whole-file review queue for AI-assisted changes +- Precise stage, unstage, discard, and commit workflows +- GitHub and Azure DevOps pull-request review +- File editing and repository-scoped terminal tabs +- Branches, tags, stashes, reflog, worktrees, and submodules +- Interactive rebase and conflict-resolution surfaces +- Command palette and keyboard-first navigation +- Optional user-initiated AI drafts through Codex or Claude Code +- Light and dark themes with density and font controls +- No product telemetry or Strand account + +Keywords: + +```text +git +developer tools +code review +diff +repository +version control +pull requests +``` + +Applicable license terms: + +```text +Strand is offered under the GNU Affero General Public License version 3.0. +The complete license text is available at +https://github.com/danielss-dev/strand/blob/main/LICENSE. Organizations that +do not wish to use Strand under AGPL-3.0 may obtain a separate commercial +license under the terms published at +https://github.com/danielss-dev/strand/blob/main/COMMERCIAL.md. +``` + +Copyright and trademark: + +```text +Copyright © 2026 Daniel Schwarz. Strand name and marks are subject to the +owner's rights and the repository's pending trademark review. +``` + +Store logos: + +- 1:1 box art source: `strand.png` (1254×1254) +- Windows package logo: `crates/strand-tauri/icons/StoreLogo.png` (50×50) + +Fresh sanitized 2160×1380 candidate captures are checked in at: + +1. `docs/store-assets/01-review.png` +2. `docs/store-assets/02-history.png` +3. `docs/store-assets/03-settings.png` +4. `docs/store-assets/04-work.png` + +Recheck every image before submission for real credentials, private repository +names, tokens, email addresses, or terminal history. + +## Final external gates + +- [ ] Partner Center developer account is verified. +- [ ] **Strand** is created as an MSIX/PWA app. +- [ ] Exact Product identity values are copied into the MSIX workflow. +- [ ] Owner/counsel closes or explicitly accepts the trademark gate. +- [ ] Owner approves the privacy and license listing text. +- [ ] Partner Center accepts the `.msixupload` and completes package validation. +- [ ] Store-signed package passes clean install/update/uninstall. +- [ ] Final screenshots and Store box art contain no private data. +- [ ] Age-rating questionnaire is completed accurately. +- [ ] First submission is certified through link-only discoverability. diff --git a/docs/packaging.md b/docs/packaging.md index 760e35b..5629b29 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -108,6 +108,28 @@ Windows (`.msi`) and Linux (`.deb`/`.rpm`/`.appimage`) targets are already in AppImage a keyless Sigstore bundle; the Windows publisher identity remains an external 1.0 gate. +### Microsoft Store MSI flavor + +The Microsoft Store uses a separate MSI flavor so the normal GitHub installer +stays small. `crates/strand-tauri/tauri.microsoftstore.conf.json` builds only +MSI, sets the non-product publisher name, and embeds the silent offline WebView2 +installer required by Microsoft's Win32 Store route. Run: + +```text +pnpm store:check +pnpm store:build +``` + +The manual **Microsoft Store candidate** workflow is the publishable path. It +checks out an exact tag, imports the external Authenticode identity, injects +only its thumbprint into a generated config, builds the offline MSI, verifies +valid timestamped signatures on both `strand.exe` and the MSI, and reuses the +existing updater-key identity gate. The optional `publish_asset` input attaches +the verified package to the exact GitHub release under an immutable, +version-bearing `_store.msi` name. Partner Center then links to that HTTPS asset. +Listing copy, privacy/legal notes, screenshots, and remaining external gates +live in `docs/microsoft-store-submission.md`. + --- ## Release CI @@ -199,14 +221,18 @@ repo (or org) Actions **secret**. | `APPLE_TEAM_ID` | macOS notarization | `57CBXS5P39` | | `TAURI_SIGNING_PRIVATE_KEY` | **Every build** | Tauri updater private key (see below) | | `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | **Every build** | Password for that key | +| `WINDOWS_CERTIFICATE_BASE64` | Microsoft Store candidate | Base64 PKCS#12/PFX publisher certificate with private key | +| `WINDOWS_CERTIFICATE_PASSWORD` | Microsoft Store candidate | Password for the publisher PKCS#12/PFX | The `APPLE_*` secrets are mandatory for the release workflow because both the app and the universal helper must be signed/notarized. The `TAURI_SIGNING_*` pair is also **mandatory**: it signs updater artifacts and the helper manifest, and `bundle.createUpdaterArtifacts` is `true`. Any bundle build (CI or local) fails without the private key. Windows publisher signing is -not wired until the external certificate or cloud-signing identity is chosen; -Linux AppImage publisher identity is keyless and needs no repository secret. +wired for the separate Microsoft Store candidate once those two external +certificate secrets exist; the normal release remains unsigned until the owner +deliberately applies the same identity there. Linux AppImage publisher identity +is keyless and needs no repository secret. > **Local builds** now need the key too. Point Tauri at the generated file: > ```sh diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 3765d84..716804d 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -10,6 +10,7 @@ Run from the repository root on the exact candidate commit: ```text pnpm install --frozen-lockfile pnpm release:check-security +pnpm store:check pnpm --filter ./ui exec tsc --noEmit pnpm --filter ./ui exec vitest run cargo check -p strand-core -p strand-tauri @@ -56,6 +57,54 @@ deliberate maintainer action. promoted through a disposable test endpoint, then confirm the normal stable endpoint only sees the published release. +## Microsoft Store distribution + +- [x] Manual packaged-classic MSIX path builds a MakeAppx-validated x64 package, + disables the direct updater in favor of Store-managed updates, and fails + closed unless exact Partner Center identity values are supplied. +- [x] A temporary locally signed MSIX registered and launched from its real + WindowsApps identity on Windows 11; the test app and certificates were then + removed and all four stores audited clean (2026-07-25). +- [x] Manual **Microsoft Store MSIX candidate** workflow creates an unsigned + `.msix` plus the recommended `.msixupload`; Partner Center supplies the + production signature after certification. +- [x] Store-only Tauri flavor builds an MSI with silent offline WebView2 + (`tauri.microsoftstore.conf.json`) while preserving the signed stable updater + as a certificate-dependent fallback. +- [x] Manual Store workflow imports the external publisher certificate, + verifies timestamped Authenticode on both the executable and MSI, verifies + the updater key, and can publish an immutable versioned GitHub asset. +- [x] Partner Center copy, truthful live-generative-AI disclosure, in-product + inappropriate-content reporting, license text, privacy notice, user-content + guidelines, certification notes, and four sanitized screenshots are prepared in + `docs/microsoft-store-submission.md` and `docs/store-assets/`. +- [ ] Deploy the privacy page, verify the Partner Center developer account, + create **Strand** as an MSIX/PWA product, and copy its exact Product identity. +- [ ] Obtain a green **Microsoft Store MSIX candidate** workflow for the exact + submitted tag and upload its `.msixupload` to Partner Center. +- [ ] Run clean Windows 11 install/update/uninstall and Microsoft Defender + scans, then submit initially as link-only discoverability. + +The fresh development-identity MSIX built on 2026-07-25 is 17,656,211 bytes +(SHA-256 +`6D13C5B4E8CD7A705148EBA7B97EE1DB2615FFD46E82B0CF3DD5F9FCA7947D7F`). +MakeAppx completed full semantic validation. A separately copied package was +signed with a temporary self-signed test certificate, registered as +`dev.danielss.strand.msix.test_1.1.1.0_x64__94yh9fqhspgzm`, and launched a +responsive `Strand` window from its protected WindowsApps location. The test +package and exact certificate thumbprint were removed afterward. This proves +the repository-owned package path; it is not a Store candidate because only +Partner Center can provide the real identity and production signature. + +The unsigned local 1.1.1 MSI engineering build on 2026-07-25 produced a +221,757,440-byte offline MSI (SHA-256 +`301EFA1539BA289A46F0454E96CE2730409CB00C0E8D979CECE2C3113EA7D359`). +WiX embeds `MicrosoftEdgeWebView2RuntimeInstallerX64.exe` and invokes it with +`/silent /install`. This proves the repository-owned offline/silent build +path, not the publisher gate: both the MSI and executable correctly report +`NotSigned` locally, and the workstation updater key remains the previously +recorded mismatch. Do not publish this engineering artifact. + ## Brand and legal gate - [ ] Obtain owner/counsel review of `docs/trademark-search.md`. The preliminary diff --git a/docs/store-assets/01-review.png b/docs/store-assets/01-review.png new file mode 100644 index 0000000..9583b7d Binary files /dev/null and b/docs/store-assets/01-review.png differ diff --git a/docs/store-assets/02-history.png b/docs/store-assets/02-history.png new file mode 100644 index 0000000..7003b1e Binary files /dev/null and b/docs/store-assets/02-history.png differ diff --git a/docs/store-assets/03-settings.png b/docs/store-assets/03-settings.png new file mode 100644 index 0000000..db2b0dc Binary files /dev/null and b/docs/store-assets/03-settings.png differ diff --git a/docs/store-assets/04-work.png b/docs/store-assets/04-work.png new file mode 100644 index 0000000..aa21be5 Binary files /dev/null and b/docs/store-assets/04-work.png differ diff --git a/package.json b/package.json index 9603a8a..0bf5d6d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "strand", "private": true, - "version": "1.1.0", + "version": "1.1.1", "description": "Fast, friendly Git client (Tauri 2 + Rust + React).", "scripts": { "dev": "pnpm --filter strand-ui dev", @@ -13,6 +13,10 @@ "tauri:build": "tauri build", "release:check-security": "node scripts/check-release-security.mjs", "release:check-updater-signatures": "node scripts/check-updater-signatures.mjs", + "store:check": "node scripts/check-microsoft-store.mjs", + "store:build": "tauri build --config crates/strand-tauri/tauri.microsoftstore.conf.json --ci", + "store:msix:check": "node scripts/check-msix.mjs", + "store:msix:build": "pwsh -NoProfile -File scripts/build-msix.ps1", "version:set": "node scripts/bump-version.mjs" }, "devDependencies": { diff --git a/packaging/msix/AppxManifest.xml.in b/packaging/msix/AppxManifest.xml.in new file mode 100644 index 0000000..5210588 --- /dev/null +++ b/packaging/msix/AppxManifest.xml.in @@ -0,0 +1,50 @@ + + + + + + Strand + __PUBLISHER_DISPLAY_NAME__ + Fast, friendly Git client + Assets\StoreLogo.png + + + + + + + + + + + + + + + + + + + + diff --git a/scripts/build-msix.ps1 b/scripts/build-msix.ps1 new file mode 100644 index 0000000..070bb87 --- /dev/null +++ b/scripts/build-msix.ps1 @@ -0,0 +1,161 @@ +param( + [string]$IdentityName = 'dev.danielss.strand.msix.test', + [string]$Publisher = 'CN=Strand MSIX Development', + [string]$PublisherDisplayName = 'Daniel Schwarz', + [string]$PackageVersion, + [string]$OutputPath, + [switch]$SkipAppBuild, + [switch]$StoreSubmission +) + +$ErrorActionPreference = 'Stop' + +function ConvertTo-XmlText { + param([Parameter(Mandatory = $true)][string]$Value) + return [System.Security.SecurityElement]::Escape($Value) +} + +function Assert-ChildPath { + param( + [Parameter(Mandatory = $true)][string]$Root, + [Parameter(Mandatory = $true)][string]$Candidate + ) + $rootPath = [IO.Path]::GetFullPath($Root).TrimEnd('\') + $candidatePath = [IO.Path]::GetFullPath($Candidate) + if (-not $candidatePath.StartsWith("$rootPath\", [StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to write outside $rootPath`: $candidatePath" + } + return $candidatePath +} + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$packageJson = Get-Content -Raw -LiteralPath (Join-Path $repoRoot 'package.json') | + ConvertFrom-Json + +if (-not $PackageVersion) { + $parts = [string]$packageJson.version -split '\.' + if ($parts.Count -ne 3) { + throw "package.json version must have three numeric parts, got $($packageJson.version)" + } + $PackageVersion = "$($parts[0]).$($parts[1]).$($parts[2]).0" +} + +if ($IdentityName -notmatch '^[A-Za-z0-9][A-Za-z0-9.-]{2,49}$') { + throw 'IdentityName must be 3-50 characters using letters, numbers, periods, or hyphens.' +} +if ($PackageVersion -notmatch '^\d+\.\d+\.\d+\.\d+$') { + throw 'PackageVersion must use four numeric parts, for example 1.1.1.0.' +} +foreach ($part in $PackageVersion -split '\.') { + if ([int64]$part -gt 65535) { + throw 'Every PackageVersion part must be between 0 and 65535.' + } +} +try { + [void][System.Security.Cryptography.X509Certificates.X500DistinguishedName]::new($Publisher) +} catch { + throw "Publisher must be a valid X.500 distinguished name: $Publisher" +} +if ([string]::IsNullOrWhiteSpace($PublisherDisplayName)) { + throw 'PublisherDisplayName is required.' +} +if ($StoreSubmission) { + if ($IdentityName.EndsWith('.test', [StringComparison]::OrdinalIgnoreCase)) { + throw 'StoreSubmission requires the exact Partner Center package Identity Name.' + } + if ($Publisher -eq 'CN=Strand MSIX Development') { + throw 'StoreSubmission requires the exact Partner Center Publisher ID.' + } +} + +$targetRoot = Join-Path $repoRoot 'target\msix' +$layoutPath = Assert-ChildPath -Root $repoRoot -Candidate (Join-Path $targetRoot 'layout') +$distPath = Assert-ChildPath -Root $repoRoot -Candidate (Join-Path $targetRoot 'dist') +if (-not $OutputPath) { + $OutputPath = Join-Path $distPath "Strand_$($PackageVersion)_x64.msix" +} +$OutputPath = Assert-ChildPath -Root $repoRoot -Candidate $OutputPath + +if (-not $SkipAppBuild) { + $previousDistribution = $env:VITE_DISTRIBUTION + try { + $env:VITE_DISTRIBUTION = 'msix' + & (Join-Path $repoRoot 'node_modules\.bin\tauri.CMD') build --no-bundle --ci + if ($LASTEXITCODE -ne 0) { + throw "Tauri application build failed with exit code $LASTEXITCODE" + } + } finally { + $env:VITE_DISTRIBUTION = $previousDistribution + } +} + +$executablePath = Join-Path $repoRoot 'target\release\strand.exe' +if (-not (Test-Path -LiteralPath $executablePath -PathType Leaf)) { + throw "Missing application executable: $executablePath" +} + +if (Test-Path -LiteralPath $layoutPath) { + Remove-Item -LiteralPath $layoutPath -Recurse -Force +} +New-Item -ItemType Directory -Force -Path (Join-Path $layoutPath 'Assets') | Out-Null +New-Item -ItemType Directory -Force -Path $distPath | Out-Null + +Copy-Item -LiteralPath $executablePath -Destination (Join-Path $layoutPath 'strand.exe') +foreach ($asset in @('StoreLogo.png', 'Square150x150Logo.png', 'Square44x44Logo.png')) { + Copy-Item ` + -LiteralPath (Join-Path $repoRoot "crates\strand-tauri\icons\$asset") ` + -Destination (Join-Path $layoutPath "Assets\$asset") +} +Copy-Item ` + -LiteralPath (Join-Path $repoRoot 'crates\strand-tauri\icons\Square44x44Logo.png') ` + -Destination (Join-Path $layoutPath 'Assets\Square44x44Logo.targetsize-44_altform-unplated.png') + +$templatePath = Join-Path $repoRoot 'packaging\msix\AppxManifest.xml.in' +$manifest = Get-Content -Raw -LiteralPath $templatePath +$manifest = $manifest.Replace('__IDENTITY_NAME__', (ConvertTo-XmlText $IdentityName)) +$manifest = $manifest.Replace('__PUBLISHER__', (ConvertTo-XmlText $Publisher)) +$manifest = $manifest.Replace( + '__PUBLISHER_DISPLAY_NAME__', + (ConvertTo-XmlText $PublisherDisplayName) +) +$manifest = $manifest.Replace('__VERSION__', $PackageVersion) +$manifestPath = Join-Path $layoutPath 'AppxManifest.xml' +[IO.File]::WriteAllText($manifestPath, $manifest, [Text.UTF8Encoding]::new($false)) + +$sdkRoot = 'C:\Program Files (x86)\Windows Kits\10\bin' +$makeAppx = Get-ChildItem -LiteralPath $sdkRoot -Directory | + Where-Object { $_.Name -match '^\d+\.\d+\.\d+\.\d+$' } | + Sort-Object { [version]$_.Name } -Descending | + ForEach-Object { Join-Path $_.FullName 'x64\makeappx.exe' } | + Where-Object { Test-Path -LiteralPath $_ } | + Select-Object -First 1 +if (-not $makeAppx) { + throw 'MakeAppx.exe was not found. Install the Windows SDK.' +} + +& $makeAppx pack /o /v /h SHA256 /d $layoutPath /p $OutputPath +if ($LASTEXITCODE -ne 0) { + throw "MakeAppx failed with exit code $LASTEXITCODE" +} + +$hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $OutputPath).Hash +Write-Host "Built MSIX: $OutputPath" +if ($StoreSubmission) { + $uploadPath = [IO.Path]::ChangeExtension($OutputPath, '.msixupload') + $zipPath = [IO.Path]::ChangeExtension($OutputPath, '.zip') + foreach ($candidate in @($uploadPath, $zipPath)) { + if (Test-Path -LiteralPath $candidate) { + Remove-Item -LiteralPath $candidate -Force + } + } + Compress-Archive -LiteralPath $OutputPath -DestinationPath $zipPath + Move-Item -LiteralPath $zipPath -Destination $uploadPath + Write-Host "Built Store upload: $uploadPath" +} +Write-Host "Identity: $IdentityName" +Write-Host "Publisher: $Publisher" +Write-Host "Version: $PackageVersion" +Write-Host "SHA-256: $hash" +if (-not $StoreSubmission) { + Write-Warning 'This package uses the development identity and is not a Partner Center submission.' +} diff --git a/scripts/check-microsoft-store.mjs b/scripts/check-microsoft-store.mjs new file mode 100644 index 0000000..5623e51 --- /dev/null +++ b/scripts/check-microsoft-store.mjs @@ -0,0 +1,165 @@ +import { readFileSync } from 'node:fs'; + +const mainConfig = JSON.parse( + readFileSync('crates/strand-tauri/tauri.conf.json', 'utf8'), +); +const storeConfig = JSON.parse( + readFileSync('crates/strand-tauri/tauri.microsoftstore.conf.json', 'utf8'), +); +const packageJson = JSON.parse(readFileSync('package.json', 'utf8')); +const storeWorkflow = readFileSync( + '.github/workflows/microsoft-store.yml', + 'utf8', +); +const submissionGuide = readFileSync( + 'docs/microsoft-store-submission.md', + 'utf8', +); +const privacyPolicy = readFileSync('website/docs/privacy.md', 'utf8'); +const contentGuidelines = readFileSync( + 'website/docs/content-guidelines.md', + 'utf8', +); +const docsManifest = readFileSync('website/docs/manifest.json', 'utf8'); +const websiteIndex = readFileSync('website/index.html', 'utf8'); + +function fail(message) { + throw new Error(`Microsoft Store check failed: ${message}`); +} + +function pngSize(path) { + const bytes = readFileSync(path); + const signature = '89504e470d0a1a0a'; + if (bytes.subarray(0, 8).toString('hex') !== signature) { + fail(`${path} is not a PNG`); + } + return { + width: bytes.readUInt32BE(16), + height: bytes.readUInt32BE(20), + }; +} + +if (mainConfig.version !== packageJson.version) { + fail('Tauri and package.json versions differ'); +} + +const bundle = storeConfig.bundle; +if (JSON.stringify(bundle?.targets) !== JSON.stringify(['msi'])) { + fail('Store flavor must build only the MSI target'); +} +if (!bundle.publisher || bundle.publisher === mainConfig.productName) { + fail('publisher must be present and differ from the product name'); +} + +const windows = bundle.windows; +if ( + windows?.webviewInstallMode?.type !== 'offlineInstaller' + || windows.webviewInstallMode.silent !== true +) { + fail('Store MSI must bundle the silent offline WebView2 installer'); +} +if (windows.digestAlgorithm !== 'sha256' || !windows.timestampUrl) { + fail('Store signing must use SHA-256 and a timestamp service'); +} + +if (mainConfig.bundle?.createUpdaterArtifacts !== true) { + fail('Store flavor must retain signed Tauri updater artifacts'); +} +const endpoint = mainConfig.plugins?.updater?.endpoints; +if ( + endpoint?.length !== 1 + || !endpoint[0].startsWith('https://') +) { + fail('Store flavor must retain one HTTPS updater endpoint'); +} + +for (const fragment of [ + 'ref: ${{ inputs.tag }}', + 'node scripts/check-release-version.mjs', + 'pnpm release:check-security', + 'pnpm store:check', + 'WINDOWS_CERTIFICATE_BASE64', + 'WINDOWS_CERTIFICATE_PASSWORD', + 'prepare-microsoft-store-config.mjs', + 'verify-microsoft-store-package.ps1', + 'pnpm release:check-updater-signatures', + 'if: inputs.publish_asset', + 'gh release upload', +]) { + if (!storeWorkflow.includes(fragment)) { + fail(`Store workflow must retain ${JSON.stringify(fragment)}`); + } +} +if ( + storeWorkflow.includes('gh release upload') + && storeWorkflow.includes('--clobber') +) { + fail('Store release assets must be immutable and cannot use --clobber'); +} + +if (!/live generative AI/i.test(submissionGuide)) { + fail('submission guide must disclose live generative AI'); +} +if (!/report inappropriate output/i.test(submissionGuide)) { + fail('submission guide must explain how to report inappropriate AI output'); +} +if (!/privacy/i.test(privacyPolicy)) { + fail('privacy policy must remain present'); +} +if (!/"file"\s*:\s*"privacy"/i.test(docsManifest)) { + fail('documentation manifest must include the privacy page'); +} +if (!/"file"\s*:\s*"content-guidelines"/i.test(docsManifest)) { + fail('documentation manifest must include the content guidelines'); +} +if (!/docs\/\?page=privacy/i.test(websiteIndex)) { + fail('website footer must link to the privacy policy'); +} +if (!/docs\/\?page=content-guidelines/i.test(websiteIndex)) { + fail('website footer must link to the content guidelines'); +} +if ( + !/report inappropriate content/i.test(contentGuidelines) + || !/removed or\s+disabled/i.test(contentGuidelines) +) { + fail('content guidelines must retain reporting and enforcement guidance'); +} + +for (const [path, expected] of [ + ['strand.png', 1254], + ['crates/strand-tauri/icons/icon.png', 512], + ['crates/strand-tauri/icons/StoreLogo.png', 50], +]) { + const size = pngSize(path); + if (size.width !== expected || size.height !== expected) { + fail(`${path} must be ${expected}x${expected}, got ${size.width}x${size.height}`); + } +} + +for (const path of [ + 'docs/microsoft-store-submission.md', + 'website/docs/privacy.md', + 'website/docs/content-guidelines.md', +]) { + readFileSync(path); +} + +const screenshots = [ + 'docs/store-assets/01-review.png', + 'docs/store-assets/02-history.png', + 'docs/store-assets/03-settings.png', + 'docs/store-assets/04-work.png', +]; +for (const path of screenshots) { + const size = pngSize(path); + if (size.width < 1366 || size.height < 768) { + fail(`${path} is too small for a desktop Store listing`); + } +} + +console.log( + `Microsoft Store configuration is valid for Strand ${packageJson.version}: ` + + 'MSI-only, offline WebView2, silent install, SHA-256 timestamping, ' + + 'signed release workflow and updater retained, privacy, user-content, and ' + + `AI disclosures present, and ${screenshots.length} listing screenshots present.`, +); diff --git a/scripts/check-msix.mjs b/scripts/check-msix.mjs new file mode 100644 index 0000000..1687499 --- /dev/null +++ b/scripts/check-msix.mjs @@ -0,0 +1,110 @@ +import { readFileSync } from 'node:fs'; + +const packageJson = JSON.parse(readFileSync('package.json', 'utf8')); +const manifest = readFileSync('packaging/msix/AppxManifest.xml.in', 'utf8'); +const buildScript = readFileSync('scripts/build-msix.ps1', 'utf8'); +const workflow = readFileSync( + '.github/workflows/microsoft-store-msix.yml', + 'utf8', +); +const app = readFileSync('ui/src/App.tsx', 'utf8'); +const updatesStore = readFileSync('ui/src/stores/updates.ts', 'utf8'); +const updatesSection = readFileSync( + 'ui/src/views/settings/UpdatesSection.tsx', + 'utf8', +); + +function fail(message) { + throw new Error(`MSIX check failed: ${message}`); +} + +function pngSize(path) { + const bytes = readFileSync(path); + if (bytes.subarray(0, 8).toString('hex') !== '89504e470d0a1a0a') { + fail(`${path} is not a PNG`); + } + return { + width: bytes.readUInt32BE(16), + height: bytes.readUInt32BE(20), + }; +} + +for (const fragment of [ + 'uap10:RuntimeBehavior="packagedClassicApp"', + 'uap10:TrustLevel="mediumIL"', + '', + 'ProcessorArchitecture="x64"', + 'MinVersion="10.0.22000.0"', + 'Executable="strand.exe"', +]) { + if (!manifest.includes(fragment)) { + fail(`manifest must retain ${JSON.stringify(fragment)}`); + } +} + +for (const placeholder of [ + '__IDENTITY_NAME__', + '__PUBLISHER__', + '__PUBLISHER_DISPLAY_NAME__', + '__VERSION__', +]) { + if (!manifest.includes(placeholder)) { + fail(`manifest must retain ${placeholder}`); + } +} + +for (const fragment of [ + "VITE_DISTRIBUTION = 'msix'", + 'build --no-bundle --ci', + 'MakeAppx.exe', + "ChangeExtension($OutputPath, '.msixupload')", + 'StoreSubmission requires the exact Partner Center package Identity Name', + 'StoreSubmission requires the exact Partner Center Publisher ID', +]) { + if (!buildScript.includes(fragment)) { + fail(`build script must retain ${JSON.stringify(fragment)}`); + } +} + +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 (!updatesSection.includes("t('updates.managedByStore')")) { + fail('Updates settings must explain Store-managed updates'); +} + +for (const fragment of [ + 'identity_name:', + 'publisher:', + 'publisher_display_name:', + '-StoreSubmission', + 'target/msix/dist/*.msixupload', +]) { + if (!workflow.includes(fragment)) { + fail(`Store workflow must retain ${JSON.stringify(fragment)}`); + } +} + +for (const [path, expected] of [ + ['crates/strand-tauri/icons/StoreLogo.png', 50], + ['crates/strand-tauri/icons/Square150x150Logo.png', 150], + ['crates/strand-tauri/icons/Square44x44Logo.png', 44], +]) { + const size = pngSize(path); + if (size.width !== expected || size.height !== expected) { + fail(`${path} must be ${expected}x${expected}, got ${size.width}x${size.height}`); + } +} + +if (!/^\d+\.\d+\.\d+$/.test(packageJson.version)) { + fail(`package version must map to an MSIX quad, got ${packageJson.version}`); +} + +console.log( + `MSIX packaging policy is valid for Strand ${packageJson.version}: ` + + 'x64 packaged-classic full trust, Windows 11 minimum, parameterized ' + + 'Partner Center identity, validated assets, and Store-managed updates.', +); diff --git a/scripts/prepare-microsoft-store-config.mjs b/scripts/prepare-microsoft-store-config.mjs new file mode 100644 index 0000000..5bd48e1 --- /dev/null +++ b/scripts/prepare-microsoft-store-config.mjs @@ -0,0 +1,26 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import process from 'node:process'; + +const [sourcePath, outputPath] = process.argv.slice(2); +if (!sourcePath || !outputPath) { + throw new Error( + 'usage: node scripts/prepare-microsoft-store-config.mjs ', + ); +} + +const thumbprint = (process.env.WINDOWS_CERTIFICATE_THUMBPRINT ?? '') + .replaceAll(/\s/g, '') + .toUpperCase(); +if (!/^[A-F0-9]{40}$/.test(thumbprint)) { + throw new Error( + 'WINDOWS_CERTIFICATE_THUMBPRINT must be a 40-character SHA-1 thumbprint', + ); +} + +const config = JSON.parse(await readFile(sourcePath, 'utf8')); +config.bundle.windows.certificateThumbprint = thumbprint; + +await mkdir(dirname(outputPath), { recursive: true }); +await writeFile(outputPath, `${JSON.stringify(config, null, 2)}\n`); +console.log(`Prepared Store signing config for certificate ${thumbprint}.`); diff --git a/scripts/verify-microsoft-store-package.ps1 b/scripts/verify-microsoft-store-package.ps1 new file mode 100644 index 0000000..95af5af --- /dev/null +++ b/scripts/verify-microsoft-store-package.ps1 @@ -0,0 +1,35 @@ +param( + [Parameter(Mandatory = $true)] + [string]$MsiPath, + + [Parameter(Mandatory = $true)] + [string]$ExecutablePath +) + +$ErrorActionPreference = 'Stop' + +function Assert-ValidSignature { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $resolved = (Resolve-Path -LiteralPath $Path).Path + $signature = Get-AuthenticodeSignature -LiteralPath $resolved + if ($signature.Status -ne 'Valid') { + throw "Microsoft Store signature check failed for $resolved`: $($signature.Status) ($($signature.StatusMessage))" + } + if ($null -eq $signature.SignerCertificate) { + throw "Microsoft Store signature check found no signer for $resolved" + } + if ($null -eq $signature.TimeStamperCertificate) { + throw "Microsoft Store signature check found no timestamp for $resolved" + } + + Write-Host "Valid Authenticode signature: $resolved" + Write-Host " Signer: $($signature.SignerCertificate.Subject)" + Write-Host " Timestamp: $($signature.TimeStamperCertificate.Subject)" +} + +Assert-ValidSignature -Path $ExecutablePath +Assert-ValidSignature -Path $MsiPath diff --git a/ui/package.json b/ui/package.json index b1e013f..a6ca9f7 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,7 +1,7 @@ { "name": "strand-ui", "private": true, - "version": "1.1.0", + "version": "1.1.1", "type": "module", "scripts": { "dev": "node scripts/clean-stale-js.mjs && vite", diff --git a/ui/src/App.tsx b/ui/src/App.tsx index f24fc33..35362f1 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -24,8 +24,9 @@ import { useRepoIcons } from './stores/repoIcons'; import { DEFAULT_WORKSPACE_ID, useWorkspaces } from './stores/workspaces'; import { useWorkspaceReview } from './stores/workspaceReview'; import { useUpdates } from './stores/updates'; +import { UPDATES_MANAGED_BY_STORE } from './lib/distribution'; import { accentHueForColor, groupTabs, pathKey, repoFamilyName, workspaceMemberSet } from './lib/repoIdentity'; -import { buildCrashIssueUrl } from './lib/crashReport'; +import { buildContentReportUrl, buildCrashIssueUrl } from './lib/crashReport'; import { pickCodeWorkspaceFile, pickRepoDirectories } from './lib/dialog'; import { editorTemplate, osType, terminalTemplate } from './lib/integrations'; import { t } from './lib/i18n'; @@ -1010,7 +1011,7 @@ export function App() { // update endpoint may not be reachable. One-shot by design: prefs read at // fire time, not subscribed. useEffect(() => { - if (!isTauri()) return; + if (!isTauri() || UPDATES_MANAGED_BY_STORE) return; const timer = setTimeout(() => { const { updateAutoCheck, updateAutoInstall } = useSettings.getState(); if (!updateAutoCheck) return; @@ -1587,6 +1588,23 @@ export function App() { { id: 'settings', label: 'Settings…', group: 'Actions', shortcut: keyHint('settings'), keywords: 'preferences shortcuts keyboard config options', run: () => openSettingsAt('appearance') }, { id: 'keybindings', label: 'Settings: Keyboard shortcuts', group: 'Actions', keywords: 'keyboard shortcuts keybindings rebind configure customize', run: () => openSettingsAt('keyboard') }, { id: 'settings-ai', label: 'Settings: AI', group: 'Actions', keywords: 'ai chatgpt codex claude commit message suggest login', run: () => openSettingsAt('ai') }, + { + id: 'report-inappropriate-content', + label: 'Report inappropriate content…', + group: 'Actions', + keywords: 'support abuse user generated ugc ai safety pull request report', + run: () => { + void (async () => { + try { + const version = await getVersion().catch(() => 'unknown'); + await shellOpen(buildContentReportUrl(version, osType())); + showToast('Content report opened in your browser — review it before submitting'); + } catch (e) { + showToast(`Couldn't open the report: ${errMessage(e)}`, 'error'); + } + })(); + }, + }, { id: 'theme-light', label: 'Theme: Light', group: 'Actions', run: () => setTheme('light') }, { id: 'theme-dark', label: 'Theme: Dark', group: 'Actions', run: () => setTheme('dark') }, { id: 'theme-system', label: 'Theme: System', group: 'Actions', shortcut: keyHint('theme-toggle'), run: () => setTheme('system') }, diff --git a/ui/src/lib/crashReport.test.ts b/ui/src/lib/crashReport.test.ts index 7090fe0..a6a84a1 100644 --- a/ui/src/lib/crashReport.test.ts +++ b/ui/src/lib/crashReport.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { buildCrashIssueUrl, crashIssueTitle } from './crashReport'; +import { buildContentReportUrl, buildCrashIssueUrl, crashIssueTitle } from './crashReport'; const ENTRY = [ '=== panic at unix:1730000000 (strand 0.8.0)', @@ -47,3 +47,16 @@ describe('buildCrashIssueUrl', () => { expect(decoded).toContain('truncated — the full entry is in crash.log'); }); }); + +describe('buildContentReportUrl', () => { + it('opens a user-reviewed inappropriate-content report without private data', () => { + const url = buildContentReportUrl('1.1.1', 'windows'); + expect(url.startsWith('https://github.com/danielss-dev/strand/issues/new?title=')).toBe(true); + const decoded = decodeURIComponent(url); + expect(decoded).toContain('Report inappropriate content'); + expect(decoded).toContain('Strand version:** 1.1.1'); + expect(decoded).toContain('Platform:** windows'); + expect(decoded).toContain('Do not include credentials, secrets, or private repository content'); + expect(decoded).toContain('User-generated content, pull-request interaction, or AI-generated draft'); + }); +}); diff --git a/ui/src/lib/crashReport.ts b/ui/src/lib/crashReport.ts index 3fac227..b0ac668 100644 --- a/ui/src/lib/crashReport.ts +++ b/ui/src/lib/crashReport.ts @@ -13,6 +13,36 @@ const URL_BUDGET = 7000; const TRUNCATION_NOTE = '\n… (truncated — the full entry is in crash.log)'; +/** + * Prefilled report for inappropriate pull-request, user-generated, or AI + * content. The reporter chooses what to disclose and submits the issue in + * their browser; Strand sends nothing automatically. + */ +export function buildContentReportUrl(version: string, platform: string): string { + const body = [ + '', + '', + `**Strand version:** ${version}`, + `**Platform:** ${platform}`, + '', + '### Content type', + '', + '', + '', + '### Where it appeared', + '', + '', + '', + '### Why it is inappropriate', + '', + '', + '', + ].join('\n'); + return `${ISSUES_URL}?title=${encodeURIComponent('Report inappropriate content')}` + + `&body=${encodeURIComponent(body)}`; +} + /** * Derive an issue title from a panic entry. The hook writes the std panic * Display — `panicked at :::\n` — so the message diff --git a/ui/src/lib/distribution.test.ts b/ui/src/lib/distribution.test.ts new file mode 100644 index 0000000..76efdd3 --- /dev/null +++ b/ui/src/lib/distribution.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; + +import { distributionChannel } from './distribution'; + +describe('distributionChannel', () => { + it('recognizes the Microsoft Store MSIX build', () => { + expect(distributionChannel('msix')).toBe('msix'); + }); + + it('fails closed to the direct distribution channel', () => { + expect(distributionChannel(undefined)).toBe('direct'); + expect(distributionChannel('unexpected')).toBe('direct'); + }); +}); diff --git a/ui/src/lib/distribution.ts b/ui/src/lib/distribution.ts new file mode 100644 index 0000000..3fabcf8 --- /dev/null +++ b/ui/src/lib/distribution.ts @@ -0,0 +1,11 @@ +export type DistributionChannel = 'direct' | 'msix'; + +export function distributionChannel(value: string | undefined): DistributionChannel { + return value === 'msix' ? 'msix' : 'direct'; +} + +export const DISTRIBUTION_CHANNEL = distributionChannel( + import.meta.env.VITE_DISTRIBUTION, +); + +export const UPDATES_MANAGED_BY_STORE = DISTRIBUTION_CHANNEL === 'msix'; diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 882286b..cf5786f 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -83,6 +83,7 @@ export const en = { 'updates.checkOnLaunch': 'Check for updates on launch', '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.', 'settings.title': 'Settings', 'settings.sections': 'Settings sections', 'settings.done': 'Done', diff --git a/ui/src/stores/updates.ts b/ui/src/stores/updates.ts index 54e46ed..0299a84 100644 --- a/ui/src/stores/updates.ts +++ b/ui/src/stores/updates.ts @@ -2,6 +2,8 @@ import { relaunch } from '@tauri-apps/plugin-process'; import { check, type Update } from '@tauri-apps/plugin-updater'; import { create } from 'zustand'; +import { UPDATES_MANAGED_BY_STORE } from '../lib/distribution'; + /** * App-update state (Settings → Updates + the launch auto-check). One store so * the section and App's auto-check effect share a single in-flight update. @@ -45,6 +47,11 @@ export const useUpdates = create()((set, get) => ({ total: null, async check() { + if (UPDATES_MANAGED_BY_STORE) { + pending = null; + set({ status: 'upToDate', version: null, notes: null, error: null }); + return; + } const { status } = get(); if (status === 'checking' || status === 'downloading' || status === 'ready') return; set({ status: 'checking', error: null }); @@ -62,6 +69,7 @@ export const useUpdates = create()((set, get) => ({ }, async downloadAndInstall() { + if (UPDATES_MANAGED_BY_STORE) return; if (!pending || get().status !== 'available') return; set({ status: 'downloading', received: 0, total: null, error: null }); try { diff --git a/ui/src/views/settings/PrivacySection.tsx b/ui/src/views/settings/PrivacySection.tsx index a576b15..01f2738 100644 --- a/ui/src/views/settings/PrivacySection.tsx +++ b/ui/src/views/settings/PrivacySection.tsx @@ -2,7 +2,7 @@ import { getVersion } from '@tauri-apps/api/app'; import { useEffect, useState } from 'react'; import { open as shellOpen } from '@tauri-apps/plugin-shell'; -import { buildCrashIssueUrl } from '../../lib/crashReport'; +import { buildContentReportUrl, buildCrashIssueUrl } from '../../lib/crashReport'; import { osType } from '../../lib/integrations'; import { errMessage, isTauri, tauri } from '../../lib/tauri'; import { useSettings } from '../../stores/settings'; @@ -51,8 +51,31 @@ export function PrivacySection() { } }; + const reportContent = async () => { + try { + const version = await getVersion().catch(() => 'unknown'); + await shellOpen(buildContentReportUrl(version, osType())); + setStatus('Content report opened in your browser — review it before submitting.'); + } catch (e) { + setStatus(`Couldn't open the report: ${errMessage(e)}`); + } + }; + return (
+
+ Content reports +
+ +
+

+ Report inappropriate pull-request, user-generated, or AI-generated content. + Strand opens a pre-filled GitHub issue for you to review and submit; nothing + is sent automatically. +

+
Crash reports
diff --git a/ui/src/views/settings/UpdatesSection.tsx b/ui/src/views/settings/UpdatesSection.tsx index 10001f0..38822d7 100644 --- a/ui/src/views/settings/UpdatesSection.tsx +++ b/ui/src/views/settings/UpdatesSection.tsx @@ -1,6 +1,7 @@ import { getVersion } from '@tauri-apps/api/app'; import { useEffect, useState } from 'react'; +import { UPDATES_MANAGED_BY_STORE } from '../../lib/distribution'; import { isTauri } from '../../lib/tauri'; import { formatNumber, formatPercent, t } from '../../lib/i18n'; import { useSettings } from '../../stores/settings'; @@ -30,6 +31,24 @@ export function UpdatesSection() { }, []); const inTauri = isTauri(); + if (UPDATES_MANAGED_BY_STORE) { + return ( +
+
+ {t('updates.version')} +
+ + {inTauri ? `Strand ${current ?? '…'}` : t('updates.browserPreview')} + +
+

+ {t('updates.managedByStore')} +

+
+
+ ); + } + const statusLine = status === 'checking' ? t('updates.checking') : status === 'upToDate' ? t('updates.current') diff --git a/website/README.md b/website/README.md index badcefb..6925b7b 100644 --- a/website/README.md +++ b/website/README.md @@ -39,6 +39,10 @@ Preview locally with `pnpm site` from the repo root (serves on relative `foo.md` links — the viewer rewrites them (and they render on GitHub too). Keep the guide in sync with app releases: every claim in it was fact-checked against `ui/src` on 2026-07-18 for the 1.0 release candidate. + The public privacy policy used by distribution listings is + `/docs/?page=privacy`, and Store UGC guidance is + `/docs/?page=content-guidelines`; deploy website changes before submitting + either Store URL. ## Before launch diff --git a/website/docs/content-guidelines.md b/website/docs/content-guidelines.md new file mode 100644 index 0000000..d5c831a --- /dev/null +++ b/website/docs/content-guidelines.md @@ -0,0 +1,57 @@ +# Content Guidelines + +Effective 25 July 2026 + +Strand is a local-first Git client. It can display repository files, commit +messages, pull-request discussions, and other content supplied by you or by a +Git hosting provider. Optional AI features can also create editable drafts. +These guidelines apply whenever that content is viewed, created, or shared +through Strand. + +## Be lawful and respectful + +Do not use Strand to create, promote, or share content that: + +- is illegal, fraudulent, or infringes intellectual-property or privacy rights; +- threatens, harasses, exploits, or targets a person or protected group; +- is pornographic, sexually exploitative, or depicts child sexual abuse; +- promotes terrorism, violent extremism, self-harm, or credible real-world + violence; +- contains malware, stolen credentials, secrets, or instructions intended to + compromise another system; or +- deliberately misleads people about the source or safety of software. + +Repository owners and hosting providers may impose stricter rules. Their terms, +community guidelines, and moderation decisions continue to apply. + +## AI-generated drafts + +Treat AI output as an untrusted draft. Review it before use, remove sensitive +repository information, and do not commit or publish content that violates +these guidelines. Strand never commits or submits an AI draft automatically. + +## Reporting + +Choose **Settings → Privacy → Report inappropriate content…** or run **Report +inappropriate content…** from the command palette. Strand opens a pre-filled +GitHub issue that you review before submitting. Do not copy private repository +content, credentials, personal data, or unlawful material into a public report. +Use a safe description and a provider link only when that link is suitable for +public disclosure. + +For content hosted by GitHub, Azure DevOps, or another service, also use that +service's reporting tools and contact the repository owner or organization +administrator when appropriate. Immediate danger should be reported to the +relevant local authorities. + +## Enforcement + +Reports about Strand-owned content or features are reviewed by the Strand +maintainer. Violating content under the maintainer's control may be removed or +disabled, and product safeguards may be changed. Content stored in a user's +repository or on a third-party provider remains controlled by that user, +repository owner, or provider; Strand will cooperate with valid Microsoft, +provider, and legal requests within its control. + +Questions or reports can be opened at +[github.com/danielss-dev/strand/issues](https://github.com/danielss-dev/strand/issues). diff --git a/website/docs/getting-started.md b/website/docs/getting-started.md index 1c4ea0c..277bf27 100644 --- a/website/docs/getting-started.md +++ b/website/docs/getting-started.md @@ -107,9 +107,15 @@ the right. ## In-app updates -Strand checks for updates on launch by default and manages them under Settings → Updates: check for updates, download and install, and restart to apply. Automatic download-and-install is off by default, and updates always apply on the next restart — Strand never restarts itself. Update packages are cryptographically signed. - -The in-app updater covers every install method: the macOS app, the Windows MSI install, and the Linux `.deb`, `.rpm`, and AppImage packages. +Direct Strand installations check for updates on launch by default and manage +them under Settings → Updates: check for updates, download and install, and +restart to apply. Automatic download-and-install is off by default, and updates +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. ## Settings diff --git a/website/docs/index.md b/website/docs/index.md index 8d8039e..8ba73e6 100644 --- a/website/docs/index.md +++ b/website/docs/index.md @@ -25,10 +25,14 @@ Strand is open source under AGPL-3.0, free for individuals forever, with an hono | Platform | Installer | | --- | --- | | macOS | Universal `.dmg` (Apple Silicon + Intel), signed and notarized | -| Windows | `.msi` | +| Windows | `.msi`; `.msix` through Microsoft Store | | Linux | `.deb`, `.rpm`, `.AppImage` | -Installers are small — roughly 15 MB for the Windows MSI and Linux `.deb`/`.rpm`, about 31 MB for the universal macOS DMG. The auto-updater covers the macOS app bundle, the Windows MSI, and the Linux AppImage; `.deb`/`.rpm` installs update through your package manager. +Installers are small — roughly 15 MB for the direct Windows MSI and Linux +`.deb`/`.rpm`, about 31 MB for the universal macOS DMG. The auto-updater covers +the macOS app bundle, direct Windows MSI installs, and the Linux AppImage; +Microsoft Store MSIX installs update through the Store, while `.deb`/`.rpm` +installs update through your package manager. ## Guide contents diff --git a/website/docs/manifest.json b/website/docs/manifest.json index f8eed35..df7df0e 100644 --- a/website/docs/manifest.json +++ b/website/docs/manifest.json @@ -10,6 +10,8 @@ { "file": "everyday-git", "title": "Everyday Git" }, { "file": "commits-and-history", "title": "Commits & History" }, { "file": "keyboard-and-palette", "title": "Keyboard & Command Palette" }, - { "file": "settings", "title": "Settings" } + { "file": "settings", "title": "Settings" }, + { "file": "privacy", "title": "Privacy Policy" }, + { "file": "content-guidelines", "title": "Content Guidelines" } ] } diff --git a/website/docs/privacy.md b/website/docs/privacy.md new file mode 100644 index 0000000..abc3683 --- /dev/null +++ b/website/docs/privacy.md @@ -0,0 +1,77 @@ +# Privacy Policy + +Effective 24 July 2026 + +Strand is a local-first desktop Git client operated by Daniel Schwarz. This +notice explains what Strand handles, what leaves your computer, and which +choices remain yours. + +## The short version + +Strand has no product telemetry, advertising, analytics SDK, Strand account, or +automatic crash upload. Repository data and settings stay on your device unless +you deliberately use a network action or share a report. + +## Data stored on your device + +Strand stores the information needed to restore and operate your workspace, +including recently opened repository paths, workspaces, tabs, view preferences, +application settings, provider profile metadata, and local crash logs. +Repository files and Git metadata remain in the repositories you open. + +Azure DevOps Server personal access tokens that Strand owns are stored in the +operating system's protected credential vault: Windows Credential Manager, +macOS Keychain, or Linux Secret Service. Strand does not write those tokens to +its settings database. Credentials owned by Git, GitHub CLI, Azure CLI, OpenAI +Codex CLI, or Claude Code remain under those tools' own storage and policies. + +Deleting Strand's application data removes Strand's saved settings and session +metadata. It does not delete your Git repositories or credentials owned by +other tools. + +## When data leaves your device + +Strand makes a network request only for a feature that needs one: + +- Git fetch, pull, push, and clone use system Git and the remote you selected. +- GitHub and Azure DevOps features contact those providers through your + configured command-line tools or the optional signed Azure DevOps Server + helper. +- Update checks contact Strand's signed stable release channel on GitHub + Releases. Installing the optional Azure DevOps Server helper also downloads + its signed release from GitHub. +- AI writing features run only when you ask for them. Strand sends the bounded + prompt and repository context to the OpenAI Codex CLI or Claude Code CLI you + selected, under your account with that provider. Strand does not retain or + receive a copy through a Strand service. +- Opening external links, including repository hosts and documentation, sends + the normal request to that site in your browser. + +Those services process data under their own privacy policies. Strand does not +sell personal information and does not operate a server that receives your +repository content. + +## Crash reports + +Crash logs are written locally and can include application diagnostics and +repository paths. Crash reporting is off by default. If you enable the offer or +choose **Report last crash…**, Strand opens a pre-filled GitHub issue in your +browser. You can inspect, edit, or abandon it before anything is submitted. +Strand never uploads a crash report automatically. + +## Website + +The Strand website does not set analytics or advertising cookies. Its hosting +provider may process ordinary request information such as IP address, browser +details, requested path, and timestamp to deliver and protect the site. + +## Children + +Strand is a developer tool and is not directed to children. It does not +knowingly collect children's personal information. + +## Changes and contact + +Material changes to this notice will be published on this page with a new +effective date. Questions or privacy requests can be opened at +[github.com/danielss-dev/strand/issues](https://github.com/danielss-dev/strand/issues). diff --git a/website/docs/settings.md b/website/docs/settings.md index 78719fe..8e3115c 100644 --- a/website/docs/settings.md +++ b/website/docs/settings.md @@ -169,14 +169,28 @@ outputs, or sensitive classifications. ## Updates -- **Version** — shows the current version with a state-dependent action: **Check for updates**, **Download & install** (with a progress bar), or **Restart now**. Release notes are shown when available. -- **Automatic updates** — "Check for updates on launch" (on by default) and "Download and install automatically" (off by default). Updates always apply on the next restart; Strand never restarts itself. - -The in-app updater covers the macOS app, the Windows MSI install, and the Linux AppImage; `.deb` and `.rpm` installs are not covered — update them by downloading the new release from GitHub Releases. +- **Version** — direct installations show the current version with a + state-dependent action: **Check for updates**, **Download & install** (with a + progress bar), or **Restart now**. Release notes are shown when available. +- **Automatic updates** — direct installations provide "Check for updates on + launch" (on by default) and "Download and install automatically" (off by + default). Updates always apply on the next restart; Strand never restarts + itself. + +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. ## Privacy -Strand has no telemetry. The only reporting mechanism is crash reports, and those are opt-in and user-mediated: +Strand has no telemetry. Reports are user-mediated and open a pre-filled GitHub +issue in your browser for review before anything is submitted: + +- **Report inappropriate content…** — report inappropriate pull-request, + user-generated, or AI-generated content. This action is also available from + the command palette. - **Offer to report crashes on launch** — off by default. When enabled and Strand crashed last time, a toast offers to report it; reporting opens a pre-filled GitHub issue in your browser, which you review and submit yourself. Nothing is ever uploaded automatically. - **Report last crash…** — manually start that flow for the most recent crash (disabled when the crash log is empty). diff --git a/website/index.html b/website/index.html index 77616d0..a2481a1 100644 --- a/website/index.html +++ b/website/index.html @@ -652,6 +652,8 @@

Project

GitHub Issues Licensing + Privacy + Content guidelines