Merge cursor/fix-web-db-stub-382d: Flutter Web/Chrome compile stub

This commit is contained in:
Claude 2026-07-30 07:18:45 +00:00
commit 9cd52f884d
No known key found for this signature in database
14 changed files with 258 additions and 132 deletions

View File

@ -15,7 +15,7 @@ migration:
- platform: root - platform: root
create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323
base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323
- platform: android - platform: web
create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323
base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323

View File

@ -1,128 +1,5 @@
import 'dart:ffi'; /// Platform-specific local database entrypoint.
import 'dart:io'; ///
import 'dart:math'; /// - IO (Android / iOS / Linux / ): Drift + SQLCipher (`app_database_native.dart`)
/// - Web (Chrome): in-memory stub (`app_database_web.dart`) FFI cannot compile
import 'package:drift/drift.dart'; export 'app_database_native.dart' if (dart.library.html) 'app_database_web.dart';
import 'package:drift/native.dart';
import 'package:flutter/services.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:sqlcipher_flutter_libs/sqlcipher_flutter_libs.dart';
import 'package:sqlite3/open.dart';
import 'tables.dart';
part 'app_database.g.dart';
@DriftDatabase(tables: [ToneSamples, LocalKv])
class AppDatabase extends _$AppDatabase {
AppDatabase(super.e);
@override
int get schemaVersion => 1;
/// In-memory DB for unit tests (no SQLCipher / filesystem).
factory AppDatabase.memory() => AppDatabase(NativeDatabase.memory());
/// Production opener: encrypted on-device file (tech-design.md §8).
static Future<AppDatabase> open() async {
final db = AppDatabase(_openEncryptedExecutor());
// Force open so missing SQLCipher SO fails here (caller can fall back).
await db.customSelect('SELECT 1').get();
return db;
}
Future<List<String>> loadToneSamples() async {
final rows = await (select(toneSamples)..orderBy([(t) => OrderingTerm.asc(t.sortOrder)])).get();
return rows.map((r) => r.sampleText).toList();
}
Future<void> replaceToneSamples(List<String> samples) async {
final now = DateTime.now().millisecondsSinceEpoch;
await transaction(() async {
await delete(toneSamples).go();
for (var i = 0; i < samples.length; i++) {
await into(toneSamples).insert(
ToneSamplesCompanion.insert(
id: 'tone_${i}_$now',
sampleText: samples[i],
createdAtMs: now,
sortOrder: i,
),
);
}
});
}
Future<String?> getKv(String key) async {
final row = await (select(localKv)..where((t) => t.key.equals(key))).getSingleOrNull();
return row?.value;
}
Future<void> setKv(String key, String value) async {
await into(localKv).insertOnConflictUpdate(LocalKvCompanion.insert(key: key, value: value));
}
Future<bool> getBoolKv(String key, {bool defaultValue = false}) async {
final v = await getKv(key);
if (v == null) return defaultValue;
return v == '1' || v.toLowerCase() == 'true';
}
Future<void> setBoolKv(String key, bool value) async {
await setKv(key, value ? '1' : '0');
}
}
const _kDbPassphrase = 'db_passphrase_v1';
QueryExecutor _openEncryptedExecutor() {
return LazyDatabase(() async {
final dir = await getApplicationDocumentsDirectory();
final file = File(p.join(dir.path, 'bunsin_encrypted.db'));
final passphrase = await _loadOrCreatePassphrase();
// Background isolate does not inherit open.overrideFor re-apply there.
final token = RootIsolateToken.instance;
return NativeDatabase.createInBackground(
file,
isolateSetup: () async {
if (token != null) {
BackgroundIsolateBinaryMessenger.ensureInitialized(token);
}
await _configureSqlCipherOpen();
},
setup: (rawDb) {
// SQLCipher key must be set before any other statement.
final escaped = passphrase.replaceAll("'", "''");
rawDb.execute("PRAGMA key = '$escaped'");
rawDb.config.doubleQuotedStringLiterals = false;
},
);
});
}
Future<void> _configureSqlCipherOpen() async {
if (Platform.isAndroid) {
await applyWorkaroundToOpenSqlCipherOnOldAndroidVersions();
open.overrideFor(OperatingSystem.android, openCipherOnAndroid);
} else if (Platform.isLinux) {
// Desktop/dev: try SQLCipher SO; fall back is handled by open() failure upstream.
open.overrideFor(OperatingSystem.linux, () => DynamicLibrary.open('libsqlcipher.so'));
} else if (Platform.isWindows) {
open.overrideFor(OperatingSystem.windows, () => DynamicLibrary.open('sqlcipher.dll'));
}
// iOS/macOS: sqlcipher_flutter_libs links into the process.
}
Future<String> _loadOrCreatePassphrase() async {
const storage = FlutterSecureStorage();
final existing = await storage.read(key: _kDbPassphrase);
if (existing != null && existing.isNotEmpty) return existing;
final rand = Random.secure();
final bytes = List<int>.generate(32, (_) => rand.nextInt(256));
final passphrase = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
await storage.write(key: _kDbPassphrase, value: passphrase);
return passphrase;
}

