From c8abee8b1579d1b0cfc38f5c68f73345d1338ba6 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Fri, 31 Jul 2026 15:24:59 -0400 Subject: [PATCH 1/3] Fix builds after Wownero's migration off Codeberg Wownero left Codeberg in July 2026 (Codeberg announced it would ban crypto projects) and remigrated to github.com/wownero/wownero. monero_c's .gitmodules still points at the dead Codeberg URLs, which now fail with "remote: Bye" / HTTP 403. That broke the iOS TestFlight build on 2026-07-31 during "Fetch prebuilt monero_c bundle". The clone never needed to happen. Every workflow (android/ios-sim/ ios-testflight/linux/windows) consumes PREBUILT .so/.a files from release-bundle.zip; nothing outside release/ references the monero/wownero source trees. The only thing needed from the checkout is `git describe --tags` to resolve the release// path. prepare_moneroc.sh was cloning ~1GB of source plus deep transitive submodules purely to discard it. So skip the submodules by default. Source checkout is now opt-in behind MONERO_C_BUILD_FROM_SOURCE=1, and when requested it redirects the dead Codeberg URLs: wownero -> github.com/wownero/wownero (canonical after the migration) randomwow -> github.com/mrcyjanek/randomwow (what monero_c's apply_patches.sh already switches the remote to; NOT wownero/RandomWOW, which is a tevador/RandomX fork on a different lineage without the pinned commit) Both submodules stay pinned to exact SHAs, so git verifies the object hash on fetch and a different host cannot substitute content. Verified clean-room with Codeberg still 403ing: prebuilt mode exits 0 with `git describe --tags` = v0.18.4.6-RC1-2-gbc8d1a0 and zero Codeberg access; source mode lands randomwow at the pinned cd137b1ea. --- scripts/prepare_moneroc.sh | 71 +++++++++++++++++++++++++++++--------- 1 file changed, 54 insertions(+), 17 deletions(-) diff --git a/scripts/prepare_moneroc.sh b/scripts/prepare_moneroc.sh index a78aa2beb..41c52e7ed 100755 --- a/scripts/prepare_moneroc.sh +++ b/scripts/prepare_moneroc.sh @@ -19,22 +19,59 @@ git fetch -a git checkout bc8d1a0b75b97156d71579581b4cdfe58c777ed2 git reset --hard -# Hash Bags: monero_c bundles monero, wownero, AND zano as sibling -# submodules. Upstream did `git submodule update --init --recursive` which -# pulled ALL three (and their deep transitive submodules — zano alone drags -# down a ~100MB Qt UI tree we don't need). Init only the coins we ship, -# shallow clones, in parallel. -git submodule deinit -f zano 2>/dev/null || true -rm -rf zano -git submodule update --init --recursive --depth 1 --jobs 8 monero wownero +# Hash Bags: every CI workflow (android/ios-sim/ios-testflight/linux/windows) +# consumes PREBUILT .so/.a files from monero_c's release-bundle.zip. None of +# them compile monero_c from source. The only thing they need out of this +# checkout is `git describe --tags`, which resolves the release// path. +# +# So by default we do NOT init the monero/wownero submodules. Besides saving +# ~1GB of source plus deep transitive submodules per CI run, this decouples us +# from where Wownero is hosted: +# +# Wownero left Codeberg in July 2026 (Codeberg announced it would ban crypto +# projects) and remigrated to https://github.com/wownero/wownero. monero_c's +# .gitmodules still points at the dead Codeberg URLs, which now fail with +# "remote: Bye" / HTTP 403. That is what broke the iOS TestFlight build on +# 2026-07-31 — at a step that never needed the source in the first place. +# +# Set MONERO_C_BUILD_FROM_SOURCE=1 only if you genuinely intend to compile +# monero_c yourself. See the caveat below before you do. +if [[ "${MONERO_C_BUILD_FROM_SOURCE:-0}" == "1" ]]; +then + # zano is a sibling submodule we never ship — skip it (its Qt UI tree alone + # is ~100MB). + git submodule deinit -f zano 2>/dev/null || true + rm -rf zano -for coin in monero wownero; -do - if [[ ! -f "$coin/.patch-applied" ]]; - then - ./apply_patches.sh $coin - fi -done -cd .. + # Redirect the dead Codeberg URLs to where the code actually lives now. + # Safe despite being a host swap: both submodules are pinned to exact + # commit SHAs, and git verifies the object hash on fetch, so a different + # host cannot substitute different content under the same SHA. + # + # wownero -> github.com/wownero/wownero (canonical since the July 2026 + # remigration off Codeberg) + # randomwow -> github.com/mrcyjanek/randomwow, which is what monero_c + # itself switches the remote to in apply_patches.sh. Note + # this is NOT github.com/wownero/RandomWOW — that one is a + # fork of tevador/RandomX on a different lineage and does + # not contain the pinned commit. The redirect has to happen + # here because apply_patches.sh only runs AFTER the clone + # that would otherwise fail against Codeberg. + git \ + -c url."https://github.com/wownero/wownero".insteadOf="https://codeberg.org/wownero/wownero" \ + -c url."https://github.com/mrcyjanek/randomwow".insteadOf="https://codeberg.org/wownero/RandomWOW" \ + submodule update --init --recursive --depth 1 --jobs 8 monero wownero -echo "monero_c source prepared". + for coin in monero wownero; + do + if [[ ! -f "$coin/.patch-applied" ]]; + then + ./apply_patches.sh $coin + fi + done + cd .. + echo "monero_c source prepared (full source checkout)." +else + cd .. + echo "monero_c prepared (prebuilt mode — submodules intentionally skipped)." +fi From 4be0eb96137400dc38638795723f4bf6a9ae1a00 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Fri, 31 Jul 2026 15:25:07 -0400 Subject: [PATCH 2/3] Bump build number to 1.0.0+9 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestFlight run #118 succeeded on 2026-07-09, and that workflow ends with `xcrun altool --upload-app` — so 1.0.0+8 is already uploaded and App Store Connect will reject a redundant binary at the same build number. Re-running at +8 would have burned a full signed archive before failing at the last step. Both toolchains read this single source (scripts/{ios,android}/app_env.sh parse pubspec_description.yaml), verified reporting build=9. Note there is still no auto-bump: build-ios-testflight.yml is workflow_dispatch only and its own comment flags this as a TODO, so every TestFlight run needs a manual bump until that is wired up. --- pubspec_description.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec_description.yaml b/pubspec_description.yaml index e67436035..8e599f390 100644 --- a/pubspec_description.yaml +++ b/pubspec_description.yaml @@ -1,6 +1,6 @@ name: hash_wallet description: Hash Bags — a noncustodial multi-currency wallet by Such Software LLC. -version: 1.0.0+8 +version: 1.0.0+9 publish_to: none environment: From c396e94b77f80516e6772d6394659d0704280e78 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Fri, 31 Jul 2026 15:25:29 -0400 Subject: [PATCH 3/3] Add store capture harness: record real wallet flows into screenshots + video MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drives the real app through real flows on a device while recording the screen, then cuts ONE recording into both deliverables — store screenshots and marketing video — so a listing can't drift out of sync with its own trailer. integration_test/demo/ DemoSession adds what a passing test lacks: pacing (beat/hold/typeSlowly, so footage is watchable rather than a strobe), timing marks reported through binding.reportData, and seed guards. Scenarios reuse the existing 25 page robots rather than reimplementing navigation: core_store_set dashboard, receive, send (composed, NEVER broadcast), wallets, security — the store screenshot set onboarding_create welcome -> PIN -> seed -> dashboard restore_from_seed restore -> syncing swap_and_buy Trocador quote, stopping BEFORE creating a trade; Buy skipped when no provider key, matching what reviewers see tools/demo/ capture.sh orchestrates boot/record/cut/shots/reel marks.py maps timing marks onto the recording; cuts, extracts frames, builds manifests reel.py branded master; reuses such_graphics video_transitions/video_qa when ~/src/such-graphics is present, degrades cleanly when not (this repo is public and must build without it) build-apk.sh local APK via the CI container image Seed safety — the demo wallet is a real wallet with real funds: - seed comes from Vaultwarden at capture time, never the repo - guardNoSeedOnScreen() fails the run on a visible mnemonic, catching both the known seed and the generic shape of one (so freshly generated seeds are caught too) - unavoidable seed screens are wrapped in blindfold(); those spans are excised from the video with 0.6s padding, and screenshots honour the same padding - marks.py refuses to build a publishable master if any clip was cut without marks, since blindfold spans could not have been excised Apple App Preview gets no touch indicators, captions, cards or music, and reel.py enforces the 15-30s window rather than emitting an out-of-spec file. Everything else keeps taps on, drawn by SurfaceFlinger via show_touches rather than composited, so they look real. Play sizing: the harness builds its own 1080x1920 AVD. The stock Medium Phone AVD is 1080x2400 = 2.22:1 and Play rejects phone screenshots above 2:1. Also gitignores android/app/upload-keystore.jks (and *.jks/*.keystore): the Android workflow decodes ANDROID_KEYSTORE_BASE64 to exactly that path, so the Play upload signing key was one `git add -A` away from being committed. --- .gitignore | 10 + integration_test/demo/demo_config.dart | 60 +++ integration_test/demo/demo_session.dart | 241 +++++++++ .../demo/scenarios/core_store_set.dart | 121 +++++ .../demo/scenarios/onboarding_create.dart | 67 +++ .../demo/scenarios/restore_from_seed.dart | 70 +++ .../demo/scenarios/swap_and_buy.dart | 114 +++++ .../HashBagsScreenshotsUITests.swift | 70 +++ ios/fastlane/Appfile | 3 + ios/fastlane/Fastfile | 11 + ios/fastlane/Snapfile | 23 + store/SCREENSHOTS.md | 94 ++++ store/feature-graphic.svg | 34 ++ store/play-feature-graphic-1024x500.png | Bin 0 -> 103448 bytes store/play-icon-512.png | Bin 0 -> 1617 bytes store/screenshot.sh | 20 + tools/demo/README.md | 129 +++++ tools/demo/build-apk.sh | 222 ++++++++ tools/demo/capture.sh | 365 +++++++++++++ tools/demo/demo.env.example | 58 +++ tools/demo/lib/secrets.sh | 110 ++++ tools/demo/marks.py | 377 ++++++++++++++ tools/demo/packs/hash/pack.json | 37 ++ tools/demo/reel.py | 480 ++++++++++++++++++ 24 files changed, 2716 insertions(+) create mode 100644 integration_test/demo/demo_config.dart create mode 100644 integration_test/demo/demo_session.dart create mode 100644 integration_test/demo/scenarios/core_store_set.dart create mode 100644 integration_test/demo/scenarios/onboarding_create.dart create mode 100644 integration_test/demo/scenarios/restore_from_seed.dart create mode 100644 integration_test/demo/scenarios/swap_and_buy.dart create mode 100644 ios/RunnerUITests/HashBagsScreenshotsUITests.swift create mode 100644 ios/fastlane/Appfile create mode 100644 ios/fastlane/Fastfile create mode 100644 ios/fastlane/Snapfile create mode 100644 store/SCREENSHOTS.md create mode 100644 store/feature-graphic.svg create mode 100644 store/play-feature-graphic-1024x500.png create mode 100644 store/play-icon-512.png create mode 100755 store/screenshot.sh create mode 100644 tools/demo/README.md create mode 100755 tools/demo/build-apk.sh create mode 100755 tools/demo/capture.sh create mode 100644 tools/demo/demo.env.example create mode 100755 tools/demo/lib/secrets.sh create mode 100755 tools/demo/marks.py create mode 100644 tools/demo/packs/hash/pack.json create mode 100755 tools/demo/reel.py diff --git a/.gitignore b/.gitignore index 6c8b67715..10fcf303b 100644 --- a/.gitignore +++ b/.gitignore @@ -88,6 +88,11 @@ cw_monero/cw_monero/android/.cxx/ android/key.properties android/app/key.jks +# The Android CI workflow decodes ANDROID_KEYSTORE_BASE64 to this exact path. +# That is the Play upload signing key — it must never be committable. +android/app/upload-keystore.jks +android/app/*.jks +android/app/*.keystore **/tool/.secrets-prod.json **/tool/.secrets-test.json @@ -234,3 +239,8 @@ scripts/reown_flutter docs/links.md docs/UPSTREAM_SYNC.md scripts/windows/setup-windows-runner.ps1 + +# Capture harness: local config (Vaultwarden host, demo addresses). The +# committed template is tools/demo/demo.env.example. +tools/demo/demo.env +__pycache__/ diff --git a/integration_test/demo/demo_config.dart b/integration_test/demo/demo_config.dart new file mode 100644 index 000000000..dfb54e576 --- /dev/null +++ b/integration_test/demo/demo_config.dart @@ -0,0 +1,60 @@ +import 'package:cw_core/wallet_type.dart'; + +/// Capture-time configuration, injected by `tools/demo/capture.sh` via +/// `--dart-define`. Nothing here is baked into a shipped build. +/// +/// The demo wallet seed is pulled from the self-hosted Vaultwarden at capture +/// time and passed straight through — it is never written to the repo, never +/// committed, and never placed in `.secrets.g.dart`. +class DemoConfig { + /// BIP39 / Wownero seed for the demo wallet. + static const String walletSeed = String.fromEnvironment('DEMO_WALLET_SEED'); + + /// Which wallet the demo restores into. Hash Bags is Wownero-first, so that + /// is the default face of the app in store material. + static const String _walletTypeName = + String.fromEnvironment('DEMO_WALLET_TYPE', defaultValue: 'wownero'); + + /// A receive address used to populate the Send screen. This is a + /// *destination shown on camera*, so it should be an address you are happy + /// to publish — the capture script defaults it to the demo wallet's own + /// address rather than a stranger's. + static const String sendToAddress = + String.fromEnvironment('DEMO_SEND_ADDRESS'); + + /// Amount typed into the Send screen. Never broadcast in the core scenario. + static const String sendAmount = + String.fromEnvironment('DEMO_SEND_AMOUNT', defaultValue: '0.5'); + + /// Amount typed into the Swap screen. + static const String swapAmount = + String.fromEnvironment('DEMO_SWAP_AMOUNT', defaultValue: '10'); + + /// PIN used for the demo wallet. + static List get pin { + const raw = String.fromEnvironment('DEMO_PIN', defaultValue: '0801'); + return raw.split('').map(int.parse).toList(); + } + + static bool get hasSeed => walletSeed.trim().isNotEmpty; + + static WalletType get walletType { + switch (_walletTypeName.toLowerCase()) { + case 'monero': + return WalletType.monero; + case 'bitcoin': + return WalletType.bitcoin; + case 'litecoin': + return WalletType.litecoin; + case 'wownero': + default: + return WalletType.wownero; + } + } + + /// Human label for captions/log lines. + static String get walletLabel { + final n = _walletTypeName.toLowerCase(); + return n.isEmpty ? 'Wownero' : '${n[0].toUpperCase()}${n.substring(1)}'; + } +} diff --git a/integration_test/demo/demo_session.dart b/integration_test/demo/demo_session.dart new file mode 100644 index 000000000..6ec0d6a98 --- /dev/null +++ b/integration_test/demo/demo_session.dart @@ -0,0 +1,241 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +/// Shared plumbing for the marketing / store-capture scenarios. +/// +/// These scenarios are NOT correctness tests. They exist to drive the real app +/// through real flows at a pace a human can watch, while the host records the +/// screen. Three things separate them from the suites in `test_suites/`: +/// +/// 1. **Pacing.** A passing test rips through screens in milliseconds. That +/// footage is unwatchable. [beat] holds on a screen long enough to read it. +/// +/// 2. **Marks.** Each beat is timestamped relative to session start and +/// reported through `binding.reportData`, which the driver writes to +/// `integration_test/integration_response_data.json`. `capture.sh` reads +/// that file to build the reel manifest, so captions land on the exact +/// frame the action happens instead of being hand-timed. +/// +/// 3. **Seed safety.** The demo wallet is a REAL wallet. [guardNoSeedOnScreen] +/// fails the run if a mnemonic is visible while recording, so a seed can +/// never end up in a published video. Scenarios that must pass through a +/// seed screen wrap it in [blindfold]. +class DemoSession { + DemoSession(this.tester, this.binding, this.scenario); + + final WidgetTester tester; + final IntegrationTestWidgetsFlutterBinding binding; + final String scenario; + + final Stopwatch _clock = Stopwatch(); + final List> _marks = >[]; + + /// Device-clock epoch (ms) at the moment the scenario started. + /// + /// The host starts `adb screenrecord` before the app is even installed, so + /// mark timestamps (relative to scenario start) mean nothing to the video + /// on their own. capture.sh reads the device clock at record start, and the + /// difference between that and this value is the offset from the first frame + /// of video to mark 0. Both numbers come from the DEVICE clock, so host/guest + /// clock skew cannot corrupt the alignment. + int startedAtEpochMs = 0; + + /// Words that, seen together in bulk, mean a mnemonic is on screen. + static const int _mnemonicWordThreshold = 12; + + /// Extra secrets the host injects (the demo wallet seed) that must never be + /// rendered while the camera rolls. Populated from --dart-define. + static const String _demoSeed = String.fromEnvironment('DEMO_WALLET_SEED'); + + double get _elapsed => _clock.elapsedMilliseconds / 1000.0; + + void begin() { + startedAtEpochMs = DateTime.now().millisecondsSinceEpoch; + _clock.start(); + _marks.add({ + 'at': 0.0, + 'label': 'start', + 'caption': null, + }); + } + + /// Record a beat. [caption], when given, becomes a burned caption in the + /// social reel at exactly this timestamp. + void mark(String label, {String? caption}) { + _marks.add({ + 'at': double.parse(_elapsed.toStringAsFixed(3)), + 'label': label, + 'caption': caption, + }); + tester.printToConsole('[demo:$scenario] ${_elapsed.toStringAsFixed(2)}s $label'); + } + + /// Hold on the current screen for [seconds] of WALL CLOCK time, pumping + /// frames throughout. + /// + /// Deliberately not `pumpAndSettle`: the dashboard runs a perpetual sync + /// indicator animation, so `pumpAndSettle` would spin until it times out. + /// Pumping on a wall-clock deadline is both safe and what makes the footage + /// watchable. + Future beat([double seconds = 1.6]) async { + final deadline = DateTime.now().add( + Duration(milliseconds: (seconds * 1000).round()), + ); + while (DateTime.now().isBefore(deadline)) { + await tester.pump(const Duration(milliseconds: 33)); // ~30fps + } + } + + /// Beat, then record a mark — the common "let the viewer read this" move. + Future hold(String label, {String? caption, double seconds = 1.8}) async { + mark(label, caption: caption); + await beat(seconds); + } + + /// Tap a finder with camera-friendly lead-in and follow-through, so the + /// viewer sees WHAT is about to be tapped before the screen changes. + Future tap( + Finder finder, { + required String label, + String? caption, + double before = 0.9, + double after = 1.4, + }) async { + await beat(before); + mark(label, caption: caption); + await tester.tap(finder, warnIfMissed: false); + await beat(after); + } + + /// Type text at human speed instead of instantly, which reads as a paste and + /// looks fake on camera. `perChar` is the inter-keystroke delay. + Future typeSlowly( + Finder field, + String text, { + double perChar = 0.06, + }) async { + await tester.tap(field, warnIfMissed: false); + await beat(0.4); + final buffer = StringBuffer(); + for (final rune in text.runes) { + buffer.writeCharCode(rune); + await tester.enterText(field, buffer.toString()); + await tester.pump(Duration(milliseconds: (perChar * 1000).round())); + } + await beat(0.5); + } + + /// Every rendered string currently on screen. + Iterable _visibleText() sync* { + for (final element in find.byType(Text).evaluate()) { + final widget = element.widget as Text; + final data = widget.data; + if (data != null && data.isNotEmpty) yield data; + } + for (final element in find.byType(EditableText).evaluate()) { + final widget = element.widget as EditableText; + final data = widget.controller.text; + if (data.isNotEmpty) yield data; + } + } + + /// Fail loudly if a mnemonic is on screen while recording. + /// + /// Two independent checks, because either alone has a blind spot: the known + /// demo seed may be split across widgets, and an unknown seed (a freshly + /// generated one) will not match any known string. + void guardNoSeedOnScreen({String? because}) { + final visible = _visibleText().toList(); + + if (_demoSeed.isNotEmpty) { + final needle = _demoSeed.trim().toLowerCase(); + final haystack = visible.join(' ').toLowerCase(); + if (haystack.contains(needle)) { + fail('SEED ON CAMERA (demo wallet seed rendered) ' + '${because ?? ''} — scenario "$scenario". Recording aborted.'); + } + // Also catch the seed rendered one-word-per-widget. + final words = needle.split(RegExp(r'\s+')).toSet(); + final onScreenWords = haystack.split(RegExp(r'[^a-z]+')).toSet(); + final overlap = words.intersection(onScreenWords).length; + if (words.length >= _mnemonicWordThreshold && + overlap >= _mnemonicWordThreshold) { + fail('SEED ON CAMERA (demo seed words rendered individually, ' + '$overlap matches) ${because ?? ''} — scenario "$scenario".'); + } + } + + // Generic shape check: any single widget holding many lowercase words is + // almost certainly a mnemonic in this app. + for (final text in visible) { + final words = text + .trim() + .split(RegExp(r'\s+')) + .where((w) => RegExp(r'^[a-zA-Z]{3,}$').hasMatch(w)) + .toList(); + if (words.length >= _mnemonicWordThreshold) { + fail('SEED ON CAMERA (widget holds ${words.length} word-like tokens) ' + '${because ?? ''} — scenario "$scenario". Recording aborted.'); + } + } + } + + /// Run [body] with the recording explicitly marked as unsafe-to-publish. + /// + /// Used for the unavoidable seed screens in the create/restore scenarios. + /// The frames still get recorded — we cannot stop the host recorder from + /// here — so this brackets them with marks. `capture.sh` reads the resulting + /// `blindfold` spans and passes them to reel.py as regions to cut, and + /// refuses to emit a publishable master if any span is unhandled. + Future blindfold(String reason, Future Function() body) async { + mark('blindfold_start', caption: null); + tester.printToConsole( + '[demo:$scenario] BLINDFOLD START ($reason) — frames from here are ' + 'NOT publishable until cut', + ); + try { + await body(); + } finally { + mark('blindfold_end', caption: null); + tester.printToConsole('[demo:$scenario] BLINDFOLD END'); + } + } + + /// Publish the marks back to the host through the driver. + void finish() { + _clock.stop(); + mark('end'); + final existing = binding.reportData ?? {}; + existing['demo'] = { + 'scenario': scenario, + 'started_at_epoch_ms': startedAtEpochMs, + 'duration': double.parse(_elapsed.toStringAsFixed(3)), + 'marks': _marks, + 'blindfold_spans': _blindfoldSpans(), + }; + binding.reportData = existing; + } + + /// Pair up blindfold_start/blindfold_end marks into [start, end] windows. + List> _blindfoldSpans() { + final spans = >[]; + double? open; + for (final m in _marks) { + if (m['label'] == 'blindfold_start') { + open = m['at'] as double; + } else if (m['label'] == 'blindfold_end' && open != null) { + spans.add({'start': open, 'end': m['at'] as double}); + open = null; + } + } + // An unclosed span means the scenario crashed mid-seed. Treat the whole + // remainder as unsafe rather than assuming it ended. + if (open != null) { + spans.add({'start': open, 'end': _elapsed}); + } + return spans; + } +} diff --git a/integration_test/demo/scenarios/core_store_set.dart b/integration_test/demo/scenarios/core_store_set.dart new file mode 100644 index 000000000..c8d560a9e --- /dev/null +++ b/integration_test/demo/scenarios/core_store_set.dart @@ -0,0 +1,121 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../components/common_test_cases.dart'; +import '../../components/common_test_flows.dart'; +import '../../robots/dashboard_page_robot.dart'; +import '../../robots/send_page_robot.dart'; +import '../demo_config.dart'; +import '../demo_session.dart'; + +/// CORE STORE SET — the screens that become App Store / Play screenshots and +/// the backbone of the marketing reel. +/// +/// Dashboard → Receive → Send (composed, never broadcast) → Wallets → Security. +/// +/// If the device already holds the demo wallet, this shoots it directly. If it +/// does not, the scenario restores from seed first — slower, and the restore +/// frames get excised as a blindfold span, but the footage is still correct. +/// +/// NOTHING here broadcasts a transaction. The Send screen is filled in and +/// held on the review step so the UI is fully populated, then backed out of. +void main() { + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('demo: core store set', (tester) async { + FlutterError.onError = (FlutterErrorDetails details) { + debugPrint('FlutterError caught: ${details.exception}'); + }; + + final demo = DemoSession(tester, binding, 'core_store_set'); + final flows = CommonTestFlows(tester); + final cases = CommonTestCases(tester); + final dashboard = DashboardPageRobot(tester); + final send = SendPageRobot(tester: tester); + + demo.begin(); + await flows.startAppFlow(const ValueKey('demo_core_store_set')); + await demo.beat(3.0); + + // If the device was not pre-seeded with the demo wallet, restore it now. + // The seed screens are bracketed so the frames can be cut. + if (DemoConfig.hasSeed && find.text('Restore').evaluate().isNotEmpty) { + await demo.blindfold('restoring demo wallet from seed', () async { + await flows.welcomePageToRestoreWalletThroughSeedsFlow( + DemoConfig.walletType, + DemoConfig.walletSeed, + DemoConfig.pin, + ); + }); + await demo.beat(2.0); + } + + // ---------------- 1. Dashboard ------------------------------------ + await dashboard.isDashboardPage(); + demo.guardNoSeedOnScreen(because: 'on dashboard'); + await demo.hold( + 'dashboard', + caption: 'Your keys. Your coins.', + seconds: 3.5, + ); + + // ---------------- 2. Receive -------------------------------------- + await dashboard.navigateToReceivePage(); + await demo.beat(2.0); + demo.guardNoSeedOnScreen(because: 'on receive page'); + await demo.hold( + 'receive', + caption: 'Receive in a tap', + seconds: 4.0, + ); + await cases.goBack(); + await demo.beat(1.5); + + // ---------------- 3. Send (compose only) -------------------------- + await dashboard.navigateToSendPage(); + await send.checkIfSendPageIsVisible(); + await demo.beat(1.2); + demo.mark('send_open', caption: 'Send with confidence'); + + if (DemoConfig.sendToAddress.isNotEmpty) { + // Typed at human speed — an instant fill reads as a paste on camera. + final addressField = find.byType(EditableText).first; + await demo.typeSlowly(addressField, DemoConfig.sendToAddress); + demo.mark('send_address_entered'); + } + await demo.beat(1.0); + await send.enterSendAmount(DemoConfig.sendAmount); + await demo.hold('send_amount_entered', seconds: 2.5); + + // Deliberately NOT calling onSendButtonPressed(). We show a fully + // composed transaction and walk away — no funds move to make a video. + demo.mark('send_composed_not_broadcast'); + await cases.goBack(); + await demo.beat(1.5); + + // ---------------- 4. Wallet list ---------------------------------- + await dashboard.navigateToWalletsListPage(); + await demo.beat(2.0); + demo.guardNoSeedOnScreen(because: 'on wallet list'); + await demo.hold( + 'wallets', + caption: 'Wownero, Monero, Bitcoin & more', + seconds: 3.5, + ); + await cases.goBack(); + await demo.beat(1.5); + + // ---------------- 5. Security & backup ---------------------------- + await dashboard.openDrawerMenu(); + await demo.beat(2.0); + await demo.hold( + 'menu', + caption: 'Everything under your control', + seconds: 3.0, + ); + demo.guardNoSeedOnScreen(because: 'in drawer menu'); + + demo.finish(); + }); +} diff --git a/integration_test/demo/scenarios/onboarding_create.dart b/integration_test/demo/scenarios/onboarding_create.dart new file mode 100644 index 000000000..a65236633 --- /dev/null +++ b/integration_test/demo/scenarios/onboarding_create.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../components/common_test_flows.dart'; +import '../../robots/dashboard_page_robot.dart'; +import '../demo_config.dart'; +import '../demo_session.dart'; + +/// ONBOARDING — first launch through to a live wallet. +/// +/// The strongest marketing story the app has: no email, no KYC, no account, +/// wallet in under a minute. +/// +/// SEED SAFETY: this flow generates a brand-new wallet, so a real mnemonic +/// WILL be rendered on the seed + verification screens. Those beats run inside +/// `blindfold`, which records the span into the driver output. `capture.sh` +/// refuses to promote a master to the publishable directory while any +/// blindfold span is uncut, so the frames cannot reach a store listing by +/// accident. +/// +/// The wallet created here is throwaway. Never fund it. +void main() { + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('demo: onboarding + create wallet', (tester) async { + FlutterError.onError = (FlutterErrorDetails details) { + debugPrint('FlutterError caught: ${details.exception}'); + }; + + final demo = DemoSession(tester, binding, 'onboarding_create'); + final flows = CommonTestFlows(tester); + final dashboard = DashboardPageRobot(tester); + + demo.begin(); + + await flows.startAppFlow(const ValueKey('demo_onboarding_create')); + await demo.beat(3.0); + await demo.hold( + 'welcome', + caption: 'No email. No KYC. No account.', + seconds: 3.5, + ); + + // The create flow walks PIN setup, seed display and seed verification in + // one call. Everything from here until the dashboard is unsafe to publish. + await demo.blindfold('new wallet seed is displayed and verified', () async { + await flows.welcomePageToCreateNewWalletFlow( + DemoConfig.walletType, + DemoConfig.pin, + ); + }); + + await demo.beat(2.5); + + // Back on safe ground — verify it and shoot the payoff. + await dashboard.isDashboardPage(); + demo.guardNoSeedOnScreen(because: 'post-create dashboard'); + await demo.hold( + 'wallet_ready', + caption: 'A ${DemoConfig.walletLabel} wallet in under a minute', + seconds: 4.0, + ); + + demo.finish(); + }); +} diff --git a/integration_test/demo/scenarios/restore_from_seed.dart b/integration_test/demo/scenarios/restore_from_seed.dart new file mode 100644 index 000000000..70cb946bb --- /dev/null +++ b/integration_test/demo/scenarios/restore_from_seed.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../components/common_test_flows.dart'; +import '../../robots/dashboard_page_robot.dart'; +import '../demo_config.dart'; +import '../demo_session.dart'; + +/// RESTORE — bring an existing wallet back from its seed. +/// +/// The "you are not locked in" story: the seed is yours, the wallet is +/// portable, Hash Bags is just a viewer for keys you already hold. +/// +/// SEED SAFETY: the entry screen necessarily shows the demo wallet's real +/// mnemonic, so the whole restore is wrapped in `blindfold`. The publishable +/// cut starts at the dashboard. Do not lift that bracket to "get a better +/// shot" — the demo wallet holds real funds. +void main() { + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('demo: restore from seed', (tester) async { + FlutterError.onError = (FlutterErrorDetails details) { + debugPrint('FlutterError caught: ${details.exception}'); + }; + + final demo = DemoSession(tester, binding, 'restore_from_seed'); + final flows = CommonTestFlows(tester); + final dashboard = DashboardPageRobot(tester); + + if (!DemoConfig.hasSeed) { + fail('restore_from_seed needs DEMO_WALLET_SEED. Run it through ' + 'tools/demo/capture.sh, which pulls the seed from Vaultwarden.'); + } + + demo.begin(); + + await flows.startAppFlow(const ValueKey('demo_restore_from_seed')); + await demo.beat(3.0); + await demo.hold( + 'welcome', + caption: 'Already have a wallet?', + seconds: 3.0, + ); + + await demo.blindfold('demo wallet mnemonic is typed on screen', () async { + await flows.welcomePageToRestoreWalletThroughSeedsFlow( + DemoConfig.walletType, + DemoConfig.walletSeed, + DemoConfig.pin, + ); + }); + + await demo.beat(3.0); + + await dashboard.isDashboardPage(); + demo.guardNoSeedOnScreen(because: 'post-restore dashboard'); + await demo.hold( + 'restored', + caption: 'Your seed. Your wallet. Anywhere.', + seconds: 4.0, + ); + + // Let the sync indicator run a little — real syncing is part of the story + // and it looks alive on camera. + await demo.hold('syncing', caption: 'Syncing from the network', seconds: 4.0); + + demo.finish(); + }); +} diff --git a/integration_test/demo/scenarios/swap_and_buy.dart b/integration_test/demo/scenarios/swap_and_buy.dart new file mode 100644 index 000000000..8e44cd8d3 --- /dev/null +++ b/integration_test/demo/scenarios/swap_and_buy.dart @@ -0,0 +1,114 @@ +import 'package:cw_core/crypto_currency.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../components/common_test_cases.dart'; +import '../../components/common_test_flows.dart'; +import '../../robots/dashboard_page_robot.dart'; +import '../../robots/exchange_page_robot.dart'; +import '../demo_config.dart'; +import '../demo_session.dart'; + +/// SWAP + BUY — the exchange surface. +/// +/// Shows a Trocador swap being configured and quoted. It deliberately stops +/// BEFORE `onExchangeButtonPressed()`: creating a trade moves real funds and +/// burns a real Trocador order, neither of which should happen to make a +/// marketing clip. +/// +/// BUY is gated behind provider keys (see the MoonPay key-gating commit). A +/// capture build has empty secrets, so the Buy action is hidden and this +/// scenario logs that it skipped rather than failing — which is also the +/// correct store-review posture. +void main() { + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('demo: swap and buy', (tester) async { + FlutterError.onError = (FlutterErrorDetails details) { + debugPrint('FlutterError caught: ${details.exception}'); + }; + + final demo = DemoSession(tester, binding, 'swap_and_buy'); + final flows = CommonTestFlows(tester); + final cases = CommonTestCases(tester); + final dashboard = DashboardPageRobot(tester); + final exchange = ExchangePageRobot(tester); + + demo.begin(); + await flows.startAppFlow(const ValueKey('demo_swap_and_buy')); + await demo.beat(3.0); + + if (DemoConfig.hasSeed && find.text('Restore').evaluate().isNotEmpty) { + await demo.blindfold('restoring demo wallet from seed', () async { + await flows.welcomePageToRestoreWalletThroughSeedsFlow( + DemoConfig.walletType, + DemoConfig.walletSeed, + DemoConfig.pin, + ); + }); + await demo.beat(2.0); + } + + await dashboard.isDashboardPage(); + await demo.beat(1.5); + + // ---------------- Swap -------------------------------------------- + await dashboard.navigateToExchangePage(); + await demo.beat(2.5); + await exchange.isExchangePage(); + demo.guardNoSeedOnScreen(because: 'on exchange page'); + await demo.hold( + 'swap_open', + caption: 'Swap without an account', + seconds: 3.0, + ); + + await exchange.displayBothExchangeCards(); + await demo.beat(1.5); + + // Pick a pair. Wrapped because currency pickers are the most brittle part + // of this screen — a picker change upstream should degrade the clip, not + // kill the whole capture run. + try { + await exchange.selectDepositCurrency(CryptoCurrency.wow); + await demo.beat(1.2); + await exchange.selectReceiveCurrency(CryptoCurrency.btc); + await demo.hold('pair_selected', caption: 'WOW → BTC', seconds: 2.5); + + await exchange.enterDepositAmount(DemoConfig.swapAmount); + await demo.hold( + 'quote', + caption: 'Live rate, no sign-up', + seconds: 4.0, + ); + } catch (e) { + tester.printToConsole('[demo] swap pair selection degraded: $e'); + demo.mark('swap_pair_skipped'); + } + + // Stop here on purpose — no trade is created. + demo.mark('swap_quoted_not_executed'); + await demo.beat(1.5); + + // ---------------- Buy (only if a provider is actually enabled) ------ + await cases.goBack(); + await demo.beat(1.5); + + final buyVisible = find.text('Buy').evaluate().isNotEmpty; + if (buyVisible) { + await dashboard.navigateToBuyPage(); + await demo.beat(2.5); + demo.guardNoSeedOnScreen(because: 'on buy page'); + await demo.hold('buy', caption: 'Buy with card, where available', seconds: 3.5); + } else { + tester.printToConsole( + '[demo] Buy is hidden — no provider key in this build. Skipping, ' + 'which matches what store reviewers will see.', + ); + demo.mark('buy_hidden_no_provider'); + } + + demo.finish(); + }); +} diff --git a/ios/RunnerUITests/HashBagsScreenshotsUITests.swift b/ios/RunnerUITests/HashBagsScreenshotsUITests.swift new file mode 100644 index 000000000..4c50df30c --- /dev/null +++ b/ios/RunnerUITests/HashBagsScreenshotsUITests.swift @@ -0,0 +1,70 @@ +import XCTest + +// fastlane snapshot UI test for Hash Bags. +// +// IMPORTANT — Flutter caveat: Flutter renders the whole UI into ONE native view. +// XCUITest finds "elements" only through the ACCESSIBILITY tree. So for taps to +// work you must either (a) match by the visible label text, or (b) add +// Semantics(identifier: 'send_button') around the widgets you drive. Wallet flows +// (create/restore, PIN) are painful to automate, so the recommended pattern is to +// launch straight into a screenshot-ready DEMO wallet via a launch argument that +// the Flutter app recognizes (see store/SCREENSHOTS.md), then shoot the key +// screens. Adjust identifiers/waits to match the actual UI. +final class HashBagsScreenshotsUITests: XCTestCase { + + var app: XCUIApplication! + + override func setUpWithError() throws { + continueAfterFailure = false + app = XCUIApplication() + setupSnapshot(app) // from SnapshotHelper.swift (fastlane snapshot init) + // Tell the Flutter app to restore a demo wallet + skip onboarding so the + // shots show a real dashboard. Handle this arg in Dart (see the guide). + app.launchArguments += ["--dart-define=SCREENSHOT_MODE=true", "FASTLANE_SNAPSHOT"] + app.launch() + } + + func testScreenshots() throws { + // 1) Home / dashboard — wait for it to settle, then shoot. + _ = app.otherElements["home_dashboard"].waitForExistence(timeout: 45) + snapshot("01-Home") + + // 2) Receive + tapFirst(["Receive", "receive_action"]) + snapshot("02-Receive") + goBack() + + // 3) Send + tapFirst(["Send", "send_action"]) + snapshot("03-Send") + goBack() + + // 4) Swap (Trocador) + tapFirst(["Swap", "exchange_action"]) + snapshot("04-Swap") + goBack() + + // 5) Settings + tapFirst(["Settings", "settings_action"]) + snapshot("05-Settings") + } + + // MARK: - helpers + + /// Tap the first element (button or generic) matching any of the given + /// accessibility labels/identifiers. + private func tapFirst(_ names: [String], timeout: TimeInterval = 15) { + for n in names { + let btn = app.buttons[n] + if btn.waitForExistence(timeout: 2) { btn.tap(); return } + let any = app.descendants(matching: .any)[n] + if any.exists { any.tap(); return } + } + XCTFail("Could not find any of: \(names). Add a Semantics(identifier:) to that widget.") + } + + private func goBack() { + let back = app.navigationBars.buttons.element(boundBy: 0) + if back.waitForExistence(timeout: 3) { back.tap() } + } +} diff --git a/ios/fastlane/Appfile b/ios/fastlane/Appfile new file mode 100644 index 000000000..7e4bdffc0 --- /dev/null +++ b/ios/fastlane/Appfile @@ -0,0 +1,3 @@ +app_identifier("com.suchsoftware.hashwallet") +team_id("32J6BB6VUS") # Apple Developer Team (Such Software LLC) +# apple_id("you@such.software") # fill in only if you later use fastlane to upload diff --git a/ios/fastlane/Fastfile b/ios/fastlane/Fastfile new file mode 100644 index 000000000..c85f2238c --- /dev/null +++ b/ios/fastlane/Fastfile @@ -0,0 +1,11 @@ +default_platform(:ios) + +platform :ios do + desc "Generate App Store screenshots (iPhone 6.9\" + iPad 13\") via snapshot" + lane :screenshots do + capture_ios_screenshots # reads ios/fastlane/Snapfile + # Optional: add device frames + captions (needs `brew install imagemagick`): + # frame_screenshots(white: true) + UI.success("Screenshots in ios/screenshots/en-US/ — upload to App Store Connect") + end +end diff --git a/ios/fastlane/Snapfile b/ios/fastlane/Snapfile new file mode 100644 index 000000000..536ff39b9 --- /dev/null +++ b/ios/fastlane/Snapfile @@ -0,0 +1,23 @@ +# fastlane snapshot config for Hash Bags App Store screenshots. +# Run on macOS (Mac-mini runner or your Mac): cd ios && fastlane screenshots +# +# These two devices cover Apple's required sizes: +# iPhone 16 Pro Max -> 6.9" (1290x2796 / 1320x2868) [REQUIRED] +# iPad Pro 13" (M4) -> 13" (2064x2752) [required if you ship iPad] +devices([ + "iPhone 16 Pro Max", + "iPad Pro 13-inch (M4)" +]) + +languages(["en-US"]) + +# The Runner scheme's Test action must include the RunnerUITests target +# (see store/SCREENSHOTS.md). only_testing keeps us to just the screenshot test. +scheme("Runner") +only_testing(["RunnerUITests/HashBagsScreenshotsUITests"]) + +output_directory("./screenshots") +clear_previous_screenshots(true) +concurrent_simulators(false) # wallet sync is flaky under parallel sims +override_status_bar(true) # clean status bar: 9:41, full battery/wifi +stop_after_first_error(true) diff --git a/store/SCREENSHOTS.md b/store/SCREENSHOTS.md new file mode 100644 index 000000000..74786a856 --- /dev/null +++ b/store/SCREENSHOTS.md @@ -0,0 +1,94 @@ +# Hash Bags — store screenshots + +Three ways to produce the App Store / Play screenshots. + +**Play (and all marketing video) runs on Linux** via the automated capture +harness — Path C, below. Only the **iOS** sets need macOS, because an iPhone/iPad +Simulator can't run on the Linux box. + +## Required sizes (the Simulator outputs these exactly — don't hand-size) +| Store | Device to capture | Size | Count | +|---|---|---|---| +| App Store | **iPhone 16 Pro Max** (6.9") | 1290×2796 / 1320×2868 | 3–10 (required) | +| App Store | **iPad Pro 13" (M4)** | 2064×2752 | required *if* you ship iPad | +| Google Play | Pixel-class emulator | 1080×1920 (9:16) | 2–8 (required) | + +--- + +## Path A — Manual (recommended for the first submission; ~15 min) +```bash +# iPhone +xcrun simctl boot "iPhone 16 Pro Max"; open -a Simulator +flutter run -d "iPhone 16 Pro Max" # from repo root; navigate the app, then: +store/screenshot.sh 01-home +store/screenshot.sh 02-receive +store/screenshot.sh 03-send +store/screenshot.sh 04-swap +store/screenshot.sh 05-settings +# iPad — repeat booted on the iPad sim +xcrun simctl boot "iPad Pro 13-inch (M4)" +flutter run -d "iPad Pro 13-inch (M4)" +OUT_DIR=store/screenshots-ipad store/screenshot.sh 01-home # ...etc + +# Android (Play) — boot a Pixel emulator, run the app, then: +adb exec-out screencap -p > store/screenshots-play/01-home.png +``` +`store/screenshot.sh` captures the booted simulator at the exact store size. + +--- + +## Path B — Automated (fastlane snapshot; repeatable, multi-device/language) +Scaffolded here: `ios/fastlane/{Appfile,Snapfile,Fastfile}` + +`ios/RunnerUITests/HashBagsScreenshotsUITests.swift`. + +**One-time setup (on the Mac):** +1. `cd ios && fastlane snapshot init` — drops `SnapshotHelper.swift` (matched to your fastlane version) into `ios/`. Move it into `ios/RunnerUITests/`. +2. In **Xcode** (`ios/Runner.xcworkspace`): File ▸ New ▸ Target ▸ **UI Testing Bundle**, name it **`RunnerUITests`**, target = `Runner`. Add `SnapshotHelper.swift` + `HashBagsScreenshotsUITests.swift` to it. Then edit the **Runner scheme ▸ Test** action and tick `RunnerUITests`. +3. **Screenshot-ready state:** wallet flows (create/PIN/seed/sync) are painful to drive, so make the app launch straight into a demo wallet when it sees the flag. In Dart, gate on `const bool.fromEnvironment('SCREENSHOT_MODE')` (or check `Platform.environment`/launch args): when true, restore a known demo wallet, skip onboarding, and stub balances. The UITest already passes `--dart-define=SCREENSHOT_MODE=true`. +4. **Accessibility:** the test taps by label/identifier. Add `Semantics(identifier: 'send_action')` (etc.) to the widgets you shoot, or rely on visible text ("Send", "Receive", "Swap", "Settings"). Tune the identifiers in the test. + +**Run:** +```bash +cd ios && fastlane screenshots # → ios/screenshots/en-US/-01-Home.png ... +``` + +--- + +--- + +## Path C — Automated capture harness (Play + all video; runs on Linux) +`tools/demo/` drives the real app on a headless emulator while recording the +screen, then cuts one recording into **both** the screenshots and the video. +See `tools/demo/README.md`. + +```bash +cp tools/demo/demo.env.example tools/demo/demo.env # fill in; gitignored +export BW_SESSION=$(bw unlock --raw) # demo seed from Vaultwarden + +tools/demo/build-apk.sh # debug APK via the CI container (Flutter 3.32.0) +tools/demo/capture.sh boot # creates/boots a 1080x1920 AVD +tools/demo/capture.sh all # record all scenarios, cut, shoot, assemble +``` + +Why it exists, beyond convenience: +- **Sizing.** It builds its own AVD at exactly 1080×1920 (16:9). The stock + "Medium Phone" AVD is 1080×2400 = **2.22:1**, which Play rejects (max 2:1). +- **Consistency.** Screenshots are frames of the same recording the trailer is + cut from, taken at the timestamps the scenario reports. +- **Seed safety.** Frames containing a mnemonic are bracketed by the scenario + and excised automatically; the tooling refuses to emit a publishable master + if any clip wasn't verified. The demo wallet holds real funds. +- **Apple rules.** The `apple-preview` profile force-disables touch indicators, + captions and music, and enforces Apple's 15–30s window. + +Scenarios cover the core store set, onboarding/create, restore-from-seed, and +swap/buy. + +--- + +## Upload +- **App Store Connect** ▸ your app ▸ version ▸ Previews and Screenshots → drag the 6.9" iPhone (and 13" iPad) sets. +- **Play Console** ▸ Store listing ▸ Phone screenshots → the 1080×1920 set; plus the **icon** (`store/play-icon-512.png`) and **feature graphic** (`store/play-feature-graphic-1024x500.png`). + +> Reality check: for a *first* launch, Path A (manual) is faster. Path B pays off +> when you re-shoot across versions/locales. Either way it's a Mac task. diff --git a/store/feature-graphic.svg b/store/feature-graphic.svg new file mode 100644 index 000000000..cf079a125 --- /dev/null +++ b/store/feature-graphic.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + # + + + + + Hash Bags + Open-source, non-custodial wallet + Wownero · Monero · Bitcoin · Litecoin · and more + + diff --git a/store/play-feature-graphic-1024x500.png b/store/play-feature-graphic-1024x500.png new file mode 100644 index 0000000000000000000000000000000000000000..120bdfa79ff183e3791a5d3a045300b4fec14b2d GIT binary patch literal 103448 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�D1%oK#={*k#23!|Ln6EQTJbxaBJU6!U3$ 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 BE%E>W literal 0 HcmV?d00001 diff --git a/store/screenshot.sh b/store/screenshot.sh new file mode 100755 index 000000000..a18147123 --- /dev/null +++ b/store/screenshot.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Manual screenshot helper (the fast path for a first submission). +# Boot the target simulator, run the app, navigate to a screen, then run this to +# capture it at App-Store-exact dimensions. +# +# Usage: ./screenshot.sh # captures the booted simulator +# OUT_DIR=~/hb-shots ./screenshot.sh 01-home +# +# Recommended flow (on the Mac): +# xcrun simctl boot "iPhone 16 Pro Max"; open -a Simulator +# (cd .. && flutter run -d "iPhone 16 Pro Max") # navigate to each screen, then: +# ./screenshot.sh 01-home ; ./screenshot.sh 02-receive ; ... +# # repeat booted on "iPad Pro 13-inch (M4)" for the iPad set. +set -euo pipefail +name="${1:?usage: screenshot.sh }" +out="${OUT_DIR:-./screenshots-manual}" +mkdir -p "$out" +xcrun simctl io booted screenshot "$out/$name.png" +dims=$(python3 -c "import struct;d=open('$out/$name.png','rb').read(26);print('x'.join(map(str,struct.unpack('>II',d[16:24]))))" 2>/dev/null || echo "?") +echo "saved $out/$name.png ($dims)" diff --git a/tools/demo/README.md b/tools/demo/README.md new file mode 100644 index 000000000..caf78893f --- /dev/null +++ b/tools/demo/README.md @@ -0,0 +1,129 @@ +# Hash Bags capture harness + +Drives the real app through real wallet flows on an emulator while recording +the screen, then cuts the recording into store screenshots and marketing video. + +One recording produces **both** deliverables. Screenshots are pulled from the +same footage as the video, at the timestamps the scenario reports, so a listing +can't drift out of sync with its own trailer. + +``` +integration_test/demo/scenarios/*.dart drive the app, emit timing marks + │ + ▼ +tools/demo/capture.sh record adb screenrecord + flutter drive + │ + ├── marks.py cut → trims lead-in, EXCISES seed frames + ├── marks.py shots → store screenshots at native 1080x1920 + └── reel.py → branded master (social / apple-preview / play) +``` + +## Quick start + +```bash +cp tools/demo/demo.env.example tools/demo/demo.env # fill in, gitignored +export BW_SESSION=$(bw unlock --raw) # Vaultwarden + +tools/demo/build-apk.sh # optional but recommended — see below +tools/demo/capture.sh boot # 1080x1920 headless AVD +tools/demo/capture.sh all # record every scenario + assemble a reel +``` + +`build-apk.sh` is **not** a strict prerequisite — `capture.sh record` runs +`flutter drive`, which builds its own instrumented debug APK. Run it first +anyway on a fresh checkout: it validates the entire toolchain in one shot, +mints the local signing key, and warms the Gradle cache, so your first `record` +doesn't spend twenty minutes compiling only to hit a config error. It also +leaves you a sideloadable APK. + +Individual steps: + +```bash +tools/demo/capture.sh record core_store_set +tools/demo/capture.sh shots core_store_set +tools/demo/capture.sh reel social +tools/demo/capture.sh reel apple-preview --overlays none +``` + +Output lands in `~/Build/hash-wallet/demo/` (never in the repo), split into +`raw/`, `cut/`, `screenshots/` and `publish/`. + +## Scenarios + +| Scenario | Shows | Notes | +|---|---|---| +| `core_store_set` | Dashboard, Receive, Send, Wallets, Security | The store screenshot set. Send is **composed, never broadcast**. | +| `onboarding_create` | Welcome → PIN → seed → dashboard | Creates a throwaway wallet. Never fund it. | +| `restore_from_seed` | Restore from mnemonic → syncing | Uses the demo wallet seed. | +| `swap_and_buy` | Trocador swap quote, Buy | Stops **before** creating a trade. Buy is skipped when no provider key is present. | + +Scenarios reuse the existing page robots in `integration_test/robots/`. They +are not correctness tests — they add pacing (`beat`, `hold`, `typeSlowly`) so +the footage is watchable, and timing marks so captions land on the right frame. + +## Seed safety + +The demo wallet is a **real wallet with a real balance** — that is what makes +the screenshots honest, and it is also why the harness is paranoid: + +- The seed lives in Vaultwarden. It is fetched at capture time into one shell + variable and passed as a `--dart-define`. It is never written to the repo, + to `.secrets.g.dart`, or to `demo.env`. +- `DemoSession.guardNoSeedOnScreen()` fails the run if a mnemonic is rendered + while recording. It checks for the known demo seed (whole and word-scattered) + *and* for the generic shape of a mnemonic, so a freshly generated seed is + caught too. +- Flows that must pass through a seed screen wrap it in `blindfold()`. Those + spans are reported to the host and **excised from the video**, padded by + 0.6s on each side. Screenshots use the same padding. +- `marks.py manifest` **refuses** to build a publishable master if any clip was + cut without marks, since blindfold spans could not have been excised. + +Do not lift those guards to get a nicer shot. + +## Overlays and the Apple App Preview + +Touch indicators are the system-level ripple (`settings put system +show_touches 1`), not a composited fake cursor — so they appear in the +recording exactly as a user would see them. + +They are **on by default**, and **force-disabled for `apple-preview`**. +`capture.sh reel apple-preview` refuses to run against footage recorded with +`--overlays taps`. The App Preview shows the app as-is: no touch indicators, +no captions, no title/CTA cards, no music bed. `reel.py` also enforces +Apple's 15–30s duration window and will fail rather than emit an out-of-spec +preview. + +## Store sizing + +The harness creates its own AVD at exactly **1080×1920 (16:9)**. Do not point +it at the stock "Medium Phone" AVD: that is 1080×2400 = **2.22:1**, and Google +Play rejects phone screenshots above **2:1**. `marks.py shots` warns if the +frames it extracts violate that ratio. + +iOS App Store screenshots and the App Preview still have to come from a Mac — +see `store/SCREENSHOTS.md` for the fastlane snapshot path. + +## Why a container for the build + +The repo pins Flutter 3.32.0. A host Flutter 3.38+ **cannot resolve the +dependency set at all** — `hive_generator` wants an analyzer that still ships +the `macros` package, which newer Dart SDKs removed. `build-apk.sh` therefore +builds inside the same image CI uses. + +It also mints a **throwaway** signing key: `android/app/build.gradle` wires the +debug build type to `signingConfigs.release`, so a local build cannot even +configure without a keystore. A capture APK must never be signed with the Play +upload key, and minting a separate one keeps that impossible by construction. + +## Reusing this elsewhere + +`reel.py` imports `such_graphics.video_transitions` (tuned xfade presets) and +`such_graphics.video_qa` (decode-level "did this render black" gate) from +`~/src/such-graphics` when that repo is checked out, and falls back to a local +subset when it is not — this repo is public and must build without it. Point +`SUCH_GRAPHICS_HOME` elsewhere if needed. + +The music-bed handling (random offset per render, so repeat uploads of the same +variant don't feel identical) is lifted from +`~/src/medusa-multi-tenant-platform/tools/marketing-video/build.mjs`. diff --git a/tools/demo/build-apk.sh b/tools/demo/build-apk.sh new file mode 100755 index 000000000..85712c5b8 --- /dev/null +++ b/tools/demo/build-apk.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash +# Build a Hash Bags APK locally using the SAME container image CI uses. +# +# Why a container: this repo pins Flutter 3.32.0. A newer host Flutter (3.38+) +# cannot resolve the dependency set at all -- hive_generator wants an analyzer +# that still ships the `macros` package, which newer Dart SDKs removed. Rather +# than fight that, we build inside +# ghcr.io/cake-tech/cake_wallet:debian13-flutter3.32.0-ndkr28-go1.24.1-ruststablenightly +# which is exactly what .github/workflows/build-android.yml uses. +# +# Usage: +# tools/demo/build-apk.sh # debug, x86_64 (emulator capture) +# BUILD_MODE=release ABI=arm64 tools/demo/build-apk.sh +# SKIP_DEPS=1 tools/demo/build-apk.sh # re-build only, deps already fetched +# +# The container runs as root (FLUTTER_ROOT lives under /root in the image), so +# the script chowns everything back to the invoking user on exit -- otherwise +# you get root-owned build artifacts scattered through your worktree. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +IMAGE="${CI_IMAGE:-ghcr.io/cake-tech/cake_wallet:debian13-flutter3.32.0-ndkr28-go1.24.1-ruststablenightly}" +BUILD_MODE="${BUILD_MODE:-debug}" +ABI="${ABI:-x86_64}" +SKIP_DEPS="${SKIP_DEPS:-0}" +HOST_UID="$(id -u)" +HOST_GID="$(id -g)" + +case "$ABI" in + x86_64) TARGET_PLATFORM="android-x64" ;; + arm64) TARGET_PLATFORM="android-arm64" ;; + all) TARGET_PLATFORM="" ;; + *) echo "ABI must be x86_64 | arm64 | all" >&2; exit 1 ;; +esac + +echo "==> image $IMAGE" +echo "==> mode $BUILD_MODE" +echo "==> abi $ABI (${TARGET_PLATFORM:-universal})" +echo "==> repo $REPO_ROOT" + +docker run --rm \ + --network host \ + -v "$REPO_ROOT":/work \ + -w /work \ + -e SKIP_DEPS="$SKIP_DEPS" \ + -e BUILD_MODE="$BUILD_MODE" \ + -e TARGET_PLATFORM="$TARGET_PLATFORM" \ + -e HOST_UID="$HOST_UID" \ + -e HOST_GID="$HOST_GID" \ + "$IMAGE" bash -c ' +set -euo pipefail +# Chown back on ANY exit path so a failed build does not leave a root-owned +# worktree behind. +trap "chown -R ${HOST_UID}:${HOST_GID} /work 2>/dev/null || true" EXIT + +git config --global --add safe.directory "*" + +step() { echo; echo "########## $* ##########"; } + +if [[ "$SKIP_DEPS" != "1" ]]; then + step "torch_dart prebuilt" + pushd scripts >/dev/null + if [[ ! -d torch_dart ]]; then + wget -q https://github.com/MrCyjaneK/torch_dart/releases/download/v1.0.17/torch_dart-v1.0.17.tar.gz -O torch_dart.tar.gz + mkdir -p torch_dart && tar -xzf torch_dart.tar.gz -C torch_dart && rm torch_dart.tar.gz + else echo "already present"; fi + popd >/dev/null + + step "reown_flutter prebuilt" + pushd scripts >/dev/null + if [[ ! -d reown_flutter ]]; then + wget -q https://github.com/cake-tech/reown_flutter/releases/download/v0.0.4/reown_flutter-v0.0.4.tar.gz -O reown_flutter.tar.gz + mkdir -p reown_flutter && tar -xzf reown_flutter.tar.gz -C reown_flutter && rm reown_flutter.tar.gz + else echo "already present"; fi + popd >/dev/null + + step "bitbox flutter" + # Guard on the ARTIFACT, not the directory. A half-finished checkout with no + # api.aar looks "done" by directory alone, and gradle then fails deep into + # assembleDebug with an opaque "Could not find :api:". + pushd scripts >/dev/null + if [[ -e bitbox_flutter/go/api/api.aar ]]; then + echo "api.aar already built" + else + ./build_bitbox_flutter.sh + fi + popd >/dev/null + + step "monero_c prebuilt .so bundle" + ./scripts/prepare_moneroc.sh + MONERO_C_TAG=$(cd scripts/monero_c && git describe --tags) + echo "monero_c TAG: $MONERO_C_TAG" + BUNDLE_DIR="scripts/monero_c/release/$MONERO_C_TAG" + mkdir -p "$BUNDLE_DIR" + if [[ ! -d "$BUNDLE_DIR/x86_64-linux-android" ]]; then + pushd "$BUNDLE_DIR" >/dev/null + wget -q https://github.com/MrCyjaneK/monero_c/releases/download/v0.18.4.6-RC1/release-bundle.zip + unzip -q release-bundle.zip && rm release-bundle.zip + popd >/dev/null + else echo "bundle already extracted"; fi + + step "stage jniLibs" + declare -A ABI_MAP=( + [aarch64-linux-android]=arm64-v8a + [armv7a-linux-androideabi]=armeabi-v7a + [x86_64-linux-android]=x86_64 + ) + for target in "${!ABI_MAP[@]}"; do + abi="${ABI_MAP[$target]}" + [[ -d "$BUNDLE_DIR/$target" ]] || { echo "SKIP $target"; continue; } + mkdir -p "android/app/src/main/jniLibs/$abi" + for coin in monero wownero; do + src="$BUNDLE_DIR/$target/lib${coin}_wallet2_api_c.so" + [[ -f "$src" ]] && cp -f "$src" "android/app/src/main/jniLibs/$abi/" + done + done + find android/app/src/main/jniLibs -name "*.so" | sort + + step "reown yttrium uniffi bindings" + # The prebuilt reown_flutter tarball that build-android.yml fetches does NOT + # contain the generated Kotlin uniffi bindings. Without them, gradle dies + # deep inside assembleDebug with a wall of + # e: ...Extensions.kt: Unresolved reference "uniffi" + # which says nothing about the real cause. + # + # Upstream generates them in a SEPARATE image layer + # (scripts/android/docker/Dockerfile.reown runs build_reown_deps.sh), which + # the GitHub workflow never runs -- it assumes the runner already has them. + # That is why the Gitea mac-mini runner builds green while a clean + # replication of build-android.yml does not. + if find scripts/reown_flutter -type d -name uniffi 2>/dev/null | grep -q .; then + echo "uniffi bindings present" + elif [[ -x scripts/android/build_reown_deps.sh ]]; then + echo "uniffi bindings missing -- generating via build_reown_deps.sh" + echo "(this needs the Rust/uniffi toolchain and is slow on a cold run)" + ./scripts/android/build_reown_deps.sh || { + echo + echo "FATAL: could not generate the reown uniffi bindings." + echo "Options:" + echo " 1. Build the upstream deps image: scripts/android/docker/build.sh" + echo " 2. Use an APK from the Gitea runner, which already has them:" + echo " https://git.such.software/Builds/hash-wallet/actions" + exit 1 + } + else + echo "FATAL: no uniffi bindings and no build_reown_deps.sh to generate them." >&2 + exit 1 + fi + + step "android configure (generates pubspec.yaml, manifest, app_properties)" + pushd scripts/android >/dev/null + source ./app_env.sh cakewallet + ./app_config.sh + popd >/dev/null +fi + +step "local throwaway signing key" +# android/app/build.gradle wires the DEBUG build type to signingConfigs.release +# (see `debug { signingConfig signingConfigs.release }`). With no key.properties +# present, `file(null)` blows up gradle configuration before a single source +# file compiles -- so even a debug build needs a keystore. +# +# CI supplies the real Play upload key from secrets. Locally we mint a +# throwaway instead: a capture/demo APK must never be signed with the upload +# key, and this keeps that impossible by construction. +if [[ ! -f android/key.properties ]]; then + keytool -genkeypair -v \ + -keystore android/app/debug-capture.jks \ + -storepass hashbags -keypass hashbags \ + -alias hashbags-debug \ + -keyalg RSA -keysize 2048 -validity 3650 \ + -dname "CN=Hash Bags Capture, OU=Demo, O=Such Software LLC, C=US" 2>/dev/null + cat > android/key.properties < artifacts:" +ls -la build/app/outputs/flutter-apk/*.apk +' + +# scripts/android/app_icon.sh points assets/images/app_logo.png at an ABSOLUTE +# path built from `pwd`, which inside the container is /work. That leaves a +# tracked file replaced by a symlink that dangles on the host and shows up as a +# typechange in `git status`. Put it back. +if [[ -L "$REPO_ROOT/assets/images/app_logo.png" ]]; then + git -C "$REPO_ROOT" checkout -- assets/images/app_logo.png 2>/dev/null \ + && echo "restored assets/images/app_logo.png (container symlink removed)" +fi + +echo +echo "Done. APK(s) under $REPO_ROOT/build/app/outputs/flutter-apk/" diff --git a/tools/demo/capture.sh b/tools/demo/capture.sh new file mode 100755 index 000000000..e18bc6d89 --- /dev/null +++ b/tools/demo/capture.sh @@ -0,0 +1,365 @@ +#!/usr/bin/env bash +# Hash Bags capture harness — drive real wallet flows while recording screen. +# +# One recording produces BOTH deliverables: the video (trimmed and captioned +# from the scenario's timing marks) and the store screenshots (frames pulled at +# those same marks, at native device resolution). Shooting them separately is +# how listings end up inconsistent. +# +# tools/demo/capture.sh boot +# tools/demo/capture.sh record core_store_set +# tools/demo/capture.sh shots core_store_set +# tools/demo/capture.sh reel social +# tools/demo/capture.sh all +# +# Options: +# --target android|linux where to run (default: android) +# --overlays taps|none touch indicators (default: taps) +# --avd NAME emulator AVD (default: HashBags_Capture) +# --serial NAME adb serial (default: autodetect) +# +# OVERLAYS AND THE APPLE APP PREVIEW +# The `apple-preview` reel profile force-disables overlays and refuses to run +# with --overlays=taps. The App Preview must be the app as a user sees it, +# with no synthetic touch indicators composited on top. Everything else +# (social cuts, Play listing video, docs GIFs) keeps taps on by default, +# because there they genuinely help a viewer follow the interaction. +# +# SEED SAFETY +# Scenarios bracket every frame containing a mnemonic in a `blindfold` span +# and report those spans back through the driver. This script refuses to +# promote any master to the publishable directory while a blindfold span is +# still inside the cut. The demo wallet holds real funds — that guard is not +# optional and should not be edited out. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$HERE/../.." && pwd)" +# shellcheck source=lib/secrets.sh +source "$HERE/lib/secrets.sh" +demo_load_env "$HERE/demo.env" + +IMAGE="${CI_IMAGE:-ghcr.io/cake-tech/cake_wallet:debian13-flutter3.32.0-ndkr28-go1.24.1-ruststablenightly}" +ANDROID_SDK="${ANDROID_HOME:-$HOME/Android/Sdk}" +ADB="$ANDROID_SDK/platform-tools/adb" +EMULATOR_BIN="$ANDROID_SDK/emulator/emulator" + +TARGET="android" +OVERLAYS="taps" +AVD="${DEMO_AVD:-HashBags_Capture}" +SERIAL="${DEMO_SERIAL:-}" + +# Build outputs live outside the repo, per the workstation conventions. +OUT_ROOT="${DEMO_OUT_ROOT:-$HOME/Build/hash-wallet/demo}" +RAW_DIR="$OUT_ROOT/raw" # untrimmed recordings + driver JSON +CUT_DIR="$OUT_ROOT/cut" # trimmed, blindfold-free clips +SHOT_DIR="$OUT_ROOT/screenshots" # store screenshots +PUB_DIR="$OUT_ROOT/publish" # finished masters — only safe material lands here + +SCENARIOS=(core_store_set onboarding_create restore_from_seed swap_and_buy) + +# On-device path for the in-flight recording. +DEV_CAPTURE=/data/local/tmp/hb-capture.mp4 + +log() { printf '\033[1;32m==>\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$*" >&2; } +die() { printf '\033[1;31m[fail]\033[0m %s\n' "$*" >&2; exit 1; } + +# --------------------------------------------------------------------------- +# arg parsing +# --------------------------------------------------------------------------- +CMD="${1:-help}"; shift || true +POSITIONAL=() +while [[ $# -gt 0 ]]; do + case "$1" in + --target) TARGET="$2"; shift 2 ;; + --overlays) OVERLAYS="$2"; shift 2 ;; + --avd) AVD="$2"; shift 2 ;; + --serial) SERIAL="$2"; shift 2 ;; + -h|--help) CMD="help"; shift ;; + *) POSITIONAL+=("$1"); shift ;; + esac +done +set -- "${POSITIONAL[@]+"${POSITIONAL[@]}"}" + +# --------------------------------------------------------------------------- +# device helpers +# --------------------------------------------------------------------------- +# Wait up to `timeout` seconds for a device to appear. A freshly launched +# emulator takes appreciably longer than a fixed sleep to register with adb, +# and the port varies (5554, 5556, ...) depending on what else is running. +detect_serial() { + [[ -n "$SERIAL" ]] && return 0 + local timeout="${1:-90}" waited=0 + while (( waited < timeout )); do + SERIAL="$("$ADB" devices | awk '$2=="device" {print $1; exit}')" + [[ -n "$SERIAL" ]] && { log "device: $SERIAL"; return 0; } + sleep 2; waited=$((waited + 2)) + done + die "no device appeared within ${timeout}s. Run: tools/demo/capture.sh boot" +} + +adbs() { "$ADB" -s "$SERIAL" "$@"; } + +wait_for_boot() { + log "waiting for device boot" + "$ADB" -s "$SERIAL" wait-for-device + local tries=0 + until [[ "$("$ADB" -s "$SERIAL" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" == "1" ]]; do + ((tries++)); [[ $tries -gt 180 ]] && die "device did not finish booting" + sleep 2 + done + log "device booted ($SERIAL)" +} + +cmd_boot() { + if "$ADB" devices | grep -q "device$"; then + log "a device is already booted"; detect_serial; return 0 + fi + log "booting AVD $AVD (headless)" + # -feature -GLDMA: with swiftshader_indirect on a headless host, the guest + # graphics stack otherwise aborts on + # "Assertion failed: !rcEnc->featureInfo()->hasReadColorBufferDma" + # which kills screencap and screenrecord — i.e. exactly the two things this + # harness needs. Disabling the DMA colour-buffer path costs a little capture + # throughput and makes both work. + nohup "$EMULATOR_BIN" -avd "$AVD" -no-window -no-audio -no-boot-anim \ + -gpu swiftshader_indirect -feature -GLDMA -no-snapshot-save \ + > "$OUT_ROOT/emulator.log" 2>&1 & + sleep 5 + detect_serial + wait_for_boot +} + +set_overlays() { + # Native touch indicators. Far cleaner than compositing a fake cursor: this + # is the real system-level ripple, drawn by SurfaceFlinger, so it appears in + # screenrecord exactly as a user would see it. + local on="$1" + if [[ "$on" == "taps" ]]; then + log "touch indicators ON" + adbs shell settings put system show_touches 1 || true + adbs shell settings put system pointer_location 0 || true + else + log "touch indicators OFF" + adbs shell settings put system show_touches 0 || true + adbs shell settings put system pointer_location 0 || true + fi +} + +prep_device_for_camera() { + # A clean status bar. Google's own demo-mode API — no root needed. + adbs shell settings put global sysui_demo_allowed 1 || true + adbs shell am broadcast -a com.android.systemui.demo -e command enter >/dev/null 2>&1 || true + adbs shell am broadcast -a com.android.systemui.demo -e command clock -e hhmm 0941 >/dev/null 2>&1 || true + adbs shell am broadcast -a com.android.systemui.demo -e command battery -e level 100 -e plugged false >/dev/null 2>&1 || true + adbs shell am broadcast -a com.android.systemui.demo -e command network -e wifi show -e level 4 >/dev/null 2>&1 || true + adbs shell am broadcast -a com.android.systemui.demo -e command network -e mobile show -e datatype none -e level 4 >/dev/null 2>&1 || true + adbs shell am broadcast -a com.android.systemui.demo -e command notifications -e visible false >/dev/null 2>&1 || true + # Kill animations-off if a previous test run set them; we WANT animations. + adbs shell settings put global window_animation_scale 1 || true + adbs shell settings put global transition_animation_scale 1 || true + adbs shell settings put global animator_duration_scale 1 || true +} + +exit_demo_mode() { + adbs shell am broadcast -a com.android.systemui.demo -e command exit >/dev/null 2>&1 || true +} + +# --------------------------------------------------------------------------- +# record +# --------------------------------------------------------------------------- +cmd_record() { + local scenario="${1:-}" + [[ -n "$scenario" ]] || die "usage: capture.sh record (${SCENARIOS[*]})" + [[ -f "$REPO_ROOT/integration_test/demo/scenarios/$scenario.dart" ]] \ + || die "no such scenario: $scenario" + + mkdir -p "$RAW_DIR" + detect_serial + wait_for_boot + set_overlays "$OVERLAYS" + prep_device_for_camera + + # Pull the demo seed only if the scenario needs it. + local seed="" + if [[ "$scenario" != "onboarding_create" ]]; then + if seed="$(demo_secret seed 2>/dev/null)"; then + log "demo wallet seed loaded from Vaultwarden ($(echo "$seed" | wc -w) words)" + else + warn "no demo seed available — scenario will run against whatever wallet the device already has" + seed="" + fi + fi + local send_addr="${DEMO_SEND_ADDRESS:-}" + + local dev_epoch_start + dev_epoch_start="$(adbs shell date +%s%3N | tr -d '\r')" + + log "starting screenrecord" + # /data/local/tmp, not /sdcard: the emulator's emulated-storage FUSE layer + # is flaky headless ("Transport endpoint is not connected"), and screenrecord + # then fails to open its output. /data/local/tmp is a plain filesystem that + # adb can always read and write. + adbs shell rm -f "$DEV_CAPTURE" || true + adbs shell screenrecord --size 1080x1920 --bit-rate 12000000 \ + --time-limit 180 "$DEV_CAPTURE" & + local rec_pid=$! + sleep 2 # let the encoder actually start before the app launches + + log "driving scenario: $scenario" + set +e + docker run --rm --network host \ + -v "$REPO_ROOT":/work -w /work \ + -e HOST_UID="$(id -u)" -e HOST_GID="$(id -g)" \ + -e DEMO_SEED="$seed" -e DEMO_SEND_ADDR="$send_addr" \ + -e SCENARIO="$scenario" -e SERIAL="$SERIAL" \ + "$IMAGE" bash -c ' + trap "chown -R ${HOST_UID}:${HOST_GID} /work 2>/dev/null || true" EXIT + git config --global --add safe.directory "*" + flutter drive \ + --driver=test_driver/integration_test.dart \ + --target=integration_test/demo/scenarios/'"$scenario"'.dart \ + -d "$SERIAL" \ + --dart-define-from-file=env.json \ + --dart-define=DEMO_WALLET_SEED="$DEMO_SEED" \ + --dart-define=DEMO_SEND_ADDRESS="$DEMO_SEND_ADDR" + ' 2>&1 | tee "$RAW_DIR/$scenario.drive.log" + local drive_rc=${PIPESTATUS[0]} + set -e + + log "stopping screenrecord" + adbs shell pkill -INT screenrecord || true + wait "$rec_pid" 2>/dev/null || true + sleep 3 # screenrecord needs a moment to finalise the moov atom + + adbs pull "$DEV_CAPTURE" "$RAW_DIR/$scenario.mp4" >/dev/null \ + || die "could not pull recording — did screenrecord start?" + adbs shell rm -f "$DEV_CAPTURE" || true + + # Driver output: marks + blindfold spans. + if [[ -f "$REPO_ROOT/integration_test/integration_response_data.json" ]]; then + cp "$REPO_ROOT/integration_test/integration_response_data.json" \ + "$RAW_DIR/$scenario.marks.json" + else + warn "no driver output — captions and screenshots will be unavailable" + fi + echo "$dev_epoch_start" > "$RAW_DIR/$scenario.recstart" + + exit_demo_mode + if [[ $drive_rc -ne 0 ]]; then + warn "scenario exited non-zero ($drive_rc); the recording was still saved." + warn "check $RAW_DIR/$scenario.drive.log" + fi + log "raw recording: $RAW_DIR/$scenario.mp4" + cmd_cut "$scenario" +} + +# --------------------------------------------------------------------------- +# cut — trim launch/install lead-in and excise every blindfold span +# --------------------------------------------------------------------------- +cmd_cut() { + local scenario="${1:-}" + [[ -n "$scenario" ]] || die "usage: capture.sh cut " + local raw="$RAW_DIR/$scenario.mp4" + [[ -f "$raw" ]] || die "no raw recording for $scenario" + mkdir -p "$CUT_DIR" + + python3 "$HERE/marks.py" cut \ + --raw "$raw" \ + --marks "$RAW_DIR/$scenario.marks.json" \ + --recstart "$RAW_DIR/$scenario.recstart" \ + --out "$CUT_DIR/$scenario.mp4" \ + --manifest "$CUT_DIR/$scenario.manifest.json" + log "cut clip: $CUT_DIR/$scenario.mp4" +} + +# --------------------------------------------------------------------------- +# shots — store screenshots pulled from the recording at mark timestamps +# --------------------------------------------------------------------------- +cmd_shots() { + local scenario="${1:-}" + [[ -n "$scenario" ]] || die "usage: capture.sh shots " + mkdir -p "$SHOT_DIR" + python3 "$HERE/marks.py" shots \ + --raw "$RAW_DIR/$scenario.mp4" \ + --marks "$RAW_DIR/$scenario.marks.json" \ + --recstart "$RAW_DIR/$scenario.recstart" \ + --outdir "$SHOT_DIR" + log "screenshots: $SHOT_DIR" +} + +# --------------------------------------------------------------------------- +# reel — assemble a finished master +# --------------------------------------------------------------------------- +cmd_reel() { + local profile="${1:-social}" + mkdir -p "$PUB_DIR" + + if [[ "$profile" == "apple-preview" && "$OVERLAYS" == "taps" ]]; then + die "apple-preview requires --overlays none. Re-record with: + tools/demo/capture.sh record --overlays none +An App Preview must show the app as-is, without composited touch indicators." + fi + + local manifest="$PUB_DIR/$profile.manifest.json" + local music_args=() + # apple-preview ignores music by design; reel.py enforces that too. + if [[ -n "${DEMO_MUSIC:-}" && "$profile" != "apple-preview" ]]; then + if [[ -f "$DEMO_MUSIC" ]]; then + music_args=(--music "$DEMO_MUSIC") + else + warn "DEMO_MUSIC set but not found: $DEMO_MUSIC — rendering silent" + fi + fi + python3 "$HERE/marks.py" manifest \ + --cutdir "$CUT_DIR" --profile "$profile" --out "$manifest" \ + --scenarios "${SCENARIOS[*]}" "${music_args[@]+"${music_args[@]}"}" + + python3 "$HERE/reel.py" \ + --manifest "$manifest" --profile "$profile" \ + -o "$PUB_DIR/hashbags-$profile.mp4" + log "master: $PUB_DIR/hashbags-$profile.mp4" +} + +cmd_all() { + cmd_boot + for s in "${SCENARIOS[@]}"; do + cmd_record "$s" || warn "scenario $s failed; continuing" + cmd_shots "$s" || warn "shots for $s failed; continuing" + done + cmd_reel social + log "done. publishable output under $PUB_DIR" +} + +cmd_help() { sed -n '2,40p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; } + +mkdir -p "$OUT_ROOT" + +if [[ "$TARGET" == "linux" ]]; then + die "--target linux is not implemented. + +The Linux desktop build DOES render the phone UI (linux/my_application.cc +forces a 720x1280 window when DESKTOP_FORCE_MOBILE is set), and Xvfb + +ffmpeg x11grab are available on this box, so it is a reasonable fast-iteration +path. What it needs first: + 1. ./configure_hash_wallet.sh linux (builds the x86_64-linux-gnu monero_c libs) + 2. a recorder branch here using: Xvfb :99 + DESKTOP_FORCE_MOBILE=Y + flutter drive -d linux + ffmpeg -f x11grab -video_size 720x1280 -i :99 + +It is deliberately NOT wired up half-way: desktop chrome, fonts and insets +differ from the phone build, so Linux footage must never be used for a store +listing. Use --target android for anything shippable." +elif [[ "$TARGET" != "android" ]]; then + die "--target must be 'android' (see help)" +fi + +case "$CMD" in + boot) cmd_boot ;; + record) cmd_record "${1:-}" ;; + cut) cmd_cut "${1:-}" ;; + shots) cmd_shots "${1:-}" ;; + reel) cmd_reel "${1:-social}" ;; + all) cmd_all ;; + help|*) cmd_help ;; +esac diff --git a/tools/demo/demo.env.example b/tools/demo/demo.env.example new file mode 100644 index 000000000..48a7a86fc --- /dev/null +++ b/tools/demo/demo.env.example @@ -0,0 +1,58 @@ +# Hash Bags capture harness — local configuration TEMPLATE. +# +# Copy to tools/demo/demo.env (which is gitignored) and fill in. +# Per ~/src/docs/engineering/developer-workstations.md: human credentials live +# in Vaultwarden; ignored development values sit beside the worktree; only +# reviewed templates get committed. +# +# cp tools/demo/demo.env.example tools/demo/demo.env +# +# NEVER put the demo wallet seed in this file. It is fetched from Vaultwarden +# at capture time. NEVER put the Vaultwarden hostname in a committed file +# either — that is infrastructure detail and does not belong in a public repo. + +# --------------------------------------------------------------------------- +# Vaultwarden +# --------------------------------------------------------------------------- +# Your self-hosted server URL. Set it here (this file is gitignored), then: +# bw config server "$VAULTWARDEN_SERVER" +# bw login +# export BW_SESSION=$(bw unlock --raw) +VAULTWARDEN_SERVER=https://vault.example.internal + +# Name of the Vaultwarden item holding the demo wallet. The seed may live in +# the item's notes, its password field, or a custom field named `seed`. +VAULTWARDEN_ITEM=hash-bags-demo-wallet + +# --------------------------------------------------------------------------- +# Demo wallet presentation +# --------------------------------------------------------------------------- +# Which chain is the face of the app in store material. Hash Bags is +# Wownero-first. One of: wownero | monero | bitcoin | litecoin +DEMO_WALLET_TYPE=wownero + +# Destination shown on the Send screen. This APPEARS ON CAMERA — use an +# address you are happy to publish. Defaulting it to the demo wallet's own +# receive address avoids putting a stranger's address in a store listing. +DEMO_SEND_ADDRESS= + +# Amounts typed on camera. Nothing is ever broadcast by the scenarios. +DEMO_SEND_AMOUNT=0.5 +DEMO_SWAP_AMOUNT=10 + +# PIN entered during capture (digits only). +DEMO_PIN=0801 + +# --------------------------------------------------------------------------- +# Capture targets +# --------------------------------------------------------------------------- +# AVD created by the harness at exactly 1080x1920 (16:9). Do not point this at +# a 1080x2400 phone AVD: that is 2.22:1 and Google Play rejects phone +# screenshots with an aspect ratio above 2:1. +DEMO_AVD=HashBags_Capture + +# Where captures and renders land. Kept out of the repo on purpose. +DEMO_OUT_ROOT=$HOME/Build/hash-wallet/demo + +# Optional music bed baked into the `social` reel. Ignored by apple-preview. +# DEMO_MUSIC=/home/jw/src/meatspace/assets/music/instrumental_vibey.mp3 diff --git a/tools/demo/lib/secrets.sh b/tools/demo/lib/secrets.sh new file mode 100755 index 000000000..0dea9e7e3 --- /dev/null +++ b/tools/demo/lib/secrets.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# Demo-wallet secret retrieval from the self-hosted Vaultwarden. +# +# The demo wallet is a REAL wallet with a REAL balance -- that is what makes +# the screenshots honest. Its seed therefore never lands in this repo, in +# .secrets.g.dart, in a shell history, or in a committed env file. It is +# fetched at capture time, held in a shell variable for the life of one +# capture run, and passed to the app as a --dart-define. +# +# Sourced by capture.sh; not meant to be run directly. +# +# Configuration lives in tools/demo/demo.env (gitignored). Copy +# demo.env.example and fill it in. Per ~/src/docs/engineering/ +# developer-workstations.md: human credentials in Vaultwarden, ignored +# development values beside the worktree, only reviewed templates committed. + +# --------------------------------------------------------------------------- +# bw CLI bootstrap +# --------------------------------------------------------------------------- +demo_secrets_check() { + if ! command -v bw >/dev/null 2>&1; then + cat >&2 <<'EOF' +ERROR: the Bitwarden CLI (`bw`) is not installed, so the demo wallet seed +cannot be fetched from Vaultwarden. + +Install one of: + npm install -g @bitwarden/cli + sudo snap install bw + +Then point it at the self-hosted server and log in (do this once): + bw config server "$VAULTWARDEN_SERVER" # value lives in tools/demo/demo.env + bw login + export BW_SESSION=$(bw unlock --raw) + +Alternatively, for a one-off run you can bypass Vaultwarden entirely: + DEMO_WALLET_SEED='word word ...' tools/demo/capture.sh record core_store_set + +...but do not put that in a file, and do not leave it in your shell history +(prefix the command with a space if HISTCONTROL=ignorespace). +EOF + return 1 + fi + return 0 +} + +# demo_secret +# +# Resolution order: +# 1. An already-exported env var of the same name (CI / one-off override). +# 2. A custom field on the Vaultwarden item named $VAULTWARDEN_ITEM. +# 3. The item's password/notes, when the field name is `seed`. +demo_secret() { + local field="$1" + local envname + envname="$(echo "$field" | tr '[:lower:]-' '[:upper:]_')" + + # 1. explicit override + local existing="${!envname:-}" + if [[ -n "$existing" ]]; then + printf '%s' "$existing" + return 0 + fi + + demo_secrets_check || return 1 + + if [[ -z "${BW_SESSION:-}" ]]; then + echo "ERROR: BW_SESSION is not set. Unlock the vault first:" >&2 + echo " export BW_SESSION=\$(bw unlock --raw)" >&2 + return 1 + fi + + local item="${VAULTWARDEN_ITEM:-hash-bags-demo-wallet}" + local json + if ! json="$(bw get item "$item" 2>/dev/null)"; then + echo "ERROR: no Vaultwarden item named '$item'." >&2 + echo "Create it and store the demo wallet seed there, or set" >&2 + echo "VAULTWARDEN_ITEM in tools/demo/demo.env." >&2 + return 1 + fi + + # Prefer an explicitly named custom field. + local value + value="$(printf '%s' "$json" | python3 -c ' +import json, sys +field = sys.argv[1] +data = json.load(sys.stdin) +for f in (data.get("fields") or []): + if (f.get("name") or "").lower() == field.lower(): + print(f.get("value") or "", end="") + sys.exit(0) +# fall back to notes / password for the seed itself +if field.lower() in ("seed", "demo_wallet_seed"): + print((data.get("notes") or (data.get("login") or {}).get("password") or ""), end="") +' "$field")" + + if [[ -z "$value" ]]; then + echo "ERROR: Vaultwarden item '$item' has no field '$field'." >&2 + return 1 + fi + printf '%s' "$value" +} + +# Load the gitignored local config if present. +demo_load_env() { + local envfile="$1" + if [[ -f "$envfile" ]]; then + # shellcheck disable=SC1090 + set -a; source "$envfile"; set +a + fi +} diff --git a/tools/demo/marks.py b/tools/demo/marks.py new file mode 100755 index 000000000..4eeff226c --- /dev/null +++ b/tools/demo/marks.py @@ -0,0 +1,377 @@ +#!/usr/bin/env python3 +""" +marks.py -- turn a scenario's timing marks into cuts, screenshots, manifests. + +The scenarios in integration_test/demo/ report a list of marks through the +Flutter driver. Each mark carries a scenario-relative timestamp, a label, and +optionally a caption. This script maps those onto the raw screen recording. + +Three subcommands: + + cut Trim the install/launch lead-in and EXCISE every blindfold span + (frames where a mnemonic was on screen), producing a clip that is + safe to publish. + + shots Pull store screenshots at native device resolution, one per + captioned mark. Frames are taken from the MIDDLE of each hold + rather than at its leading edge, which makes alignment robust + against the ~0.5s of encoder start-up jitter. + + manifest Assemble the per-scenario clips into a reel.py manifest. + +TIME BASE + recstart device-clock epoch (ms) read just before + `adb shell screenrecord` was launched + started_at_epoch_ms device-clock epoch (ms) at scenario start + + video_time(mark) = (started_at_epoch_ms - recstart)/1000 + mark.at + - LEAD_CORRECTION + + Both epochs come from the DEVICE clock, so host/guest skew cannot break + alignment. LEAD_CORRECTION absorbs the small delay between launching + screenrecord and the encoder producing its first frame. +""" +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path + +# Seconds between `screenrecord` being launched and it emitting frame 0. +# Measured empirically on the x86_64 emulator; override with --lead-correction. +LEAD_CORRECTION = 0.45 + +# Safety margin applied around every blindfold span. A span boundary lands on a +# mark, and the frames immediately either side can still show the tail of a +# seed screen (or its dismiss animation). Used by BOTH the cut and the +# screenshot paths -- they must agree, or a screenshot can land in a region the +# video cut treated as unsafe. +BLINDFOLD_PAD = 0.6 + + +def run(cmd: list[str]) -> None: + r = subprocess.run(cmd, capture_output=True, text=True) + if r.returncode != 0: + sys.stderr.write(r.stderr[-3000:]) + raise SystemExit(f"command failed: {' '.join(str(c) for c in cmd[:8])} ...") + + +def probe_duration(path: Path) -> float: + r = subprocess.run( + ["ffprobe", "-v", "error", "-show_entries", "format=duration", + "-of", "default=nk=1:nw=1", str(path)], + capture_output=True, text=True, + ) + try: + return float(r.stdout.strip()) + except ValueError: + raise SystemExit(f"cannot probe {path}") + + +def load_demo(marks_path: Path | None) -> dict | None: + """Read the `demo` block out of the driver's response JSON.""" + if not marks_path or not marks_path.exists(): + return None + try: + data = json.loads(marks_path.read_text()) + except json.JSONDecodeError: + return None + if not data: + return None + demo = data.get("demo") + # integrationDriver nests per-test results; dig one level if needed. + if demo is None: + for v in data.values(): + if isinstance(v, dict) and "demo" in v: + demo = v["demo"] + break + if isinstance(v, str): + try: + inner = json.loads(v) + if isinstance(inner, dict) and "demo" in inner: + demo = inner["demo"] + break + except json.JSONDecodeError: + continue + return demo + + +def video_offset(demo: dict, recstart_path: Path | None, lead: float) -> float: + if not recstart_path or not recstart_path.exists(): + return 0.0 + try: + recstart = float(recstart_path.read_text().strip()) + except ValueError: + return 0.0 + started = float(demo.get("started_at_epoch_ms") or 0) + if not started: + return 0.0 + return max((started - recstart) / 1000.0 - lead, 0.0) + + +# --------------------------------------------------------------------------- +# cut +# --------------------------------------------------------------------------- +def safe_spans(demo: dict, offset: float, duration: float) -> list[tuple[float, float]]: + """Video-time spans that contain no mnemonic frames. + + Everything before the scenario actually starts is also dropped -- that is + the install/launch lead-in, which is not part of the story. + """ + blind = [ + (offset + float(s["start"]), offset + float(s["end"])) + for s in (demo.get("blindfold_spans") or []) + ] + blind = [(max(a - BLINDFOLD_PAD, 0.0), min(b + BLINDFOLD_PAD, duration)) + for a, b in blind] + blind.sort() + + spans: list[tuple[float, float]] = [] + cursor = offset + for a, b in blind: + if a > cursor: + spans.append((cursor, min(a, duration))) + cursor = max(cursor, b) + if cursor < duration: + spans.append((cursor, duration)) + # Drop slivers that are not worth a cut. + return [(a, b) for a, b in spans if b - a > 0.8] + + +def cmd_cut(args: argparse.Namespace) -> None: + raw = Path(args.raw) + out = Path(args.out) + out.parent.mkdir(parents=True, exist_ok=True) + duration = probe_duration(raw) + demo = load_demo(Path(args.marks) if args.marks else None) + + if demo is None: + print("WARNING: no marks available; copying raw recording unchanged. " + "This clip has NOT been checked for seed frames — do not publish " + "it without watching it.", file=sys.stderr) + run(["ffmpeg", "-y", "-v", "error", "-i", str(raw), + "-c", "copy", "-movflags", "+faststart", str(out)]) + manifest = {"scenario": raw.stem, "verified_seed_free": False, + "captions": [], "duration": duration} + Path(args.manifest).write_text(json.dumps(manifest, indent=2)) + return + + offset = video_offset(demo, Path(args.recstart) if args.recstart else None, + args.lead_correction) + spans = safe_spans(demo, offset, duration) + if not spans: + raise SystemExit( + f"every frame of {raw} falls inside a blindfold span — nothing " + f"safe to publish. Check the scenario." + ) + + blind_count = len(demo.get("blindfold_spans") or []) + tmp = out.parent / f".{out.stem}-parts" + tmp.mkdir(exist_ok=True) + parts = [] + for i, (a, b) in enumerate(spans): + p = tmp / f"part{i:02d}.mp4" + run(["ffmpeg", "-y", "-v", "error", "-ss", f"{a:.3f}", "-to", f"{b:.3f}", + "-i", str(raw), "-an", "-c:v", "libx264", "-preset", "veryfast", + "-crf", "18", "-pix_fmt", "yuv420p", str(p)]) + parts.append(p) + + if len(parts) == 1: + run(["ffmpeg", "-y", "-v", "error", "-i", str(parts[0]), + "-c", "copy", "-movflags", "+faststart", str(out)]) + else: + listing = tmp / "concat.txt" + listing.write_text("".join(f"file '{p.resolve()}'\n" for p in parts)) + run(["ffmpeg", "-y", "-v", "error", "-f", "concat", "-safe", "0", + "-i", str(listing), "-c", "copy", "-movflags", "+faststart", + str(out)]) + for p in parts: + p.unlink(missing_ok=True) + (tmp / "concat.txt").unlink(missing_ok=True) + tmp.rmdir() + + # Captions, rebased onto the CUT timeline. + captions = [] + removed_before = 0.0 + span_idx = 0 + for m in demo.get("marks", []): + if not m.get("caption"): + continue + vt = offset + float(m["at"]) + # find which safe span this mark falls in, and how much was removed + acc = 0.0 + placed = None + for a, b in spans: + if a <= vt <= b: + placed = acc + (vt - a) + break + acc += b - a + if placed is not None: + captions.append({"at": round(placed, 3), "text": m["caption"], + "label": m["label"]}) + + manifest = { + "scenario": demo.get("scenario", raw.stem), + "verified_seed_free": True, + "blindfold_spans_excised": blind_count, + "duration": round(sum(b - a for a, b in spans), 3), + "captions": captions, + } + Path(args.manifest).write_text(json.dumps(manifest, indent=2)) + print(f"cut {out.name}: {manifest['duration']:.1f}s, " + f"{blind_count} blindfold span(s) excised, " + f"{len(captions)} caption(s)") + + +# --------------------------------------------------------------------------- +# shots +# --------------------------------------------------------------------------- +def cmd_shots(args: argparse.Namespace) -> None: + raw = Path(args.raw) + outdir = Path(args.outdir) + outdir.mkdir(parents=True, exist_ok=True) + demo = load_demo(Path(args.marks) if args.marks else None) + if demo is None: + raise SystemExit(f"no marks for {raw}; cannot place screenshots") + + duration = probe_duration(raw) + offset = video_offset(demo, Path(args.recstart) if args.recstart else None, + args.lead_correction) + # Same padding the video cut uses -- otherwise a screenshot could be taken + # from a region `cut` considered unsafe enough to excise. + blind = [(offset + float(s["start"]) - BLINDFOLD_PAD, + offset + float(s["end"]) + BLINDFOLD_PAD) + for s in (demo.get("blindfold_spans") or [])] + + marks = demo.get("marks", []) + scenario = demo.get("scenario", raw.stem) + made = 0 + for i, m in enumerate(marks): + if not m.get("caption"): + continue + start_vt = offset + float(m["at"]) + # Land in the middle of the hold, not on its edge: the next mark tells + # us when this screen stops being on camera. + next_vt = duration + for later in marks[i + 1:]: + nv = offset + float(later["at"]) + if nv > start_vt: + next_vt = nv + break + at = start_vt + min((next_vt - start_vt) / 2.0, 2.5) + at = min(at, duration - 0.2) + + if any(a <= at <= b for a, b in blind): + print(f" skip {m['label']}: inside a blindfold span", file=sys.stderr) + continue + + name = f"{scenario}-{made + 1:02d}-{m['label']}.png" + run(["ffmpeg", "-y", "-v", "error", "-ss", f"{at:.3f}", "-i", str(raw), + "-frames:v", "1", str(outdir / name)]) + made += 1 + print(f" {name} @{at:.2f}s") + + print(f"{made} screenshot(s) -> {outdir}") + if made: + sample = next(outdir.glob(f"{scenario}-*.png"), None) + if sample: + dims = subprocess.run( + ["ffprobe", "-v", "error", "-select_streams", "v:0", + "-show_entries", "stream=width,height", "-of", "csv=p=0:s=x", + str(sample)], capture_output=True, text=True).stdout.strip() + print(f"dimensions: {dims}") + w, h = (int(x) for x in dims.split("x")) + if h / w > 2.0: + print(f"WARNING: aspect {h/w:.2f}:1 exceeds Google Play's 2:1 " + f"maximum for phone screenshots.", file=sys.stderr) + + +# --------------------------------------------------------------------------- +# manifest +# --------------------------------------------------------------------------- +def cmd_manifest(args: argparse.Namespace) -> None: + cutdir = Path(args.cutdir) + scenarios = args.scenarios.split() + clips = [] + unsafe = [] + for s in scenarios: + clip = cutdir / f"{s}.mp4" + meta = cutdir / f"{s}.manifest.json" + if not clip.exists(): + continue + info = json.loads(meta.read_text()) if meta.exists() else {} + if not info.get("verified_seed_free", False): + unsafe.append(s) + dur = probe_duration(clip) + caption = "" + caps = info.get("captions") or [] + if caps: + caption = caps[0]["text"] + # Apple previews get no captions at all. + if args.profile == "apple-preview": + caption = "" + clips.append({ + "src": str(clip), + "caption": caption, + "seconds": round(min(dur, args.max_clip), 2), + "start": 0, + }) + + if not clips: + raise SystemExit(f"no cut clips found in {cutdir}. Record something first.") + + if unsafe: + raise SystemExit( + "REFUSING to build a publishable master.\n" + f"These clips were never verified seed-free: {', '.join(unsafe)}\n" + "They were cut without driver marks, so blindfold spans could not " + "be excised. Re-record them, or watch each one and cut by hand." + ) + + manifest = {"transition": "cross_dissolve", "clips": clips} + if args.music: + manifest["music"] = args.music + Path(args.out).write_text(json.dumps(manifest, indent=2)) + total = sum(c["seconds"] for c in clips) + print(f"manifest: {len(clips)} clip(s), ~{total:.0f}s -> {args.out}") + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + sub = ap.add_subparsers(dest="cmd", required=True) + + c = sub.add_parser("cut") + c.add_argument("--raw", required=True) + c.add_argument("--marks") + c.add_argument("--recstart") + c.add_argument("--out", required=True) + c.add_argument("--manifest", required=True) + c.add_argument("--lead-correction", type=float, default=LEAD_CORRECTION) + c.set_defaults(func=cmd_cut) + + s = sub.add_parser("shots") + s.add_argument("--raw", required=True) + s.add_argument("--marks") + s.add_argument("--recstart") + s.add_argument("--outdir", required=True) + s.add_argument("--lead-correction", type=float, default=LEAD_CORRECTION) + s.set_defaults(func=cmd_shots) + + m = sub.add_parser("manifest") + m.add_argument("--cutdir", required=True) + m.add_argument("--profile", default="social") + m.add_argument("--out", required=True) + m.add_argument("--scenarios", required=True) + m.add_argument("--music", default=None) + m.add_argument("--max-clip", type=float, default=8.0) + m.set_defaults(func=cmd_manifest) + + args = ap.parse_args() + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/tools/demo/packs/hash/pack.json b/tools/demo/packs/hash/pack.json new file mode 100644 index 000000000..e9f4a1c98 --- /dev/null +++ b/tools/demo/packs/hash/pack.json @@ -0,0 +1,37 @@ +{ + "name": "hash", + "description": "Hash Bags — an open-source, non-custodial multi-currency wallet by Such Software LLC. Wownero-first, privacy-respecting, no KYC. Deep-forest green on near-black, clean geometric sans, the '#' mark. Calm and credible, not hype.", + "palette": { + "bg": "#0a1a12", + "bg_mid": "#0f2b1c", + "bg_hi": "#123f28", + "surface": "#123f28", + "ink": "#e8efe9", + "muted": "#7fae97", + "accent": "#2EA56A", + "accent_hi": "#8fd6b0", + "primary": "#1A5C38" + }, + "type": { + "families": { "display": "Inter Display", "body": "Inter" }, + "scale": { "title": 96, "subhead": 52, "body": 34, "caption": 48 }, + "font_files": { + "display": "/usr/share/fonts/opentype/inter/InterDisplay-Bold.otf", + "body": "/usr/share/fonts/opentype/inter/Inter-SemiBold.otf" + }, + "font_fallbacks": [ + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf" + ] + }, + "motion": { + "transition_default": "cross_dissolve", + "transition_social": "blur_punch" + }, + "copy": { + "title": "Hash Bags", + "subtitle": "You hold the keys.", + "cta": "hash.boats", + "cta_sub": "open-source · non-custodial · no KYC" + } +} diff --git a/tools/demo/reel.py b/tools/demo/reel.py new file mode 100755 index 000000000..13cf03069 --- /dev/null +++ b/tools/demo/reel.py @@ -0,0 +1,480 @@ +#!/usr/bin/env python3 +""" +reel.py -- assemble Hash Bags marketing video from recorded wallet captures. + +Takes the raw screen recordings produced by capture.sh (one MP4 per scenario) +plus a manifest describing captions and pacing, and renders a finished master. + +Profiles decide how much production sits on top of the raw capture: + + social 1080x1920 vertical. Title card, per-clip burned captions on a + blurred fill, xfade transitions, CTA end card, optional music. + This is the TikTok / Reels / Shorts deliverable. + + apple-preview App Store App Preview. DELIBERATELY BARE: no burned captions, + no title/CTA cards, no tap overlay, no music bed. Apple wants + the app itself on screen, and the recording is used as shot. + Trimmed to Apple's 15-30s window and left at device pixels. + + play-listing Google Play listing video. Same production as `social` but + sized 1080x1920 and duration-capped at 30s. + + raw Passthrough + QA only. Useful for eyeballing a capture. + +The genuinely reusable pieces of ~/src/such-graphics (video_transitions for the +xfade preset table, video_qa for the "did this decode to black" gate) are +imported when that repo is checked out, and cleanly stubbed when it is not -- +hash-wallet is public and must build without it. + +Usage: + python3 tools/demo/reel.py --manifest reel.json --profile social -o out.mp4 + python3 tools/demo/reel.py --manifest reel.json --profile apple-preview \ + -o preview.mp4 + +Manifest shape: + { + "title": "Hash Bags", # optional, defaults from pack.json + "subtitle": "You hold the keys.", + "cta": "hash.boats", + "transition": "cross_dissolve", # optional + "music": "/path/to/bed.mp3", # optional; ignored by apple-preview + "clips": [ + {"src": "captures/receive.mp4", "caption": "Receive in a tap", + "seconds": 6, "start": 1.5} + ] + } +""" +from __future__ import annotations + +import argparse +import json +import os +import random +import shutil +import subprocess +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path + +HERE = Path(__file__).resolve().parent +DEFAULT_PACK = HERE / "packs" / "hash" / "pack.json" + +# --------------------------------------------------------------------------- +# such-graphics interop (optional) +# --------------------------------------------------------------------------- +SUCH_GRAPHICS_HOME = Path( + os.environ.get("SUCH_GRAPHICS_HOME", Path.home() / "src" / "such-graphics") +) + +_FALLBACK_TRANSITIONS = { + "cross_dissolve": ("fade", 0.52), + "blur_punch": ("hblur", 0.42), + "black_dip": ("fadeblack", 0.34), + "slide_up": ("slideup", 0.40), + "zoom_punch": ("zoomin", 0.45), + "whip_left": ("smoothleft", 0.38), +} + + +@dataclass(frozen=True) +class Transition: + ffmpeg_name: str + duration: float + + +def load_transition(name: str) -> Transition: + """Prefer such-graphics' tuned preset table; fall back to a local subset.""" + if SUCH_GRAPHICS_HOME.is_dir(): + sys.path.insert(0, str(SUCH_GRAPHICS_HOME)) + try: + from such_graphics.video_transitions import ( # noqa: E402 + get_video_transition, + ) + + t = get_video_transition(name) + return Transition(t.ffmpeg_name, t.duration) + except Exception: + pass # fall through to the local table + ff, dur = _FALLBACK_TRANSITIONS.get(name, _FALLBACK_TRANSITIONS["cross_dissolve"]) + return Transition(ff, dur) + + +def run_video_qa(path: Path) -> str: + """Decode-level QA. ffmpeg can emit a valid MP4 that is entirely black -- + a real hazard with headless emulator capture, so this gate matters.""" + if SUCH_GRAPHICS_HOME.is_dir(): + sys.path.insert(0, str(SUCH_GRAPHICS_HOME)) + try: + from such_graphics.video_qa import verify_video # noqa: E402 + + verify_video(path) + return "such_graphics.video_qa: PASS" + except ImportError: + pass + except Exception as exc: # QA genuinely failed -- surface it + raise SystemExit(f"VIDEO QA FAILED for {path}: {exc}") + return _local_black_check(path) + + +def _local_black_check(path: Path) -> str: + """Minimal standalone equivalent: sample frames, fail if all are black.""" + dur = probe_duration(path) + samples = [t for t in (0.5, dur * 0.25, dur * 0.5, dur * 0.75) if t < dur] + brightest = 0.0 + for at in samples: + r = subprocess.run( + ["ffmpeg", "-v", "error", "-ss", f"{at}", "-i", str(path), + "-frames:v", "1", "-vf", "scale=64:64,format=gray", + "-f", "rawvideo", "-"], + capture_output=True, + ) + if r.stdout: + brightest = max(brightest, sum(r.stdout) / len(r.stdout)) + if brightest < 4.0: + raise SystemExit( + f"VIDEO QA FAILED for {path}: decodes to black " + f"(peak mean luma {brightest:.2f}). The capture likely raced the " + f"app launch, or the emulator GPU produced no frames." + ) + return f"local black-check: PASS (peak mean luma {brightest:.1f})" + + +# --------------------------------------------------------------------------- +# ffmpeg helpers +# --------------------------------------------------------------------------- +W, H, FPS = 1080, 1920, 30 + + +def run(cmd: list[str]) -> None: + r = subprocess.run(cmd, capture_output=True, text=True) + if r.returncode != 0: + sys.stderr.write(r.stderr[-4000:]) + raise SystemExit(f"ffmpeg failed: {' '.join(str(c) for c in cmd[:8])} ...") + + +def probe_duration(path: str | Path) -> float: + r = subprocess.run( + ["ffprobe", "-v", "error", "-show_entries", "format=duration", + "-of", "default=nk=1:nw=1", str(path)], + capture_output=True, text=True, + ) + try: + return float(r.stdout.strip()) + except ValueError: + raise SystemExit(f"cannot probe duration of {path} -- is it a valid video?") + + +def esc(text: str) -> str: + """Escape text for the ffmpeg drawtext filter.""" + return ( + text.replace("\\", "\\\\") + .replace(":", "\\:") + .replace("'", "’") + .replace("%", "\\%") + ) + + +class Pack: + def __init__(self, path: Path): + self.data = json.loads(Path(path).read_text()) + self.palette = self.data["palette"] + self.copy = self.data.get("copy", {}) + self.font = self._resolve_font("display") + self.body_font = self._resolve_font("body") + + def _resolve_font(self, role: str) -> str: + t = self.data.get("type", {}) + cand = [t.get("font_files", {}).get(role, "")] + cand += t.get("font_fallbacks", []) + for c in cand: + if c and Path(c).exists(): + return c + raise SystemExit( + "no usable font found; install Inter or DejaVu, or edit " + f"{DEFAULT_PACK} type.font_fallbacks" + ) + + def hexc(self, key: str) -> str: + """pack.json stores #rrggbb; ffmpeg wants 0xrrggbb.""" + return "0x" + self.palette[key].lstrip("#") + + +def drawtext(pack: Pack, text: str, y: str, size: int, color: str, + box: bool = True, font: str | None = None) -> str: + parts = [ + f"fontfile={font or pack.font}", + f"text='{esc(text)}'", + "x=(w-text_w)/2", + f"y={y}", + f"fontsize={size}", + f"fontcolor={color}", + ] + if box: + parts += ["box=1", "boxcolor=black@0.55", "boxborderw=28"] + return "drawtext=" + ":".join(parts) + + +# --------------------------------------------------------------------------- +# segment builders +# --------------------------------------------------------------------------- +def build_card(pack: Pack, lines: list[str], seconds: float, out: Path) -> None: + """Brand card: centered lines, first line accent-coloured and larger.""" + draws = [] + n = len(lines) + for i, line in enumerate(lines): + big = i == 0 + color = pack.hexc("accent") if big else pack.hexc("ink") + size = pack.data["type"]["scale"]["title"] if big else pack.data["type"]["scale"]["subhead"] + yoff = int((i - (n - 1) / 2) * 150) + draws.append( + drawtext(pack, line, y=f"(h-text_h)/2+{yoff}", size=size, + color=color, box=False, + font=pack.font if big else pack.body_font) + ) + # Flat brand field with a single lift pass. Deliberately plain: the card + # exists to frame the wordmark, not to compete with the app footage. + bg = ( + f"color=c={pack.hexc('bg')}:s={W}x{H}:d={seconds}:r={FPS}[base];" + f"[base]drawbox=x=0:y=0:w={W}:h={H}:color={pack.hexc('bg_hi')}@0.35:t=fill[grad];" + f"[grad]" + ",".join(draws) + ",format=yuv420p[v]" + ) + run([ + "ffmpeg", "-y", "-f", "lavfi", + "-i", f"color=c={pack.hexc('bg')}:s={W}x{H}:d={seconds}:r={FPS}", + "-filter_complex", bg, "-map", "[v]", "-an", + "-c:v", "libx264", "-preset", "medium", "-crf", "18", str(out), + ]) + + +def build_clip(pack: Pack, src: str, caption: str, seconds: float, out: Path, + start: float = 0.0, captioned: bool = True) -> None: + """Frame one capture on a blurred fill of itself, optionally caption it. + + `start` seeks into the clip before taking `seconds`. Use it to skip past + the seed-entry frames of a restore recording so a mnemonic never lands on + camera even if the scenario paused there. + """ + chain = ( + f"[0:v]scale={W}:{H}:force_original_aspect_ratio=increase," + f"crop={W}:{H},boxblur=40:2,eq=brightness=-0.32:saturation=0.7[bg];" + f"[0:v]scale=-2:1480,pad=iw+8:ih+8:4:4:{pack.hexc('accent')}@0.9[fg];" + f"[bg][fg]overlay=(W-w)/2:(H-h)/2-60[cmp];" + ) + tail = ( + f"fade=t=in:st=0:d=0.4,fade=t=out:st={max(seconds - 0.4, 0.1):.2f}:d=0.4," + f"setsar=1,fps={FPS},format=yuv420p[v]" + ) + if captioned and caption: + cap = drawtext(pack, caption, y="h-260", + size=pack.data["type"]["scale"]["caption"], + color=pack.hexc("accent_hi")) + chain += f"[cmp]{cap},{tail}" + else: + chain += f"[cmp]{tail}" + + run([ + "ffmpeg", "-y", "-ss", f"{start}", "-t", f"{seconds}", "-i", src, + "-filter_complex", chain, "-map", "[v]", "-an", + "-c:v", "libx264", "-preset", "medium", "-crf", "18", + "-t", f"{seconds}", str(out), + ]) + + +def build_bare_clip(src: str, seconds: float, out: Path, start: float = 0.0) -> None: + """Apple App Preview path: re-encode only. No framing, no caption, no + overlay -- the recording is the deliverable. Kept at source geometry so + the device pixels Apple expects survive untouched.""" + run([ + "ffmpeg", "-y", "-ss", f"{start}", "-t", f"{seconds}", "-i", src, + "-an", "-c:v", "libx264", "-preset", "slow", "-crf", "18", + "-pix_fmt", "yuv420p", "-r", str(FPS), + "-movflags", "+faststart", str(out), + ]) + + +def xfade_concat(segments: list[Path], transition: str, out: Path) -> None: + """Chain N segments with a single xfade preset. Video only.""" + t = load_transition(transition) + inputs: list[str] = [] + for s in segments: + inputs += ["-i", str(s)] + durs = [probe_duration(s) for s in segments] + filt = [] + prev = "[0:v]" + running = durs[0] + for i in range(1, len(segments)): + off = max(running - t.duration, 0.1) + label = f"[x{i}]" + filt.append( + f"{prev}[{i}:v]xfade=transition={t.ffmpeg_name}:" + f"duration={t.duration}:offset={off:.3f}{label}" + ) + prev = label + running = running + durs[i] - t.duration + run([ + "ffmpeg", "-y", *inputs, + "-filter_complex", ";".join(filt), "-map", prev, + "-c:v", "libx264", "-preset", "slow", "-crf", "18", + "-pix_fmt", "yuv420p", "-colorspace", "bt709", "-color_primaries", "bt709", + "-movflags", "+faststart", str(out), + ]) + + +def concat_simple(segments: list[Path], out: Path, tmp: Path) -> None: + """Straight concat, no transitions (apple-preview keeps cuts honest).""" + listing = tmp / "concat.txt" + listing.write_text("".join(f"file '{s}'\n" for s in segments)) + run([ + "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(listing), + "-c:v", "libx264", "-preset", "slow", "-crf", "18", + "-pix_fmt", "yuv420p", "-movflags", "+faststart", str(out), + ]) + + +def mix_music(video: Path, music: str, out: Path, seed: int | None = None) -> None: + """Bake a random-offset slice of the bed under the master, with fades. + + Random offset means repeat uploads of the same variant do not feel + identical -- lifted from the medusa marketing-video builder. + """ + dur = probe_duration(video) + bed_len = probe_duration(music) + rng = random.Random(seed) + max_off = max(bed_len - dur - 1.0, 0.0) + off = rng.uniform(0, max_off) if max_off > 0 else 0.0 + run([ + "ffmpeg", "-y", "-i", str(video), "-ss", f"{off:.2f}", "-i", music, + "-filter_complex", + f"[1:a]atrim=0:{dur:.2f},afade=t=in:st=0:d=0.5," + f"afade=t=out:st={max(dur - 1.0, 0.1):.2f}:d=1.0,volume=0.35[a]", + "-map", "0:v", "-map", "[a]", + "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", + "-shortest", "-movflags", "+faststart", str(out), + ]) + + +# --------------------------------------------------------------------------- +# profiles +# --------------------------------------------------------------------------- +APPLE_MIN, APPLE_MAX = 15.0, 30.0 +PLAY_MAX = 30.0 + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--manifest", required=True) + ap.add_argument("-o", "--out", required=True) + ap.add_argument("--profile", default="social", + choices=["social", "apple-preview", "play-listing", "raw"]) + ap.add_argument("--pack", default=str(DEFAULT_PACK)) + ap.add_argument("--transition", default=None) + ap.add_argument("--music", default=None, help="override manifest music bed") + ap.add_argument("--seed", type=int, default=None, + help="deterministic music offset (for reproducible renders)") + ap.add_argument("--keep-work", action="store_true") + args = ap.parse_args() + + if not shutil.which("ffmpeg"): + raise SystemExit("ffmpeg not found on PATH") + + pack = Pack(Path(args.pack)) + m = json.loads(Path(args.manifest).read_text()) + clips = m.get("clips", []) + if not clips: + raise SystemExit(f"{args.manifest} has no clips") + for c in clips: + if not Path(c["src"]).exists(): + raise SystemExit(f"clip source missing: {c['src']}") + + out = Path(args.out) + out.parent.mkdir(parents=True, exist_ok=True) + tmp = Path(tempfile.mkdtemp(prefix="hashbags-reel-")) + segs: list[Path] = [] + + apple = args.profile == "apple-preview" + branded = args.profile in ("social", "play-listing") + + try: + if branded: + title = m.get("title", pack.copy.get("title", "Hash Bags")) + subtitle = m.get("subtitle", pack.copy.get("subtitle", "")) + seg = tmp / "00-title.mp4" + build_card(pack, [title, subtitle], 2.5, seg) + segs.append(seg) + + for i, clip in enumerate(clips): + seg = tmp / f"{i + 1:02d}-clip.mp4" + seconds = float(clip.get("seconds", 5)) + start = float(clip.get("start", 0)) + if apple or args.profile == "raw": + build_bare_clip(clip["src"], seconds, seg, start=start) + else: + build_clip(pack, clip["src"], clip.get("caption", ""), + seconds, seg, start=start, captioned=True) + segs.append(seg) + + if branded: + cta = m.get("cta", pack.copy.get("cta", "hash.boats")) + cta_sub = m.get("cta_sub", pack.copy.get("cta_sub", "")) + seg = tmp / "99-cta.mp4" + build_card(pack, [cta, cta_sub], 2.5, seg) + segs.append(seg) + + silent = tmp / "master-silent.mp4" + if apple or args.profile == "raw": + # Honest cuts, no transition sugar, geometry preserved. + concat_simple(segs, silent, tmp) + else: + transition = (args.transition or m.get("transition") + or pack.data["motion"]["transition_default"]) + xfade_concat(segs, transition, silent) + + dur = probe_duration(silent) + + # ---- profile duration gates ------------------------------------ + if apple: + if dur > APPLE_MAX: + raise SystemExit( + f"apple-preview is {dur:.1f}s; Apple accepts {APPLE_MIN:.0f}-" + f"{APPLE_MAX:.0f}s. Trim clip `seconds` in {args.manifest}." + ) + if dur < APPLE_MIN: + raise SystemExit( + f"apple-preview is {dur:.1f}s; Apple requires at least " + f"{APPLE_MIN:.0f}s. Lengthen clip `seconds` in {args.manifest}." + ) + if args.profile == "play-listing" and dur > PLAY_MAX: + print(f"WARNING: {dur:.1f}s exceeds the {PLAY_MAX:.0f}s Play " + f"listing-video guidance", file=sys.stderr) + + # ---- music ------------------------------------------------------ + music = args.music or m.get("music") + if music and apple: + print("NOTE: apple-preview ignores the music bed on purpose " + "(App Preview should carry app audio or silence)", + file=sys.stderr) + music = None + if music and Path(music).exists(): + mix_music(silent, music, out, seed=args.seed) + else: + shutil.copy(silent, out) + + qa = run_video_qa(out) + print(f"wrote {out}") + print(f" profile {args.profile}") + print(f" duration {probe_duration(out):.1f}s") + print(f" qa {qa}") + if apple: + print(" overlays NONE (no captions/cards/tap effect) — per App " + "Preview deliverable rules") + finally: + if args.keep_work: + print(f" work dir {tmp}") + else: + shutil.rmtree(tmp, ignore_errors=True) + + +if __name__ == "__main__": + main()