craft-native 0.0.451 → 0.0.464

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.
Files changed (33) hide show
  1. package/dist/android/src/index.d.ts +8 -0
  2. package/dist/android/src/index.js +227 -30
  3. package/dist/android/src/testing.d.ts +1 -1
  4. package/dist/android/templates/CraftNativeBackground.kt.template +65 -25
  5. package/dist/android/templates/CraftNativeCapabilities.kt.template +3 -1
  6. package/dist/android/templates/CraftNativeMessagingService.kt.template +8 -28
  7. package/dist/android/templates/CraftNativeNotificationReceiver.kt.template +213 -15
  8. package/dist/android/templates/CraftNativeServices.kt.template +6 -4
  9. package/dist/android/templates/CraftNativeShell.kt.template +72 -21
  10. package/dist/android/templates/MainActivityNative.kt.template +227 -12
  11. package/dist/cli.js +1 -1
  12. package/dist/components/index.d.ts +18 -0
  13. package/dist/index.cjs +1 -1
  14. package/dist/index.js +1 -1
  15. package/dist/ios/src/index.d.ts +13 -14
  16. package/dist/ios/src/index.js +292 -81
  17. package/dist/ios/templates/CraftApp.swift +118 -16
  18. package/dist/ios/templates/CraftHybrid.swift +75 -5
  19. package/dist/ios/templates/CraftNativeActions.swift +84 -13
  20. package/dist/ios/templates/CraftNativeFiles.swift +83 -1
  21. package/dist/ios/templates/CraftNativeScreen.swift +130 -4
  22. package/dist/ios/templates/CraftNotificationService.swift +199 -0
  23. package/dist/ios/templates/CraftVoiceRecording.swift +29 -1
  24. package/dist/ios/templates/testing/DriverTests.swift.template +44 -5
  25. package/dist/mobile.d.ts +2 -0
  26. package/dist/mobile.js +120 -0
  27. package/dist/native-notifications.d.ts +17 -0
  28. package/dist/native-routing.d.ts +26 -0
  29. package/dist/testing/media-cache/media-cache.kt +28 -2
  30. package/dist/testing/media-cache/media-cache.swift.fixture +18 -4
  31. package/dist/typescript/src/native-notifications.d.ts +17 -0
  32. package/dist/typescript/src/native-routing.d.ts +26 -0
  33. package/package.json +1 -1
