@inglorious/capacitor 0.2.0 → 0.4.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 +38 -14
- package/bin/inglorious-capacitor.js +12 -0
- package/package.json +1 -1
- package/src/config.js +14 -1
- package/src/configure/configure.test.js +87 -0
- package/src/configure/ios.js +87 -0
- package/src/dev/dev.test.js +103 -0
- package/src/dev/index.js +101 -0
- package/src/index.js +1 -0
- package/types/index.d.ts +40 -0
package/README.md
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
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
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
|
+
- `inglorious-capacitor dev <ios|android>` runs the app on a device or emulator with live reload from the Vite dev server.
|
|
8
9
|
- `inglorious-capacitor assets` makes every icon and splash screen, light and dark, from one logo.
|
|
9
10
|
- `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.
|
|
10
11
|
|
|
@@ -22,6 +23,8 @@ Run `configure` as Capacitor's `sync` hook, so `cap add` and `cap sync` both app
|
|
|
22
23
|
"capacitor:sync:after": "inglorious-capacitor configure",
|
|
23
24
|
"mobile:init:ios": "cap add ios && inglorious-capacitor assets --ios",
|
|
24
25
|
"mobile:init:android": "cap add android && inglorious-capacitor assets --android",
|
|
26
|
+
"mobile:dev:ios": "inglorious-capacitor dev ios",
|
|
27
|
+
"mobile:dev:android": "inglorious-capacitor dev android",
|
|
25
28
|
"mobile:assets": "inglorious-capacitor assets",
|
|
26
29
|
"mobile:listing-assets": "inglorious-capacitor listing-assets"
|
|
27
30
|
}
|
|
@@ -65,20 +68,37 @@ The `native` key of `package.json` holds them all. Every key is optional; the ap
|
|
|
65
68
|
|
|
66
69
|
### What `configure` does
|
|
67
70
|
|
|
68
|
-
| Setting
|
|
69
|
-
|
|
|
70
|
-
| `appLinks`
|
|
71
|
-
| `appLinks` + `teamId`
|
|
72
|
-
| `appLinks` + `sha256`
|
|
73
|
-
| `ios.teamId`
|
|
74
|
-
| `ios.swipeBack`
|
|
75
|
-
| `ios.infoPlist`
|
|
76
|
-
| `
|
|
77
|
-
| `@capacitor-
|
|
78
|
-
|
|
|
79
|
-
|
|
|
80
|
-
|
|
|
81
|
-
|
|
|
71
|
+
| Setting | iOS | Android |
|
|
72
|
+
| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
|
|
73
|
+
| `appLinks` | Associated Domains entitlement | Verified intent filter for `paths` |
|
|
74
|
+
| `appLinks` + `teamId` | `public/.well-known/apple-app-site-association` | |
|
|
75
|
+
| `appLinks` + `sha256` | | `public/.well-known/assetlinks.json` |
|
|
76
|
+
| `ios.teamId` | `DEVELOPMENT_TEAM`, so Xcode signs without asking | |
|
|
77
|
+
| `ios.swipeBack` | A swipe from the left edge goes back: WebKit's back gesture, turned on in a generated `MainViewController` | |
|
|
78
|
+
| `ios.infoPlist` | Info.plist entries | |
|
|
79
|
+
| `ios.privacyManifest` | `PrivacyInfo.xcprivacy` entries, with Apple's keys | |
|
|
80
|
+
| `@capawesome/capacitor-live-update` installed | `PrivacyInfo.xcprivacy` declaring its use of `UserDefaults`, which it doesn't declare itself | |
|
|
81
|
+
| `android.permissions` | | `<uses-permission>` entries |
|
|
82
|
+
| `@capacitor-firebase/messaging` installed | Push entitlement, background mode, AppDelegate methods | |
|
|
83
|
+
| `@capacitor-firebase/analytics` installed | Collection off until consent | Collection off until consent, and no advertising ID permission |
|
|
84
|
+
| `GoogleService-Info.plist` in the root | Copied into the app target | |
|
|
85
|
+
| `google-services.json` in the root | | Copied to `android/app/` |
|
|
86
|
+
| always | | Keystores kept out of git |
|
|
87
|
+
|
|
88
|
+
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:
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
"privacyManifest": {
|
|
92
|
+
"NSPrivacyAccessedAPITypes": [
|
|
93
|
+
{
|
|
94
|
+
"NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryUserDefaults",
|
|
95
|
+
"NSPrivacyAccessedAPITypeReasons": ["CA92.1"]
|
|
96
|
+
}
|
|
97
|
+
]
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
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`.
|
|
82
102
|
|
|
83
103
|
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`.
|
|
84
104
|
|
|
@@ -88,6 +108,10 @@ It runs for the platform Capacitor is syncing, and changes nothing when the sett
|
|
|
88
108
|
|
|
89
109
|
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.
|
|
90
110
|
|
|
111
|
+
## Live reload on a device
|
|
112
|
+
|
|
113
|
+
`inglorious-capacitor dev ios` (or `android`) starts Vite on the local network (`vite --host`) and runs `cap run ios --live-reload`, which points the installed app at it: every saved file reloads on the phone. The phone must be on the same network as the computer. It builds the web app first if `webDir` has none, since `cap run` syncs it. Further options go to `cap run`, such as `--target <id>`; `--port=<port>` changes Vite's port (5173 by default). Ctrl+C stops both, and Capacitor puts the native config back.
|
|
114
|
+
|
|
91
115
|
## Store listing images
|
|
92
116
|
|
|
93
117
|
`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:
|
|
@@ -5,6 +5,9 @@ const USAGE = `Usage: inglorious-capacitor <command>
|
|
|
5
5
|
|
|
6
6
|
configure Apply the "native" settings in package.json to the
|
|
7
7
|
native projects (runs after every cap sync)
|
|
8
|
+
dev <ios|android> [--port=5173] [cap run options]
|
|
9
|
+
Run the app on a device or emulator with live reload
|
|
10
|
+
from the Vite dev server
|
|
8
11
|
assets [--ios] [--android]
|
|
9
12
|
Make the app icons and splash screens from the logo
|
|
10
13
|
listing-assets Capture store screenshots of the running app, and make
|
|
@@ -21,6 +24,15 @@ try {
|
|
|
21
24
|
const { configure } = await import("../src/configure/index.js")
|
|
22
25
|
const platforms = await configure()
|
|
23
26
|
if (platforms.length) console.log(`Configured ${platforms.join(" and ")}`)
|
|
27
|
+
} else if (command === "dev") {
|
|
28
|
+
const { dev } = await import("../src/dev/index.js")
|
|
29
|
+
const [platform, ...rest] = flags
|
|
30
|
+
const portFlag = rest.find((flag) => flag.startsWith("--port="))
|
|
31
|
+
process.exitCode = await dev({
|
|
32
|
+
platform,
|
|
33
|
+
port: portFlag ? Number(portFlag.slice("--port=".length)) : undefined,
|
|
34
|
+
args: rest.filter((flag) => flag !== portFlag),
|
|
35
|
+
})
|
|
24
36
|
} else if (command === "assets") {
|
|
25
37
|
const { assets } = await import("../src/assets/index.js")
|
|
26
38
|
await assets({ platforms: flags.map((flag) => flag.replace(/^--/, "")) })
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inglorious/capacitor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
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",
|
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
|
|
@@ -32,14 +41,18 @@ export async function readSettings(root, env = process.env) {
|
|
|
32
41
|
root,
|
|
33
42
|
appId: capacitor.appId,
|
|
34
43
|
appName: capacitor.appName,
|
|
44
|
+
webDir: capacitor.webDir ?? "www",
|
|
35
45
|
appLinks: native.appLinks?.domain
|
|
36
46
|
? normalizeAppLinks(native.appLinks)
|
|
37
47
|
: null,
|
|
38
|
-
ios: { teamId: null, infoPlist: {}, ...native.ios },
|
|
48
|
+
ios: { teamId: null, infoPlist: {}, privacyManifest: null, ...native.ios },
|
|
39
49
|
android: { sha256: [], permissions: [], ...native.android },
|
|
40
50
|
assets: native.assets ?? {},
|
|
41
51
|
hasPush: PUSH_PLUGIN in dependencies,
|
|
42
52
|
hasAnalytics: ANALYTICS_PLUGIN in dependencies,
|
|
53
|
+
accessedApiTypes: Object.entries(PLUGIN_API_TYPES)
|
|
54
|
+
.filter(([plugin]) => plugin in dependencies)
|
|
55
|
+
.flatMap(([, types]) => types),
|
|
43
56
|
}
|
|
44
57
|
}
|
|
45
58
|
|
|
@@ -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
|
})
|
|
@@ -186,6 +188,90 @@ function writePackage(root, pkg) {
|
|
|
186
188
|
writeFileSync(path.join(root, "package.json"), JSON.stringify(pkg))
|
|
187
189
|
}
|
|
188
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
|
+
|
|
189
275
|
function snapshot(root) {
|
|
190
276
|
const read = (...segments) => {
|
|
191
277
|
const file = path.join(root, ...segments)
|
|
@@ -201,6 +287,7 @@ function snapshot(root) {
|
|
|
201
287
|
entitlements: read("ios", "App", "App", "App.entitlements"),
|
|
202
288
|
appDelegate: read("ios", "App", "App", "AppDelegate.swift"),
|
|
203
289
|
viewController: read("ios", "App", "App", "MainViewController.swift"),
|
|
290
|
+
privacyManifest: read("ios", "App", "App", "PrivacyInfo.xcprivacy"),
|
|
204
291
|
storyboard: read("ios", "App", "App", "Base.lproj", "Main.storyboard"),
|
|
205
292
|
association: read("public", ".well-known", "apple-app-site-association"),
|
|
206
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)) {
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events"
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
|
7
|
+
|
|
8
|
+
import { dev } from "./index.js"
|
|
9
|
+
|
|
10
|
+
const CAPACITOR_CONFIG = {
|
|
11
|
+
appId: "com.example.app",
|
|
12
|
+
appName: "Example",
|
|
13
|
+
webDir: "dist",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("dev", () => {
|
|
17
|
+
let root
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
root = mkdtempSync(path.join(tmpdir(), "inglorious-capacitor-dev-"))
|
|
21
|
+
writeFileSync(path.join(root, "package.json"), "{}")
|
|
22
|
+
writeFileSync(
|
|
23
|
+
path.join(root, "capacitor.config.json"),
|
|
24
|
+
JSON.stringify(CAPACITOR_CONFIG),
|
|
25
|
+
)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
rmSync(root, { recursive: true, force: true })
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it("serves Vite on the network and runs the app against it", async () => {
|
|
33
|
+
buildWebDir(root)
|
|
34
|
+
const spawn = fakeSpawn()
|
|
35
|
+
|
|
36
|
+
const code = await dev({
|
|
37
|
+
root,
|
|
38
|
+
platform: "ios",
|
|
39
|
+
args: ["--target", "ABC"],
|
|
40
|
+
spawn,
|
|
41
|
+
isReady: async () => true,
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
expect(code).toBe(0)
|
|
45
|
+
expect(spawn.calls).toEqual([
|
|
46
|
+
["vite", ["--host", "--port", "5173", "--strictPort"]],
|
|
47
|
+
[
|
|
48
|
+
"cap",
|
|
49
|
+
["run", "ios", "--live-reload", "--port", "5173", "--target", "ABC"],
|
|
50
|
+
],
|
|
51
|
+
])
|
|
52
|
+
expect(spawn.children[0].kill).toHaveBeenCalled()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it("builds the web app first when it was never built", async () => {
|
|
56
|
+
const spawn = fakeSpawn()
|
|
57
|
+
|
|
58
|
+
await dev({
|
|
59
|
+
root,
|
|
60
|
+
platform: "android",
|
|
61
|
+
port: 3000,
|
|
62
|
+
spawn,
|
|
63
|
+
isReady: async () => true,
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
expect(
|
|
67
|
+
spawn.calls.map(([command, args]) => `${command} ${args[0]}`),
|
|
68
|
+
).toEqual(["vite build", "vite --host", "cap run"])
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it("stops the dev server when the app fails", async () => {
|
|
72
|
+
buildWebDir(root)
|
|
73
|
+
const spawn = fakeSpawn({ cap: 1 })
|
|
74
|
+
|
|
75
|
+
await expect(
|
|
76
|
+
dev({ root, platform: "android", spawn, isReady: async () => true }),
|
|
77
|
+
).rejects.toThrow("exited with 1")
|
|
78
|
+
expect(spawn.children[0].kill).toHaveBeenCalled()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it("refuses an unknown platform", async () => {
|
|
82
|
+
await expect(dev({ root, platform: "web" })).rejects.toThrow("Usage")
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
function buildWebDir(root) {
|
|
87
|
+
mkdirSync(path.join(root, "dist"))
|
|
88
|
+
writeFileSync(path.join(root, "dist", "index.html"), "")
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function fakeSpawn(exitCodes = {}) {
|
|
92
|
+
const spawn = (command, args) => {
|
|
93
|
+
spawn.calls.push([command, args])
|
|
94
|
+
const child = Object.assign(new EventEmitter(), { kill: vi.fn() })
|
|
95
|
+
spawn.children.push(child)
|
|
96
|
+
const isServer = command === "vite" && args[0] === "--host"
|
|
97
|
+
if (!isServer) setTimeout(() => child.emit("exit", exitCodes[command] ?? 0))
|
|
98
|
+
return child
|
|
99
|
+
}
|
|
100
|
+
spawn.calls = []
|
|
101
|
+
spawn.children = []
|
|
102
|
+
return spawn
|
|
103
|
+
}
|
package/src/dev/index.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { spawn as nodeSpawn } from "node:child_process"
|
|
2
|
+
import { existsSync } from "node:fs"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
import process from "node:process"
|
|
5
|
+
|
|
6
|
+
import { readSettings } from "../config.js"
|
|
7
|
+
|
|
8
|
+
export const DEV_PORT = 5173
|
|
9
|
+
const PLATFORMS = ["ios", "android"]
|
|
10
|
+
const SUCCESS = 0
|
|
11
|
+
const READY_POLL_MS = 250
|
|
12
|
+
const READY_TIMEOUT_MS = 30_000
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Runs the app on a phone or emulator against the Vite dev server on this
|
|
16
|
+
* computer, so every saved file reloads on the device. Vite listens on the
|
|
17
|
+
* local network and `cap run --live-reload` points the native app at it;
|
|
18
|
+
* stopping either stops both, and Capacitor restores the native config.
|
|
19
|
+
*
|
|
20
|
+
* @param {{ root?: string, platform: string, port?: number, args?: string[], spawn?: typeof nodeSpawn, isReady?: (url: string) => Promise<boolean> }} options
|
|
21
|
+
* `args` go to `cap run`, such as `--target <id>`.
|
|
22
|
+
* @returns {Promise<number>} The exit code of `cap run`.
|
|
23
|
+
*/
|
|
24
|
+
export async function dev({
|
|
25
|
+
root = process.cwd(),
|
|
26
|
+
platform,
|
|
27
|
+
port = DEV_PORT,
|
|
28
|
+
args = [],
|
|
29
|
+
spawn = nodeSpawn,
|
|
30
|
+
isReady = respondsAt,
|
|
31
|
+
}) {
|
|
32
|
+
if (!PLATFORMS.includes(platform)) {
|
|
33
|
+
throw new Error(`Usage: inglorious-capacitor dev <ios|android>`)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const { webDir } = await readSettings(root, {})
|
|
37
|
+
if (!existsSync(path.join(root, webDir, "index.html"))) {
|
|
38
|
+
await run(spawn, "vite", ["build"], root)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const commands = devCommands({ platform, port, args })
|
|
42
|
+
const server = spawn(...commands.server, { cwd: root, stdio: "inherit" })
|
|
43
|
+
try {
|
|
44
|
+
await waitFor(() => isReady(`http://localhost:${port}`))
|
|
45
|
+
const ignoreInterrupt = () => {}
|
|
46
|
+
process.on("SIGINT", ignoreInterrupt)
|
|
47
|
+
try {
|
|
48
|
+
return await run(spawn, ...commands.app, root)
|
|
49
|
+
} finally {
|
|
50
|
+
process.off("SIGINT", ignoreInterrupt)
|
|
51
|
+
}
|
|
52
|
+
} finally {
|
|
53
|
+
server.kill()
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The two processes `dev` starts.
|
|
59
|
+
*
|
|
60
|
+
* @param {{ platform: string, port: number, args: string[] }} options
|
|
61
|
+
*/
|
|
62
|
+
export function devCommands({ platform, port, args }) {
|
|
63
|
+
return {
|
|
64
|
+
server: ["vite", ["--host", "--port", String(port), "--strictPort"]],
|
|
65
|
+
app: [
|
|
66
|
+
"cap",
|
|
67
|
+
["run", platform, "--live-reload", "--port", String(port), ...args],
|
|
68
|
+
],
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function run(spawn, command, args, cwd) {
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
const child = spawn(command, args, { cwd, stdio: "inherit" })
|
|
75
|
+
child.on("error", reject)
|
|
76
|
+
child.on("exit", (code) => {
|
|
77
|
+
if (code)
|
|
78
|
+
reject(new Error(`${command} ${args.join(" ")} exited with ${code}`))
|
|
79
|
+
else resolve(SUCCESS)
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function waitFor(check) {
|
|
85
|
+
const deadline = Date.now() + READY_TIMEOUT_MS
|
|
86
|
+
while (!(await check())) {
|
|
87
|
+
if (Date.now() > deadline) {
|
|
88
|
+
throw new Error("The dev server didn't start in time")
|
|
89
|
+
}
|
|
90
|
+
await new Promise((resolve) => setTimeout(resolve, READY_POLL_MS))
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function respondsAt(url) {
|
|
95
|
+
try {
|
|
96
|
+
await fetch(url)
|
|
97
|
+
return true
|
|
98
|
+
} catch {
|
|
99
|
+
return false
|
|
100
|
+
}
|
|
101
|
+
}
|
package/src/index.js
CHANGED
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,20 +51,39 @@ 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
|
|
51
71
|
appName: string
|
|
72
|
+
/** The built web app, from the Capacitor config. */
|
|
73
|
+
webDir: string
|
|
52
74
|
appLinks: Required<NonNullable<NativeConfig["appLinks"]>> | null
|
|
53
75
|
ios: {
|
|
54
76
|
teamId: string | null
|
|
55
77
|
infoPlist: Record<string, unknown>
|
|
56
78
|
swipeBack?: boolean
|
|
79
|
+
privacyManifest: PrivacyManifest | null
|
|
57
80
|
}
|
|
58
81
|
android: { sha256: string[]; permissions: string[] }
|
|
59
82
|
assets: NonNullable<NativeConfig["assets"]>
|
|
60
83
|
hasPush: boolean
|
|
61
84
|
hasAnalytics: boolean
|
|
85
|
+
/** Required-reason APIs the app's plugins use without declaring them. */
|
|
86
|
+
accessedApiTypes: AccessedApiType[]
|
|
62
87
|
}
|
|
63
88
|
|
|
64
89
|
/**
|
|
@@ -162,3 +187,18 @@ export declare function assets(options?: {
|
|
|
162
187
|
root?: string
|
|
163
188
|
platforms?: ("ios" | "android")[]
|
|
164
189
|
}): Promise<void>
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Runs the app on a device or emulator with live reload: Vite listens on the
|
|
193
|
+
* local network and `cap run --live-reload` points the app at it. Builds the
|
|
194
|
+
* web app first if it was never built.
|
|
195
|
+
* @returns The exit code of `cap run`.
|
|
196
|
+
*/
|
|
197
|
+
export declare function dev(options: {
|
|
198
|
+
root?: string
|
|
199
|
+
platform: "ios" | "android"
|
|
200
|
+
/** Defaults to 5173. */
|
|
201
|
+
port?: number
|
|
202
|
+
/** More options for `cap run`, such as `["--target", "<id>"]`. */
|
|
203
|
+
args?: string[]
|
|
204
|
+
}): Promise<number>
|