craft-native 0.0.200 → 0.0.201

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.
@@ -50,6 +50,14 @@ export interface CraftAndroidConfig {
50
50
  /** Reach the person's own devices on the local network over plain HTTP (a Mac companion). */
51
51
  enableLocalNetwork?: boolean;
52
52
  urlSchemes?: string[];
53
+ /** HTTPS hosts owned by the app; path rules are ordered, with `*` matching any path text. */
54
+ appLinks?: Array<{
55
+ host: string;
56
+ paths?: Array<{
57
+ pattern: string;
58
+ exclude?: boolean;
59
+ }>;
60
+ }>;
53
61
  trustedOrigins?: string[];
54
62
  appIconPath?: string;
55
63
  googleServicesFile?: string;
@@ -188,6 +188,7 @@ var DEFAULT_CONFIG = {
188
188
  enableKeepAwake: false,
189
189
  enableDeepLinks: false,
190
190
  urlSchemes: [],
191
+ appLinks: [],
191
192
  trustedOrigins: [],
192
193
  minSdk: ANDROID_MIN_SDK,
193
194
  compileSdk: 36,
@@ -255,8 +256,21 @@ function normalizeAndroidNetworkConfig(config) {
255
256
  throw new Error("Android deep-link schemes must be valid URI schemes");
256
257
  }
257
258
  config.urlSchemes = [...new Set(schemes)];
258
- if (config.enableDeepLinks && config.urlSchemes.length === 0) {
259
- throw new Error("Android deep links require at least one URL scheme");
259
+ config.appLinks = (config.appLinks ?? []).map((link) => {
260
+ const host = link.host.trim().toLowerCase();
261
+ const parsed = new URL(`https://${host}`);
262
+ if (!host || parsed.hostname !== host || parsed.port || parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash)
263
+ throw new Error("Android App Links need a hostname without a scheme, port or path");
264
+ for (const rule of link.paths ?? []) {
265
+ if (!rule.pattern.startsWith("/") || /[\x00-\x1f]/.test(rule.pattern) || rule.exclude !== undefined && typeof rule.exclude !== "boolean")
266
+ throw new Error("Android App Link path rules need an absolute path pattern and a boolean exclude");
267
+ }
268
+ return { ...link, host };
269
+ });
270
+ if (new Set(config.appLinks.map((link) => link.host)).size !== config.appLinks.length)
271
+ throw new Error("Android App Link hosts must be unique");
272
+ if (config.enableDeepLinks && config.urlSchemes.length === 0 && config.appLinks.length === 0) {
273
+ throw new Error("Android deep links require at least one URL scheme or an App Link host");
260
274
  }
261
275
  const trustedOrigins = (config.trustedOrigins ?? []).map((value) => {
262
276
  return androidWebUrl(value, "Android trusted origin").origin;
@@ -454,12 +468,25 @@ function renderAndroidDeepLinks(config) {
454
468
  if (!config.enableDeepLinks)
455
469
  return "";
456
470
  const schemes = [...new Set(config.urlSchemes?.map((value) => value.trim()).filter((value) => /^[a-z][a-z0-9+.-]*$/i.test(value)) ?? [])];
457
- return schemes.map((scheme) => ` <intent-filter>
471
+ const custom = schemes.map((scheme) => ` <intent-filter>
458
472
  <action android:name="android.intent.action.VIEW" />
459
473
  <category android:name="android.intent.category.DEFAULT" />
460
474
  <category android:name="android.intent.category.BROWSABLE" />
461
475
  <data android:scheme="${scheme}" />
462
476
  </intent-filter>`).join(`
477
+ `);
478
+ const verified = (config.appLinks ?? []).map((link) => {
479
+ if (!/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/i.test(link.host))
480
+ throw new Error("Invalid Android App Link host");
481
+ return ` <intent-filter android:autoVerify="true">
482
+ <action android:name="android.intent.action.VIEW" />
483
+ <category android:name="android.intent.category.DEFAULT" />
484
+ <category android:name="android.intent.category.BROWSABLE" />
485
+ <data android:scheme="https" android:host="${link.host}" />
486
+ </intent-filter>`;
487
+ }).join(`
488
+ `);
489
+ return [custom, verified].filter(Boolean).join(`
463
490
  `);
464
491
  }
465
492
  var RUNTIME_ABIS = ["arm64-v8a", "x86_64"];
@@ -555,6 +582,7 @@ async function init(options) {
555
582
  const mainActivityTemplate = readFileSync(join(TEMPLATES_DIR, config.renderer === "native" ? "MainActivityNative.kt.template" : "MainActivity.kt.template"), "utf-8");
556
583
  const mainActivity = mainActivityTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{APP_NAME\}\}/g, name);
557
584
  writeFileSync(join(output, "app/src/main/java", packagePath, "MainActivity.kt"), mainActivity);
585
+ writeFileSync(join(output, "app/src/main/java", packagePath, "CraftAppLinks.kt"), readFileSync(join(TEMPLATES_DIR, "CraftAppLinks.kt.template"), "utf8").replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
558
586
  writeFileSync(join(output, "app/src/main/java", packagePath, "CraftCrashReporter.kt"), readFileSync(join(TEMPLATES_DIR, "CraftCrashReporter.kt.template"), "utf-8").replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{CRAFT_VERSION\}\}/g, CRAFT_ANDROID_VERSION));
559
587
  if (config.renderer === "native") {
560
588
  const mutationTemplate = readFileSync(join(TEMPLATES_DIR, "CraftNativeMutation.kt.template"), "utf-8");
@@ -8,6 +8,12 @@
8
8
  <!-- craft.speech: from Android 11 an app cannot see, and so cannot bind,
9
9
  a text-to-speech engine it has not declared it will look for. -->
10
10
  <queries>
11
+ <intent><action android:name="android.support.customtabs.action.CustomTabsService" /></intent>
12
+ <intent>
13
+ <action android:name="android.intent.action.VIEW" />
14
+ <category android:name="android.intent.category.BROWSABLE" />
15
+ <data android:scheme="https" />
16
+ </intent>
11
17
  <intent>
12
18
  <action android:name="android.intent.action.TTS_SERVICE" />
13
19
  </intent>
@@ -0,0 +1,47 @@
1
+ package {{PACKAGE_NAME}}
2
+
3
+ import android.app.Activity
4
+ import android.content.Intent
5
+ import android.net.Uri
6
+ import androidx.browser.customtabs.CustomTabsClient
7
+ import androidx.browser.customtabs.CustomTabsIntent
8
+ import org.json.JSONObject
9
+
10
+ /** Ordered website path rules, also publishable as Android 15 dynamic App Links. */
11
+ internal object CraftAppLinks {
12
+ fun opensInBrowser(config: JSONObject?, uri: Uri): Boolean {
13
+ if (uri.scheme != "https") return false
14
+ val hosts = config?.optJSONArray("appLinks") ?: return false
15
+ for (index in 0 until hosts.length()) {
16
+ val host = hosts.optJSONObject(index) ?: continue
17
+ if (!host.optString("host").equals(uri.host, ignoreCase = true)) continue
18
+ val rules = host.optJSONArray("paths") ?: return false
19
+ val path = uri.path.orEmpty().ifEmpty { "/" }
20
+ for (ruleIndex in 0 until rules.length()) {
21
+ val rule = rules.optJSONObject(ruleIndex) ?: continue
22
+ val pattern = rule.optString("pattern")
23
+ val regex = pattern.split('*').joinToString(".*") { Regex.escape(it) }
24
+ if (Regex("^$regex$").matches(path)) return rule.optBoolean("exclude", false)
25
+ }
26
+ return true
27
+ }
28
+ return false
29
+ }
30
+
31
+ /** An explicit browser package avoids handing a verified site URL back to ourselves. */
32
+ fun openBrowser(activity: Activity, uri: Uri) {
33
+ val customTabsPackage = CustomTabsClient.getPackageName(activity, null)
34
+ if (customTabsPackage != null && customTabsPackage != activity.packageName) {
35
+ val tab = CustomTabsIntent.Builder().setShowTitle(true).build()
36
+ tab.intent.setPackage(customTabsPackage)
37
+ tab.launchUrl(activity, uri)
38
+ return
39
+ }
40
+ val probe = Intent(Intent.ACTION_VIEW, Uri.parse("https://example.com")).addCategory(Intent.CATEGORY_BROWSABLE)
41
+ val browser = activity.packageManager.queryIntentActivities(probe, 0)
42
+ .firstOrNull { it.activityInfo.packageName != activity.packageName }
43
+ ?.activityInfo?.packageName
44
+ ?: throw IllegalStateException("No browser is installed")
45
+ activity.startActivity(Intent(Intent.ACTION_VIEW, uri).addCategory(Intent.CATEGORY_BROWSABLE).setPackage(browser))
46
+ }
47
+ }
@@ -17,7 +17,6 @@ import android.speech.tts.UtteranceProgressListener
17
17
  import android.util.Log
18
18
  import android.view.WindowManager
19
19
  import androidx.appcompat.app.AlertDialog
20
- import androidx.browser.customtabs.CustomTabsIntent
21
20
  import androidx.core.app.ActivityCompat
22
21
  import androidx.core.app.NotificationManagerCompat
23
22
  import androidx.core.content.ContextCompat
@@ -204,7 +203,7 @@ internal class CraftNativeServices(
204
203
  pendingBrowser = callback to done
205
204
  browserOpened = true
206
205
  try {
207
- CustomTabsIntent.Builder().setShowTitle(true).build().launchUrl(host, uri)
206
+ CraftAppLinks.openBrowser(host, uri)
208
207
  } catch (_: ActivityNotFoundException) {
209
208
  host.startActivity(Intent(Intent.ACTION_VIEW, uri))
210
209
  }
@@ -99,6 +99,11 @@ class MainActivity : AppCompatActivity() {
99
99
  // reading its intent, which onNewIntent may have replaced with a
100
100
  // later warm link.
101
101
  val launchedFromHistory = intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0
102
+ if (savedInstanceState == null && !launchedFromHistory && intent.data?.let { CraftAppLinks.opensInBrowser(configJson(), it) } == true) {
103
+ CraftAppLinks.openBrowser(this, intent.data!!)
104
+ finish()
105
+ return
106
+ }
102
107
  if (savedInstanceState == null) {
103
108
  if (!launchedFromHistory) {
104
109
  intent.data?.toString()?.let(craftBridge::receiveDeepLink)
@@ -300,6 +305,12 @@ class MainActivity : AppCompatActivity() {
300
305
  override fun onNewIntent(intent: Intent) {
301
306
  super.onNewIntent(intent)
302
307
  setIntent(intent)
308
+ intent.data?.let { link ->
309
+ if (CraftAppLinks.opensInBrowser(configJson(), link)) {
310
+ CraftAppLinks.openBrowser(this, link)
311
+ return
312
+ }
313
+ }
303
314
  intent.data?.toString()?.let(craftBridge::receiveDeepLink)
304
315
  handleIncomingShortcut(intent)
305
316
  }
@@ -3458,6 +3458,11 @@ class MainActivity : AppCompatActivity() {
3458
3458
  routes = CraftNativeRoutes(config)
3459
3459
  stores = CraftNativeStores.get(this, config)
3460
3460
  services = CraftNativeServices(this, config) { this }
3461
+ if (savedInstanceState == null && intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY == 0 && intent.data?.let { CraftAppLinks.opensInBrowser(config, it) } == true) {
3462
+ CraftAppLinks.openBrowser(this, intent.data!!)
3463
+ finish()
3464
+ return
3465
+ }
3461
3466
  services.onBackgroundComplete = { ok, id ->
3462
3467
  if (id == null) {
3463
3468
  val waiting = backgroundWaiters.values.toList()
@@ -6182,6 +6187,10 @@ class MainActivity : AppCompatActivity() {
6182
6187
  }
6183
6188
  intent.data?.let { link ->
6184
6189
  if (::services.isInitialized && services.deepLink(link)) return
6190
+ if (CraftAppLinks.opensInBrowser(config, link)) {
6191
+ CraftAppLinks.openBrowser(this, link)
6192
+ return
6193
+ }
6185
6194
  publish("DEEP_LINK", deepLinkData(link).put("initial", false))
6186
6195
  if (::routes.isInitialized && !routes.isEmpty) routes.appPath(link.toString(), urlSchemes)?.let { navigateOpen(it, fromLink = true) }
6187
6196
  }
package/dist/cli.js CHANGED
@@ -3424,7 +3424,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3424
3424
  `);
3425
3425
  }
3426
3426
  // package.json
3427
- var version = "0.0.200";
3427
+ var version = "0.0.201";
3428
3428
 
3429
3429
  // bin/cli.ts
3430
3430
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -309,7 +309,7 @@ function craftBinaryNotFoundMessage(triedPath) {
309
309
  `);
310
310
  }
311
311
  // package.json
312
- var version = "0.0.200";
312
+ var version = "0.0.201";
313
313
 
314
314
  // src/sdk-version.ts
315
315
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -57,7 +57,7 @@ function craftBinaryNotFoundMessage(triedPath) {
57
57
  `);
58
58
  }
59
59
  // package.json
60
- var version = "0.0.200";
60
+ var version = "0.0.201";
61
61
 
62
62
  // src/sdk-version.ts
63
63
  var SDK_VERSION = version;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.200",
3
+ "version": "0.0.201",
4
4
  "type": "module",
5
5
  "description": "Build desktop apps with web languages - TypeScript SDK for Craft",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",