craft-native 0.0.222 → 0.0.223

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/README.md CHANGED
@@ -536,6 +536,35 @@ it does not change the application's animations or test assertions.
536
536
 
537
537
  We love contributions! Please see our [contributing guide](../../CONTRIBUTING.md).
538
538
 
539
+ ### Offline video storage failures
540
+
541
+ On iOS and Android native screens, `craft.media.keep(url)` rejects with
542
+ `code: 'NO_SPACE'` when the filesystem runs out of room, including wrapped
543
+ URLSession and Android write errors. Treat that separately from connectivity:
544
+ stop downloading and let the person free space before retrying the same URL.
545
+ A failed transfer is never published as a playable cached video.
546
+ `craft.media.prune(keepUrls)` removes only unwanted files in the media cache;
547
+ it does not clear app storage, workout progress, snapshots or databases.
548
+
549
+ The Bun-only testing entrypoint verifies those guarantees on native devices:
550
+
551
+ ```ts
552
+ import { verifyMobileMediaCache } from 'craft-native/testing'
553
+
554
+ await verifyMobileMediaCache({ platform: 'ios' }) // creates and deletes its own simulator
555
+ await verifyMobileMediaCache({ platform: 'android', device: 'emulator-5554', online: true })
556
+ ```
557
+
558
+ Android requires an explicitly selected rootable AOSP emulator and an installed
559
+ Gradle CLI. Xcode and an available simulator runtime are required for iOS. The
560
+ probe uses a bounded 16 MB disk image on iOS and a 16 MB tmpfs mounted only over
561
+ the probe's cache on Android. The Android cache keeps its app UID and SELinux
562
+ label; enforcement stays on. It never fills the host disk or an athlete's data partition. It
563
+ checks `NO_SPACE`, partial-file cleanup, preserved state outside the cache,
564
+ redownloading, interrupted transfers and decoding a local video frame. Set
565
+ `storageOnly: true` for only the disk-full cases, or `sourceRoot` to Craft's
566
+ absolute `packages` path during upstream development before publication.
567
+
539
568
  ## License
540
569
 
541
570
  MIT © [Chris Breuer](https://github.com/chrisbbreuer)
@@ -1093,7 +1093,10 @@ internal object CraftNativeMedia {
1093
1093
  connection.disconnect()
1094
1094
  }
1095
1095
  }.getOrElse { error ->
1096
- CraftNativeCapabilityResult(code = if (error is java.net.SocketTimeoutException) "TIMEOUT" else "NETWORK_ERROR", message = error.message ?: "media.keep failed")
1096
+ val full = generateSequence(error) { it.cause }.take(16).any {
1097
+ it is android.system.ErrnoException && it.errno == android.system.OsConstants.ENOSPC
1098
+ }
1099
+ CraftNativeCapabilityResult(code = if (full) "NO_SPACE" else if (error is java.net.SocketTimeoutException) "TIMEOUT" else "NETWORK_ERROR", message = error.message ?: "media.keep failed")
1097
1100
  }
1098
1101
  val waiting = synchronized(inFlight) { inFlight.remove(address) ?: mutableListOf() }
1099
1102
  main.post { waiting.forEach { it(result) } }
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.222";
3427
+ var version = "0.0.223";
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.222";
312
+ var version = "0.0.223";
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.222";
60
+ var version = "0.0.223";
61
61
 
62
62
  // src/sdk-version.ts
63
63
  var SDK_VERSION = version;
@@ -794,7 +794,7 @@ enum CraftNativeActions {
794
794
  return
795
795
  }
796
796
  let code = (error as? URLError)?.code == .timedOut ? "TIMEOUT" : "NETWORK_ERROR"
797
- CraftNativeMedia.finish(address, failure(code, error.localizedDescription))
797
+ CraftNativeMedia.finish(address, failure(CraftNativeMedia.errorCode(error, fallback: code), error.localizedDescription))
798
798
  return
799
799
  }
