From 038d966d43a6842e85b17dfa074712c7f167ad5b Mon Sep 17 00:00:00 2001 From: u8array Date: Fri, 2 Oct 2026 18:07:37 +0200 Subject: [PATCH] build(store): Microsoft Store MSIX package --- .github/workflows/msix.yml | 116 ++++++++++++++++++ .github/workflows/release.yml | 4 + PRIVACY.md | 28 +++++ README.md | 2 +- package.json | 1 + scripts/build-msix.mjs | 101 +++++++++++++++ scripts/msix.d.mts | 6 + scripts/msix.mjs | 30 +++++ src-tauri/Cargo.toml | 4 +- src-tauri/packaging/windows/AppxManifest.xml | 70 +++++++++++ src-tauri/src/credentials.rs | 54 ++++++-- src-tauri/src/db.rs | 9 +- src-tauri/src/lib.rs | 16 ++- src-tauri/src/package.rs | 18 +++ src-tauri/src/updates.rs | 12 ++ src-tauri/tauri.msix.conf.json | 10 ++ .../PrinterSettings/AppSettingsTab.test.tsx | 31 +++++ .../PrinterSettings/AppSettingsTab.tsx | 11 +- src/lib/appUpdate.ts | 6 + src/lib/msixScripts.test.ts | 63 ++++++++++ src/locales/ar.ts | 1 + src/locales/bg.ts | 1 + src/locales/cs.ts | 1 + src/locales/da.ts | 1 + src/locales/de.ts | 1 + src/locales/el.ts | 1 + src/locales/en.ts | 1 + src/locales/es.ts | 1 + src/locales/et.ts | 1 + src/locales/fa.ts | 1 + src/locales/fi.ts | 1 + src/locales/fr.ts | 1 + src/locales/he.ts | 1 + src/locales/hr.ts | 1 + src/locales/hu.ts | 1 + src/locales/it.ts | 1 + src/locales/ja.ts | 1 + src/locales/ko.ts | 1 + src/locales/lt.ts | 1 + src/locales/lv.ts | 1 + src/locales/nl.ts | 1 + src/locales/no.ts | 1 + src/locales/pl.ts | 1 + src/locales/pt.ts | 1 + src/locales/ro.ts | 1 + src/locales/sk.ts | 1 + src/locales/sl.ts | 1 + src/locales/sr.ts | 1 + src/locales/sv.ts | 1 + src/locales/tr.ts | 1 + src/locales/zh-hans.ts | 1 + src/locales/zh-hant.ts | 1 + src/store/labelStore.selectors.ts | 7 ++ src/store/labelStore.ts | 2 + src/store/slices/appUpdateSlice.test.ts | 73 +++++++++++ src/store/slices/appUpdateSlice.ts | 36 +++--- src/store/slices/feedbackSlice.test.ts | 30 ++--- src/test/sliceHarness.ts | 8 ++ tsconfig.test.json | 1 + 59 files changed, 716 insertions(+), 65 deletions(-) create mode 100644 .github/workflows/msix.yml create mode 100644 PRIVACY.md create mode 100644 scripts/build-msix.mjs create mode 100644 scripts/msix.d.mts create mode 100644 scripts/msix.mjs create mode 100644 src-tauri/packaging/windows/AppxManifest.xml create mode 100644 src-tauri/src/package.rs create mode 100644 src-tauri/src/updates.rs create mode 100644 src-tauri/tauri.msix.conf.json create mode 100644 src/components/PrinterSettings/AppSettingsTab.test.tsx create mode 100644 src/lib/appUpdate.ts create mode 100644 src/lib/msixScripts.test.ts create mode 100644 src/store/slices/appUpdateSlice.test.ts create mode 100644 src/test/sliceHarness.ts diff --git a/.github/workflows/msix.yml b/.github/workflows/msix.yml new file mode 100644 index 00000000..1925f399 --- /dev/null +++ b/.github/workflows/msix.yml @@ -0,0 +1,116 @@ +name: MSIX (Microsoft Store) + +# The package for Partner Center comes from release.yml, behind its gates. +# Pull requests run this only to keep the packaging buildable. +on: + workflow_call: + pull_request: + branches: [main] + paths: + - 'src-tauri/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'scripts/build-msix.mjs' + - 'scripts/msix.mjs' + - '.github/workflows/msix.yml' + +concurrency: + group: msix-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + msix: + runs-on: windows-latest + steps: + - uses: actions/checkout@v7 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: pnpm + + - uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-pc-windows-msvc + + - uses: swatinem/rust-cache@v2 + with: + workspaces: './src-tauri -> target' + + - run: pnpm install --frozen-lockfile + + # LTO buys nothing on a pull request but time. + - name: fast release profile for pull requests + if: github.event_name == 'pull_request' + shell: pwsh + run: | + "CARGO_PROFILE_RELEASE_LTO=off" >> $env:GITHUB_ENV + "CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16" >> $env:GITHUB_ENV + + - run: pnpm msix:build + + # Launched through its package identity, as a Store install runs. + - name: smoke-launch the packaged app + # Windows PowerShell, since the UI Automation assemblies are .NET Framework only. + shell: powershell + run: | + # Registering an unsigned layout needs developer mode. + reg add 'HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock' /t REG_DWORD /f /v AllowDevelopmentWithoutDevLicense /d 1 + Add-AppxPackage -Register "$env:MSIX_DIR\layout\AppxManifest.xml" + # The manifest names the package and the app, so the launch cannot drift from it. + $manifest = [xml](Get-Content "$env:MSIX_DIR\layout\AppxManifest.xml") + $pkg = Get-AppxPackage $manifest.Package.Identity.Name + Start-Process "shell:AppsFolder\$($pkg.PackageFamilyName)!$($manifest.Package.Applications.Application.Id)" + + $deadline = (Get-Date).AddSeconds(90) + do { + if ((Get-Date) -gt $deadline) { throw 'ZPLab did not start' } + Start-Sleep -Seconds 2 + $proc = Get-Process ZPLab -ErrorAction Ignore | Select-Object -First 1 + } until ($proc) + # The handle must be open before the process exits for ExitCode to stay readable. + $null = $proc.Handle + # The render gets its own time, so a slow launch is not blamed on the frontend. + $deadline = (Get-Date).AddSeconds(90) + + Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes + $ae = [System.Windows.Automation.AutomationElement] + $scope = [System.Windows.Automation.TreeScope] + $mainWindow = [System.Windows.Automation.AndCondition]::new([System.Windows.Automation.Condition[]]@( + [System.Windows.Automation.PropertyCondition]::new($ae::ProcessIdProperty, $proc.Id), + [System.Windows.Automation.PropertyCondition]::new($ae::ControlTypeProperty, [System.Windows.Automation.ControlType]::Window), + [System.Windows.Automation.PropertyCondition]::new($ae::NameProperty, 'ZPLab'))) + # en.ts palette.searchPlaceholder. The runner's UI language is English. + $search = [System.Windows.Automation.PropertyCondition]::new($ae::NameProperty, "Search objects$([char]0x2026)") + $lastError = $null + do { + if ($proc.HasExited) { throw "ZPLab exited with code $($proc.ExitCode)" } + if ((Get-Date) -gt $deadline) { throw "no palette search field, the frontend did not render. Last UIA error: $lastError" } + Start-Sleep -Seconds 2 + # The tree is rebuilt while WebView2 mounts. + try { + $window = $ae::RootElement.FindFirst($scope::Children, $mainWindow) + $rendered = $window -and $window.FindFirst($scope::Descendants, $search) + } catch { + $lastError = $_.Exception.Message + $rendered = $false + } + } until ($rendered) + + # The Store variant must not share the GitHub build's WebView2 profile. + $identifier = (Get-Content src-tauri/tauri.msix.conf.json -Raw | ConvertFrom-Json).identifier + $webviewData = "$env:LOCALAPPDATA\Packages\$($pkg.PackageFamilyName)\LocalCache\Local\$identifier\EBWebView" + if (-not (Test-Path $webviewData)) { throw "WebView2 profile missing at $webviewData" } + Stop-Process -Id $proc.Id + + # A failed smoke test is when the package is wanted for inspection. + - uses: actions/upload-artifact@v7 + if: always() + with: + name: zplab-msix + path: ${{ env.MSIX_DIR }}/*.msix + if-no-files-found: error + # An MSIX is already a zip. + compression-level: 0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 48c955fe..ddc703a7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -172,6 +172,10 @@ jobs: echo "$BIN requires ${MAX:?no glibc symbols found}, floor $FLOOR" [ "$(printf '%s\n%s\n' "$MAX" "$FLOOR" | sort -V | tail -1)" = "$FLOOR" ] || { echo "built against a too-new glibc"; exit 1; } + msix: + needs: gates + uses: ./.github/workflows/msix.yml + upload-web-dist: needs: [gates, create-release] runs-on: ubuntu-latest diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 00000000..bc7f88b0 --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,28 @@ +# Privacy Policy + +ZPLab is a label designer that runs on your device. It has no user accounts, no analytics, no telemetry and no ads. The app sends no data to the developer. + +## Data stored on your device + +- Label designs, templates and settings are stored locally on your device, either in ZPLab's application data or in files you save yourself. +- Secrets such as a Labelary API key or database passwords are kept in the operating system's credential store: Windows Credential Manager, macOS Keychain or Secret Service on Linux. In the browser version, a Labelary key is kept in that browser's storage. If the credential store is unavailable, the desktop app keeps the MCP server token unencrypted in its application data instead. + +## Network connections + +ZPLab only connects to the following destinations: + +- **Printers and data sources you configure.** Labels go to the printer you choose, over the network or USB, or through the system print spooler or the Zebra Browser Print agent on your computer. Variable data is read from the files, databases or servers you connect. +- **Labelary rendering.** Labelary is the default renderer for previews, **Print as Image** and PDF export. ZPLab asks for your consent before the first request to the public service. After that, each of these actions sends the label's ZPL to `api.labelary.com`, including values filled in from connected data sources. Labelary is an independent third-party service with its own [terms and privacy practices](https://labelary.com/service.html). If you set your own endpoint, requests go there instead. You can withdraw your consent or disable Labelary entirely under **File → Settings… → App → Preview**. In the desktop app, a connected printer can render instead. +- **Updates.** A build downloaded from GitHub checks `github.com` for a new version at every start and downloads an update from GitHub's release hosts only when you choose to install it. The Store version and other packaged installs update through their package and skip this check. +- **MCP server, optional.** When you start it, the server listens on `127.0.0.1` only and requires a token that the app generates. An assistant you connect can read and edit the open label, so its content goes to whatever service that assistant uses. +- **The web version.** Your browser loads `app.zplab.org` from GitHub Pages, which is covered by [GitHub's privacy statement](https://docs.github.com/site-policy/privacy-policies/github-general-privacy-statement). + +## App stores + +If you install ZPLab from an app store, the store operator may collect installation, crash or usage data under its own privacy policy. The developer may see aggregated statistics that the store provides. + +## Contact + +For questions about this policy, open an issue at . + +Last updated: 2026-10-02 diff --git a/README.md b/README.md index d915b52a..dbdb7f4a 100644 --- a/README.md +++ b/README.md @@ -250,4 +250,4 @@ Issues and pull requests are welcome. If a ZPL file imports incorrectly, attach ## License -MIT, see [LICENSE](LICENSE). +MIT, see [LICENSE](LICENSE). The app sends no data to the developer, see [PRIVACY.md](PRIVACY.md). diff --git a/package.json b/package.json index 58449cc4..ac777303 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "licenses:check": "node scripts/check-licenses.mjs", "attribution:gen": "node scripts/gen-attribution.mjs", "attribution:check": "node scripts/gen-attribution.mjs --check", + "msix:build": "node scripts/build-msix.mjs", "coverage:gen": "node scripts/gen-coverage.mjs", "coverage:check": "node scripts/gen-coverage.mjs --check", "preview": "vite preview", diff --git a/scripts/build-msix.mjs b/scripts/build-msix.mjs new file mode 100644 index 00000000..134001a1 --- /dev/null +++ b/scripts/build-msix.mjs @@ -0,0 +1,101 @@ +// The MSIX ships unsigned. The Store signs it on ingestion. +import { execFileSync } from 'node:child_process'; +import { appendFileSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { basename, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { manifestExecutable, packageVersion, readManifest, stampVersion, storeVersion } from './msix.mjs'; + +const ROOT = fileURLToPath(new URL('..', import.meta.url)); +const TAURI = join(ROOT, 'src-tauri'); +const TAURI_CLI = createRequire(import.meta.url).resolve('@tauri-apps/cli/tauri.js'); +// Pinned so an ARM64 host cannot pack its own binaries into the x64 package. +const TARGET = 'x86_64-pc-windows-msvc'; +const UNPLATED_SIZES = [16, 24, 32, 48, 256]; + +const run = (file, args) => execFileSync(file, args, { cwd: ROOT, stdio: 'inherit' }); + +/** Where the SDK installer put the Windows Kits, since its install path is choosable. */ +function kitsRoot() { + let out = ''; + try { + out = execFileSync( + 'reg', + ['query', 'HKLM\\SOFTWARE\\Microsoft\\Windows Kits\\Installed Roots', '/v', 'KitsRoot10', '/reg:32'], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }, + ); + } catch { + // reg exits non-zero when the key is absent. + } + const root = /KitsRoot10\s+REG_SZ\s+(.+)/.exec(out)?.[1]?.trim(); + if (!root) throw new Error('KitsRoot10 not found in the registry, install the Windows SDK'); + return root; +} + +function sdkTools() { + const bin = join(kitsRoot(), 'bin'); + const versions = existsSync(bin) ? readdirSync(bin).filter((v) => /^\d+\.\d+\.\d+\.\d+$/.test(v)) : []; + versions.sort((a, b) => b.localeCompare(a, undefined, { numeric: true })); + for (const v of versions) { + const tools = { makeappx: join(bin, v, 'x64', 'makeappx.exe'), makepri: join(bin, v, 'x64', 'makepri.exe') }; + if (existsSync(tools.makeappx) && existsSync(tools.makepri)) return tools; + } + throw new Error(`makeappx.exe/makepri.exe not found under ${bin}, install the Windows SDK`); +} + +// Read from the config, so a new sidecar ships in the MSIX as in every Tauri bundle. +function shippedExecutables() { + const readConf = (file) => JSON.parse(readFileSync(join(TAURI, file), 'utf8')); + const conf = readConf('tauri.conf.json'); + const overlay = readConf('tauri.msix.conf.json'); + if (overlay.mainBinaryName || overlay.bundle?.externalBin || overlay.bundle?.resources || conf.bundle.resources) { + throw new Error('build-msix packs only mainBinaryName and externalBin from tauri.conf.json'); + } + return [conf.mainBinaryName, ...(conf.bundle.externalBin ?? []).map((bin) => basename(bin))].map((name) => `${name}.exe`); +} + +if (process.platform !== 'win32') throw new Error('build-msix runs on Windows only'); + +// Validated before the release compile, so a broken input fails in seconds. +const { makeappx, makepri } = sdkTools(); +const version = storeVersion(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version); +const manifest = stampVersion(readManifest(), packageVersion(version)); +const executables = shippedExecutables(); +if (manifestExecutable(manifest) !== executables[0]) throw new Error(`AppxManifest must launch ${executables[0]}`); +// cargo, not a fixed path: CARGO_TARGET_DIR or a config target-dir moves it. +const targetDir = JSON.parse( + execFileSync('cargo', ['metadata', '--format-version', '1', '--no-deps'], { cwd: TAURI, encoding: 'utf8' }), +).target_directory; +const out = join(targetDir, 'msix'); +const layout = join(out, 'layout'); +const assets = join(layout, 'Assets'); + +rmSync(out, { recursive: true, force: true }); +mkdirSync(assets, { recursive: true }); +writeFileSync(join(layout, 'AppxManifest.xml'), manifest); +for (const icon of ['StoreLogo.png', 'Square44x44Logo.png', 'Square150x150Logo.png']) { + copyFileSync(join(TAURI, 'icons', icon), join(assets, icon)); +} +// Taskbar and Start look up these variants through resources.pri. Without +// them Windows draws the plated, upscaled 44px tile. +const sized = join(out, 'sized'); +run(process.execPath, [TAURI_CLI, 'icon', join(TAURI, 'icons', 'icon.png'), '--png', UNPLATED_SIZES.join(','), '-o', sized]); +for (const size of UNPLATED_SIZES) { + copyFileSync(join(sized, `${size}x${size}.png`), join(assets, `Square44x44Logo.targetsize-${size}_altform-unplated.png`)); +} +const priconfig = join(out, 'priconfig.xml'); +run(makepri, ['createconfig', '/cf', priconfig, '/dq', 'en-US_scale-100', '/o']); + +// The overlay turns the Tauri bundler off: makeappx packages this variant. +// The binary reports the same version the Store lists. +run(process.execPath, [TAURI_CLI, 'build', '--target', TARGET, '--config', join(TAURI, 'tauri.msix.conf.json'), '--config', JSON.stringify({ version })]); + +const release = join(targetDir, TARGET, 'release'); +for (const exe of executables) copyFileSync(join(release, exe), join(layout, exe)); +run(makepri, ['new', '/pr', layout, '/cf', priconfig, '/mn', join(layout, 'AppxManifest.xml'), '/of', join(layout, 'resources.pri'), '/o']); + +const msix = join(out, `ZPLab_${version}_x64.msix`); +run(makeappx, ['pack', '/d', layout, '/p', msix, '/o']); +console.log(`Packed ${msix}`); +// Only this script knows the dir the workflow installs and uploads from. +if (process.env.GITHUB_ENV) appendFileSync(process.env.GITHUB_ENV, `MSIX_DIR=${out}\n`); diff --git a/scripts/msix.d.mts b/scripts/msix.d.mts new file mode 100644 index 00000000..0f439ff7 --- /dev/null +++ b/scripts/msix.d.mts @@ -0,0 +1,6 @@ +// Types for the node-only MSIX helpers, so the parity test can import them under the app tsconfig. +export declare function readManifest(): string; +export declare function storeVersion(version: string): string; +export declare function packageVersion(store: string): string; +export declare function stampVersion(manifest: string, version: string): string; +export declare function manifestExecutable(manifest: string): string | undefined; diff --git a/scripts/msix.mjs b/scripts/msix.mjs new file mode 100644 index 00000000..368fdd43 --- /dev/null +++ b/scripts/msix.mjs @@ -0,0 +1,30 @@ +// Split out of build-msix.mjs so msixScripts.test.ts can check these without a Windows build. +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = fileURLToPath(new URL('..', import.meta.url)); +const MANIFEST = join(ROOT, 'src-tauri', 'packaging', 'windows', 'AppxManifest.xml'); + +export const readManifest = () => readFileSync(MANIFEST, 'utf8'); + +/** The Store refuses a leading 0 and orders by version, so the major stays shifted by one past 1.0. */ +export function storeVersion(version) { + const parts = /^(\d+)\.(\d+)\.(\d+)$/.exec(version)?.slice(1).map(Number); + if (!parts || parts[0] > 65534 || parts.some((part) => part > 65535)) throw new Error(`MSIX cannot express version '${version}'`); + const [major, minor, patch] = parts; + return `${major + 1}.${minor}.${patch}`; +} + +/** The Store reserves the fourth part. */ +export const packageVersion = (store) => `${store}.0`; + +const IDENTITY_VERSION = /(]*?\bVersion=")[^"]*(")/g; + +export function stampVersion(manifest, version) { + const found = manifest.match(IDENTITY_VERSION)?.length ?? 0; + if (found !== 1) throw new Error(`expected one Identity Version in the manifest, found ${found}`); + return manifest.replace(IDENTITY_VERSION, (_, before, after) => `${before}${version}${after}`); +} + +export const manifestExecutable = (manifest) => /]*\bExecutable="([^"]+)"/.exec(manifest)?.[1]; diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5c84b721..5eb65199 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -60,9 +60,11 @@ libc = "0.2" [target.'cfg(windows)'.dependencies] # Kill-on-close Job Object so the MCP child's cmd/pnpm/node tree dies with the -# app, including on crash (the OS closes the handle on process exit). +# app, including on crash. The OS closes the handle on process exit. +# Win32_Storage_Packaging_Appx reads the package identity that marks a packaged install. windows-sys = { version = "0.61", features = [ "Win32_Foundation", + "Win32_Storage_Packaging_Appx", "Win32_System_JobObjects", ] } diff --git a/src-tauri/packaging/windows/AppxManifest.xml b/src-tauri/packaging/windows/AppxManifest.xml new file mode 100644 index 00000000..d2cc2f3a --- /dev/null +++ b/src-tauri/packaging/windows/AppxManifest.xml @@ -0,0 +1,70 @@ + + + + + + ZPLab + u8array + Assets\StoreLogo.png + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src-tauri/src/credentials.rs b/src-tauri/src/credentials.rs index c84bd645..dbd7d623 100644 --- a/src-tauri/src/credentials.rs +++ b/src-tauri/src/credentials.rs @@ -2,13 +2,34 @@ //! Linux Secret Service). Keeps API keys out of localStorage/app-data JSON, //! which live as plaintext on disk. +use std::sync::OnceLock; + use keyring::Entry; use crate::transport::blocking; -/// Keychain service name; the credential name (e.g. "labelary-api-key") is -/// the account under it. -const SERVICE: &str = "ZPLab"; +/// The GitHub build's service name. Renaming it would orphan every key its users already stored. +const GITHUB_SERVICE: &str = "ZPLab"; +const GITHUB_IDENTIFIER: &str = "de.u8array.zplab"; + +static SERVICE: OnceLock = OnceLock::new(); + +/// Keyed by app identifier, since Credential Manager is per user and the variants would collide. +pub(crate) fn init_service(identifier: &str) { + let _ = SERVICE.set(service_for(identifier)); +} + +fn service_for(identifier: &str) -> String { + if identifier == GITHUB_IDENTIFIER { + GITHUB_SERVICE.to_string() + } else { + format!("{GITHUB_SERVICE} {identifier}") + } +} + +fn service() -> &'static str { + SERVICE.get().map_or(GITHUB_SERVICE, String::as_str) +} /// Typed credential error; the db connector consumes it via `#[from]`, the IPC /// commands stringify it at the edge. @@ -23,7 +44,7 @@ pub(crate) enum CredError { } fn entry(name: &str) -> Result { - Ok(Entry::new(SERVICE, name)?) + Ok(Entry::new(service(), name)?) } /// Rust-internal read (db connector), unlike the IPC `credential_get` @@ -95,21 +116,28 @@ pub async fn credential_set(name: String, value: String) -> Result<(), String> { #[tauri::command] pub async fn credential_delete(name: String) -> Result<(), String> { - blocking(move || -> Result<(), CredError> { - match entry(&name)?.delete_credential() { - // Deleting a missing entry is the caller's desired end state, not an error. - Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), - Err(e) => Err(e.into()), - } - }) - .await? - .map_err(|e| e.to_string()) + blocking(move || delete_password(&name)) + .await? + .map_err(|e| e.to_string()) } #[cfg(test)] mod tests { use super::*; + #[test] + fn github_build_keeps_its_service_and_the_store_variant_gets_its_own() { + let base: serde_json::Value = serde_json::from_str(include_str!("../tauri.conf.json")).unwrap(); + let msix: serde_json::Value = + serde_json::from_str(include_str!("../tauri.msix.conf.json")).unwrap(); + assert_eq!(base["identifier"], GITHUB_IDENTIFIER); + assert_eq!(service_for(GITHUB_IDENTIFIER), "ZPLab"); + assert_eq!( + service_for(msix["identifier"].as_str().unwrap()), + "ZPLab de.u8array.zplab.msix" + ); + } + #[test] fn db_profile_credentials_are_not_readable_over_ipc() { let rt = tokio::runtime::Builder::new_current_thread() diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index b0b912d0..61c27987 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -827,16 +827,11 @@ mod tests { "{}\n{value}", endpoint_id("127.0.0.1", port, SslMode::Prefer, user, "zpltest") ); - keyring::Entry::new("ZPLab", &password_cred(profile_id)) - .unwrap() - .set_password(&blob) - .unwrap(); + crate::credentials::write_password(&password_cred(profile_id), &blob).unwrap(); } fn keychain_drop(profile_id: &str) { - let _ = keyring::Entry::new("ZPLab", &password_cred(profile_id)) - .unwrap() - .delete_credential(); + let _ = crate::credentials::delete_password(&password_cred(profile_id)); } fn assert_live_rows(spec: &DbSpec) { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 83477de9..a18d5295 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,10 +3,12 @@ mod dataset; mod db; mod excel; mod mcp; +mod package; mod preview; mod print; mod scope; mod transport; +mod updates; mod usb; use tauri::Manager; @@ -48,20 +50,28 @@ pub fn run() { mcp::mcp_stop, mcp::mcp_status, mcp::mcp_listeners_ready, - mcp::mcp_reply + mcp::mcp_reply, + updates::app_update_supported ]); // On the builder, not in setup(): config windows exist before the setup // closure runs, and window-state only restores/tracks via on_window_ready. #[cfg(desktop)] let builder = builder .plugin(tauri_plugin_window_state::Builder::default().build()) - .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()); + let context = tauri::generate_context!(); + credentials::init_service(&context.config().identifier); + #[cfg(desktop)] + let builder = if updates::self_update_enabled(context.config()) { + builder.plugin(tauri_plugin_updater::Builder::new().build()) + } else { + builder + }; builder - .build(tauri::generate_context!()) + .build(context) .expect("error while building tauri application") .run(|app, event| { // Kill the MCP child on exit so it never outlives the app window. diff --git a/src-tauri/src/package.rs b/src-tauri/src/package.rs new file mode 100644 index 00000000..a28af31d --- /dev/null +++ b/src-tauri/src/package.rs @@ -0,0 +1,18 @@ +#[cfg(windows)] +pub fn is_packaged() -> bool { + use windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER; + use windows_sys::Win32::Storage::Packaging::Appx::GetCurrentPackageFamilyName; + + let mut len = 0u32; + // SAFETY: a zero length with a null buffer only queries the required size. + // Packaged processes get ERROR_INSUFFICIENT_BUFFER, others + // APPMODEL_ERROR_NO_PACKAGE. + unsafe { + GetCurrentPackageFamilyName(&mut len, std::ptr::null_mut()) == ERROR_INSUFFICIENT_BUFFER + } +} + +#[cfg(not(windows))] +pub fn is_packaged() -> bool { + false +} diff --git a/src-tauri/src/updates.rs b/src-tauri/src/updates.rs new file mode 100644 index 00000000..45a4ed50 --- /dev/null +++ b/src-tauri/src/updates.rs @@ -0,0 +1,12 @@ +use tauri::{AppHandle, Config}; + +/// Tauri's Windows updater runs the NSIS setup, which cannot replace a packaged install. +/// The Store variant also ships without plugins.updater. +pub fn self_update_enabled(config: &Config) -> bool { + config.plugins.0.contains_key("updater") && !crate::package::is_packaged() +} + +#[tauri::command] +pub fn app_update_supported(app: AppHandle) -> bool { + self_update_enabled(app.config()) +} diff --git a/src-tauri/tauri.msix.conf.json b/src-tauri/tauri.msix.conf.json new file mode 100644 index 00000000..86806cd6 --- /dev/null +++ b/src-tauri/tauri.msix.conf.json @@ -0,0 +1,10 @@ +{ + "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", + "identifier": "de.u8array.zplab.msix", + "bundle": { + "active": false + }, + "plugins": { + "updater": null + } +} diff --git a/src/components/PrinterSettings/AppSettingsTab.test.tsx b/src/components/PrinterSettings/AppSettingsTab.test.tsx new file mode 100644 index 00000000..d5387907 --- /dev/null +++ b/src/components/PrinterSettings/AppSettingsTab.test.tsx @@ -0,0 +1,31 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach } from "vitest"; +import { render, cleanup, act } from "@testing-library/react"; +import { AppSettingsTab } from "./AppSettingsTab"; +import { useLabelStore } from "../../store/labelStore"; +import { fallbackTranslations as en } from "../../locales"; + +afterEach(() => { + cleanup(); + act(() => { + useLabelStore.setState({ appUpdate: { phase: "idle" } }); + }); +}); + +const loc = en.printerSettings.app; + +describe("AppSettingsTab updates", () => { + it("points a packaged install at the Store and hides the check button", () => { + act(() => { + useLabelStore.setState({ appUpdate: { phase: "unsupported" } }); + }); + const { getByText, queryByText } = render(); + expect(getByText(loc.updatesViaStore)).toBeTruthy(); + expect(queryByText(loc.checkUpdates)).toBeNull(); + }); + + it("says nothing about the Store when the app updates itself", () => { + const { queryByText } = render(); + expect(queryByText(loc.updatesViaStore)).toBeNull(); + }); +}); diff --git a/src/components/PrinterSettings/AppSettingsTab.tsx b/src/components/PrinterSettings/AppSettingsTab.tsx index 81a8560e..8550de89 100644 --- a/src/components/PrinterSettings/AppSettingsTab.tsx +++ b/src/components/PrinterSettings/AppSettingsTab.tsx @@ -1,6 +1,6 @@ import { useId } from "react"; import { useT } from "../../hooks/useT"; -import { useLabelStore } from "../../store/labelStore"; +import { useLabelStore, selectAppUpdateBusy, selectAppUpdateSettled } from "../../store/labelStore"; import { isDesktopShell } from "../../lib/platform"; import { formatTemplate } from "../../lib/formatTemplate"; import { labelCls } from "../ui/formStyles"; @@ -46,6 +46,8 @@ export function AppSettingsTab() { const setCanvasSettings = useLabelStore((s) => s.setCanvasSettings); const resetSettings = useLabelStore((s) => s.resetSettings); const appUpdate = useLabelStore((s) => s.appUpdate); + const updateBusy = useLabelStore(selectAppUpdateBusy); + const updateSettled = useLabelStore(selectAppUpdateSettled); const checkForAppUpdate = useLabelStore((s) => s.checkForAppUpdate); const installAppUpdate = useLabelStore((s) => s.installAppUpdate); const relaunchApp = useLabelStore((s) => s.relaunchApp); @@ -134,10 +136,10 @@ export function AppSettingsTab() { {t.app.updateRestart} )} - {isDesktopShell && appUpdate.phase !== "available" && appUpdate.phase !== "installed" && ( + {isDesktopShell && appUpdate.phase !== "available" && !updateSettled && (