fix Flutter Web/Chrome compile by stubbing SQLCipher DB
dart:ffi and SQLCipher cannot compile for Chrome. Split AppDatabase into native (Drift+SQLCipher) and web in-memory stub via conditional export, and enable the web platform so flutter run -d chrome works for UI preview. Co-authored-by: okuma <o0kuma@users.noreply.github.com>
This commit is contained in:
parent
86f65d27ce
commit
43aaa31f3c
|
|
@ -15,7 +15,7 @@ migration:
|
|||
- platform: root
|
||||
create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323
|
||||
base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323
|
||||
- platform: android
|
||||
- platform: web
|
||||
create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323
|
||||
base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323
|
||||
|
||||
|
|
|
|||
|
|
@ -1,128 +1,5 @@
|
|||
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);
|
||||
|
||||
@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;
|
||||
}
|
||||
/// Platform-specific local database entrypoint.
|
||||
///
|
||||
/// - IO (Android / iOS / Linux / …): Drift + SQLCipher (`app_database_native.dart`)
|
||||
/// - Web (Chrome): in-memory stub (`app_database_web.dart`) — FFI cannot compile
|
||||
export 'app_database_native.dart' if (dart.library.html) 'app_database_web.dart';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'app_database.dart';
|
||||
part of 'app_database_native.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
class $ToneSamplesTable extends ToneSamples
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
}
|
||||
|
|
@ -32,7 +32,9 @@ class DataFlowScreen extends StatelessWidget {
|
|||
subtitle: Text(
|
||||
[
|
||||
samples.isEmpty ? '(아직 없음 — 말투 샘플 화면에서 추가)' : samples.take(3).join(' · '),
|
||||
session.localDbEncrypted ? '저장: drift + SQLCipher(암호화)' : '저장: 메모리 폴백(이 환경에 SQLCipher 없음)',
|
||||
session.localDbEncrypted
|
||||
? '저장: drift + SQLCipher(암호화)'
|
||||
: '저장: 메모리/웹 스텁(이 환경에 SQLCipher 없음 — Chrome·Linux 폴백)',
|
||||
].join('\n'),
|
||||
),
|
||||
isThreeLine: true,
|
||||
|
|
|
|||
|
|
@ -60,11 +60,12 @@ class SessionState extends ChangeNotifier {
|
|||
Future<AppDatabase> _openDb() async {
|
||||
try {
|
||||
final db = await AppDatabase.open();
|
||||
localDbEncrypted = true;
|
||||
localDbEncrypted = db.encrypted;
|
||||
return db;
|
||||
} catch (e) {
|
||||
// Linux CI / hosts without libsqlcipher.so — fall back to memory so the
|
||||
// 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');
|
||||
localDbEncrypted = false;
|
||||
return AppDatabase.memory();
|
||||
|
|
|
|||
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 |
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue