diff --git a/assets/seed.db b/assets/seed.db new file mode 100644 index 0000000..8e804fc Binary files /dev/null and b/assets/seed.db differ diff --git a/lib/main.dart b/lib/main.dart index 49e8ae0..96aff49 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,8 +1,9 @@ import 'dart:async'; -import 'dart:io' show Platform; +import 'dart:io' show File, Platform; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show rootBundle; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; @@ -235,6 +236,9 @@ final appInitializationProvider = FutureProvider((ref) async { // Clear storage if requested from a clear all operation await maybeClearStorage(dbPath); + // Seed database on first launch so new users see content immediately + await _maybeCopySeedDatabase(dbPath); + // Load local relay config BEFORE storage init // This ensures custom relays work even when signed out final secureStorage = ref.read(secureStorageServiceProvider); @@ -291,6 +295,26 @@ final amberSignerProvider = Provider( (ref) => AmberSigner(ref, persistence: SecureStoragePubkeyPersistence()), ); +/// Copy the bundled seed database on first launch so the UI has content +/// before relay data arrives. No-op if the database already exists. +Future _maybeCopySeedDatabase(String dbPath) async { + final dbFile = File(dbPath); + if (dbFile.existsSync()) return; + try { + final seedData = await rootBundle.load('assets/seed.db'); + await dbFile.create(recursive: true); + await dbFile.writeAsBytes( + seedData.buffer.asUint8List( + seedData.offsetInBytes, + seedData.lengthInBytes, + ), + flush: true, + ); + } catch (_) { + // Non-fatal: the app works fine without the seed — just a cold start + } +} + Future _attemptAutoSignIn(Ref ref) async { try { await ref.read(amberSignerProvider).attemptAutoSignIn(); diff --git a/pubspec.lock b/pubspec.lock index fc01e63..168e6d7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1076,7 +1076,7 @@ packages: source: hosted version: "2.4.0" sqlite3: - dependency: transitive + dependency: "direct dev" description: name: sqlite3 sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2" @@ -1292,7 +1292,7 @@ packages: source: hosted version: "1.1.1" web_socket: - dependency: transitive + dependency: "direct dev" description: name: web_socket sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" diff --git a/pubspec.yaml b/pubspec.yaml index bea0e0f..258d261 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -86,6 +86,9 @@ dev_dependencies: flutter_lints: ^5.0.0 flutter_launcher_icons: ^0.14.4 + sqlite3: any + web_socket: any + # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec @@ -99,6 +102,7 @@ flutter: # To add assets to your application, add an assets section, like this: assets: - assets/images/ + - assets/seed.db # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images diff --git a/tool/seed_database.dart b/tool/seed_database.dart new file mode 100644 index 0000000..47e7905 --- /dev/null +++ b/tool/seed_database.dart @@ -0,0 +1,210 @@ +/// Pre-release script that generates assets/seed.db for new-user cold start. +/// +/// Fetches curated stacks (kind 30267) and their referenced apps (kind 32267) +/// from the AppCatalog relay, plus author profiles (kind 0) from social relays. +/// Writes them into a SQLite database using the same schema/codec as purplebase. +/// +/// Usage: +/// dart run tool/seed_database.dart +/// +/// The output file is assets/seed.db — commit it before building the release APK. +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:sqlite3/sqlite3.dart'; +import 'package:web_socket/web_socket.dart'; + +// Internal purplebase imports for codec + schema +import 'package:purplebase/src/db/codec.dart'; +import 'package:purplebase/src/db/schema.dart'; + +const _kZapstoreCommunityPubkey = + 'acfeaea6e51420e8068fac446ca9d17d7a9ef6a5d20d93894e50fee3d4902a84'; + +const _kAppCatalogRelay = 'wss://relay.zapstore.dev'; + +const _kProfileRelays = [ + 'wss://relay.vertexlab.io', + 'wss://relay.damus.io', + 'wss://relay.primal.net', + 'wss://nos.lol', +]; + +void main() async { + final outputPath = 'assets/seed.db'; + + stdout.writeln('=== Zapstore seed database generator ===\n'); + + // 1. Fetch stacks + stdout.writeln('Fetching stacks from $_kAppCatalogRelay ...'); + final stacks = await _fetchEvents( + _kAppCatalogRelay, + {'kinds': [30267], 'authors': [_kZapstoreCommunityPubkey], 'limit': 100}, + ); + stdout.writeln(' ${stacks.length} stacks'); + + // 2. Extract referenced app coordinates and author pubkeys + final appRefs = {}; + final pubkeys = {_kZapstoreCommunityPubkey}; + + for (final stack in stacks) { + pubkeys.add(stack['pubkey'] as String); + for (final tag in (stack['tags'] as List)) { + if (tag[0] == 'a' && (tag[1] as String).startsWith('32267:')) { + appRefs.add(tag[1] as String); + pubkeys.add((tag[1] as String).split(':')[1]); + } + } + } + + // 3. Fetch apps by d-tag + final dTags = appRefs.map((r) => r.split(':')[2]).toList(); + stdout.writeln('Fetching ${dTags.length} apps from $_kAppCatalogRelay ...'); + final apps = await _fetchEvents( + _kAppCatalogRelay, + {'kinds': [32267], '#d': dTags, 'limit': 500}, + ); + stdout.writeln(' ${apps.length} apps'); + + // Collect any additional pubkeys from fetched apps + for (final app in apps) { + pubkeys.add(app['pubkey'] as String); + } + + // 4. Fetch profiles from multiple relays, dedup by pubkey (keep newest) + stdout.writeln('Fetching profiles for ${pubkeys.length} pubkeys ...'); + final profilesByPubkey = >{}; + + for (final relay in [_kAppCatalogRelay, ..._kProfileRelays]) { + final remaining = + pubkeys.where((pk) => !profilesByPubkey.containsKey(pk)).toList(); + if (remaining.isEmpty) break; + + stdout.write(' $relay (${remaining.length} remaining) ... '); + try { + final profiles = await _fetchEvents( + relay, + {'kinds': [0], 'authors': remaining, 'limit': remaining.length}, + ); + for (final p in profiles) { + final pk = p['pubkey'] as String; + final existing = profilesByPubkey[pk]; + if (existing == null || + (p['created_at'] as int) > (existing['created_at'] as int)) { + profilesByPubkey[pk] = p; + } + } + stdout.writeln('${profiles.length} found'); + } catch (e) { + stdout.writeln('failed ($e)'); + } + } + + final profiles = profilesByPubkey.values.toList(); + final missingCount = pubkeys.length - profiles.length; + stdout.writeln(' ${profiles.length} profiles total' + '${missingCount > 0 ? ' ($missingCount missing — will be fetched at runtime)' : ''}'); + + // 5. Build the SQLite database + final allEvents = [...stacks, ...apps, ...profiles]; + stdout.writeln('\nWriting ${allEvents.length} events to $outputPath ...'); + + final dbFile = File(outputPath); + if (dbFile.existsSync()) dbFile.deleteSync(); + dbFile.parent.createSync(recursive: true); + + final db = sqlite3.open(outputPath); + try { + db.execute(setUpSql); + _insertEvents(db, allEvents); + db.execute('VACUUM'); + } finally { + db.dispose(); + } + + final fileSize = File(outputPath).lengthSync(); + stdout.writeln(' Done: $fileSize bytes (${(fileSize / 1024).toStringAsFixed(1)} KB)'); + stdout.writeln('\nSeed database ready. Commit $outputPath before building the release.'); +} + +/// Fetch events from a single relay using a one-shot REQ/EOSE pattern. +Future>> _fetchEvents( + String relayUrl, + Map filter, +) async { + final uri = Uri.parse(relayUrl); + final ws = await WebSocket.connect(uri).timeout(const Duration(seconds: 10)); + + final events = >[]; + final completer = Completer>>(); + const subId = 'seed'; + + final sub = ws.events.listen((event) { + if (event case TextDataReceived(:final text)) { + final msg = jsonDecode(text) as List; + if (msg[0] == 'EVENT' && msg[1] == subId) { + events.add(msg[2] as Map); + } else if (msg[0] == 'EOSE' && msg[1] == subId) { + if (!completer.isCompleted) completer.complete(events); + } + } + }); + + ws.sendText(jsonEncode(['REQ', subId, filter])); + + try { + return await completer.future.timeout(const Duration(seconds: 15)); + } finally { + ws.sendText(jsonEncode(['CLOSE', subId])); + sub.cancel(); + unawaited(ws.close().catchError((_) {})); + } +} + +/// Insert events into the database using the same codec as purplebase. +void _insertEvents(Database db, List> events) { + final (encodedEvents, tagsForId) = EventCodec.encode(events); + + final sql = ''' + INSERT INTO events (id, pubkey, kind, created_at, blob) + VALUES (:id, :pubkey, :kind, :created_at, :blob) + ON CONFLICT(id) DO UPDATE SET + pubkey = EXCLUDED.pubkey, + kind = EXCLUDED.kind, + created_at = EXCLUDED.created_at, + blob = EXCLUDED.blob + WHERE EXCLUDED.created_at > events.created_at; + INSERT OR REPLACE INTO event_tags (event_id, value, is_relay) + VALUES (:event_id, :value, :is_relay); + '''; + + final [eventPs, tagsPs] = db.prepareMultiple(sql); + + try { + db.execute('BEGIN'); + for (final event in encodedEvents) { + eventPs.executeWith(StatementParameters.named(event)); + + for (final List tag in tagsForId[event[':id']]!) { + if (tag.length < 2 || tag[0].toString().length > 1) continue; + tagsPs.executeWith( + StatementParameters.named({ + ':event_id': event[':id'], + ':value': '${tag[0]}:${tag[1]}', + ':is_relay': false, + }), + ); + } + } + db.execute('COMMIT'); + } catch (e) { + db.execute('ROLLBACK'); + rethrow; + } finally { + eventPs.dispose(); + tagsPs.dispose(); + } +}