Skip to content

Commit 038d966

Browse files
committed
build(store): Microsoft Store MSIX package
1 parent 3c605dd commit 038d966

59 files changed

Lines changed: 716 additions & 65 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/msix.yml‎

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
name: MSIX (Microsoft Store)
2+
3+
# The package for Partner Center comes from release.yml, behind its gates.
4+
# Pull requests run this only to keep the packaging buildable.
5+
on:
6+
workflow_call:
7+
pull_request:
8+
branches: [main]
9+
paths:
10+
- 'src-tauri/**'
11+
- 'package.json'
12+
- 'pnpm-lock.yaml'
13+
- 'scripts/build-msix.mjs'
14+
- 'scripts/msix.mjs'
15+
- '.github/workflows/msix.yml'
16+
17+
concurrency:
18+
group: msix-${{ github.ref }}
19+
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
20+
21+
jobs:
22+
msix:
23+
runs-on: windows-latest
24+
steps:
25+
- uses: actions/checkout@v7
26+
27+
- uses: pnpm/action-setup@v6
28+
29+
- uses: actions/setup-node@v7
30+
with:
31+
node-version: 24
32+
cache: pnpm
33+
34+
- uses: dtolnay/rust-toolchain@stable
35+
with:
36+
targets: x86_64-pc-windows-msvc
37+
38+
- uses: swatinem/rust-cache@v2
39+
with:
40+
workspaces: './src-tauri -> target'
41+
42+
- run: pnpm install --frozen-lockfile
43+
44+
# LTO buys nothing on a pull request but time.
45+
- name: fast release profile for pull requests
46+
if: github.event_name == 'pull_request'
47+
shell: pwsh
48+
run: |
49+
"CARGO_PROFILE_RELEASE_LTO=off" >> $env:GITHUB_ENV
50+
"CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16" >> $env:GITHUB_ENV
51+
52+
- run: pnpm msix:build
53+
54+
# Launched through its package identity, as a Store install runs.
55+
- name: smoke-launch the packaged app
56+
# Windows PowerShell, since the UI Automation assemblies are .NET Framework only.
57+
shell: powershell
58+
run: |
59+
# Registering an unsigned layout needs developer mode.
60+
reg add 'HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock' /t REG_DWORD /f /v AllowDevelopmentWithoutDevLicense /d 1
61+
Add-AppxPackage -Register "$env:MSIX_DIR\layout\AppxManifest.xml"
62+
# The manifest names the package and the app, so the launch cannot drift from it.
63+
$manifest = [xml](Get-Content "$env:MSIX_DIR\layout\AppxManifest.xml")
64+
$pkg = Get-AppxPackage $manifest.Package.Identity.Name
65+
Start-Process "shell:AppsFolder\$($pkg.PackageFamilyName)!$($manifest.Package.Applications.Application.Id)"
66+
67+
$deadline = (Get-Date).AddSeconds(90)
68+
do {
69+
if ((Get-Date) -gt $deadline) { throw 'ZPLab did not start' }
70+
Start-Sleep -Seconds 2
71+
$proc = Get-Process ZPLab -ErrorAction Ignore | Select-Object -First 1
72+
} until ($proc)
73+
# The handle must be open before the process exits for ExitCode to stay readable.
74+
$null = $proc.Handle
75+
# The render gets its own time, so a slow launch is not blamed on the frontend.
76+
$deadline = (Get-Date).AddSeconds(90)
77+
78+
Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes
79+
$ae = [System.Windows.Automation.AutomationElement]
80+
$scope = [System.Windows.Automation.TreeScope]
81+
$mainWindow = [System.Windows.Automation.AndCondition]::new([System.Windows.Automation.Condition[]]@(
82+
[System.Windows.Automation.PropertyCondition]::new($ae::ProcessIdProperty, $proc.Id),
83+
[System.Windows.Automation.PropertyCondition]::new($ae::ControlTypeProperty, [System.Windows.Automation.ControlType]::Window),
84+
[System.Windows.Automation.PropertyCondition]::new($ae::NameProperty, 'ZPLab')))
85+
# en.ts palette.searchPlaceholder. The runner's UI language is English.
86+
$search = [System.Windows.Automation.PropertyCondition]::new($ae::NameProperty, "Search objects$([char]0x2026)")
87+
$lastError = $null
88+
do {
89+
if ($proc.HasExited) { throw "ZPLab exited with code $($proc.ExitCode)" }
90+
if ((Get-Date) -gt $deadline) { throw "no palette search field, the frontend did not render. Last UIA error: $lastError" }
91+
Start-Sleep -Seconds 2
92+
# The tree is rebuilt while WebView2 mounts.
93+
try {
94+
$window = $ae::RootElement.FindFirst($scope::Children, $mainWindow)
95+
$rendered = $window -and $window.FindFirst($scope::Descendants, $search)
96+
} catch {
97+
$lastError = $_.Exception.Message
98+
$rendered = $false
99+
}
100+
} until ($rendered)
101+
102+
# The Store variant must not share the GitHub build's WebView2 profile.
103+
$identifier = (Get-Content src-tauri/tauri.msix.conf.json -Raw | ConvertFrom-Json).identifier
104+
$webviewData = "$env:LOCALAPPDATA\Packages\$($pkg.PackageFamilyName)\LocalCache\Local\$identifier\EBWebView"
105+
if (-not (Test-Path $webviewData)) { throw "WebView2 profile missing at $webviewData" }
106+
Stop-Process -Id $proc.Id
107+
108+
# A failed smoke test is when the package is wanted for inspection.
109+
- uses: actions/upload-artifact@v7
110+
if: always()
111+
with:
112+
name: zplab-msix
113+
path: ${{ env.MSIX_DIR }}/*.msix
114+
if-no-files-found: error
115+
# An MSIX is already a zip.
116+
compression-level: 0

‎.github/workflows/release.yml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,10 @@ jobs:
172172
echo "$BIN requires ${MAX:?no glibc symbols found}, floor $FLOOR"
173173
[ "$(printf '%s\n%s\n' "$MAX" "$FLOOR" | sort -V | tail -1)" = "$FLOOR" ] || { echo "built against a too-new glibc"; exit 1; }
174174
175+
msix:
176+
needs: gates
177+
uses: ./.github/workflows/msix.yml
178+
175179
upload-web-dist:
176180
needs: [gates, create-release]
177181
runs-on: ubuntu-latest

‎PRIVACY.md‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Privacy Policy
2+
3+
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.
4+
5+
## Data stored on your device
6+
7+
- Label designs, templates and settings are stored locally on your device, either in ZPLab's application data or in files you save yourself.
8+
- 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.
9+
10+
## Network connections
11+
12+
ZPLab only connects to the following destinations:
13+
14+
- **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.
15+
- **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.
16+
- **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.
17+
- **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.
18+
- **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).
19+
20+
## App stores
21+
22+
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.
23+
24+
## Contact
25+
26+
For questions about this policy, open an issue at <https://github.com/u8array/ZPLab/issues>.
27+
28+
Last updated: 2026-10-02

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,4 +250,4 @@ Issues and pull requests are welcome. If a ZPL file imports incorrectly, attach
250250

251251
## License
252252

253-
MIT, see [LICENSE](LICENSE).
253+
MIT, see [LICENSE](LICENSE). The app sends no data to the developer, see [PRIVACY.md](PRIVACY.md).

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"licenses:check": "node scripts/check-licenses.mjs",
1919
"attribution:gen": "node scripts/gen-attribution.mjs",
2020
"attribution:check": "node scripts/gen-attribution.mjs --check",
21+
"msix:build": "node scripts/build-msix.mjs",
2122
"coverage:gen": "node scripts/gen-coverage.mjs",
2223
"coverage:check": "node scripts/gen-coverage.mjs --check",
2324
"preview": "vite preview",

‎scripts/build-msix.mjs‎

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// The MSIX ships unsigned. The Store signs it on ingestion.
2+
import { execFileSync } from 'node:child_process';
3+
import { appendFileSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4+
import { createRequire } from 'node:module';
5+
import { basename, join } from 'node:path';
6+
import { fileURLToPath } from 'node:url';
7+
import { manifestExecutable, packageVersion, readManifest, stampVersion, storeVersion } from './msix.mjs';
8+
9+
const ROOT = fileURLToPath(new URL('..', import.meta.url));
10+
const TAURI = join(ROOT, 'src-tauri');
11+
const TAURI_CLI = createRequire(import.meta.url).resolve('@tauri-apps/cli/tauri.js');
12+
// Pinned so an ARM64 host cannot pack its own binaries into the x64 package.
13+
const TARGET = 'x86_64-pc-windows-msvc';
14+
const UNPLATED_SIZES = [16, 24, 32, 48, 256];
15+
16+
const run = (file, args) => execFileSync(file, args, { cwd: ROOT, stdio: 'inherit' });
17+
18+
/** Where the SDK installer put the Windows Kits, since its install path is choosable. */
19+
function kitsRoot() {
20+
let out = '';
21+
try {
22+
out = execFileSync(
23+
'reg',
24+
['query', 'HKLM\\SOFTWARE\\Microsoft\\Windows Kits\\Installed Roots', '/v', 'KitsRoot10', '/reg:32'],
25+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
26+
);
27+
} catch {
28+
// reg exits non-zero when the key is absent.
29+
}
30+
const root = /KitsRoot10\s+REG_SZ\s+(.+)/.exec(out)?.[1]?.trim();
31+
if (!root) throw new Error('KitsRoot10 not found in the registry, install the Windows SDK');
32+
return root;
33+
}
34+
35+
function sdkTools() {
36+
const bin = join(kitsRoot(), 'bin');
37+
const versions = existsSync(bin) ? readdirSync(bin).filter((v) => /^\d+\.\d+\.\d+\.\d+$/.test(v)) : [];
38+
versions.sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
39+
for (const v of versions) {
40+
const tools = { makeappx: join(bin, v, 'x64', 'makeappx.exe'), makepri: join(bin, v, 'x64', 'makepri.exe') };
41+
if (existsSync(tools.makeappx) && existsSync(tools.makepri)) return tools;
42+
}
43+
throw new Error(`makeappx.exe/makepri.exe not found under ${bin}, install the Windows SDK`);
44+
}
45+
46+
// Read from the config, so a new sidecar ships in the MSIX as in every Tauri bundle.
47+
function shippedExecutables() {
48+
const readConf = (file) => JSON.parse(readFileSync(join(TAURI, file), 'utf8'));
49+
const conf = readConf('tauri.conf.json');
50+
const overlay = readConf('tauri.msix.conf.json');
51+
if (overlay.mainBinaryName || overlay.bundle?.externalBin || overlay.bundle?.resources || conf.bundle.resources) {
52+
throw new Error('build-msix packs only mainBinaryName and externalBin from tauri.conf.json');
53+
}
54+
return [conf.mainBinaryName, ...(conf.bundle.externalBin ?? []).map((bin) => basename(bin))].map((name) => `${name}.exe`);
55+
}
56+
57+
if (process.platform !== 'win32') throw new Error('build-msix runs on Windows only');
58+
59+
// Validated before the release compile, so a broken input fails in seconds.
60+
const { makeappx, makepri } = sdkTools();
61+
const version = storeVersion(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version);
62+
const manifest = stampVersion(readManifest(), packageVersion(version));
63+
const executables = shippedExecutables();
64+
if (manifestExecutable(manifest) !== executables[0]) throw new Error(`AppxManifest must launch ${executables[0]}`);
65+
// cargo, not a fixed path: CARGO_TARGET_DIR or a config target-dir moves it.
66+
const targetDir = JSON.parse(
67+
execFileSync('cargo', ['metadata', '--format-version', '1', '--no-deps'], { cwd: TAURI, encoding: 'utf8' }),
68+
).target_directory;
69+
const out = join(targetDir, 'msix');
70+
const layout = join(out, 'layout');
71+
const assets = join(layout, 'Assets');
72+
73+
rmSync(out, { recursive: true, force: true });
74+
mkdirSync(assets, { recursive: true });
75+
writeFileSync(join(layout, 'AppxManifest.xml'), manifest);
76+
for (const icon of ['StoreLogo.png', 'Square44x44Logo.png', 'Square150x150Logo.png']) {
77+
copyFileSync(join(TAURI, 'icons', icon), join(assets, icon));
78+
}
79+
// Taskbar and Start look up these variants through resources.pri. Without
80+
// them Windows draws the plated, upscaled 44px tile.
81+
const sized = join(out, 'sized');
82+
run(process.execPath, [TAURI_CLI, 'icon', join(TAURI, 'icons', 'icon.png'), '--png', UNPLATED_SIZES.join(','), '-o', sized]);
83+
for (const size of UNPLATED_SIZES) {
84+
copyFileSync(join(sized, `${size}x${size}.png`), join(assets, `Square44x44Logo.targetsize-${size}_altform-unplated.png`));
85+
}
86+
const priconfig = join(out, 'priconfig.xml');
87+
run(makepri, ['createconfig', '/cf', priconfig, '/dq', 'en-US_scale-100', '/o']);
88+
89+
// The overlay turns the Tauri bundler off: makeappx packages this variant.
90+
// The binary reports the same version the Store lists.
91+
run(process.execPath, [TAURI_CLI, 'build', '--target', TARGET, '--config', join(TAURI, 'tauri.msix.conf.json'), '--config', JSON.stringify({ version })]);
92+
93+
const release = join(targetDir, TARGET, 'release');
94+
for (const exe of executables) copyFileSync(join(release, exe), join(layout, exe));
95+
run(makepri, ['new', '/pr', layout, '/cf', priconfig, '/mn', join(layout, 'AppxManifest.xml'), '/of', join(layout, 'resources.pri'), '/o']);
96+
97+
const msix = join(out, `ZPLab_${version}_x64.msix`);
98+
run(makeappx, ['pack', '/d', layout, '/p', msix, '/o']);
99+
console.log(`Packed ${msix}`);
100+
// Only this script knows the dir the workflow installs and uploads from.
101+
if (process.env.GITHUB_ENV) appendFileSync(process.env.GITHUB_ENV, `MSIX_DIR=${out}\n`);

