@inglorious/capacitor 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,8 +4,9 @@
4
4
 
5
5
  Native project setup for [Capacitor](https://capacitorjs.com) apps, written once in `package.json` instead of clicked through Xcode and Android Studio. `@inglorious/create-app --mobile` sets it up for you.
6
6
 
7
- - `inglorious-capacitor configure` writes the app's native settings into `android/` and `ios/` after every `cap sync`: App Links and Universal Links, the signing team, push, Analytics off until consent, permissions and Info.plist entries, and the Firebase files.
7
+ - `inglorious-capacitor configure` writes the app's native settings into `android/` and `ios/` after every `cap sync`: App Links and Universal Links, the signing team, push, Analytics off until consent and without the advertising ID, permissions and Info.plist entries, and the Firebase files.
8
8
  - `inglorious-capacitor assets` makes every icon and splash screen, light and dark, from one logo.
9
+ - `inglorious-capacitor listing-assets` captures store screenshots of the running app in every size and language, and makes the promotional images (Google Play's feature graphic, a header and a search results card) and Google Play's 512px icon.
9
10
 
10
11
  ## Installation
11
12
 
@@ -21,7 +22,8 @@ Run `configure` as Capacitor's `sync` hook, so `cap add` and `cap sync` both app
21
22
  "capacitor:sync:after": "inglorious-capacitor configure",
22
23
  "mobile:init:ios": "cap add ios && inglorious-capacitor assets --ios",
23
24
  "mobile:init:android": "cap add android && inglorious-capacitor assets --android",
24
- "mobile:assets": "inglorious-capacitor assets"
25
+ "mobile:assets": "inglorious-capacitor assets",
26
+ "mobile:listing-assets": "inglorious-capacitor listing-assets"
25
27
  }
26
28
  }
27
29
  ```
@@ -63,20 +65,37 @@ The `native` key of `package.json` holds them all. Every key is optional; the ap
63
65
 
64
66
  ### What `configure` does
65
67
 
66
- | Setting | iOS | Android |
67
- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------ |
68
- | `appLinks` | Associated Domains entitlement | Verified intent filter for `paths` |
69
- | `appLinks` + `teamId` | `public/.well-known/apple-app-site-association` | |
70
- | `appLinks` + `sha256` | | `public/.well-known/assetlinks.json` |
71
- | `ios.teamId` | `DEVELOPMENT_TEAM`, so Xcode signs without asking | |
72
- | `ios.swipeBack` | A swipe from the left edge goes back: WebKit's back gesture, turned on in a generated `MainViewController` | |
73
- | `ios.infoPlist` | Info.plist entries | |
74
- | `android.permissions` | | `<uses-permission>` entries |
75
- | `@capacitor-firebase/messaging` installed | Push entitlement, background mode, AppDelegate methods | |
76
- | `@capacitor-firebase/analytics` installed | Collection off until consent | Collection off until consent |
77
- | `GoogleService-Info.plist` in the root | Copied into the app target | |
78
- | `google-services.json` in the root | | Copied to `android/app/` |
79
- | always | | Keystores kept out of git |
68
+ | Setting | iOS | Android |
69
+ | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
70
+ | `appLinks` | Associated Domains entitlement | Verified intent filter for `paths` |
71
+ | `appLinks` + `teamId` | `public/.well-known/apple-app-site-association` | |
72
+ | `appLinks` + `sha256` | | `public/.well-known/assetlinks.json` |
73
+ | `ios.teamId` | `DEVELOPMENT_TEAM`, so Xcode signs without asking | |
74
+ | `ios.swipeBack` | A swipe from the left edge goes back: WebKit's back gesture, turned on in a generated `MainViewController` | |
75
+ | `ios.infoPlist` | Info.plist entries | |
76
+ | `ios.privacyManifest` | `PrivacyInfo.xcprivacy` entries, with Apple's keys | |
77
+ | `@capawesome/capacitor-live-update` installed | `PrivacyInfo.xcprivacy` declaring its use of `UserDefaults`, which it doesn't declare itself | |
78
+ | `android.permissions` | | `<uses-permission>` entries |
79
+ | `@capacitor-firebase/messaging` installed | Push entitlement, background mode, AppDelegate methods | |
80
+ | `@capacitor-firebase/analytics` installed | Collection off until consent | Collection off until consent, and no advertising ID permission |
81
+ | `GoogleService-Info.plist` in the root | Copied into the app target | |
82
+ | `google-services.json` in the root | | Copied to `android/app/` |
83
+ | always | | Keystores kept out of git |
84
+
85
+ The privacy manifest is merged with the one already in the project, so entries added by hand stay. Apple refuses a build that calls a required-reason API without a declared reason; `configure` declares the ones of known plugins that ship no manifest of their own, and `ios.privacyManifest` adds the app's:
86
+
87
+ ```json
88
+ "privacyManifest": {
89
+ "NSPrivacyAccessedAPITypes": [
90
+ {
91
+ "NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryUserDefaults",
92
+ "NSPrivacyAccessedAPITypeReasons": ["CA92.1"]
93
+ }
94
+ ]
95
+ }
96
+ ```
97
+
98
+ A file input that takes images (a `Button` with `accept="image/*"`) offers the camera on iOS, which closes the app unless `ios.infoPlist` has an `NSCameraUsageDescription`.
80
99
 
81
100
  Path patterns end in `*` to match a prefix: `/jobs*` is every path that starts with `/jobs`. `exclude` takes paths and, starting with `#`, fragments; only iOS can exclude, so on Android list in `paths` only what opens the app. Serve both `.well-known` files as `application/json`.
82
101
 
@@ -86,6 +105,70 @@ It runs for the platform Capacitor is syncing, and changes nothing when the sett
86
105
 