View File

@ -1,6 +1,6 @@
// GENERATED CODE - DO NOT MODIFY BY HAND // GENERATED CODE - DO NOT MODIFY BY HAND
part of 'app_database.dart'; part of 'app_database_native.dart';
// ignore_for_file: type=lint // ignore_for_file: type=lint
class $ToneSamplesTable extends ToneSamples class $ToneSamplesTable extends ToneSamples

View File

@ -0,0 +1,131 @@
import 'dart:ffi';
import 'dart:io';
import 'dart:math';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:flutter/services.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:sqlcipher_flutter_libs/sqlcipher_flutter_libs.dart';
import 'package:sqlite3/open.dart';
import 'tables.dart';
part 'app_database.g.dart';
@DriftDatabase(tables: [ToneSamples, LocalKv])
class AppDatabase extends _$AppDatabase {
AppDatabase(super.e, {this.encrypted = false});
/// True when backed by on-device SQLCipher (not memory / web stub).
final bool encrypted;
@override
int get schemaVersion => 1;
/// In-memory DB for unit tests (no SQLCipher / filesystem).
factory AppDatabase.memory() => AppDatabase(NativeDatabase.memory(), encrypted: false);
/// Production opener: encrypted on-device file (tech-design.md §8).
static Future<AppDatabase> open() async {
final db = AppDatabase(_openEncryptedExecutor(), encrypted: true);
// Force open so missing SQLCipher SO fails here (caller can fall back).
await db.customSelect('SELECT 1').get();
return db;
}
Future<List<String>> loadToneSamples() async {
final rows = await (select(toneSamples)..orderBy([(t) => OrderingTerm.asc(t.sortOrder)])).get();
return rows.map((r) => r.sampleText).toList();
}
Future<void> replaceToneSamples(List<String> samples) async {
final now = DateTime.now().millisecondsSinceEpoch;
await transaction(() async {
await delete(toneSamples).go();
for (var i = 0; i < samples.length; i++) {
await into(toneSamples).insert(
ToneSamplesCompanion.insert(
id: 'tone_${i}_$now',
sampleText: samples[i],
createdAtMs: now,
sortOrder: i,
),
);
}
});
}
Future<String?> getKv(String key) async {
final row = await (select(localKv)..where((t) => t.key.equals(key))).getSingleOrNull();
return row?.value;
}
Future<void> setKv(String key, String value) async {
await into(localKv).insertOnConflictUpdate(LocalKvCompanion.insert(key: key, value: value));
}
Future<bool> getBoolKv(String key, {bool defaultValue = false}) async {
final v = await getKv(key);
if (v == null) return defaultValue;
return v == '1' || v.toLowerCase() == 'true';
}
Future<void> setBoolKv(String key, bool value) async {
await setKv(key, value ? '1' : '0');
}
}
const _kDbPassphrase = 'db_passphrase_v1';
QueryExecutor _openEncryptedExecutor() {
return LazyDatabase(() async {
final dir = await getApplicationDocumentsDirectory();
final file = File(p.join(dir.path, 'bunsin_encrypted.db'));
final passphrase = await _loadOrCreatePassphrase();
// Background isolate does not inherit open.overrideFor re-apply there.
final token = RootIsolateToken.instance;
return NativeDatabase.createInBackground(
file,
isolateSetup: () async {
if (token != null) {
BackgroundIsolateBinaryMessenger.ensureInitialized(token);
}
await _configureSqlCipherOpen();
},
setup: (rawDb) {
// SQLCipher key must be set before any other statement.
final escaped = passphrase.replaceAll("'", "''");
rawDb.execute("PRAGMA key = '$escaped'");
rawDb.config.doubleQuotedStringLiterals = false;
},
);
});
}
Future<void> _configureSqlCipherOpen() async {
if (Platform.isAndroid) {
await applyWorkaroundToOpenSqlCipherOnOldAndroidVersions();
open.overrideFor(OperatingSystem.android, openCipherOnAndroid);
} else if (Platform.isLinux) {
// Desktop/dev: try SQLCipher SO; fall back is handled by open() failure upstream.
open.overrideFor(OperatingSystem.linux, () => DynamicLibrary.open('libsqlcipher.so'));
} else if (Platform.isWindows) {
open.overrideFor(OperatingSystem.windows, () => DynamicLibrary.open('sqlcipher.dll'));
}
// iOS/macOS: sqlcipher_flutter_libs links into the process.
}
Future<String> _loadOrCreatePassphrase() async {
const storage = FlutterSecureStorage();
final existing = await storage.read(key: _kDbPassphrase);
if (existing != null && existing.isNotEmpty) return existing;
final rand = Random.secure();
final bytes = List<int>.generate(32, (_) => rand.nextInt(256));
final passphrase = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
await storage.write(key: _kDbPassphrase, value: passphrase);
return passphrase;
}

