@jskit-ai/mobile-capacitor 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.
@@ -0,0 +1,231 @@
1
+ import path from "node:path";
2
+ import { readdir, rm } from "node:fs/promises";
3
+ import { loadAppConfigFromAppRoot, resolveMobileConfig } from "@jskit-ai/kernel/server/support";
4
+ import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
5
+
6
+ function requireNonEmptyText(value, label = "value") {
7
+ const normalized = normalizeText(value);
8
+ if (!normalized) {
9
+ throw new Error(`${label} is required in config.mobile before installing Capacitor support.`);
10
+ }
11
+ return normalized;
12
+ }
13
+
14
+ function requireUrl(value, label = "value", { allowHttp = true, allowHttps = true } = {}) {
15
+ const normalized = requireNonEmptyText(value, label);
16
+ let parsed = null;
17
+ try {
18
+ parsed = new URL(normalized);
19
+ } catch {
20
+ throw new Error(`${label} must be a valid absolute URL in config.mobile.`);
21
+ }
22
+
23
+ const protocol = String(parsed.protocol || "").toLowerCase();
24
+ if ((protocol === "http:" && allowHttp !== true) || (protocol === "https:" && allowHttps !== true)) {
25
+ throw new Error(`${label} must use an allowed protocol in config.mobile.`);
26
+ }
27
+ if (protocol !== "http:" && protocol !== "https:") {
28
+ throw new Error(`${label} must use http or https in config.mobile.`);
29
+ }
30
+
31
+ return parsed.toString();
32
+ }
33
+
34
+ function buildCapacitorServerBlock(mobileConfig = {}) {
35
+ if (mobileConfig.assetMode !== "dev_server") {
36
+ return "";
37
+ }
38
+
39
+ const devServerUrl = requireUrl(mobileConfig.devServerUrl, "config.mobile.devServerUrl");
40
+ const cleartext = String(new URL(devServerUrl).protocol || "").toLowerCase() === "http:";
41
+
42
+ return [
43
+ ",",
44
+ ' "server": {',
45
+ ` "url": ${JSON.stringify(devServerUrl)},`,
46
+ ` "cleartext": ${cleartext}`,
47
+ " }"
48
+ ].join("\n");
49
+ }
50
+
51
+ function buildAppLinkDomainsValue(appLinkDomains = []) {
52
+ const domains = Array.isArray(appLinkDomains) ? appLinkDomains : [];
53
+ if (domains.length < 1) {
54
+ return "(none)";
55
+ }
56
+ return domains.join(", ");
57
+ }
58
+
59
+ async function directoryContainsAnyFiles(directoryPath = "") {
60
+ const entries = await readdir(directoryPath, { withFileTypes: true });
61
+ for (const entry of entries) {
62
+ const absoluteChildPath = path.join(directoryPath, entry.name);
63
+ if (entry.isFile() || entry.isSymbolicLink()) {
64
+ return true;
65
+ }
66
+ if (entry.isDirectory() && await directoryContainsAnyFiles(absoluteChildPath)) {
67
+ return true;
68
+ }
69
+ }
70
+ return false;
71
+ }
72
+
73
+ async function buildTemplateContext({ appRoot } = {}) {
74
+ const mergedConfig = await loadAppConfigFromAppRoot({
75
+ appRoot: path.resolve(String(appRoot || ""))
76
+ });
77
+ const mobileConfig = resolveMobileConfig({
78
+ mobile: mergedConfig.mobile
79
+ });
80
+
81
+ if (mobileConfig.enabled !== true) {
82
+ throw new Error("config.mobile.enabled must be true before installing Capacitor support.");
83
+ }
84
+ if (mobileConfig.strategy !== "capacitor") {
85
+ throw new Error('config.mobile.strategy must be "capacitor" before installing Capacitor support.');
86
+ }
87
+
88
+ const appId = requireNonEmptyText(mobileConfig.appId, "config.mobile.appId");
89
+ const appName = requireNonEmptyText(mobileConfig.appName, "config.mobile.appName");
90
+ const apiBaseUrl = requireUrl(mobileConfig.apiBaseUrl, "config.mobile.apiBaseUrl");
91
+ const callbackPath = requireNonEmptyText(mobileConfig.auth.callbackPath, "config.mobile.auth.callbackPath");
92
+ const customScheme = requireNonEmptyText(mobileConfig.auth.customScheme, "config.mobile.auth.customScheme");
93
+ const androidPackageName = requireNonEmptyText(
94
+ mobileConfig.android.packageName,
95
+ "config.mobile.android.packageName"
96
+ );
97
+ const androidVersionName = requireNonEmptyText(
98
+ mobileConfig.android.versionName,
99
+ "config.mobile.android.versionName"
100
+ );
101
+
102
+ return {
103
+ "__JSKIT_MOBILE_CAPACITOR_APP_ID__": appId,
104
+ "__JSKIT_MOBILE_CAPACITOR_APP_NAME__": appName,
105
+ "__JSKIT_MOBILE_CAPACITOR_WEB_DIR__": "dist",
106
+ "__JSKIT_MOBILE_CAPACITOR_SERVER_BLOCK__": buildCapacitorServerBlock(mobileConfig),
107
+ "__JSKIT_MOBILE_CAPACITOR_ASSET_MODE__": mobileConfig.assetMode,
108
+ "__JSKIT_MOBILE_CAPACITOR_DEV_SERVER_URL__": mobileConfig.devServerUrl || "(unused)",
109
+ "__JSKIT_MOBILE_CAPACITOR_API_BASE_URL__": apiBaseUrl,
110
+ "__JSKIT_MOBILE_CAPACITOR_CALLBACK_PATH__": callbackPath,
111
+ "__JSKIT_MOBILE_CAPACITOR_CUSTOM_SCHEME__": customScheme,
112
+ "__JSKIT_MOBILE_CAPACITOR_APP_LINK_DOMAINS__": buildAppLinkDomainsValue(mobileConfig.auth.appLinkDomains),
113
+ "__JSKIT_MOBILE_CAPACITOR_ANDROID_PACKAGE_NAME__": androidPackageName,
114
+ "__JSKIT_MOBILE_CAPACITOR_ANDROID_MIN_SDK__": String(mobileConfig.android.minSdk),
115
+ "__JSKIT_MOBILE_CAPACITOR_ANDROID_TARGET_SDK__": String(mobileConfig.android.targetSdk),
116
+ "__JSKIT_MOBILE_CAPACITOR_ANDROID_VERSION_CODE__": String(mobileConfig.android.versionCode),
117
+ "__JSKIT_MOBILE_CAPACITOR_ANDROID_VERSION_NAME__": androidVersionName
118
+ };
119
+ }
120
+
121
+ async function prepareInstallHook({
122
+ appRoot,
123
+ appPackageJson = {},
124
+ io,
125
+ dryRun = false,
126
+ helpers = {}
127
+ } = {}) {
128
+ const ensureManagedMobileConfig = helpers?.ensureManagedMobileConfig;
129
+ if (typeof ensureManagedMobileConfig !== "function") {
130
+ throw new Error("install hook helpers.ensureManagedMobileConfig is required.");
131
+ }
132
+
133
+ const addedConfigStub = await ensureManagedMobileConfig({
134
+ dryRun
135
+ });
136
+ if (dryRun === true && addedConfigStub) {
137
+ return {
138
+ stopInstall: true,
139
+ touchedFiles: ["config/public.js"],
140
+ stopMessage:
141
+ "[dry-run] mobile package install preview stops after the config.mobile stub because rendered Capacitor files depend on those values."
142
+ };
143
+ }
144
+
145
+ return {};
146
+ }
147
+
148
+ async function finalizeInstallHook({
149
+ appRoot,
150
+ io,
151
+ dryRun = false,
152
+ skipManagedFinalize = false,
153
+ helpers = {}
154
+ } = {}) {
155
+ if (skipManagedFinalize === true) {
156
+ return {};
157
+ }
158
+
159
+ const installAppDependencies = helpers?.installAppDependencies;
160
+ const runProjectBinary = helpers?.runProjectBinary;
161
+ const helperFileExists = helpers?.fileExists;
162
+ const collectShellInstallIssues = helpers?.collectCapacitorShellInstallIssues;
163
+ const ensureDeepLinks = helpers?.ensureAndroidManifestDeepLinks;
164
+ const ensureNativeShellIdentity = helpers?.ensureAndroidNativeShellIdentity;
165
+ if (typeof installAppDependencies !== "function") {
166
+ throw new Error("install hook helpers.installAppDependencies is required.");
167
+ }
168
+ if (typeof runProjectBinary !== "function") {
169
+ throw new Error("install hook helpers.runProjectBinary is required.");
170
+ }
171
+ if (typeof helperFileExists !== "function") {
172
+ throw new Error("install hook helpers.fileExists is required.");
173
+ }
174
+ if (typeof collectShellInstallIssues !== "function") {
175
+ throw new Error("install hook helpers.collectCapacitorShellInstallIssues is required.");
176
+ }
177
+ if (typeof ensureDeepLinks !== "function") {
178
+ throw new Error("install hook helpers.ensureAndroidManifestDeepLinks is required.");
179
+ }
180
+ if (typeof ensureNativeShellIdentity !== "function") {
181
+ throw new Error("install hook helpers.ensureAndroidNativeShellIdentity is required.");
182
+ }
183
+
184
+ await installAppDependencies({
185
+ dryRun
186
+ });
187
+
188
+ const androidDirectoryPath = path.join(appRoot, "android");
189
+ const shellInstallIssues = await collectShellInstallIssues();
190
+ if (shellInstallIssues.length < 1) {
191
+ io?.stdout?.write("[mobile] Android shell is already installed. Skipping cap add android.\n");
192
+ } else {
193
+ if (await helperFileExists(androidDirectoryPath)) {
194
+ io?.stdout?.write("[mobile] Android shell is partial or stale. Reprovisioning it with Capacitor CLI because these artifacts are missing:\n");
195
+ for (const issue of shellInstallIssues) {
196
+ io?.stdout?.write(`- ${issue}\n`);
197
+ }
198
+ const androidDirectoryHasFiles = await directoryContainsAnyFiles(androidDirectoryPath);
199
+ if (!androidDirectoryHasFiles) {
200
+ io?.stdout?.write("[mobile] android/ exists but contains no files. Removing the empty partial shell before reprovisioning.\n");
201
+ if (!dryRun) {
202
+ await rm(androidDirectoryPath, { recursive: true, force: true });
203
+ }
204
+ } else {
205
+ throw new Error(
206
+ "android/ exists but the Capacitor shell is incomplete. JSKIT will not delete a non-empty native tree automatically. Remove android/ and rerun the install."
207
+ );
208
+ }
209
+ } else {
210
+ io?.stdout?.write("[mobile] Android shell is not installed yet. Provisioning it with Capacitor CLI.\n");
211
+ }
212
+ await runProjectBinary("cap", ["add", "android"], {
213
+ explanation: "[mobile] Provisioning Android shell with Capacitor CLI:",
214
+ dryRun
215
+ });
216
+ if (!dryRun) {
217
+ io?.stdout?.write("[mobile] Added Android shell with Capacitor CLI.\n");
218
+ }
219
+ }
220
+ await ensureDeepLinks({
221
+ dryRun
222
+ });
223
+ await ensureNativeShellIdentity({
224
+ dryRun
225
+ });
226
+ return {
227
+ touchedFiles: ["android"]
228
+ };
229
+ }
230
+
231
+ export { buildTemplateContext, prepareInstallHook, finalizeInstallHook };
@@ -0,0 +1,10 @@
1
+ {
2
+ "appId": "__JSKIT_MOBILE_CAPACITOR_APP_ID__",
3
+ "appName": "__JSKIT_MOBILE_CAPACITOR_APP_NAME__",
4
+ "webDir": "__JSKIT_MOBILE_CAPACITOR_WEB_DIR__"__JSKIT_MOBILE_CAPACITOR_SERVER_BLOCK__,
5
+ "plugins": {
6
+ "CapacitorHttp": {
7
+ "enabled": true
8
+ }
9
+ }
10
+ }
@@ -0,0 +1,47 @@
1
+ # Mobile Capacitor
2
+
3
+ This file is managed by `@jskit-ai/mobile-capacitor`.
4
+
5
+ Installed contract:
6
+
7
+ - strategy: `capacitor`
8
+ - asset mode: `__JSKIT_MOBILE_CAPACITOR_ASSET_MODE__`
9
+ - dev server url: `__JSKIT_MOBILE_CAPACITOR_DEV_SERVER_URL__`
10
+ - API base URL: `__JSKIT_MOBILE_CAPACITOR_API_BASE_URL__`
11
+ - auth callback path: `__JSKIT_MOBILE_CAPACITOR_CALLBACK_PATH__`
12
+ - custom scheme: `__JSKIT_MOBILE_CAPACITOR_CUSTOM_SCHEME__`
13
+ - app link domains: `__JSKIT_MOBILE_CAPACITOR_APP_LINK_DOMAINS__`
14
+ - Capacitor app id: `__JSKIT_MOBILE_CAPACITOR_APP_ID__`
15
+ - Capacitor app name: `__JSKIT_MOBILE_CAPACITOR_APP_NAME__`
16
+ - Android package name: `__JSKIT_MOBILE_CAPACITOR_ANDROID_PACKAGE_NAME__`
17
+ - Android min SDK: `__JSKIT_MOBILE_CAPACITOR_ANDROID_MIN_SDK__`
18
+ - Android target SDK: `__JSKIT_MOBILE_CAPACITOR_ANDROID_TARGET_SDK__`
19
+ - Android version code: `__JSKIT_MOBILE_CAPACITOR_ANDROID_VERSION_CODE__`
20
+ - Android version name: `__JSKIT_MOBILE_CAPACITOR_ANDROID_VERSION_NAME__`
21
+
22
+ Owned artifacts:
23
+
24
+ - `capacitor.config.json`
25
+ - `android/` after `jskit add package @jskit-ai/mobile-capacitor` or `jskit mobile add capacitor` runs `cap add android`
26
+ - `android/app/src/main/AndroidManifest.xml` managed deep-link intent filter for the custom scheme
27
+
28
+ Managed commands:
29
+
30
+ - `jskit add package @jskit-ai/mobile-capacitor`
31
+ - `jskit mobile dev android [--target <device-id>]`
32
+ - `jskit mobile devices android`
33
+ - `jskit mobile add capacitor`
34
+ - `jskit mobile sync android`
35
+ - `jskit mobile tunnel android --target <device-id>`
36
+ - `jskit mobile restart android --target <device-id>`
37
+ - `jskit mobile run android [--target <device-id>]`
38
+ - `jskit mobile build android`
39
+ - `jskit mobile doctor`
40
+
41
+ Current Stage 1 limits:
42
+
43
+ - Android only
44
+ - web assets stay the JSKIT web client
45
+ - OAuth start uses the external browser/custom tab only when the app is running inside the Capacitor shell
46
+ - auth/deep-link handling stays routed through normal JSKIT paths
47
+ - native app-link verification is still out of scope for Stage 1
@@ -0,0 +1,187 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { MobileCapacitorClientProvider } from "../src/client/providers/MobileCapacitorClientProvider.js";
4
+
5
+ function createAppDouble({
6
+ adapter = null,
7
+ authCallbackCompleter = null,
8
+ authGuardRuntime = null,
9
+ placementRuntime = null
10
+ } = {}) {
11
+ const singletons = new Map();
12
+ const singletonInstances = new Map();
13
+ const runtime = {
14
+ initializeCalls: 0,
15
+ disposeCalls: 0
16
+ };
17
+
18
+ const app = {
19
+ singleton(token, factory) {
20
+ singletons.set(token, factory);
21
+ },
22
+ has(token) {
23
+ if (token === "jskit.client.router") {
24
+ return true;
25
+ }
26
+ if (token === "runtime.web-placement.client") {
27
+ return Boolean(placementRuntime);
28
+ }
29
+ if (token === "auth.mobile-callback.client") {
30
+ return Boolean(authCallbackCompleter);
31
+ }
32
+ if (token === "runtime.auth-guard.client") {
33
+ return Boolean(authGuardRuntime);
34
+ }
35
+ if (token === "auth.oauth-launch.client") {
36
+ return singletons.has(token) || singletonInstances.has(token);
37
+ }
38
+ return singletons.has(token) || singletonInstances.has(token);
39
+ },
40
+ make(token) {
41
+ if (token === "jskit.client.router") {
42
+ return {
43
+ currentRoute: {
44
+ value: {
45
+ fullPath: "/home"
46
+ }
47
+ },
48
+ async replace() {}
49
+ };
50
+ }
51
+ if (token === "runtime.web-placement.client") {
52
+ return placementRuntime;
53
+ }
54
+ if (token === "auth.mobile-callback.client") {
55
+ return authCallbackCompleter;
56
+ }
57
+ if (token === "runtime.auth-guard.client") {
58
+ return authGuardRuntime;
59
+ }
60
+ if (singletonInstances.has(token)) {
61
+ return singletonInstances.get(token);
62
+ }
63
+ const factory = singletons.get(token);
64
+ if (!factory) {
65
+ throw new Error(`Unknown token ${String(token)}`);
66
+ }
67
+ const instance = factory(this);
68
+ singletonInstances.set(token, instance);
69
+ return instance;
70
+ }
71
+ };
72
+
73
+ if (adapter) {
74
+ app.singleton("mobile.capacitor.adapter.client", () => adapter);
75
+ }
76
+
77
+ return {
78
+ app,
79
+ runtime,
80
+ singletons,
81
+ singletonInstances
82
+ };
83
+ }
84
+
85
+ test("MobileCapacitorClientProvider registers and boots the mobile runtime", async () => {
86
+ globalThis.__JSKIT_CLIENT_APP_CONFIG__ = {
87
+ mobile: {
88
+ enabled: true,
89
+ auth: {
90
+ customScheme: "convict"
91
+ }
92
+ }
93
+ };
94
+
95
+ try {
96
+ const adapter = {
97
+ available: true,
98
+ async getInitialLaunchUrl() {
99
+ return "";
100
+ },
101
+ subscribeToLaunchUrls() {
102
+ return () => {};
103
+ }
104
+ };
105
+ const { app } = createAppDouble({ adapter });
106
+ const provider = new MobileCapacitorClientProvider();
107
+
108
+ provider.register(app);
109
+
110
+ assert.equal(app.has("mobile.capacitor.adapter.client"), true);
111
+ assert.equal(app.has("mobile.capacitor.client.runtime"), true);
112
+ assert.equal(app.has("auth.oauth-launch.client"), true);
113
+
114
+ const runtime = app.make("mobile.capacitor.client.runtime");
115
+ assert.equal(typeof runtime.initialize, "function");
116
+ assert.equal(runtime.getState().available, true);
117
+
118
+ await provider.boot(app);
119
+ assert.equal(runtime.getState().initialized, true);
120
+
121
+ provider.shutdown(app);
122
+ assert.equal(runtime.getState().initialized, false);
123
+ } finally {
124
+ delete globalThis.__JSKIT_CLIENT_APP_CONFIG__;
125
+ }
126
+ });
127
+
128
+ test("MobileCapacitorClientProvider installs and restores the Capacitor fetch wrapper", async () => {
129
+ const originalConfig = globalThis.__JSKIT_CLIENT_APP_CONFIG__;
130
+ const originalFetch = globalThis.fetch;
131
+ const fetchCalls = [];
132
+ const stubFetch = async (url, options) => {
133
+ fetchCalls.push({
134
+ url,
135
+ options
136
+ });
137
+ return {
138
+ ok: true
139
+ };
140
+ };
141
+
142
+ globalThis.__JSKIT_CLIENT_APP_CONFIG__ = {
143
+ mobile: {
144
+ enabled: true,
145
+ apiBaseUrl: "http://127.0.0.1:3000",
146
+ auth: {
147
+ customScheme: "exampleapp"
148
+ }
149
+ }
150
+ };
151
+ globalThis.fetch = stubFetch;
152
+
153
+ try {
154
+ const { app } = createAppDouble({
155
+ adapter: {
156
+ available: true,
157
+ async getInitialLaunchUrl() {
158
+ return "";
159
+ },
160
+ subscribeToLaunchUrls() {
161
+ return () => {};
162
+ }
163
+ }
164
+ });
165
+ const provider = new MobileCapacitorClientProvider();
166
+
167
+ provider.register(app);
168
+ await globalThis.fetch("/api/session", {
169
+ method: "GET"
170
+ });
171
+ await provider.boot(app);
172
+ provider.shutdown(app);
173
+
174
+ assert.deepEqual(fetchCalls, [
175
+ {
176
+ url: "http://127.0.0.1:3000/api/session",
177
+ options: {
178
+ method: "GET"
179
+ }
180
+ }
181
+ ]);
182
+ assert.equal(globalThis.fetch, stubFetch);
183
+ } finally {
184
+ globalThis.__JSKIT_CLIENT_APP_CONFIG__ = originalConfig;
185
+ globalThis.fetch = originalFetch;
186
+ }
187
+ });