800
800
  guard let http = response as? HTTPURLResponse, let location else {
@@ -492,6 +492,20 @@ enum CraftNativeFiles {
492
492
  // JavaScript. The screen decides what to keep (`keep`) and what to drop
493
493
  // (`prune`); nothing is evicted behind its back.
494
494
  enum CraftNativeMedia {
495
+ /// Foundation often wraps ENOSPC inside a URL or Cocoa file error.
496
+ static func errorCode(_ error: Error, fallback: String) -> String {
497
+ var cause: NSError? = error as NSError
498
+ var seen = Set<ObjectIdentifier>()
499
+ while let current = cause, seen.insert(ObjectIdentifier(current)).inserted {
500
+ if (current.domain == NSCocoaErrorDomain && current.code == NSFileWriteOutOfSpaceError)
501
+ || (current.domain == NSPOSIXErrorDomain && current.code == Int(POSIXError.Code.ENOSPC.rawValue)) {
502
+ return "NO_SPACE"
503
+ }
504
+ cause = current.userInfo[NSUnderlyingErrorKey] as? NSError
505
+ }
506
+ return fallback
507
+ }
508
+
495
509
  static var root: URL {
496
510
  let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
497
511
  ?? FileManager.default.temporaryDirectory
@@ -578,7 +592,7 @@ enum CraftNativeMedia {
578
592
  return .success(describe(address, target))
579
593
  }
580
594
  catch {
581
- return .failure(CraftNativeActionError(code: "IO_ERROR", message: "media.keep could not store the file: \(error.localizedDescription)"))
595
+ return .failure(CraftNativeActionError(code: errorCode(error, fallback: "IO_ERROR"), message: "media.keep could not store the file: \(error.localizedDescription)"))
582
596
  }
583
597
  }
584
598
 
@@ -0,0 +1,18 @@
1
+ export interface MobileMediaCacheTestOptions {
2
+ platform: 'ios' | 'android';
3
+ /** An owned simulator UUID, or emulator serial. iOS creates a disposable simulator when omitted. */
4
+ device?: string;
5
+ /** Unpublished Craft's packages directory; installed SDK templates are used by default. */
6
+ sourceRoot?: string;
7
+ storageOnly?: boolean;
8
+ /** Installed Gradle CLI (8.11.1 or compatible). */
9
+ gradle?: string;
10
+ /** Allow Gradle to resolve build dependencies. Defaults to offline. */
11
+ online?: boolean;
12
+ log?: (line: string) => void;
13
+ }
14
+ /** Real ENOSPC, cache cleanup, retry, coalescing and offline video decode on an isolated native probe. */
15
+ export declare function verifyMobileMediaCache(options: MobileMediaCacheTestOptions): Promise<{
16
+ platform: string;
17
+ requests: Record<string, number>;
18
+ }>;
@@ -1,3 +1,5 @@
1
+ export { verifyMobileMediaCache } from './mobile-media-testing.js';
2
+ export type { MobileMediaCacheTestOptions } from './mobile-media-testing.js';
1
3
  export type MobileTestDeviceOptions = {
2
4
  platform: 'ios';
3
5
  device: string;
@@ -1,14 +1,270 @@
1
1
  // @bun
2
2
  // src/mobile-testing.ts
3
3
  import { access } from "fs/promises";
4
+ import { isAbsolute as isAbsolute2, join as join2 } from "path";
5
+
6
+ // src/mobile-media-testing.ts
7
+ import { existsSync } from "fs";
8
+ import { mkdir, mkdtemp, rm } from "fs/promises";
9
+ import { homedir, tmpdir } from "os";
4
10
  import { isAbsolute, join } from "path";
11
+ async function verifyMobileMediaCache(options) {
12
+ if (options.platform !== "ios" && options.platform !== "android")
13
+ throw new Error("Choose ios or android");
14
+ if (options.platform === "android" && !/^emulator-\d+$/.test(options.device || ""))
15
+ throw new Error("An explicit disposable Android emulator serial is required");
16
+ if (options.platform === "ios" && options.device && !/^[a-f\d]{8}(?:-[a-f\d]{4}){3}-[a-f\d]{12}$/i.test(options.device))
17
+ throw new Error("An explicit iOS simulator UUID is required");
18
+ if (options.sourceRoot && !isAbsolute(options.sourceRoot))
19
+ throw new Error("sourceRoot must be an absolute local path");
20
+ const root = options.sourceRoot || import.meta.dir;
21
+ const packagedFixtures = join(import.meta.dir, "testing/media-cache");
22
+ const fixtures = existsSync(packagedFixtures) ? packagedFixtures : join(import.meta.dir, "../fixtures/media-cache");
23
+ const print = options.log || console.log;
24
+ const storageOnly = options.storageOnly === true;
25
+ const scratch = await mkdtemp(join(tmpdir(), "craft-media-probe-"));
26
+ const bytes = new Uint8Array(await Bun.file(join(fixtures, "demo.mp4")).arrayBuffer());
27
+ const counts = new Map;
28
+ const timers = new Set;
29
+ const server = Bun.listen({
30
+ hostname: "0.0.0.0",
31
+ port: 0,
32
+ socket: {
33
+ open(socket) {
34
+ socket.data = { request: "", answered: false };
35
+ },
36
+ data(socket, input) {
37
+ socket.data.request += new TextDecoder().decode(input);
38
+ if (socket.data.answered || !socket.data.request.includes(`\r
39
+ \r
40
+ `))
41
+ return;
42
+ socket.data.answered = true;
43
+ const path = socket.data.request.split(" ")[1];
44
+ const n = (counts.get(path) || 0) + 1;
45
+ counts.set(path, n);
46
+ const empty = path.endsWith("/empty.mp4");
47
+ const partial = path.endsWith("/partial.mp4");
48
+ const cut = (path.endsWith("/broken.mp4") || path.endsWith("/restart.mp4")) && n === 1;
49
+ const slow = path.endsWith("/slow.mp4") || path.endsWith("/restart.mp4") && n === 1;
50
+ socket.write(`HTTP/1.1 ${partial ? "206 Partial Content" : "200 OK"}\r
51
+ Content-Type: video/mp4\r
52
+ Content-Length: ${empty ? 0 : bytes.length}\r
53
+ Connection: close\r
54
+ \r
55
+ `);
56
+ if (empty) {
57
+ socket.end();
58
+ return;
59
+ }
60
+ socket.write(bytes.subarray(0, Math.floor(bytes.length / 2)));
61
+ if (cut && !slow) {
62
+ socket.end();
63
+ return;
64
+ }
65
+ const finish = () => {
66
+ if (!cut)
67
+ socket.write(bytes.subarray(Math.floor(bytes.length / 2)));
68
+ socket.end();
69
+ };
70
+ if (slow) {
71
+ const timer = setTimeout(() => {
72
+ timers.delete(timer);
73
+ finish();
74
+ }, cut ? 60000 : 700);
75
+ timers.add(timer);
76
+ } else
77
+ finish();
78
+ },
79
+ error() {},
80
+ drain() {}
81
+ }
82
+ });
83
+ async function run(args, cwd = scratch, allowedFailure = false) {
84
+ const proc = Bun.spawn(args, { cwd, env: { ...process.env, ANDROID_HOME: process.env.ANDROID_HOME || join(homedir(), "Library/Android/sdk") }, stdout: "pipe", stderr: "pipe", timeout: 180000 });
85
+ const [output, errors, status] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]);
86
+ if (status && !allowedFailure)
87
+ throw new Error(`${args[0]} failed (${status}):
88
+ ${output}
89
+ ${errors}`);
90
+ return output + errors;
91
+ }
92
+ let simulator = "";
93
+ let ownsSimulator = false;
94
+ const packageName = "dev.craft.mediaprobe";
95
+ const sdk = process.env.ANDROID_HOME || join(homedir(), "Library/Android/sdk");
96
+ const adb = join(sdk, "platform-tools/adb");
97
+ let serial = "";
98
+ let mountedVolume = "";
99
+ let mountedAndroidCache = "";
100
+ try {
101
+ if (options.platform === "ios") {
102
+ const files = await Bun.file(join(root, "ios/templates/CraftNativeFiles.swift")).text();
103
+ const media = files.slice(files.indexOf("enum CraftNativeMedia {"));
104
+ if (!media.startsWith("enum CraftNativeMedia"))
105
+ throw new Error("iOS media implementation missing");
106
+ const source = join(scratch, "main.swift");
107
+ 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") }');
108
+ await Bun.write(source, `import Foundation
109
+ import CryptoKit
110
+ struct CraftNativeActionError: Error { let code: String;
111
+ let message: String }
112
+ ${isolated}
113
+ ${await Bun.file(join(fixtures, "media-cache.swift.fixture")).text()}`);
114
+ const iosSDK = (await run(["xcrun", "--sdk", "iphonesimulator", "--show-sdk-path"])).trim();
115
+ const executable = join(scratch, "CraftMediaProbe");
116
+ const architecture = process.arch === "arm64" ? "arm64" : "x86_64";
117
+ await run(["xcrun", "swiftc", "-sdk", iosSDK, "-target", `${architecture}-apple-ios17.0-simulator`, source, "-o", executable]);
118
+ await run(["codesign", "--force", "--sign", "-", executable]);
119
+ simulator = options.device || "";
120
+ if (!simulator) {
121
+ const runtimes = JSON.parse(await run(["xcrun", "simctl", "list", "runtimes", "-j"])).runtimes;
122
+ const runtime = runtimes.filter((r) => r.isAvailable && /^com\.apple\.CoreSimulator\.SimRuntime\.iOS-/.test(r.identifier)).sort((a, b) => b.version.localeCompare(a.version, undefined, { numeric: true }))[0];
123
+ if (!runtime)
124
+ throw new Error("Install an available iOS simulator runtime");
125
+ simulator = (await run(["xcrun", "simctl", "create", "Craft Media Cache QA", "com.apple.CoreSimulator.SimDeviceType.iPhone-16", runtime.identifier])).trim();
126
+ ownsSimulator = true;
127
+ await run(["xcrun", "simctl", "boot", simulator]);
128
+ await run(["xcrun", "simctl", "bootstatus", simulator, "-b"]);
129
+ }
130
+ const base = `http://127.0.0.1:${server.port}/ios`;
131
+ const image = join(scratch, "cache-pressure.sparseimage");
132
+ const volume = join(scratch, "cache-volume");
133
+ await mkdir(volume);
134
+ await run(["hdiutil", "create", "-size", "16m", "-fs", "HFS+", "-volname", "HQCachePressure", "-type", "SPARSE", image]);
135
+ await run(["hdiutil", "attach", "-nobrowse", "-mountpoint", volume, image]);
136
+ mountedVolume = volume;
137
+ print(await run(["xcrun", "simctl", "spawn", simulator, executable, base, "storage", join(volume, "craft-media")]));
138
+ await run(["hdiutil", "detach", mountedVolume]);
139
+ mountedVolume = "";
140
+ print("PASS: iOS native ENOSPC, cache cleanup, progress preservation and video redownload");
141
+ if (!storageOnly) {
142
+ print(await run(["xcrun", "simctl", "spawn", simulator, executable, base, "normal"]));
143
+ const interrupted = await run(["xcrun", "simctl", "spawn", simulator, executable, base, "interrupt"], scratch, true);
144
+ print(interrupted);
145
+ if (!interrupted.includes("KILLING"))
146
+ throw new Error("iOS probe did not reach process-death checkpoint");
147
+ if (counts.get("/ios/restart.mp4") !== 1)
148
+ throw new Error("iOS process did not start interrupted download");
149
+ print(await run(["xcrun", "simctl", "spawn", simulator, executable, base, "recover"]));
150
+ if (counts.get("/ios/slow.mp4") !== 1)
151
+ throw new Error("iOS duplicate fetch: keep did not coalesce");
152
+ print("PASS: iOS simulator interruption, restart, retry, prune and cached video decode");
153
+ }
154
+ }
155
+ if (options.platform === "android") {
156
+ serial = options.device || "";
157
+ if (!/^emulator-\d+$/.test(serial))
158
+ throw new Error("Select a disposable Android emulator with ANDROID_SERIAL");
159
+ const shell = await Bun.file(join(root, "android/templates/CraftNativeShell.kt.template")).text();
160
+ const media = shell.match(/internal object CraftNativeMedia \{[\s\S]*?\n\}/)?.[0];
161
+ if (!media)
162
+ throw new Error("Android media implementation missing");
163
+ const android = join(scratch, "android");
164
+ const app = join(android, "app");
165
+ const sources = join(app, "src/main/java/dev/craft/mediaprobe");
166
+ await mkdir(sources, { recursive: true });
167
+ await Bun.write(join(android, "settings.gradle.kts"), `pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } }
168
+ dependencyResolutionManagement { repositories { google(); mavenCentral() } }
169
+ rootProject.name = "HQDownloadProbe"
170
+ include(":app")`);
171
+ await Bun.write(join(android, "build.gradle.kts"), 'plugins { id("com.android.application") version "8.9.1" apply false; id("org.jetbrains.kotlin.android") version "2.0.21" apply false }');
172
+ await Bun.write(join(android, "local.properties"), `sdk.dir=${sdk}
173
+ `);
174
+ await Bun.write(join(android, "gradle.properties"), `org.gradle.jvmargs=-Xmx768m
175
+ org.gradle.workers.max=2
176
+ `);
177
+ await Bun.write(join(app, "build.gradle.kts"), `plugins { id("com.android.application");
178
+ id("org.jetbrains.kotlin.android") }
179
+ android { namespace = "${packageName}";
180
+ compileSdk = 36;
181
+ defaultConfig { applicationId = "${packageName}";
182
+ minSdk = 26;
183
+ targetSdk = 35 };
184
+ compileOptions { sourceCompatibility = JavaVersion.VERSION_17;
185
+ targetCompatibility = JavaVersion.VERSION_17 };
186
+ kotlinOptions { jvmTarget = "17" } }`);
187
+ await Bun.write(join(app, "src/main/AndroidManifest.xml"), `<manifest xmlns:android="http://schemas.android.com/apk/res/android"><uses-permission android:name="android.permission.INTERNET"/><uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/><application android:usesCleartextTraffic="true" android:label="HQ Download Probe"/><instrumentation android:name=".DownloadProbe" android:targetPackage="${packageName}"/></manifest>`);
188
+ await Bun.write(join(sources, "DownloadProbe.kt"), `package ${packageName}
189
+ import android.content.Context
190
+ import android.net.Uri
191
+ import android.os.Handler
192
+ import android.os.Looper
193
+ import org.json.JSONObject
194
+ import java.io.File
195
+ import java.util.concurrent.Executors
196
+ internal data class CraftNativeCapabilityResult(val data: Any? = null, val code: String? = null, val message: String? = null)
197
+ ${media}
198
+ ${await Bun.file(join(fixtures, "media-cache.kt")).text()}`);
199
+ await run([options.gradle || process.env.GRADLE_EXECUTABLE || "gradle", ...options.online ? [] : ["--offline"], "--no-daemon", ":app:assembleDebug"], android);
200
+ print(await run([adb, "-s", serial, "install", "-r", join(app, "build/outputs/apk/debug/app-debug.apk")]));
201
+ const base = `http://10.0.2.2:${server.port}/android`;
202
+ const probe = async (phase, interrupted = false) => {
203
+ const result = await run([adb, "-s", serial, "shell", "am", "instrument", "-w", "-e", "base", base, "-e", "phase", phase, `${packageName}/.DownloadProbe`], scratch, interrupted);
204
+ if (!interrupted && (!result.includes("PASS:") || result.includes("FAIL:")))
205
+ throw new Error(result);
206
+ print(result);
207
+ };
208
+ await run([adb, "-s", serial, "root"]);
209
+ await run([adb, "-s", serial, "wait-for-device"]);
210
+ if ((await run([adb, "-s", serial, "shell", "id", "-u"])).trim() !== "0")
211
+ throw new Error("Storage fault tests require a rootable disposable AOSP emulator");
212
+ await run([adb, "-s", serial, "shell", "run-as", packageName, "mkdir", "-p", "files/craft-media"]);
213
+ const cache = `/data/user/0/${packageName}/files/craft-media`;
214
+ const owner = (await run([adb, "-s", serial, "shell", "stat", "-c", "%u:%g", cache])).trim();
215
+ const label = (await run([adb, "-s", serial, "shell", "ls", "-Zd", cache])).trim().split(/\s+/)[0];
216
+ if (!/^\d+:\d+$/.test(owner) || !/^u:object_r:app_data_file:s0(?::c\d+(?:,c\d+)*)?$/.test(label))
217
+ throw new Error("Unexpected probe cache ownership or SELinux label");
218
+ const [uid, gid] = owner.split(":");
219
+ await run([adb, "-s", serial, "shell", "mount", "-t", "tmpfs", "-o", `size=16m,mode=700,uid=${uid},gid=${gid}`, "tmpfs", cache]);
220
+ mountedAndroidCache = cache;
221
+ await run([adb, "-s", serial, "shell", "chcon", label, cache]);
222
+ await probe("storage");
223
+ await run([adb, "-s", serial, "shell", "am", "force-stop", packageName]);
224
+ await run([adb, "-s", serial, "shell", "umount", mountedAndroidCache]);
225
+ mountedAndroidCache = "";
226
+ print("PASS: Android native ENOSPC, cache cleanup, progress preservation and video redownload");
227
+ if (!storageOnly) {
228
+ await probe("normal");
229
+ await probe("interrupt", true);
230
+ if (counts.get("/android/restart.mp4") !== 1)
231
+ throw new Error("Android process did not start interrupted download");
232
+ await probe("recover");
233
+ if (counts.get("/android/slow.mp4") !== 1)
234
+ throw new Error("Android duplicate fetch: keep did not coalesce");
235
+ print("PASS: Android emulator interruption, restart, retry, prune and cached video decode");
236
+ }
237
+ }
238
+ } finally {
239
+ print(JSON.stringify({ requests: Object.fromEntries(counts) }));
240
+ if (mountedVolume)
241
+ await run(["hdiutil", "detach", mountedVolume]);
242
+ if (mountedAndroidCache) {
243
+ await run([adb, "-s", serial, "shell", "am", "force-stop", packageName], scratch, true);
244
+ await run([adb, "-s", serial, "shell", "umount", mountedAndroidCache]);
245
+ }
246
+ if (simulator && ownsSimulator) {
247
+ await run(["xcrun", "simctl", "shutdown", simulator], scratch, true);
248
+ await run(["xcrun", "simctl", "delete", simulator], scratch, true);
249
+ }
250
+ if (serial)
251
+ await run([adb, "-s", serial, "uninstall", packageName], scratch, true);
252
+ for (const timer of timers)
253
+ clearTimeout(timer);
254
+ server.stop(true);
255
+ await rm(scratch, { recursive: true, force: true });
256
+ }
257
+ return { platform: options.platform, requests: Object.fromEntries(counts) };
258
+ }
259
+
260
+ // src/mobile-testing.ts
5
261
  function requireIosSimulator(device) {
6
262
  if (!/^[a-f\d]{8}(?:-[a-f\d]{4}){3}-[a-f\d]{12}$/i.test(device))
7
263
  throw new Error("An explicit iOS simulator UUID is required");
8
264
  }
9
265
  async function recordIosSimulatorVideo(options) {
10
266
  requireIosSimulator(options.device);
11
- if (!isAbsolute(options.path))
267
+ if (!isAbsolute2(options.path))
12
268
  throw new Error("A local absolute recording path is required");
13
269
  const timeout = options.timeoutMs ?? 30000;
14
270
  if (!Number.isSafeInteger(timeout) || timeout < 1)
@@ -120,7 +376,7 @@ ${errors || output}`);
120
376
  async function installedBuild() {
121
377
  if (options.platform === "ios") {
122
378
  const app = await ios("get_app_container", options.device, options.appId, "app");
123
- return run(["/usr/libexec/PlistBuddy", "-c", "Print :CFBundleVersion", join(app, "Info.plist")]);
379
+ return run(["/usr/libexec/PlistBuddy", "-c", "Print :CFBundleVersion", join2(app, "Info.plist")]);
124
380
  }
125
381
  const result = await android("shell", "dumpsys", "package", options.appId);
126
382
  const version = result.match(/\bversionCode=(\d+)/)?.[1];
@@ -154,7 +410,7 @@ ${errors || output}`);
154
410
  async function installUpdate(artifact) {
155
411
  const previousBuild = await installedBuild();
156
412
  if (options.platform === "ios") {
157
- const id = await run(["/usr/libexec/PlistBuddy", "-c", "Print :CFBundleIdentifier", join(artifact, "Info.plist")]);
413
+ const id = await run(["/usr/libexec/PlistBuddy", "-c", "Print :CFBundleIdentifier", join2(artifact, "Info.plist")]);
158
414
  if (id !== options.appId)
159
415
  throw new Error("Update bundle identifier differs from the installed app");
160
416
  await ios("install", options.device, artifact);
@@ -167,6 +423,7 @@ ${errors || output}`);
167
423
  return { restart, installUpdate, installedBuild };
168
424
  }
169
425
  export {
426
+ verifyMobileMediaCache,
170
427
  recordIosSimulatorVideo,
171
428
  createMobileTestDevice
172
429
  };
@@ -0,0 +1,123 @@
1
+ class DownloadProbe : android.app.Instrumentation() {
2
+ override fun onCreate(arguments: android.os.Bundle?) {
3
+ super.onCreate(arguments)
4
+ args = arguments ?: android.os.Bundle()
5
+ start()
6
+ }
7
+ private var args = android.os.Bundle()
8
+ private var latestErrorCode: String? = null
9
+ private val proof = StringBuilder()
10
+ private fun check(yes: Boolean, message: String) {
11
+ if (!yes) throw IllegalStateException(message)
12
+ proof.append("PASS: ").append(message).append('\n')
13
+ android.util.Log.i("CraftMediaProbe", "PASS: $message")
14
+ }
15
+ private fun keep(address: String): Boolean {
16
+ val done = java.util.concurrent.CountDownLatch(1)
17
+ var ok = false
18
+ CraftNativeMedia.keep(targetContext, address, JSONObject(), true) {
19
+ latestErrorCode = it.code
20
+ ok = (it.data as? JSONObject)?.optBoolean("ok") == true
21
+ done.countDown()
22
+ }
23
+ check(done.await(25, java.util.concurrent.TimeUnit.SECONDS), "operation settled")
24
+ return ok
25
+ }
26
+ override fun onStart() {
27
+ val result = android.os.Bundle()
28
+ try {
29
+ val base = args.getString("base")!!
30
+ val restart = "$base/restart.mp4"
31
+ when (args.getString("phase")) {
32
+ "storage" -> {
33
+ CraftNativeMedia.prune(targetContext, emptyList())
34
+ val prefs = targetContext.getSharedPreferences("workout-recovery-probe", Context.MODE_PRIVATE)
35
+ val progress = """{"sets":["A1"],"note":"Keep this workout","rpe":7}"""
36
+ check(prefs.edit().putString("progress", progress).commit(), "workout progress is durable before storage failure")
37
+ val prefsDisk = File(targetContext.applicationInfo.dataDir, "shared_prefs/workout-recovery-probe.xml")
38
+ val savedPrefs = prefsDisk.readBytes()
39
+ val retained = "$base/retained.mp4"
40
+ check(keep(retained), "existing video is kept before storage pressure")
41
+ val original = CraftNativeMedia.local(targetContext, retained)!!.readBytes()
42
+ val fresh = "$base/storage.mp4"
43
+ val staging = File(CraftNativeMedia.file(targetContext, fresh).absolutePath + ".part")
44
+ val root = staging.parentFile!!
45
+ check(root.totalSpace in 1..(32L * 1024 * 1024), "storage pressure is confined to a bounded disposable volume")
46
+ val pressure = File(root, "pressure.mp4")
47
+ var written = 0L
48
+ pressure.outputStream().use { output ->
49
+ for (size in listOf(65536, 1024)) {
50
+ val chunk = ByteArray(size) { 0x5A.toByte() }
51
+ while (written < 32L * 1024 * 1024) {
52
+ try { output.write(chunk); written += size }
53
+ catch (error: java.io.IOException) { break }
54
+ }
55
+ }
56
+ }
57
+ check(written > 0 && root.usableSpace < 1024, "the probe cache filesystem is actually full")
58
+ check(!keep(fresh), "out-of-space write refuses a new video")
59
+ check(latestErrorCode == "NO_SPACE", "a full disk is reported as NO_SPACE, not a network failure")
60
+ check(CraftNativeMedia.local(targetContext, fresh) == null, "storage failure never publishes a partial video")
61
+ check(!staging.exists(), "storage failure removes staging files")
62
+ check(CraftNativeMedia.local(targetContext, retained)!!.readBytes().contentEquals(original), "storage failure preserves an existing video")
63
+ check(CraftNativeMedia.prune(targetContext, listOf(retained)) == 1, "cache cleanup frees the obsolete file and preserves the retained video")
64
+ check(prefs.getString("progress", null) == progress && prefsDisk.readBytes().contentEquals(savedPrefs), "cache cleanup preserves workout progress and feedback on disk")
65
+ check(keep(fresh), "same missing video downloads after cleanup")
66
+ val decoder = android.media.MediaMetadataRetriever()
67
+ try {
68
+ decoder.setDataSource(CraftNativeMedia.local(targetContext, fresh)!!.absolutePath)
69
+ check(decoder.getFrameAtTime(1000000) != null, "downloaded replacement plays from its local file")
70
+ } finally { decoder.release() }
71
+ check(prefs.getString("progress", null) == progress && prefsDisk.readBytes().contentEquals(savedPrefs), "redownloading leaves workout progress and feedback unchanged on disk")
72
+ CraftNativeMedia.prune(targetContext, emptyList())
73
+ prefs.edit().clear().commit()
74
+ }
75
+ "interrupt" -> {
76
+ CraftNativeMedia.prune(targetContext, emptyList())
77
+ CraftNativeMedia.keep(targetContext, restart, JSONObject(), true) {}
78
+ val part = File(CraftNativeMedia.file(targetContext, restart).absolutePath + ".part")
79
+ val until = System.currentTimeMillis() + 25000
80
+ while ((!part.exists() || part.length() == 0L) && System.currentTimeMillis() < until) Thread.sleep(25)
81
+ check(part.length() > 0, "download is in progress before process death")
82
+ android.os.Process.killProcess(android.os.Process.myPid())
83
+ }
84
+ "recover" -> {
85
+ check(CraftNativeMedia.local(targetContext, restart) == null, "interrupted process never published a partial video")
86
+ check(keep(restart), "retry after process death downloads a complete video")
87
+ val media = android.media.MediaMetadataRetriever()
88
+ try {
89
+ media.setDataSource(CraftNativeMedia.local(targetContext, restart)!!.absolutePath)
90
+ check(media.getFrameAtTime(1000000) != null, "recovered local video decodes a frame without network")
91
+ } finally { media.release() }
92
+ CraftNativeMedia.prune(targetContext, emptyList())
93
+ }
94
+ else -> {
95
+ CraftNativeMedia.prune(targetContext, emptyList())
96
+ val broken = "$base/broken.mp4"
97
+ check(!keep(broken), "truncated transfer fails")
98
+ check(CraftNativeMedia.local(targetContext, broken) == null, "truncated transfer is not playable cache")
99
+ val root = CraftNativeMedia.file(targetContext, broken).parentFile!!
100
+ check(root.listFiles()?.none { it.name.endsWith(".part") } != false, "failed transfer cleans staging files")
101
+ check(keep(broken), "same URL retries successfully")
102
+ check(!keep("$base/partial.mp4"), "unsolicited partial response is refused")
103
+ check(!keep("$base/empty.mp4"), "empty file is refused")
104
+ val slow = "$base/slow.mp4"
105
+ val done = java.util.concurrent.CountDownLatch(2)
106
+ val answers = java.util.Collections.synchronizedList(mutableListOf<Boolean>())
107
+ repeat(2) { CraftNativeMedia.keep(targetContext, slow, JSONObject(), true) { answers.add((it.data as? JSONObject)?.optBoolean("ok") == true); done.countDown() } }
108
+ Thread.sleep(150)
109
+ CraftNativeMedia.prune(targetContext, emptyList())
110
+ check(done.await(25, java.util.concurrent.TimeUnit.SECONDS), "coalesced downloads settled")
111
+ check(answers.size == 2 && answers.all { it }, "concurrent downloads coalesce and survive prune")
112
+ check(root.listFiles()?.none { it.name.endsWith(".part") } != false, "no abandoned staging files remain")
113
+ CraftNativeMedia.prune(targetContext, emptyList())
114
+ }
115
+ }
116
+ result.putString("stream", "${proof}PASS: ${args.getString("phase")} native download probe")
117
+ finish(-1, result)
118
+ } catch (error: Throwable) {
119
+ result.putString("stream", "${proof}FAIL: ${error.stackTraceToString()}")
120
+ finish(1, result)
121
+ }
122
+ }
123
+ }
@@ -0,0 +1,118 @@
1
+ // Compiled with the released CraftNativeMedia into an iOS simulator process.
2
+ import AVFoundation
3
+
4
+ let base = CommandLine.arguments[1]
5
+ let phase = CommandLine.arguments[2]
6
+ let fm = FileManager.default
7
+ func check(_ yes: @autoclosure () -> Bool, _ message: String) {
8
+ guard yes() else { print("FAIL: \(message)"); exit(1) }
9
+ print("PASS: \(message)")
10
+ }
11
+ func wait(_ ready: () -> Bool) {
12
+ let limit = Date().addingTimeInterval(25)
13
+ while !ready() && Date() < limit { RunLoop.current.run(until: Date().addingTimeInterval(0.01)) }
14
+ check(ready(), "operation settled")
15
+ }
16
+ var latestTask: URLSessionTask?
17
+ var latestErrorCode: String?
18
+ func start(_ address: String, _ done: @escaping (Bool) -> Void) {
19
+ if let local = CraftNativeMedia.local(address) { done((CraftNativeMedia.describe(address, local)["size"] as? Int ?? 0) > 0); return }
20
+ if CraftNativeMedia.join(address, { result in
21
+ if case .success(let data) = result { done((data as? [String: Any])?["ok"] as? Bool == true) }
22
+ else if case .failure(let error) = result { latestErrorCode = error.code; done(false) }
23
+ }) { return }
24
+ let task = URLSession.shared.downloadTask(with: URL(string: address)!) { location, response, error in
25
+ let result: Result<Any, CraftNativeActionError>
26
+ if error != nil { result = .failure(CraftNativeActionError(code: "NETWORK_ERROR", message: "Interrupted")) }
27
+ else if let location, let response = response as? HTTPURLResponse { result = CraftNativeMedia.store(address, at: location, response: response) }
28
+ else { result = .failure(CraftNativeActionError(code: "NETWORK_ERROR", message: "No file")) }
29
+ CraftNativeMedia.finish(address, result)
30
+ }
31
+ latestTask = task
32
+ task.resume()
33
+ }
34
+ func keep(_ address: String) -> Bool {
35
+ var result: Bool?
36
+ start(address) { result = $0 }
37
+ wait { result != nil }
38
+ return result!
39
+ }
40
+ let restart = base + "/restart.mp4"
41
+ if phase == "storage" {
42
+ let volume = CraftNativeMedia.root.deletingLastPathComponent()
43
+ let capacity = try volume.resourceValues(forKeys: [.volumeTotalCapacityKey]).volumeTotalCapacity ?? 0
44
+ check(capacity > 0 && capacity <= 32 * 1024 * 1024, "storage pressure is confined to a bounded disposable volume")
45
+ let progress = volume.appendingPathComponent("workout-progress.json")
46
+ let saved = Data(#"{"sets":["A1"],"note":"Keep this workout","rpe":7}"#.utf8)
47
+ try saved.write(to: progress)
48
+ let pressure = volume.appendingPathComponent("pressure")
49
+ defer { try? fm.removeItem(at: pressure); try? fm.removeItem(at: progress) }
50
+ let retained = base + "/retained.mp4"
51
+ check(keep(retained), "existing video is kept before storage pressure")
52
+ let original = try Data(contentsOf: CraftNativeMedia.local(retained)!)
53
+ fm.createFile(atPath: pressure.path, contents: nil)
54
+ let writer = try FileHandle(forWritingTo: pressure)
55
+ var written = 0
56
+ for size in [65536, 1024] {
57
+ let chunk = Data(repeating: 0x5A, count: size)
58
+ while written < 32 * 1024 * 1024 {
59
+ do { try writer.write(contentsOf: chunk); written += size }
60
+ catch { break }
61
+ }
62
+ }
63
+ try writer.close()
64
+ let fresh = base + "/storage.mp4"
65
+ check(!keep(fresh), "full cache volume refuses a new video")
66
+ check(latestErrorCode == "NO_SPACE", "a full disk is reported as NO_SPACE, not a network failure")
67
+ check(CraftNativeMedia.local(fresh) == nil, "storage failure never publishes a partial video")
68
+ check((try? Data(contentsOf: CraftNativeMedia.local(retained)!)) == original, "storage failure preserves an existing video")
69
+ check(((try? fm.contentsOfDirectory(atPath: CraftNativeMedia.root.path)) ?? []).allSatisfy { !$0.hasSuffix(".part") }, "storage failure removes staging files")
70
+ check(CraftNativeMedia.prune(keep: []) == 1, "cache cleanup frees only the retained video")
71
+ check((try? Data(contentsOf: progress)) == saved, "cache cleanup preserves workout progress and feedback")
72
+ check(keep(fresh), "same missing video downloads after cache cleanup frees space")
73
+ let generator = AVAssetImageGenerator(asset: AVURLAsset(url: CraftNativeMedia.local(fresh)!))
74
+ do { _ = try generator.copyCGImage(at: CMTime(seconds: 1, preferredTimescale: 600), actualTime: nil) }
75
+ catch { check(false, "downloaded replacement decodes: \(error)") }
76
+ check(true, "downloaded replacement plays from its local file")
77
+ check((try? Data(contentsOf: progress)) == saved, "redownloading leaves workout progress and feedback unchanged")
78
+ _ = CraftNativeMedia.prune(keep: [])
79
+ exit(0)
80
+ }
81
+ if phase == "interrupt" {
82
+ _ = CraftNativeMedia.prune(keep: [])
83
+ start(restart) { _ in }
84
+ print("INTERRUPTING"); fflush(stdout)
85
+ wait { (latestTask?.countOfBytesReceived ?? 0) > 0 }
86
+ print("KILLING"); fflush(stdout)
87
+ raise(SIGKILL)
88
+ exit(2)
89
+ }
90
+ if phase == "recover" {
91
+ check(CraftNativeMedia.local(restart) == nil, "interrupted process never published a partial video")
92
+ check(keep(restart), "retry after process death downloads a complete video")
93
+ let local = CraftNativeMedia.local(restart)!
94
+ let generator = AVAssetImageGenerator(asset: AVURLAsset(url: local))
95
+ do { _ = try generator.copyCGImage(at: CMTime(seconds: 1, preferredTimescale: 600), actualTime: nil) }
96
+ catch { check(false, "recovered video decodes offline: \(error)") }
97
+ check(true, "recovered local video decodes a frame without network")
98
+ _ = CraftNativeMedia.prune(keep: [])
99
+ exit(0)
100
+ }
101
+ _ = CraftNativeMedia.prune(keep: [])
102
+ let broken = base + "/broken.mp4"
103
+ check(!keep(broken), "truncated transfer fails")
104
+ check(CraftNativeMedia.local(broken) == nil, "truncated transfer is not playable cache")
105
+ check(keep(broken), "same URL retries successfully")
106
+ check(!keep(base + "/partial.mp4"), "unsolicited partial response is refused")
107
+ check(!keep(base + "/empty.mp4"), "empty file is refused")
108
+ let slow = base + "/slow.mp4"
109
+ var answers = [Bool]()
110
+ start(slow) { answers.append($0) }
111
+ start(slow) { answers.append($0) }
112
+ RunLoop.current.run(until: Date().addingTimeInterval(0.15))
113
+ _ = CraftNativeMedia.prune(keep: [])
114
+ wait { answers.count == 2 }
115
+ check(answers == [true, true], "concurrent downloads coalesce and survive prune")
116
+ let parts = (try? fm.contentsOfDirectory(atPath: CraftNativeMedia.root.path))?.filter { $0.hasSuffix(".part") } ?? []
117
+ check(parts.isEmpty, "no abandoned staging files remain")
118
+ _ = CraftNativeMedia.prune(keep: [])
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.222",
3
+ "version": "0.0.223",
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>",