mirror of
https://github.com/Such-Software/hash-wallet.git
synced 2026-10-17 05:41:29 +02:00
Merge pull request 'Dev' (#38) from github-such-software/hash-wallet:dev into dev
Reviewed-on: Builds/hash-wallet#38
This commit is contained in:
+10
@@ -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__/
|
||||
|
||||
@@ -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<int> 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)}';
|
||||
}
|
||||
}
|
||||
@@ -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<Map<String, dynamic>> _marks = <Map<String, dynamic>>[];
|
||||
|
||||
/// 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(<String, dynamic>{
|
||||
'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(<String, dynamic>{
|
||||
'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<void> 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<void> 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<void> 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<void> 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<String> _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<void> blindfold(String reason, Future<void> 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 ?? <String, dynamic>{};
|
||||
existing['demo'] = <String, dynamic>{
|
||||
'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<Map<String, double>> _blindfoldSpans() {
|
||||
final spans = <Map<String, double>>[];
|
||||
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(<String, double>{'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(<String, double>{'start': open, 'end': _elapsed});
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
+54
-17
@@ -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/<tag>/ 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
|
||||
|
||||
@@ -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/<device>-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.
|
||||
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="500" viewBox="0 0 1024 500">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#0a1a12"/>
|
||||
<stop offset="0.55" stop-color="#0f2b1c"/>
|
||||
<stop offset="1" stop-color="#123f28"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="glow" cx="0.70" cy="0.30" r="0.65">
|
||||
<stop offset="0" stop-color="#2EA56A" stop-opacity="0.30"/>
|
||||
<stop offset="1" stop-color="#2EA56A" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<rect width="1024" height="500" fill="url(#bg)"/>
|
||||
<rect width="1024" height="500" fill="url(#glow)"/>
|
||||
|
||||
<!-- Hash Bags mark -->
|
||||
<g transform="translate(104,130)">
|
||||
<rect width="240" height="240" rx="53" fill="#1a5c38"/>
|
||||
<text x="120" y="180" font-family="monospace" font-size="180" font-weight="700"
|
||||
fill="#ffffff" text-anchor="middle">#</text>
|
||||
</g>
|
||||
|
||||
<!-- Wordmark + taglines -->
|
||||
<g transform="translate(404,0)">
|
||||
<text x="0" y="228" font-family="sans-serif" font-size="98" font-weight="700"
|
||||
fill="#e8efe9">Hash Bags</text>
|
||||
<text x="4" y="292" font-family="sans-serif" font-size="31" font-weight="500"
|
||||
fill="#8fd6b0">Open-source, non-custodial wallet</text>
|
||||
<text x="4" y="342" font-family="sans-serif" font-size="27" font-weight="400"
|
||||
fill="#7fae97">Wownero · Monero · Bitcoin · Litecoin · and more</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
Executable
+20
@@ -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 <name> # 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 <name>}"
|
||||
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)"
|
||||
@@ -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 <scenario> 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`.
|
||||
Executable
+222
@@ -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 <<EOF
|
||||
storePassword=hashbags
|
||||
keyPassword=hashbags
|
||||
keyAlias=hashbags-debug
|
||||
storeFile=debug-capture.jks
|
||||
EOF
|
||||
echo "minted throwaway keystore android/app/debug-capture.jks (NOT the upload key)"
|
||||
else
|
||||
echo "android/key.properties already present — leaving it alone"
|
||||
fi
|
||||
|
||||
step "flutter pub get"
|
||||
flutter pub get
|
||||
|
||||
step "generate secrets (empty defaults)"
|
||||
# Real keys are injected by CI only. Locally these stay empty, which is the
|
||||
# correct posture for a capture build: no live Trocador/MoonPay credentials
|
||||
# end up in a demo APK.
|
||||
[[ -f lib/.secrets.g.dart ]] && echo "secrets already generated" || dart run tool/generate_new_secrets.dart
|
||||
|
||||
step "codegen (mobx + hive adapters) -- this is the slow one"
|
||||
bash model_generator.sh
|
||||
|
||||
step "localization"
|
||||
dart run tool/generate_localization.dart
|
||||
|
||||
step "compile svg assets"
|
||||
./compile_graphics.sh
|
||||
|
||||
step "build apk ($BUILD_MODE / ${TARGET_PLATFORM:-universal})"
|
||||
EXTRA=""
|
||||
[[ -n "$TARGET_PLATFORM" ]] && EXTRA="--target-platform=$TARGET_PLATFORM"
|
||||
flutter build apk --dart-define-from-file=env.json --$BUILD_MODE $EXTRA
|
||||
|
||||
echo
|
||||
echo "==> 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/"
|
||||
Executable
+365
@@ -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 <scenario> (${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 <scenario>"
|
||||
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 <scenario>"
|
||||
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 <scenario> --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
|
||||
@@ -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
|
||||
Executable
+110
@@ -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 <field-name>
|
||||
#
|
||||
# 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
|
||||
}
|
||||
Executable
+377
@@ -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()
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Executable
+480
@@ -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()
|
||||
Reference in New Issue
Block a user