@@ -61,6 +61,8 @@ final class DriverTests: XCTestCase {
61
61
  let tabs: [String] = {{TABS}}
62
62
  let tabIdentifiers: [String: String] = {{TAB_IDENTIFIERS}}
63
63
  private var bundledJourney = false
64
+ private var lastTappedInput: XCUIElement?
65
+ private var lastBooleanTap: [String: Any]?
64
66
 
65
67
  func get(_ path: String, timeout: Double = 15) -> Data? {
66
68
  var result: Data?
@@ -293,8 +295,6 @@ final class DriverTests: XCTestCase {
293
295
  return (parts[0], parts.count > 1 ? Double(parts[1]) ?? 10 : 10)
294
296
  }
295
297
 
296
- /// A switch reports "0" or "1". Any other value is not a switch state to
297
- /// wait on, so the tap stands as it would for any other control.
298
298
  /// The switch answers `/exec` with 200 whatever the hook did, its first
299
299
  /// word the hook's exit code: only `exit 0` is a hook that worked.
300
300
  func hookSucceeded(_ answer: String) -> Bool {
@@ -308,16 +308,26 @@ final class DriverTests: XCTestCase {
308
308
  return fields.count >= 2 && fields[1] == "0"
309
309
  }
310
310
 
311
+ /// UIKit and Craft's solid toggle both publish "0" or "1", regardless
312
+ /// of the accessibility element type that XCTest assigns to them.
311
313
  func switchFlipped(from before: String?, to after: String?) -> Bool {
312
314
  let states: Set<String> = ["0", "1"]
313
- guard let before, let after, states.contains(before), states.contains(after) else { return true }
315
+ // A lost accessibility value is missing evidence, not a successful tap.
316
+ guard let before, let after, states.contains(before), states.contains(after) else { return false }
314
317
  return before != after
315
318
  }
316
319
 
317
320
  /// Deliver one gesture; the command's observation determines success.
318
321
  func press(_ element: XCUIElement) {
322
+ // Resolve this before activation: a menu item can disappear as soon
323
+ // as its gesture completes (for example Select All).
324
+ let input = [.textField, .secureTextField, .textView, .searchField].contains(element.elementType)
325
+ ? element : nil
319
326
  if element.isHittable { element.tap() }
320
327
  else { element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() }
328
+ // A tap on a read-only text view need not open the keyboard. Only a
329
+ // following type command observes whether this target took focus.
330
+ lastTappedInput = input
321
331
  }
322
332
 
323
333
  func answerOpenPrompt() {
@@ -353,6 +363,11 @@ final class DriverTests: XCTestCase {
353
363
  let parts = line.split(separator: " ", maxSplits: 1).map(String.init)
354
364
  let command = parts[0]
355
365
  let arg = parts.count > 1 ? parts[1] : ""
366
+ // Context changes can move focus without going through press().
367
+ if ["launch", "activate", "terminate", "home", "background", "openurl", "universallink",
368
+ "back", "return", "tab", "tapxy", "edge", "notifications", "systemtap", "longpressid", "exec"].contains(command) {
369
+ lastTappedInput = nil
370
+ }
356
371
  if bundledJourney && ["net", "delay", "exec"].contains(command) {
357
372
  check(false, "\(command) requires a control-server journey")
358
373
  return
@@ -470,12 +485,15 @@ final class DriverTests: XCTestCase {
470
485
  if let target = poll(10, { exact(arg) }) { press(target); note("tapexact \(arg): ok") }
471
486
  else { XCTFail("Element not found"); note("FAIL tapexact \(arg): NOT FOUND") }
472
487
  case "tapid":
488
+ lastBooleanTap = nil
473
489
  let target = identified(arg)
474
490
  if !target.waitForExistence(timeout: 10) { XCTFail("Element not found"); note("FAIL tapid \(arg): NOT FOUND") }
475
- else if target.elementType == .switch {
491
+ else if target.elementType == .switch || ["0", "1"].contains(target.value as? String ?? "") {
476
492
  // Observe one physical gesture. Retapping could conceal a
477
493
  // renderer or hit-testing failure (HQ agreement job 7570).
478
494
  let before = target.value as? String
495
+ let enabledBefore = target.isEnabled
496
+ let frameBefore = target.frame
479
497
  press(target)
480
498
  let settle = Date().addingTimeInterval(1.5)
481
499
  var flipped = false
@@ -483,6 +501,15 @@ final class DriverTests: XCTestCase {
483
501
  flipped = switchFlipped(from: before, to: target.value as? String)
484
502
  if !flipped { Thread.sleep(forTimeInterval: 0.25) }
485
503
  } while !flipped && Date() < settle
504
+ let observation: [String: Any] = [
505
+ "id": arg, "gestures": 1, "flipped": flipped,
506
+ "enabledBefore": enabledBefore, "enabledAfter": target.isEnabled,
507
+ "valueBefore": before ?? "unknown", "valueAfter": target.value as? String ?? "unknown",
508
+ "frameBefore": [frameBefore.origin.x, frameBefore.origin.y, frameBefore.width, frameBefore.height],
509
+ ]
510
+ lastBooleanTap = observation
511
+ if let data = try? JSONSerialization.data(withJSONObject: observation, options: [.sortedKeys]),
512
+ let json = String(data: data, encoding: .utf8) { note("BOOLEAN_TAP \(json)") }
486
513
  check(flipped, "tapid \(arg) flips the switch")
487
514
  }
488
515
  else { press(target); note("tapid \(arg): ok") }
@@ -596,9 +623,20 @@ final class DriverTests: XCTestCase {
596
623
  start.press(forDuration: 0.05, thenDragTo: app.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5)))
597
624
  note("back: edge swipe")
598
625
  }
599
- case "type": app.typeText(arg)
626
+ case "type":
627
+ let target = lastTappedInput ?? app
628
+ lastTappedInput = nil
629
+ // A returned gesture is not proof of keyboard focus. Observe the
630
+ // one gesture before typing, including WebKit/cold-keyboard focus
631
+ // delivery. Never retap and conceal a dropped input event.
632
+ guard poll(15, { self.hasKeyboardFocus(target) ? target : nil }) != nil else {
633
+ check(false, "type into \(target.label) takes keyboard focus")
634
+ return
635
+ }
636
+ app.typeText(arg)
600
637
  case "return": app.typeText(XCUIKeyboardKey.return.rawValue); note("return")
601
638
  case "typeid":
639
+ lastTappedInput = nil
602
640
  let idParts = arg.split(separator: " ", maxSplits: 1).map(String.init)
603
641
  let target = identified(idParts[0])
604
642
  if target.waitForExistence(timeout: 10) {
@@ -627,6 +665,7 @@ final class DriverTests: XCTestCase {
627
665
  let target = XCUIApplication(bundleIdentifier: bundle)
628
666
  check(target.wait(for: .runningForeground, timeout: seconds), "expectapp \(bundle)")
629
667
  case "field":
668
+ lastTappedInput = nil
630
669
  let fieldParts = arg.split(separator: " ", maxSplits: 1).map(String.init)
631
670
  let index = Int(fieldParts[0]) ?? 0
632
671
  let fields = app.textFields.allElementsBoundByIndex + app.secureTextFields.allElementsBoundByIndex
package/dist/mobile.d.ts CHANGED
@@ -6,3 +6,5 @@
6
6
  * which intentionally target Bun and must not enter a browser bundle.
7
7
  */
8
8
  export * from './api/mobile.js';
9
+ export { matchNativePath, normalizeNativePath } from './native-routing.js';
10
+ export type { NativePathMatch, NativeParameterConstraint, NativeRouteConstraints } from './native-routing.js';
package/dist/mobile.js CHANGED
@@ -1964,6 +1964,124 @@ function getCraftMobile() {
1964
1964
  }
1965
1965
  return;
1966
1966
  }
1967
+ // src/native-routing.ts
1968
+ function applyNativeParameterConstraints(params, rules) {
1969
+ if (rules === undefined)
1970
+ return true;
1971
+ if (!rules || typeof rules !== "object" || Array.isArray(rules))
1972
+ return false;
1973
+ for (const [name, rule] of Object.entries(rules)) {
1974
+ let value = params[name];
1975
+ if (typeof value !== "string" || !rule || typeof rule !== "object" || Array.isArray(rule))
1976
+ return false;
1977
+ if (Object.keys(rule).some((key) => !["pattern", "exclude", "normalize"].includes(key)))
1978
+ return false;
1979
+ if (rule.normalize !== undefined && rule.normalize !== "lowercase")
1980
+ return false;
1981
+ if (rule.normalize === "lowercase")
1982
+ value = value.toLowerCase();
1983
+ if (rule.exclude !== undefined) {
1984
+ if (!Array.isArray(rule.exclude) || rule.exclude.some((item) => typeof item !== "string"))
1985
+ return false;
1986
+ if (rule.exclude.some((item) => (rule.normalize === "lowercase" ? item.toLowerCase() : item) === value))
1987
+ return false;
1988
+ }
1989
+ if (rule.pattern !== undefined) {
1990
+ if (typeof rule.pattern !== "string")
1991
+ return false;
1992
+ try {
1993
+ const matched = new RegExp(`^(?:${rule.pattern})$`).exec(value);
1994
+ if (!matched || matched[0] !== value)
1995
+ return false;
1996
+ } catch {
1997
+ return false;
1998
+ }
1999
+ }
2000
+ params[name] = value;
2001
+ }
2002
+ return true;
2003
+ }
2004
+ function normalizeNativePath(raw) {
2005
+ if (typeof raw !== "string")
2006
+ return null;
2007
+ let path = raw;
2008
+ const cut = path.search(/[?#]/);
2009
+ if (cut >= 0)
2010
+ path = path.slice(0, cut);
2011
+ if (!path.startsWith("/"))
2012
+ return null;
2013
+ path = path.replace(/\/{2,}/g, "/");
2014
+ if (path.length > 1 && path.endsWith("/"))
2015
+ path = path.slice(0, -1);
2016
+ return path;
2017
+ }
2018
+ function routeSegments(path) {
2019
+ return path === "/" ? [] : path.slice(1).split("/");
2020
+ }
2021
+ function matchNativePath(nativeScreens, raw, constraints) {
2022
+ if (constraints !== undefined && (!constraints || typeof constraints !== "object" || Array.isArray(constraints)))
2023
+ return null;
2024
+ const path = normalizeNativePath(raw);
2025
+ if (path === null)
2026
+ return null;
2027
+ const routes = Object.entries(nativeScreens ?? {}).map(([pattern, screen]) => ({ key: pattern, pattern: normalizeNativePath(pattern), screen })).filter((route) => route.pattern !== null && !!route.screen).map((route) => ({ ...route, segments: routeSegments(route.pattern) }));
2028
+ const wild = (segments) => segments.at(-1) === "*" ? 1 : 0;
2029
+ const fixed = (segments) => segments.filter((s) => s !== "*" && !s.startsWith(":")).length;
2030
+ routes.sort((a, b) => wild(a.segments) - wild(b.segments) || fixed(b.segments) - fixed(a.segments) || b.segments.length - a.segments.length || (a.pattern < b.pattern ? -1 : a.pattern > b.pattern ? 1 : 0));
2031
+ const decode = (value) => {
2032
+ try {
2033
+ return decodeURIComponent(value);
2034
+ } catch {
2035
+ return value;
2036
+ }
2037
+ };
2038
+ const parts = routeSegments(path);
2039
+ for (const route of routes) {
2040
+ const params = {};
2041
+ let matched = true;
2042
+ for (let i = 0;i < route.segments.length; i++) {
2043
+ const segment = route.segments[i];
2044
+ if (segment === "*") {
2045
+ params["*"] = parts.slice(i).join("/");
2046
+ break;
2047
+ }
2048
+ if (i >= parts.length) {
2049
+ matched = false;
2050
+ break;
2051
+ }
2052
+ if (segment.startsWith(":")) {
2053
+ if (!parts[i]) {
2054
+ matched = false;
2055
+ break;
2056
+ }
2057
+ params[segment.slice(1)] = decode(parts[i]);
2058
+ } else if (segment !== parts[i]) {
2059
+ matched = false;
2060
+ break;
2061
+ }
2062
+ }
2063
+ if (!matched)
2064
+ continue;
2065
+ if (route.segments.at(-1) !== "*" && route.segments.length !== parts.length)
2066
+ continue;
2067
+ if (!applyNativeParameterConstraints(params, constraints?.[route.key]))
2068
+ continue;
2069
+ const query = raw.indexOf("?");
2070
+ if (query >= 0) {
2071
+ for (const item of raw.slice(query + 1).split("#")[0].split("&")) {
2072
+ if (!item)
2073
+ continue;
2074
+ const [rawKey = "", ...rest] = item.split("=");
2075
+ const key = decode(rawKey);
2076
+ if (!key || key in params)
2077
+ continue;
2078
+ params[key] = decode(rest.join("=").replace(/\+/g, " "));
2079
+ }
2080
+ }
2081
+ return { path, screen: route.screen, params };
2082
+ }
2083
+ return null;
2084
+ }
1967
2085
  export {
1968
2086
  DEFAULT_SESSION_TOKEN_KEY,
1969
2087
  SESSION_EVENT,
@@ -1995,6 +2113,7 @@ export {
1995
2113
  lifecycle,
1996
2114
  liveActivities,
1997
2115
  location,
2116
+ matchNativePath,
1998
2117
  music,
1999
2118
  network,
2000
2119
  normalizeDeepLinkURL,
@@ -2002,6 +2121,7 @@ export {
2002
2121
  normalizeHeartRateReading,
2003
2122
  normalizeHeartRateSamples,
2004
2123
  normalizeHeartRateState,
2124
+ normalizeNativePath,
2005
2125
  normalizeNowPlaying,
2006
2126
  normalizeScheduledWorkout,
2007
2127
  notifications,
@@ -0,0 +1,17 @@
1
+ /** Registered before notification delivery; payloads reference category ids. */
2
+ export interface NativeNotificationCategory {
3
+ id: string;
4
+ actions: {
5
+ id: string;
6
+ title: string;
7
+ destructive?: boolean;
8
+ /** Open the app. Otherwise nativeApp handles the action in the background. */
9
+ foreground?: boolean;
10
+ authenticationRequired?: boolean;
11
+ textInput?: {
12
+ buttonTitle: string;
13
+ placeholder?: string;
14
+ };
15
+ }[];
16
+ }
17
+ export declare function nativeNotificationProblems(categories: unknown, nativeApp?: string, renderer?: string): string[];
@@ -0,0 +1,26 @@
1
+ /** Constraints on decoded named path parameters; query items cannot override them. */
2
+ export interface NativeParameterConstraint {
3
+ /** A case-sensitive regular expression for the complete normalized value. */
4
+ pattern?: string;
5
+ exclude?: readonly string[];
6
+ /** Normalize decoded values and exclusions before case-sensitive grammar validation. */
7
+ normalize?: 'lowercase';
8
+ }
9
+ export type NativeRouteConstraints = Record<string, Record<string, NativeParameterConstraint>>;
10
+ /** Validate and normalize the decoded parameter map before query binding. */
11
+ export declare function applyNativeParameterConstraints(params: Record<string, string>, rules?: Record<string, NativeParameterConstraint>): boolean;
12
+ export declare function nativeRouteConstraintProblems(screens?: Record<string, string>, constraints?: NativeRouteConstraints): string[];
13
+ /** `/a//b/?q#h` → `/a/b`, the way the shell and the page compare paths. Null for a relative path. */
14
+ export declare function normalizeNativePath(raw: string): string | null;
15
+ export interface NativePathMatch {
16
+ path: string;
17
+ screen: string;
18
+ params: Record<string, string>;
19
+ }
20
+ export declare function routeSegments(path: string): string[];
21
+ /**
22
+ * The native screen for a path, by the rules the shell (`CraftHybridRoutes`)
23
+ * and the page script apply: static segments win over `:params`, which win
24
+ * over a trailing `*`; query items become params under the path's own.
25
+ */
26
+ export declare function matchNativePath(nativeScreens: Record<string, string> | undefined, raw: string, constraints?: NativeRouteConstraints): NativePathMatch | null;
@@ -23,10 +23,10 @@ class DownloadProbe : android.app.Instrumentation() {
23
23
  proof.append("PASS: ").append(message).append('\n')
24
24
  android.util.Log.i("CraftMediaProbe", "PASS: $message")
25
25
  }
26
- private fun keep(address: String): Boolean {
26
+ private fun keep(address: String, cacheKey: String? = null): Boolean {
27
27
  val done = java.util.concurrent.CountDownLatch(1)
28
28
  var ok = false
29
- CraftNativeMedia.keep(targetContext, address, JSONObject(), true) {
29
+ CraftNativeMedia.keep(targetContext, address, JSONObject(), true, cacheKey = cacheKey) {
30
30
  latestErrorCode = it.code
31
31
  ok = (it.data as? JSONObject)?.optBoolean("ok") == true
32
32
  done.countDown()
@@ -113,6 +113,32 @@ class DownloadProbe : android.app.Instrumentation() {
113
113
  }
114
114
  else -> {
115
115
  CraftNativeMedia.prune(targetContext, emptyList())
116
+ val queuedKey = "account:one:lesson:8:version:a"
117
+ val expired = base + "/queued.mp4?token=expired"
118
+ val renewed = base + "/queued.mp4?token=renewed"
119
+ val queuedIdentity = CraftNativeMedia.identity(expired, JSONObject().put("cacheKey", queuedKey))
120
+ val queuedGeneration = CraftNativeMedia.generation(targetContext)
121
+ targetContext.getSharedPreferences("craft.media", Context.MODE_PRIVATE).edit().putString("source:${CraftNativeMedia.digest(queuedIdentity)}", CraftNativeMedia.digest(expired + "\n")).commit()
122
+ val queuedWork = androidx.work.OneTimeWorkRequestBuilder<CraftNativeMediaWorker>()
123
+ .setInputData(androidx.work.workDataOf("address" to queuedIdentity, "source" to expired, "headers" to "{}", "generation" to queuedGeneration))
124
+ .setInitialDelay(1, java.util.concurrent.TimeUnit.DAYS)
125
+ .addTag("craft-media:generation:$queuedGeneration")
126
+ .build()
127
+ androidx.work.WorkManager.getInstance(targetContext).enqueueUniqueWork("craft-media:${CraftNativeMedia.digest(queuedIdentity)}:any", androidx.work.ExistingWorkPolicy.REPLACE, queuedWork).result.get()
128
+ check(keep(renewed, queuedKey), "a renewed authorized source replaces expired queued work immediately")
129
+ val signed = base + "/keyed.mp4?token=first"
130
+ val key = "account:one:lesson:7:version:a"
131
+ check(keep(signed, key), "signed URL downloads under an account/version identity")
132
+ val identity = CraftNativeMedia.identity(signed, JSONObject().put("cacheKey", key))
133
+ val rotated = CraftNativeMedia.identity(base + "/keyed.mp4?token=second", JSONObject().put("cacheKey", key))
134
+ check(identity == rotated && CraftNativeMedia.local(targetContext, rotated) != null, "renewed signed URLs reuse the complete copy offline")
135
+ check(keep(base + "/keyed.mp4?token=second", key), "renewing authorization does not redownload unchanged content")
136
+ check(CraftNativeMedia.local(targetContext, CraftNativeMedia.identity(signed, JSONObject().put("cacheKey", "account:two:lesson:7:version:a"))) == null, "another account cannot address the copy")
137
+ check(CraftNativeMedia.local(targetContext, CraftNativeMedia.identity(signed, JSONObject().put("cacheKey", "account:one:lesson:7:version:b"))) == null, "a new version invalidates the copy")
138
+ CraftNativeMedia.prune(targetContext, listOf(rotated))
139
+ check(CraftNativeMedia.local(targetContext, rotated) != null, "key-aware prune retains the authorized source")
140
+ CraftNativeMedia.prune(targetContext, emptyList())
141
+ check(CraftNativeMedia.local(targetContext, rotated) == null, "revocation removes the keyed copy")
116
142
  val broken = "$base/broken.mp4"
117
143
  check(!keep(broken), "truncated transfer fails")
118
144
  check(CraftNativeMedia.local(targetContext, broken) == null, "truncated transfer is not playable cache")
@@ -15,13 +15,14 @@ func wait(_ ready: () -> Bool) {
15
15
  }
16
16
  var latestTask: URLSessionTask?
17
17
  var latestErrorCode: String?
18
- func start(_ address: String, _ done: @escaping (Bool) -> Void) {
18
+ func start(_ source: String, cacheKey: String? = nil, _ done: @escaping (Bool) -> Void) {
19
+ let address = CraftNativeMedia.identity(source, options: cacheKey.map { ["cacheKey": $0] } ?? [:])
19
20
  if let local = CraftNativeMedia.local(address) { done((CraftNativeMedia.describe(address, local)["size"] as? Int ?? 0) > 0); return }
20
21
  guard let flight = CraftNativeMedia.begin(address, { result in
21
22
  if case .success(let data) = result { done((data as? [String: Any])?["ok"] as? Bool == true) }
22
23
  else if case .failure(let error) = result { latestErrorCode = error.code; done(false) }
23
24
  }) else { return }
24
- let task = URLSession.shared.downloadTask(with: URL(string: address)!) { location, response, error in
25
+ let task = URLSession.shared.downloadTask(with: URL(string: source)!) { location, response, error in
25
26
  let result: Result<Any, CraftNativeActionError>
26
27
  if error != nil { result = .failure(CraftNativeActionError(code: "NETWORK_ERROR", message: "Interrupted")) }
27
28
  else if let location, let response = response as? HTTPURLResponse { result = CraftNativeMedia.store(address, at: location, response: response, flight: flight) }
@@ -31,9 +32,9 @@ func start(_ address: String, _ done: @escaping (Bool) -> Void) {
31
32
  latestTask = task
32
33
  task.resume()
33
34
  }
34
- func keep(_ address: String) -> Bool {
35
+ func keep(_ address: String, cacheKey: String? = nil) -> Bool {
35
36
  var result: Bool?
36
- start(address) { result = $0 }
37
+ start(address, cacheKey: cacheKey) { result = $0 }
37
38
  wait { result != nil }
38
39
  return result!
39
40
  }
@@ -105,6 +106,19 @@ check(CraftNativeMedia.local(broken) == nil, "truncated transfer is not playable
105
106
  check(keep(broken), "same URL retries successfully")
106
107
  check(!keep(base + "/partial.mp4"), "unsolicited partial response is refused")
107
108
  check(!keep(base + "/empty.mp4"), "empty file is refused")
109
+ let signed = base + "/keyed.mp4?token=first"
110
+ let key = "account:one:lesson:7:version:a"
111
+ check(keep(signed, cacheKey: key), "authorized signed URL downloads under account/version identity")
112
+ let identity = CraftNativeMedia.identity(signed, options: ["cacheKey": key])
113
+ let rotated = CraftNativeMedia.identity(base + "/keyed.mp4?token=second", options: ["cacheKey": key])
114
+ check(identity == rotated && CraftNativeMedia.local(rotated) != nil, "renewing a signed URL reuses the complete cached file offline")
115
+ check(keep(base + "/keyed.mp4?token=second", cacheKey: key), "renewed authorization does not redownload unchanged content")
116
+ check(CraftNativeMedia.local(CraftNativeMedia.identity(signed, options: ["cacheKey": "account:two:lesson:7:version:a"])) == nil, "another account cannot address the private copy")
117
+ check(CraftNativeMedia.local(CraftNativeMedia.identity(signed, options: ["cacheKey": "account:one:lesson:7:version:b"])) == nil, "a new prepared version invalidates the old copy")
118
+ _ = CraftNativeMedia.prune(keep: [rotated])
119
+ check(CraftNativeMedia.local(rotated) != nil, "key-aware prune retains the current authorized source")
120
+ _ = CraftNativeMedia.prune(keep: [])
121
+ check(CraftNativeMedia.local(rotated) == nil, "definitive revocation removes the keyed copy")
108
122
  let slow = base + "/slow.mp4"
109
123
  var answers = [Bool]()
110
124
  start(slow) { answers.append($0) }
@@ -0,0 +1,17 @@
1
+ /** Registered before notification delivery; payloads reference category ids. */
2
+ export interface NativeNotificationCategory {
3
+ id: string;
4
+ actions: {
5
+ id: string;
6
+ title: string;
7
+ destructive?: boolean;
8
+ /** Open the app. Otherwise nativeApp handles the action in the background. */
9
+ foreground?: boolean;
10
+ authenticationRequired?: boolean;
11
+ textInput?: {
12
+ buttonTitle: string;
13
+ placeholder?: string;
14
+ };
15
+ }[];
16
+ }
17
+ export declare function nativeNotificationProblems(categories: unknown, nativeApp?: string, renderer?: string): string[];
@@ -0,0 +1,26 @@
1
+ /** Constraints on decoded named path parameters; query items cannot override them. */
2
+ export interface NativeParameterConstraint {
3
+ /** A case-sensitive regular expression for the complete normalized value. */
4
+ pattern?: string;
5
+ exclude?: readonly string[];
6
+ /** Normalize decoded values and exclusions before case-sensitive grammar validation. */
7
+ normalize?: 'lowercase';
8
+ }
9
+ export type NativeRouteConstraints = Record<string, Record<string, NativeParameterConstraint>>;
10
+ /** Validate and normalize the decoded parameter map before query binding. */
11
+ export declare function applyNativeParameterConstraints(params: Record<string, string>, rules?: Record<string, NativeParameterConstraint>): boolean;
12
+ export declare function nativeRouteConstraintProblems(screens?: Record<string, string>, constraints?: NativeRouteConstraints): string[];
13
+ /** `/a//b/?q#h` → `/a/b`, the way the shell and the page compare paths. Null for a relative path. */
14
+ export declare function normalizeNativePath(raw: string): string | null;
15
+ export interface NativePathMatch {
16
+ path: string;
17
+ screen: string;
18
+ params: Record<string, string>;
19
+ }
20
+ export declare function routeSegments(path: string): string[];
21
+ /**
22
+ * The native screen for a path, by the rules the shell (`CraftHybridRoutes`)
23
+ * and the page script apply: static segments win over `:params`, which win
24
+ * over a trailing `*`; query items become params under the path's own.
25
+ */
26
+ export declare function matchNativePath(nativeScreens: Record<string, string> | undefined, raw: string, constraints?: NativeRouteConstraints): NativePathMatch | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.451",
3
+ "version": "0.0.464",
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>",