@inglorious/capacitor 0.0.0-stage → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,9 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright © 2025 Inglorious Coderz Srl.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,95 @@
1
- # Temporary Holding Version
1
+ # @inglorious/capacitor
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
4
+
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
+
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.
8
+ - `inglorious-capacitor assets` makes every icon and splash screen, light and dark, from one logo.
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ pnpm add -D @inglorious/capacitor
14
+ ```
15
+
16
+ Run `configure` as Capacitor's `sync` hook, so `cap add` and `cap sync` both apply the settings:
17
+
18
+ ```json
19
+ {
20
+ "scripts": {
21
+ "capacitor:sync:after": "inglorious-capacitor configure",
22
+ "mobile:init:ios": "cap add ios && inglorious-capacitor assets --ios",
23
+ "mobile:init:android": "cap add android && inglorious-capacitor assets --android",
24
+ "mobile:assets": "inglorious-capacitor assets"
25
+ }
26
+ }
27
+ ```
28
+
29
+ ## Settings
30
+
31
+ The `native` key of `package.json` holds them all. Every key is optional; the app's id comes from `capacitor.config.ts`.
32
+
33
+ ```json
34
+ {
35
+ "native": {
36
+ "appLinks": {
37
+ "domain": "app.example.com",
38
+ "paths": ["/", "/jobs*"],
39
+ "exclude": ["#*access_token*", "/onboarding*"]
40
+ },
41
+ "ios": {
42
+ "teamId": "ABCDE12345",
43
+ "swipeBack": true,
44
+ "infoPlist": {
45
+ "ITSAppUsesNonExemptEncryption": false,
46
+ "NSMicrophoneUsageDescription": "Records the voice message for your job."
47
+ }
48
+ },
49
+ "android": {
50
+ "sha256": ["C1:0B:…:07"],
51
+ "permissions": ["android.permission.RECORD_AUDIO"]
52
+ },
53
+ "assets": {
54
+ "logo": "public/logo.svg",
55
+ "background": "#f6f3ea",
56
+ "backgroundDark": "#2b2b26",
57
+ "iconScale": 0.6,
58
+ "splashScale": 0.3
59
+ }
60
+ }
61
+ }
62
+ ```
63
+
64
+ ### What `configure` does
65
+
66
+ | Setting | iOS | Android |
67
+ | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------ |
68
+ | `appLinks` | Associated Domains entitlement | Verified intent filter for `paths` |
69
+ | `appLinks` + `teamId` | `public/.well-known/apple-app-site-association` | |
70
+ | `appLinks` + `sha256` | | `public/.well-known/assetlinks.json` |
71
+ | `ios.teamId` | `DEVELOPMENT_TEAM`, so Xcode signs without asking | |
72
+ | `ios.swipeBack` | A swipe from the left edge goes back: WebKit's back gesture, turned on in a generated `MainViewController` | |
73
+ | `ios.infoPlist` | Info.plist entries | |
74
+ | `android.permissions` | | `<uses-permission>` entries |
75
+ | `@capacitor-firebase/messaging` installed | Push entitlement, background mode, AppDelegate methods | |
76
+ | `@capacitor-firebase/analytics` installed | Collection off until consent | Collection off until consent |
77
+ | `GoogleService-Info.plist` in the root | Copied into the app target | |
78
+ | `google-services.json` in the root | | Copied to `android/app/` |
79
+ | always | | Keystores kept out of git |
80
+
81
+ 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
+
83
+ It runs for the platform Capacitor is syncing, and changes nothing when the settings haven't changed. Commit `android/` and `ios/` as usual.
84
+
85
+ ### What `assets` does
86
+
87
+ 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
+
89
+ ## License
90
+
91
+ **MIT License - Free and open source**
92
+
93
+ Created by [Matteo Antony Mistretta](https://github.com/IngloriousCoderz)
94
+
95
+ You're free to use, modify, and distribute this software. See [LICENSE](./LICENSE) for details.
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+ import process from "node:process"
3
+
4
+ const USAGE = `Usage: inglorious-capacitor <command>
5
+
6
+ configure Apply the "native" settings in package.json to the
7
+ native projects (runs after every cap sync)
8
+ assets [--ios] [--android]
9
+ Make the app icons and splash screens from the logo`
10
+
11
+ const FIRST_ARGUMENT = 2
12
+ const SUCCESS = 0
13
+ const FAILURE = 1
14
+
15
+ const [command, ...flags] = process.argv.slice(FIRST_ARGUMENT)
16
+
17
+ try {
18
+ if (command === "configure") {
19
+ const { configure } = await import("../src/configure/index.js")
20
+ const platforms = await configure()
21
+ if (platforms.length) console.log(`Configured ${platforms.join(" and ")}`)
22
+ } else if (command === "assets") {
23
+ const { assets } = await import("../src/assets/index.js")
24
+ await assets({ platforms: flags.map((flag) => flag.replace(/^--/, "")) })
25
+ } else {
26
+ console.log(USAGE)
27
+ process.exitCode = command ? FAILURE : SUCCESS
28
+ }
29
+ } catch (error) {
30
+ console.error(error.message)
31
+ process.exitCode = FAILURE
32
+ }
package/package.json CHANGED
@@ -1,6 +1,62 @@
1
1
  {
2
2
  "name": "@inglorious/capacitor",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "description": "Configures Capacitor's native projects from package.json, and makes their icons and splash screens from one logo.",
5
+ "author": "Matteo Antony Mistretta <antony.mistretta@gmail.com> (https://ingloriouscoderz.it)",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/IngloriousCoderz/inglorious-forge.git",
10
+ "directory": "packages/capacitor"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/IngloriousCoderz/inglorious-forge/issues"
14
+ },
15
+ "keywords": [
16
+ "capacitor",
17
+ "ios",
18
+ "android",
19
+ "app-links",
20
+ "icons",
21
+ "splash-screen",
22
+ "inglorious-web"
23
+ ],
24
+ "type": "module",
25
+ "bin": {
26
+ "inglorious-capacitor": "./bin/inglorious-capacitor.js"
27
+ },
28
+ "exports": {
29
+ ".": {
30
+ "types": "./types/index.d.ts",
31
+ "import": "./src/index.js"
32
+ }
33
+ },
34
+ "files": [
35
+ "bin",
36
+ "src",
37
+ "types"
38
+ ],
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "dependencies": {
43
+ "@capacitor/assets": "^4.0.0",
44
+ "@trapezedev/project": "^7.1.11",
45
+ "plist": "^3.1.1",
46
+ "sharp": "^0.35.5"
47
+ },
48
+ "devDependencies": {
49
+ "@inglorious/eslint-config": "1.2.0",
50
+ "prettier": "^3.9.10",
51
+ "vitest": "^5.1.0"
52
+ },
53
+ "engines": {
54
+ "node": ">= 26"
55
+ },
56
+ "scripts": {
57
+ "format": "prettier --write .",
58
+ "lint": "eslint .",
59
+ "test:watch": "vitest",
60
+ "test": "vitest run"
61
+ }
6
62
  }
@@ -0,0 +1,127 @@
1
+ import { execFileSync } from "node:child_process"
2
+ import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs"
3
+ import { createRequire } from "node:module"
4
+ import path from "node:path"
5
+ import process from "node:process"
6
+
7
+ import sharp from "sharp"
8
+
9
+ import { readSettings } from "../config.js"
10
+
11
+ const LOGOS = ["public/logo.svg", "public/logo.png"]
12
+ const SOURCES = path.join(
13
+ "node_modules",
14
+ ".cache",
15
+ "inglorious-capacitor",
16
+ "assets",
17
+ )
18
+ const PLATFORMS = ["ios", "android"]
19
+ const DEFAULTS = {
20
+ background: "#ffffff",
21
+ backgroundDark: "#121212",
22
+ iconScale: 0.6,
23
+ splashScale: 0.3,
24
+ }
25
+ const ICON_SIZE = 1024
26
+ const SPLASH_SIZE = 2732
27
+ const TRANSPARENT = "#00000000"
28
+
29
+ /**
30
+ * Makes every app icon and splash screen of the native projects from one
31
+ * logo, centred on the background colours of `native.assets` in
32
+ * `package.json`, in light and dark. The mark is sized from the logo itself,
33
+ * so a new logo needs no new settings.
34
+ *
35
+ * @param {{ root?: string, platforms?: string[] }} [options] Both platforms
36
+ * that exist by default.
37
+ */
38
+ export async function assets({ root = process.cwd(), platforms = [] } = {}) {
39
+ const settings = await readSettings(root, {})
40
+ const options = { ...DEFAULTS, ...settings.assets }
41
+ const logo =
42
+ options.logo ?? LOGOS.find((file) => existsSync(path.join(root, file)))
43
+ if (!logo || !existsSync(path.join(root, logo))) {
44
+ throw new Error(
45
+ `No logo found: add ${LOGOS.join(" or ")}, or set native.assets.logo`,
46
+ )
47
+ }
48
+
49
+ const targets = (platforms.length ? platforms : PLATFORMS).filter(
50
+ (platform) =>
51
+ PLATFORMS.includes(platform) && existsSync(path.join(root, platform)),
52
+ )
53
+ if (!targets.length) {
54
+ throw new Error(
55
+ "No native project found: run mobile:init:ios or mobile:init:android",
56
+ )
57
+ }
58
+
59
+ const sources = path.join(root, SOURCES)
60
+ rmSync(sources, { recursive: true, force: true })
61
+ mkdirSync(sources, { recursive: true })
62
+ await composeSources(readFileSync(path.join(root, logo)), sources, options)
63
+
64
+ execFileSync(
65
+ process.execPath,
66
+ [
67
+ capacitorAssetsBin(),
68
+ "generate",
69
+ ...targets.map((platform) => `--${platform}`),
70
+ "--assetPath",
71
+ SOURCES,
72
+ ],
73
+ { cwd: root, stdio: "inherit" },
74
+ )
75
+ }
76
+
77
+ /**
78
+ * The sources `@capacitor/assets` expects, so it never scales the logo itself:
79
+ * it would size the splash from the image's width, not the mark's.
80
+ */
81
+ async function composeSources(
82
+ logo,
83
+ dir,
84
+ { background, backgroundDark, iconScale, splashScale },
85
+ ) {
86
+ const mark = (canvas, scale) =>
87
+ sharp(logo)
88
+ .resize(Math.round(canvas * scale))
89
+ .png()
90
+ .toBuffer()
91
+
92
+ const canvas = (size, color) =>
93
+ sharp({
94
+ create: { width: size, height: size, channels: 4, background: color },
95
+ })
96
+
97
+ const icon = await mark(ICON_SIZE, iconScale)
98
+ await canvas(ICON_SIZE, background)
99
+ .composite([{ input: icon, gravity: "centre" }])
100
+ .png()
101
+ .toFile(path.join(dir, "icon-only.png"))
102
+ await canvas(ICON_SIZE, TRANSPARENT)
103
+ .composite([{ input: icon, gravity: "centre" }])
104
+ .png()
105
+ .toFile(path.join(dir, "icon-foreground.png"))
106
+ await canvas(ICON_SIZE, background)
107
+ .png()
108
+ .toFile(path.join(dir, "icon-background.png"))
109
+
110
+ const splash = await mark(SPLASH_SIZE, splashScale)
111
+ for (const [name, color] of [
112
+ ["splash.png", background],
113
+ ["splash-dark.png", backgroundDark],
114
+ ]) {
115
+ await canvas(SPLASH_SIZE, color)
116
+ .composite([{ input: splash, gravity: "centre" }])
117
+ .png()
118
+ .toFile(path.join(dir, name))
119
+ }
120
+ }
121
+
122
+ function capacitorAssetsBin() {
123
+ const require = createRequire(import.meta.url)
124
+ const manifest = require.resolve("@capacitor/assets/package.json")
125
+ const { bin } = JSON.parse(readFileSync(manifest, "utf8"))
126
+ return path.join(path.dirname(manifest), bin["capacitor-assets"])
127
+ }
package/src/config.js ADDED
@@ -0,0 +1,62 @@
1
+ import { existsSync, readFileSync } from "node:fs"
2
+ import path from "node:path"
3
+ import process from "node:process"
4
+ import { pathToFileURL } from "node:url"
5
+
6
+ const CAPACITOR_CONFIG_FILES = [
7
+ "capacitor.config.ts",
8
+ "capacitor.config.js",
9
+ "capacitor.config.json",
10
+ ]
11
+ const PUSH_PLUGIN = "@capacitor-firebase/messaging"
12
+ const ANALYTICS_PLUGIN = "@capacitor-firebase/analytics"
13
+
14
+ /**
15
+ * Everything `configure` and `assets` act on: the app's id and name from the
16
+ * Capacitor config, the `native` settings from `package.json`, and the Firebase
17
+ * plugins the app depends on.
18
+ *
19
+ * @param {string} root The project's folder.
20
+ * @param {NodeJS.ProcessEnv} [env] Capacitor hands its config to hooks here.
21
+ * @returns {Promise<import("../types/index.d.ts").ProjectSettings>}
22
+ */
23
+ export async function readSettings(root, env = process.env) {
24
+ const pkg = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"))
25
+ const capacitor = env.CAPACITOR_CONFIG
26
+ ? JSON.parse(env.CAPACITOR_CONFIG)
27
+ : await importCapacitorConfig(root)
28
+ const dependencies = { ...pkg.dependencies, ...pkg.devDependencies }
29
+ const native = pkg.native ?? {}
30
+
31
+ return {
32
+ root,
33
+ appId: capacitor.appId,
34
+ appName: capacitor.appName,
35
+ appLinks: native.appLinks?.domain
36
+ ? normalizeAppLinks(native.appLinks)
37
+ : null,
38
+ ios: { teamId: null, infoPlist: {}, ...native.ios },
39
+ android: { sha256: [], permissions: [], ...native.android },
40
+ assets: native.assets ?? {},
41
+ hasPush: PUSH_PLUGIN in dependencies,
42
+ hasAnalytics: ANALYTICS_PLUGIN in dependencies,
43
+ }
44
+ }
45
+
46
+ async function importCapacitorConfig(root) {
47
+ const file = CAPACITOR_CONFIG_FILES.map((name) => path.join(root, name)).find(
48
+ (candidate) => existsSync(candidate),
49
+ )
50
+ if (!file) {
51
+ throw new Error(`No Capacitor config found in ${root}`)
52
+ }
53
+ if (file.endsWith(".json")) {
54
+ return JSON.parse(readFileSync(file, "utf8"))
55
+ }
56
+ const { default: config } = await import(pathToFileURL(file).href)
57
+ return config
58
+ }
59
+
60
+ function normalizeAppLinks({ domain, paths = ["/*"], exclude = [] }) {
61
+ return { domain, paths, exclude }
62
+ }
@@ -0,0 +1,96 @@
1
+ import { copyFileSync, existsSync, readFileSync, writeFileSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import { intentFilterData } from "./well-known.js"
5
+
6
+ const ACTIVITY = "manifest/application/activity"
7
+ const APPLICATION = "manifest/application"
8
+ const MANIFEST = "manifest"
9
+ const APP_LINKS_FILTER = `${ACTIVITY}/intent-filter[@android:autoVerify='true']`
10
+ const ANALYTICS_OFF = [
11
+ "firebase_analytics_collection_enabled",
12
+ "google_analytics_adid_collection_enabled",
13
+ ]
14
+ const GOOGLE_SERVICES = "google-services.json"
15
+ const KEYSTORES = ["*.jks", "*.keystore"]
16
+
17
+ /**
18
+ * 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.
20
+ *
21
+ * @param {import("@trapezedev/project").MobileProject} project
22
+ * @param {import("../../types/index.d.ts").ProjectSettings} settings
23
+ */
24
+ export function configureAndroid(project, settings) {
25
+ const manifest = project.android.getAndroidManifest()
26
+ const androidRoot = path.join(settings.root, "android")
27
+
28
+ if (manifest.find(APP_LINKS_FILTER)?.length) {
29
+ manifest.deleteNodes(APP_LINKS_FILTER)
30
+ }
31
+ if (settings.appLinks) {
32
+ manifest.injectFragment(ACTIVITY, appLinksFilter(settings.appLinks))
33
+ }
34
+
35
+ if (settings.hasAnalytics) {
36
+ for (const name of ANALYTICS_OFF) {
37
+ if (
38
+ manifest.find(`${APPLICATION}/meta-data[@android:name='${name}']`)
39
+ ?.length
40
+ ) {
41
+ continue
42
+ }
43
+ manifest.injectFragment(
44
+ APPLICATION,
45
+ `<meta-data android:name="${name}" android:value="false" />`,
46
+ )
47
+ }
48
+ }
49
+
50
+ for (const permission of settings.android.permissions) {
51
+ if (
52
+ manifest.find(
53
+ `${MANIFEST}/uses-permission[@android:name='${permission}']`,
54
+ )?.length
55
+ ) {
56
+ continue
57
+ }
58
+ manifest.injectFragment(
59
+ MANIFEST,
60
+ `<uses-permission android:name="${permission}" />`,
61
+ )
62
+ }
63
+
64
+ const googleServices = path.join(settings.root, GOOGLE_SERVICES)
65
+ if (existsSync(googleServices)) {
66
+ copyFileSync(googleServices, path.join(androidRoot, "app", GOOGLE_SERVICES))
67
+ }
68
+
69
+ ignoreKeystores(path.join(androidRoot, ".gitignore"))
70
+ }
71
+
72
+ function appLinksFilter(appLinks) {
73
+ const data = intentFilterData(appLinks)
74
+ .map(
75
+ (attributes) =>
76
+ `<data ${Object.entries(attributes)
77
+ .map(([name, value]) => `${name}="${value}"`)
78
+ .join(" ")} />`,
79
+ )
80
+ .join("")
81
+
82
+ return `<intent-filter android:autoVerify="true"><action android:name="android.intent.action.VIEW" /><category android:name="android.intent.category.DEFAULT" /><category android:name="android.intent.category.BROWSABLE" />${data}</intent-filter>`
83
+ }
84
+
85
+ function ignoreKeystores(file) {
86
+ if (!existsSync(file)) return
87
+
88
+ const source = readFileSync(file, "utf8")
89
+ const lines = source.split("\n").map((line) => {
90
+ const uncommented = line.replace(/^#/, "")
91
+ return KEYSTORES.includes(uncommented) ? uncommented : line
92
+ })
93
+ const missing = KEYSTORES.filter((pattern) => !lines.includes(pattern))
94
+ const next = [...lines, ...missing].join("\n")
95
+ if (next !== source) writeFileSync(file, next)
96
+ }