‎scripts/msix.d.mts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// Types for the node-only MSIX helpers, so the parity test can import them under the app tsconfig.
2+
export declare function readManifest(): string;
3+
export declare function storeVersion(version: string): string;
4+
export declare function packageVersion(store: string): string;
5+
export declare function stampVersion(manifest: string, version: string): string;
6+
export declare function manifestExecutable(manifest: string): string | undefined;

‎scripts/msix.mjs‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Split out of build-msix.mjs so msixScripts.test.ts can check these without a Windows build.
2+
import { readFileSync } from 'node:fs';
3+
import { join } from 'node:path';
4+
import { fileURLToPath } from 'node:url';
5+
6+
const ROOT = fileURLToPath(new URL('..', import.meta.url));
7+
const MANIFEST = join(ROOT, 'src-tauri', 'packaging', 'windows', 'AppxManifest.xml');
8+
9+
export const readManifest = () => readFileSync(MANIFEST, 'utf8');
10+
11+
/** The Store refuses a leading 0 and orders by version, so the major stays shifted by one past 1.0. */
12+
export function storeVersion(version) {
13+
const parts = /^(\d+)\.(\d+)\.(\d+)$/.exec(version)?.slice(1).map(Number);
14+
if (!parts || parts[0] > 65534 || parts.some((part) => part > 65535)) throw new Error(`MSIX cannot express version '${version}'`);
15+
const [major, minor, patch] = parts;
16+
return `${major + 1}.${minor}.${patch}`;
17+
}
18+
19+
/** The Store reserves the fourth part. */
20+
export const packageVersion = (store) => `${store}.0`;
21+
22+
const IDENTITY_VERSION = /(<Identity\b[^>]*?\bVersion=")[^"]*(")/g;
23+
24+
export function stampVersion(manifest, version) {
25+
const found = manifest.match(IDENTITY_VERSION)?.length ?? 0;
26+
if (found !== 1) throw new Error(`expected one Identity Version in the manifest, found ${found}`);
27+
return manifest.replace(IDENTITY_VERSION, (_, before, after) => `${before}${version}${after}`);
28+
}
29+
30+
export const manifestExecutable = (manifest) => /<Application\b[^>]*\bExecutable="([^"]+)"/.exec(manifest)?.[1];

