craft-native 0.0.451 → 0.0.467

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 (35) 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 +23 -5
  9. package/dist/android/templates/CraftNativeShell.kt.template +201 -50
  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 +293 -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 +105 -14
  20. package/dist/ios/templates/CraftNativeFiles.swift +230 -18
  21. package/dist/ios/templates/CraftNativeMediaOwnership.swift +92 -0
  22. package/dist/ios/templates/CraftNativeScreen.swift +136 -6
  23. package/dist/ios/templates/CraftNotificationService.swift +199 -0
  24. package/dist/ios/templates/CraftVoiceRecording.swift +29 -1
  25. package/dist/ios/templates/testing/DriverTests.swift.template +44 -5
  26. package/dist/mobile-testing.js +12 -3
  27. package/dist/mobile.d.ts +2 -0
  28. package/dist/mobile.js +120 -0
  29. package/dist/native-notifications.d.ts +17 -0
  30. package/dist/native-routing.d.ts +26 -0
  31. package/dist/testing/media-cache/media-cache.kt +74 -5
  32. package/dist/testing/media-cache/media-cache.swift.fixture +60 -4
  33. package/dist/typescript/src/native-notifications.d.ts +17 -0
  34. package/dist/typescript/src/native-routing.d.ts +26 -0
  35. package/package.json +1 -1
@@ -201,6 +201,7 @@ ${errors}`);
201
201
  try {
202
202
  if (options.platform === "ios") {
203
203
  const files = await Bun.file(join(root, "ios/templates/CraftNativeFiles.swift")).text();
204
+ const ownership = await Bun.file(join(root, "ios/templates/CraftNativeMediaOwnership.swift")).text();
204
205
  const media = files.slice(files.indexOf("enum CraftNativeMedia {"));
205
206
  if (!media.startsWith("enum CraftNativeMedia"))
206
207
  throw new Error("iOS media implementation missing");
@@ -208,8 +209,10 @@ ${errors}`);
208
209
  const isolated = media.replace(/static var root: URL \{[\s\S]*?\n \}/, 'static var root: URL { CommandLine.arguments.count > 3 ? URL(fileURLWithPath: CommandLine.arguments[3]) : URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("hq-download-probe-media") }');
209
210
  await Bun.write(source, `import Foundation
210
211
  import CryptoKit
212
+ import Network
211
213
  struct CraftNativeActionError: Error { let code: String;
212
214
  let message: String }
215
+ ${ownership}
213
216
  ${isolated}
214
217
  ${await Bun.file(join(fixtures, "media-cache.swift.fixture")).text()}`);
215
218
  const iosSDK = (await run(["xcrun", "--sdk", "iphonesimulator", "--show-sdk-path"])).trim();
@@ -229,6 +232,7 @@ ${await Bun.file(join(fixtures, "media-cache.swift.fixture")).text()}`);
229
232
  await run(["xcrun", "simctl", "bootstatus", simulator, "-b"]);
230
233
  }
231
234
  const base = `http://127.0.0.1:${server.port}/ios`;
235
+ const mediaRoot = join(scratch, "media");
232
236
  const image = join(scratch, "cache-pressure.sparseimage");
233
237
  const volume = join(scratch, "cache-volume");
234
238
  await mkdir(volume);
@@ -240,14 +244,16 @@ ${await Bun.file(join(fixtures, "media-cache.swift.fixture")).text()}`);
240
244
  mountedVolume = "";
241
245
  print("PASS: iOS native ENOSPC, cache cleanup, progress preservation and video redownload");
242
246
  if (!storageOnly) {
243
- print(await run(["xcrun", "simctl", "spawn", simulator, executable, base, "normal"]));
244
- const interrupted = await run(["xcrun", "simctl", "spawn", simulator, executable, base, "interrupt"], scratch, true);
247
+ print(await run(["xcrun", "simctl", "spawn", simulator, executable, base, "normal", mediaRoot]));
248
+ for (const phase of ["retire", "retired-recover"])
249
+ print(await run(["xcrun", "simctl", "spawn", simulator, executable, base, phase, mediaRoot]));
250
+ const interrupted = await run(["xcrun", "simctl", "spawn", simulator, executable, base, "interrupt", mediaRoot], scratch, true);
245
251
  print(interrupted);
246
252
  if (!interrupted.includes("KILLING"))
247
253
  throw new Error("iOS probe did not reach process-death checkpoint");
248
254
  if (counts.get("/ios/restart.mp4") !== 1)
249
255
  throw new Error("iOS process did not start interrupted download");
250
- print(await run(["xcrun", "simctl", "spawn", simulator, executable, base, "recover"]));
256
+ print(await run(["xcrun", "simctl", "spawn", simulator, executable, base, "recover", mediaRoot]));
251
257
  if (counts.get("/ios/slow.mp4") !== 1)
252
258
  throw new Error("iOS duplicate fetch: keep did not coalesce");
253
259
  if (counts.get("/ios/account/slow.mp4") !== 2)
@@ -328,6 +334,9 @@ ${await Bun.file(join(fixtures, "media-cache.kt")).text()}`);
328
334
  print("PASS: Android native ENOSPC, cache cleanup, progress preservation and video redownload");
