qapture2 0.9.1 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -166,6 +166,7 @@ function deleteQaDatabase(namespace) {
166
166
  // src/config/schema.ts
167
167
  var DEFAULTS = {
168
168
  namespace: "qapture",
169
+ shotPort: 7017,
169
170
  brandLabel: "Qapture",
170
171
  loginField: { en: "Username", ar: "\u0627\u0633\u0645 \u0627\u0644\u0645\u0633\u062A\u062E\u062F\u0645" },
171
172
  rtl: false,
@@ -336,6 +337,7 @@ function validateConfig(input) {
336
337
  return {
337
338
  config: {
338
339
  namespace: DEFAULTS.namespace,
340
+ shotPort: DEFAULTS.shotPort,
339
341
  brand: { label: DEFAULTS.brandLabel },
340
342
  loginField: { ...DEFAULTS.loginField },
341
343
  credentials: [],
@@ -358,6 +360,7 @@ function validateConfig(input) {
358
360
  return {
359
361
  config: {
360
362
  namespace: DEFAULTS.namespace,
363
+ shotPort: DEFAULTS.shotPort,
361
364
  brand: { label: DEFAULTS.brandLabel },
362
365
  loginField: { ...DEFAULTS.loginField },
363
366
  credentials: [],
@@ -377,6 +380,8 @@ function validateConfig(input) {
377
380
  }
378
381
  const raw = input;
379
382
  const namespace = isNonEmptyString(raw["namespace"]) ? raw["namespace"].trim() : DEFAULTS.namespace;
383
+ const rawPort = Number(raw["shotPort"]);
384
+ const shotPort = Number.isInteger(rawPort) && rawPort > 0 && rawPort < 65536 ? rawPort : DEFAULTS.shotPort;
380
385
  if (raw["theme"] !== void 0) {
381
386
  warnings.push(
382
387
  'theme: custom themes were removed in Qapture 0.3.0 \u2014 the widget now ships one fixed, self-contained design. The "theme" key is ignored; remove it from your qa.config to silence this warning.'
@@ -426,6 +431,7 @@ function validateConfig(input) {
426
431
  return {
427
432
  config: {
428
433
  namespace,
434
+ shotPort,
429
435
  brand: { label: brandLabel },
430
436
  loginField,
431
437
  credentials,
@@ -1931,6 +1937,10 @@ var STR = {
1931
1937
  exact_declined: "Staying on redrawn screenshots",
1932
1938
  exact_turn_on: "Turn on",
1933
1939
  exact_unsupported: "Needs a desktop browser \u2014 phones cannot photograph the screen",
1940
+ exact_native_hint: "Screenshots are real photographs, taken by the qapture helper on this machine with the same engine as Cmd+Shift+4. It never asks permission and leaves nothing recording, so it costs no battery. Nothing leaves your device.",
1941
+ exact_native_on: "Real screenshots on \u2014 local helper",
1942
+ exact_helper_offer: "To stop being asked every time: run this next to your dev server and leave it going. macOS asks once for Screen Recording, then never again.",
1943
+ exact_helper_safari: "Safari cannot reach the local helper on an https site \u2014 it blocks loopback as mixed content, and no setting changes that. Open this site in Chrome for screenshots that never ask, or turn the option above off to stay on redrawn shots.",
1934
1944
  sync_title: "Save to a folder",
1935
1945
  sync_hint: "Every note is written to your disk the moment you save it \u2014 nothing is lost if this browser dies.",
1936
1946
  sync_choose: "Choose folder",
@@ -2168,6 +2178,10 @@ var STR = {
2168
2178
  exact_off: "\u0627\u0644\u0639\u0648\u062F\u0629 \u0625\u0644\u0649 \u0627\u0644\u0644\u0642\u0637\u0627\u062A \u0627\u0644\u0645\u064F\u0639\u0627\u062F \u0631\u0633\u0645\u0647\u0627",
2169
2179
  exact_declined: "\u0633\u0646\u0628\u0642\u0649 \u0639\u0644\u0649 \u0627\u0644\u0644\u0642\u0637\u0627\u062A \u0627\u0644\u0645\u064F\u0639\u0627\u062F \u0631\u0633\u0645\u0647\u0627",
2170
2180
  exact_turn_on: "\u062A\u0641\u0639\u064A\u0644",
2181
+ exact_native_hint: "\u0627\u0644\u0644\u0642\u0637\u0627\u062A \u0635\u0648\u0631 \u062D\u0642\u064A\u0642\u064A\u0629 \u064A\u0644\u062A\u0642\u0637\u0647\u0627 \u0645\u0633\u0627\u0639\u062F qapture \u0639\u0644\u0649 \u0647\u0630\u0627 \u0627\u0644\u062C\u0647\u0627\u0632 \u0628\u0627\u0644\u0645\u062D\u0631\u0643 \u0646\u0641\u0633\u0647 \u0627\u0644\u0630\u064A \u064A\u0633\u062A\u062E\u062F\u0645\u0647 Cmd+Shift+4. \u0644\u0627 \u064A\u0637\u0644\u0628 \u0625\u0630\u0646\u064B\u0627 \u0648\u0644\u0627 \u064A\u062A\u0631\u0643 \u0623\u064A \u062A\u0633\u062C\u064A\u0644 \u064A\u0639\u0645\u0644\u060C \u0641\u0644\u0627 \u064A\u0633\u062A\u0647\u0644\u0643 \u0627\u0644\u0628\u0637\u0627\u0631\u064A\u0629. \u0648\u0644\u0627 \u0634\u064A\u0621 \u064A\u063A\u0627\u062F\u0631 \u062C\u0647\u0627\u0632\u0643.",
2182
+ exact_native_on: "\u0627\u0644\u0644\u0642\u0637\u0627\u062A \u0627\u0644\u062D\u0642\u064A\u0642\u064A\u0629 \u0645\u0641\u0639\u0651\u0644\u0629 \u2014 \u0627\u0644\u0645\u0633\u0627\u0639\u062F \u0627\u0644\u0645\u062D\u0644\u064A",
2183
+ exact_helper_offer: "\u0644\u0625\u064A\u0642\u0627\u0641 \u0627\u0644\u0633\u0624\u0627\u0644 \u0641\u064A \u0643\u0644 \u0645\u0631\u0629: \u0634\u063A\u0651\u0644 \u0647\u0630\u0627 \u0627\u0644\u0623\u0645\u0631 \u0628\u062C\u0627\u0646\u0628 \u062E\u0627\u062F\u0645 \u0627\u0644\u062A\u0637\u0648\u064A\u0631 \u0648\u0627\u062A\u0631\u0643\u0647 \u064A\u0639\u0645\u0644. \u064A\u0637\u0644\u0628 \u0645\u0627\u0643 \u0625\u0630\u0646 \u062A\u0633\u062C\u064A\u0644 \u0627\u0644\u0634\u0627\u0634\u0629 \u0645\u0631\u0629 \u0648\u0627\u062D\u062F\u0629 \u0641\u0642\u0637.",
2184
+ exact_helper_safari: "\u0644\u0627 \u064A\u0633\u062A\u0637\u064A\u0639 Safari \u0627\u0644\u0648\u0635\u0648\u0644 \u0625\u0644\u0649 \u0627\u0644\u0645\u0633\u0627\u0639\u062F \u0627\u0644\u0645\u062D\u0644\u064A \u0639\u0644\u0649 \u0645\u0648\u0642\u0639 https \u2014 \u0641\u0647\u0648 \u064A\u062D\u062C\u0628 \u0627\u0644\u0639\u0646\u0648\u0627\u0646 \u0627\u0644\u0645\u062D\u0644\u064A \u0643\u0645\u062D\u062A\u0648\u0649 \u0645\u062E\u062A\u0644\u0637\u060C \u0648\u0644\u0627 \u064A\u063A\u064A\u0651\u0631 \u0630\u0644\u0643 \u0623\u064A \u0625\u0639\u062F\u0627\u062F. \u0627\u0641\u062A\u062D \u0627\u0644\u0645\u0648\u0642\u0639 \u0641\u064A Chrome \u0644\u0644\u062D\u0635\u0648\u0644 \u0639\u0644\u0649 \u0644\u0642\u0637\u0627\u062A \u0628\u0644\u0627 \u0633\u0624\u0627\u0644\u060C \u0623\u0648 \u0623\u0637\u0641\u0626 \u0627\u0644\u062E\u064A\u0627\u0631 \u0623\u0639\u0644\u0627\u0647 \u0644\u0644\u0628\u0642\u0627\u0621 \u0639\u0644\u0649 \u0627\u0644\u0644\u0642\u0637\u0627\u062A \u0627\u0644\u0645\u064F\u0639\u0627\u062F \u0631\u0633\u0645\u0647\u0627.",
2171
2185
  exact_unsupported: "\u064A\u062A\u0637\u0644\u0628 \u0645\u062A\u0635\u0641\u062D \u0633\u0637\u062D \u0645\u0643\u062A\u0628 \u2014 \u0627\u0644\u0647\u0648\u0627\u062A\u0641 \u0644\u0627 \u062A\u0633\u062A\u0637\u064A\u0639 \u062A\u0635\u0648\u064A\u0631 \u0627\u0644\u0634\u0627\u0634\u0629",
2172
2186
  sync_title: "\u0627\u0644\u062D\u0641\u0638 \u0641\u064A \u0645\u062C\u0644\u062F",
2173
2187
  sync_hint: "\u062A\u064F\u0643\u062A\u0628 \u0643\u0644 \u0645\u0644\u0627\u062D\u0638\u0629 \u0639\u0644\u0649 \u0627\u0644\u0642\u0631\u0635 \u0644\u062D\u0638\u0629 \u062D\u0641\u0638\u0647\u0627 \u2014 \u0644\u0627 \u0634\u064A\u0621 \u064A\u0636\u064A\u0639 \u0625\u0630\u0627 \u062A\u0639\u0637\u0651\u0644 \u0627\u0644\u0645\u062A\u0635\u0641\u062D.",
@@ -2504,13 +2518,156 @@ function mapRectToFrame(rect, m, frameW, frameH) {
2504
2518
  if (sw < 1 || sh < 1) return null;
2505
2519
  return { sx, sy, sw, sh };
2506
2520
  }
2521
+ function environmentSignature() {
2522
+ if (typeof window === "undefined") return "";
2523
+ return [
2524
+ window.innerWidth,
2525
+ window.innerHeight,
2526
+ window.outerWidth,
2527
+ window.outerHeight,
2528
+ window.screenX,
2529
+ window.screenY,
2530
+ Math.round((window.devicePixelRatio || 1) * 100)
2531
+ ].join("x");
2532
+ }
2533
+
2534
+ // src/lib/nativeShot.ts
2535
+ var DEFAULT_SHOT_PORT = 7017;
2536
+ var PROBE_TIMEOUT_MS = 700;
2537
+ var SHOT_TIMEOUT_MS = 9e3;
2538
+ var ABSENT_RECHECK_MS = 3e4;
2539
+ var configuredPort = DEFAULT_SHOT_PORT;
2540
+ function helperReachableHere() {
2541
+ if (typeof window === "undefined") return false;
2542
+ if (window.location.protocol !== "https:") return true;
2543
+ const ua = navigator.userAgent;
2544
+ const isSafari = /Safari/.test(ua) && !/Chrome|Chromium|Edg|OPR/.test(ua);
2545
+ return !isSafari;
2546
+ }
2547
+ function setShotPort(port) {
2548
+ if (!Number.isInteger(port) || port <= 0 || port >= 65536) return;
2549
+ if (port === configuredPort) return;
2550
+ configuredPort = port;
2551
+ resetNativeShotProbe();
2552
+ }
2553
+ var cachedBase = null;
2554
+ var lastProbeAt = 0;
2555
+ var lastProbeResult = false;
2556
+ function baseUrl(port) {
2557
+ return `http://127.0.0.1:${port}`;
2558
+ }
2559
+ async function fetchWithTimeout(url, init, ms) {
2560
+ if (typeof fetch !== "function") return null;
2561
+ const ac = typeof AbortController === "function" ? new AbortController() : null;
2562
+ const timer = setTimeout(() => ac?.abort(), ms);
2563
+ try {
2564
+ return await fetch(url, { ...init, signal: ac?.signal, cache: "no-store" });
2565
+ } catch {
2566
+ return null;
2567
+ } finally {
2568
+ clearTimeout(timer);
2569
+ }
2570
+ }
2571
+ async function isNativeShotAvailable(port = configuredPort) {
2572
+ if (typeof window === "undefined") return false;
2573
+ if (!helperReachableHere()) return false;
2574
+ const now2 = Date.now();
2575
+ if (lastProbeResult && cachedBase) return true;
2576
+ if (!lastProbeResult && now2 - lastProbeAt < ABSENT_RECHECK_MS) return false;
2577
+ lastProbeAt = now2;
2578
+ const res = await fetchWithTimeout(`${baseUrl(port)}/qapture/health`, { method: "GET" }, PROBE_TIMEOUT_MS);
2579
+ lastProbeResult = !!res && res.ok;
2580
+ cachedBase = lastProbeResult ? baseUrl(port) : null;
2581
+ return lastProbeResult;
2582
+ }
2583
+ function helperStartCommand(port = configuredPort) {
2584
+ const flags = [];
2585
+ if (port !== DEFAULT_SHOT_PORT) flags.push(`--port ${port}`);
2586
+ if (typeof window !== "undefined") {
2587
+ const origin = window.location.origin;
2588
+ const local = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:|$)/.test(origin);
2589
+ if (!local) flags.push(`--allow ${origin}`);
2590
+ }
2591
+ return `npx qapture2 shots${flags.length ? " " + flags.join(" ") : ""}`;
2592
+ }
2593
+ function resetNativeShotProbe() {
2594
+ cachedBase = null;
2595
+ lastProbeAt = 0;
2596
+ lastProbeResult = false;
2597
+ }
2598
+ async function shootBrowserWindow(port = configuredPort) {
2599
+ if (typeof window === "undefined") return null;
2600
+ const base = cachedBase ?? baseUrl(port);
2601
+ const body = JSON.stringify({
2602
+ x: window.screenX,
2603
+ y: window.screenY,
2604
+ w: window.outerWidth,
2605
+ h: window.outerHeight
2606
+ });
2607
+ const res = await fetchWithTimeout(
2608
+ `${base}/qapture/shot`,
2609
+ { method: "POST", headers: { "content-type": "application/json" }, body },
2610
+ SHOT_TIMEOUT_MS
2611
+ );
2612
+ if (!res || !res.ok) return null;
2613
+ let payload;
2614
+ try {
2615
+ payload = await res.json();
2616
+ } catch {
2617
+ return null;
2618
+ }
2619
+ if (!payload.png) return null;
2620
+ return decodeToCanvas(payload.png);
2621
+ }
2622
+ function decodeToCanvas(dataUrl) {
2623
+ return new Promise((resolve) => {
2624
+ const img = new Image();
2625
+ img.onload = () => {
2626
+ const c = document.createElement("canvas");
2627
+ c.width = img.naturalWidth;
2628
+ c.height = img.naturalHeight;
2629
+ const ctx = c.getContext("2d", { willReadFrequently: true });
2630
+ if (!ctx) {
2631
+ resolve(null);
2632
+ return;
2633
+ }
2634
+ ctx.drawImage(img, 0, 0);
2635
+ resolve(c);
2636
+ };
2637
+ img.onerror = () => resolve(null);
2638
+ img.src = dataUrl;
2639
+ });
2640
+ }
2641
+ var cachedMapping = null;
2642
+ var cachedFor = "";
2643
+ function getCachedMapping() {
2644
+ if (!cachedMapping) return null;
2645
+ return environmentSignature() === cachedFor ? cachedMapping : null;
2646
+ }
2647
+ function cacheMapping(m) {
2648
+ cachedMapping = m;
2649
+ cachedFor = m ? environmentSignature() : "";
2650
+ }
2507
2651
 
2508
2652
  // src/lib/screenCapture.ts
2509
2653
  var ASPECT_TOLERANCE = 0.08;
2510
2654
  var CALIBRATION_SETTLE_MS = 220;
2511
2655
  var FRESH_FRAME_TIMEOUT_MS = 500;
2512
2656
  var VIDEO_READY_TIMEOUT_MS = 4e3;
2657
+ var nativeReady = false;
2658
+ function isHelperReachableHere() {
2659
+ return helperReachableHere();
2660
+ }
2661
+ function getHelperCommand() {
2662
+ return helperStartCommand();
2663
+ }
2664
+ async function refreshNativeAvailability(port) {
2665
+ if (typeof port === "number") setShotPort(port);
2666
+ nativeReady = await isNativeShotAvailable();
2667
+ return nativeReady;
2668
+ }
2513
2669
  function isExactCaptureSupported() {
2670
+ if (nativeReady) return true;
2514
2671
  if (typeof navigator === "undefined" || typeof document === "undefined") return false;
2515
2672
  const md = navigator.mediaDevices;
2516
2673
  return !!md && typeof md.getDisplayMedia === "function";
@@ -2518,13 +2675,15 @@ function isExactCaptureSupported() {
2518
2675
  var armed = false;
2519
2676
  var declined = false;
2520
2677
  var frozen = null;
2521
- var lastMode = null;
2522
2678
  function armExactCapture() {
2523
2679
  if (!isExactCaptureSupported()) return false;
2524
2680
  armed = true;
2525
2681
  declined = false;
2526
2682
  return true;
2527
2683
  }
2684
+ function exactCaptureIsFree() {
2685
+ return nativeReady;
2686
+ }
2528
2687
  function disarmExactCapture() {
2529
2688
  armed = false;
2530
2689
  releaseFrozenFrame();
@@ -2533,6 +2692,7 @@ function resetExactCaptureDecline() {
2533
2692
  declined = false;
2534
2693
  }
2535
2694
  function getExactCaptureStatus() {
2695
+ if (nativeReady) return "native";
2536
2696
  if (!isExactCaptureSupported()) return "unsupported";
2537
2697
  if (armed) return "live";
2538
2698
  if (declined) return "declined";
@@ -2557,7 +2717,7 @@ function stillIsCurrent() {
2557
2717
  return true;
2558
2718
  }
2559
2719
  async function freezeOrReuse() {
2560
- if (stillIsCurrent()) return frozen;
2720
+ if (!nativeReady && stillIsCurrent()) return frozen;
2561
2721
  return freezeViewport();
2562
2722
  }
2563
2723
  function releaseFrozenFrame() {
@@ -2657,7 +2817,7 @@ async function grabFrameCanvas(video, grabber) {
2657
2817
  bitmap?.close?.();
2658
2818
  return c;
2659
2819
  }
2660
- async function calibrate(video, grabber) {
2820
+ async function calibrate(grabFrame) {
2661
2821
  const vw = window.innerWidth;
2662
2822
  const vh = window.innerHeight;
2663
2823
  if (vw <= MARKER_SIZE || vh <= MARKER_SIZE) return null;
@@ -2674,7 +2834,7 @@ async function calibrate(video, grabber) {
2674
2834
  document.body.appendChild(card);
2675
2835
  try {
2676
2836
  await new Promise((r) => setTimeout(r, CALIBRATION_SETTLE_MS));
2677
- const frame = await grabFrameCanvas(video, grabber);
2837
+ const frame = await grabFrame();
2678
2838
  if (!frame) return null;
2679
2839
  const ctx = frame.getContext("2d", { willReadFrequently: true });
2680
2840
  if (!ctx) return null;
@@ -2696,8 +2856,69 @@ async function calibrate(video, grabber) {
2696
2856
  }
2697
2857
  }
2698
2858
  async function freezeViewport() {
2699
- if (!isExactCaptureSupported()) return null;
2700
2859
  releaseFrozenFrame();
2860
+ const native = await freezeViaNativeHelper();
2861
+ if (native) return native;
2862
+ if (!isExactCaptureSupported()) return null;
2863
+ return freezeViaDisplayMedia();
2864
+ }
2865
+ function adoptFrame(raw, mapping, mode, vw, vh) {
2866
+ let page;
2867
+ if (mapping) {
2868
+ const box = mapRectToFrame(
2869
+ { left: 0, top: 0, width: vw, height: vh },
2870
+ mapping,
2871
+ raw.width,
2872
+ raw.height
2873
+ );
2874
+ if (!box) return null;
2875
+ page = document.createElement("canvas");
2876
+ page.width = box.sw;
2877
+ page.height = box.sh;
2878
+ const ctx = page.getContext("2d");
2879
+ if (!ctx) return null;
2880
+ ctx.drawImage(raw, box.sx, box.sy, box.sw, box.sh, 0, 0, box.sw, box.sh);
2881
+ raw.width = 0;
2882
+ raw.height = 0;
2883
+ } else {
2884
+ page = raw;
2885
+ }
2886
+ frozen = {
2887
+ canvas: page,
2888
+ mode,
2889
+ viewportWidth: vw,
2890
+ viewportHeight: vh,
2891
+ takenAt: Date.now()
2892
+ };
2893
+ frozenAtScrollX = window.scrollX;
2894
+ frozenAtScrollY = window.scrollY;
2895
+ frozenAtPath = window.location.pathname + window.location.search;
2896
+ return frozen;
2897
+ }
2898
+ async function freezeViaNativeHelper() {
2899
+ if (typeof window === "undefined") return null;
2900
+ nativeReady = await isNativeShotAvailable();
2901
+ if (!nativeReady) return null;
2902
+ const vw = window.innerWidth;
2903
+ const vh = window.innerHeight;
2904
+ let mapping = getCachedMapping();
2905
+ if (!mapping) {
2906
+ mapping = await calibrate(() => shootBrowserWindow());
2907
+ if (!mapping) {
2908
+ cacheMapping(null);
2909
+ return null;
2910
+ }
2911
+ cacheMapping(mapping);
2912
+ }
2913
+ const raw = await withOverlayHidden(() => shootBrowserWindow());
2914
+ if (!raw) return null;
2915
+ const adopted = adoptFrame(raw, mapping, "native", vw, vh);
2916
+ if (!adopted) {
2917
+ cacheMapping(null);
2918
+ }
2919
+ return adopted;
2920
+ }
2921
+ async function freezeViaDisplayMedia() {
2701
2922
  let stream = null;
2702
2923
  let el = null;
2703
2924
  const release = () => {
@@ -2770,45 +2991,14 @@ async function freezeViewport() {
2770
2991
  const vw = window.innerWidth;
2771
2992
  const vh = window.innerHeight;
2772
2993
  const mode = sharedTab && looksLikeViewport(video.videoWidth, video.videoHeight) ? "tab" : "surface";
2773
- const mapping = mode === "surface" ? await calibrate(video, grabber) : null;
2994
+ const mapping = mode === "surface" ? await calibrate(() => grabFrameCanvas(video, grabber)) : null;
2774
2995
  if (mode === "surface" && !mapping) {
2775
2996
  declined = true;
2776
2997
  return null;
2777
2998
  }
2778
2999
  const raw = await withOverlayHidden(() => grabFrameCanvas(video, grabber));
2779
3000
  if (!raw) return null;
2780
- let page;
2781
- if (mode === "surface" && mapping) {
2782
- const box = mapRectToFrame(
2783
- { left: 0, top: 0, width: vw, height: vh },
2784
- mapping,
2785
- raw.width,
2786
- raw.height
2787
- );
2788
- if (!box) return null;
2789
- page = document.createElement("canvas");
2790
- page.width = box.sw;
2791
- page.height = box.sh;
2792
- const ctx = page.getContext("2d");
2793
- if (!ctx) return null;
2794
- ctx.drawImage(raw, box.sx, box.sy, box.sw, box.sh, 0, 0, box.sw, box.sh);
2795
- raw.width = 0;
2796
- raw.height = 0;
2797
- } else {
2798
- page = raw;
2799
- }
2800
- frozen = {
2801
- canvas: page,
2802
- mode,
2803
- viewportWidth: vw,
2804
- viewportHeight: vh,
2805
- takenAt: Date.now()
2806
- };
2807
- frozenAtScrollX = window.scrollX;
2808
- frozenAtScrollY = window.scrollY;
2809
- frozenAtPath = window.location.pathname + window.location.search;
2810
- lastMode = mode;
2811
- return frozen;
3001
+ return adoptFrame(raw, mode === "surface" ? mapping : null, mode, vw, vh);
2812
3002
  } catch {
2813
3003
  declined = true;
2814
3004
  return null;
@@ -3629,11 +3819,24 @@ function reproSpec(note, index) {
3629
3819
  }
3630
3820
 
3631
3821
  // src/lib/exportZip.ts
3632
- function safeName(name, stamp) {
3633
- const fallback = `qa-notes-${stamp.slice(0, 10)}`;
3822
+ function autoName(project, stamp) {
3823
+ const date = stamp.slice(0, 10);
3824
+ const time = stamp.slice(11, 16).replace(":", "");
3825
+ const slug = (project ?? "").trim().replace(/[\\/:*?"<>|]+/g, "-").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
3826
+ const when = time ? `${date}-${time}` : date;
3827
+ return slug ? `${slug}-qa-${when}` : `qa-notes-${when}`;
3828
+ }
3829
+ function exportProjectName(config) {
3830
+ const name = config?.preamble?.projectName;
3831
+ return typeof name === "string" && name.trim() ? name.trim() : void 0;
3832
+ }
3833
+ function safeName(name, stamp, project) {
3634
3834
  let base = (name ?? "").trim().replace(/\.zip$/i, "");
3635
3835
  base = base.replace(/[\\/:*?"<>|]+/g, "-").replace(/\s+/g, " ").slice(0, 80).trim();
3636
- return `${base || fallback}.zip`;
3836
+ return `${base || autoName(project, stamp)}.zip`;
3837
+ }
3838
+ function suggestedExportName(project, stamp) {
3839
+ return autoName(project, stamp);
3637
3840
  }
3638
3841
  function toStrings(val) {
3639
3842
  if (val == null) return [];
@@ -4011,14 +4214,14 @@ async function buildAndDownloadZip(notes, stamp, filename, config, guideChecked,
4011
4214
  const url = URL.createObjectURL(blob);
4012
4215
  const a = document.createElement("a");
4013
4216
  a.href = url;
4014
- a.download = safeName(filename, stamp);
4217
+ a.download = safeName(filename, stamp, exportProjectName(config));
4015
4218
  document.body.appendChild(a);
4016
4219
  a.click();
4017
4220
  a.remove();
4018
4221
  setTimeout(() => URL.revokeObjectURL(url), 2e3);
4019
4222
  }
4020
- function exportFileName(filename, stamp) {
4021
- return safeName(filename, stamp);
4223
+ function exportFileName(filename, stamp, project) {
4224
+ return safeName(filename, stamp, project);
4022
4225
  }
4023
4226
 
4024
4227
  // src/lib/shareZip.ts
@@ -4716,7 +4919,23 @@ function QaProvider({
4716
4919
  if (exactShotsWanted(storage)) armExactCapture();
4717
4920
  return getExactCaptureStatus();
4718
4921
  });
4719
- const exactSupported = isExactCaptureSupported();
4922
+ const [exactSupported, setExactSupported] = useState(() => isExactCaptureSupported());
4923
+ useEffect(() => {
4924
+ let alive = true;
4925
+ const look = () => {
4926
+ void refreshNativeAvailability(config.shotPort).then(() => {
4927
+ if (!alive) return;
4928
+ setExactSupported(isExactCaptureSupported());
4929
+ setExactStatus(getExactCaptureStatus());
4930
+ });
4931
+ };
4932
+ look();
4933
+ window.addEventListener("focus", look);
4934
+ return () => {
4935
+ alive = false;
4936
+ window.removeEventListener("focus", look);
4937
+ };
4938
+ }, [config.shotPort]);
4720
4939
  const [frozenAt, setFrozenAt] = useState(null);
4721
4940
  const [syncState, setSyncState] = useState(() => getFsSyncState());
4722
4941
  const [syncTick, setSyncTick] = useState(0);
@@ -5242,7 +5461,7 @@ function QaProvider({
5242
5461
  setCapturePrefill(prefill ?? "");
5243
5462
  setCaptureActive(true);
5244
5463
  if (!stillIsCurrent()) setFrozenAt(null);
5245
- if (exactShotsWanted(storage) && isExactCaptureSupported()) {
5464
+ if (exactCaptureIsFree() || exactShotsWanted(storage) && isExactCaptureSupported()) {
5246
5465
  resetExactCaptureDecline();
5247
5466
  void freezeOrReuse().then((frame) => {
5248
5467
  setFrozenAt(frame?.takenAt ?? null);
@@ -5560,7 +5779,7 @@ function QaProvider({
5560
5779
  const shareExport = useCallback(async (filename) => {
5561
5780
  if (!notes.length) return { status: "unsupported" };
5562
5781
  const stamp = nowIso();
5563
- const name = exportFileName(filename, stamp);
5782
+ const name = exportFileName(filename, stamp, exportProjectName(config));
5564
5783
  setIsExporting(true);
5565
5784
  try {
5566
5785
  const blob = await buildZipBlob(notes, stamp, config, guideChecked, guideSkipped);
@@ -5871,7 +6090,13 @@ function QaProvider({
5871
6090
  gradeStep,
5872
6091
  evidenceByStep,
5873
6092
  // v0.4 — screenshots
5874
- exactShots: { supported: exactSupported, status: exactStatus, frozenAt },
6093
+ exactShots: {
6094
+ supported: exactSupported,
6095
+ status: exactStatus,
6096
+ frozenAt,
6097
+ helperReachable: isHelperReachableHere(),
6098
+ helperCommand: getHelperCommand()
6099
+ },
5875
6100
  enableExactShots,
5876
6101
  photographNow,
5877
6102
  disableExactShots,
@@ -5934,6 +6159,7 @@ function QaProvider({
5934
6159
  setSimpleMode,
5935
6160
  compactCapture,
5936
6161
  developerMode,
6162
+ projectName: exportProjectName(config),
5937
6163
  setDeveloperMode,
5938
6164
  setCompactCapture,
5939
6165
  exportZip: exportZipFn
@@ -8207,7 +8433,7 @@ function upgradeHint(latest) {
8207
8433
  }
8208
8434
 
8209
8435
  // src/version.ts
8210
- var QA_VERSION = "0.9.1" ;
8436
+ var QA_VERSION = "0.10.1" ;
8211
8437
  function Section({
8212
8438
  icon,
8213
8439
  title,
@@ -8445,8 +8671,11 @@ function SettingsSheet({ onClose }) {
8445
8671
  /* @__PURE__ */ jsx("div", { className: "qa-h-px qa-bg-3 qa-mt-3 qa-mb-4" }),
8446
8672
  /* @__PURE__ */ jsx("div", { className: "qa-h-px qa-bg-3 qa-mt-3 qa-mb-4" }),
8447
8673
  /* @__PURE__ */ jsxs(Section, { icon: "Camera", title: t("exact_label"), children: [
8448
- /* @__PURE__ */ jsx("p", { className: "qa-m-0 qa-text-10 qa-text-lo qa-leading-relaxed", children: t("exact_hint") }),
8449
- !exactShots.supported ? /* @__PURE__ */ jsx("p", { className: "qa-m-0 qa-text-10 qa-text-mid", children: t("exact_unsupported") }) : exactShots.status === "live" ? /* @__PURE__ */ jsxs(
8674
+ /* @__PURE__ */ jsx("p", { className: "qa-m-0 qa-text-10 qa-text-lo qa-leading-relaxed", children: exactShots.status === "native" ? t("exact_native_hint") : t("exact_hint") }),
8675
+ exactShots.status === "native" ? /* @__PURE__ */ jsxs("p", { className: "qa-m-0 qa-inline-flex qa-items-center qa-gap-1.5 qa-text-xs qa-font-semibold qa-text-success", children: [
8676
+ /* @__PURE__ */ jsx(Icon, { name: "CheckCircle2", size: 13 }),
8677
+ t("exact_native_on")
8678
+ ] }) : !exactShots.supported ? /* @__PURE__ */ jsx("p", { className: "qa-m-0 qa-text-10 qa-text-mid", children: t("exact_unsupported") }) : exactShots.status === "live" ? /* @__PURE__ */ jsxs(
8450
8679
  "button",
8451
8680
  {
8452
8681
  type: "button",
@@ -8471,7 +8700,18 @@ function SettingsSheet({ onClose }) {
8471
8700
  t("exact_turn_on")
8472
8701
  ]
8473
8702
  }
8474
- )
8703
+ ),
8704
+ exactShots.status !== "native" && (exactShots.helperReachable ? /* @__PURE__ */ jsxs("div", { className: "qa-mt-2", children: [
8705
+ /* @__PURE__ */ jsx("p", { className: "qa-m-0 qa-mb-1 qa-text-10 qa-text-lo qa-leading-relaxed", children: t("exact_helper_offer") }),
8706
+ /* @__PURE__ */ jsx(
8707
+ "code",
8708
+ {
8709
+ className: "qa-block qa-rounded-lg qa-bg-2 qa-border qa-border-subtle qa-px-2 qa-py-1.5 qa-text-10 qa-text-hi",
8710
+ style: { userSelect: "all", wordBreak: "break-all" },
8711
+ children: exactShots.helperCommand
8712
+ }
8713
+ )
8714
+ ] }) : /* @__PURE__ */ jsx("p", { className: "qa-m-0 qa-mt-2 qa-text-10 qa-text-mid qa-leading-relaxed", children: t("exact_helper_safari") }))
8475
8715
  ] }),
8476
8716
  /* @__PURE__ */ jsx("div", { className: "qa-h-px qa-bg-3 qa-mt-3 qa-mb-4" }),
8477
8717
  /* @__PURE__ */ jsxs("label", { className: "qa-flex qa-items-start qa-gap-2 qa-text-xs qa-text-hi", style: { cursor: "pointer" }, children: [
@@ -8820,8 +9060,8 @@ var TABS = ALL_TABS.filter((tab) => tab.key !== "guide" || GUIDE_TAB_ENABLED);
8820
9060
  function visibleTab(tab) {
8821
9061
  return tab === "guide" && !GUIDE_TAB_ENABLED ? "notes" : tab;
8822
9062
  }
8823
- function todayName() {
8824
- return `qa-notes-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}`;
9063
+ function suggestName(project) {
9064
+ return suggestedExportName(project, (/* @__PURE__ */ new Date()).toISOString());
8825
9065
  }
8826
9066
  function panelReducer(state2, action) {
8827
9067
  switch (action.type) {
@@ -8883,7 +9123,8 @@ function QaPanel() {
8883
9123
  panelSide,
8884
9124
  setPanelSide,
8885
9125
  panelCollapsed,
8886
- setPanelCollapsed
9126
+ setPanelCollapsed,
9127
+ projectName
8887
9128
  } = useQa();
8888
9129
  const activeTab = visibleTab(storedTab);
8889
9130
  const [confirmClear, setConfirmClear] = useState(false);
@@ -9002,7 +9243,7 @@ function QaPanel() {
9002
9243
  const appliedKeyboardLift = keyboardLiftActive ? keyboardLift : 0;
9003
9244
  if (phase === "hidden") return null;
9004
9245
  const openNaming = () => {
9005
- setFilename(todayName());
9246
+ setFilename(suggestName(projectName));
9006
9247
  setNaming(true);
9007
9248
  };
9008
9249
  const doExport = () => {
@@ -11055,7 +11296,7 @@ function CaptureMode() {
11055
11296
  }
11056
11297
  ),
11057
11298
  developerMode && /* @__PURE__ */ jsx(LocationReveal, { target: selection }),
11058
- developerMode && /* @__PURE__ */ jsxs(
11299
+ /* @__PURE__ */ jsxs(
11059
11300
  "div",
11060
11301
  {
11061
11302
  role: "group",
@@ -11155,7 +11396,8 @@ function CaptureMode() {
11155
11396
  ] }),
11156
11397
  /* @__PURE__ */ jsxs("p", { className: "qa-text-center qa-text-10 qa-text-slate-400", children: [
11157
11398
  t("save_hint"),
11158
- developerMode ? ` \xB7 ${t("severity_keys")}` : ""
11399
+ " \xB7 ",
11400
+ t("severity_keys")
11159
11401
  ] })
11160
11402
  ] })
11161
11403
  ]
@@ -11361,5 +11603,5 @@ function Qapture({ config }) {
11361
11603
  }
11362
11604
 
11363
11605
  export { Qapture, deleteQaDatabase, initQaStudio };
11364
- //# sourceMappingURL=chunk-HEHVZCSV.js.map
11365
- //# sourceMappingURL=chunk-HEHVZCSV.js.map
11606
+ //# sourceMappingURL=chunk-F66JBHJG.js.map
11607
+ //# sourceMappingURL=chunk-F66JBHJG.js.map