apple build --platform macos --tauri: Mac App Store build of a Tauri app (sandbox entitlements, profile, productbuild pkg, path-free, checks); apple upload accepts the pkg

This commit is contained in:
CRTkafa 2026-10-24 12:04:26 +08:00
parent 29a9683ab7
commit cd6667e0b6
8 changed files with 726 additions and 22 deletions

194
tests/mas-build.test.ts Normal file
View file

@ -0,0 +1,194 @@
// Mac App Store build of a Tauri app (cli/lib/mas.ts, cli/apple/build-mas.ts): plan, entitlements, identities, path-free
// flags, leak scan, app / pkg checks, and the pkg variant of the build upload requests.
import { afterAll, describe, expect, test } from 'bun:test';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { tauriCommand } from '../cli/apple/build-mas';
import { appProblems, categoryId, entitlementKeysOf, entitlementsXml, findTauri, masEntitlements, masSteps, parsePackageInfo, parsePkgSignature, pathFreeEnv, pickMasIdentities, pickMasProfiles, pkgProblems, scanPathLeaks, tauriOverlay } from '../cli/lib/mas';
import { altoolArgs, buildUploadReqs } from '../cli/lib/xcode';
const tmp = mkdtempSync(join(tmpdir(), 'sb-mas-'));
afterAll(() => rmSync(tmp, { recursive: true, force: true }));
const conf = { productName: 'Demo App', identifier: 'com.example.demo', version: '1.2.3', bundle: { category: 'Utility', macOS: { minimumSystemVersion: '12.0' } } };
function fixture(): string {
const root = mkdtempSync(join(tmp, 'p-'));
mkdirSync(join(root, 'apps/desktop/src-tauri'), { recursive: true });
writeFileSync(join(root, 'apps/desktop/src-tauri/tauri.conf.json'), JSON.stringify(conf));
return root;
}
describe('tauri project', () => {
test('found by scan and by hint; fields read', () => {
const root = fixture();
const t = findTauri(root);
expect(t.productName).toBe('Demo App');
expect(t.identifier).toBe('com.example.demo');
expect(t.cwd).toBe(join(root, 'apps/desktop'));
expect(t.minOs).toBe('12.0');
expect(findTauri(root, 'apps/desktop/src-tauri').dir).toBe(t.dir);
expect(() => findTauri(root, 'nope')).toThrow('no tauri.conf.json');
});
test('none or several found', () => {
const empty = mkdtempSync(join(tmp, 'e-'));
expect(() => findTauri(empty)).toThrow('no tauri.conf.json found');
const two = fixture();
mkdirSync(join(two, 'other/src-tauri'), { recursive: true });
writeFileSync(join(two, 'other/src-tauri/tauri.conf.json'), JSON.stringify(conf));
expect(() => findTauri(two)).toThrow('several Tauri projects');
});
test('tauri command: project binary, cargo-tauri, else bunx', () => {
expect(tauriCommand('/a/b', p => /a.node_modules..bin.tauri$/.test(p), () => null)).toHaveLength(1);
expect(tauriCommand('/a/b', () => false, c => (c === 'cargo-tauri' ? '/x' : null))).toEqual(['cargo', 'tauri']);
expect(tauriCommand('/a/b', () => false, () => null)[0]).toBe('bunx');
});
test('category ids', () => {
expect(categoryId('Utility')).toBe('public.app-category.utilities');
expect(categoryId('public.app-category.games')).toBe('public.app-category.games');
expect(categoryId('Nope')).toBeNull();
});
});
describe('entitlements', () => {
test('sandbox and identifiers forced, project keys kept', () => {
const e = masEntitlements({ teamId: 'ABCDE12345', bundleId: 'com.example.demo', existing: { 'com.apple.security.app-sandbox': false, 'com.apple.security.files.user-selected.read-write': true } });
expect(e['com.apple.security.app-sandbox']).toBe(true);
expect(e['com.apple.application-identifier']).toBe('ABCDE12345.com.example.demo');
expect(e['com.apple.developer.team-identifier']).toBe('ABCDE12345');
expect(e['com.apple.security.files.user-selected.read-write']).toBe(true);
expect(e['com.apple.security.network.client']).toBe(true);
});
test('xml round trip', () => {
const e = masEntitlements({ teamId: 'ABCDE12345', bundleId: 'com.example.demo' });
expect(entitlementKeysOf(entitlementsXml(e))?.['com.apple.security.app-sandbox']).toBe(true);
expect(entitlementKeysOf('garbage')).toBeNull();
});
});
describe('identities and profiles', () => {
const ids = [
{ sha1: 'A', name: 'Apple Development: X (AAAAAAAAAA)' },
{ sha1: 'B', name: 'Apple Distribution: Co (ABCDE12345)' },
{ sha1: 'C', name: '3rd Party Mac Developer Installer: Co (ABCDE12345)' },
{ sha1: 'D', name: 'Apple Distribution: Other (ZZZZZZZZZZ)' },
];
test('app and installer identity of the team', () => {
const p = pickMasIdentities(ids, 'ABCDE12345');
expect(p.app?.sha1).toBe('B');
expect(p.installer?.sha1).toBe('C');
expect(pickMasIdentities(ids, 'QQQQQQQQQQ')).toEqual({ app: null, installer: null });
expect(pickMasIdentities([{ sha1: 'E', name: '3rd Party Mac Developer Application: Co (ABCDE12345)' }, ids[1]!], 'ABCDE12345').app?.sha1).toBe('E');
expect(pickMasIdentities([{ sha1: 'F', name: 'Mac Installer Distribution: Co (ABCDE12345)' }, ids[2]!], 'ABCDE12345').installer?.sha1).toBe('F');
});
const prof = (o: Record<string, unknown>) => ({ path: '/p', profile: { UUID: 'u1', Name: 'n', TeamIdentifier: ['ABCDE12345'], Platform: ['OSX'], ExpirationDate: '2099-01-01T00:00:00Z',
Entitlements: { 'com.apple.application-identifier': 'ABCDE12345.com.example.demo' }, ...o } as never });
test('Mac App Store profile: macOS, no devices, matching id, not expired', () => {
expect(pickMasProfiles([prof({})], 'com.example.demo', 'ABCDE12345')).toHaveLength(1);
expect(pickMasProfiles([prof({ Platform: ['iOS'] })], 'com.example.demo', null)).toHaveLength(0);
expect(pickMasProfiles([prof({ ProvisionedDevices: ['x'] })], 'com.example.demo', null)).toHaveLength(0);
expect(pickMasProfiles([prof({})], 'com.example.other', null)).toHaveLength(0);
expect(pickMasProfiles([prof({ ExpirationDate: '2000-01-01T00:00:00Z' })], 'com.example.demo', null)).toHaveLength(0);
});
});
describe('path-free', () => {
test('remaps checkout, cargo, rustup, home; no debug info', () => {
const e = pathFreeEnv({ home: '/Users/dev', user: 'dev', root: '/Users/dev/work/app', rustflags: '-C target-cpu=native' });
expect(e.RUSTFLAGS).toContain('-C target-cpu=native');
expect(e.RUSTFLAGS).toContain('--remap-path-prefix=/Users/dev/work/app=/src');
expect(e.RUSTFLAGS).toContain('--remap-path-prefix=/Users/dev/.cargo=/cargo');
expect(e.RUSTFLAGS).toContain('--remap-path-prefix=/Users/dev/.rustup=/rustup');
expect(e.RUSTFLAGS).toContain('--remap-path-prefix=/Users/dev=/home/build');
expect(e.CARGO_PROFILE_RELEASE_DEBUG).toBe('0');
});
test('leak scan finds the home folder in a binary and a plist', () => {
const app = mkdtempSync(join(tmp, 'app-'));
mkdirSync(join(app, 'Contents/MacOS'), { recursive: true });
writeFileSync(join(app, 'Contents/MacOS/bin'), Buffer.concat([Buffer.from([0, 1, 2]), Buffer.from('panic at /Users/dev/.cargo/registry/x.rs'), Buffer.from([0])]));
writeFileSync(join(app, 'Contents/Info.plist'), 'clean');
const leaks = scanPathLeaks(app, ['/Users/dev', '/Users/dev/']);
expect(leaks.map(l => `${l.file}:${l.needle}`)).toEqual(['Contents/MacOS/bin:/Users/dev', 'Contents/MacOS/bin:/Users/dev/']);
expect(scanPathLeaks(app, ['/Users/nobody'])).toEqual([]);
});
});
describe('app and pkg checks', () => {
const good = { info: { CFBundleIdentifier: 'com.example.demo', CFBundleShortVersionString: '1.2.3', CFBundleVersion: '7', LSApplicationCategoryType: 'public.app-category.utilities', LSMinimumSystemVersion: '12.0', ITSAppUsesNonExemptEncryption: false },
entitlements: masEntitlements({ teamId: 'ABCDE12345', bundleId: 'com.example.demo' }), hasProfile: true, leaks: [] };
const o = { bundleId: 'com.example.demo', version: '1.2.3', build: '7', signed: true, pathFree: true };
test('a good app passes', () => { expect(appProblems(good, o)).toEqual({ errors: [], warnings: [] }); });
test('missing sandbox, category, min OS, profile, wrong build', () => {
const bad = { ...good, info: { ...good.info, CFBundleVersion: '6', LSApplicationCategoryType: undefined, LSMinimumSystemVersion: undefined } as never, entitlements: { 'com.apple.security.app-sandbox': false }, hasProfile: false };
const e = appProblems(bad, o).errors.join('\n');
for (const s of ['CFBundleVersion is 6', 'LSApplicationCategoryType', 'LSMinimumSystemVersion', 'app-sandbox is not true', 'application-identifier', 'embedded.provisionprofile']) expect(e).toContain(s);
expect(appProblems({ ...good, entitlements: null }, o).errors[0]).toContain('no entitlements');
});
test('get-task-allow refused; unsigned skips entitlement checks', () => {
expect(appProblems({ ...good, entitlements: { ...good.entitlements!, 'com.apple.security.get-task-allow': true } }, o).errors.join()).toContain('get-task-allow');
expect(appProblems({ ...good, entitlements: null, hasProfile: false }, { ...o, signed: false }).errors).toEqual([]);
});
test('path leaks are errors with --path-free, warnings without', () => {
const leaks = [{ file: 'Contents/MacOS/bin', needle: '/Users/dev', count: 3 }];
expect(appProblems({ ...good, leaks }, o).errors[0]).toContain('local paths');
const w = appProblems({ ...good, leaks }, { ...o, pathFree: false });
expect(w.errors).toEqual([]);
expect(w.warnings.join()).toContain('--path-free');
});
test('pkgutil --check-signature', () => {
const ok = parsePkgSignature('Package "a.pkg":\n Status: signed by a certificate trusted by macOS\n Certificate Chain:\n 1. 3rd Party Mac Developer Installer: Co (ABCDE12345)\n Expires: 2027');
expect(ok).toMatchObject({ signed: true, installerCert: true, certificate: '3rd Party Mac Developer Installer: Co (ABCDE12345)' });
expect(pkgProblems(ok)).toEqual([]);
expect(pkgProblems(parsePkgSignature('Package "a.pkg":\n Status: no signature'))[0]).toContain('not signed');
const dev = parsePkgSignature(' Status: signed by a certificate trusted by macOS\n 1. Developer ID Installer: Co (ABCDE12345)');
expect(pkgProblems(dev)[0]).toContain('not a "3rd Party Mac Developer Installer"');
});
test('PackageInfo', () => {
const x = '<pkg-info identifier="com.example.demo" version="1.2.3"><bundle path="./Demo.app" id="com.example.demo" CFBundleShortVersionString="1.2.3" CFBundleVersion="7"/></pkg-info>';
expect(parsePackageInfo(x)).toEqual({ bundleId: 'com.example.demo', version: '1.2.3', build: '7' });
expect(parsePackageInfo('<pkg-info/>')).toBeNull();
});
});
describe('plan', () => {
const t = findTauri(fixture());
const base = { tauri: t, tauriCmd: ['cargo', 'tauri'], target: 'universal-apple-darwin', out: '/o', configFile: '/o/c.json', entitlementsFile: '/o/e', profile: '/o/p', appIdentity: 'Apple Distribution: Co (ABCDE12345)',
installerIdentity: '3rd Party Mac Developer Installer: Co (ABCDE12345)', version: '1.2.3', build: '7', unsigned: false, pathFree: true, pkg: '/o/Demo App.pkg' };
test('signed steps: tauri app bundle, productbuild with the installer identity', () => {
const s = masSteps(base);
expect(s.tauri).toEqual(['cargo', 'tauri', 'build', '--bundles', 'app', '--target', 'universal-apple-darwin', '--config', '/o/c.json']);
expect(s.productbuild.slice(0, 2)).toEqual(['productbuild', '--component']);
expect(s.productbuild).toContain('--sign');
expect(s.productbuild.at(-1)).toBe('/o/Demo App.pkg');
expect(s.pkgCheck).toEqual(['pkgutil', '--check-signature', '/o/Demo App.pkg']);
});
test('unsigned: no --sign', () => { expect(masSteps({ ...base, unsigned: true }).productbuild).not.toContain('--sign'); });
test('tauri overlay: identity, entitlements, embedded profile, bundleVersion', () => {
const o = tauriOverlay({ ...base, minOs: '12.0' }) as { version: string; bundle: { macOS: Record<string, unknown> } };
expect(o.version).toBe('1.2.3');
expect(o.bundle.macOS.bundleVersion).toBe('7');
expect(o.bundle.macOS.signingIdentity).toBe(base.appIdentity);
expect(o.bundle.macOS.hardenedRuntime).toBe(true);
expect(o.bundle.macOS.files).toEqual({ 'embedded.provisionprofile': '/o/p' });
const u = tauriOverlay({ ...base, unsigned: true }) as { bundle: { macOS: Record<string, unknown> } };
expect(u.bundle.macOS.signingIdentity).toBeUndefined();
});
});
describe('build upload of a pkg', () => {
const info = { bundleId: 'com.example.demo', version: '1.2.3', build: '7' };
test('platform MAC_OS and the pkg uti; iOS unchanged', () => {
const m = buildUploadReqs('app1', info, 'Demo.pkg', 10, 'MAC_OS');
expect((m.upload.body.data.attributes as Record<string, unknown>).platform).toBe('MAC_OS');
expect((m.file('u1').body.data.attributes as Record<string, unknown>).uti).toBe('com.apple.pkg');
const i = buildUploadReqs('app1', info, 'Demo.ipa', 10);
expect((i.upload.body.data.attributes as Record<string, unknown>).platform).toBe('IOS');
expect((i.file('u1').body.data.attributes as Record<string, unknown>).uti).toBe('com.apple.ipa');
});
test('altool gets --type macos for a pkg', () => {
const k = { keyPath: 'k', keyId: 'i', issuerId: 's' };
expect(altoolArgs('/o/Demo.pkg', k)).toContain('macos');
expect(altoolArgs('/o/Demo.ipa', k)).not.toContain('--type');
});
});