329
335
  if (!storageOnly) {
330
336
  await probe("normal");
337
+ await probe("retire");
338
+ await run([adb, "-s", serial, "shell", "am", "force-stop", packageName]);
339
+ await probe("retired-recover");
331
340
  await probe("interrupt", true);
332
341
  if (counts.get("/android/restart.mp4") !== 1)
333
342
  throw new Error("Android process did not start interrupted download");
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()
@@ -58,7 +58,7 @@ class DownloadProbe : android.app.Instrumentation() {
58
58
  check(keep(retained), "existing video is kept before storage pressure")
59
59
  val original = CraftNativeMedia.local(targetContext, retained)!!.readBytes()
60
60
  val fresh = "$base/storage.mp4"
61
- val staging = File(CraftNativeMedia.file(targetContext, fresh).absolutePath + ".part")
61
+ val staging = CraftNativeMedia.part(targetContext, fresh)
62
62
  val root = staging.parentFile!!
63
63
  check(root.totalSpace in 1..(32L * 1024 * 1024), "storage pressure is confined to a bounded disposable volume")
64
64
  val pressure = File(root, "pressure.mp4")
@@ -90,6 +90,44 @@ class DownloadProbe : android.app.Instrumentation() {
90
90
  CraftNativeMedia.prune(targetContext, emptyList())
91
91
  prefs.edit().clear().commit()
92
92
  }
93
+ "retire" -> {
94
+ val a = "$base/selective-a.mp4"
95
+ val b = "$base/selective-b.mp4"
96
+ check(keep(a) && keep(b), "two authorized videos are kept")
97
+ val oldGeneration = CraftNativeMedia.generation(targetContext)
98
+ check(targetContext.getSharedPreferences("media-probe", Context.MODE_PRIVATE).edit().putInt("old-generation", oldGeneration).commit(), "old worker generation is retained for restart verification")
99
+ val cacheRoot = CraftNativeMedia.file(targetContext, a).parentFile!!
100
+ android.system.Os.chmod(cacheRoot.absolutePath, 365) // 0555: read/search, no unlink
101
+ var unlinkFailed = false
102
+ try { CraftNativeMedia.remove(targetContext, a) } catch (error: java.io.IOException) { unlinkFailed = true }
103
+ finally { android.system.Os.chmod(cacheRoot.absolutePath, 448) } // 0700
104
+ check(unlinkFailed, "a real unlink failure is reported")
105
+ check(CraftNativeMedia.local(targetContext, a) == null && CraftNativeMedia.local(targetContext, b) != null, "failed unlink hides revoked A and preserves authorized B")
106
+ check(CraftNativeMedia.remove(targetContext, a) == 1, "removing A removes only its copy")
107
+ check(CraftNativeMedia.local(targetContext, a) == null && CraftNativeMedia.local(targetContext, b) != null, "B stays available after A is revoked")
108
+ }
109
+ "retired-recover" -> {
110
+ val a = "$base/selective-a.mp4"
111
+ val b = "$base/selective-b.mp4"
112
+ val oldGeneration = targetContext.getSharedPreferences("media-probe", Context.MODE_PRIVATE).getInt("old-generation", -1)
113
+ check(CraftNativeMedia.local(targetContext, a) == null && CraftNativeMedia.local(targetContext, b) != null, "selective revocation survives a fresh Android process")
114
+ fun obsoleteCompletion() {
115
+ val result = CraftNativeMedia.transfer(targetContext, a, JSONObject(), oldGeneration, 0, ownerRevision = "") { false }
116
+ check(result == CraftNativeMedia.Outcome.DONE, "obsolete worker retires without retry or publication")
117
+ }
118
+ obsoleteCompletion()
119
+ check(keep(a), "a new authorized keep can re-grant A")
120
+ val fresh = CraftNativeMedia.local(targetContext, a)!!.readBytes()
121
+ obsoleteCompletion()
122
+ check(CraftNativeMedia.local(targetContext, a)!!.readBytes().contentEquals(fresh), "obsolete worker cannot replace re-granted A")
123
+ val decoder = android.media.MediaMetadataRetriever()
124
+ try {
125
+ decoder.setDataSource(CraftNativeMedia.local(targetContext, b)!!.absolutePath)
126
+ check(decoder.getFrameAtTime(1000000) != null, "unrelated B still decodes offline after revocation and re-grant")
127
+ } finally { decoder.release() }
128
+ CraftNativeMedia.clear(targetContext)
129
+ targetContext.getSharedPreferences("media-probe", Context.MODE_PRIVATE).edit().clear().commit()
130
+ }
93
131
  "interrupt" -> {
94
132
  CraftNativeMedia.prune(targetContext, emptyList())
95
133
  CraftNativeMedia.keep(targetContext, restart, JSONObject(), true) {}
@@ -113,6 +151,32 @@ class DownloadProbe : android.app.Instrumentation() {
113
151
  }
114
152
  else -> {
115
153
  CraftNativeMedia.prune(targetContext, emptyList())
154
+ val queuedKey = "account:one:lesson:8:version:a"
155
+ val expired = base + "/queued.mp4?token=expired"
156
+ val renewed = base + "/queued.mp4?token=renewed"
157
+ val queuedIdentity = CraftNativeMedia.identity(expired, JSONObject().put("cacheKey", queuedKey))
158
+ val queuedGeneration = CraftNativeMedia.generation(targetContext)
159
+ targetContext.getSharedPreferences("craft.media", Context.MODE_PRIVATE).edit().putString("source:${CraftNativeMedia.digest(queuedIdentity)}", CraftNativeMedia.digest(expired + "\n")).commit()
160
+ val queuedWork = androidx.work.OneTimeWorkRequestBuilder<CraftNativeMediaWorker>()
161
+ .setInputData(androidx.work.workDataOf("address" to queuedIdentity, "source" to expired, "headers" to "{}", "generation" to queuedGeneration))
162
+ .setInitialDelay(1, java.util.concurrent.TimeUnit.DAYS)
163
+ .addTag("craft-media:generation:$queuedGeneration")
164
+ .build()
165
+ androidx.work.WorkManager.getInstance(targetContext).enqueueUniqueWork("craft-media:${CraftNativeMedia.digest(queuedIdentity)}:any", androidx.work.ExistingWorkPolicy.REPLACE, queuedWork).result.get()
166
+ check(keep(renewed, queuedKey), "a renewed authorized source replaces expired queued work immediately")
167
+ val signed = base + "/keyed.mp4?token=first"
168
+ val key = "account:one:lesson:7:version:a"
169
+ check(keep(signed, key), "signed URL downloads under an account/version identity")
170
+ val identity = CraftNativeMedia.identity(signed, JSONObject().put("cacheKey", key))
171
+ val rotated = CraftNativeMedia.identity(base + "/keyed.mp4?token=second", JSONObject().put("cacheKey", key))
172
+ check(identity == rotated && CraftNativeMedia.local(targetContext, rotated) != null, "renewed signed URLs reuse the complete copy offline")
173
+ check(keep(base + "/keyed.mp4?token=second", key), "renewing authorization does not redownload unchanged content")
174
+ check(CraftNativeMedia.local(targetContext, CraftNativeMedia.identity(signed, JSONObject().put("cacheKey", "account:two:lesson:7:version:a"))) == null, "another account cannot address the copy")
175
+ check(CraftNativeMedia.local(targetContext, CraftNativeMedia.identity(signed, JSONObject().put("cacheKey", "account:one:lesson:7:version:b"))) == null, "a new version invalidates the copy")
176
+ CraftNativeMedia.prune(targetContext, listOf(rotated))
177
+ check(CraftNativeMedia.local(targetContext, rotated) != null, "key-aware prune retains the authorized source")
178
+ CraftNativeMedia.prune(targetContext, emptyList())
179
+ check(CraftNativeMedia.local(targetContext, rotated) == null, "revocation removes the keyed copy")
116
180
  val broken = "$base/broken.mp4"
117
181
  check(!keep(broken), "truncated transfer fails")
118
182
  check(CraftNativeMedia.local(targetContext, broken) == null, "truncated transfer is not playable cache")
@@ -129,7 +193,8 @@ class DownloadProbe : android.app.Instrumentation() {
129
193
  val settledName = "craft-media:${CraftNativeMedia.digest(settled)}:any"
130
194
  val settledRequest = androidx.work.OneTimeWorkRequestBuilder<SettledMediaAttemptWorker>()
131
195
  .addTag("craft-media").addTag("craft-media:${CraftNativeMedia.digest(settled)}")
132
- .addTag("craft-media:generation:${CraftNativeMedia.generation(targetContext)}").build()
196
+ .addTag("craft-media:generation:${CraftNativeMedia.generation(targetContext)}")
197
+ .addTag("craft-media:identity:${CraftNativeMedia.digest(settled)}:legacy").build()
133
198
  work.enqueueUniqueWork(settledName, androidx.work.ExistingWorkPolicy.KEEP, settledRequest).result.get()
134
199
  val settledDeadline = System.currentTimeMillis() + 10000
135
200
  var settledInfo = work.getWorkInfoById(settledRequest.id).get()!!
@@ -150,7 +215,8 @@ class DownloadProbe : android.app.Instrumentation() {
150
215
  .setInitialDelay(10, java.util.concurrent.TimeUnit.SECONDS)
151
216
  .setInputData(androidx.work.workDataOf("address" to queued, "headers" to "{}", "generation" to CraftNativeMedia.generation(targetContext)))
152
217
  .addTag("craft-media").addTag("craft-media:${CraftNativeMedia.digest(queued)}")
153
- .addTag("craft-media:generation:${CraftNativeMedia.generation(targetContext)}").build()
218
+ .addTag("craft-media:generation:${CraftNativeMedia.generation(targetContext)}")
219
+ .addTag("craft-media:identity:${CraftNativeMedia.digest(queued)}:legacy").build()
154
220
  work.enqueueUniqueWork(queuedName, androidx.work.ExistingWorkPolicy.KEEP, queuedRequest).result.get()
155
221
  val beforeJoin = work.getWorkInfoById(queuedRequest.id).get()!!
156
222
  check(beforeJoin.state == androidx.work.WorkInfo.State.ENQUEUED && beforeJoin.runAttemptCount == 0, "the coalescing regression starts with a fresh queued transfer")
@@ -191,6 +257,9 @@ class DownloadProbe : android.app.Instrumentation() {
191
257
  check(currentDone.await(25, java.util.concurrent.TimeUnit.SECONDS) && currentOK, "an obsolete worker cannot cancel the next generation's waiters")
192
258
 
193
259
  val inflight = "$base/inflight/slow.mp4"
260
+ // This fixture starts the old worker directly rather than through keep.
261
+ // Give it the same persisted grant keep records after a clear.
262
+ check(targetContext.getSharedPreferences("craft.media", Context.MODE_PRIVATE).edit().putBoolean("authorized:${CraftNativeMedia.digest(inflight)}", true).commit(), "the direct worker starts with an explicit grant")
194
263
  val inflightGeneration = CraftNativeMedia.generation(targetContext)
195
264
  var obsoleteOutcome: CraftNativeMedia.Outcome? = null
196
265
  val obsoleteWriter = Thread { obsoleteOutcome = CraftNativeMedia.transfer(targetContext, inflight, JSONObject(), inflightGeneration, 0) { false } }
@@ -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
  }
@@ -78,6 +79,48 @@ if phase == "storage" {
78
79
  _ = CraftNativeMedia.prune(keep: [])
79
80
  exit(0)
80
81
  }
82
+ if phase == "retire" || phase == "retired-recover" {
83
+ let a = base + "/selective-a.mp4"
84
+ let b = base + "/selective-b.mp4"
85
+ let saved = CraftNativeMedia.root.deletingLastPathComponent().appendingPathComponent("obsolete-stamp.json")
86
+ if phase == "retire" {
87
+ check(keep(a) && keep(b), "two authorized videos are kept")
88
+ let flight = CraftNativeMedia.begin(a) { _ in }!
89
+ let stamp = CraftNativeMedia.ownershipStamp(a, flight: flight)!
90
+ try JSONEncoder().encode(stamp).write(to: saved)
91
+ try fm.setAttributes([.posixPermissions: 0o555], ofItemAtPath: CraftNativeMedia.root.path)
92
+ var unlinkFailed = false
93
+ do { _ = try CraftNativeMedia.remove(a) } catch { unlinkFailed = true }
94
+ try fm.setAttributes([.posixPermissions: 0o700], ofItemAtPath: CraftNativeMedia.root.path)
95
+ check(unlinkFailed, "a real unlink failure is reported")
96
+ check(CraftNativeMedia.local(a) == nil && CraftNativeMedia.local(b) != nil, "failed unlink hides revoked A and preserves authorized B")
97
+ let removed = try CraftNativeMedia.remove(a)
98
+ check(removed == 1, "removing A removes only its copy")
99
+ check(CraftNativeMedia.local(a) == nil && CraftNativeMedia.local(b) != nil, "B stays available after A is revoked")
100
+ } else {
101
+ let stamp = try JSONDecoder().decode(CraftNativeMediaOwnership.Stamp.self, from: Data(contentsOf: saved))
102
+ check(CraftNativeMedia.local(a) == nil && CraftNativeMedia.local(b) != nil, "selective revocation survives a fresh simulator process")
103
+ func obsoleteCompletion() throws {
104
+ let payload = CraftNativeMedia.root.deletingLastPathComponent().appendingPathComponent("obsolete-download.mp4")
105
+ try Data(contentsOf: CraftNativeMedia.local(b)!).write(to: payload)
106
+ defer { try? fm.removeItem(at: payload) }
107
+ let response = HTTPURLResponse(url: URL(string: a)!, statusCode: 200, httpVersion: nil, headerFields: nil)!
108
+ let result = CraftNativeMedia.store(a, at: payload, response: response, stamp: stamp)
109
+ if case .failure(let error) = result { check(error.code == "CANCELLED", "obsolete transfer cannot publish after process restart") }
110
+ else { check(false, "obsolete transfer was accepted") }
111
+ }
112
+ try obsoleteCompletion()
113
+ check(keep(a), "a new authorized keep can re-grant A")
114
+ try obsoleteCompletion()
115
+ check(CraftNativeMedia.local(a) != nil && CraftNativeMedia.local(b) != nil, "old completion cannot replace re-granted A or remove B")
116
+ let generator = AVAssetImageGenerator(asset: AVURLAsset(url: CraftNativeMedia.local(b)!))
117
+ _ = try generator.copyCGImage(at: CMTime(seconds: 1, preferredTimescale: 600), actualTime: nil)
118
+ check(true, "unrelated B still decodes offline after revocation and re-grant")
119
+ _ = try CraftNativeMedia.clear()
120
+ try fm.removeItem(at: saved)
121
+ }
122
+ exit(0)
123
+ }
81
124
  if phase == "interrupt" {
82
125
  _ = CraftNativeMedia.prune(keep: [])
83
126
  start(restart) { _ in }
@@ -105,6 +148,19 @@ check(CraftNativeMedia.local(broken) == nil, "truncated transfer is not playable
105
148
  check(keep(broken), "same URL retries successfully")
106
149
  check(!keep(base + "/partial.mp4"), "unsolicited partial response is refused")
107
150
  check(!keep(base + "/empty.mp4"), "empty file is refused")
151
+ let signed = base + "/keyed.mp4?token=first"
152
+ let key = "account:one:lesson:7:version:a"
153
+ check(keep(signed, cacheKey: key), "authorized signed URL downloads under account/version identity")
154
+ let identity = CraftNativeMedia.identity(signed, options: ["cacheKey": key])
155
+ let rotated = CraftNativeMedia.identity(base + "/keyed.mp4?token=second", options: ["cacheKey": key])
156
+ check(identity == rotated && CraftNativeMedia.local(rotated) != nil, "renewing a signed URL reuses the complete cached file offline")
157
+ check(keep(base + "/keyed.mp4?token=second", cacheKey: key), "renewed authorization does not redownload unchanged content")
158
+ check(CraftNativeMedia.local(CraftNativeMedia.identity(signed, options: ["cacheKey": "account:two:lesson:7:version:a"])) == nil, "another account cannot address the private copy")
159
+ check(CraftNativeMedia.local(CraftNativeMedia.identity(signed, options: ["cacheKey": "account:one:lesson:7:version:b"])) == nil, "a new prepared version invalidates the old copy")
160
+ _ = CraftNativeMedia.prune(keep: [rotated])
161
+ check(CraftNativeMedia.local(rotated) != nil, "key-aware prune retains the current authorized source")
162
+ _ = CraftNativeMedia.prune(keep: [])
163
+ check(CraftNativeMedia.local(rotated) == nil, "definitive revocation removes the keyed copy")
108
164
  let slow = base + "/slow.mp4"
109
165
  var answers = [Bool]()
110
166
  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.467",
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>",