‎src-tauri/Cargo.toml‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,11 @@ libc = "0.2"
6060

6161
[target.'cfg(windows)'.dependencies]
6262
# Kill-on-close Job Object so the MCP child's cmd/pnpm/node tree dies with the
63-
# app, including on crash (the OS closes the handle on process exit).
63+
# app, including on crash. The OS closes the handle on process exit.
64+
# Win32_Storage_Packaging_Appx reads the package identity that marks a packaged install.
6465
windows-sys = { version = "0.61", features = [
6566
"Win32_Foundation",
67+
"Win32_Storage_Packaging_Appx",
6668
"Win32_System_JobObjects",
6769
] }
6870

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<!-- Identity comes from the Partner Center product identity.
3+
build-msix.mjs stamps Version from package.json. -->
4+
<Package
5+
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
6+
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
7+
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
8+
IgnorableNamespaces="uap rescap">
9+
<Identity
10+
Name="u8array.ZPLab"
11+
Publisher="CN=A10112AA-798A-4AA4-9DA8-62D37845A2C4"
12+
Version="0.0.0.0"
13+
ProcessorArchitecture="x64" />
14+
<Properties>
15+
<DisplayName>ZPLab</DisplayName>
16+
<PublisherDisplayName>u8array</PublisherDisplayName>
17+
<Logo>Assets\StoreLogo.png</Logo>
18+
</Properties>
19+
<Dependencies>
20+
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.26100.0" />
21+
</Dependencies>
22+
<!-- One per UI language in src/locales, the package default first. -->
23+
<Resources>
24+
<Resource Language="en-US" />
25+
<Resource Language="de" />
26+
<Resource Language="fr" />
27+
<Resource Language="es" />
28+
<Resource Language="pt" />
29+
<Resource Language="it" />
30+
<Resource Language="nl" />
31+
<Resource Language="pl" />
32+
<Resource Language="cs" />
33+
<Resource Language="sk" />
34+
<Resource Language="hu" />
35+
<Resource Language="ro" />
36+
<Resource Language="sv" />
37+
<Resource Language="nb" />
38+
<Resource Language="da" />
39+
<Resource Language="fi" />
40+
<Resource Language="el" />
41+
<Resource Language="bg" />
42+
<Resource Language="hr" />
43+
<Resource Language="sr-Cyrl" />
44+
<Resource Language="sl" />
45+
<Resource Language="et" />
46+
<Resource Language="lv" />
47+
<Resource Language="lt" />
48+
<Resource Language="ar" />
49+
<Resource Language="he" />
50+
<Resource Language="fa" />
51+
<Resource Language="tr" />
52+
<Resource Language="zh-Hans" />
53+
<Resource Language="zh-Hant" />
54+
<Resource Language="ja" />
55+
<Resource Language="ko" />
56+
</Resources>
57+
<Applications>
58+
<Application Id="ZPLab" Executable="ZPLab.exe" EntryPoint="Windows.FullTrustApplication">
59+
<uap:VisualElements
60+
DisplayName="ZPLab"
61+
Description="Visual designer for Zebra ZPL printer labels"
62+
BackgroundColor="transparent"
63+
Square150x150Logo="Assets\Square150x150Logo.png"
64+
Square44x44Logo="Assets\Square44x44Logo.png" />
65+
</Application>
66+
</Applications>
67+
<Capabilities>
68+
<rescap:Capability Name="runFullTrust" />
69+
</Capabilities>
70+
</Package>

0 commit comments

Comments
 (0)