87
106
  It centres the logo (`public/logo.svg` or `public/logo.png` by default) on `background` for the icons and the splash screen, and on `backgroundDark` for the dark splash screen, then hands the result to [`@capacitor/assets`](https://github.com/ionic-team/capacitor-assets) for every size and density. `iconScale` and `splashScale` are the logo's width as a share of the icon and of the splash screen. Pass `--ios` or `--android` for one platform.
88
107
 
108
+ ## Store listing images
109
+
110
+ `listing-assets` opens the running app in your installed Chrome (through `playwright-core`, so no browser download) and captures the screens listed in `listing.config.js`, once per device and language:
111
+
112
+ ```js
113
+ /** @type {import("@inglorious/capacitor").ListingConfig} */
114
+ export default {
115
+ url: "http://localhost:5173",
116
+ locales: ["en", "it"],
117
+ localeParam: "lang",
118
+ storage: { "app-analytics": { consent: "denied" } },
119
+
120
+ async signIn(page, { url }) {
121
+ await page.goto(`${url}/login`)
122
+ await page.fill('input[type="email"]', "demo@example.com")
123
+ await page.keyboard.press("Enter")
124
+ await page.fill('input[type="password"]', "demo-password")
125
+ await page.keyboard.press("Enter")
126
+ await page.waitForURL((address) => address.pathname !== "/login")
127
+ },
128
+
129
+ screens: [
130
+ { name: "01-feed", path: "/jobs" },
131
+ {
132
+ name: "02-new-job",
133
+ path: "/jobs/new",
134
+ prepare: (page) => page.getByLabel("Title").fill("Wedding in Puglia"),
135
+ },
136
+ ],
137
+
138
+ promo: {
139
+ tagline: { en: "Can't make it?\nPass it on.", it: "Non puoi?\nPassalo." },
140
+ subtitle: { en: "Jobs between colleagues.", it: "Lavori tra colleghi." },
141
+ accent: "#b4532a",
142
+ },
143
+ }
144
+ ```
145
+
146
+ It writes to `listing-assets/`:
147
+
148
+ - `<device>/<locale>/<screen>.png` for each device in `devices`:
149
+
150
+ | Device | Size | For |
151
+ | ------------ | --------- | ------------------------------------------- |
152
+ | `iphone-6.9` | 1320×2868 | App Store, required (default) |
153
+ | `iphone-6.3` | 1206×2622 | App Store, optional |
154
+ | `ipad-13` | 2064×2752 | App Store, required if the app runs on iPad |
155
+ | `android` | 1080×1920 | Google Play phones (default) |
156
+ | `tablet-7` | 1080×1920 | Google Play 7" tablets |
157
+ | `tablet-10` | 1440×2560 | Google Play 10" tablets |
158
+
159
+ - the promotional images for each locale, on `native.assets.background` unless `promo.background` says otherwise, with `promo.color`, `promo.font` and an optional `promo.accent` bar:
160
+ - `feature-graphic-<locale>.png`, 1024×500 (Google Play): the logo beside the app's name and `promo.tagline`.
161
+ - `header-<locale>.png`, 3840×1646: the logo, name and tagline in the middle 3:2 an iPhone shows, between two framed screenshots.
162
+ - `search-results-<locale>.png`, 1920×1280: the logo, name, tagline and `promo.subtitle` beside one framed screenshot.
163
+
164
+ The framed screenshots are `promo.screens` (the first two `screens` by default) from `promo.device` (the first iPhone by default).
165
+
166
+ - `icon-512.png`: Google Play's icon, made like the app icon.
167
+
168
+ `storage` sets `localStorage` before the app starts, so prompts such as analytics consent or push stay out of the shots; it can be a function of the locale. `localeParam` adds the language to each screen's address. If the app takes the language from the signed-in account instead, choose it in `signIn`. `prepare` brings a screen to the state worth showing, after it loads and settles for `settle` milliseconds (1000 by default).
169
+
170
+ Screenshots show real data, so use a demo account on a local backend with realistic seed data, never production.
171
+
89
172
  ## License
90
173
 
91
174
  **MIT License - Free and open source**
@@ -6,7 +6,9 @@ const USAGE = `Usage: inglorious-capacitor <command>
6
6
  configure Apply the "native" settings in package.json to the
7
7
  native projects (runs after every cap sync)
8
8
  assets [--ios] [--android]
9
- Make the app icons and splash screens from the logo`
9
+ Make the app icons and splash screens from the logo
10
+ listing-assets Capture store screenshots of the running app, and make
11
+ Google Play's feature graphic and icon (listing.config.js)`
10
12
 
11
13
  const FIRST_ARGUMENT = 2
12
14
  const SUCCESS = 0
@@ -22,6 +24,9 @@ try {
22
24
  } else if (command === "assets") {
23
25
  const { assets } = await import("../src/assets/index.js")
24
26
  await assets({ platforms: flags.map((flag) => flag.replace(/^--/, "")) })
27
+ } else if (command === "listing-assets") {
28
+ const { listingAssets } = await import("../src/listing-assets/index.js")
29
+ console.log(`Listing assets in ${await listingAssets()}`)
25
30
  } else {
26
31
  console.log(USAGE)
27
32
  process.exitCode = command ? FAILURE : SUCCESS
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@inglorious/capacitor",
3
- "version": "0.1.1",
4
- "description": "Configures Capacitor's native projects from package.json, and makes their icons and splash screens from one logo.",
3
+ "version": "0.3.0",
4
+ "description": "Configures Capacitor's native projects from package.json, makes their icons and splash screens from one logo, and the screenshots and graphics the stores ask for.",
5
5
  "author": "Matteo Antony Mistretta <antony.mistretta@gmail.com> (https://ingloriouscoderz.it)",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -19,7 +19,8 @@
19
19
  "app-links",
20
20
  "icons",
21
21
  "splash-screen",
22
- "inglorious-web"
22
+ "inglorious-web",
23
+ "store-listing"
23
24
  ],
24
25
  "type": "module",
25
26
  "bin": {
@@ -42,6 +43,7 @@
42
43
  "dependencies": {
43
44
  "@capacitor/assets": "^4.0.0",
44
45
  "@trapezedev/project": "^7.1.11",
46
+ "playwright-core": "^1.65.0",
45
47
  "plist": "^3.1.1",
46
48
  "sharp": "^0.35.5"
47
49
  },
@@ -16,7 +16,7 @@ const SOURCES = path.join(
16
16
  "assets",
17
17
  )
18
18
  const PLATFORMS = ["ios", "android"]
19
- const DEFAULTS = {
19
+ export const ASSET_DEFAULTS = {
20
20
  background: "#ffffff",
21
21
  backgroundDark: "#121212",
22
22
  iconScale: 0.6,
@@ -37,14 +37,8 @@ const TRANSPARENT = "#00000000"
37
37
  */
38
38
  export async function assets({ root = process.cwd(), platforms = [] } = {}) {
39
39
  const settings = await readSettings(root, {})
40
- const options = { ...DEFAULTS, ...settings.assets }
41
- const logo =
42
- options.logo ?? LOGOS.find((file) => existsSync(path.join(root, file)))
43
- if (!logo || !existsSync(path.join(root, logo))) {
44
- throw new Error(
45
- `No logo found: add ${LOGOS.join(" or ")}, or set native.assets.logo`,
46
- )
47
- }
40
+ const options = { ...ASSET_DEFAULTS, ...settings.assets }
41
+ const logo = findLogo(root, options)
48
42
 
49
43
  const targets = (platforms.length ? platforms : PLATFORMS).filter(
50
44
  (platform) =>
@@ -59,7 +53,7 @@ export async function assets({ root = process.cwd(), platforms = [] } = {}) {
59
53
  const sources = path.join(root, SOURCES)
60
54
  rmSync(sources, { recursive: true, force: true })
61
55
  mkdirSync(sources, { recursive: true })
62
- await composeSources(readFileSync(path.join(root, logo)), sources, options)
56
+ await composeSources(readFileSync(logo), sources, options)
63
57
 
64
58
  execFileSync(
65
59
  process.execPath,
@@ -74,6 +68,22 @@ export async function assets({ root = process.cwd(), platforms = [] } = {}) {
74
68
  )
75
69
  }
76
70
 
71
+ /**
72
+ * @param {string} root
73
+ * @param {{ logo?: string }} options
74
+ * @returns {string} The logo's full path.
75
+ */
76
+ export function findLogo(root, { logo } = {}) {
77
+ const file =
78
+ logo ?? LOGOS.find((candidate) => existsSync(path.join(root, candidate)))
79
+ if (!file || !existsSync(path.join(root, file))) {
80
+ throw new Error(
81
+ `No logo found: add ${LOGOS.join(" or ")}, or set native.assets.logo`,
82
+ )
83
+ }
84
+ return path.join(root, file)
85
+ }
86
+
77
87
  /**
78
88
  * The sources `@capacitor/assets` expects, so it never scales the logo itself:
79
89
  * it would size the splash from the image's width, not the mark's.
package/src/config.js CHANGED
@@ -10,6 +10,15 @@ const CAPACITOR_CONFIG_FILES = [
10
10
  ]
11
11
  const PUSH_PLUGIN = "@capacitor-firebase/messaging"
12
12
  const ANALYTICS_PLUGIN = "@capacitor-firebase/analytics"
13
+ const USER_DEFAULTS = {
14
+ NSPrivacyAccessedAPIType: "NSPrivacyAccessedAPICategoryUserDefaults",
15
+ NSPrivacyAccessedAPITypeReasons: ["CA92.1"],
16
+ }
17
+ // Plugins that call Apple's required-reason APIs without a privacy manifest of
18
+ // their own, so the app has to declare them.
19
+ const PLUGIN_API_TYPES = {
20
+ "@capawesome/capacitor-live-update": [USER_DEFAULTS],
21
+ }
13
22
 
14
23
  /**
15
24
  * Everything `configure` and `assets` act on: the app's id and name from the
@@ -35,11 +44,14 @@ export async function readSettings(root, env = process.env) {
35
44
  appLinks: native.appLinks?.domain
36
45
  ? normalizeAppLinks(native.appLinks)
37
46
  : null,
38
- ios: { teamId: null, infoPlist: {}, ...native.ios },
47
+ ios: { teamId: null, infoPlist: {}, privacyManifest: null, ...native.ios },
39
48
  android: { sha256: [], permissions: [], ...native.android },
40
49
  assets: native.assets ?? {},
41
50
  hasPush: PUSH_PLUGIN in dependencies,
42
51
  hasAnalytics: ANALYTICS_PLUGIN in dependencies,
52
+ accessedApiTypes: Object.entries(PLUGIN_API_TYPES)
53
+ .filter(([plugin]) => plugin in dependencies)
54
+ .flatMap(([, types]) => types),
43
55
  }
44
56
  }
45
57
 
@@ -11,12 +11,17 @@ const ANALYTICS_OFF = [
11
11
  "firebase_analytics_collection_enabled",
12
12
  "google_analytics_adid_collection_enabled",
13
13
  ]
14
+ const AD_ID_PERMISSIONS = [
15
+ "com.google.android.gms.permission.AD_ID",
16
+ "android.permission.ACCESS_ADSERVICES_AD_ID",
17
+ ]
18
+ const TOOLS_NAMESPACE = "http://schemas.android.com/tools"
14
19
  const GOOGLE_SERVICES = "google-services.json"
15
20
  const KEYSTORES = ["*.jks", "*.keystore"]
16
21
 
17
22
  /**
18
23
  * Applies the settings to the Android project: App Links, Analytics off until
19
- * consent, extra permissions, the Firebase file, and keystores kept out of git.
24
+ * consent and without the advertising ID, extra permissions, the Firebase file, and keystores kept out of git.
20
25
  *
21
26
  * @param {import("@trapezedev/project").MobileProject} project
22
27
  * @param {import("../../types/index.d.ts").ProjectSettings} settings
@@ -45,6 +50,7 @@ export function configureAndroid(project, settings) {
45
50
  `<meta-data android:name="${name}" android:value="false" />`,
46
51
  )
47
52
  }
53
+ removeAdIdPermissions(manifest)
48
54
  }
49
55
 
50
56
  for (const permission of settings.android.permissions) {
@@ -69,6 +75,23 @@ export function configureAndroid(project, settings) {
69
75
  ignoreKeystores(path.join(androidRoot, ".gitignore"))
70
76
  }
71
77
 
78
+ function removeAdIdPermissions(manifest) {
79
+ manifest.setAttrs(MANIFEST, { "xmlns:tools": TOOLS_NAMESPACE })
80
+ for (const permission of AD_ID_PERMISSIONS) {
81
+ if (
82
+ manifest.find(
83
+ `${MANIFEST}/uses-permission[@android:name='${permission}']`,
84
+ )?.length
85
+ ) {
86
+ continue
87
+ }
88
+ manifest.injectFragment(
89
+ MANIFEST,
90
+ `<uses-permission android:name="${permission}" tools:node="remove" />`,
91
+ )
92
+ }
93
+ }
94
+
72
95
  function appLinksFilter(appLinks) {
73
96
  const data = intentFilterData(appLinks)
74
97
  .map(
@@ -10,6 +10,7 @@ import { tmpdir } from "node:os"
10
10
  import path from "node:path"
11
11
  import { fileURLToPath } from "node:url"
12
12
 
13
+ import plist from "plist"
13
14
  import { afterEach, beforeEach, describe, expect, it } from "vitest"
14
15
 
15
16
  import { configure } from "./index.js"
@@ -43,6 +44,7 @@ describe("configure", () => {
43
44
  expect(after.manifest).not.toContain("autoVerify")
44
45
  expect(after.plist).not.toContain("UIBackgroundModes")
45
46
  expect(after.appDelegate).toBe(before.appDelegate)
47
+ expect(after.privacyManifest).toBeNull()
46
48
  expect(after.gitignore).toMatch(/^\*\.jks$/m)
47
49
  expect(existsSync(path.join(root, "public"))).toBe(false)
48
50
  })
@@ -85,6 +87,15 @@ describe("configure", () => {
85
87
  expect(first.manifest).toContain(
86
88
  '<meta-data android:name="firebase_analytics_collection_enabled" android:value="false" />',
87
89
  )
90
+ expect(first.manifest).toContain(
91
+ 'xmlns:tools="http://schemas.android.com/tools"',
92
+ )
93
+ expect(first.manifest).toMatch(
94
+ /android:name="com\.google\.android\.gms\.permission\.AD_ID"[^>]*tools:node="remove"/,
95
+ )
96
+ expect(first.manifest).toMatch(
97
+ /android:name="android\.permission\.ACCESS_ADSERVICES_AD_ID"[^>]*tools:node="remove"/,
98
+ )
88
99
  expect(first.manifest).toContain(
89
100
  '<uses-permission android:name="android.permission.RECORD_AUDIO" />',
90
101
  )
@@ -177,6 +188,90 @@ function writePackage(root, pkg) {
177
188
  writeFileSync(path.join(root, "package.json"), JSON.stringify(pkg))
178
189
  }
179
190
 
191
+ describe("configure, privacy manifest", () => {
192
+ let root
193
+
194
+ beforeEach(() => {
195
+ root = mkdtempSync(path.join(tmpdir(), "inglorious-capacitor-"))
196
+ cpSync(FIXTURES, root, { recursive: true })
197
+ })
198
+
199
+ afterEach(() => {
200
+ rmSync(root, { recursive: true, force: true })
201
+ })
202
+
203
+ it("declares what plugins use without saying so, once, as a resource", async () => {
204
+ writePackage(root, {
205
+ dependencies: { "@capawesome/capacitor-live-update": "^8.0.0" },
206
+ })
207
+
208
+ await configure({ root, env: { CAPACITOR_CONFIG } })
209
+ const first = snapshot(root)
210
+ await configure({ root, env: { CAPACITOR_CONFIG } })
211
+
212
+ expect(snapshot(root)).toEqual(first)
213
+ expect(plist.parse(first.privacyManifest)).toEqual({
214
+ NSPrivacyTracking: false,
215
+ NSPrivacyTrackingDomains: [],
216
+ NSPrivacyCollectedDataTypes: [],
217
+ NSPrivacyAccessedAPITypes: [
218
+ {
219
+ NSPrivacyAccessedAPIType: "NSPrivacyAccessedAPICategoryUserDefaults",
220
+ NSPrivacyAccessedAPITypeReasons: ["CA92.1"],
221
+ },
222
+ ],
223
+ })
224
+ expect(first.pbxproj).toContain("PrivacyInfo.xcprivacy in Resources")
225
+ expect(first.pbxproj).not.toContain("PrivacyInfo.xcprivacy in Sources")
226
+ })
227
+
228
+ it("merges the declared entries with the manifest already there", async () => {
229
+ writeFileSync(
230
+ path.join(root, "ios", "App", "App", "PrivacyInfo.xcprivacy"),
231
+ plist.build({
232
+ NSPrivacyAccessedAPITypes: [
233
+ {
234
+ NSPrivacyAccessedAPIType:
235
+ "NSPrivacyAccessedAPICategoryUserDefaults",
236
+ NSPrivacyAccessedAPITypeReasons: ["1C8F.1"],
237
+ },
238
+ ],
239
+ }),
240
+ )
241
+ writePackage(root, {
242
+ dependencies: { "@capawesome/capacitor-live-update": "^8.0.0" },
243
+ native: {
244
+ ios: {
245
+ privacyManifest: {
246
+ NSPrivacyAccessedAPITypes: [
247
+ {
248
+ NSPrivacyAccessedAPIType:
249
+ "NSPrivacyAccessedAPICategoryFileTimestamp",
250
+ NSPrivacyAccessedAPITypeReasons: ["C617.1"],
251
+ },
252
+ ],
253
+ },
254
+ },
255
+ },
256
+ })
257
+
258
+ await configure({ root, env: { CAPACITOR_CONFIG } })
259
+
260
+ expect(
261
+ plist.parse(snapshot(root).privacyManifest).NSPrivacyAccessedAPITypes,
262
+ ).toEqual([
263
+ {
264
+ NSPrivacyAccessedAPIType: "NSPrivacyAccessedAPICategoryUserDefaults",
265
+ NSPrivacyAccessedAPITypeReasons: ["1C8F.1", "CA92.1"],
266
+ },
267
+ {
268
+ NSPrivacyAccessedAPIType: "NSPrivacyAccessedAPICategoryFileTimestamp",
269
+ NSPrivacyAccessedAPITypeReasons: ["C617.1"],
270
+ },
271
+ ])
272
+ })
273
+ })
274
+
180
275
  function snapshot(root) {
181
276
  const read = (...segments) => {
182
277
  const file = path.join(root, ...segments)
@@ -192,6 +287,7 @@ function snapshot(root) {
192
287
  entitlements: read("ios", "App", "App", "App.entitlements"),
193
288
  appDelegate: read("ios", "App", "App", "AppDelegate.swift"),
194
289
  viewController: read("ios", "App", "App", "MainViewController.swift"),
290
+ privacyManifest: read("ios", "App", "App", "PrivacyInfo.xcprivacy"),
195
291
  storyboard: read("ios", "App", "App", "Base.lproj", "Main.storyboard"),
196
292
  association: read("public", ".well-known", "apple-app-site-association"),
197
293
  assetLinks: read("public", ".well-known", "assetlinks.json"),
@@ -14,6 +14,8 @@ const ANALYTICS_OFF = {
14
14
  GOOGLE_ANALYTICS_IDFV_COLLECTION_ENABLED: false,
15
15
  }
16
16
  const GOOGLE_SERVICE_INFO = "GoogleService-Info.plist"
17
+ const PRIVACY_MANIFEST = "PrivacyInfo.xcprivacy"
18
+ const APP_GROUP = "App"
17
19
  const APP_DELEGATE = "AppDelegate.swift"
18
20
  const MAIN_VIEW_CONTROLLER = "MainViewController.swift"
19
21
  const MAIN_STORYBOARD = path.join("Base.lproj", "Main.storyboard")
@@ -113,11 +115,96 @@ export async function configureIos(project, settings) {
113
115
  addPushMethods(path.join(appRoot, APP_DELEGATE))
114
116
  }
115
117
 
118
+ if (settings.ios.privacyManifest || settings.accessedApiTypes.length) {
119
+ writePrivacyManifest(path.join(appRoot, PRIVACY_MANIFEST), settings)
120
+ if (
121
+ !readFileSync(pbxprojPath(settings.root), "utf8").includes(
122
+ PRIVACY_MANIFEST,
123
+ )
124
+ ) {
125
+ addResource(ios, PRIVACY_MANIFEST)
126
+ }
127
+ }
128
+
116
129
  if (settings.ios.swipeBack) {
117
130
  await enableSwipeBack(ios, settings.root, appRoot)
118
131
  }
119
132
  }
120
133
 
134
+ /**
135
+ * Writes the app's privacy manifest: what is already in it, what the app
136
+ * declares in `ios.privacyManifest`, and the required-reason APIs its plugins
137
+ * use without declaring them.
138
+ */
139
+ function writePrivacyManifest(file, settings) {
140
+ const current = existsSync(file)
141
+ ? plist.parse(readFileSync(file, "utf8"))
142
+ : {}
143
+ const declared = settings.ios.privacyManifest ?? {}
144
+ const next = {
145
+ ...current,
146
+ ...declared,
147
+ NSPrivacyTracking:
148
+ declared.NSPrivacyTracking ?? current.NSPrivacyTracking ?? false,
149
+ NSPrivacyTrackingDomains: union(
150
+ current.NSPrivacyTrackingDomains,
151
+ declared.NSPrivacyTrackingDomains,
152
+ ),
153
+ NSPrivacyCollectedDataTypes:
154
+ declared.NSPrivacyCollectedDataTypes ??
155
+ current.NSPrivacyCollectedDataTypes ??
156
+ [],
157
+ NSPrivacyAccessedAPITypes: mergeApiTypes([
158
+ ...(current.NSPrivacyAccessedAPITypes ?? []),
159
+ ...(declared.NSPrivacyAccessedAPITypes ?? []),
160
+ ...settings.accessedApiTypes,
161
+ ]),
162
+ }
163
+ if (JSON.stringify(next) === JSON.stringify(current)) return
164
+ writeFileSync(file, plist.build(next))
165
+ }
166
+
167
+ function mergeApiTypes(types) {
168
+ const byType = new Map()
169
+ for (const type of types) {
170
+ const name = type.NSPrivacyAccessedAPIType
171
+ byType.set(name, {
172
+ NSPrivacyAccessedAPIType: name,
173
+ NSPrivacyAccessedAPITypeReasons: union(
174
+ byType.get(name)?.NSPrivacyAccessedAPITypeReasons,
175
+ type.NSPrivacyAccessedAPITypeReasons,
176
+ ),
177
+ })
178
+ }
179
+ return [...byType.values()]
180
+ }
181
+
182
+ function union(first = [], second = []) {
183
+ return [...new Set([...first, ...second])]
184
+ }
185
+
186
+ // Trapeze's addFile compiles any file that is not a .plist or .strings, and a
187
+ // privacy manifest has to be copied into the bundle instead.
188
+ function addResource(ios, fileName) {
189
+ const groups = ios.pbxProject?.hash.project.objects.PBXGroup ?? {}
190
+ const appGroup = Object.entries(groups).find(
191
+ ([, group]) =>
192
+ group.isa === "PBXGroup" &&
193
+ (group.name === APP_GROUP || group.path === APP_GROUP),
194
+ )
195
+ const [appGroupId] = appGroup ?? []
196
+ ios.addResourceFile(fileName, appGroupId)
197
+ const references = ios.pbxProject?.hash.project.objects.PBXFileReference ?? {}
198
+ for (const reference of Object.values(references)) {
199
+ if (reference?.path !== `"${fileName}"`) continue
200
+ reference.lastKnownFileType = "text.xml"
201
+ for (const [key, value] of Object.entries(reference)) {
202
+ if (value === undefined) delete reference[key]
203
+ }
204
+ }
205
+ ios.markPbxModified()
206
+ }
207
+
121
208
  async function enableSwipeBack(ios, root, appRoot) {
122
209
  const controller = path.join(appRoot, MAIN_VIEW_CONTROLLER)
123
210
  if (!existsSync(controller)) {
package/src/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export { assets } from "./assets/index.js"
2
2
  export { readSettings } from "./config.js"
3
3
  export { configure } from "./configure/index.js"
4
+ export { listingAssets } from "./listing-assets/index.js"
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Store screenshot sizes, as a viewport in CSS pixels and a pixel ratio whose
3
+ * product is the size the store asks for.
4
+ */
5
+ export const DEVICES = {
6
+ "iphone-6.9": { viewport: { width: 440, height: 956 }, scale: 3 },
7
+ "iphone-6.3": { viewport: { width: 402, height: 874 }, scale: 3 },
8
+ "ipad-13": { viewport: { width: 1032, height: 1376 }, scale: 2 },
9
+ android: { viewport: { width: 360, height: 640 }, scale: 3 },
10
+ "tablet-7": { viewport: { width: 540, height: 960 }, scale: 2 },
11
+ "tablet-10": { viewport: { width: 720, height: 1280 }, scale: 2 },
12
+ }
13
+
14
+ export const DEFAULT_DEVICES = ["iphone-6.9", "android"]
15
+
16
+ /**
17
+ * @param {string[]} names
18
+ * @returns {[string, typeof DEVICES[keyof typeof DEVICES]][]}
19
+ */
20
+ export function resolveDevices(names = DEFAULT_DEVICES) {
21
+ const unknown = names.filter((name) => !(name in DEVICES))
22
+ if (unknown.length) {
23
+ throw new Error(
24
+ `Unknown devices: ${unknown.join(", ")}. Choose from ${Object.keys(DEVICES).join(", ")}`,
25
+ )
26
+ }
27
+ return names.map((name) => [name, DEVICES[name]])
28
+ }
29
+
30
+ /**
31
+ * The address of a screen in a locale: `localeParam` added to the path's own
32
+ * query, if the app reads the locale from links.
33
+ *
34
+ * @param {string} base
35
+ * @param {string} screenPath
36
+ * @param {{ locale?: string, localeParam?: string }} [options]
37
+ */
38
+ export function screenUrl(base, screenPath, { locale, localeParam } = {}) {
39
+ const url = new URL(screenPath, base)
40
+ if (locale && localeParam) url.searchParams.set(localeParam, locale)
41
+ return url.href
42
+ }
@@ -0,0 +1,220 @@
1
+ import { existsSync, mkdirSync, readFileSync } from "node:fs"
2
+ import path from "node:path"
3
+ import process from "node:process"
4
+ import { pathToFileURL } from "node:url"
5
+
6
+ import sharp from "sharp"
7
+
8
+ import { ASSET_DEFAULTS, findLogo } from "../assets/index.js"
9
+ import { readSettings } from "../config.js"
10
+ import { resolveDevices, screenUrl } from "./devices.js"
11
+ import { PROMO_FORMATS, promoHtml } from "./promo.js"
12
+
13
+ const CONFIG_FILES = ["listing.config.js", "listing.config.mjs"]
14
+ const DEFAULTS = {
15
+ url: "http://localhost:5173",
16
+ out: "listing-assets",
17
+ browser: "chrome",
18
+ settle: 1000,
19
+ }
20
+ const PROMO_SHOTS = 2
21
+ const LANGUAGE_TIMEOUT = 5000
22
+ const FIRST = 0
23
+ const PLAY_ICON_SIZE = 512
24
+
25
+ /**
26
+ * Makes what the stores ask for besides the app: screenshots of the running
27
+ * app at each store size and in each locale, the promotional images (Google
28
+ * Play's feature graphic, a header and a search results card) in each locale,
29
+ * and Google Play's 512px icon. The screens, the locales and how to sign in
30
+ * come from `listing.config.js`; the logo and colours from `native.assets`.
31
+ *
32
+ * @param {{ root?: string }} [options]
33
+ * @returns {Promise<string>} The folder written to.
34
+ */
35
+ export async function listingAssets({ root = process.cwd() } = {}) {
36
+ const settings = await readSettings(root, {})
37
+ const config = { ...DEFAULTS, ...(await readListingConfig(root)) }
38
+ const assets = { ...ASSET_DEFAULTS, ...settings.assets }
39
+ const logo = findLogo(root, assets)
40
+ const out = path.join(root, config.out)
41
+ const locales = config.locales?.length ? config.locales : [null]
42
+ const devices = resolveDevices(config.devices)
43
+
44
+ await assertReachable(config.url)
45
+ mkdirSync(out, { recursive: true })
46
+
47
+ const { chromium } = await import("playwright-core")
48
+ const browser = await chromium.launch({ channel: config.browser })
49
+ try {
50
+ for (const locale of locales) {
51
+ let session
52
+ for (const [device, { viewport, scale }] of devices) {
53
+ const dir = path.join(out, device, locale ?? "")
54
+ mkdirSync(dir, { recursive: true })
55
+ const context = await browser.newContext({
56
+ viewport,
57
+ deviceScaleFactor: scale,
58
+ isMobile: true,
59
+ hasTouch: true,
60
+ ...(locale && { locale }),
61
+ ...(session && { storageState: session }),
62
+ })
63
+ await context.addInitScript(
64
+ storageScript,
65
+ storageEntries(config.storage, locale),
66
+ )
67
+ const page = await context.newPage()
68
+ if (config.signIn && !session) {
69
+ await config.signIn(page, { url: config.url, locale })
70
+ session = await context.storageState()
71
+ }
72
+ for (const screen of config.screens ?? []) {
73
+ await page.goto(
74
+ screenUrl(config.url, screen.path, {
75
+ locale,
76
+ localeParam: config.localeParam,
77
+ }),
78
+ )
79
+ await page.waitForLoadState("networkidle")
80
+ await page.waitForTimeout(config.settle)
81
+ if (locale) await assertLanguage(page, locale, screen.name)
82
+ if (screen.prepare) await screen.prepare(page, { locale })
83
+ await page.screenshot({ path: path.join(dir, `${screen.name}.png`) })
84
+ }
85
+ await context.close()
86
+ }
87
+ }
88
+
89
+ const promo = config.promo ?? {}
90
+ const promoDevice = promo.device ?? defaultPromoDevice(devices)
91
+ const promoScreens =
92
+ promo.screens ??
93
+ (config.screens ?? []).slice(FIRST, PROMO_SHOTS).map(({ name }) => name)
94
+ for (const locale of locales) {
95
+ const options = {
96
+ logo: dataUrl(logo),
97
+ name: settings.appName,
98
+ tagline: localized(promo.tagline, locale),
99
+ subtitle: localized(promo.subtitle, locale),
100
+ shots: promoScreens.map((name) =>
101
+ dataUrl(path.join(out, promoDevice, locale ?? "", `${name}.png`)),
102
+ ),
103
+ background: promo.background ?? assets.background,
104
+ color: promo.color,
105
+ accent: promo.accent,
106
+ font: promo.font,
107
+ }
108
+ for (const [format, { width, height, scale }] of Object.entries(
109
+ PROMO_FORMATS,
110
+ )) {
111
+ const page = await browser.newPage({
112
+ viewport: { width, height },
113
+ deviceScaleFactor: scale,
114
+ })
115
+ await page.setContent(promoHtml(format, options))
116
+ await page.waitForLoadState("load")
117
+ await page.screenshot({
118
+ path: path.join(
119
+ out,
120
+ locale ? `${format}-${locale}.png` : `${format}.png`,
121
+ ),
122
+ })
123
+ await page.close()
124
+ }
125
+ }
126
+ } finally {
127
+ await browser.close()
128
+ }
129
+
130
+ await playIcon(logo, assets, path.join(out, "icon-512.png"))
131
+ return out
132
+ }
133
+
134
+ async function assertLanguage(page, locale, screen) {
135
+ try {
136
+ await page.waitForFunction(
137
+ (expected) => {
138
+ const { lang } = globalThis.document.documentElement
139
+ return !lang || lang === expected || lang.startsWith(`${expected}-`)
140
+ },
141
+ locale,
142
+ { timeout: LANGUAGE_TIMEOUT },
143
+ )
144
+ } catch {
145
+ const lang = await page.evaluate(
146
+ () => globalThis.document.documentElement.lang,
147
+ )
148
+ throw new Error(
149
+ `${screen} shows "${lang}" instead of "${locale}": check localeParam, or choose the language in signIn`,
150
+ )
151
+ }
152
+ }
153
+
154
+ function defaultPromoDevice(devices) {
155
+ const [[first]] = devices
156
+ const [iphone] = devices.find(([name]) => name.startsWith("iphone")) ?? []
157
+ return iphone ?? first
158
+ }
159
+
160
+ async function readListingConfig(root) {
161
+ const file = CONFIG_FILES.map((name) => path.join(root, name)).find(
162
+ (candidate) => existsSync(candidate),
163
+ )
164
+ if (!file) {
165
+ throw new Error(
166
+ `No listing config found: add listing.config.js with the screens to capture`,
167
+ )
168
+ }
169
+ const { default: config } = await import(pathToFileURL(file).href)
170
+ return config
171
+ }
172
+
173
+ async function assertReachable(url) {
174
+ try {
175
+ await fetch(url)
176
+ } catch {
177
+ throw new Error(`Nothing answers at ${url}: start the app first`)
178
+ }
179
+ }
180
+
181
+ function storageEntries(storage, locale) {
182
+ const values = typeof storage === "function" ? storage(locale) : storage
183
+ return Object.entries(values ?? {}).map(([key, value]) => [
184
+ key,
185
+ typeof value === "string" ? value : JSON.stringify(value),
186
+ ])
187
+ }
188
+
189
+ function storageScript(entries) {
190
+ for (const [key, value] of entries) localStorage.setItem(key, value)
191
+ }
192
+
193
+ function localized(value, locale) {
194
+ if (value == null || typeof value === "string") return value ?? ""
195
+ const [first = ""] = Object.values(value)
196
+ return value[locale] ?? first
197
+ }
198
+
199
+ function dataUrl(file) {
200
+ const type = file.endsWith(".svg") ? "image/svg+xml" : "image/png"
201
+ return `data:${type};base64,${readFileSync(file).toString("base64")}`
202
+ }
203
+
204
+ async function playIcon(logo, { background, iconScale }, file) {
205
+ const mark = await sharp(readFileSync(logo))
206
+ .resize(Math.round(PLAY_ICON_SIZE * iconScale))
207
+ .png()
208
+ .toBuffer()
209
+ await sharp({
210
+ create: {
211
+ width: PLAY_ICON_SIZE,
212
+ height: PLAY_ICON_SIZE,
213
+ channels: 4,
214
+ background,
215
+ },
216
+ })
217
+ .composite([{ input: mark, gravity: "centre" }])
218
+ .png()
219
+ .toFile(file)
220
+ }
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from "vitest"
2
+
3
+ import { DEFAULT_DEVICES, resolveDevices, screenUrl } from "./devices.js"
4
+ import { promoHtml } from "./promo.js"
5
+
6
+ describe("resolveDevices", () => {
7
+ it("defaults to the sizes both stores require", () => {
8
+ expect(resolveDevices().map(([name]) => name)).toEqual(DEFAULT_DEVICES)
9
+ })
10
+
11
+ it("gives each device the store's pixel size", () => {
12
+ const [[, iphone]] = resolveDevices(["iphone-6.9"])
13
+ expect(iphone.viewport.width * iphone.scale).toBe(1320)
14
+ expect(iphone.viewport.height * iphone.scale).toBe(2868)
15
+ })
16
+
17
+ it("names the devices it doesn't know", () => {
18
+ expect(() => resolveDevices(["iphone-6.9", "watch"])).toThrow("watch")
19
+ })
20
+ })
21
+
22
+ describe("screenUrl", () => {
23
+ it("adds the locale to the path's own query", () => {
24
+ expect(
25
+ screenUrl("http://localhost:5173", "/jobs?status=open", {
26
+ locale: "it",
27
+ localeParam: "lang",
28
+ }),
29
+ ).toBe("http://localhost:5173/jobs?status=open&lang=it")
30
+ })
31
+
32
+ it("leaves the path alone when the app doesn't read the locale from links", () => {
33
+ expect(screenUrl("http://localhost:5173", "/jobs", { locale: "it" })).toBe(
34
+ "http://localhost:5173/jobs",
35
+ )
36
+ })
37
+ })
38
+
39
+ describe("promoHtml", () => {
40
+ const options = {
41
+ logo: "data:image/png;base64,",
42
+ name: "Tom & Jerry",
43
+ tagline: "First <line>\nSecond",
44
+ background: "#fff",
45
+ }
46
+
47
+ it("escapes the name and breaks the tagline into lines", () => {
48
+ const html = promoHtml("feature-graphic", options)
49
+ expect(html).toContain("Tom &amp; Jerry")
50
+ expect(html).toContain("First &lt;line&gt;<br>Second")
51
+ })
52
+
53
+ it("frames a screenshot at each edge of the header", () => {
54
+ const html = promoHtml("header", { ...options, shots: ["a.png", "b.png"] })
55
+ expect(html).toMatch(/src="a\.png"[^>]*left:/)
56
+ expect(html).toMatch(/src="b\.png"[^>]*right:/)
57
+ })
58
+
59
+ it("leaves out what isn't given", () => {
60
+ const html = promoHtml("search-results", options)
61
+ expect(html).not.toContain('<img src="a.png')
62
+ expect(html).not.toContain("opacity")
63
+ })
64
+ })
@@ -0,0 +1,186 @@
1
+ /**
2
+ * The promotional images, in CSS pixels rendered at `scale`:
3
+ * - `feature-graphic`: Google Play's banner above the listing.
4
+ * - `header`: a wide banner whose middle 3:2 is what an iPhone shows, so the
5
+ * text stays there and the screenshots sit at the edges.
6
+ * - `search-results`: a 3:2 card, the text beside one screenshot.
7
+ */
8
+ export const PROMO_FORMATS = {
9
+ "feature-graphic": { width: 1024, height: 500, scale: 1 },
10
+ header: { width: 1920, height: 823, scale: 2 },
11
+ "search-results": { width: 960, height: 640, scale: 2 },
12
+ }
13
+
14
+ const DEFAULT_COLOR = "#1f1f1f"
15
+ const DEFAULT_FONT = "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif"
16
+ const PERCENT = 100
17
+ const HALF = 2
18
+
19
+ // Sizes in hundredths of the image's height.
20
+ const FEATURE_GRAPHIC = {
21
+ gap: 11,
22
+ padding: 16,
23
+ logo: 40,
24
+ name: 19,
25
+ tagline: 7.5,
26
+ }
27
+ const HEADER = {
28
+ safeRatio: 1.5,
29
+ phoneTop: 11,
30
+ phoneSide: 2,
31
+ phoneHeight: 79,
32
+ textTop: 18,
33
+ textBottom: 8,
34
+ logo: 16,
35
+ name: 17,
36
+ taglineGap: 5,
37
+ tagline: 8.5,
38
+ }
39
+ const SEARCH_RESULTS = {
40
+ textShare: 0.55,
41
+ padding: 8,
42
+ phoneHeight: 82,
43
+ logo: 14,
44
+ name: 13,
45
+ taglineGap: 5,
46
+ tagline: 6.5,
47
+ subtitleGap: 3,
48
+ subtitle: 3.6,
49
+ subtitleOpacity: 0.75,
50
+ }
51
+ const BRAND_GAP = 3
52
+ const ACCENT = { gap: 4, width: 14, height: 1 }
53
+ const PHONE = { radius: 5, border: 0.9, shadowY: 2, shadowBlur: 6 }
54
+
55
+ const LAYOUTS = {
56
+ "feature-graphic": featureGraphic,
57
+ header,
58
+ "search-results": searchResults,
59
+ }
60
+
61
+ /**
62
+ * @param {keyof typeof PROMO_FORMATS} format
63
+ * @param {{ logo: string, name: string, tagline?: string, subtitle?: string, shots?: string[], background: string, color?: string, accent?: string, font?: string }} options
64
+ * `logo` and `shots` are image addresses.
65
+ * @returns {string} A whole HTML page.
66
+ */
67
+ export function promoHtml(format, options) {
68
+ const { width, height } = PROMO_FORMATS[format]
69
+ const style = {
70
+ color: DEFAULT_COLOR,
71
+ font: DEFAULT_FONT,
72
+ tagline: "",
73
+ subtitle: "",
74
+ shots: [],
75
+ ...options,
76
+ }
77
+ const unit = height / PERCENT
78
+ return `<!doctype html><html><body style="margin:0">
79
+ <div style="position:relative;width:${width}px;height:${height}px;box-sizing:border-box;overflow:hidden;
80
+ background:${style.background};color:${style.color};font-family:${style.font}">
81
+ ${LAYOUTS[format]({ width, height, unit, ...style })}
82
+ </div></body></html>`
83
+ }
84
+
85
+ function featureGraphic({ unit, logo, name, tagline }) {
86
+ const size = scaled(FEATURE_GRAPHIC, unit)
87
+ return `<div style="height:100%;display:flex;align-items:center;gap:${size.gap}px;padding:0 ${size.padding}px">
88
+ <img src="${logo}" style="height:${size.logo}px;flex:none">
89
+ <div>
90
+ <div style="font-size:${size.name}px;line-height:1">${escapeHtml(name)}</div>
91
+ ${text(tagline, `margin-top:${size.tagline}px;font-size:${size.tagline}px;line-height:1.25`)}
92
+ </div>
93
+ </div>`
94
+ }
95
+
96
+ function header({
97
+ width,
98
+ height,
99
+ unit,
100
+ logo,
101
+ name,
102
+ tagline,
103
+ shots,
104
+ color,
105
+ accent,
106
+ }) {
107
+ const size = scaled(HEADER, unit)
108
+ const safeWidth = Math.round(height * HEADER.safeRatio)
109
+ const safeLeft = (width - safeWidth) / HALF
110
+ const [left, right] = shots
111
+ const placement = (side) =>
112
+ `top:${size.phoneTop}px;${side}:${size.phoneSide}px;height:${size.phoneHeight}px`
113
+ return `${left ? phone(left, unit, color, placement("left")) : ""}
114
+ ${right ? phone(right, unit, color, placement("right")) : ""}
115
+ <div style="position:absolute;left:${safeLeft}px;width:${safeWidth}px;top:${size.textTop}px;bottom:${size.textBottom}px;
116
+ display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center">
117
+ ${brand(unit, logo, name, size)}
118
+ ${text(tagline, `margin-top:${size.taglineGap}px;font-size:${size.tagline}px;line-height:1.2`)}
119
+ ${accentBar(unit, accent)}
120
+ </div>`
121
+ }
122
+
123
+ function searchResults({
124
+ width,
125
+ unit,
126
+ logo,
127
+ name,
128
+ tagline,
129
+ subtitle,
130
+ shots,
131
+ color,
132
+ accent,
133
+ }) {
134
+ const size = scaled(SEARCH_RESULTS, unit)
135
+ const [shot] = shots
136
+ return `<div style="height:100%;display:flex;align-items:center;justify-content:space-between;padding:0 ${size.padding}px">
137
+ <div style="max-width:${width * SEARCH_RESULTS.textShare}px">
138
+ ${brand(unit, logo, name, size)}
139
+ ${text(tagline, `margin-top:${size.taglineGap}px;font-size:${size.tagline}px;line-height:1.2`)}
140
+ ${text(subtitle, `margin-top:${size.subtitleGap}px;font-size:${size.subtitle}px;line-height:1.4;opacity:${SEARCH_RESULTS.subtitleOpacity}`)}
141
+ ${accentBar(unit, accent)}
142
+ </div>
143
+ ${shot ? phone(shot, unit, color, `position:static;height:${size.phoneHeight}px`) : ""}
144
+ </div>`
145
+ }
146
+
147
+ function scaled(sizes, unit) {
148
+ return Object.fromEntries(
149
+ Object.entries(sizes).map(([key, value]) => [key, value * unit]),
150
+ )
151
+ }
152
+
153
+ function brand(unit, logo, name, size) {
154
+ return `<div style="display:flex;align-items:center;gap:${BRAND_GAP * unit}px">
155
+ <img src="${logo}" style="height:${size.logo}px">
156
+ <span style="font-size:${size.name}px;line-height:1">${escapeHtml(name)}</span>
157
+ </div>`
158
+ }
159
+
160
+ function text(value, style) {
161
+ return value ? `<div style="${style}">${lines(value)}</div>` : ""
162
+ }
163
+
164
+ function accentBar(unit, accent) {
165
+ if (!accent) return ""
166
+ const size = scaled(ACCENT, unit)
167
+ return `<div style="margin-top:${size.gap}px;width:${size.width}px;height:${size.height}px;border-radius:${size.height}px;background:${accent}"></div>`
168
+ }
169
+
170
+ function phone(src, unit, color, placement) {
171
+ const size = scaled(PHONE, unit)
172
+ return `<img src="${src}" style="position:absolute;${placement};border-radius:${size.radius}px;
173
+ border:${size.border}px solid ${color};box-shadow:0 ${size.shadowY}px ${size.shadowBlur}px rgba(0,0,0,.2)">`
174
+ }
175
+
176
+ function lines(value) {
177
+ return escapeHtml(value).split("\n").join("<br>")
178
+ }
179
+
180
+ function escapeHtml(value) {
181
+ return value
182
+ .replaceAll("&", "&amp;")
183
+ .replaceAll("<", "&lt;")
184
+ .replaceAll(">", "&gt;")
185
+ .replaceAll('"', "&quot;")
186
+ }
package/types/index.d.ts CHANGED
@@ -24,6 +24,12 @@ export interface NativeConfig {
24
24
  * WebKit's back gesture in a generated `MainViewController`.
25
25
  */
26
26
  swipeBack?: boolean
27
+ /**
28
+ * Entries of the app's `PrivacyInfo.xcprivacy`, with Apple's keys. Merged
29
+ * with the manifest already there and with the required-reason APIs of
30
+ * known plugins that do not declare their own.
31
+ */
32
+ privacyManifest?: PrivacyManifest
27
33
  }
28
34
  android?: {
29
35
  /** SHA-256 fingerprints of the keys that sign the app, for App Links. */
@@ -45,6 +51,20 @@ export interface NativeConfig {
45
51
  }
46
52
  }
47
53
 
54
+ /** An app's privacy manifest, with Apple's keys. */
55
+ export interface PrivacyManifest {
56
+ NSPrivacyTracking?: boolean
57
+ NSPrivacyTrackingDomains?: string[]
58
+ NSPrivacyCollectedDataTypes?: Record<string, unknown>[]
59
+ NSPrivacyAccessedAPITypes?: AccessedApiType[]
60
+ }
61
+
62
+ /** A required-reason API and why the app calls it. */
63
+ export interface AccessedApiType {
64
+ NSPrivacyAccessedAPIType: string
65
+ NSPrivacyAccessedAPITypeReasons: string[]
66
+ }
67
+
48
68
  export interface ProjectSettings {
49
69
  root: string
50
70
  appId: string
@@ -54,11 +74,14 @@ export interface ProjectSettings {
54
74
  teamId: string | null
55
75
  infoPlist: Record<string, unknown>
56
76
  swipeBack?: boolean
77
+ privacyManifest: PrivacyManifest | null
57
78
  }
58
79
  android: { sha256: string[]; permissions: string[] }
59
80
  assets: NonNullable<NativeConfig["assets"]>
60
81
  hasPush: boolean
61
82
  hasAnalytics: boolean
83
+ /** Required-reason APIs the app's plugins use without declaring them. */
84
+ accessedApiTypes: AccessedApiType[]
62
85
  }
63
86
 
64
87
  /**
@@ -81,6 +104,82 @@ export declare function configure(options?: {
81
104
  env?: NodeJS.ProcessEnv
82
105
  }): Promise<string[]>
83
106
 
107
+ /** A device whose store screenshots `listingAssets` captures. */
108
+ export type ListingDevice =
109
+ "iphone-6.9" | "iphone-6.3" | "ipad-13" | "android" | "tablet-7" | "tablet-10"
110
+
111
+ /** The default export of `listing.config.js`. */
112
+ export interface ListingConfig {
113
+ /** The running app. Defaults to `http://localhost:5173`. */
114
+ url?: string
115
+ /** One set of screenshots each. Defaults to one set, in the browser's locale. */
116
+ locales?: string[]
117
+ /** The query parameter the app reads the locale from, such as `lang`. */
118
+ localeParam?: string
119
+ /** Defaults to `["iphone-6.9", "android"]`. */
120
+ devices?: ListingDevice[]
121
+ /** Defaults to `listing-assets`. */
122
+ out?: string
123
+ /** The Playwright browser channel. Defaults to `chrome`. */
124
+ browser?: string
125
+ /** Milliseconds to wait after a screen loads. Defaults to `1000`. */
126
+ settle?: number
127
+ /**
128
+ * `localStorage` entries set before the app starts, such as answers to the
129
+ * consent and push prompts, so they stay out of the screenshots.
130
+ */
131
+ storage?:
132
+ | Record<string, unknown>
133
+ | ((locale: string | null) => Record<string, unknown>)
134
+ /** Signs the demo account in, once per locale: the other devices reuse the session. */
135
+ signIn?: (
136
+ page: import("playwright-core").Page,
137
+ context: { url: string; locale: string | null },
138
+ ) => Promise<void>
139
+ /** In the order the stores show them. */
140
+ screens: {
141
+ /** The file name, such as `01-feed`. */
142
+ name: string
143
+ path: string
144
+ /** Brings the screen to the state worth showing, such as a filled form. */
145
+ prepare?: (
146
+ page: import("playwright-core").Page,
147
+ context: { locale: string | null },
148
+ ) => Promise<void>
149
+ }[]
150
+ /** The feature graphic, header and search results images. */
151
+ promo?: {
152
+ /** One line, or one per locale. A newline breaks the line. */
153
+ tagline?: string | Record<string, string>
154
+ /** The search results card's smaller line under the tagline. */
155
+ subtitle?: string | Record<string, string>
156
+ /**
157
+ * The screenshots framed in the header (both) and search results card
158
+ * (the first). Defaults to the first two `screens`.
159
+ */
160
+ screens?: string[]
161
+ /** Whose screenshots to frame. Defaults to the first iPhone in `devices`. */
162
+ device?: ListingDevice
163
+ /** Defaults to `native.assets.background`. */
164
+ background?: string
165
+ /** Text and phone frames. Defaults to `#1f1f1f`. */
166
+ color?: string
167
+ /** A short bar under the text, in this colour. None by default. */
168
+ accent?: string
169
+ /** A CSS font-family. Defaults to the system font. */
170
+ font?: string
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Captures store screenshots of the running app and makes the promotional
176
+ * images and Google Play's 512px icon, from `listing.config.js`.
177
+ * @returns The folder written to.
178
+ */
179
+ export declare function listingAssets(options?: {
180
+ root?: string
181
+ }): Promise<string>
182
+
84
183
  /** Makes the native icons and splash screens from one logo. */
85
184
  export declare function assets(options?: {
86
185
  root?: string