Apple metadata refusals give only verified grant advice
Refuse every supplied Apple metadata key together. Generate specific grant advice with companion lines and prove it through the bake and platform writers. Offer alternative grants as choices and keep prerequisites beside the grant that needs them. Other derived keys point to the bundle metadata reference; keys Exact never writes ask for an app feedback note. Keep portable grant derivation in the gate and Apple writer integration in the async lane. Add the grant path to the recipes and cite the recorded entitlement decision. Leave unowned Info.plist admission and notification service extension packaging open. Part of issues/20261113-ios-app-cannot-declare-entitlements-or-plist-keys.md. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
1520c454d1
commit
18da214e25
18 files changed
+525
-88
No files matched your search
+3
-3
@@ -803,7 +803,7 @@ mod grant_lines_tests;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn compat(grants: &str) -> Compat {
|
||||
pub(super) fn compat(grants: &str) -> Compat {
|
||||
Compat {
|
||||
id: "old".into(),
|
||||
inputs: json!({"grantCeiling": grants, "javascriptGrants": grants, "rustGrants": ""}),
|
||||
@@ -818,7 +818,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn app(tables: &[(&str, &str)]) -> tempdir::Dir {
|
||||
pub(super) fn app(tables: &[(&str, &str)]) -> tempdir::Dir {
|
||||
let dir = tempdir::Dir::new();
|
||||
std::fs::create_dir(dir.0.join("strings")).unwrap();
|
||||
for (locale, json) in tables {
|
||||
@@ -827,7 +827,7 @@ mod tests {
|
||||
dir
|
||||
}
|
||||
|
||||
mod tempdir {
|
||||
pub(super) mod tempdir {
|
||||
pub struct Dir(pub std::path::PathBuf);
|
||||
impl Dir {
|
||||
pub fn new() -> Dir {
|
||||
|
||||
@@ -85,3 +85,104 @@ fn grant_lines_parse() {
|
||||
// The form the advice must not take: the device table refuses it.
|
||||
assert!(exact_runner::device::device_grants("device.auth.apple purpose.k").is_err());
|
||||
}
|
||||
|
||||
/// Apply the diagnostic's lines verbatim, filling only its documented placeholders.
|
||||
fn apple_metadata_cases() -> serde_json::Value {
|
||||
use serde_json::json;
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
|
||||
let out = crate::bun()
|
||||
.current_dir(&root)
|
||||
.args(["--eval", r#"
|
||||
import { appleMetadataOwners, appleMetadataProblem } from './host/apple/plist.mjs';
|
||||
const cases = [];
|
||||
for (const [platform, kinds] of Object.entries(appleMetadataOwners()))
|
||||
for (const [kind, keys] of Object.entries(kinds))
|
||||
for (const [key, owners] of keys) for (const owner of owners) {
|
||||
const message = appleMetadataProblem(`host.${platform}.${kind}.${key}: not a known key`);
|
||||
if (!message.includes(owner)) throw new Error(message);
|
||||
const lines = [...owner.matchAll(/`([^`]+)`/g)].map(m => m[1]);
|
||||
if (!owner.startsWith('write ') || !lines.length) throw new Error(`unproven advice: ${owner}`);
|
||||
cases.push({ platform, kind, key, owner, ceiling: lines.join('\n') });
|
||||
}
|
||||
console.log(JSON.stringify(cases));
|
||||
"#])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
let mut cases: Vec<serde_json::Value> = serde_json::from_slice(&out.stdout).unwrap();
|
||||
let dir = super::tests::app(&[("en", r#"{"purpose":"Use this device."}"#)]);
|
||||
for case in &mut cases {
|
||||
let platform = case["platform"].as_str().unwrap();
|
||||
let grants = case["ceiling"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.replace("purpose.<key>", "purpose")
|
||||
.replace("<client id>", "com.example.app");
|
||||
assert!(!grants.contains('<'), "unfilled advice: {case}");
|
||||
let mut c = super::tests::compat(&grants);
|
||||
super::derive(&dir.0, platform, None, &mut c).unwrap_or_else(|e| panic!("{case}: {e}"));
|
||||
if case["kind"] == "infoPlist" {
|
||||
assert!(
|
||||
c.reach["usage"]
|
||||
.get(case["key"].as_str().unwrap())
|
||||
.is_some(),
|
||||
"{case}"
|
||||
);
|
||||
}
|
||||
case["ceiling"] = json!(grants);
|
||||
case["reach"] = c.reach;
|
||||
}
|
||||
for key in [
|
||||
"aps-environment",
|
||||
"com.apple.developer.applesignin",
|
||||
"NSPhotoLibraryAddUsageDescription",
|
||||
"NSMicrophoneUsageDescription",
|
||||
] {
|
||||
assert!(
|
||||
cases.iter().any(|c| c["key"] == key),
|
||||
"missing diary case: {key}"
|
||||
);
|
||||
}
|
||||
assert!(cases.len() > 30, "{} advice cases", cases.len());
|
||||
json!(cases)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apple_metadata_advice_grants_derive_without_added_prerequisites() {
|
||||
apple_metadata_cases();
|
||||
}
|
||||
|
||||
/// The Apple writer integration is separate from the portable grant checks.
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
#[ignore = "async lane: Apple metadata writers"]
|
||||
fn apple_metadata_advice_emits_the_key_on_the_named_bundle() {
|
||||
assert_eq!(
|
||||
std::env::consts::OS,
|
||||
"macos",
|
||||
"run the Apple integration on a Mac"
|
||||
);
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
|
||||
let out = crate::bun()
|
||||
.current_dir(root)
|
||||
.args(["test", "host/apple/metadata-advice.test.mjs"])
|
||||
.env("EXACT_METADATA_CASES", apple_metadata_cases().to_string())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"{}\n{}",
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
let output = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(
|
||||
output.contains("platform/key/owner writes verified"),
|
||||
"writer test did not run: {output}"
|
||||
);
|
||||
print!("{output}");
|
||||
}
|
||||
+13
-22
@@ -1,6 +1,6 @@
|
||||
# Recipe: app.json, assets and modules
|
||||
|
||||
`app.json` names the app and what it ships; its `$schema` gives an editor every key.
|
||||
`app.json` names the app and its contents; `$schema` lists every key.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -14,19 +14,6 @@
|
||||
}
|
||||
```
|
||||
|
||||
```contract
|
||||
sound "assets/tick.wav"
|
||||
|
||||
component App
|
||||
action tick
|
||||
playSound("assets/tick.wav")
|
||||
view
|
||||
column
|
||||
image "assets/logo.png" width=96 object-fit="contain" aria-label="Euxy"
|
||||
button press=tick testId="tick"
|
||||
text "Tick"
|
||||
```
|
||||
|
||||
- **`assets/` and `deck/`** ship except `.DS_Store` files. Contract paths start at the app
|
||||
(no leading slash): `image "assets/logo.png"`, a `sound` and `playSound`, a `font` block's
|
||||
`400 = "assets/Inter-Regular.ttf"` (then `font-family="Inter"`).
|
||||
@@ -45,14 +32,18 @@ component App
|
||||
Cold, it opens in its route's tab (declare `p "/p/:d"`); running, `go` pushes it on the
|
||||
selected tab, `open` on its route's (`vocab navigate`).
|
||||
- **Other keys:** `userInterfaceStyle` (`dark`/`light`), `categories` (store, Game Mode),
|
||||
`data` (`data-*` words a hatch reads), `tamber` (design file), `strings`,
|
||||
`data` (`data-*` words a hatch reads), `tamber` (design file),
|
||||
`typescript.sources` (outside directories `app.ts` imports), `commands` (the app's
|
||||
`exact.mjs` verbs), `env` (settings inlined at build, so never a secret:
|
||||
[a REST server](rest-backend.md)), `host.web.head` ([a website](website.md)).
|
||||
`exact.mjs` verbs).
|
||||
- **`strings`**: `{ "base": "en" }` sets the base locale for `strings/<locale>.json`.
|
||||
- **`env`**: settings inlined at build, so never a secret
|
||||
([a REST server](rest-backend.md)). **`host.web.head`**: [a website](website.md).
|
||||
- **`host.ios.appClip`** `{}`: an App Clip too (`id` `<app id>.Clip` by default; each
|
||||
`applinks:` domain is its `appclips:` one).
|
||||
- **Not here:** grants (in `app.ts`), fonts and sounds (Contract).
|
||||
- **A change to `app.json`, `assets/`, Swift, grants, `appId`, fonts or strings rebuilds** the
|
||||
native app (`bun exact.mjs ios`). An edit to `.contract` files, to `app.ts` (short of its
|
||||
grants, `appId` and canvas surfaces) or what it imports reaches a running build in seconds;
|
||||
with a Rust data crate, a file the build reads rebuilds ([driving iOS](ios-driving.md)).
|
||||
- **Apple metadata:** grants derive keys ([permissions](permissions.md)), including
|
||||
`device.photos-add purpose.savePhoto` for `NSPhotoLibraryAddUsageDescription`.
|
||||
`host.ios` and `host.macos` dictionaries `infoPlist` and `entitlements` refuse
|
||||
every key; see [bundle metadata](../reference.md#apple-bundle-metadata).
|
||||
- **Binary inputs** (`app.json`, assets, Swift, grants, `appId`, canvas surfaces, fonts,
|
||||
strings) require `bun exact.mjs ios`. Other `.contract`, `app.ts` and imported edits
|
||||
reach a running app in seconds; Rust data inputs rebuild ([driving iOS](ios-driving.md)).
|
||||
@@ -3,21 +3,24 @@
|
||||
Put an `ExactModule` in `modules/apple/*.swift`; `let exactModule` names its class.
|
||||
No app.json entry.
|
||||
|
||||
**Kept answers:** a resource that reads `native.available` or calls `native.call`
|
||||
reads device state, so its answer can be kept across launches. Read launch
|
||||
configuration in a mount mutation and pass it to fetch resources; keep native
|
||||
access out of their sources:
|
||||
Declare device access in the activating source's `grants`
|
||||
([permissions](permissions.md)). `device.photos-add purpose.savePhoto` derives
|
||||
`NSPhotoLibraryAddUsageDescription` from that strings key. Grants derive
|
||||
entitlements too. Apple `infoPlist` and `entitlements` dictionaries refuse every
|
||||
key; see [bundle metadata](../reference.md#apple-bundle-metadata).
|
||||
|
||||
**Kept answers:** a resource reading `native.available` or calling `native.call`
|
||||
reads device state; its answer is kept across launches. Keep native access out of
|
||||
fetch sources. Read launch configuration in a mount mutation and
|
||||
pass it as a resource argument:
|
||||
|
||||
```swift
|
||||
import Foundation
|
||||
|
||||
final class Launch: ExactModule {
|
||||
override func call(_ request: [String: Any]) -> [String: Any] {
|
||||
return ["url": ProcessInfo.processInfo.environment["API_BASE_URL"]
|
||||
?? "https://jsonplaceholder.typicode.com"]
|
||||
}
|
||||
override func later(_ request: [String: Any], reply: ExactReply) {
|
||||
reply.send(call(request))
|
||||
reply.send(["url": ProcessInfo.processInfo.environment["API_BASE_URL"]
|
||||
?? "https://jsonplaceholder.typicode.com"])
|
||||
}
|
||||
}
|
||||
let exactModule: ExactModule.Type = Launch.self
|
||||
@@ -38,16 +41,10 @@ component App
|
||||
after(1, configure)
|
||||
action configure
|
||||
send endpoint = launchEndpoint()
|
||||
action retry
|
||||
refresh feed
|
||||
view
|
||||
column
|
||||
if api == "" or pending(feed)
|
||||
progress aria-label="Loading"
|
||||
if failed(endpoint) or failed(feed)
|
||||
text "Couldn't load."
|
||||
button press=retry testId="retry"
|
||||
text "Retry"
|
||||
for p in feed.posts key=p
|
||||
text p
|
||||
```
|
||||
@@ -76,16 +73,13 @@ export const answer: Answer = (source, args, store, storage, native) =>
|
||||
`ExactReply.send` or `fail` from any thread. The web implements only `later`, in
|
||||
`modules/web/index.js`.
|
||||
|
||||
Web drives use that module when present, otherwise the fallback; iOS calls Swift.
|
||||
Use `clock +1`, then `clock data`.
|
||||
Swift edits need `bun exact.mjs ios`. `print` reaches agent `logs`.
|
||||
Drive with `clock +1`, then `clock data`. Rebuild Swift with `bun exact.mjs ios`; `print` reaches agent `logs`.
|
||||
|
||||
Set the environment read above on an installed simulator build (`id` in app.json):
|
||||
Set the environment on the simulator app (`id` in app.json):
|
||||
|
||||
```sh
|
||||
SIMCTL_CHILD_API_BASE_URL=https://jsonplaceholder.typicode.com xcrun simctl launch --terminate-running-process <udid> <bundle-id>
|
||||
```
|
||||
|
||||
The app receives `API_BASE_URL`. Use the same prefix on
|
||||
`bun exact.mjs agent ios "clock +1" "clock data" state`.
|
||||
`-API_BASE_URL value` sets `UserDefaults`, which this example does not read.
|
||||
Use the same prefix on `bun exact.mjs agent ios "clock +1" "clock data" state`.
|
||||
`-API_BASE_URL value` sets `UserDefaults`, not the environment.
|
||||
@@ -1444,6 +1444,17 @@ enters the compatibility id. An older bake without the fields answers `""`.
|
||||
The web JS build uses these variables at build time and answers `""` for each
|
||||
unset variable. No `app.json` version key is defined.
|
||||
|
||||
## Apple bundle metadata
|
||||
|
||||
Exact writes Info.plist keys and entitlements from grants, named manifest fields,
|
||||
packages and build inputs. Entitlements remain derived (LLP 1069.008 D4).
|
||||
The `host.ios` and `host.macos` dictionaries `infoPlist` and `entitlements` refuse
|
||||
all supplied keys together; their values never enter a bundle. Diagnostics give
|
||||
specific grant advice only where tests apply those lines, including companion
|
||||
grants, through the bake and platform writer. Other derived keys cannot be set
|
||||
there. For a key Exact never writes, record a `bun exact.mjs feedback note` from
|
||||
the app directory naming the key and what the native module needs.
|
||||
|
||||
## Apple privacy manifests
|
||||
|
||||
Apple builds write `PrivacyInfo.xcprivacy` before signing each app, App Clip,
|
||||
|
||||
@@ -64,7 +64,7 @@ import { appleImplementations } from '../../js/bake/src/typescript.mjs';
|
||||
import { composedModule, moduleSeam, systemFrameworks } from './compose.mjs';
|
||||
import { associatedDomains, entitlementValue, grantEntitlements, macSigning, merchantEntitlement, signInEntitlement } from './presence.mjs';
|
||||
import { writeDataKeys } from './data-keys.mjs';
|
||||
import { backgroundKeys, declaredOrigin, interfaceStyle, localNetworking, memoryEntitlements, modulePlist, nfcKeys, startLocation, storeKeys, transportSecurity, updateActivation, withPackageKeys } from './plist.mjs';
|
||||
import { backgroundKeys, firstFrameKeys, declaredOrigin, interfaceStyle, localNetworking, memoryEntitlements, modulePlist, nfcKeys, startLocation, storeKeys, transportSecurity, updateActivation, withPackageKeys } from './plist.mjs';
|
||||
import { compileFixtures, iosTestRuns, macTests, onlyClasses } from './xctest.mjs';
|
||||
import { APPLE_STATIC_TREES, appIcon, appleRefusals, buildInfo, iosAssets, appleAssets, appleAssetCatalogInventory, copyNativeResources, signingOrder, helperEntitlements, plistFile, wrapFramework, signedGpuProduct, assertArchiveFrameworks } from './assets.mjs';
|
||||
import { auditLaunchParts, launchModules, launchPartsObjects, launchServices, serviceArgs, writeLaunchParts } from './launch-parts.mjs';
|
||||
@@ -1287,8 +1287,7 @@ async function main(args, lockHeld = false) {
|
||||
if (roster) {
|
||||
provided = Object.keys(roster).filter(key => key.includes('-')); // its tags: beside them, `hatches`, `extensions` and `facts`, which no tag can be
|
||||
// LLP 1024 D3's first-frame tags, which the host reads before it loads the module.
|
||||
const firstFrame = provided.filter(tag => roster[tag].firstFrame === true);
|
||||
if (firstFrame.length && !tv) rosterKeys = { ExactFirstFrameModules: firstFrame };
|
||||
rosterKeys = firstFrameKeys(roster, tv);
|
||||
} else if (routes.length) {
|
||||
const why = `host/apple: cannot qualify ${app.id}'s ${tv ? 'tvOS' : ios ? 'iOS' : 'macOS'} native-module roster.\n${causes.map(c => ` ${c}`).join('\n')}\n${noSimulator ? 'Install an iOS Simulator runtime (xcodebuild -downloadPlatform iOS), or add an iPhone simulator in Xcode' : 'Give each xcframework a slice for one route, or fix the failure a route names'}, and rebuild.${logs.length ? `\nLogs: ${logs.join(', ')}` : ''}`;
|
||||
const qualification = process.platform === 'linux' && existsSync(resolve(linkRoot, 'native-probes.json')) ? `\nOn Linux, set EXACT_NATIVE_ROSTERS to a simulator qualification for the compiled probe listed in ${resolve(linkRoot, 'native-probes.json')} (README.md).` : '';
|
||||
|
||||
+9
-13
@@ -2,7 +2,7 @@ import { writePrivacyManifest } from './privacy.mjs';
|
||||
// Apple bundle assembly. The build owns compilation and artifact publication.
|
||||
// @ref LLP 1126 D2/D8 — an extension is packaged inside its app's cohort.
|
||||
import { packageActivityArm, assertActivityRefusals } from './activities.mjs';
|
||||
import { packageWidgets, packageAlarmExtension, widgetId, entryProfiles, activityEntries, platformWidgetEntries, macWidgetGroup, macWidgetEntitlements, widgetGroup, widgetSigningTeam } from './widgets.mjs';
|
||||
import { packageWidgets, packageAlarmExtension, widgetId, entryProfiles, activityEntries, platformWidgetEntries, macWidgetGroup, macWidgetEntitlements, widgetGroup, widgetSigningTeam, widgetAppKeys } from './widgets.mjs';
|
||||
import { packageShareExtension, shareInfo, shareId, macShareEntitlements } from './extensions.mjs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { spawnSync } from './toolchain.mjs';
|
||||
@@ -10,7 +10,7 @@ import { resolve, basename, dirname } from 'node:path';
|
||||
import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { verifyBakeFiles } from '../../scripts/app.mjs';
|
||||
import { webRefusal, browserEntitlements } from './documents.mjs';
|
||||
import { memoryEntitlements } from './plist.mjs';
|
||||
import { memoryEntitlements, alarmKeys } from './plist.mjs';
|
||||
import { listAssets } from '../web/serve.mjs';
|
||||
import { appIcon, buildInfo, iosAssets, appleAssets, copyNativeResources, IOS_RESOURCE_ROOT, iosBundleGrants, iosExecutableRefusal, shareIosResources, iconFiles, signingOrder, helperEntitlements, wrapFramework, assertArchiveFrameworks, plistFile } from './assets.mjs';
|
||||
import { allows, profile, identity, embeddedProfile } from './devices.mjs';
|
||||
@@ -40,14 +40,10 @@ export function assembleMac({intents, shared, library, moduleLibraries = [], ser
|
||||
const team = widgetTeam ?? (app.manifest.share_target && sha1 && sha1 !== '-' ? (() => { try { return widgetSigningTeam(sha1); } catch (e) { console.log(`Mac share extension: ${e.message}`); return null; } })() : null);
|
||||
if (app.manifest.share_target && !team) console.log('Mac share extension: no Apple Development identity; building without it (EXACT_IDENTITY names one, LLP 1133.024 D4).');
|
||||
const shareGroup = app.manifest.share_target && team ? macWidgetGroup(app, team) : null;
|
||||
const widgetKeys = { ...shareInfo(app, shareGroup), ...(shared ? {
|
||||
ExactAppGroup: macWidgetGroup(app, team), ExactAppIdentifier: app.id,
|
||||
ExactWidgetKinds: platformWidgetEntries(app, 'macos').map(entry => entry.kind),
|
||||
ExactWidgetTypeScript: existsSync(resolve(app.dir, 'app.ts')),
|
||||
ExactWidgetRequiresSecrets: platformWidgetEntries(app, 'macos').filter(entry => entry.requiresSecrets).map(entry => entry.kind),
|
||||
ExactWidgetGeneration: createHash('sha256').update(JSON.stringify([bakedCompat.id, buildReceipt.graph.artifacts])).digest('hex'),
|
||||
...(profiles ? { ExactKeychainAccessGroup: profiles.keychain } : {}),
|
||||
} : {}) };
|
||||
const widgetKeys = { ...shareInfo(app, shareGroup), ...(shared ? widgetAppKeys(app, 'macos', {
|
||||
group: macWidgetGroup(app, team), keychain: profiles?.keychain,
|
||||
generation: createHash('sha256').update(JSON.stringify([bakedCompat.id, buildReceipt.graph.artifacts])).digest('hex'),
|
||||
}) : {}) };
|
||||
if (shared && !profiles) console.log('Mac widgets: no shared Keychain profiles; entries with requiresSecrets show the placeholder (LLP 1126.001 §5 Q5).');
|
||||
// What a macOS profile authorizes (LLP 1133.010 D7) is signed beside the widgets' group, under the one profile.
|
||||
const ent = shared || shareGroup || provisioning ? resolve(linkRoot, 'mac-entitlements.plist') : null;
|
||||
@@ -244,7 +240,7 @@ export function assembleIOS({intents, shared, library, moduleLibraries = [], ser
|
||||
if (shared) run('plutil', ['-insert', 'ExactWidgetGeneration', '-string', createHash('sha256').update(JSON.stringify([bakedCompat.id, buildReceipt.graph.artifacts])).digest('hex'), resolve(bundle, 'Info.plist')]);
|
||||
writeUsageStrings(appleReach, bundle);
|
||||
// AlarmKit's countdown is a Live Activity the extension draws (LLP 1133.030 D8 item 2).
|
||||
if (alarm && !tv) for (const key of ['NSSupportsLiveActivities', 'ExactAlarmCountdown']) run('plutil', ['-replace', key, '-bool', 'YES', resolve(bundle, 'Info.plist')]);
|
||||
for (const [key, value] of Object.entries(alarmKeys(alarm, tv))) run('plutil', ['-replace', key, '-bool', String(value), resolve(bundle, 'Info.plist')]);
|
||||
intents?.place(bundle); // LLP 1133.023 D8: the faces' App Intents metadata
|
||||
copyAppleGpuProducts(libDir, resolve(bundle, 'Frameworks'), gpuArtifacts, production);
|
||||
for (const [has, built, name] of looseArms) if (has) copyFileSync(built, resolve(bundle, 'Frameworks', name));
|
||||
@@ -305,8 +301,8 @@ export function assembleIOS({intents, shared, library, moduleLibraries = [], ser
|
||||
writeFileSync(ent, entitlements({ ...app, id, widgetTV: tv, widgetHost: kind === 'host', ...(kind === 'clip' ? { clipParent: app.id } : {}) }, signingProfile?.team, signingProfile?.dev ?? !unsigned, appleReach, { simulator: !device, prefix: signingProfile?.prefix, allows: signingProfile?.entitlements ?? [], distributed: distribution }));
|
||||
if (shared && kind === 'app') {
|
||||
const group = JSON.parse(runApple('plutil', ['-convert', 'json', '-o', '-', ent], { read: true }).stdout)['keychain-access-groups'][0];
|
||||
runApple('plutil', ['-insert', 'ExactKeychainAccessGroup', '-string', group, resolve(assembled, 'Info.plist')]);
|
||||
runApple('plutil', ['-insert', 'ExactWidgetTypeScript', '-bool', String(existsSync(resolve(app.dir, 'app.ts'))), resolve(assembled, 'Info.plist')]);
|
||||
const keys = widgetAppKeys(app, 'ios', { group: widgetGroup(app), keychain: group });
|
||||
for (const key of ['ExactKeychainAccessGroup', 'ExactWidgetTypeScript']) runApple('plutil', ['-insert', key, typeof keys[key] === 'boolean' ? '-bool' : '-string', String(keys[key]), resolve(assembled, 'Info.plist')]);
|
||||
}
|
||||
verifyBakeFiles(bakedCompat, bakedPlan, listAssets(assembled, true, ['typescript']));
|
||||
assertAppleIdentity(app, resolve(assembled, executable), bakedCompat.id);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { capturePrivacyManifest } from './privacy.mjs';
|
||||
import { test } from 'bun:test';
|
||||
import { test, spyOn } from 'bun:test';
|
||||
import * as appleToolchain from './toolchain.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import {copyFileSync, cpSync, existsSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, writeFileSync, rmSync} from 'node:fs';
|
||||
import {tmpdir} from 'node:os';
|
||||
@@ -606,16 +607,39 @@ test.skipIf(process.platform !== 'darwin' || !['clang', 'codesign', 'sips', 'dsy
|
||||
const clipBin = resolve(binDir, 'clip', 'Clip');
|
||||
mkdirSync(resolve(binDir, 'clip'));
|
||||
copyFileSync(bin, clipBin);
|
||||
const result = assembleIOS({ app, library, paths: { bundle, executable: 'ExactClip', capture }, binDir, bin, device,
|
||||
const options = { app, library, paths: { bundle, executable: 'ExactClip', capture }, binDir, bin, device,
|
||||
clipBin, production, distribution: archived, ipa, bakedCompat: compat, bakedPlan: plan, buildReceipt: build,
|
||||
libDir: capture, gpuArtifacts: [gpu], looseArms: [[true, dylib, ...pairs[0]]],
|
||||
modulesBuilt: dylib, modulesLoadName: pairs[1][0], hasSvg: true, svgBuilt: dylib, svgLoadName: pairs[2][0],
|
||||
canvasGpuBuilt: dylib, canvasGpuLoadName: pairs[3][0], serviceDylibs: [{ built: service, load: pairs[4][0], framework: pairs[4][1] }],
|
||||
args: [], unsigned: true, sha1: '-', linkRoot: binDir, sdkName: 'iphoneos' }, {
|
||||
args: [], unsigned: true, sha1: '-', linkRoot: binDir, sdkName: 'iphoneos' };
|
||||
const helpers = {
|
||||
runApple: run, infoPlist: () => plistFile({ CFBundleIdentifier: app.id, CFBundleExecutable: 'ExactClip', CFBundlePackageType: 'APPL' }),
|
||||
copyAppleStaticTrees() {}, writeUsageStrings() {}, assertAppleIdentity() {}, distributionKeys: () => ({}),
|
||||
receipt: () => whole, shippedReceipt, copyAppleGpuProducts, entitlements: () => plistFile({}), appClip: () => ({ id: app.id + '.Clip', name: 'Clip', executable: 'Clip' }),
|
||||
});
|
||||
};
|
||||
if (!production) {
|
||||
// Inspect the app after AlarmKit's plist writes, before compiling its separate extension.
|
||||
const { appleMetadataProblem } = await import('./plist.mjs');
|
||||
const metadataWritten = new Error('alarm metadata inspected');
|
||||
const execute = appleToolchain.spawnSync;
|
||||
const probe = spyOn(appleToolchain, 'spawnSync').mockImplementation((command, args, opts) => {
|
||||
const result = execute(command, args, opts);
|
||||
if (command === 'plutil' && args[0] === '-replace' && args[1] === 'ExactAlarmCountdown') {
|
||||
probe.mockRestore();
|
||||
const metadata = JSON.parse(run('plutil', ['-convert', 'json', '-o', '-', resolve(bundle, 'Info.plist')]).stdout);
|
||||
for (const key of ['NSSupportsLiveActivities', 'ExactAlarmCountdown']) {
|
||||
assert.equal(metadata[key], true);
|
||||
assert.match(appleMetadataProblem(`host.ios.infoPlist.${key}: not a known key`), /Exact writes this key itself from its declarations.*cannot be set here/);
|
||||
}
|
||||
throw metadataWritten;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
try { assert.throws(() => assembleIOS({ ...options, alarm: {} }, helpers), error => error === metadataWritten); }
|
||||
finally { probe.mockRestore(); }
|
||||
}
|
||||
const result = assembleIOS(options, helpers);
|
||||
for (const owner of [bundle, resolve(bundle, 'AppClips/Clip.app'), ...(production ? [...pairs, [null, 'ExactGpu']].map(([, name]) => resolve(bundle, 'Frameworks', `${name}.framework`)) : [])]) {
|
||||
const privacy = resolve(owner, 'PrivacyInfo.xcprivacy'); assert.equal(run('plutil', ['-lint', privacy]).status, 0);
|
||||
const usesDisk = diskService && (owner.endsWith('/ExactService.framework') || (!production && owner.endsWith('.app')));
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// The bake supplies inputs derived from exactly the grant lines printed in each diagnostic.
|
||||
import { test } from 'bun:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { appleMetadataOwners } from './plist.mjs';
|
||||
import { infoPlist, macInfoPlist, entitlements } from './build.mjs';
|
||||
|
||||
const derived = JSON.parse(process.env.EXACT_METADATA_CASES ?? 'null');
|
||||
test.skipIf(!derived)('every specific recommendation emits its key through the platform writer', () => {
|
||||
assert.equal(process.platform, 'darwin', 'the Apple integration must run on a Mac');
|
||||
const app = { id: 'com.example.advice', displayName: 'Advice', manifest: {} };
|
||||
const checked = new Set();
|
||||
for (const { platform, kind, key, owner, reach } of derived) {
|
||||
if (kind === 'entitlements') assert.equal(platform, 'ios', 'add the Mac entitlement writer before advising a Mac grant');
|
||||
const output = kind === 'entitlements'
|
||||
? entitlements(app, 'TEAM', true, reach, { allows: ['com.apple.developer.applesignin'] })
|
||||
: platform === 'ios' ? infoPlist(app, false, { reach }) : macInfoPlist(app, { reach });
|
||||
assert.ok(output.includes(`\n <key>${key}</key>`), `${platform}.${kind}.${key}: ${owner}`);
|
||||
checked.add(JSON.stringify([platform, kind, key, owner]));
|
||||
}
|
||||
for (const [platform, kinds] of Object.entries(appleMetadataOwners()))
|
||||
for (const [kind, keys] of Object.entries(kinds)) for (const [key, owners] of keys) for (const owner of owners)
|
||||
assert.ok(checked.has(JSON.stringify([platform, kind, key, owner])), `untested advice: ${platform}.${kind}.${key}: ${owner}`);
|
||||
for (const key of ['aps-environment', 'com.apple.developer.applesignin', 'NSPhotoLibraryAddUsageDescription', 'NSMicrophoneUsageDescription'])
|
||||
assert.ok(derived.some(c => c.key === key), `missing diary key: ${key}`);
|
||||
console.log(`${checked.size} platform/key/owner writes verified`);
|
||||
});
|
||||
+147
-1
@@ -1,8 +1,9 @@
|
||||
// Info.plist rows that `build.mjs`'s `infoPlist` and `macInfoPlist` write from the manifest, the grants and the app's
|
||||
// packages.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { grantLines } from '../../scripts/grant-advice.mjs';
|
||||
import { appleImplementations } from '../../js/bake/src/typescript.mjs';
|
||||
|
||||
// Python's standard plistlib handles Apple's XML and binary encodings on a Linux builder.
|
||||
@@ -249,3 +250,148 @@ export function withPackageKeys(dict, reach) {
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
|
||||
/** Native tags that must be available before the first frame, from the qualified module roster. */
|
||||
export const firstFrameKeys = (roster, tv = false) => {
|
||||
const tags = Object.keys(roster).filter(tag => tag.includes('-') && roster[tag].firstFrame === true);
|
||||
return tags.length && !tv ? { ExactFirstFrameModules: tags } : {};
|
||||
};
|
||||
|
||||
/** AlarmKit's countdown is a Live Activity on iOS. */
|
||||
export const alarmKeys = (alarm, tv = false) => alarm && !tv ? { NSSupportsLiveActivities: true, ExactAlarmCountdown: true } : {};
|
||||
|
||||
let metadataOwners;
|
||||
/** Known derived keys; only advice exercised through the bake and platform writers names an owner. */
|
||||
export function appleMetadataOwners() {
|
||||
if (metadataOwners) return metadataOwners;
|
||||
const hosts = Object.fromEntries(['ios', 'macos'].map(platform => [platform, { infoPlist: new Map(), entitlements: new Map() }]));
|
||||
const add = (map, keys, owner) => {
|
||||
for (const key of keys) map.set(key, [...new Set([...(map.get(key) ?? []), ...(owner ? [owner] : [])])]);
|
||||
};
|
||||
const groups = [
|
||||
[['ios', 'macos'], ['CFBundleIdentifier']],
|
||||
[['ios', 'macos'], ['CFBundleName', 'CFBundleDisplayName', 'CFBundleExecutable']],
|
||||
[['ios', 'macos'], ['CFBundleShortVersionString']],
|
||||
[['ios', 'macos'], ['CFBundleVersion']],
|
||||
[['ios', 'macos'], ['CFBundlePackageType']],
|
||||
[['ios'], [ 'DTCompiler', 'DTPlatformBuild', 'DTPlatformVersion', 'DTSDKBuild', 'DTSDKName', 'DTXcode', 'DTXcodeBuild', 'BuildMachineOSBuild']],
|
||||
[['ios'], ['CFBundleSupportedPlatforms', 'DTPlatformName', 'CADisableMinimumFrameDurationOnPhone', 'UIApplicationSceneManifest', 'UIRequiredDeviceCapabilities']],
|
||||
[['macos'], ['NSHighResolutionCapable']],
|
||||
[['ios'], ['MinimumOSVersion']],
|
||||
[['macos'], ['LSMinimumSystemVersion']],
|
||||
[['ios'], ['UIDeviceFamily']],
|
||||
[['ios'], ['UISupportedInterfaceOrientations', 'UISupportedInterfaceOrientations~ipad']],
|
||||
[['ios'], ['UIUserInterfaceStyle']],
|
||||
[['macos'], ['ExactUserInterfaceStyle']],
|
||||
[['ios', 'macos'], ['NSAppTransportSecurity']],
|
||||
[['ios'], ['LSApplicationQueriesSchemes']],
|
||||
[['ios'], ['com.apple.developer.nfc.readersession.iso7816.select-identifiers']],
|
||||
[['ios'], ['com.apple.developer.nfc.readersession.felica.systemcodes']],
|
||||
[['ios'], ['UIBackgroundModes']],
|
||||
[['ios', 'macos'], ['ExactModuleConfig']],
|
||||
[['ios'], ['ExactAudioSession']],
|
||||
[['ios', 'macos'], ['CFBundleDocumentTypes', 'UTExportedTypeDeclarations']],
|
||||
[['ios'], ['LSSupportsOpeningDocumentsInPlace', 'UTImportedTypeDeclarations']],
|
||||
[['ios', 'macos'], ['ExactProtocolHandlers', 'CFBundleURLTypes']],
|
||||
[['ios', 'macos'], ['CFBundleURLTypes']],
|
||||
[['ios', 'macos'], ['ExactOpenURLSchemes']],
|
||||
[['ios', 'macos'], ['ExactDevelopmentURLScheme', 'ExactDevelopmentOrigins', 'ExactDevelopmentToken']],
|
||||
[['ios', 'macos'], ['ExactOrigin']],
|
||||
[['ios', 'macos'], ['ExactStartURL']],
|
||||
[['macos'], ['ExactLaunchMode']],
|
||||
[['ios', 'macos'], ['ExactUpdateActivate']],
|
||||
[['ios', 'macos'], ['ExactUpdateCheckInterval']],
|
||||
[['ios', 'macos'], ['LSApplicationCategoryType']],
|
||||
[['ios', 'macos'], ['LSSupportsGameMode']],
|
||||
[['ios'], ['GCSupportsGameMode']],
|
||||
[['ios'], ['UIApplicationShortcutItems']],
|
||||
[['macos'], ['ExactShortcutItems']],
|
||||
[['ios'], ['NSAppClip']],
|
||||
[['ios', 'macos'], ['ExactAppGroup', 'ExactAppIdentifier', 'ExactWidgetKinds', 'ExactWidgetTypeScript', 'ExactWidgetGeneration', 'ExactKeychainAccessGroup']],
|
||||
[['ios'], ['NSSupportsLiveActivities', 'NSSupportsLiveActivitiesFrequentUpdates']],
|
||||
[['macos'], ['ExactWidgetRequiresSecrets']],
|
||||
[['ios', 'macos'], ['ExactFirstFrameModules']],
|
||||
[['macos'], ['LSUIElement']],
|
||||
[['macos'], ['ExactWindow']],
|
||||
[['macos'], ['ExactDisplayOverride']],
|
||||
[['macos'], ['NSAutoFillRequiresTextContentTypeForOneTimeCodeOnMac']],
|
||||
[['macos'], ['CFBundleIconFile']],
|
||||
[['ios'], ['CFBundleIcons', 'CFBundleIcons~ipad']],
|
||||
[['macos'], ['CFBundleIconName']],
|
||||
[['ios', 'macos'], ['NSAccentColorName']],
|
||||
[['ios'], ['UILaunchScreen', 'ExactLaunchColor', 'ExactLaunchImageSource', 'ExactLaunchImageSHA256']],
|
||||
[['ios', 'macos'], ['CFBundleDevelopmentRegion', 'CFBundleLocalizations']],
|
||||
[['ios', 'macos'], ['NSBonjourServices']],
|
||||
[['ios', 'macos'], ['ExactShareTarget', 'ExactShareGroup']],
|
||||
[['ios', 'macos'], ['ExactFrameHealthMode']],
|
||||
[['ios', 'macos'], ['ExactReleaseNotes']],
|
||||
[['ios', 'macos'], ['ExactDevMenu']],
|
||||
[['ios', 'macos'], ['ExactDistributionRevision']],
|
||||
[['ios'], ['ITSAppUsesNonExemptEncryption']],
|
||||
[['ios', 'macos'], ['ExactBuildTime', 'ExactBuildKind', 'ExactBuildHost', 'ExactBuildXcode', 'ExactCommit', 'ExactCommitDirty', 'ExactBranch', 'ExactAppCommit', 'ExactAppCommitDirty', 'ExactAppBranch', 'ExactMetalSources']],
|
||||
[['ios', 'macos'], ['NSLocationTemporaryUsageDescriptionDictionary']],
|
||||
];
|
||||
for (const [platform, { infoPlist }] of Object.entries(hosts)) {
|
||||
for (const [platforms, keys] of groups) if (platforms.includes(platform)) add(infoPlist, keys);
|
||||
for (const [key, { platforms }] of PLIST_KEYS) if (platforms.includes(platform)) add(infoPlist, [key]);
|
||||
}
|
||||
add(hosts.macos.infoPlist, ['ExactWidgetSecretsAvailable']);
|
||||
add(hosts.ios.infoPlist, ['ExactAlarmCountdown', 'NSSupportsLiveActivities']);
|
||||
for (const [platform, keys] of [
|
||||
['ios', ['application-identifier', 'com.apple.developer.team-identifier', 'keychain-access-groups', 'get-task-allow',
|
||||
'com.apple.security.application-groups', 'com.apple.developer.parent-application-identifiers',
|
||||
'com.apple.developer.associated-domains', 'com.apple.developer.web-browser', MEMORY_ENTITLEMENT,
|
||||
'com.apple.developer.applesignin', 'com.apple.developer.in-app-payments']],
|
||||
['macos', ['com.apple.application-identifier', 'com.apple.developer.team-identifier', 'com.apple.security.application-groups',
|
||||
'com.apple.developer.associated-domains', 'keychain-access-groups', 'com.apple.security.app-sandbox',
|
||||
'com.apple.security.network.client', 'com.apple.security.get-task-allow']],
|
||||
]) add(hosts[platform].entitlements, keys);
|
||||
const table = readFileSync(new URL('../../runner/src/device.rs', import.meta.url), 'utf8');
|
||||
const advice = grant => `write ${grantLines([grant], table)[0].map(line => `\`${line}\``).join(' beside ')}`;
|
||||
for (const [, name, row] of table.matchAll(/name: "([^"]+)",([\s\S]*?)\.\.ROW/g)) {
|
||||
const keys = field => [...(new RegExp(`\\b${field}: &\\[([^\\]]*)\\]`).exec(row)?.[1] ?? '').matchAll(/"([^"]+)"/g)].map(m => m[1]);
|
||||
for (const [platform, { infoPlist }] of Object.entries(hosts)) {
|
||||
const usage = keys(platform);
|
||||
const companion = usage.length ? [...row.matchAll(/companion: Some\("([^"\n]+)"\)/g)].map(m => m[1]) : [];
|
||||
add(infoPlist, [...usage, ...companion], advice(`device.${name}`));
|
||||
if (platform === 'ios') add(infoPlist, keys('ios_flags'));
|
||||
}
|
||||
if (keys('ios_modes').length) add(hosts.ios.infoPlist, ['UIBackgroundModes']);
|
||||
if (keys('ios_tasks').length) add(hosts.ios.infoPlist, ['BGTaskSchedulerPermittedIdentifiers']);
|
||||
add(hosts.ios.entitlements, keys('ios_entitlements'));
|
||||
add(hosts.macos.entitlements, [...keys('macos_profile'), ...[...row.matchAll(/hardened: Some\("([^"\n]+)"\)/g)].map(m => m[1])]);
|
||||
}
|
||||
add(hosts.ios.entitlements, ['aps-environment'], advice('device.push'));
|
||||
add(hosts.ios.entitlements, ['com.apple.developer.applesignin'], advice('auth.apple'));
|
||||
const hardened = readFileSync(new URL('../../runner/src/hardened.rs', import.meta.url), 'utf8');
|
||||
add(hosts.macos.entitlements, [...hardened.matchAll(/entitlement: "([^"]+)"/g)].map(m => m[1]));
|
||||
add(hosts.ios.infoPlist, ['WKApplication', 'WKCompanionAppBundleIdentifier', 'WKRunsIndependentlyOfCompanionApp']);
|
||||
const packages = new URL('../../packages/', import.meta.url);
|
||||
for (const directory of readdirSync(packages, { withFileTypes: true })) {
|
||||
if (!directory.isDirectory()) continue;
|
||||
let spec;
|
||||
try { spec = JSON.parse(readFileSync(new URL(`${directory.name}/exact.json`, packages), 'utf8')); }
|
||||
catch (error) { if (error.code === 'ENOENT') continue; throw error; }
|
||||
for (const declaration of spec.names ?? []) for (const [platform, { infoPlist }] of Object.entries(hosts)) {
|
||||
const addDerived = value => {
|
||||
if (Array.isArray(value)) add(infoPlist, value);
|
||||
else if (typeof value === 'string') add(infoPlist, [value]);
|
||||
else if (value && typeof value === 'object') for (const child of Object.values(value)) addDerived(child);
|
||||
};
|
||||
addDerived(declaration.derives?.[platform]);
|
||||
}
|
||||
}
|
||||
return metadataOwners = hosts;
|
||||
}
|
||||
|
||||
/** Refusal-only manifest dictionaries: name every supplied key, including keys no writer owns yet. */
|
||||
export function appleMetadataProblem(problem) {
|
||||
const match = /^(host\.(ios|macos)\.(infoPlist|entitlements))\.([\s\S]*): not a known key$/.exec(problem);
|
||||
if (!match) return problem;
|
||||
const [, path, platform, kind, key] = match;
|
||||
const owners = appleMetadataOwners()[platform][kind].get(key);
|
||||
const advice = owners?.length ? (owners.length > 1 ? `choose one: ${owners.join(' or ')}` : owners[0]) : owners
|
||||
? 'Exact writes this key itself from its declarations (docs/reference.md#apple-bundle-metadata), so it cannot be set here'
|
||||
: `not supported on ${platform}: Exact does not write this key; file a \`bun exact.mjs feedback note\` from the app directory naming ${JSON.stringify(key)} and what the native module needs`;
|
||||
return `${path}.${key}: refused; ${advice}`;
|
||||
}
|
||||
@@ -6,8 +6,8 @@ import assert from 'node:assert/strict';
|
||||
import { cpSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { resolve } from 'node:path';
|
||||
import { PLIST_KEYS, modulePlist, packageKeys } from './plist.mjs';
|
||||
import { infoPlist, macInfoPlist } from './build.mjs';
|
||||
import { PLIST_KEYS, appleMetadataProblem, modulePlist, packageKeys } from './plist.mjs';
|
||||
import { entitlements, infoPlist, macInfoPlist } from './build.mjs';
|
||||
import { buildPainted } from '../apple-painted/build.mjs';
|
||||
|
||||
const packages = resolve(import.meta.dir, '../../packages');
|
||||
@@ -16,6 +16,47 @@ const impl = (plistKeys) => [{ extension: 'purchases', plistKeys }];
|
||||
const flat = (plist) => plist.replace(/>\s+</g, '><');
|
||||
const KEY = /<key>SKIncludeConsumableInAppPurchaseHistory<\/key><true\/>/;
|
||||
|
||||
test('location metadata advice chooses one grant and keeps prerequisites beside it', () => {
|
||||
const key = 'NSLocationWhenInUseUsageDescription';
|
||||
for (const platform of ['ios', 'macos']) {
|
||||
const path = `host.${platform}.infoPlist.${key}`;
|
||||
const background = platform === 'macos'
|
||||
? ' or write `device.geolocation-background purpose.<key>` beside `device.geolocation purpose.<key>`' : '';
|
||||
assert.equal(appleMetadataProblem(`${path}: not a known key`),
|
||||
`${path}: refused; choose one: write \`device.geolocation purpose.<key>\` or write \`device.geolocation-approximate purpose.<key>\`${background}`);
|
||||
}
|
||||
const path = 'host.ios.infoPlist.NSLocationAlwaysAndWhenInUseUsageDescription';
|
||||
assert.equal(appleMetadataProblem(`${path}: not a known key`),
|
||||
`${path}: refused; write \`device.geolocation-background purpose.<key>\` beside \`device.geolocation purpose.<key>\``);
|
||||
});
|
||||
|
||||
test('metadata refusals reserve specific advice for proven grants', () => {
|
||||
const refusal = (platform, kind, key) => appleMetadataProblem(`host.${platform}.${kind}.${key}: not a known key`);
|
||||
const generic = [
|
||||
['ios', 'entitlements', 'com.apple.developer.associated-domains'],
|
||||
['macos', 'entitlements', 'com.apple.developer.associated-domains'],
|
||||
['macos', 'entitlements', 'com.apple.application-identifier'],
|
||||
['macos', 'entitlements', 'com.apple.developer.team-identifier'],
|
||||
['ios', 'entitlements', 'keychain-access-groups'],
|
||||
...['ios', 'macos'].map(p => [p, 'entitlements', 'com.apple.security.application-groups']),
|
||||
...['ExactAppGroup', 'ExactAppIdentifier', 'ExactWidgetKinds', 'ExactWidgetTypeScript', 'ExactWidgetGeneration', 'ExactKeychainAccessGroup'].map(k => ['ios', 'infoPlist', k]),
|
||||
...['allow-jit', 'allow-unsigned-executable-memory', 'disable-library-validation'].map(k => ['macos', 'entitlements', `com.apple.security.cs.${k}`]),
|
||||
['ios', 'infoPlist', 'ExactCapturesInBackground'],
|
||||
['ios', 'infoPlist', 'UIBackgroundModes'],
|
||||
['macos', 'infoPlist', 'ExactProtocolHandlers'],
|
||||
['ios', 'infoPlist', 'NSLocationTemporaryUsageDescriptionDictionary'],
|
||||
];
|
||||
const failures = generic.filter(([platform, kind, key]) => !/refused; Exact writes this key itself from its declarations.*docs\/reference.md.*cannot be set here/.test(refusal(platform, kind, key)));
|
||||
assert.deepEqual(failures, []);
|
||||
for (const [key, line] of [
|
||||
['aps-environment', 'device.push` beside `device.notifications purpose.<key>'],
|
||||
['com.apple.developer.applesignin', 'auth.apple <client id>'],
|
||||
]) assert.ok(refusal('ios', 'entitlements', key).includes(line));
|
||||
for (const key of ['ExactAudioSession', 'NSSupportsLiveActivities', 'NSSupportsLiveActivitiesFrequentUpdates'])
|
||||
assert.match(refusal('macos', 'infoPlist', key), /feedback note/);
|
||||
assert.match(refusal('ios', 'infoPlist', 'vendor.key'), /feedback note.*vendor.key/);
|
||||
});
|
||||
|
||||
test.skipIf(process.platform !== 'darwin')('the painted Café bundle agrees with its plist and verifies after signing', () => {
|
||||
const dir = mkdtempSync(resolve(tmpdir(), 'exact-painted-sign-'));
|
||||
const name = dir.split('/').at(-1), root = resolve(import.meta.dir, '../..');
|
||||
|
||||
+10
-6
@@ -20,6 +20,15 @@ export function watchEntries(app) {
|
||||
return false;
|
||||
});
|
||||
}
|
||||
/** The companion's own Info.plist, never the iOS app's. */
|
||||
export function watchInfo(app, { appInfo, group, entries, live, generation, icon = {} }) {
|
||||
return { CFBundleIdentifier: `${app.id}.watchkitapp`, CFBundleName: app.displayName, CFBundleDisplayName: app.displayName, CFBundleExecutable: 'ExactWatch', CFBundlePackageType: 'APPL',
|
||||
CFBundleVersion: appInfo.CFBundleVersion, CFBundleShortVersionString: appInfo.CFBundleShortVersionString, CFBundleSupportedPlatforms: ['WatchSimulator'], MinimumOSVersion: '10.0', UIDeviceFamily: [4],
|
||||
WKApplication: true, WKCompanionAppBundleIdentifier: app.id, WKRunsIndependentlyOfCompanionApp: true,
|
||||
ExactAppGroup: group, ExactAppIdentifier: app.id, ExactWidgetTypeScript: false, ExactWidgetGeneration: generation,
|
||||
ExactWatchEntries: entries.map(({ kind, families, title }) => ({ kind, families, title: title ?? kind })),
|
||||
...(live ? { ExactWatchPlan: 'plans/watch.plan' } : {}), ...icon };
|
||||
}
|
||||
export function watchSimulator(args) {
|
||||
const at = args.indexOf('--watch-sim');
|
||||
if (at < 0) return undefined;
|
||||
@@ -93,12 +102,7 @@ export async function buildWatch(app, args, { runApple, appleArtifacts, appleBui
|
||||
for (const name of ['assets', 'deck']) copyStaticTreeIfPresent(resolve(app.dir, name), resolve(bundle, name));
|
||||
const appInfo = JSON.parse(runApple('plutil', ['-convert', 'json', '-o', '-', resolve(parent.bundle, 'Info.plist')], { read: true }).stdout);
|
||||
const icon = appleAssets(app, bundle, { platform: 'watchos', kept: { dir: resolve(directory, 'assets'), stamp: swiftc } });
|
||||
const info = { CFBundleIdentifier: watch.id, CFBundleName: app.displayName, CFBundleDisplayName: app.displayName, CFBundleExecutable: 'ExactWatch', CFBundlePackageType: 'APPL',
|
||||
CFBundleVersion: appInfo.CFBundleVersion, CFBundleShortVersionString: appInfo.CFBundleShortVersionString, CFBundleSupportedPlatforms: ['WatchSimulator'], MinimumOSVersion: '10.0', UIDeviceFamily: [4],
|
||||
WKApplication: true, WKCompanionAppBundleIdentifier: app.id, WKRunsIndependentlyOfCompanionApp: true,
|
||||
ExactAppGroup: group, ExactAppIdentifier: app.id, ExactWidgetTypeScript: false, ExactWidgetGeneration: createHash('sha256').update(JSON.stringify([baked.compat.id, baked.graph.artifacts])).digest('hex'),
|
||||
ExactWatchEntries: entries.map(({ kind, families, title }) => ({ kind, families, title: title ?? kind })),
|
||||
...(live ? { ExactWatchPlan: 'plans/watch.plan' } : {}), ...icon };
|
||||
const info = watchInfo(app, { appInfo, group, entries, live, generation: createHash('sha256').update(JSON.stringify([baked.compat.id, baked.graph.artifacts])).digest('hex'), icon });
|
||||
writeFileSync(resolve(bundle, 'Info.plist'), plistFile(info));
|
||||
const executableArgs = [...swiftArgs, '-Xlinker', '-rpath', '-Xlinker', '@executable_path/Frameworks',
|
||||
'-Xlinker', '-sectcreate', '-Xlinker', '__TEXT', '-Xlinker', '__entitlements', '-Xlinker', entitlementPath];
|
||||
|
||||
+14
-1
@@ -78,6 +78,19 @@ export function macWidgetSigning(app, identity, { distribution = false, log = co
|
||||
}
|
||||
return { team, profiles: macWidgetProfiles({ app, team, distribution, log, ...profiles }) };
|
||||
}
|
||||
/** Shared widget metadata on the owning app; signing supplies the group's and Keychain's identities. */
|
||||
export function widgetAppKeys(app, platform, { group, keychain, generation }) {
|
||||
return { ExactAppGroup: group, ExactAppIdentifier: app.id,
|
||||
ExactWidgetKinds: platformWidgetEntries(app, platform).map(entry => entry.kind),
|
||||
ExactWidgetTypeScript: existsSync(resolve(app.dir, 'app.ts')), ExactWidgetGeneration: generation,
|
||||
...(platform === 'macos' ? { ExactWidgetRequiresSecrets: platformWidgetEntries(app, 'macos').filter(entry => entry.requiresSecrets).map(entry => entry.kind) } : {}),
|
||||
...(keychain ? { ExactKeychainAccessGroup: keychain } : {}) };
|
||||
}
|
||||
|
||||
/** ExactWidgetSecretsAvailable is extension-only; ExactWidgetRequiresSecrets is copied from the app. */
|
||||
export const widgetExtensionKeys = appInfo => ({ ExactWidgetSecretsAvailable: !!appInfo.ExactKeychainAccessGroup,
|
||||
ExactWidgetRequiresSecrets: appInfo.ExactWidgetRequiresSecrets ?? [] });
|
||||
|
||||
/** `extra`: the app's own profile entitlements (presence.mjs `macProvisioning`), signed under the same profile. */
|
||||
export function macWidgetEntitlements({ app, team, profiles = null, extension = false, extra = {} }) {
|
||||
const id = extension ? widgetId(app) : app.id;
|
||||
@@ -413,7 +426,7 @@ export function packageWidgets({ app, bundle, shared, directory, sdk, triple, de
|
||||
const entitlementPath = resolve(directory, 'widget-entitlements.plist');
|
||||
writeFileSync(entitlementPath, entitlements);
|
||||
writeFileSync(resolve(extensionContents, 'Info.plist'), plistFile({ CFBundleIdentifier: widgetId(app), CFBundleName: 'ExactWidgets', CFBundleDisplayName: app.displayName, CFBundleExecutable: 'ExactWidgets', CFBundlePackageType: 'XPC!', ...versions, ...(!mac ? { UIDeviceFamily: appInfo.UIDeviceFamily } : {}), ...capabilities,
|
||||
CFBundleSupportedPlatforms: mac ? ['MacOSX'] : appInfo.CFBundleSupportedPlatforms, ...(appInfo.DTPlatformName ? { DTPlatformName: appInfo.DTPlatformName } : {}), ...(appInfo.NSAppTransportSecurity ? { NSAppTransportSecurity: appInfo.NSAppTransportSecurity } : {}), ExactAppGroup: appInfo.ExactAppGroup ?? widgetGroup(app), ExactAppIdentifier: appInfo.ExactAppIdentifier ?? app.id, ExactWidgetTypeScript: platform !== 'watchos' && existsSync(resolve(app.dir, 'app.ts')), ...(appInfo.ExactKeychainAccessGroup ? { ExactKeychainAccessGroup: appInfo.ExactKeychainAccessGroup } : {}), ...(mac ? { ExactWidgetSecretsAvailable: !!appInfo.ExactKeychainAccessGroup, ExactWidgetRequiresSecrets: appInfo.ExactWidgetRequiresSecrets ?? [] } : {}), ExactWidgetGeneration: appInfo.ExactWidgetGeneration, ...(typeof app.origin === 'string' ? { ExactLinkBase: app.origin } : {}),
|
||||
CFBundleSupportedPlatforms: mac ? ['MacOSX'] : appInfo.CFBundleSupportedPlatforms, ...(appInfo.DTPlatformName ? { DTPlatformName: appInfo.DTPlatformName } : {}), ...(appInfo.NSAppTransportSecurity ? { NSAppTransportSecurity: appInfo.NSAppTransportSecurity } : {}), ExactAppGroup: appInfo.ExactAppGroup ?? widgetGroup(app), ExactAppIdentifier: appInfo.ExactAppIdentifier ?? app.id, ExactWidgetTypeScript: platform !== 'watchos' && existsSync(resolve(app.dir, 'app.ts')), ...(appInfo.ExactKeychainAccessGroup ? { ExactKeychainAccessGroup: appInfo.ExactKeychainAccessGroup } : {}), ...(mac ? widgetExtensionKeys(appInfo) : {}), ExactWidgetGeneration: appInfo.ExactWidgetGeneration, ...(typeof app.origin === 'string' ? { ExactLinkBase: app.origin } : {}),
|
||||
NSExtension: { NSExtensionPointIdentifier: 'com.apple.widgetkit-extension' } }));
|
||||
// A configured entry's intent reaches the Edit Widget sheet through the extension's App Intents metadata, which
|
||||
// Xcode's pass extracts from the module's constant values (`intents.mjs`).
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# An iOS build cannot package a notification service extension
|
||||
|
||||
**Status:** Open
|
||||
**Systems:** host/apple, notifications, native modules
|
||||
**Author:** Codex, from the push diary reported in the related issue
|
||||
**Date:** 2026-11-13
|
||||
**Related:** issues/20261113-ios-app-cannot-declare-entitlements-or-plist-keys.md
|
||||
|
||||
## What is wrong
|
||||
|
||||
An ordinary iOS build replaces an app bundle to which its builder added a
|
||||
notification service extension. The rebuilt bundle has no such extension.
|
||||
The builder uses a wrapper that builds, packages and reinstalls the patched
|
||||
app in one step. The diary does not say what the extension does.
|
||||
|
||||
`device.push` derives the app's APNs entitlement; it does not package a
|
||||
notification service extension. `host/apple/extensions.mjs` and
|
||||
`host/apple/widgets.mjs` package the admitted extensions, while
|
||||
`rules/DEFERRED.md`'s notifications entry explicitly excludes a notification
|
||||
service extension. Extra Info.plist keys or entitlements alone cannot create
|
||||
its executable, bundle, provisioning or signing step.
|
||||
|
||||
## Decision needed
|
||||
|
||||
Obtain the extension's purpose and the notification behaviour the app needs
|
||||
from its builder. Decide that capability's admission and packaging before
|
||||
adding an extension target. The metadata refusals and recipe fixes in the
|
||||
related issue leave this request unresolved.
|
||||
@@ -47,10 +47,15 @@ Three reports on macOS:
|
||||
|
||||
## Fix
|
||||
|
||||
Let `app.json` declare, under `host.ios`, entitlements (checked against the signing profile as
|
||||
`widgets.mjs` checks the App Group and push ones), extra Info.plist keys, and files placed at the
|
||||
bundle root (`PrivacyInfo.xcprivacy`), each merged into what Exact derives and refused where they
|
||||
conflict.
|
||||
Under LLP 1069.008 D4 (amended 2026-11-11), entitlements remain derived from grant lines
|
||||
and named manifest switches. `host.ios.infoPlist`, `host.macos.infoPlist`,
|
||||
`host.ios.entitlements` and `host.macos.entitlements` refuse every supplied key,
|
||||
giving tested grant advice, identifying a key Exact derives, or asking for a
|
||||
feedback note when Exact never writes it. No dictionary value is merged into a bundle.
|
||||
|
||||
An app supplies `PrivacyInfo.xcprivacy` beside `app.json`; the build merges it
|
||||
before signing. Admission of an unowned Info.plist key waits for a diary naming
|
||||
one. The notification service extension remains a separate, open issue.
|
||||
|
||||
## Verified and still open
|
||||
|
||||
@@ -61,9 +66,14 @@ named-switch paths (`host/apple/build.mjs`, `host/apple/presence.mjs`,
|
||||
`runner/src/device.rs`). Package plist declarations compose through
|
||||
`host/apple/plist.mjs`.
|
||||
|
||||
The remaining app metadata requests need a design decision. LLP 1069.008 D3-D4
|
||||
excludes arbitrary entitlement dictionaries; the notification service extension
|
||||
is outside the visible-push admission (`rules/DEFERRED.md`, notifications).
|
||||
Those boundaries do not resolve the reported need for native code that has no
|
||||
admitted grant or switch. No generic dictionary is added here. The issue stays
|
||||
open for a concrete capability admission or an amendment to that boundary.
|
||||
LLP 1069.008 D4 keeps entitlements derived. `host.ios.infoPlist`,
|
||||
`host.macos.infoPlist`, `host.ios.entitlements` and `host.macos.entitlements` now
|
||||
refuse every supplied key in one diagnostic. Tested grant lines include any
|
||||
companion grants. Other derived keys point to the bundle metadata reference;
|
||||
a key Exact never writes asks for a feedback note naming it. The native-module and app-json
|
||||
recipes explain the grant path, including `device.photos-add`.
|
||||
|
||||
Admission of unowned Info.plist keys still needs a decision informed by a diary
|
||||
naming one, so this issue stays open. The notification service extension is tracked
|
||||
separately in `issues/20261113-an-ios-build-cannot-package-a.md`; it remains outside
|
||||
the visible-push admission (`rules/DEFERRED.md`, notifications).
|
||||
@@ -5,6 +5,38 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
test('Apple metadata dictionaries refuse every key together with grant or generic advice', async () => {
|
||||
const { readManifest } = await import('./app.mjs');
|
||||
const dir = mkdtempSync(resolve(tmpdir(), 'manifest-apple-metadata-'));
|
||||
const save = host => writeFileSync(resolve(dir, 'app.json'), JSON.stringify({
|
||||
name: 'Metadata', app: { id: 'dev.exact.metadata', name: 'Metadata' }, host,
|
||||
}));
|
||||
try {
|
||||
save({ ios: { infoPlist: { NSPhotoLibraryAddUsageDescription: 'Save a photo', VendorKey: true },
|
||||
entitlements: { 'aps-environment': 'development', 'com.apple.developer.applesignin': ['Default'], 'vendor.claim': true } },
|
||||
macos: { infoPlist: { NSMicrophoneUsageDescription: 'Record', LSUIElement: true }, entitlements: { 'com.apple.security.device.audio-input': true, 'com.apple.developer.aps-environment': 'development' } } });
|
||||
assert.throws(() => readManifest(dir, 'metadata'), error => {
|
||||
for (const text of [
|
||||
'host.ios.infoPlist.NSPhotoLibraryAddUsageDescription: refused; write `device.photos-add purpose.<key>`',
|
||||
'host.ios.entitlements.aps-environment: refused; write `device.push` beside `device.notifications purpose.<key>`',
|
||||
'com.apple.developer.applesignin: refused; write `auth.apple <client id>`',
|
||||
'host.macos.infoPlist.NSMicrophoneUsageDescription: refused; write `device.microphone purpose.<key>`',
|
||||
'LSUIElement: refused; Exact writes this key itself from its declarations',
|
||||
'host.macos.entitlements.com.apple.security.device.audio-input: refused; Exact writes this key itself from its declarations',
|
||||
'host.macos.entitlements.com.apple.developer.aps-environment: refused; Exact writes this key itself from its declarations',
|
||||
'feedback note', 'VendorKey', 'vendor.claim',
|
||||
]) assert.ok(error.message.includes(text), text + '\n' + error.message);
|
||||
return true;
|
||||
});
|
||||
for (const value of [null, [], true, 'key']) {
|
||||
save({ ios: { infoPlist: value, entitlements: value }, macos: { infoPlist: value, entitlements: value } });
|
||||
assert.throws(() => readManifest(dir, 'metadata'), /expected object/);
|
||||
}
|
||||
save({ ios: { infoPlist: {}, entitlements: {} }, macos: { infoPlist: {}, entitlements: {} } });
|
||||
assert.deepEqual(readManifest(dir, 'metadata').host.ios.infoPlist, {});
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('a widget declares fixed enum configuration choices', async () => {
|
||||
const { readManifest } = await import('./app.mjs');
|
||||
const dir = mkdtempSync(resolve(tmpdir(), 'widget-configuration-'));
|
||||
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
import { capturePrivacyManifest } from '../host/apple/privacy.mjs';
|
||||
import { appleMetadataProblem } from '../host/apple/plist.mjs';
|
||||
import { appleImplementations } from '../js/bake/src/typescript.mjs';
|
||||
import { webHostFiles, hostModuleSources, MODULE_PRELUDE } from '../host/web-js/glue.mjs';
|
||||
export { webHostFiles } from '../host/web-js/glue.mjs';
|
||||
@@ -577,7 +578,7 @@ export function readManifest(dir, name) {
|
||||
: /^background_color_dark: /.test(p) ? 'background_color_dark is now color_scheme_dark.background_color: "color_scheme_dark": { "background_color": "#000000" } (LLP 1163 D2)'
|
||||
// Expo's splash keys and a host's own launch colour: the launch is the manifest's one colour on every host.
|
||||
: /^((host\.\w+\.)?(splash\w*|windowBackground|backgroundColor|launchColor)): not a known key$/i.test(p) ? `${p.split(':')[0]}: not a known key; what shows before the first frame is background_color, and color_scheme_dark.background_color in a dark scheme, on every host (host.ios.launchImage adds a picture on iOS)`
|
||||
: p).filter((p) => !/^host\.web\.(head(\[|: )|csp|cspReportOnly)/.test(p)).concat(webHeadProblems(parsed), webCspProblems(parsed));
|
||||
: appleMetadataProblem(p)).filter((p) => !/^host\.web\.(head(\[|: )|csp|cspReportOnly)/.test(p)).concat(webHeadProblems(parsed), webCspProblems(parsed));
|
||||
if (!problems.length) problems.push(...installProblems(parsed), ...gpuModuleProblems(parsed), ...documentTypeProblems(parsed), ...shareTargetProblems(parsed), ...protocolHandlerProblems(parsed), ...appIconProblems(parsed, dir), ...launchImageProblems(parsed, dir));
|
||||
if (problems.length) throw new Error(`${path} does not conform to scripts/app.schema.json:\n ${problems.join('\n ')}`);
|
||||
nativeResourceMappings(parsed); nativeResourceMappings(parsed, 'ios');
|
||||
|
||||
@@ -743,6 +743,16 @@
|
||||
"description": "iOS: `minimumOS` (default 17.0), `deviceFamily` (iphone, ipad), and the keys below.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"infoPlist": {
|
||||
"description": "No keys are admitted. Each refusal gives tested grant advice, points to the bundle metadata reference for a derived key, or asks for a feedback note naming a key Exact does not write.",
|
||||
"type": "object",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"entitlements": {
|
||||
"description": "No keys are admitted. Each refusal gives tested grant advice, points to the bundle metadata reference for a derived key, or asks for a feedback note naming a key Exact does not write.",
|
||||
"type": "object",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"resources": {
|
||||
"description": "Resource trees a data module reads through `bundle:/` (LLP 1133.007 D6), on iOS, iPadOS and tvOS: copied, modes kept, into the bundle's `ExactResources/` folder before it is signed, which then holds them and nothing else. Read only, through `fs.read bundle:/<to>` (`fs.read bundle:/Resources/data-files`), the line the Mac's `host.macos.resources` takes for the same `to`. A tree holds no links and no Mach-O code; changing it requires a new binary, never an update.",
|
||||
"type": "array",
|
||||
@@ -905,6 +915,16 @@
|
||||
"description": "macOS: `minimumOS` (default 14.0), the first window's size, and the keys below.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"infoPlist": {
|
||||
"description": "No keys are admitted. Each refusal gives tested grant advice, points to the bundle metadata reference for a derived key, or asks for a feedback note naming a key Exact does not write.",
|
||||
"type": "object",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"entitlements": {
|
||||
"description": "No keys are admitted. Each refusal gives tested grant advice, points to the bundle metadata reference for a derived key, or asks for a feedback note naming a key Exact does not write.",
|
||||
"type": "object",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"presenter": {
|
||||
"enum": ["native", "painted"],
|
||||
"description": "Absent: native. Painted builds the app’s <macos-crate>-painted binary as a benchmark carrier; distribution is refused until LLP 1149’s accessibility gate."
|
||||
|
||||
Reference in new issue
Block a user