View File

@ -0,0 +1,42 @@
/// Web/Chrome stub for [AppDatabase].
///
/// SQLCipher / dart:ffi cannot compile for Flutter Web. Chrome runs use an
/// in-tab memory map so UI + API flows still work. Persistence is not encrypted
/// and is lost on refresh (web is a preview surface, not the release target).
class AppDatabase {
AppDatabase({this.encrypted = false});
/// Always false on web there is no SQLCipher path.
final bool encrypted;
final Map<String, String> _kv = {};
List<String> _toneSamples = [];
factory AppDatabase.memory() => AppDatabase(encrypted: false);
static Future<AppDatabase> open() async => AppDatabase(encrypted: false);
Future<List<String>> loadToneSamples() async => List<String>.from(_toneSamples);
Future<void> replaceToneSamples(List<String> samples) async {
_toneSamples = List<String>.from(samples);
}
Future<String?> getKv(String key) async => _kv[key];
Future<void> setKv(String key, String value) async {
_kv[key] = value;
}
Future<bool> getBoolKv(String key, {bool defaultValue = false}) async {
final v = await getKv(key);
if (v == null) return defaultValue;
return v == '1' || v.toLowerCase() == 'true';
}
Future<void> setBoolKv(String key, bool value) async {
await setKv(key, value ? '1' : '0');
}
Future<void> close() async {}
}

View File

@ -32,7 +32,9 @@ class DataFlowScreen extends StatelessWidget {
subtitle: Text( subtitle: Text(
[ [
samples.isEmpty ? '(아직 없음 — 말투 샘플 화면에서 추가)' : samples.take(3).join(' · '), samples.isEmpty ? '(아직 없음 — 말투 샘플 화면에서 추가)' : samples.take(3).join(' · '),
session.localDbEncrypted ? '저장: drift + SQLCipher(암호화)' : '저장: 메모리 폴백(이 환경에 SQLCipher 없음)', session.localDbEncrypted
? '저장: drift + SQLCipher(암호화)'
: '저장: 메모리/웹 스텁(이 환경에 SQLCipher 없음 — Chrome·Linux 폴백)',
].join('\n'), ].join('\n'),
), ),
isThreeLine: true, isThreeLine: true,

View File

@ -60,11 +60,12 @@ class SessionState extends ChangeNotifier {
Future<AppDatabase> _openDb() async { Future<AppDatabase> _openDb() async {
try { try {
final db = await AppDatabase.open(); final db = await AppDatabase.open();
localDbEncrypted = true; localDbEncrypted = db.encrypted;
return db; return db;
} catch (e) { } catch (e) {
// Linux CI / hosts without libsqlcipher.so fall back to memory so the // Linux CI / hosts without libsqlcipher.so fall back to memory so the
// app still boots; Android release path uses SQLCipher. // app still boots; Android release path uses SQLCipher.
// Web uses an in-memory stub via conditional import (no FFI).
debugPrint('Encrypted DB unavailable ($e); using in-memory fallback'); debugPrint('Encrypted DB unavailable ($e); using in-memory fallback');
localDbEncrypted = false; localDbEncrypted = false;
return AppDatabase.memory(); return AppDatabase.memory();

BIN
mobile/web/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 917 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

38
mobile/web/index.html Normal file
View File

@ -0,0 +1,38 @@
<!DOCTYPE html>
<html>
<head>
<!--
If you are serving your web app in a path other than the root, change the
href value below to reflect the base path you are serving from.
The path provided below has to start and end with a slash "/" in order for
it to work correctly.
For more details:
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
This is a placeholder for base href that will be replaced by the value of
the `--base-href` argument provided to `flutter build`.
-->
<base href="$FLUTTER_BASE_HREF">
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="A new Flutter project.">
<!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="bunsin_mobile">
<link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/>
<title>bunsin_mobile</title>
<link rel="manifest" href="manifest.json">
</head>
<body>
<script src="flutter_bootstrap.js" async></script>
</body>
</html>

35
mobile/web/manifest.json Normal file
View File

@ -0,0 +1,35 @@
{
"name": "bunsin_mobile",
"short_name": "bunsin_mobile",
"start_url": ".",
"display": "standalone",
"background_color": "#0175C2",
"theme_color": "#0175C2",
"description": "A new Flutter project.",
"orientation": "portrait-primary",
"prefer_related_applications": false,
"icons": [
{
"src": "icons/Icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icons/Icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "icons/Icon-maskable-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "icons/Icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}