@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 +99 -16
- package/bin/inglorious-capacitor.js +6 -1
- package/package.json +5 -3
- package/src/assets/index.js +20 -10
- package/src/config.js +13 -1
- package/src/configure/android.js +24 -1
- package/src/configure/configure.test.js +96 -0
- package/src/configure/ios.js +87 -0
- package/src/index.js +1 -0
- package/src/listing-assets/devices.js +42 -0
- package/src/listing-assets/index.js +220 -0
- package/src/listing-assets/listing-assets.test.js +64 -0
- package/src/listing-assets/promo.js +186 -0
- package/types/index.d.ts +99 -0
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
|
|
67
|
-
|
|
|
68
|
-
| `appLinks`
|
|
69
|
-
| `appLinks` + `teamId`
|
|
70
|
-
| `appLinks` + `sha256`
|
|
71
|
-
| `ios.teamId`
|
|
72
|
-
| `ios.swipeBack`
|
|
73
|
-
| `ios.infoPlist`
|
|
74
|
-
| `
|
|
75
|
-
| `@capacitor-
|
|
76
|
-
|
|
|
77
|
-
|
|
|
78
|
-
|
|
|
79
|
-
|
|
|
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.
|
|
4
|
-
"description": "Configures Capacitor's native projects from package.json,
|
|
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
|
},
|
package/src/assets/index.js
CHANGED
|
@@ -16,7 +16,7 @@ const SOURCES = path.join(
|
|
|
16
16
|
"assets",
|
|
17
17
|
)
|
|
18
18
|
const PLATFORMS = ["ios", "android"]
|
|
19
|
-
const
|
|
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 = { ...
|
|
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(
|
|
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
|
|
package/src/configure/android.js
CHANGED
|
@@ -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"),
|
package/src/configure/ios.js
CHANGED
|
@@ -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
|
@@ -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 & Jerry")
|
|
50
|
+
expect(html).toContain("First <line><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("&", "&")
|
|
183
|
+
.replaceAll("<", "<")
|
|
184
|
+
.replaceAll(">", ">")
|
|
185
|
+
.replaceAll('"', """)
|
|
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
|