@inglorious/capacitor 0.2.0 → 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
@@ -65,20 +65,37 @@ The `native` key of `package.json` holds them all. Every key is optional; the ap
65
65
 
66
66
  ### What `configure` does
67
67
 
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
- | `android.permissions` | | `<uses-permission>` entries |
77
- | `@capacitor-firebase/messaging` installed | Push entitlement, background mode, AppDelegate methods | |
78
- | `@capacitor-firebase/analytics` installed | Collection off until consent | Collection off until consent, and no advertising ID permission |
79
- | `GoogleService-Info.plist` in the root | Copied into the app target | |
80
- | `google-services.json` in the root | | Copied to `android/app/` |
81
- | 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`.
82
99
 
83
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`.
84
101
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inglorious/capacitor",
3
- "version": "0.2.0",
3
+ "version": "0.3.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
@@ -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
 
@@ -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"),
@@ -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/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
  /**