From 6cdf922c52773a476cf2eab786630fa6b3fc8e83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Wed, 19 Aug 2026 01:06:18 +0800 Subject: [PATCH 01/16] feat(store): wire in package:sqlite3, bundled through build hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the dependency and picks the SQLite variant, ahead of the stores moving onto it. Nothing reads it yet. `sqlite3mc` rather than `sqlcipher`: both encrypt the whole file, keys and indexes included, but SQLCipher links OpenSSL on Windows, Linux and Android, where SQLite3MultipleCiphers carries its cipher implementations in its own source and needs nothing installed on any of the five platforms. `sqlite3` is listed here as well as in fl_lib because only the root package can set `hooks.user_defines`, and the Hive migration will open the database from here too. The test asserts `sqlite3mc_version()` resolves, which is what proves the user-define took effect rather than a plain SQLite being linked. TODOS.md: the Hive and sbm_ffi sections both carried facts that no longer hold. sqlcipher_flutter_libs is discontinued and points at sqlite3 3.x; build hooks are stable as of Flutter 3.38 / Dart 3.10, so "native assets is still experimental" was the reason sbm_ffi was left on CocoaPods and it is no longer a reason. The CocoaPods fallback also has two pods in it, not one — flutter_pty ships no Package.swift either. --- TODOS.md | 157 ++++++++++++++------ packages/fl_lib | 2 +- pubspec.lock | 16 +++ pubspec.yaml | 17 +++ test/sqlite_store_test.dart | 279 ++++++++++++++++++++++++++++++++++++ 5 files changed, 423 insertions(+), 48 deletions(-) create mode 100644 test/sqlite_store_test.dart diff --git a/TODOS.md b/TODOS.md index a9e34a1e7..ed884fb17 100644 --- a/TODOS.md +++ b/TODOS.md @@ -106,13 +106,16 @@ relay 服务,而不是(或者除了)在本地直接对外提供面板访问;面 决定放哪个 crate/仓库、怎么部署、TLS/域名怎么搞,这和现在"每个 agent 一个 单体二进制"的假设不一样,是本仓库目前唯一的例外 -## sbm_ffi:Swift Package Manager 支持 +## sbm_ffi:脱离 CocoaPods,改走 Dart build hooks -Flutter 3.44 起 SPM 是默认路径,`flutter build ios/macos` 会对 `sbm_ffi` 报 -"The following plugins do not support Swift Package Manager"。项目里其他 13 个 -plugin 都已走 SPM(见生成的 `FlutterGeneratedPluginSwiftPackage/Package.swift`), -只剩 `sbm_ffi` 靠 CocoaPods —— `ios/Podfile.lock` 和 `macos/Podfile.lock` 里都只有 -它一个 pod。混合模式在 3.44 下可用,警告不阻塞构建,但 Flutter 声明未来会变成 error。 +Flutter 3.44 起 SPM 是默认路径。SPM 本身已经生效——13 个 plugin 走的是生成的 +`{ios,macos}/Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage/Package.swift` +——剩下的走 CocoaPods 回退。**回退里有两个 pod,不是一个**:`sbm_ffi` 和 +`flutter_pty`(`~/.pub-cache/hosted/pub.dev/flutter_pty-0.4.2` 只有 podspec, +没有 `Package.swift`)。两个 `Podfile.lock` 的 `DEPENDENCIES` 都列着这两个。 + +时限不是 Flutter 定的:CocoaPods trunk 于 **2026-12-02 永久只读**,Flutter 声明 +回退会在那之后移除,具体日期未定。 卡点:`crates/sbm_ffi/{ios,macos}/sbm_ffi.podspec` 靠 CocoaPods 的 `script_phase` 调 `cargokit/build_pod.sh` 编 Rust,再用 `-force_load` 链接 `libsbm_ffi.a`。 @@ -125,17 +128,31 @@ plugin 都已走 SPM(见生成的 `FlutterGeneratedPluginSwiftPackage/Package.sw - cargokit 上游已于 2026-03-26 归档,irondash/cargokit#106 提了这件事,无人跟进 - flutter_rust_bridge 的 SPM PR(fzyzcjy/flutter_rust_bridge#3315)未合并 -两条候选路线(都还没做): -- **预编译 xcframework**:给 `crates/sbm_ffi/{ios,macos}/sbm_ffi/` 写 `Package.swift`, - 用 `.binaryTarget` 指向预先构建好的 `sbm_ffi.xcframework`,cargo 编译移到 - Makefile / fl_build 的 pre-build 步骤,绕开 sandbox。代价:在 Xcode 里直接 Run - 不会重编 Rust,容易用到旧产物;要额外维护打包脚本和 `-force_load` 处理 -- **Dart native assets**:删掉 cargokit,改用 `hook/build.dart` + - `native_toolchain_rust`,五个平台统一,不再需要 podspec 或 Package.swift。代价: - Flutter native assets 仍是实验特性(`--enable-native-assets`),FRB 侧 - `ExternalLibrary` 的加载方式(`frb_generated.dart` 的 `stem`/`ioDirectory`)要改 - -现状决定:先维持混合模式,等 FRB #3315 合并或 native assets 转正再动。 +**结论改为走 Dart build hooks**,不再等 SPM。原先记的"native assets 仍是实验 +特性(`--enable-native-assets`)"已不成立:build hooks 自 **Flutter 3.38 / +Dart 3.10** 起 stable,无需任何 flag,本项目在 Flutter 3.47 / Dart 3.13。走 hooks +意味着 sbm_ffi 既不需要 podspec 也不需要 `Package.swift`,五个平台统一, +CocoaPods 那条时限对它不再适用。 + +做法:`flutter_rust_bridge_codegen integrate --integration-backend native-assets` +生成 `crates/sbm_ffi/hook/build.dart`(经 `flutter_rust_bridge_hooks` 调 +`native_toolchain_rust`),删掉 cargokit 的 5 个接入点(`{ios,macos}` podspec、 +`{linux,windows}/CMakeLists.txt`、`android/build.gradle`)和 `cargokit/` 目录, +`pubspec.yaml` 的五平台 `ffiPlugin: true` 声明随之去掉。另需新增 +`rust-toolchain.toml`(该后端要求钉具体 toolchain + targets,仓库现在没有); +`crate-type = ["cdylib", "staticlib"]` 已经是对的。 + +**未解的两件事:** +- **后端只有 beta。** 要求 `flutter_rust_bridge_codegen >= 2.13.0-beta.2`,而 + 2.13.0 至今无 stable(最新 `2.13.0-beta.6`),stable 仍是项目现钉的 2.12.0 + (`crates/sbm_ffi/Cargo.toml` 与 `pubspec.yaml` 两处)。要么接受 beta,要么等, + 而 12-02 在前面 +- **`flutter_pty` 不在这条路上。** 它是普通 Flutter plugin 不是 FFI plugin, + hooks 解决不了。sbm_ffi 迁完 Podfile 仍删不掉,除非上游支持 SPM 或本地 fork + +被这条替代的旧路线(留作记录):预编译 xcframework + `.binaryTarget`。代价是在 +Xcode 里直接 Run 不会重编 Rust,容易用到旧产物,还要维护打包脚本和 `-force_load`。 +hooks 路线没有这些问题。 ## monitor:cpu_core_metrics / velocity_metrics 是只写表 @@ -308,54 +325,100 @@ provider、backup、SFTP 页面、server 编辑页全被卷进去了。 上一版的改动摊到 34 个文件,主要就是这一条带出来的。 - **K-V blob → 关系化 schema**:不必须,而且只有少数 store 值得。 -第一阶段只动引擎,`Store` 接口不变,上层零改动: +第一阶段只动引擎,`Store` 接口不变: ``` -kv(store TEXT, key TEXT, value BLOB, updated_at INTEGER, PRIMARY KEY(store, key)) +kv(store TEXT, key TEXT, value TEXT /* JSON */, updated_at INTEGER, PRIMARY KEY(store, key)) ``` `HiveStore` 换成 `SqliteStore`,`get`/`set`/`keys`/`clear`/`lastUpdateTs` 一一对应; `box.watch()` 换成自己发的 per-key 通知,`HivePropListenable` 那套 `_BoxListenerManager` -逻辑可以整个搬过去。providers、backup、UI 一行不用改。 +逻辑可以整个搬过去。 + +两条原先没算到的成本: + +- **`SqliteStore` 必须写在 fl_lib 里。** `Store` 是 `sealed class` + (`packages/fl_lib/lib/src/core/store/iface.dart`),sealed 限制子类与基类同一 + library;`hive.dart` / `pref.dart` / `mock.dart` 都是 `part of 'iface.dart'`。 + 所以这是跨仓库改动,不是 app 内部的事。 +- **"上层零改动"只对 `Store` 的调用方成立,对直接摸 `box` 的地方不成立。** + `rg -n '\bbox\.' lib/` = 75 处 / 18 文件。其中 `data/model/app/bak/backup.dart` + 独占 32 处,全是为了绕开 `lastUpdateTs` 而直接 `box.put/keys/delete/putAll` + ——这些改用已有的 `updateLastUpdateTsOnSet: false` 参数即可,不需要 box。 + store 外部另有 5 处:`core/service/watch_sync.dart`(`box.watch()`)、 + `view/page/server/edit/actions.dart`(`box.keys`)、 + `view/page/setting/entries/app.dart`(备份加密,同 backup.dart)、 + `data/store/migrations/m002_nest_ssh.dart`。 + +序列化随之从 TypeAdapter 二进制换成 JSON,17 个 adapter 全部消失 +(`lib/hive/` 整个目录 + `hive_ce*` 依赖)。覆盖情况已逐个核对:9 个类都有 +`fromJson`(freezed 模型自带 `toJson`),7 个是 enum(按 name 存),setting 里的 +virt keys 本来就存 `List`,`agent_conversation` 本来就存 JSON Map。 第二阶段只关系化真正需要 SQL 的: -- **`connection_stats`**:现在为了做时间窗口查询,额外开了一个不加密的 `conn_stats_index` - 盒子,外加手写的 `_rebuildIndexCore` / `_compactIfNeeded` / 每服务器 100 条上限。这一整套 - 在 SQL 里是一条 `DELETE WHERE timestamp < ?` 加一个索引。 -- **`agent_conversation`**:同理。 -- `setting` / `server` / `snippet` / `key` / `history` 留在 K-V。它们都在 10KB 以下, - 关系化只换来迁移工作量——上一版的 diff 就是证据。 - -### 加密:两条路 +- **`connection_stats`**:现在为了做时间窗口查询,额外开了一个 `conn_stats_index` + 盒子,外加手写的 `_rebuildIndexCore` / `_updateIndex` / `_pruneExcessRecords` / + `_compactIfNeeded` / 每服务器 100 条上限。这一整套在 SQL 里是一条 + `DELETE WHERE timestamp < ?` 加一个索引。 + **那个索引盒子是未加密的**——`connection_stats.dart` 开它时没传 `encryptionCipher`, + 本机实测 114 KB 明文存着 110 条 `_<毫秒时间戳>`,比它索引的那个加密盒子 + 还大。这是目前唯一一处现存的泄露,关系化之后随表消失(整库加密,索引也在库内)。 +- **`agent_conversation`**:同理。`fetchForServer` 现在是全表扫加内存排序。 +- `setting` / `server` / `snippet` / `key` / `history` / `docker` / `port_forward` + 留在 K-V。它们都在 10KB 以下,关系化只换来迁移工作量——上一版的 diff 就是证据。 + +### 加密:`package:sqlite3` 3.x + build hooks + +**`sqlcipher_flutter_libs` 这条路没了。** 它已废弃(最后版本 `0.7.0+eol`,0.7.0 起 +不再提供任何功能),README 要求改用 `package:sqlite3` 3.x。 + +替代方案比原方案好,原因是 CocoaPods:`package:sqlite3` 3.5.1 通过 **Dart build +hooks** 打包 SQLite,不是 Flutter plugin,既不产生 podspec 也不需要 `Package.swift`。 +配置就是 pubspec 里一段: + +```yaml +hooks: + user_defines: + sqlite3: + source: sqlite3mc +``` -两条都是 SQLCipher(整库加密,包括 key 和索引)。 +**选 `sqlite3mc` 而不是 `sqlcipher`。** 两者都是整库加密(包括 key 和索引),差别在 +依赖:`sqlcipher` 在 Windows / Linux / Android 链接 OpenSSL,CI 要装系统依赖; +SQLite3MultipleCiphers 的 cipher 实现内置于源码("There is no direct dependency on +external projects"),而且它的 cipher 列表里就有一个 SQLCipher 兼容的 AES-256 方案, +日后要互操作可以切过去。新库没有历史包袱,默认方案即可。 -- **`sqlcipher_flutter_libs` + `package:sqlite3`**:五个平台都支持;Linux 要 `libssl-dev`、 - Windows 要 `choco install openssl`(CI 要改);iOS/macOS 装 SQLCipher pod,README 明确写 - 「依赖任何链接普通 sqlite3 的包都会出事」——目前 `pubspec.lock` 里干净,这条不成立,但 - 以后加依赖时要盯着。同步调用是原生的。 -- **Rust `rusqlite` + FRB**:`libsqlite3-sys` 的 `bundled-sqlcipher-vendored-openssl` 把 - SQLCipher 和 OpenSSL 一起编进去,无系统依赖、无 pod,走现有的 cargokit 出五个平台的产物; - 同步调用靠 `#[frb(sync)]`。代价:每次 K-V 操作过一次 FFI;而且 CLAUDE.md 写的 FFI 边界 - 原则是「不持有可变状态」,一个数据库连接正好是可变状态,要么破例要么专门论证。 - 注意 Windows 上必须用 vendored-openssl 那个 feature,`bundled-sqlcipher` 单独用只在 Unix - 上成立。 +Rust `rusqlite` + FRB 那条路不再考虑:它要先等 sbm_ffi 迁完 build hooks 才能脱离 +CocoaPods(见上面那节),而且 CLAUDE.md 写的 FFI 边界原则是「不持有可变状态」, +一个数据库连接正好是可变状态。 -**倾向第一条**,而且不要 drift。不用 drift 的理由就是上面第二个决定:它是异步优先的, -而异步化 `Store` 正是上一版把改动摊开的原因。 +不用 drift,理由是上面第二个决定:drift 是异步优先的,而异步化 `Store` 正是上一版 +把改动摊到 34 个文件的原因。 -### 两个具体的点 +### 三个具体的点 -**密钥要用 raw key,不是 passphrase。** 上一版是 `PRAGMA key = '$escapedKey'`,SQLCipher 会 -把它当口令做 PBKDF2 派生(默认 256000 轮)。而 `SecureStoreProps.hivePwd` 里存的本来就是 +**密钥要用 raw key,不是 passphrase。** 上一版是 `PRAGMA key = '$escapedKey'`,会被 +当口令做 PBKDF2 派生(默认 256000 轮)。而 `SecureStoreProps.hivePwd` 里存的本来就是 `Hive.generateSecureKey()` 产生的 32 字节随机密钥,直接用 `PRAGMA key = "x'<64位hex>'"` 跳过派生,既快也没有降低强度。 **迁移的形状。** 每个盒子一次,`Stores.init` 之前跑,用现有 cipher 打开 `*_enc.hive` -逐 key 写进 `kv` 表;成功后**保留** `.hive` 文件若干版本再删,理由和 `SandboxImport` 一样 -——复制过的原件留着,出问题可回退,并且加 TODO 标记删除时机。`BackupV2` 是 JSON, -和存储引擎无关,不受影响。 +逐 key 转 JSON 写进 `kv` 表;成功后**保留** `.hive` 文件若干版本再删,理由和 +`SandboxImport` 一样——复制过的原件留着,出问题可回退,并且加 TODO 标记删除时机。 +`BackupV2` 是 JSON,和存储引擎无关,不受影响。 + +**迁移有顺序约束。** 新增 `SchemaVersion` v4 + `m003_hive_to_sqlite`。但 v2→v3 的 +`SpiNestSshMigration` 依赖 `SpiLegacyAdapter` 和 Hive 的 typeId 解码(它直接读 +`store.box`),所以 **m003 必须在 Hive 侧走完 v3 之后跑,m003 落地前不能删 Hive +依赖**。实际次序:保留 Hive 只读能力 → m002 → m003 → 之后的版本里删 Hive。 + +### 测试侧 + +19 个测试文件引用 Hive。各 store 的 `forBox(Box testBox)` 测试构造器换成 +`sqlite3.openInMemory()`。这比 CLAUDE.md 里记的 Hive `bytes: Uint8List(0)` 干净, +而且不会踩 fake-async 下写锁不释放、`flutter test` 整体挂死那个坑。 ### 顺带要清的残留 diff --git a/packages/fl_lib b/packages/fl_lib index 9894975a9..28dbd5424 160000 --- a/packages/fl_lib +++ b/packages/fl_lib @@ -1 +1 @@ -Subproject commit 9894975a91576820cdd6299fb27a8aa72e6c7e7a +Subproject commit 28dbd54247f3862c09b51e772680f4f2291deb06 diff --git a/pubspec.lock b/pubspec.lock index ac6746786..8b918a690 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1074,6 +1074,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.6.2" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: a1c26117c48cebe5677b0cf0e33a980a79a7c5577effc86f52e5a0d309cdcb60 + url: "https://pub.dev" + source: hosted + version: "0.19.3" nested: dependency: transitive description: @@ -1582,6 +1590,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.2" + sqlite3: + dependency: "direct main" + description: + name: sqlite3 + sha256: "64b2c63c8232dd20d14b34105a81ebfd74320442e8451f836179ec89986aa478" + url: "https://pub.dev" + source: hosted + version: "3.5.1" stack_trace: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 381d28054..b47724018 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,6 +38,10 @@ dependencies: responsive_framework: ^1.5.1 riverpod: ^3.2.1 riverpod_annotation: ^4.0.2 + # Direct, though `SqliteStore` lives in fl_lib: `hooks.user_defines` below is + # only honoured for this package's own dependencies, and the Hive migration + # opens the database from here too. + sqlite3: ^3.5.1 wakelock_plus: ^1.7.0 wake_on_lan: ^4.1.1+3 webdav_client_plus: ^2.0.0 @@ -83,6 +87,19 @@ dev_dependencies: fl_build: path: packages/fl_build +# Which SQLite gets compiled into the app. Only the root package can set this — +# a dependency cannot supply its own defaults — so it lives here even though +# every caller is in fl_lib. +# +# `sqlite3mc` rather than `sqlcipher`: both encrypt the whole file, keys and +# indexes included, but SQLCipher links OpenSSL on Windows, Linux and Android, +# where SQLite3MultipleCiphers carries its cipher implementations in its own +# source and needs nothing installed on any of the five platforms. +hooks: + user_defines: + sqlite3: + source: sqlite3mc + flutter: generate: true uses-material-design: true diff --git a/test/sqlite_store_test.dart b/test/sqlite_store_test.dart new file mode 100644 index 000000000..b49613d10 --- /dev/null +++ b/test/sqlite_store_test.dart @@ -0,0 +1,279 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:fl_lib/fl_lib.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqlite3/sqlite3.dart'; + +/// Covers the two halves of [SqliteStore] separately. +/// +/// The store's own behaviour runs against an in-memory database, because the +/// keyed path needs the platform vault and a test that reaches the keychain +/// cannot run on a bare CI machine. The keying itself is exercised directly +/// against a temp file with a fixed key, which is the part that has to be right +/// before any real data is written through it. +void main() { + group('sqlite3mc is what got compiled in', () { + test('the multiple-ciphers build reports itself', () { + final db = sqlite3.openInMemory(); + addTearDown(db.close); + + // Absent from a plain SQLite build, so this failing means + // `hooks.user_defines.sqlite3.source` in pubspec.yaml did not take. + final rows = db.select('SELECT sqlite3mc_version() AS v;'); + expect(rows.single['v'], isA()); + }); + }); + + group('encryption', () { + late Directory dir; + + setUp(() => dir = Directory.systemTemp.createTempSync('sbm_sqlite_test')); + tearDown(() => dir.deleteSync(recursive: true)); + + String hex(Uint8List b) => + b.map((e) => e.toRadixString(16).padLeft(2, '0')).join(); + + Uint8List randomKey() => Uint8List.fromList( + List.generate(32, (_) => Random.secure().nextInt(256)), + ); + + void key(Database db, Uint8List k) { + db.execute("PRAGMA cipher = 'chacha20';"); + db.execute('PRAGMA key = "x\'${hex(k)}\'";'); + } + + test('a raw key round-trips, and the file is not plaintext', () { + final path = '${dir.path}/keyed.db'; + final k = randomKey(); + + final db = sqlite3.open(path); + key(db, k); + db.execute('CREATE TABLE t (v TEXT);'); + db.execute("INSERT INTO t VALUES ('a-recognisable-secret');"); + db.close(); + + final reopened = sqlite3.open(path); + key(reopened, k); + expect(reopened.select('SELECT v FROM t;').single['v'], + 'a-recognisable-secret'); + reopened.close(); + + // The point of the whole exercise: neither the value nor the schema is + // readable in the file. Hive only ever encrypted values, which is what + // made `conn_stats_index.hive` legible. + final bytes = File(path).readAsBytesSync(); + expect(String.fromCharCodes(bytes), isNot(contains('a-recognisable-secret'))); + expect(String.fromCharCodes(bytes), isNot(contains('CREATE TABLE'))); + }); + + test('the wrong key does not open the file', () { + final path = '${dir.path}/keyed.db'; + + final db = sqlite3.open(path); + key(db, randomKey()); + db.execute('CREATE TABLE t (v TEXT);'); + db.close(); + + final reopened = sqlite3.open(path); + key(reopened, randomKey()); + // `PRAGMA key` itself succeeds; the first statement that reads a page is + // where it fails. `SqliteDb._open` runs exactly this for that reason. + expect( + () => reopened.select('SELECT count(*) FROM sqlite_master;'), + throwsA(isA()), + ); + reopened.close(); + }); + }); + + group('SqliteStore', () { + late SqliteStore store; + late SqliteStore other; + + setUp(() { + SqliteDb.openInMemory(); + store = SqliteStore('a'); + other = SqliteStore('b'); + }); + tearDown(SqliteDb.close); + + test('round-trips primitives, maps and lists', () { + store.set('s', 'txt'); + store.set('i', 42); + store.set('b', true); + store.set('m', {'x': 1}); + store.set('l', [1, 2, 3]); + + expect(store.get('s'), 'txt'); + expect(store.get('i'), 42); + expect(store.get('b'), true); + expect(store.get('m'), {'x': 1}); + expect(store.get('l'), [1, 2, 3]); + }); + + test('an absent key is null, not a throw', () { + expect(store.get('nope'), isNull); + }); + + test('stores with the same key do not see each other', () { + store.set('k', 'from-a'); + other.set('k', 'from-b'); + + expect(store.get('k'), 'from-a'); + expect(other.get('k'), 'from-b'); + }); + + test('keys() hides internal keys unless asked', () { + store.set('visible', 1); + // Written by `updateLastUpdateTs` on every set above. + expect(store.keys(), {'visible'}); + expect( + store.keys(includeInternalKeys: true), + containsAll(['visible', store.lastUpdateTsKey]), + ); + }); + + test('remove drops one key, clear drops the store only', () { + store.set('x', 1); + store.set('y', 2); + other.set('x', 3); + + store.remove('x'); + expect(store.get('x'), isNull); + expect(store.get('y'), 2); + + store.clear(); + expect(store.keys(), isEmpty); + expect(other.get('x'), 3); + }); + + test('clear keeps the last-update map', () { + store.set('x', 1); + final before = store.lastUpdateTs; + expect(before, isNotNull); + expect(before!['x'], isNotNull); + + store.clear(); + + // Not just non-null: `clear` used to put the map back in a shape the + // reader rejected, so the entries have to survive, not only the key. + final after = store.lastUpdateTs; + expect(after, isNotNull); + expect(after!['x'], isNotNull); + }); + + test('enums are stored by name, not index', () { + store.set('e', _Fruit.pear); + expect(store.get('e'), 'pear'); + expect( + store.get<_Fruit>('e', fromObj: (v) => _Fruit.values.byName(v as String)), + _Fruit.pear, + ); + }); + + test('an object is stored through toJson', () { + store.set('o', _Point(1, 2)); + expect(store.get('o'), {'x': 1, 'y': 2}); + }); + + test('a value that cannot be encoded fails loudly, not silently', () { + expect(store.set('bad', _NoJson()), isFalse); + expect(store.get('bad'), isNull); + }); + + test('fromObj converts when the stored shape is not T', () { + store.set('m', {'x': 1, 'y': 2}); + final p = store.get<_Point>( + 'm', + fromObj: (v) => _Point((v as Map)['x'] as int, v['y'] as int), + ); + expect(p?.x, 1); + expect(p?.y, 2); + }); + + test('setAll writes every entry', () { + expect(store.setAll({'a': 1, 'b': 2}), isTrue); + expect(store.get('a'), 1); + expect(store.get('b'), 2); + }); + + test('getAllMap returns the store contents without internal keys', () { + store.set('a', 1); + store.set('b', 'two'); + expect(store.getAllMap(), {'a': 1, 'b': 'two'}); + }); + + test('a property notifies its own listeners on write', () { + final prop = store.property('n'); + final listenable = prop.listenable(); + + var calls = 0; + void onChange() => calls++; + listenable.addListener(onChange); + addTearDown(() => listenable.removeListener(onChange)); + + prop.set(1); + expect(calls, 1); + expect(listenable.value, 1); + + // Another key on the same store must not wake this listener. + store.set('unrelated', 9); + expect(calls, 1); + + prop.remove(); + expect(calls, 2); + expect(listenable.value, isNull); + }); + + test('a default property reports its default until written', () { + final prop = store.propertyDefault('n', 7); + expect(prop.get(), 7); + prop.set(1); + expect(prop.get(), 1); + }); + + test('listProperty round-trips through JSON', () { + final prop = store.listProperty('l', defaultValue: const [1]); + expect(prop.get(), [1]); + prop.set([4, 5, 6]); + expect(prop.get(), [4, 5, 6]); + }); + + test('a removed listener stops being called', () { + final prop = store.property('n'); + final listenable = prop.listenable(); + + var calls = 0; + void onChange() => calls++; + listenable.addListener(onChange); + prop.set(1); + listenable.removeListener(onChange); + prop.set(2); + + expect(calls, 1); + }); + + test('the stored value really is JSON text', () { + store.set('m', {'x': 1}); + final raw = SqliteDb.instance.select( + 'SELECT value FROM kv WHERE store = ? AND key = ?;', + ['a', 'm'], + ).single['value'] as String; + expect(json.decode(raw), {'x': 1}); + }); + }); +} + +enum _Fruit { apple, pear } + +class _Point { + const _Point(this.x, this.y); + final int x; + final int y; + Map toJson() => {'x': x, 'y': y}; +} + +class _NoJson {} From 9c7aa139cda8e6f0920820e817ae4f605b223c79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Wed, 19 Aug 2026 01:24:37 +0800 Subject: [PATCH 02/16] migrate(store): move every store off Hive onto encrypted SQLite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hive encrypted values only — keys and box structure were plaintext in the file. The clearest case was `conn_stats_index`, opened with no cipher at all: 114 KB of `_` in the clear, next to the encrypted records it pointed at, and larger than them. It is now a store in the same keyed database as everything else, and the plaintext file is deleted on import. Only the engine changes. `Store` stays synchronous — `package:sqlite3` is, and the async-first rewrite is what spread the previous attempt at this across 34 files. Values are JSON, so nothing decodes through a TypeAdapter any more. `lib/hive/` and the `hive_ce*` dependencies stay for now because `HiveImport` needs them to read what is already on disk; nothing writes Hive. Enums are stored by name rather than index: an index silently changes meaning when a case is inserted, and these values outlive the build that wrote them. `HiveImport` is not a `SchemaMigration`. Those are keyed on a version that itself lives in a store, and on the launch that upgrades an install the SQLite side is empty and would report a fresh install's default. It runs inside `Stores.init` ahead of every fixup there, because each of those writes a flag meaning "this device has been dealt with" and setting one over data that has not arrived yet would leave the records that need converting arriving after the only pass that would have converted them. It also does what the v2 -> v3 step did, since a pre-v3 record only exists as a Hive value and this is the one pass that reads one — so `SpiNestSshMigration` is deleted and every install reaches SQLite at v4. The `.hive` files are kept, as `SandboxImport` keeps what it copied. `Backup.merge` had the same six-block diff written out per store; it is one helper now, which also stops it deleting the store's own `lastUpdateTs` on every non-forced merge — `getAllMap` leaves that key out of a backup, so the box-level version saw it as "absent upstream" and dropped it. `schemaVersion` no longer stamps `lastUpdateTs`. It describes this device's storage and is left out of backups, so a device that had only just upgraded was claiming the newer copy of everything at the next sync. Tests open `SqliteDb.openInMemory()` instead of a Hive box, which also retires the fake-async write-lock hazard those files each carried a comment about. --- TODOS.md | 21 ++ lib/core/service/watch_sync.dart | 2 +- lib/core/utils/server.dart | 2 +- lib/data/model/app/bak/backup.dart | 169 ++++--------- lib/data/provider/ai/agent_session.dart | 2 +- lib/data/res/store.dart | 14 +- lib/data/store/agent_conversation.dart | 25 +- lib/data/store/cached_store.dart | 148 +++++------ lib/data/store/connection_stats.dart | 171 ++++++------- lib/data/store/container.dart | 8 +- lib/data/store/history.dart | 67 ++--- lib/data/store/migrations/m002_nest_ssh.dart | 55 ---- .../store/migrations/m003_hive_to_sqlite.dart | 138 ++++++++++ lib/data/store/port_forward.dart | 33 +-- lib/data/store/private_key.dart | 26 +- lib/data/store/schema.dart | 9 +- lib/data/store/server.dart | 12 +- lib/data/store/setting.dart | 46 ++-- lib/data/store/snippet.dart | 12 +- lib/main.dart | 26 +- lib/view/page/private_key/list.dart | 2 +- lib/view/page/server/connection_stats.dart | 12 +- lib/view/page/server/edit/actions.dart | 2 +- lib/view/page/setting/entries/ai.dart | 4 +- lib/view/page/setting/entries/app.dart | 24 +- lib/view/page/setting/entries/editor.dart | 6 +- lib/view/page/setting/entry.dart | 3 +- packages/fl_lib | 2 +- test/agent_conversation_store_test.dart | 23 +- test/agent_shell_view_test.dart | 21 +- test/agent_view_test.dart | 22 +- test/app_locale_test.dart | 14 +- test/file_browser_test.dart | 9 +- test/file_tab_restore_test.dart | 26 +- test/file_transfer_test.dart | 20 +- test/hive_import_test.dart | 239 ++++++++++++++++++ test/identity_file_key_test.dart | 27 +- test/pane_width_test.dart | 20 +- test/server_card_gesture_test.dart | 20 +- test/server_func_btn_test.dart | 37 +-- test/setting_store_test.dart | 78 +++--- test/settings_menu_test.dart | 15 +- test/snippet_list_test.dart | 21 +- test/ssh_tab_restore_test.dart | 29 +-- test/terminal_clipboard_test.dart | 10 +- 45 files changed, 880 insertions(+), 792 deletions(-) delete mode 100644 lib/data/store/migrations/m002_nest_ssh.dart create mode 100644 lib/data/store/migrations/m003_hive_to_sqlite.dart create mode 100644 test/hive_import_test.dart diff --git a/TODOS.md b/TODOS.md index ed884fb17..2e4fa47d5 100644 --- a/TODOS.md +++ b/TODOS.md @@ -296,6 +296,27 @@ agent 的那条完整路径。 ## Hive → SQLite:分阶段做,以及加密怎么落 +**状态(2026-08-19):第一阶段已合入,第二阶段未做。** + +已做:`SqliteStore` 在 fl_lib(`store/sqlite.dart`),九个 store 全部换过去, +写入一律 JSON,75 处 `.box.` 已收敛,`HiveImport` 负责一次性导入并记 schema +v4。`conn_stats_index` 那个未加密的盒子已经进了加密库,明文文件在导入时删除。 + +未做: +- 第二阶段(把 `connection_stats` / `agent_conversation` 关系化)。现在两者仍是 + K-V,索引维护还是手写的。 +- **`lib/hive/`、`hive_ce*` 依赖、`main.dart` 里的 `Hive.initFlutter()` 都还在。** + 17 个 TypeAdapter 现在只用于一件事:让 `HiveImport` 读得懂旧盒子。没有任何 + 代码再往 Hive 写。这些要等到没有受支持的安装还停在 Hive 上才能删,`HiveImport` + 和 `SpiLegacyAdapter` 同批(代码里已标 TODO)。 + +**一处没有覆盖到的路径:v2 记录的导入。** `HiveImport` 会把 `LegacySpiV2` +转成 `Spi`(原 `SpiNestSshMigration` 做的事),但没有测试跑过它 —— +`SpiLegacyAdapter.write` 按设计抛异常,所以测试造不出 v2 的盒子,除非再注册 +一个只在测试里用的可写 adapter,而 Hive 按运行时类型解析写入,两个 adapter +认领同一个类型会让行为取决于注册顺序。覆盖面是「装了 v3 版本之前的包、之后 +一直没启动过」的安装。`test/hive_import_test.dart` 覆盖的是 v3 那条路。 + 想换的理由有三条:Hive 把整个盒子读进内存、compact 靠重写整个文件;它只加密 value, key 和盒子结构在文件里是明文;`hive_ce` 是社区接手的 fork。本机实测的盒子大小: `connection_stats_enc.hive` 227KB、`agent_conversation_enc.hive` 128KB,其余(setting / diff --git a/lib/core/service/watch_sync.dart b/lib/core/service/watch_sync.dart index b1b724f5e..481b60d2c 100644 --- a/lib/core/service/watch_sync.dart +++ b/lib/core/service/watch_sync.dart @@ -209,7 +209,7 @@ final class WatchSync { }); // A server's monitor address or password can change without the watch // selection changing, and the watch would keep using the stale copy. - _serverStoreSub = Stores.server.box.watch().listen((_) => _schedulePush()); + _serverStoreSub = Stores.server.watch().listen((_) => _schedulePush()); } void _schedulePush() { diff --git a/lib/core/utils/server.dart b/lib/core/utils/server.dart index 784816f4b..8bb66d0fb 100644 --- a/lib/core/utils/server.dart +++ b/lib/core/utils/server.dart @@ -343,7 +343,7 @@ List _resolveJumpCandidates({ for (final jumpId in spi.resolvedJumpIds) { final candidate = preloadedJumpSpi?.id == jumpId ? preloadedJumpSpi - : jumpSpisById?[jumpId] ?? Stores.server.box.get(jumpId); + : jumpSpisById?[jumpId] ?? Stores.server.fetchOneRaw(jumpId); if (candidate == null || candidates.any((e) => e.id == candidate.id)) { continue; } diff --git a/lib/data/model/app/bak/backup.dart b/lib/data/model/app/bak/backup.dart index 8759bc544..9248fdf1b 100644 --- a/lib/data/model/app/bak/backup.dart +++ b/lib/data/model/app/bak/backup.dart @@ -78,133 +78,19 @@ class Backup implements Mergeable { return; } - // Snippets - if (force) { - for (final s in snippets) { - Stores.snippet.box.put(s.name, s); - } - } else { - final nowSnippets = Stores.snippet.box.keys.toSet(); - final bakSnippets = snippets.map((e) => e.name).toSet(); - final newSnippets = bakSnippets.difference(nowSnippets); - final delSnippets = nowSnippets.difference(bakSnippets); - final updateSnippets = nowSnippets.intersection(bakSnippets); - for (final s in newSnippets) { - Stores.snippet.box.put(s, snippets.firstWhere((e) => e.name == s)); - } - for (final s in delSnippets) { - Stores.snippet.box.delete(s); - } - for (final s in updateSnippets) { - Stores.snippet.box.put(s, snippets.firstWhere((e) => e.name == s)); - } - } - - // ServerPrivateInfo - if (force) { - for (final s in spis) { - Stores.server.box.put(s.id, s); - } - } else { - final nowSpis = Stores.server.box.keys.toSet(); - final bakSpis = spis.map((e) => e.id).toSet(); - final newSpis = bakSpis.difference(nowSpis); - final delSpis = nowSpis.difference(bakSpis); - final updateSpis = nowSpis.intersection(bakSpis); - for (final s in newSpis) { - Stores.server.box.put(s, spis.firstWhere((e) => e.id == s)); - } - for (final s in delSpis) { - Stores.server.box.delete(s); - } - for (final s in updateSpis) { - Stores.server.box.put(s, spis.firstWhere((e) => e.id == s)); - } - } - - // PrivateKeyInfo - if (force) { - for (final s in keys) { - Stores.key.box.put(s.id, s); - } - } else { - final nowKeys = Stores.key.box.keys.toSet(); - final bakKeys = keys.map((e) => e.id).toSet(); - final newKeys = bakKeys.difference(nowKeys); - final delKeys = nowKeys.difference(bakKeys); - final updateKeys = nowKeys.intersection(bakKeys); - for (final s in newKeys) { - Stores.key.box.put(s, keys.firstWhere((e) => e.id == s)); - } - for (final s in delKeys) { - Stores.key.box.delete(s); - } - for (final s in updateKeys) { - Stores.key.box.put(s, keys.firstWhere((e) => e.id == s)); - } - } - - // History - if (force) { - Stores.history.box.putAll(history); - } else { - final nowHistory = Stores.history.box.keys.toSet(); - final bakHistory = history.keys.toSet(); - final newHistory = bakHistory.difference(nowHistory); - final delHistory = nowHistory.difference(bakHistory); - final updateHistory = nowHistory.intersection(bakHistory); - for (final s in newHistory) { - Stores.history.box.put(s, history[s]); - } - for (final s in delHistory) { - Stores.history.box.delete(s); - } - for (final s in updateHistory) { - Stores.history.box.put(s, history[s]); - } - } - - // Container - if (force) { - Stores.container.box.putAll(container); - } else { - final nowContainer = Stores.container.box.keys.toSet(); - final bakContainer = container.keys.toSet(); - final newContainer = bakContainer.difference(nowContainer); - final delContainer = nowContainer.difference(bakContainer); - final updateContainer = nowContainer.intersection(bakContainer); - for (final s in newContainer) { - Stores.container.box.put(s, container[s]); - } - for (final s in delContainer) { - Stores.container.box.delete(s); - } - for (final s in updateContainer) { - Stores.container.box.put(s, container[s]); - } - } + _restoreInto( + Stores.snippet, + {for (final s in snippets) s.name: s}, + force: force, + ); + _restoreInto(Stores.server, {for (final s in spis) s.id: s}, force: force); + _restoreInto(Stores.key, {for (final s in keys) s.id: s}, force: force); + _restoreInto(Stores.history, history, force: force); + _restoreInto(Stores.container, container, force: force); - // Settings final settings_ = settings; if (settings_ != null) { - if (force) { - Stores.setting.box.putAll(settings_); - } else { - final nowSettings = Stores.setting.box.keys.toSet(); - final bakSettings = settings_.keys.toSet(); - final newSettings = bakSettings.difference(nowSettings); - final delSettings = nowSettings.difference(bakSettings); - final updateSettings = nowSettings.intersection(bakSettings); - for (final s in newSettings) { - Stores.setting.box.put(s, settings_[s]); - } - for (final s in delSettings) { - Stores.setting.box.delete(s); - } - for (final s in updateSettings) { - Stores.setting.box.put(s, settings_[s]); - } - } + _restoreInto(Stores.setting, settings_, force: force); } Provider.reload(); @@ -217,6 +103,41 @@ class Backup implements Mergeable { Backup.fromJson(json.decode(_diyDecrypt(raw))); } +/// Writes one section of a backup into the store it came from. +/// +/// [force] replaces what is there; otherwise the store is also made to *stop* +/// holding whatever the backup does not, which is what makes a delete on one +/// device reach another. +/// +/// Nothing here stamps `lastUpdateTs`, which is what writing straight to the +/// Hive box used to achieve. A restore is not an edit: marking every restored +/// key as changed now would leave the merged copy looking newer than the backup +/// it came from, and the next sync would push it straight back out. +/// +/// Uses `keys()` rather than every key in the store, so the internal +/// `lastUpdateTs` entry is not one of the ones deleted for being absent from +/// the backup — `getAllMap` leaves it out of the backup by the same rule, so +/// the box-level version deleted it on every non-forced merge. +void _restoreInto( + SqliteStore store, + Map incoming, { + required bool force, +}) { + if (!force) { + for (final key in store.keys().difference(incoming.keys.toSet())) { + store.remove(key, updateLastUpdateTsOnRemove: false); + } + } + for (final entry in incoming.entries) { + final value = entry.value; + if (value == null) { + store.remove(entry.key, updateLastUpdateTsOnRemove: false); + continue; + } + store.set(entry.key, value, updateLastUpdateTsOnSet: false); + } +} + String _diyEncrypt(String raw) => json.encode(raw.codeUnits.map((e) => e * 2 + 1).toList(growable: false)); diff --git a/lib/data/provider/ai/agent_session.dart b/lib/data/provider/ai/agent_session.dart index 9a95b3f18..dd2e8308b 100644 --- a/lib/data/provider/ai/agent_session.dart +++ b/lib/data/provider/ai/agent_session.dart @@ -178,7 +178,7 @@ class AgentSession extends _$AgentSession { AgentSessionState build() { // Watches the box, so a write this class did not make — a restored backup // — is not missed. - _conversationWatch = Stores.agentConversation.box.watch().listen((_) { + _conversationWatch = Stores.agentConversation.watch().listen((_) { state = state.copyWith(conversations: _fetchConversations()); }); ref.onDispose(() { diff --git a/lib/data/res/store.dart b/lib/data/res/store.dart index ea50ba82f..f792bae92 100644 --- a/lib/data/res/store.dart +++ b/lib/data/res/store.dart @@ -4,6 +4,7 @@ import 'package:server_box/data/store/agent_conversation.dart'; import 'package:server_box/data/store/connection_stats.dart'; import 'package:server_box/data/store/container.dart'; import 'package:server_box/data/store/history.dart'; +import 'package:server_box/data/store/migrations/m003_hive_to_sqlite.dart'; import 'package:server_box/data/store/port_forward.dart'; import 'package:server_box/data/store/private_key.dart'; import 'package:server_box/data/store/server.dart'; @@ -27,7 +28,7 @@ abstract final class Stores { static PortForwardStore get portForward => getIt(); /// All stores that need backup - static List get _allBackup => [ + static List get _allBackup => [ setting, server, container, @@ -40,7 +41,7 @@ abstract final class Stores { /// Stores initialized locally. Agent conversations intentionally stay out of /// backup and sync because they may contain terminal output and reasoning. - static List get _allStores => [..._allBackup, agentConversation]; + static List get _allStores => [..._allBackup, agentConversation]; static Future init() async { getIt.registerLazySingleton(() => SettingStore.instance); @@ -63,13 +64,20 @@ abstract final class Stores { await Future.wait(_allStores.map((store) => store.init())); + // Before every fixup below. Each of them writes a flag meaning "this device + // has been dealt with", and running them against the empty stores would set + // those flags over data that has not been copied across yet — so the + // records that need converting would arrive after the only pass that would + // have converted them. + await HiveImport.runIfNeeded(); + await setting.removeRetiredKeys(); // Migrate sshConnectionMode from old int values to bool setting.migrateSshConnectionMode(); await setting.migrateHomeTabsAgent(); - if (connectionStats.indexDbKeys.isEmpty) { + if (connectionStats.indexKeys.isEmpty) { await connectionStats.rebuildIndexAndCompact(); } } diff --git a/lib/data/store/agent_conversation.dart b/lib/data/store/agent_conversation.dart index 925d000da..78448cdba 100644 --- a/lib/data/store/agent_conversation.dart +++ b/lib/data/store/agent_conversation.dart @@ -1,10 +1,9 @@ import 'package:fl_lib/fl_lib.dart'; -import 'package:hive_ce/hive.dart'; import 'package:meta/meta.dart'; import 'package:server_box/data/model/ai/agent_conversation.dart'; import 'package:server_box/data/model/ai/ask_ai_models.dart'; -class AgentConversationStore extends HiveStore { +class AgentConversationStore extends SqliteStore { AgentConversationStore._() : super( 'agent_conversation', @@ -14,15 +13,13 @@ class AgentConversationStore extends HiveStore { ); @visibleForTesting - AgentConversationStore.forBox(Box testBox) + AgentConversationStore.forTest() : super( 'agent_conversation_test', updateLastUpdateTsOnClear: false, updateLastUpdateTsOnRemove: false, updateLastUpdateTsOnSet: false, - ) { - box = testBox; - } + ); static final instance = AgentConversationStore._(); @@ -35,9 +32,9 @@ class AgentConversationStore extends HiveStore { List fetchForServer(String serverId) { final conversations = []; - for (final key in box.keys) { - if (key is! String || !key.startsWith(_conversationPrefix)) continue; - final conversation = _conversationFromValue(box.get(key)); + for (final key in keys()) { + if (!key.startsWith(_conversationPrefix)) continue; + final conversation = _conversationFromValue(get(key)); if (conversation?.serverId == serverId) conversations.add(conversation!); } conversations.sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); @@ -45,7 +42,7 @@ class AgentConversationStore extends HiveStore { } AgentConversation? fetch(String conversationId) { - return _conversationFromValue(box.get(_conversationKey(conversationId))); + return _conversationFromValue(get(_conversationKey(conversationId))); } AgentConversation? fetchActive(String serverId) { @@ -56,8 +53,8 @@ class AgentConversationStore extends HiveStore { } String? activeConversationId(String serverId) { - final value = box.get(_activeKey(serverId)); - return value is String && value.isNotEmpty ? value : null; + final value = get(_activeKey(serverId)); + return value != null && value.isNotEmpty ? value : null; } AgentConversation create({ @@ -249,8 +246,8 @@ class AgentConversationStore extends HiveStore { static String _conversationKey(String id) => '$_conversationPrefix$id'; static String _activeKey(String serverId) => '$_activePrefix$serverId'; - static AgentConversation? _conversationFromValue(Object? value) { - if (value is! Map) return null; + static AgentConversation? _conversationFromValue(Map? value) { + if (value == null) return null; try { final conversation = AgentConversation.fromJson( Map.from(value), diff --git a/lib/data/store/cached_store.dart b/lib/data/store/cached_store.dart index cec2ac994..84e84c302 100644 --- a/lib/data/store/cached_store.dart +++ b/lib/data/store/cached_store.dart @@ -1,129 +1,99 @@ -import 'dart:async'; - import 'package:fl_lib/fl_lib.dart'; -abstract class CachedHiveStore extends HiveStore { - CachedHiveStore(super.boxName); +/// A [SqliteStore] whose rows are one model type, kept in a list cache. +/// +/// The cache is dropped by the write methods themselves rather than by watching +/// the store. Hive needed a watcher because a box could be written behind the +/// store's back — `Backup.restore` did exactly that — and a watcher then had to +/// be suppressed around the store's own writes so they did not each cost a +/// reload. Nothing can reach the database except through here now, so +/// invalidating in [set], [remove] and [clear] covers every path. +abstract class CachedSqliteStore extends SqliteStore { + CachedSqliteStore(super.name); List? _cache; - StreamSubscription? _boxWatchSub; - bool _suppressWatch = false; List? get cachedItems => _cache; + /// The row key for [item]. String getKey(T item); + /// Rebuilds one item from its stored JSON. + T? fromJson(Map json); + @override - Future init() async { - await super.init(); - _boxWatchSub?.cancel(); - _boxWatchSub = box.watch().listen((_) { - if (!_suppressWatch) { - _cache = null; - } - }); + bool set( + String key, + V val, { + StoreToObj? toObj, + bool? updateLastUpdateTsOnSet, + }) { + final res = super.set( + key, + val, + toObj: toObj, + updateLastUpdateTsOnSet: updateLastUpdateTsOnSet, + ); + if (res) _cache = null; + return res; } @override - bool clear({bool? updateLastUpdateTsOnClear}) { - _suppressWatch = true; - try { - _cache = null; - return super.clear(updateLastUpdateTsOnClear: updateLastUpdateTsOnClear); - } finally { - _suppressWatch = false; - } + bool remove(String key, {bool? updateLastUpdateTsOnRemove}) { + final res = super.remove( + key, + updateLastUpdateTsOnRemove: updateLastUpdateTsOnRemove, + ); + if (res) _cache = null; + return res; } - void invalidateCache() { + @override + bool clear({bool? updateLastUpdateTsOnClear}) { _cache = null; + return super.clear(updateLastUpdateTsOnClear: updateLastUpdateTsOnClear); } - void put(T item) { - _suppressWatch = true; - try { - set(getKey(item), item); - _cache = null; - } finally { - _suppressWatch = false; - } - } + void invalidateCache() => _cache = null; - void putRaw(T item) { - _suppressWatch = true; - try { - box.put(getKey(item), item); - _cache = null; - } finally { - _suppressWatch = false; - } - } + void put(T item) => set(getKey(item), item); - List fetch() { - return List.from(_cache ??= _loadAll()); - } + /// A copy, so a caller sorting or filtering the result cannot reorder the + /// cache everyone else reads. + List fetch() => List.from(_cache ??= _loadAll()); List _loadAll() { final result = []; for (final key in keys()) { - final item = _getAndConvert(key); - if (item != null) { - result.add(item); - } + final item = fetchOneRaw(key); + if (item != null) result.add(item); } return result; } - T? _getAndConvert(String key) { - final val = get(key); - if (val != null) return val; - - final raw = box.get(key); - if (raw == null) return null; - - if (raw is Map) { - try { - final item = fromJson(Map.from(raw)); - if (item != null) { - putRaw(item); - } - return item; - } catch (e) { - dprint('Parsing $T from JSON', e); - } - } - return null; - } - - T? fromJson(Map json); - - void deleteById(String id) { - _suppressWatch = true; + /// Reads one row, bypassing the cache. + T? fetchOneRaw(String key) { + final raw = get(key); + if (raw is! Map) return null; try { - remove(id); - _cache = null; - } finally { - _suppressWatch = false; + return fromJson(Map.from(raw)); + } catch (e) { + dprint('Parsing $T from JSON', e); + return null; } } - void delete(T item) { - deleteById(getKey(item)); - } + void deleteById(String id) => remove(id); + + void delete(T item) => deleteById(getKey(item)); void update(T old, T newItem) { if (!have(old)) { throw Exception('Old $T: $old not found'); } - _suppressWatch = true; - try { - remove(getKey(old)); - set(getKey(newItem), newItem); - _cache = null; - } finally { - _suppressWatch = false; - } + remove(getKey(old)); + set(getKey(newItem), newItem); } - bool have(T item) => get(getKey(item)) != null; + bool have(T item) => get(getKey(item)) != null; } diff --git a/lib/data/store/connection_stats.dart b/lib/data/store/connection_stats.dart index 3b33aed0a..842245e77 100644 --- a/lib/data/store/connection_stats.dart +++ b/lib/data/store/connection_stats.dart @@ -1,40 +1,46 @@ -import 'dart:io'; - import 'package:fl_lib/fl_lib.dart'; -import 'package:hive_ce/hive.dart'; import 'package:server_box/data/model/server/connection_stat.dart'; -class ConnectionStatsStore extends HiveStore { +class ConnectionStatsStore extends SqliteStore { ConnectionStatsStore._() : super('connection_stats'); static final instance = ConnectionStatsStore._(); - static const _indexBoxName = 'conn_stats_index'; static const _maxRecordsPerServer = 100; - /// Not `final`, for the same reason [box] is not: [init] can run twice. - late Box _indexBox; + /// Per-server lists of record keys, so a server's history can be read without + /// scanning every record. + /// + /// Its own store rather than a prefix in this one, so `keys()` here stays + /// "the records" and the pruning below does not have to filter itself out. + /// + /// It used to be a separate Hive box, and — unlike every other box — an + /// unencrypted one: `Hive.openBox` was called without a cipher, so 114 KB of + /// `_` sat in plaintext next to the encrypted records they + /// point at. Sharing the database puts it behind the same key as everything + /// else. + /// + /// TODO: delete along with the hand-rolled pruning below, once the records + /// are a table with an index on (server_id, timestamp). + final _index = SqliteStore('conn_stats_index'); @override - Future init() async { - await super.init(); - _indexBox = await Hive.openBox( - _indexBoxName, - path: box.path?.substring(0, box.path!.lastIndexOf(Pfs.seperator)), - ); + Future init({String? dir}) async { + await super.init(dir: dir); + await _index.init(dir: dir); } Future rebuildIndexAndCompact() async { - await _cleanAllOldAndRebuildIndex(); + _rebuildIndexCore(); await _compactIfNeeded(); } - Future _rebuildIndexCore() async { + void _rebuildIndexCore() { final cutoffTime = DateTime.now().subtract(const Duration(days: 30)); final serverIdToKeys = >{}; for (final key in keys().toList()) { - final stat = get(key); + final stat = _statOf(key); if (stat == null) continue; if (stat.timestamp.isBefore(cutoffTime)) { @@ -42,15 +48,11 @@ class ConnectionStatsStore extends HiveStore { continue; } - final serverId = stat.serverId; - serverIdToKeys.putIfAbsent(serverId, () => []).add(key); + serverIdToKeys.putIfAbsent(stat.serverId, () => []).add(key); } - final idxKeysToDelete = _indexBox.keys - .where((k) => k.toString().startsWith('idx_')) - .toList(); - for (final k in idxKeysToDelete) { - await _indexBox.delete(k); + for (final k in _index.keys().toList()) { + if (k.startsWith('idx_')) _index.remove(k); } for (final entry in serverIdToKeys.entries) { @@ -58,7 +60,7 @@ class ConnectionStatsStore extends HiveStore { if (keys.length > _maxRecordsPerServer) { final keyStatPairs = <(String, ConnectionStat)>[]; for (final key in keys) { - final stat = get(key); + final stat = _statOf(key); if (stat != null) keyStatPairs.add((key, stat)); } keyStatPairs.sort((a, b) => b.$2.timestamp.compareTo(a.$2.timestamp)); @@ -68,59 +70,48 @@ class ConnectionStatsStore extends HiveStore { .toList() .reversed .toList(); - final toRemove = keyStatPairs.skip(_maxRecordsPerServer); - for (final pair in toRemove) { + for (final pair in keyStatPairs.skip(_maxRecordsPerServer)) { remove(pair.$1); } - await _indexBox.put('idx_${entry.key}', toKeep); + _index.set('idx_${entry.key}', toKeep); } else { - await _indexBox.put('idx_${entry.key}', keys); + _index.set('idx_${entry.key}', keys); } } } - Future _cleanAllOldAndRebuildIndex() async { - await _rebuildIndexCore(); - } - Future _compactIfNeeded() async { try { - await box.compact(); - await _indexBox.compact(); + SqliteDb.vacuum(); } catch (e, st) { Loggers.app.warning('Auto compact failed during init', e, st); } } - Future _updateIndex(String serverId, String recordKey) async { + void _updateIndex(String serverId, String recordKey) { final indexKey = 'idx_$serverId'; - final keys = - (_indexBox.get(indexKey) as List?)?.cast().toList() ?? []; + final keys = _indexKeys(serverId); - if (!keys.contains(recordKey)) { - keys.add(recordKey); - if (keys.length > _maxRecordsPerServer) { - await _pruneExcessRecords(serverId, keys); - } - await _indexBox.put(indexKey, keys); + if (keys.contains(recordKey)) return; + keys.add(recordKey); + if (keys.length > _maxRecordsPerServer) { + _pruneExcessRecords(keys); } + _index.set(indexKey, keys); } - Future _pruneExcessRecords(String serverId, List keys) async { + void _pruneExcessRecords(List keys) { if (keys.length <= _maxRecordsPerServer) return; final keyStatPairs = <(String, ConnectionStat)>[]; for (final key in keys) { - final stat = get(key); - if (stat != null) { - keyStatPairs.add((key, stat)); - } + final stat = _statOf(key); + if (stat != null) keyStatPairs.add((key, stat)); } keyStatPairs.sort((a, b) => b.$2.timestamp.compareTo(a.$2.timestamp)); - final toRemove = keyStatPairs.skip(_maxRecordsPerServer); - for (final pair in toRemove) { + for (final pair in keyStatPairs.skip(_maxRecordsPerServer)) { remove(pair.$1); keys.remove(pair.$1); } @@ -129,7 +120,7 @@ class ConnectionStatsStore extends HiveStore { Future recordConnection(ConnectionStat stat) async { final key = '${stat.serverId}_${stat.timestamp.millisecondsSinceEpoch}'; set(key, stat); - await _updateIndex(stat.serverId, key); + _updateIndex(stat.serverId, key); } ServerConnectionStats getServerStats(String serverId, String serverName) { @@ -185,35 +176,24 @@ class ConnectionStatsStore extends HiveStore { } List getConnectionHistory(String serverId) { - final indexKey = 'idx_$serverId'; - final keys = (_indexBox.get(indexKey) as List?)?.cast() ?? []; - final stats = []; - for (final key in keys.reversed) { - final stat = get(key); - if (stat != null) { - stats.add(stat); - } + for (final key in _indexKeys(serverId).reversed) { + final stat = _statOf(key); + if (stat != null) stats.add(stat); } return stats; } List getAllServerStats() { - final indexKeys = _indexBox.keys - .where((k) => k is String && k.startsWith('idx_')) - .cast() - .toList(); - final allStats = []; for (final indexKey in indexKeys) { final serverId = indexKey.substring(4); - final keys = (_indexBox.get(indexKey) as List?)?.cast() ?? []; - + final keys = _indexKeys(serverId); if (keys.isEmpty) continue; String? serverName; for (final key in keys.reversed) { - final stat = get(key); + final stat = _statOf(key); if (stat != null) { serverName = stat.serverName; break; @@ -222,58 +202,55 @@ class ConnectionStatsStore extends HiveStore { if (serverName == null) continue; - final stats = getServerStats(serverId, serverName); - allStats.add(stats); + allStats.add(getServerStats(serverId, serverName)); } return allStats; } Future clearAll() async { - await box.clear(); - await _indexBox.clear(); + clear(); + _index.clear(); } Future clearServerStats(String serverId) async { - final indexKey = 'idx_$serverId'; - final keys = (_indexBox.get(indexKey) as List?)?.cast() ?? []; - - for (final key in keys) { + for (final key in _indexKeys(serverId)) { remove(key); } - await _indexBox.delete(indexKey); + _index.remove('idx_$serverId'); } Future compact() async { - Loggers.app.info('Start compacting connection_stats database...'); + Loggers.app.info('Start compacting the store database...'); try { - await box.compact(); - await _indexBox.compact(); - Loggers.app.info('Finished compacting connection_stats database'); + SqliteDb.vacuum(); + Loggers.app.info('Finished compacting the store database'); } catch (e, st) { - Loggers.app.warning('Failed compacting connection_stats database', e, st); + Loggers.app.warning('Failed compacting the store database', e, st); rethrow; } } - String? get dbPath => box.path; + Iterable get indexKeys => + _index.keys().where((k) => k.startsWith('idx_')); - String? get indexDbPath => _indexBox.path; + List _indexKeys(String serverId) => + _index.get('idx_$serverId')?.cast().toList() ?? []; - Iterable get indexDbKeys => - _indexBox.keys.where((k) => k.toString().startsWith('idx_')); - - Future dbSizeAsync() async { - final path = dbPath; - if (path == null) return 0; - final file = File(path); - return await file.exists() ? await file.length() : 0; + ConnectionStat? _statOf(String key) { + final raw = get(key); + if (raw == null) return null; + try { + return ConnectionStat.fromJson(Map.from(raw)); + } catch (e) { + dprint('Parsing ConnectionStat from JSON', e); + return null; + } } - Future indexDbSizeAsync() async { - final path = indexDbPath; - if (path == null) return 0; - final file = File(path); - return await file.exists() ? await file.length() : 0; - } + /// Size of the whole store database, not of this store's rows. + /// + /// Every store shares one file, so there is no per-store number to report and + /// the compaction this feeds is `VACUUM` on that file. + Future dbSizeAsync() => SqliteDb.size(); } diff --git a/lib/data/store/container.dart b/lib/data/store/container.dart index d5b456584..b00f23214 100644 --- a/lib/data/store/container.dart +++ b/lib/data/store/container.dart @@ -5,17 +5,17 @@ import 'package:server_box/data/res/store.dart'; const _keyConfig = 'providerConfig'; const _keyHost = 'containerHost'; -class ContainerStore extends HiveStore { +class ContainerStore extends SqliteStore { ContainerStore._() : super('docker'); static final instance = ContainerStore._(); String? fetch(String? id, ContainerType type) { - final host = box.get(_hostKey(id, type)); + final host = get(_hostKey(id, type)); if (host != null || type == ContainerType.podman) return host; // Preserve existing Docker host settings stored before per-runtime hosts. - return box.get(id); + return id == null ? null : get(id); } void put(String id, ContainerType type, String host) { @@ -31,7 +31,7 @@ class ContainerStore extends HiveStore { '$_keyHost${type.name}${id ?? ''}'; ContainerType getType([String id = '']) { - final cfg = box.get(_keyConfig + id); + final cfg = get(_keyConfig + id); if (cfg != null) { final type = ContainerType.values.firstWhereOrNull( (e) => e.toString() == cfg, diff --git a/lib/data/store/history.dart b/lib/data/store/history.dart index 5c4f85eca..eb70a91a3 100644 --- a/lib/data/store/history.dart +++ b/lib/data/store/history.dart @@ -1,71 +1,72 @@ import 'package:fl_lib/fl_lib.dart'; -import 'package:hive_ce_flutter/hive_flutter.dart'; import 'package:meta/meta.dart'; /// index from 0 -> n : latest -> oldest class _ListHistory { - final List _history; + const _ListHistory({required SqliteStore store, required String name}) + : _store = store, + _name = name; + + final SqliteStore _store; final String _name; - final Box _box; - _ListHistory({required Box box, required String name}) - : _box = box, - _name = name, - _history = box.get(name, defaultValue: [])!; + /// Read through on every call rather than cached at construction. + /// + /// The Hive version held the list it was built with and wrote the same + /// instance back, so it was the store's contents only as long as nothing else + /// touched the key. Nothing did, but a restore now goes through the store + /// like everything else, and a snapshot taken at first access would outlive + /// it. + List get all => + _store.get(_name)?.cast().toList() ?? []; void add(String path) { - _history.remove(path); - _history.insert(0, path); - _box.put(_name, _history); + final history = all..remove(path); + history.insert(0, path); + _store.set(_name, history); } - List get all => _history; - - void clear() { - _history.clear(); - _box.put(_name, _history); - } + void clear() => _store.set(_name, const []); } class _MapHistory { - final Map _history; + const _MapHistory({required SqliteStore store, required String name}) + : _store = store, + _name = name; + + final SqliteStore _store; final String _name; - final Box _box; - _MapHistory({required Box box, required String name}) - : _box = box, - _name = name, - _history = box.get(name, defaultValue: {})!; + Map get _all => + _store.get(_name)?.cast() ?? {}; void put(String id, String val) { - _history[id] = val; - _box.put(_name, _history); + _store.set(_name, {..._all, id: val}); } - String? fetch(String id) => _history[id]; + String? fetch(String id) => _all[id]; } -class HistoryStore extends HiveStore { +class HistoryStore extends SqliteStore { HistoryStore._() : super('history'); - /// The same seam [SettingStore.forBox] and [ServerStore.forBox] have. + /// The same seam [ServerStore.forTest] has: a distinct store name, so a test + /// on `SqliteDb.openInMemory()` cannot collide with another test's rows. /// /// This one holds the terminal tab set, which is the piece of session state /// that does survive a relaunch — Flutter's own restoration does not, here — /// so it is what a test of "what comes back" has to be able to write. @visibleForTesting - HistoryStore.forBox(Box testBox) : super('history_test') { - box = testBox; - } + HistoryStore.forTest() : super('history_test'); static final instance = HistoryStore._(); - late final sftpGoPath = _ListHistory(box: box, name: 'sftpPath'); + late final sftpGoPath = _ListHistory(store: this, name: 'sftpPath'); - late final sftpLastPath = _MapHistory(box: box, name: 'sftpLastPath'); + late final sftpLastPath = _MapHistory(store: this, name: 'sftpLastPath'); late final sshServerHistory = _ListHistory( - box: box, + store: this, name: 'sshServerHistory', ); diff --git a/lib/data/store/migrations/m002_nest_ssh.dart b/lib/data/store/migrations/m002_nest_ssh.dart deleted file mode 100644 index 06fe1a161..000000000 --- a/lib/data/store/migrations/m002_nest_ssh.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'package:fl_lib/fl_lib.dart'; -import 'package:server_box/data/model/server/server_private_info.dart'; -import 'package:server_box/data/store/schema.dart'; -import 'package:server_box/data/store/server.dart'; -import 'package:server_box/hive/spi_legacy_adapter.dart'; - -/// v2 -> v3: rewrites every stored server so its SSH fields live under -/// `Spi.ssh` instead of flat on the record. -/// -/// The read half is [SpiLegacyAdapter], registered on the old typeId; this -/// step only has to put each record back, which the generated adapter then -/// writes under the current typeId in the current shape. -/// -/// Safe to re-run: a record already in the new shape is decoded by the current -/// adapter and written back unchanged. The version is only recorded once the -/// whole pass completes, so a crash part-way means the next launch repeats it. -class SpiNestSshMigration implements SchemaMigration { - const SpiNestSshMigration(); - - @override - int get from => 2; - - @override - Future apply() async { - final store = ServerStore.instance; - final migrated = {}; - - for (final key in store.box.keys) { - if (key is! String) continue; - try { - // Untyped: a v2 record decodes to LegacySpiV2, a v3 one to Spi - final raw = store.box.get(key); - final spi = switch (raw) { - final LegacySpiV2 legacy => legacy.toSpi(), - final Spi spi => spi, - _ => null, - }; - if (spi != null) migrated[key] = spi; - } catch (e, s) { - // One unreadable record must not block the rest: leaving the whole - // store on v2 would mean every launch retries and fails the same way - Loggers.app.warning('Skipping unreadable server record "$key"', e, s); - } - } - - for (final entry in migrated.entries) { - // Keyed by the original key, not `spi.id`: records written before 1155 - // are stored under `user@ip:port`, and `ServerStore.migrateIds` rekeys - // them afterwards - store.set(entry.key, entry.value); - } - - Loggers.app.info('Nested SSH credentials for ${migrated.length} servers'); - } -} diff --git a/lib/data/store/migrations/m003_hive_to_sqlite.dart b/lib/data/store/migrations/m003_hive_to_sqlite.dart new file mode 100644 index 000000000..4b4143a06 --- /dev/null +++ b/lib/data/store/migrations/m003_hive_to_sqlite.dart @@ -0,0 +1,138 @@ +import 'dart:io'; + +import 'package:fl_lib/fl_lib.dart'; +import 'package:server_box/data/res/store.dart'; +import 'package:server_box/data/store/schema.dart'; +import 'package:server_box/hive/spi_legacy_adapter.dart'; + +/// Copies every Hive box into the SQLite stores, once per device. +/// +/// Not a [SchemaMigration]: those run against one storage engine and are keyed +/// on a version number that itself lives in a store. This has to happen before +/// that number can be read at all, because on the launch that upgrades an +/// install the SQLite side is empty and would report a fresh install's default. +/// +/// TODO: delete this, `lib/hive/`, the `hive_ce*` dependencies and the +/// `Hive.initFlutter()` call in `main.dart` once no supported install can still +/// be on Hive. Keep [SpiLegacyAdapter] until then — reading a pre-v3 record is +/// what [_toSpi] needs it for. +abstract final class HiveImport { + /// Internal, so it stays out of backups and out of `lastUpdateTs`. + static const _markerKey = '${StoreDefaults.prefixKey}hiveImported'; + + /// Box name -> the store that now holds it. + /// + /// `conn_stats_index` is deliberately absent: it is derived from the records, + /// `Stores.init` rebuilds it when it is empty, and it is the one box that was + /// never encrypted — so it is deleted below rather than carried across. + static Map get _boxes => { + 'setting': Stores.setting, + 'server': Stores.server, + 'docker': Stores.container, + 'key': Stores.key, + 'snippet': Stores.snippet, + 'history': Stores.history, + 'connection_stats': Stores.connectionStats, + 'port_forward': Stores.portForward, + 'agent_conversation': Stores.agentConversation, + }; + + /// Runs the import if this device has data in Hive and none in SQLite yet. + /// + /// Safe to re-run: nothing is deleted from Hive, so a crash part-way through + /// leaves the marker unwritten and the next launch copies everything again, + /// overwriting whatever the interrupted attempt had managed to write. + static Future runIfNeeded() async { + if (Stores.setting.get(_markerKey) == true) return; + + // The same directory `HiveStore.init` opens from, asked of it rather than + // recomputed: the macOS sandbox and the mobile platforms each answer this + // differently, and looking in the wrong one reads as "fresh install". + final dir = await HiveStore.boxDir; + final present = _boxes.keys + .where((name) => File(dir.joinPath('${name}_enc.hive')).existsSync()) + .toList(); + if (present.isEmpty) { + // A fresh install. Record the current layout so the migrator does not + // walk it through steps written for data it never had. + SchemaVersion.initFresh(); + Stores.setting.set(_markerKey, true); + return; + } + + Loggers.app.info('Importing ${present.length} Hive boxes into SQLite'); + var copied = 0; + for (final name in present) { + copied += await _importBox(name, _boxes[name]!); + } + Loggers.app.info('Imported $copied rows from Hive'); + + _dropPlaintextIndex(dir); + + // The copy nests a pre-v3 server record on the way across, which is what + // the v2 -> v3 step used to do in place, so what has landed is current by + // construction. + SchemaVersion.initFresh(); + Stores.setting.set(_markerKey, true); + } + + static Future _importBox(String name, SqliteStore target) async { + final legacy = HiveStore(name); + try { + await legacy.init(); + } catch (e, s) { + // One box that will not open must not stop the others: the alternative is + // an install that keeps all of its data and can reach none of it. + Loggers.app.warning('Hive box "$name" did not open; skipped', e, s); + return 0; + } + + var copied = 0; + for (final key in legacy.box.keys) { + if (key is! String) continue; + final raw = legacy.box.get(key); + if (raw == null) continue; + + final value = _toSpi(raw) ?? raw; + // `updateLastUpdateTsOnSet: false` throughout: the timestamps are copied + // across with everything else, and stamping each row as it lands would + // overwrite them with "now" and tell the next sync that this device holds + // the newer copy of data it has just finished reading off its own disk. + final ok = target.set(key, value as Object, updateLastUpdateTsOnSet: false); + if (ok) { + copied++; + } else { + Loggers.app.warning( + 'Could not import "$name/$key" (${value.runtimeType})', + ); + } + } + + await legacy.box.close(); + return copied; + } + + /// A pre-v3 server record, nested into the current shape. + /// + /// Returns null for anything else, including a record already in the current + /// shape — those encode themselves. + static Object? _toSpi(Object raw) => + raw is LegacySpiV2 ? raw.toSpi() : null; + + /// Removes the one box that was never encrypted. + /// + /// The other files are kept so a bad import can be rolled back to, but this + /// one holds no data that is not derivable from the records, and leaving it + /// would leave `_` for every connection sitting in + /// plaintext beside a database that exists to not do that. + static void _dropPlaintextIndex(String dir) { + for (final suffix in const ['.hive', '.lock']) { + final file = File(dir.joinPath('conn_stats_index$suffix')); + try { + if (file.existsSync()) file.deleteSync(); + } catch (e, s) { + Loggers.app.warning('Could not delete ${file.path}', e, s); + } + } + } +} diff --git a/lib/data/store/port_forward.dart b/lib/data/store/port_forward.dart index 075426023..b255a3164 100644 --- a/lib/data/store/port_forward.dart +++ b/lib/data/store/port_forward.dart @@ -1,7 +1,7 @@ import 'package:fl_lib/fl_lib.dart'; import 'package:server_box/data/model/server/port_forward.dart'; -class PortForwardStore extends HiveStore { +class PortForwardStore extends SqliteStore { PortForwardStore._() : super('port_forward'); static final instance = PortForwardStore._(); @@ -13,29 +13,16 @@ class PortForwardStore extends HiveStore { List fetch(String serverId) { final configs = []; for (final key in keys()) { - final config = get( - key, - fromObj: (val) { - if (val is PortForwardConfig) return val; - if (val is Map) { - final map = val.toStrDynMap; - if (map == null) return null; - try { - final config = PortForwardConfig.fromJson( - map as Map, - ); - put(config); - return config; - } catch (e) { - dprint('Parsing PortForwardConfig from JSON', e); - } - } - return null; - }, - ); - if (config != null && config.serverId == serverId) { - configs.add(config); + final raw = get(key); + if (raw == null) continue; + final PortForwardConfig config; + try { + config = PortForwardConfig.fromJson(Map.from(raw)); + } catch (e) { + dprint('Parsing PortForwardConfig from JSON', e); + continue; } + if (config.serverId == serverId) configs.add(config); } return configs; } diff --git a/lib/data/store/private_key.dart b/lib/data/store/private_key.dart index c2fbf38a2..2e6bef399 100644 --- a/lib/data/store/private_key.dart +++ b/lib/data/store/private_key.dart @@ -1,19 +1,15 @@ -import 'package:fl_lib/fl_lib.dart'; -import 'package:hive_ce/hive.dart'; import 'package:meta/meta.dart'; import 'package:server_box/data/model/server/private_key_info.dart'; import 'package:server_box/data/store/cached_store.dart'; import 'package:server_box/data/store/server.dart'; -class PrivateKeyStore extends CachedHiveStore { +class PrivateKeyStore extends CachedSqliteStore { PrivateKeyStore._() : super('key'); - /// See [ServerStore.forBox]. + /// See [ServerStore.forTest]. @visibleForTesting - PrivateKeyStore.forBox(Box testBox) : super('key_test') { - box = testBox; - } + PrivateKeyStore.forTest() : super('key_test'); static final instance = PrivateKeyStore._(); @@ -31,20 +27,6 @@ class PrivateKeyStore extends CachedHiveStore { if (pki.id == id) return pki; } } - return _decode(box.get(id)); - } - - PrivateKeyInfo? _decode(dynamic val) { - if (val is PrivateKeyInfo) return val; - if (val is Map) { - final map = val.toStrDynMap; - if (map == null) return null; - try { - return PrivateKeyInfo.fromJson(map as Map); - } catch (e) { - dprint('Parsing PrivateKeyInfo from JSON', e); - } - } - return null; + return fetchOneRaw(id); } } diff --git a/lib/data/store/schema.dart b/lib/data/store/schema.dart index 52ea5237e..6475814fa 100644 --- a/lib/data/store/schema.dart +++ b/lib/data/store/schema.dart @@ -52,7 +52,14 @@ abstract final class SchemaVersion { /// layout written before versioning existed, hence the starting point /// rather than v1 /// v3: Spi's flat SSH fields nested under `ssh` - static const current = 3; + /// v4: Hive boxes replaced by one encrypted SQLite database + /// + /// There is no migration registered for v2 -> v3 or v3 -> v4. Both are done + /// by `HiveImport`, which is the only code that reads a Hive box and so the + /// only place a pre-v3 record can be decoded at all; it records [current] + /// when it finishes. Every install therefore reaches SQLite already at v4, + /// and [migrate] has nothing to do until a v5 exists. + static const current = 4; /// Persisted locally, never included in a backup: it describes *this /// device's* storage, and restoring another device's number would make the diff --git a/lib/data/store/server.dart b/lib/data/store/server.dart index 74e71e41f..2c318d24c 100644 --- a/lib/data/store/server.dart +++ b/lib/data/store/server.dart @@ -1,5 +1,4 @@ import 'package:fl_lib/fl_lib.dart'; -import 'package:hive_ce/hive.dart'; import 'package:meta/meta.dart'; import 'package:server_box/data/model/container/type.dart'; import 'package:server_box/data/model/server/server_private_info.dart'; @@ -9,16 +8,13 @@ import 'package:server_box/data/store/private_key.dart'; import 'package:server_box/data/store/setting.dart'; import 'package:server_box/data/store/snippet.dart'; -class ServerStore extends CachedHiveStore { +class ServerStore extends CachedSqliteStore { ServerStore._() : super('server'); - /// The same seam [SettingStore.forBox] has: `init()` reaches for the - /// platform's secure storage to get an encryption cipher, which a unit test - /// has no implementation of. + /// The same seam [SettingStore.forTest] has: a distinct store name, so a test + /// on `SqliteDb.openInMemory()` cannot collide with another test's rows. @visibleForTesting - ServerStore.forBox(Box testBox) : super('server_test') { - box = testBox; - } + ServerStore.forTest() : super('server_test'); static final instance = ServerStore._(); diff --git a/lib/data/store/setting.dart b/lib/data/store/setting.dart index 4577b8631..18edf8e13 100644 --- a/lib/data/store/setting.dart +++ b/lib/data/store/setting.dart @@ -1,7 +1,6 @@ import 'dart:convert'; import 'package:fl_lib/fl_lib.dart'; -import 'package:hive_ce/hive.dart'; import 'package:meta/meta.dart'; import 'package:server_box/data/model/app/menu/server_func.dart'; import 'package:server_box/data/model/app/net_view.dart'; @@ -10,13 +9,13 @@ import 'package:server_box/data/model/app/tab.dart'; import 'package:server_box/data/model/ssh/virtual_key.dart'; import 'package:server_box/data/res/default.dart'; -class SettingStore extends HiveStore { +class SettingStore extends SqliteStore { SettingStore._() : super('setting'); + /// A distinct store name, so a test on `SqliteDb.openInMemory()` cannot + /// collide with another test's rows. @visibleForTesting - SettingStore.forBox(Box testBox) : super('setting_test') { - box = testBox; - } + SettingStore.forTest() : super('setting_test'); static final instance = SettingStore._(); @@ -329,7 +328,16 @@ class SettingStore extends HiveStore { /// whatever the last unversioned release wrote, and that is v2 (Spi with a /// flat SSH layout plus `monitorHttp`). A fresh install overwrites this with /// [SchemaVersion.current] before any migration runs. - late final schemaVersion = propertyDefault('schemaVersion', 2); + /// + /// Never stamps `lastUpdateTs`. It describes this device's storage and is + /// left out of backups, so counting a migration writing it as a user edit + /// would make a device that has only just upgraded claim the newer copy of + /// everything at the next sync. + late final schemaVersion = propertyDefault( + 'schemaVersion', + 2, + updateLastModified: false, + ); /// Hide title bar on desktop late final hideTitleBar = propertyDefault('hideTitleBar', isDesktop); @@ -425,12 +433,17 @@ class SettingStore extends HiveStore { ); /// Add Agent to the legacy default home tabs once. + /// + /// Written with `updateLastUpdateTsOnSet: false` throughout, as the Hive + /// version got by writing straight to the box: a migration this build runs on + /// its own is not an edit the user made, and counting it as one would have + /// every install claim a newer copy than whatever it last synced with. Future migrateHomeTabsAgent() async { const key = 'homeTabs'; const flagKey = 'homeTabsAgentMigrated'; - if (box.get(flagKey) == true) return; + if (get(flagKey) == true) return; - final tabs = AppTab.parseAppTabsFromObj(box.get(key)); + final tabs = AppTab.parseAppTabsFromObj(get(key)); const legacyDefaultTabs = { AppTab.server, AppTab.ssh, @@ -439,12 +452,13 @@ class SettingStore extends HiveStore { }; if (tabs.length == legacyDefaultTabs.length && tabs.toSet().containsAll(legacyDefaultTabs)) { - await box.put( + set( key, [...tabs, AppTab.agent].map((tab) => tab.name).toList(), + updateLastUpdateTsOnSet: false, ); } - await box.put(flagKey, true); + set(flagKey, true, updateLastUpdateTsOnSet: false); } /// Hide port forward beta warning @@ -469,7 +483,9 @@ class SettingStore extends HiveStore { /// installs are cleaned without another migration flag becoming permanent /// state of its own. Future removeRetiredKeys() async { - await box.deleteAll(const ['moveOutServerTabFuncBtns', 'forceSinglePane']); + for (final key in const ['moveOutServerTabFuncBtns', 'forceSinglePane']) { + remove(key, updateLastUpdateTsOnRemove: false); + } } /// Migrate sshConnectionMode from old int values (-1/0/1) to bool. @@ -477,8 +493,8 @@ class SettingStore extends HiveStore { void migrateSshConnectionMode() { const key = 'sshConnectionMode'; const flagKey = 'sshConnectionModeMigrated'; - if (box.get(flagKey) == true) return; - final raw = box.get(key); + if (get(flagKey) == true) return; + final raw = get(key); if (raw is int) { // -1 = auto, 0 = built-in, 1 = system SSH final bool value; @@ -487,8 +503,8 @@ class SettingStore extends HiveStore { } else { value = raw != 0; } - box.put(key, value); + set(key, value, updateLastUpdateTsOnSet: false); } - box.put(flagKey, true); + set(flagKey, true, updateLastUpdateTsOnSet: false); } } diff --git a/lib/data/store/snippet.dart b/lib/data/store/snippet.dart index c8cd61a63..c4b346f43 100644 --- a/lib/data/store/snippet.dart +++ b/lib/data/store/snippet.dart @@ -1,18 +1,14 @@ -import 'package:hive_ce/hive.dart'; import 'package:meta/meta.dart'; import 'package:server_box/data/model/server/snippet.dart'; import 'package:server_box/data/store/cached_store.dart'; -class SnippetStore extends CachedHiveStore { +class SnippetStore extends CachedSqliteStore { SnippetStore._() : super('snippet'); - /// The same seam [ServerStore.forBox] has: `init()` reaches for the - /// platform's secure storage to get an encryption cipher, which a unit test - /// has no implementation of. + /// The same seam [ServerStore.forTest] has: a distinct store name, so a test + /// on `SqliteDb.openInMemory()` cannot collide with another test's rows. @visibleForTesting - SnippetStore.forBox(Box testBox) : super('snippet_test') { - box = testBox; - } + SnippetStore.forTest() : super('snippet_test'); static final instance = SnippetStore._(); diff --git a/lib/main.dart b/lib/main.dart index 1f3c2b20c..393929c69 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -20,7 +20,6 @@ import 'package:server_box/data/res/build_data.dart'; import 'package:server_box/data/res/misc.dart'; import 'package:server_box/data/res/store.dart'; import 'package:server_box/data/ssh/session_manager.dart'; -import 'package:server_box/data/store/migrations/m002_nest_ssh.dart'; import 'package:server_box/data/store/schema.dart'; import 'package:server_box/data/store/server.dart'; import 'package:server_box/hive/hive_registrar.g.dart'; @@ -130,12 +129,15 @@ Future _initData() async { dirs: const {PathDir.img, PathDir.font}, ); + // Only so `HiveImport` can read the boxes an upgrading install still has. + // Nothing writes Hive any more. + // + // TODO: drop this, `lib/hive/` and the `hive_ce*` dependencies together with + // `HiveImport`, once no supported install can still be on Hive. await Hive.initFlutter(); Hive.registerAdapters(); // Reads pre-v3 server records, which the generated SpiAdapter no longer // understands (it owns a new typeId and the nested layout). - // TODO: drop together with SpiNestSshMigration once no install can still be - // on schema v2. Hive.registerAdapter(SpiLegacyAdapter()); await PrefStore.shared.init(); // Call this before accessing any store @@ -154,11 +156,12 @@ Future _initData() async { // start empty — an app that opens with nothing in it can still be told // what happened, one that does not open cannot. Loggers.app.warning('Stores.init after sandbox import', e, s); - // Closed before the files are deleted. `Stores.init` may have opened - // several boxes before the one that threw, and unlinking a `.hive` out - // from under a live handle is undefined at best: on Windows the delete - // fails outright and the copy stays, so the retry below reopens exactly - // the data that just failed to open. + // Closed before the files are deleted. `Stores.init` may have got as far as + // opening the database, or several boxes, before the one that threw, and + // unlinking a file out from under a live handle is undefined at best: on + // Windows the delete fails outright and the copy stays, so the retry below + // reopens exactly the data that just failed to open. + await SqliteDb.close(); await Hive.close(); await getIt.reset(); await SandboxImport.undo(); @@ -230,7 +233,12 @@ Future _doDbMigrate() async { // its current type. Throws SchemaTooNewException when the data was written // by a newer build — that must not be swallowed, since continuing would let // this build overwrite records whose shape it doesn't understand. - await SchemaVersion.migrate(const [SpiNestSshMigration()]); + // + // The list is empty as of v4: `HiveImport` brings every install straight to + // `current` while copying, because a pre-v3 record only exists as a Hive + // value and that is the one pass that reads one. The call stays for the + // downgrade check it performs, and for the next step that does exist. + await SchemaVersion.migrate(const []); // Then the app-level fixups, which read records as `Spi`. ServerStore.instance.migrateIds(); diff --git a/lib/view/page/private_key/list.dart b/lib/view/page/private_key/list.dart index 59bd3001d..8f12f27d9 100644 --- a/lib/view/page/private_key/list.dart +++ b/lib/view/page/private_key/list.dart @@ -69,7 +69,7 @@ class _PrivateKeyListState extends ConsumerState extension on _PrivateKeyListState { void _autoAddSystemPriavteKey() async { // Only trigger on desktop platform and no private key saved - if (isDesktop && Stores.snippet.box.keys.isEmpty) { + if (isDesktop && Stores.snippet.keys().isEmpty) { final home = Pfs.homeDir; if (home == null) return; final idRsaFile = File(home.joinPath('.ssh/id_rsa')); diff --git a/lib/view/page/server/connection_stats.dart b/lib/view/page/server/connection_stats.dart index d1109af57..19ddf6c77 100644 --- a/lib/view/page/server/connection_stats.dart +++ b/lib/view/page/server/connection_stats.dart @@ -288,13 +288,12 @@ extension _Actions on _ConnectionStatsPageState { } Future _showCompactDialog() async { + // One file for every store now, so this is the whole database rather than + // this page's share of it — and so is the `VACUUM` the dialog runs. final oldSize = await Stores.connectionStats.dbSizeAsync(); if (!mounted) return; - final oldIndexSize = await Stores.connectionStats.indexDbSizeAsync(); - if (!mounted) return; - final totalSize = oldSize + oldIndexSize; - final sizeStr = totalSize.bytes2Str; + final sizeStr = oldSize.bytes2Str; context.showRoundDialog( title: l10n.compactDatabase, @@ -308,10 +307,7 @@ extension _Actions on _ConnectionStatsPageState { try { await Stores.connectionStats.compact(); final newSize = await Stores.connectionStats.dbSizeAsync(); - final newIndexSize = await Stores.connectionStats - .indexDbSizeAsync(); - final newTotalSize = newSize + newIndexSize; - final newSizeStr = newTotalSize.bytes2Str; + final newSizeStr = newSize.bytes2Str; _finishCompacting('${libL10n.success}: $sizeStr -> $newSizeStr'); } catch (e) { _finishCompacting('${libL10n.error}: $e'); diff --git a/lib/view/page/server/edit/actions.dart b/lib/view/page/server/edit/actions.dart index 40c587df2..a829f9140 100644 --- a/lib/view/page/server/edit/actions.dart +++ b/lib/view/page/server/edit/actions.dart @@ -370,7 +370,7 @@ extension _Actions on _ServerEditPageState { } if (this.spi == null) { - final existsIds = ServerStore.instance.box.keys; + final existsIds = ServerStore.instance.keys(); if (existsIds.contains(spi.id)) { Toast.show('${l10n.sameIdServerExist}: ${spi.id}'); return; diff --git a/lib/view/page/setting/entries/ai.dart b/lib/view/page/setting/entries/ai.dart index ad3dbd95b..c49f24f75 100644 --- a/lib/view/page/setting/entries/ai.dart +++ b/lib/view/page/setting/entries/ai.dart @@ -5,7 +5,7 @@ extension _AI on _AppSettingsPageState { /// settings row that opens something: title, the current value under it, and /// a chevron saying there is more behind the tap. Widget _buildAskAiTextTile({ - required HiveProp prop, + required SqliteProp prop, required Widget leading, required String title, required String hint, @@ -141,7 +141,7 @@ extension _AI on _AppSettingsPageState { } Future _showAskAiFieldDialog({ - required HiveProp prop, + required SqliteProp prop, required String title, required String hint, String? description, diff --git a/lib/view/page/setting/entries/app.dart b/lib/view/page/setting/entries/app.dart index 7d6961f73..1d8c4ddae 100644 --- a/lib/view/page/setting/entries/app.dart +++ b/lib/view/page/setting/entries/app.dart @@ -494,13 +494,31 @@ extension _App on _AppSettingsPageState { return; } final encrypted = Cryptor.encrypt(json.encode(newSettings), pwd); - Stores.setting.box.put(encryptedKey, encrypted); + // Not stamping `lastUpdateTs`, which is what going straight to the + // box used to do. + // + // TODO: decide whether that was intentional. Editing the raw settings + // is a user edit, so leaving the timestamps alone means sync will not + // carry it to another device until something else is changed. + Stores.setting.set( + encryptedKey, + encrypted, + updateLastUpdateTsOnSet: false, + ); } else { - Stores.setting.box.putAll(newSettings); + for (final entry in newSettings.entries) { + final value = entry.value; + if (value == null) continue; + Stores.setting.set( + entry.key, + value as Object, + updateLastUpdateTsOnSet: false, + ); + } final newKeys = newSettings.keys.toSet(); final removedKeys = initialKeys.where((e) => !newKeys.contains(e)); for (final key in removedKeys) { - Stores.setting.box.delete(key); + Stores.setting.remove(key, updateLastUpdateTsOnRemove: false); } } } catch (e, trace) { diff --git a/lib/view/page/setting/entries/editor.dart b/lib/view/page/setting/entries/editor.dart index 74e15fb7a..048442a24 100644 --- a/lib/view/page/setting/entries/editor.dart +++ b/lib/view/page/setting/entries/editor.dart @@ -1,7 +1,7 @@ part of '../entry.dart'; extension _Editor on _AppSettingsPageState { - Future _pickEditorTheme(HiveProp property) async { + Future _pickEditorTheme(SqliteProp property) async { final selected = await context.showPickSingleDialog( title: libL10n.theme, items: themeMap.keys.toList(), @@ -102,7 +102,7 @@ extension _Editor on _AppSettingsPageState { ); } - void _showFontFamilyDialog(HiveProp property) { + void _showFontFamilyDialog(SqliteProp property) { showTextSettingDialog( title: libL10n.font, initialValue: property.fetch() ?? '', @@ -113,7 +113,7 @@ extension _Editor on _AppSettingsPageState { ); } - void _showFontSizeDialog(HiveProp property) { + void _showFontSizeDialog(SqliteProp property) { final ctrl = TextEditingController(text: property.fetch().toString()); void onSave() { context.popDialog(); diff --git a/lib/view/page/setting/entry.dart b/lib/view/page/setting/entry.dart index 4496d5fd4..d3e20a258 100644 --- a/lib/view/page/setting/entry.dart +++ b/lib/view/page/setting/entry.dart @@ -95,8 +95,7 @@ class _SettingsPageState extends ConsumerState { } void _clearAllSettings() { - final keys = SettingStore.instance.box.keys; - SettingStore.instance.box.deleteAll(keys); + SettingStore.instance.clear(); Toast.success(libL10n.success); } diff --git a/packages/fl_lib b/packages/fl_lib index 28dbd5424..3903e04ff 160000 --- a/packages/fl_lib +++ b/packages/fl_lib @@ -1 +1 @@ -Subproject commit 28dbd54247f3862c09b51e772680f4f2291deb06 +Subproject commit 3903e04ff03ef324b953f7d73e60f4efeb1efe01 diff --git a/test/agent_conversation_store_test.dart b/test/agent_conversation_store_test.dart index bab34c7ab..15120de7e 100644 --- a/test/agent_conversation_store_test.dart +++ b/test/agent_conversation_store_test.dart @@ -1,31 +1,18 @@ -import 'dart:io'; - +import 'package:fl_lib/fl_lib.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:hive_ce/hive.dart'; import 'package:server_box/data/model/ai/agent_conversation.dart'; import 'package:server_box/data/model/ai/ask_ai_models.dart'; import 'package:server_box/data/store/agent_conversation.dart'; void main() { - late Directory tempDir; - late Box box; late AgentConversationStore store; - setUpAll(() async { - tempDir = await Directory.systemTemp.createTemp('server-box-agent-test-'); - Hive.init(tempDir.path); - box = await Hive.openBox('agent_conversation_test'); - store = AgentConversationStore.forBox(box); - }); - - setUp(() async { - await box.clear(); + setUp(() { + SqliteDb.openInMemory(); + store = AgentConversationStore.forTest(); }); - tearDownAll(() async { - await box.close(); - await tempDir.delete(recursive: true); - }); + tearDown(SqliteDb.close); test('round-trips protocol-complete conversation items', () { const command = AskAiCommand( diff --git a/test/agent_shell_view_test.dart b/test/agent_shell_view_test.dart index 71289fe1b..b5b6ccbf5 100644 --- a/test/agent_shell_view_test.dart +++ b/test/agent_shell_view_test.dart @@ -1,11 +1,9 @@ import 'dart:io'; -import 'dart:typed_data'; import 'package:fl_lib/fl_lib.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:hive_ce/hive.dart'; import 'package:server_box/core/extension/context/locale.dart' as app_locale; import 'package:server_box/data/model/ai/ask_ai_models.dart'; import 'package:server_box/data/provider/ai/agent_session.dart'; @@ -30,36 +28,25 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); late Directory tempDir; - late Box settingBox; - late Box conversationBox; setUp(() async { tempDir = await Directory.systemTemp.createTemp('server-box-agent-shell-'); - Hive.init(tempDir.path); + SqliteDb.openInMemory(); // In memory rather than on disk. A widget test body runs in a fake-async // zone; a real file write started there completes on a callback that zone // is no longer pumping, so the box's write lock is never released and // closing it in `tearDown` blocks — which hangs the whole run, not just // this file. The shell persists its mode the moment it changes, so every // test here writes. - settingBox = await Hive.openBox( - 'setting_test', - bytes: Uint8List(0), - ); - conversationBox = await Hive.openBox( - 'agent_conversation_test', - bytes: Uint8List(0), - ); - getIt.registerSingleton(SettingStore.forBox(settingBox)); + getIt.registerSingleton(SettingStore.forTest()); getIt.registerSingleton( - AgentConversationStore.forBox(conversationBox), + AgentConversationStore.forTest(), ); }); tearDown(() async { await getIt.reset(); - await settingBox.close(); - await conversationBox.close(); + await SqliteDb.close(); await tempDir.delete(recursive: true); }); diff --git a/test/agent_view_test.dart b/test/agent_view_test.dart index 0d5ab631f..ea885c17a 100644 --- a/test/agent_view_test.dart +++ b/test/agent_view_test.dart @@ -1,10 +1,9 @@ import 'dart:io'; -import 'dart:typed_data'; +import 'package:fl_lib/fl_lib.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:hive_ce/hive.dart'; import 'package:riverpod/misc.dart' show Override; import 'package:server_box/core/extension/context/locale.dart' as app_locale; import 'package:server_box/data/model/ai/agent_conversation.dart'; @@ -36,35 +35,24 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); late Directory tempDir; - late Box settingBox; - late Box conversationBox; setUp(() async { tempDir = await Directory.systemTemp.createTemp('server-box-agent-view-'); - Hive.init(tempDir.path); + SqliteDb.openInMemory(); // In memory: the return-key tests below write the setting they are // about, and a real file write started inside a `testWidgets` body // completes on a callback the fake-async zone is no longer pumping — // so the box's write lock is never released and `close()` in tearDown // blocks forever, with no failure to say which file did it. - settingBox = await Hive.openBox( - 'setting_test', - bytes: Uint8List(0), - ); - conversationBox = await Hive.openBox( - 'agent_conversation_test', - bytes: Uint8List(0), - ); - getIt.registerSingleton(SettingStore.forBox(settingBox)); + getIt.registerSingleton(SettingStore.forTest()); getIt.registerSingleton( - AgentConversationStore.forBox(conversationBox), + AgentConversationStore.forTest(), ); }); tearDown(() async { await getIt.reset(); - await settingBox.close(); - await conversationBox.close(); + await SqliteDb.close(); await tempDir.delete(recursive: true); }); diff --git a/test/app_locale_test.dart b/test/app_locale_test.dart index b0ab6ffb8..adbd81f7f 100644 --- a/test/app_locale_test.dart +++ b/test/app_locale_test.dart @@ -1,31 +1,25 @@ -import 'dart:io'; - +import 'package:fl_lib/fl_lib.dart'; import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:hive_ce/hive.dart'; import 'package:server_box/app.dart'; import 'package:server_box/data/res/store.dart'; import 'package:server_box/data/store/setting.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - - late Directory tempDir; - late Box box; late SettingStore setting; setUp(() async { - tempDir = await Directory.systemTemp.createTemp('server-box-app-test-'); - Hive.init(tempDir.path); - box = await Hive.openBox('setting_test'); - setting = SettingStore.forBox(box); + SqliteDb.openInMemory(); + setting = SettingStore.forTest(); getIt.registerSingleton(setting); FlutterSecureStorage.setMockInitialValues({}); }); tearDown(() async { await getIt.reset(); + await SqliteDb.close(); }); testWidgets('updates the onboarding locale when the setting changes', ( diff --git a/test/file_browser_test.dart b/test/file_browser_test.dart index ab5ca8316..b721b79d0 100644 --- a/test/file_browser_test.dart +++ b/test/file_browser_test.dart @@ -6,7 +6,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:hive_ce/hive.dart'; import 'package:server_box/data/model/file/file_backend.dart'; import 'package:server_box/data/model/file/file_ref.dart'; import 'package:server_box/data/res/store.dart'; @@ -96,18 +95,16 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); late Directory tempDir; - late Box settingBox; setUp(() async { tempDir = await Directory.systemTemp.createTemp('server-box-browser-'); - Hive.init(tempDir.path); - settingBox = await Hive.openBox('setting_test'); - getIt.registerSingleton(SettingStore.forBox(settingBox)); + SqliteDb.openInMemory(); + getIt.registerSingleton(SettingStore.forTest()); }); tearDown(() async { await getIt.reset(); - await settingBox.close(); + await SqliteDb.close(); await tempDir.delete(recursive: true); }); diff --git a/test/file_tab_restore_test.dart b/test/file_tab_restore_test.dart index c53107b17..c7f903317 100644 --- a/test/file_tab_restore_test.dart +++ b/test/file_tab_restore_test.dart @@ -1,11 +1,10 @@ import 'dart:convert'; import 'dart:io'; -import 'dart:typed_data'; +import 'package:fl_lib/fl_lib.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:hive_ce/hive.dart'; import 'package:server_box/data/res/store.dart'; import 'package:server_box/data/store/history.dart'; import 'package:server_box/data/store/private_key.dart'; @@ -32,25 +31,17 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); late Directory tempDir; - late Box settingBox; - late Box serverBox; - late Box keyBox; - late Box historyBox; setUp(() async { tempDir = await Directory.systemTemp.createTemp('server-box-filetab-'); - Hive.init(tempDir.path); + SqliteDb.openInMemory(); // In memory: this page saves on every change, and a file write started in // a `testWidgets` body never releases the box's lock — `close()` in // `tearDown` then hangs the whole run with no output. - settingBox = await Hive.openBox('setting_test', bytes: Uint8List(0)); - serverBox = await Hive.openBox('server_test', bytes: Uint8List(0)); - keyBox = await Hive.openBox('key_test', bytes: Uint8List(0)); - historyBox = await Hive.openBox('history_test', bytes: Uint8List(0)); - getIt.registerSingleton(SettingStore.forBox(settingBox)); - getIt.registerSingleton(ServerStore.forBox(serverBox)); - getIt.registerSingleton(PrivateKeyStore.forBox(keyBox)); - getIt.registerSingleton(HistoryStore.forBox(historyBox)); + getIt.registerSingleton(SettingStore.forTest()); + getIt.registerSingleton(ServerStore.forTest()); + getIt.registerSingleton(PrivateKeyStore.forTest()); + getIt.registerSingleton(HistoryStore.forTest()); // A restored server session opens its browser, which now connects rather // than reporting that it is not connected — so an unreachable fixture // leaves a timer running. One second, pumped past below. @@ -59,10 +50,7 @@ void main() { tearDown(() async { await getIt.reset(); - await settingBox.close(); - await serverBox.close(); - await keyBox.close(); - await historyBox.close(); + await SqliteDb.close(); await tempDir.delete(recursive: true); }); diff --git a/test/file_transfer_test.dart b/test/file_transfer_test.dart index 73beda0a3..bcea61cff 100644 --- a/test/file_transfer_test.dart +++ b/test/file_transfer_test.dart @@ -1,8 +1,8 @@ import 'dart:async'; import 'dart:io'; +import 'package:fl_lib/fl_lib.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:hive_ce/hive.dart'; import 'package:server_box/data/model/file/file_ref.dart'; import 'package:server_box/data/model/file/transfer.dart'; import 'package:server_box/data/model/file/transfer_status.dart'; @@ -45,28 +45,20 @@ void main() { group('where a transfer runs', () { late Directory tempDir; - late Box settingBox; - late Box serverBox; - late Box keyBox; setUp(() async { tempDir = await Directory.systemTemp.createTemp('server-box-transfer-'); - Hive.init(tempDir.path); - settingBox = await Hive.openBox('setting_test'); - serverBox = await Hive.openBox('server_test'); - keyBox = await Hive.openBox('key_test'); - getIt.registerSingleton(SettingStore.forBox(settingBox)); + SqliteDb.openInMemory(); + getIt.registerSingleton(SettingStore.forTest()); // Building an `SftpFileRef` reads keys and jump servers out of these, // which is the work these tests are checking happens on this side. - getIt.registerSingleton(ServerStore.forBox(serverBox)); - getIt.registerSingleton(PrivateKeyStore.forBox(keyBox)); + getIt.registerSingleton(ServerStore.forTest()); + getIt.registerSingleton(PrivateKeyStore.forTest()); }); tearDown(() async { await getIt.reset(); - await settingBox.close(); - await serverBox.close(); - await keyBox.close(); + await SqliteDb.close(); await tempDir.delete(recursive: true); }); diff --git a/test/hive_import_test.dart b/test/hive_import_test.dart new file mode 100644 index 000000000..97657c6f9 --- /dev/null +++ b/test/hive_import_test.dart @@ -0,0 +1,239 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:fl_lib/fl_lib.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:server_box/data/model/container/type.dart'; +import 'package:server_box/data/model/server/connection_stat.dart'; +import 'package:server_box/data/model/server/private_key_info.dart'; +import 'package:server_box/data/model/server/server_private_info.dart'; +import 'package:server_box/data/model/server/snippet.dart'; +import 'package:server_box/data/model/server/ssh_credential.dart'; +import 'package:server_box/data/res/store.dart'; +import 'package:server_box/data/store/schema.dart'; +import 'package:server_box/hive/hive_registrar.g.dart'; +import 'package:server_box/hive/spi_legacy_adapter.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// What an existing install's data looks like after the move off Hive. +/// +/// This is the one pass over a user's real records, and it is not repeatable — +/// once the marker is written the boxes are never read again. So it is worth +/// checking against boxes written the way the app wrote them, through +/// `HiveStore`, rather than against a hand-built map. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + + setUpAll(() async { + tempDir = await Directory.systemTemp.createTemp('sbm-hive-import-'); + // `HiveStore.boxDir` resolves to this on an unsandboxed desktop build, and + // it is `late final`, so this stands in for `Paths.init` — which would need + // path_provider and would write into the real documents directory. + Paths.doc = tempDir.path; + + FlutterSecureStorage.setMockInitialValues({ + 'hivePwd': base64UrlEncode( + Uint8List.fromList( + List.generate(32, (_) => Random(1).nextInt(256)), + ), + ), + }); + SharedPreferences.setMockInitialValues({}); + await PrefStore.shared.init(); + + Hive.init(tempDir.path); + Hive.registerAdapters(); + Hive.registerAdapter(SpiLegacyAdapter()); + }); + + tearDownAll(() async { + await Hive.close(); + await tempDir.delete(recursive: true); + }); + + setUp(() async { + SqliteDb.openInMemory(); + }); + + tearDown(() async { + await getIt.reset(); + await SqliteDb.close(); + await Hive.close(); + // By file rather than `Hive.deleteFromDisk`, which only knows about boxes + // the current instance still has open — `seedHive` closes them all. + for (final f in tempDir.listSync()) { + if (f is File) f.deleteSync(); + } + Hive.init(tempDir.path); + }); + + /// Fills the boxes the way a running app would have left them. + Future seedHive() async { + Future open(String name) async { + final store = HiveStore(name); + await store.init(); + return store; + } + + final server = await open('server'); + await server.box.put( + 'srv-1', + const Spi( + id: 'srv-1', + name: 'prod', + ssh: SshCredential(ip: '10.0.0.1', user: 'root', port: 2222), + tags: ['a'], + ), + ); + + final snippet = await open('snippet'); + await snippet.box.put('uptime', const Snippet(name: 'uptime', script: 'w')); + + final key = await open('key'); + await key.box.put( + 'k1', + const PrivateKeyInfo(id: 'k1', key: 'PRIVATE'), + ); + + final setting = await open('setting'); + await setting.box.put('timeOut', 9); + await setting.box.put('recordHistory', false); + await setting.box.put('homeTabs', ['server', 'ssh']); + + final history = await open('history'); + await history.box.put('sftpPath', ['/etc', '/var']); + await history.box.put('sshTabs', '[]'); + + final docker = await open('docker'); + await docker.box.put('containerHostdocker', 'unix:///var/run/docker.sock'); + + final stats = await open('connection_stats'); + await stats.box.put( + 'srv-1_1000', + ConnectionStat( + serverId: 'srv-1', + serverName: 'prod', + timestamp: DateTime.now(), + result: ConnectionResult.success, + durationMs: 12, + ), + ); + + final agent = await open('agent_conversation'); + await agent.box.put('active::srv-1', 'conv-1'); + + await Hive.close(); + } + + test('every box lands in the store that replaced it', () async { + await seedHive(); + await Stores.init(); + + final spi = Stores.server.fetchOneRaw('srv-1'); + expect(spi?.name, 'prod'); + expect(spi?.ssh?.ip, '10.0.0.1'); + expect(spi?.ssh?.port, 2222); + expect(spi?.tags, ['a']); + + expect(Stores.snippet.fetchOneRaw('uptime')?.script, 'w'); + expect(Stores.key.fetchOneRaw('k1')?.key, 'PRIVATE'); + + expect(Stores.setting.timeout.get(), 9); + expect(Stores.setting.recordHistory.get(), false); + expect(Stores.setting.homeTabs.get().map((e) => e.name), ['server', 'ssh']); + + expect(Stores.history.sftpGoPath.all, ['/etc', '/var']); + expect( + Stores.container.fetch('', ContainerType.docker), + 'unix:///var/run/docker.sock', + ); + expect( + Stores.connectionStats.getConnectionHistory('srv-1').single.serverName, + 'prod', + ); + expect(Stores.agentConversation.activeConversationId('srv-1'), 'conv-1'); + }); + + test('the records are readable as JSON, not as adapter bytes', () async { + await seedHive(); + await Stores.init(); + + // The point of the exercise: nothing decodes through a TypeAdapter any + // more, so what is in the row has to stand on its own. + final raw = SqliteDb.instance.select( + 'SELECT value FROM kv WHERE store = ? AND key = ?;', + ['server', 'srv-1'], + ).single['value'] as String; + final decoded = json.decode(raw) as Map; + expect(decoded['name'], 'prod'); + expect((decoded['ssh'] as Map)['ip'], '10.0.0.1'); + }); + + test('it records the current schema version', () async { + await seedHive(); + await Stores.init(); + expect(SchemaVersion.stored, SchemaVersion.current); + }); + + test('a second launch does not import again', () async { + await seedHive(); + await Stores.init(); + + // Stand in for a user edit made after the upgrade. If the import ran a + // second time it would put the old value back. + Stores.setting.timeout.put(42); + await getIt.reset(); + + await Stores.init(); + expect(Stores.setting.timeout.get(), 42); + }); + + test('the plaintext index box is deleted, the encrypted boxes are kept', + () async { + await seedHive(); + // The one box the app opened without a cipher. + final index = File(tempDir.path.joinPath('conn_stats_index.hive')); + await index.writeAsString('idx_srv-1'); + + await Stores.init(); + + expect(index.existsSync(), isFalse, reason: 'it was never encrypted'); + expect( + File(tempDir.path.joinPath('server_enc.hive')).existsSync(), + isTrue, + reason: 'kept so a bad import can be rolled back to', + ); + }); + + test('the index is rebuilt from the records it no longer ships with', + () async { + await seedHive(); + await Stores.init(); + + expect(Stores.connectionStats.indexKeys, contains('idx_srv-1')); + }); + + test('a fresh install imports nothing and is already current', () async { + // No boxes on disk at all. + await Stores.init(); + + expect(SchemaVersion.stored, SchemaVersion.current); + expect(Stores.server.fetch(), isEmpty); + }); + + test('importing does not present itself as a local edit', () async { + await seedHive(); + await Stores.init(); + + // `Stores.lastModTime` drives which side of a sync wins. Stamping each row + // as it landed would make a device that has just finished reading its own + // disk claim the newer copy of everything. + expect(Stores.lastModTime, 0); + }); +} diff --git a/test/identity_file_key_test.dart b/test/identity_file_key_test.dart index c15642667..1bb72d3f0 100644 --- a/test/identity_file_key_test.dart +++ b/test/identity_file_key_test.dart @@ -13,10 +13,9 @@ library; import 'dart:async'; import 'dart:io'; -import 'dart:typed_data'; +import 'package:fl_lib/fl_lib.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:hive_ce/hive.dart'; import 'package:server_box/core/utils/server.dart'; import 'package:server_box/data/model/app/error.dart'; import 'package:server_box/data/model/server/private_key_info.dart'; @@ -68,30 +67,22 @@ void main() { }); group('migrateIdentityFilePaths', () { - late Directory tempDir; - late Box serverBox; - late Box keyBox; late ServerStore servers; late PrivateKeyStore keys; - setUp(() async { - tempDir = await Directory.systemTemp.createTemp('identity-file-'); - Hive.init(tempDir.path); + setUp(() { // In memory: a real write started in a test body completes on a callback // the fake-async zone is no longer pumping, and `close()` then blocks - serverBox = await Hive.openBox('server_test', bytes: Uint8List(0)); - keyBox = await Hive.openBox('key_test', bytes: Uint8List(0)); - servers = ServerStore.forBox(serverBox); - keys = PrivateKeyStore.forBox(keyBox); + SqliteDb.openInMemory(); + servers = ServerStore.forTest(); + keys = PrivateKeyStore.forTest(); }); - tearDown(() async { - await serverBox.close(); - await keyBox.close(); - await tempDir.delete(recursive: true); - }); + tearDown(SqliteDb.close); - Spi reread(String id) => servers.get(id)!; + /// Rows are JSON now, so a plain `get` would hand back the map. This + /// is the typed read. + Spi reread(String id) => servers.fetchOneRaw(id)!; test('a path that names no stored key moves to keyPath', () { servers.put( diff --git a/test/pane_width_test.dart b/test/pane_width_test.dart index fe268acdf..82513d657 100644 --- a/test/pane_width_test.dart +++ b/test/pane_width_test.dart @@ -1,12 +1,10 @@ import 'dart:io'; -import 'dart:typed_data'; import 'package:fl_lib/fl_lib.dart'; import 'package:fl_lib/generated/l10n/lib_l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:hive_ce/hive.dart'; import 'package:server_box/data/provider/server/selection.dart'; import 'package:server_box/data/res/store.dart'; import 'package:server_box/data/store/private_key.dart'; @@ -34,22 +32,16 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); late Directory tempDir; - late Box settingBox; - late Box serverBox; - late Box keyBox; setUp(() async { tempDir = await Directory.systemTemp.createTemp('server-box-pane-'); - Hive.init(tempDir.path); + SqliteDb.openInMemory(); // In memory: this page persists tags, order and the column's own width, // and a real write started in a `testWidgets` body never lets go of the // box's lock. - settingBox = await Hive.openBox('setting_test', bytes: Uint8List(0)); - serverBox = await Hive.openBox('server_test', bytes: Uint8List(0)); - keyBox = await Hive.openBox('key_test', bytes: Uint8List(0)); - getIt.registerSingleton(SettingStore.forBox(settingBox)); - getIt.registerSingleton(ServerStore.forBox(serverBox)); - getIt.registerSingleton(PrivateKeyStore.forBox(keyBox)); + getIt.registerSingleton(SettingStore.forTest()); + getIt.registerSingleton(ServerStore.forTest()); + getIt.registerSingleton(PrivateKeyStore.forTest()); // 0 is what `normalizeServerStatusRefreshSeconds` reads as off; its // periodic timer would otherwise outlive the tree and fail the run. Stores.setting.serverStatusUpdateInterval.put(0); @@ -57,9 +49,7 @@ void main() { tearDown(() async { await getIt.reset(); - await settingBox.close(); - await serverBox.close(); - await keyBox.close(); + await SqliteDb.close(); await tempDir.delete(recursive: true); }); diff --git a/test/server_card_gesture_test.dart b/test/server_card_gesture_test.dart index 3d2283c96..feda9b84f 100644 --- a/test/server_card_gesture_test.dart +++ b/test/server_card_gesture_test.dart @@ -1,5 +1,4 @@ import 'dart:io'; -import 'dart:typed_data'; import 'package:fl_lib/fl_lib.dart'; import 'package:fl_lib/generated/l10n/lib_l10n.dart'; @@ -7,7 +6,6 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:hive_ce/hive.dart'; import 'package:server_box/data/res/store.dart'; import 'package:server_box/data/store/private_key.dart'; import 'package:server_box/data/store/server.dart'; @@ -29,21 +27,15 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); late Directory tempDir; - late Box settingBox; - late Box serverBox; - late Box keyBox; setUp(() async { tempDir = await Directory.systemTemp.createTemp('server-box-card-'); - Hive.init(tempDir.path); + SqliteDb.openInMemory(); // In memory: this page persists tags and order, and a real write started // in a `testWidgets` body never lets go of the box's lock. - settingBox = await Hive.openBox('setting_test', bytes: Uint8List(0)); - serverBox = await Hive.openBox('server_test', bytes: Uint8List(0)); - keyBox = await Hive.openBox('key_test', bytes: Uint8List(0)); - getIt.registerSingleton(SettingStore.forBox(settingBox)); - getIt.registerSingleton(ServerStore.forBox(serverBox)); - getIt.registerSingleton(PrivateKeyStore.forBox(keyBox)); + getIt.registerSingleton(SettingStore.forTest()); + getIt.registerSingleton(ServerStore.forTest()); + getIt.registerSingleton(PrivateKeyStore.forTest()); // No auto-refresh: 0 is what `normalizeServerStatusRefreshSeconds` reads // as off, and its periodic timer would otherwise outlive the tree and // fail the run on a pending timer. @@ -52,9 +44,7 @@ void main() { tearDown(() async { await getIt.reset(); - await settingBox.close(); - await serverBox.close(); - await keyBox.close(); + await SqliteDb.close(); await tempDir.delete(recursive: true); }); diff --git a/test/server_func_btn_test.dart b/test/server_func_btn_test.dart index 9d9accbee..4097428c5 100644 --- a/test/server_func_btn_test.dart +++ b/test/server_func_btn_test.dart @@ -1,7 +1,5 @@ -import 'dart:io'; - +import 'package:fl_lib/fl_lib.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:hive_ce/hive.dart'; import 'package:server_box/data/model/app/menu/server_func.dart'; import 'package:server_box/data/res/store.dart'; import 'package:server_box/data/store/setting.dart'; @@ -9,32 +7,25 @@ import 'package:server_box/data/store/setting.dart'; /// What `ServerFuncBtn.autoAddNewFuncs` does to a row the user has already /// arranged, across an upgrade. void main() { - late Directory tempDir; - late Box box; - - setUpAll(() async { - tempDir = await Directory.systemTemp.createTemp('server-box-func-btn-'); - Hive.init(tempDir.path); - box = await Hive.openBox('setting_test'); - getIt.registerSingleton(SettingStore.forBox(box)); - }); + late SettingStore setting; - setUp(() async { - await box.clear(); + setUp(() { + SqliteDb.openInMemory(); + setting = SettingStore.forTest(); + getIt.registerSingleton(setting); }); - tearDownAll(() async { + tearDown(() async { await getIt.reset(); - await box.close(); - await tempDir.delete(recursive: true); + await SqliteDb.close(); }); /// The stored row, as indices — what the setting actually holds. - List row() => (box.get('serverBtns') as List?)?.cast() ?? const []; + List row() => setting.get('serverBtns')?.cast() ?? const []; test('adds every entry that shipped during the upgrade', () async { // A row from before systemd (1058), portForward (1340) and power (1481). - await box.put('serverBtns', [ + setting.set('serverBtns', [ ServerFuncBtn.terminal.index, ServerFuncBtn.files.index, ]); @@ -51,7 +42,7 @@ void main() { }); test('adds nothing for an upgrade that shipped no new entry', () async { - await box.put('serverBtns', [ServerFuncBtn.terminal.index]); + setting.set('serverBtns', [ServerFuncBtn.terminal.index]); ServerFuncBtn.autoAddNewFuncs(1481, 1600); @@ -63,7 +54,7 @@ void main() { // install has been running 1500, and the user took it out of the row. An // upgrade to 1600 must not put it back — and would have, when the rule was // `to >= addedVersion` alone. - await box.put('serverBtns', [ + setting.set('serverBtns', [ ServerFuncBtn.terminal.index, ServerFuncBtn.systemd.index, ]); @@ -74,7 +65,7 @@ void main() { }); test('an entry already in the row is not added twice', () async { - await box.put('serverBtns', [ + setting.set('serverBtns', [ ServerFuncBtn.power.index, ServerFuncBtn.terminal.index, ]); @@ -94,7 +85,7 @@ void main() { ServerFuncBtn.autoAddNewFuncs(0, 1600); expect( - box.get('serverBtns'), + setting.get('serverBtns'), isNull, reason: 'nothing was written, so the defaults still apply', ); diff --git a/test/setting_store_test.dart b/test/setting_store_test.dart index ba833bb38..9730a1357 100644 --- a/test/setting_store_test.dart +++ b/test/setting_store_test.dart @@ -1,70 +1,69 @@ -import 'dart:io'; - +import 'package:fl_lib/fl_lib.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:hive_ce/hive.dart'; import 'package:server_box/data/store/setting.dart'; void main() { - late Directory tempDir; - late Box box; late SettingStore store; - setUpAll(() async { - tempDir = await Directory.systemTemp.createTemp('server-box-setting-test-'); - Hive.init(tempDir.path); - box = await Hive.openBox('setting_test'); - store = SettingStore.forBox(box); - }); - - setUp(() async { - await box.clear(); + setUp(() { + SqliteDb.openInMemory(); + store = SettingStore.forTest(); }); - tearDownAll(() async { - await box.close(); - await tempDir.delete(recursive: true); - }); + tearDown(SqliteDb.close); test('adds Agent to the legacy default home tabs once', () async { - await box.put('homeTabs', ['server', 'ssh', 'file', 'snippet']); + store.set('homeTabs', ['server', 'ssh', 'file', 'snippet']); await store.migrateHomeTabsAgent(); - expect(box.get('homeTabs'), ['server', 'ssh', 'file', 'snippet', 'agent']); - expect(box.get('homeTabsAgentMigrated'), isTrue); + expect(store.get('homeTabs'), [ + 'server', + 'ssh', + 'file', + 'snippet', + 'agent', + ]); + expect(store.get('homeTabsAgentMigrated'), isTrue); }); test('preserves a custom home tab configuration', () async { - await box.put('homeTabs', ['server', 'ssh']); + store.set('homeTabs', ['server', 'ssh']); await store.migrateHomeTabsAgent(); - expect(box.get('homeTabs'), ['server', 'ssh']); - expect(box.get('homeTabsAgentMigrated'), isTrue); + expect(store.get('homeTabs'), ['server', 'ssh']); + expect(store.get('homeTabsAgentMigrated'), isTrue); }); test('preserves home tabs that already contain Agent', () async { - await box.put('homeTabs', ['server', 'ssh', 'file', 'snippet', 'agent']); + store.set('homeTabs', ['server', 'ssh', 'file', 'snippet', 'agent']); await store.migrateHomeTabsAgent(); - expect(box.get('homeTabs'), ['server', 'ssh', 'file', 'snippet', 'agent']); - expect(box.get('homeTabsAgentMigrated'), isTrue); + expect(store.get('homeTabs'), [ + 'server', + 'ssh', + 'file', + 'snippet', + 'agent', + ]); + expect(store.get('homeTabsAgentMigrated'), isTrue); }); test('a second migration does not alter later custom tabs', () async { - await box.put('homeTabs', ['server', 'ssh', 'file', 'snippet']); + store.set('homeTabs', ['server', 'ssh', 'file', 'snippet']); await store.migrateHomeTabsAgent(); - await box.put('homeTabs', ['server', 'agent']); + store.set('homeTabs', ['server', 'agent']); await store.migrateHomeTabsAgent(); - expect(box.get('homeTabs'), ['server', 'agent']); - expect(box.get('homeTabsAgentMigrated'), isTrue); + expect(store.get('homeTabs'), ['server', 'agent']); + expect(store.get('homeTabsAgentMigrated'), isTrue); }); test('removes retired setting keys without touching active settings', () async { - await box.putAll({ + store.setAll({ 'moveOutServerTabFuncBtns': true, 'forceSinglePane': true, 'recordHistory': false, @@ -72,8 +71,17 @@ void main() { await store.removeRetiredKeys(); - expect(box.containsKey('moveOutServerTabFuncBtns'), isFalse); - expect(box.containsKey('forceSinglePane'), isFalse); - expect(box.get('recordHistory'), isFalse); + expect(store.get('moveOutServerTabFuncBtns'), isNull); + expect(store.get('forceSinglePane'), isNull); + expect(store.get('recordHistory'), isFalse); + }); + + test('a migration flag does not count as a user edit', () async { + await store.migrateHomeTabsAgent(); + + // The Hive version wrote these straight to the box to keep them out of + // `lastUpdateTs`. Sync compares that number, so a device that had only ever + // run a migration would otherwise claim the newer copy. + expect(store.lastUpdateTs, anyOf(isNull, isEmpty)); }); } diff --git a/test/settings_menu_test.dart b/test/settings_menu_test.dart index 50642717f..5cf3e99aa 100644 --- a/test/settings_menu_test.dart +++ b/test/settings_menu_test.dart @@ -1,12 +1,10 @@ import 'dart:io'; -import 'dart:typed_data'; import 'package:fl_lib/fl_lib.dart'; import 'package:fl_lib/generated/l10n/lib_l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:hive_ce/hive.dart'; import 'package:server_box/core/extension/context/locale.dart'; import 'package:server_box/data/res/store.dart'; import 'package:server_box/data/store/server.dart'; @@ -24,25 +22,20 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); late Directory tempDir; - late Box settingBox; - late Box serverBox; setUp(() async { tempDir = await Directory.systemTemp.createTemp('server-box-settings-'); - Hive.init(tempDir.path); + SqliteDb.openInMemory(); // In memory: a real write started in a `testWidgets` body never lets go of // the box's lock, and this page writes on nearly every switch. - settingBox = await Hive.openBox('setting_test', bytes: Uint8List(0)); - serverBox = await Hive.openBox('server_test', bytes: Uint8List(0)); - getIt.registerSingleton(SettingStore.forBox(settingBox)); + getIt.registerSingleton(SettingStore.forTest()); // The server order page reads it as soon as it is shown. - getIt.registerSingleton(ServerStore.forBox(serverBox)); + getIt.registerSingleton(ServerStore.forTest()); }); tearDown(() async { await getIt.reset(); - await settingBox.close(); - await serverBox.close(); + await SqliteDb.close(); await tempDir.delete(recursive: true); }); diff --git a/test/snippet_list_test.dart b/test/snippet_list_test.dart index eda7227c6..b12fcb9d8 100644 --- a/test/snippet_list_test.dart +++ b/test/snippet_list_test.dart @@ -1,12 +1,10 @@ import 'dart:io'; -import 'dart:typed_data'; import 'package:fl_lib/fl_lib.dart'; import 'package:fl_lib/generated/l10n/lib_l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:hive_ce/hive.dart'; import 'package:server_box/data/model/server/snippet.dart'; import 'package:server_box/data/res/store.dart'; import 'package:server_box/data/store/setting.dart'; @@ -26,30 +24,19 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); late Directory tempDir; - late Box settingBox; - late Box snippetBox; setUp(() async { tempDir = await Directory.systemTemp.createTemp('server-box-snippet-'); - Hive.init(tempDir.path); + SqliteDb.openInMemory(); // In memory: this page persists the pane width on every drag, and a real // write started in a `testWidgets` body never lets go of the box's lock. - settingBox = await Hive.openBox( - 'setting_test', - bytes: Uint8List(0), - ); - snippetBox = await Hive.openBox( - 'snippet_test', - bytes: Uint8List(0), - ); - getIt.registerSingleton(SettingStore.forBox(settingBox)); - getIt.registerSingleton(SnippetStore.forBox(snippetBox)); + getIt.registerSingleton(SettingStore.forTest()); + getIt.registerSingleton(SnippetStore.forTest()); }); tearDown(() async { await getIt.reset(); - await settingBox.close(); - await snippetBox.close(); + await SqliteDb.close(); await tempDir.delete(recursive: true); }); diff --git a/test/ssh_tab_restore_test.dart b/test/ssh_tab_restore_test.dart index 1051a22e1..ee2c6670d 100644 --- a/test/ssh_tab_restore_test.dart +++ b/test/ssh_tab_restore_test.dart @@ -1,11 +1,10 @@ import 'dart:convert'; import 'dart:io'; -import 'dart:typed_data'; +import 'package:fl_lib/fl_lib.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:hive_ce/hive.dart'; import 'package:server_box/core/utils/local_shell.dart'; import 'package:server_box/data/res/store.dart'; import 'package:server_box/data/ssh/terminal_source.dart'; @@ -29,38 +28,24 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); late Directory tempDir; - late Box settingBox; - late Box serverBox; - late Box keyBox; - late Box historyBox; setUp(() async { tempDir = await Directory.systemTemp.createTemp('server-box-sshtab-'); - Hive.init(tempDir.path); + SqliteDb.openInMemory(); // In memory, all four. This page writes its tab set on every change, and // a real file write started inside a `testWidgets` body completes on a // callback that zone is no longer pumping — so the box's write lock is // never released and `close()` in `tearDown` blocks for ever, with no // failure and no output to say which file did it. - settingBox = await Hive.openBox('setting_test', bytes: Uint8List(0)); - serverBox = await Hive.openBox('server_test', bytes: Uint8List(0)); - keyBox = await Hive.openBox('key_test', bytes: Uint8List(0)); - historyBox = await Hive.openBox( - 'history_test', - bytes: Uint8List(0), - ); - getIt.registerSingleton(SettingStore.forBox(settingBox)); - getIt.registerSingleton(ServerStore.forBox(serverBox)); - getIt.registerSingleton(PrivateKeyStore.forBox(keyBox)); - getIt.registerSingleton(HistoryStore.forBox(historyBox)); + getIt.registerSingleton(SettingStore.forTest()); + getIt.registerSingleton(ServerStore.forTest()); + getIt.registerSingleton(PrivateKeyStore.forTest()); + getIt.registerSingleton(HistoryStore.forTest()); }); tearDown(() async { await getIt.reset(); - await settingBox.close(); - await serverBox.close(); - await keyBox.close(); - await historyBox.close(); + await SqliteDb.close(); await tempDir.delete(recursive: true); }); diff --git a/test/terminal_clipboard_test.dart b/test/terminal_clipboard_test.dart index c2223428f..ef64a8b0f 100644 --- a/test/terminal_clipboard_test.dart +++ b/test/terminal_clipboard_test.dart @@ -1,11 +1,11 @@ import 'dart:io'; +import 'package:fl_lib/fl_lib.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:hive_ce/hive.dart'; import 'package:server_box/data/res/store.dart'; import 'package:server_box/data/ssh/terminal_session.dart'; import 'package:server_box/data/ssh/terminal_source.dart'; @@ -28,14 +28,12 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); late Directory tempDir; - late Box settingBox; final clipboard = []; setUp(() async { tempDir = await Directory.systemTemp.createTemp('server-box-term-'); - Hive.init(tempDir.path); - settingBox = await Hive.openBox('setting_test'); - getIt.registerSingleton(SettingStore.forBox(settingBox)); + SqliteDb.openInMemory(); + getIt.registerSingleton(SettingStore.forTest()); clipboard.clear(); // The real channel would reach a platform that is not here. Recorded @@ -57,7 +55,7 @@ void main() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(SystemChannels.platform, null); await getIt.reset(); - await settingBox.close(); + await SqliteDb.close(); await tempDir.delete(recursive: true); }); From 88296000d7dc3a50be2596b022f78f4e0d832ca5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Wed, 19 Aug 2026 01:34:53 +0800 Subject: [PATCH 03/16] refactor(store): make connection stats and agent conversations tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both are read the same way — everything for one server, newest first — and answering that out of a K-V store meant scanning every row in the app and sorting in memory. Connection stats went further and kept a second store of per-server key lists to avoid it, with four hand-written passes to keep those lists in step: rebuild, update-on-insert, prune-to-100, and expire-after-30-days. That is an index and two DELETE statements now. The age bound also runs on every write rather than only during a rebuild at launch, so a long-running app no longer keeps expired rows until it is restarted. Conversations keep their payload in one JSON column. Nothing queries inside the item list — it is read whole or not at all — so only the two fields that are queried are lifted out beside it. Neither is a `Store` any more, which drops `connectionStats` out of the list `lastModTime` is read from. That list decides which side of a sync wins, and connecting to a server is not an edit: every attempt was marking the device as holding the newer copy of everything. `HiveImport` takes a function per box instead of a store, since these two now parse a row rather than storing it. --- TODOS.md | 18 +- lib/data/res/store.dart | 29 +- lib/data/store/agent_conversation.dart | 225 +++++++++---- lib/data/store/connection_stats.dart | 313 ++++++++---------- .../store/migrations/m003_hive_to_sqlite.dart | 68 ++-- test/agent_conversation_store_test.dart | 84 ++++- test/agent_shell_view_test.dart | 2 +- test/agent_view_test.dart | 2 +- test/connection_stats_store_test.dart | 176 ++++++++++ test/hive_import_test.dart | 15 +- 10 files changed, 643 insertions(+), 289 deletions(-) create mode 100644 test/connection_stats_store_test.dart diff --git a/TODOS.md b/TODOS.md index 2e4fa47d5..92caaae14 100644 --- a/TODOS.md +++ b/TODOS.md @@ -296,19 +296,23 @@ agent 的那条完整路径。 ## Hive → SQLite:分阶段做,以及加密怎么落 -**状态(2026-08-19):第一阶段已合入,第二阶段未做。** +**状态(2026-08-19):两个阶段都已合入。** -已做:`SqliteStore` 在 fl_lib(`store/sqlite.dart`),九个 store 全部换过去, -写入一律 JSON,75 处 `.box.` 已收敛,`HiveImport` 负责一次性导入并记 schema -v4。`conn_stats_index` 那个未加密的盒子已经进了加密库,明文文件在导入时删除。 +已做: +- `SqliteStore` 在 fl_lib(`store/sqlite.dart`),七个 K-V store 换过去,写入 + 一律 JSON,75 处 `.box.` 已收敛。 +- `connection_stats`(`conn_stat` 表)和 `agent_conversation` + (`agent_conversation` + `agent_active` 两表)已关系化,各带索引。 + `conn_stats_index` 那个未加密的盒子随之消失,明文文件在导入时删除。 +- `HiveImport` 负责一次性导入并记 schema v4。 -未做: -- 第二阶段(把 `connection_stats` / `agent_conversation` 关系化)。现在两者仍是 - K-V,索引维护还是手写的。 +未做 / 遗留: - **`lib/hive/`、`hive_ce*` 依赖、`main.dart` 里的 `Hive.initFlutter()` 都还在。** 17 个 TypeAdapter 现在只用于一件事:让 `HiveImport` 读得懂旧盒子。没有任何 代码再往 Hive 写。这些要等到没有受支持的安装还停在 Hive 上才能删,`HiveImport` 和 `SpiLegacyAdapter` 同批(代码里已标 TODO)。 +- `~/Library/Application Support/ServerBox/app.db` 那批残留和 + `sandbox_import.dart` 里对它的特判仍未清理(见本节末尾)。 **一处没有覆盖到的路径:v2 记录的导入。** `HiveImport` 会把 `LegacySpiV2` 转成 `Spi`(原 `SpiNestSshMigration` 做的事),但没有测试跑过它 —— diff --git a/lib/data/res/store.dart b/lib/data/res/store.dart index f792bae92..3e090d4b5 100644 --- a/lib/data/res/store.dart +++ b/lib/data/res/store.dart @@ -22,27 +22,27 @@ abstract final class Stores { static HistoryStore get history => getIt(); static AgentConversationStore get agentConversation => getIt(); - // Keep the legacy box registered so existing connection stats DB files remain intact. static ConnectionStatsStore get connectionStats => getIt(); static PortForwardStore get portForward => getIt(); - /// All stores that need backup - static List get _allBackup => [ + /// The stores whose contents count as something the user changed. + /// + /// [lastModTime] is read off these, and sync uses that number to decide which + /// side wins — so what belongs here is what a user edits, not what the app + /// records. `connectionStats` used to be in this list and is not: connecting + /// to a server is not an edit, and every attempt was marking the device as + /// holding the newer copy of everything. + static List get _kvStores => [ setting, server, container, key, snippet, history, - connectionStats, portForward, ]; - /// Stores initialized locally. Agent conversations intentionally stay out of - /// backup and sync because they may contain terminal output and reasoning. - static List get _allStores => [..._allBackup, agentConversation]; - static Future init() async { getIt.registerLazySingleton(() => SettingStore.instance); getIt.registerLazySingleton(() => ServerStore.instance); @@ -62,7 +62,12 @@ abstract final class Stores { () => PortForwardStore.instance, ); - await Future.wait(_allStores.map((store) => store.init())); + await Future.wait([ + ..._kvStores.map((store) => store.init()), + // Their own tables rather than rows in `kv`, so they create those instead. + connectionStats.init(), + agentConversation.init(), + ]); // Before every fixup below. Each of them writes a flag meaning "this device // has been dealt with", and running them against the empty stores would set @@ -76,15 +81,11 @@ abstract final class Stores { // Migrate sshConnectionMode from old int values to bool setting.migrateSshConnectionMode(); await setting.migrateHomeTabsAgent(); - - if (connectionStats.indexKeys.isEmpty) { - await connectionStats.rebuildIndexAndCompact(); - } } static int get lastModTime { var lastModTime = 0; - for (final store in _allBackup) { + for (final store in _kvStores) { final last = store.lastUpdateTs; if (last == null) { continue; diff --git a/lib/data/store/agent_conversation.dart b/lib/data/store/agent_conversation.dart index 78448cdba..d360eb4b7 100644 --- a/lib/data/store/agent_conversation.dart +++ b/lib/data/store/agent_conversation.dart @@ -1,25 +1,34 @@ +import 'dart:async'; +import 'dart:convert'; + import 'package:fl_lib/fl_lib.dart'; import 'package:meta/meta.dart'; import 'package:server_box/data/model/ai/agent_conversation.dart'; import 'package:server_box/data/model/ai/ask_ai_models.dart'; +import 'package:sqlite3/sqlite3.dart'; -class AgentConversationStore extends SqliteStore { - AgentConversationStore._() - : super( - 'agent_conversation', - updateLastUpdateTsOnClear: false, - updateLastUpdateTsOnRemove: false, - updateLastUpdateTsOnSet: false, - ); +/// Agent conversations, one row each, plus which one is open per server. +/// +/// Tables rather than a K-V store because both reads are per server: "every +/// conversation for this server, newest first" was a scan of every conversation +/// in the app followed by an in-memory sort, and the 30-per-server cap was that +/// same scan again. Both are indexed queries here. +/// +/// The conversation stays one JSON column. Nothing queries inside the item list +/// — it is read whole or not at all — and the two fields that are queried are +/// lifted out beside it. +/// +/// Left out of backup and sync on purpose: these may contain terminal output +/// and reasoning. +class AgentConversationStore { + AgentConversationStore._() : _suffix = ''; + /// A distinct table name, so a test on `SqliteDb.openInMemory()` cannot + /// collide with another test's rows. @visibleForTesting - AgentConversationStore.forTest() - : super( - 'agent_conversation_test', - updateLastUpdateTsOnClear: false, - updateLastUpdateTsOnRemove: false, - updateLastUpdateTsOnSet: false, - ); + AgentConversationStore.forTest() : _suffix = '_test'; + + final String _suffix; static final instance = AgentConversationStore._(); @@ -27,22 +36,65 @@ class AgentConversationStore extends SqliteStore { static const maxItemsPerConversation = 240; static const maxCharactersPerConversation = 512000; + /// Key prefixes of the Hive box these tables replaced. Only [importRow] + /// still needs them. + /// + /// TODO: delete with `HiveImport`. static const _conversationPrefix = 'conversation::'; static const _activePrefix = 'active::'; + Database get _db => SqliteDb.instance; + String get _conv => 'agent_conversation$_suffix'; + String get _active => 'agent_active$_suffix'; + + final _changes = StreamController.broadcast(); + + /// Fires after any write here. + /// + /// What `box.watch()` was: a view showing the conversation list has to notice + /// a write it did not make itself, and every write goes through this class. + Stream watch() => _changes.stream; + + Future init() async { + _db.execute( + 'CREATE TABLE IF NOT EXISTS $_conv (' + ' id TEXT NOT NULL PRIMARY KEY,' + ' server_id TEXT NOT NULL,' + ' updated_at INTEGER NOT NULL,' + ' data TEXT NOT NULL' + ') WITHOUT ROWID;', + ); + _db.execute( + 'CREATE INDEX IF NOT EXISTS idx_${_conv}_server_updated ' + 'ON $_conv(server_id, updated_at DESC);', + ); + _db.execute( + 'CREATE TABLE IF NOT EXISTS $_active (' + ' server_id TEXT NOT NULL PRIMARY KEY,' + ' conversation_id TEXT NOT NULL' + ') WITHOUT ROWID;', + ); + } + List fetchForServer(String serverId) { - final conversations = []; - for (final key in keys()) { - if (!key.startsWith(_conversationPrefix)) continue; - final conversation = _conversationFromValue(get(key)); - if (conversation?.serverId == serverId) conversations.add(conversation!); + final rows = _db.select( + 'SELECT data FROM $_conv WHERE server_id = ? ORDER BY updated_at DESC;', + [serverId], + ); + final result = []; + for (final row in rows) { + final conversation = _decode(row['data'] as String); + if (conversation != null) result.add(conversation); } - conversations.sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); - return conversations; + return result; } AgentConversation? fetch(String conversationId) { - return _conversationFromValue(get(_conversationKey(conversationId))); + final rows = _db.select('SELECT data FROM $_conv WHERE id = ?;', [ + conversationId, + ]); + if (rows.isEmpty) return null; + return _decode(rows.single['data'] as String); } AgentConversation? fetchActive(String serverId) { @@ -53,8 +105,13 @@ class AgentConversationStore extends SqliteStore { } String? activeConversationId(String serverId) { - final value = get(_activeKey(serverId)); - return value != null && value.isNotEmpty ? value : null; + final rows = _db.select( + 'SELECT conversation_id FROM $_active WHERE server_id = ?;', + [serverId], + ); + if (rows.isEmpty) return null; + final value = rows.single['conversation_id'] as String; + return value.isNotEmpty ? value : null; } AgentConversation create({ @@ -86,31 +143,24 @@ class AgentConversationStore extends SqliteStore { title: _normalizeTitle(conversation.title, conversation.items), items: trimItemsForStorage(conversation.items), ); - final saved = set( - _conversationKey(normalized.id), - normalized.toJson(), - updateLastUpdateTsOnSet: false, - ); - if (!saved) return false; - if (setActive) { - set( - _activeKey(normalized.serverId), - normalized.id, - updateLastUpdateTsOnSet: false, - ); + try { + _upsert(normalized); + } catch (e) { + dprint('Saving AgentConversation', e); + return false; } + if (setActive) _setActiveRow(normalized.serverId, normalized.id); _pruneServer(normalized.serverId); + _changes.add(null); return true; } bool setActive(String serverId, String conversationId) { final conversation = fetch(conversationId); if (conversation == null || conversation.serverId != serverId) return false; - return set( - _activeKey(serverId), - conversationId, - updateLastUpdateTsOnSet: false, - ); + _setActiveRow(serverId, conversationId); + _changes.add(null); + return true; } bool rename(String conversationId, String title) { @@ -125,24 +175,21 @@ class AgentConversationStore extends SqliteStore { void deleteConversation(String serverId, String conversationId) { final conversation = fetch(conversationId); if (conversation == null || conversation.serverId != serverId) return; - remove(_conversationKey(conversationId), updateLastUpdateTsOnRemove: false); + _db.execute('DELETE FROM $_conv WHERE id = ?;', [conversationId]); if (activeConversationId(serverId) != conversationId) return; final remaining = fetchForServer(serverId); if (remaining.isEmpty) { - remove(_activeKey(serverId), updateLastUpdateTsOnRemove: false); + _db.execute('DELETE FROM $_active WHERE server_id = ?;', [serverId]); } else { setActive(serverId, remaining.first.id); } + _changes.add(null); } void clearServer(String serverId) { - for (final conversation in fetchForServer(serverId)) { - remove( - _conversationKey(conversation.id), - updateLastUpdateTsOnRemove: false, - ); - } - remove(_activeKey(serverId), updateLastUpdateTsOnRemove: false); + _db.execute('DELETE FROM $_conv WHERE server_id = ?;', [serverId]); + _db.execute('DELETE FROM $_active WHERE server_id = ?;', [serverId]); + _changes.add(null); } static List trimItemsForStorage( @@ -172,14 +219,18 @@ class AgentConversationStore extends SqliteStore { return List.unmodifiable(items.sublist(start)); } + /// Drops everything past the newest [maxConversationsPerServer] for a server. + /// + /// One statement, where the K-V version read and decoded every conversation + /// in the app on every save to find out which ones were past the cap. void _pruneServer(String serverId) { - final conversations = fetchForServer(serverId); - for (final conversation in conversations.skip(maxConversationsPerServer)) { - remove( - _conversationKey(conversation.id), - updateLastUpdateTsOnRemove: false, - ); - } + _db.execute( + 'DELETE FROM $_conv WHERE server_id = ? AND id NOT IN (' + ' SELECT id FROM $_conv WHERE server_id = ? ' + ' ORDER BY updated_at DESC LIMIT ?' + ');', + [serverId, serverId, maxConversationsPerServer], + ); } static int _nextUserMessage(List items, int start) { @@ -243,15 +294,59 @@ class AgentConversationStore extends SqliteStore { return ''; } - static String _conversationKey(String id) => '$_conversationPrefix$id'; - static String _activeKey(String serverId) => '$_activePrefix$serverId'; + /// Takes one row out of the Hive box these tables replaced. + /// + /// Used only by `HiveImport`. The box held both kinds under one namespace, + /// told apart by the key prefix. + bool importRow(String key, Object value) { + if (key.startsWith(_activePrefix)) { + if (value is! String || value.isEmpty) return false; + _setActiveRow(key.substring(_activePrefix.length), value); + return true; + } + if (!key.startsWith(_conversationPrefix) || value is! Map) return false; + final conversation = _fromMap(Map.from(value)); + if (conversation == null) return false; + // Not through `save`: that would re-trim and re-title a record the user + // already has, and prune against a table still being filled. + _upsert(conversation); + return true; + } + + void _upsert(AgentConversation conversation) { + _db.execute( + 'INSERT INTO $_conv (id, server_id, updated_at, data) VALUES (?, ?, ?, ?) ' + 'ON CONFLICT (id) DO UPDATE SET server_id = excluded.server_id, ' + 'updated_at = excluded.updated_at, data = excluded.data;', + [ + conversation.id, + conversation.serverId, + conversation.updatedAt.millisecondsSinceEpoch, + json.encode(conversation.toJson()), + ], + ); + } - static AgentConversation? _conversationFromValue(Map? value) { - if (value == null) return null; + void _setActiveRow(String serverId, String conversationId) { + _db.execute( + 'INSERT INTO $_active (server_id, conversation_id) VALUES (?, ?) ' + 'ON CONFLICT (server_id) DO UPDATE SET ' + 'conversation_id = excluded.conversation_id;', + [serverId, conversationId], + ); + } + + static AgentConversation? _decode(String data) { try { - final conversation = AgentConversation.fromJson( - Map.from(value), - ); + return _fromMap(json.decode(data) as Map); + } catch (_) { + return null; + } + } + + static AgentConversation? _fromMap(Map map) { + try { + final conversation = AgentConversation.fromJson(map); if (conversation.id.isEmpty || conversation.serverId.isEmpty) return null; return conversation; } catch (_) { diff --git a/lib/data/store/connection_stats.dart b/lib/data/store/connection_stats.dart index 842245e77..9bb2c5889 100644 --- a/lib/data/store/connection_stats.dart +++ b/lib/data/store/connection_stats.dart @@ -1,126 +1,63 @@ import 'package:fl_lib/fl_lib.dart'; import 'package:server_box/data/model/server/connection_stat.dart'; - -class ConnectionStatsStore extends SqliteStore { - ConnectionStatsStore._() : super('connection_stats'); +import 'package:sqlite3/sqlite3.dart'; + +/// Connection attempts, one row each. +/// +/// A table rather than a K-V store because every question asked of it is a +/// range over one server's history, and answering those out of a K-V store took +/// a second store holding per-server lists of keys, plus the code to keep those +/// lists in step: rebuild, update-on-insert, prune-to-100 and expire-after-30- +/// days were four hand-written passes over the records. They are two `DELETE` +/// statements and an index here. +/// +/// That second store was also the one thing in the app that was never +/// encrypted, because it was a Hive box opened without a cipher. Being a table +/// in the shared database, this is behind the same key as everything else. +class ConnectionStatsStore { + ConnectionStatsStore._(); static final instance = ConnectionStatsStore._(); + /// Kept per server, oldest dropped first. static const _maxRecordsPerServer = 100; - /// Per-server lists of record keys, so a server's history can be read without - /// scanning every record. - /// - /// Its own store rather than a prefix in this one, so `keys()` here stays - /// "the records" and the pruning below does not have to filter itself out. - /// - /// It used to be a separate Hive box, and — unlike every other box — an - /// unencrypted one: `Hive.openBox` was called without a cipher, so 114 KB of - /// `_` sat in plaintext next to the encrypted records they - /// point at. Sharing the database puts it behind the same key as everything - /// else. - /// - /// TODO: delete along with the hand-rolled pruning below, once the records - /// are a table with an index on (server_id, timestamp). - final _index = SqliteStore('conn_stats_index'); - - @override - Future init({String? dir}) async { - await super.init(dir: dir); - await _index.init(dir: dir); - } - - Future rebuildIndexAndCompact() async { - _rebuildIndexCore(); - await _compactIfNeeded(); - } - - void _rebuildIndexCore() { - final cutoffTime = DateTime.now().subtract(const Duration(days: 30)); - final serverIdToKeys = >{}; - - for (final key in keys().toList()) { - final stat = _statOf(key); - if (stat == null) continue; - - if (stat.timestamp.isBefore(cutoffTime)) { - remove(key); - continue; - } - - serverIdToKeys.putIfAbsent(stat.serverId, () => []).add(key); - } - - for (final k in _index.keys().toList()) { - if (k.startsWith('idx_')) _index.remove(k); - } - - for (final entry in serverIdToKeys.entries) { - final keys = entry.value; - if (keys.length > _maxRecordsPerServer) { - final keyStatPairs = <(String, ConnectionStat)>[]; - for (final key in keys) { - final stat = _statOf(key); - if (stat != null) keyStatPairs.add((key, stat)); - } - keyStatPairs.sort((a, b) => b.$2.timestamp.compareTo(a.$2.timestamp)); - final toKeep = keyStatPairs - .take(_maxRecordsPerServer) - .map((p) => p.$1) - .toList() - .reversed - .toList(); - for (final pair in keyStatPairs.skip(_maxRecordsPerServer)) { - remove(pair.$1); - } - _index.set('idx_${entry.key}', toKeep); - } else { - _index.set('idx_${entry.key}', keys); - } - } - } - - Future _compactIfNeeded() async { - try { - SqliteDb.vacuum(); - } catch (e, st) { - Loggers.app.warning('Auto compact failed during init', e, st); - } - } - - void _updateIndex(String serverId, String recordKey) { - final indexKey = 'idx_$serverId'; - final keys = _indexKeys(serverId); - - if (keys.contains(recordKey)) return; - keys.add(recordKey); - if (keys.length > _maxRecordsPerServer) { - _pruneExcessRecords(keys); - } - _index.set(indexKey, keys); + /// Dropped regardless of the per-server count. + static const _retention = Duration(days: 30); + + Database get _db => SqliteDb.instance; + + Future init() async { + _db.execute(''' +CREATE TABLE IF NOT EXISTS conn_stat ( + id TEXT NOT NULL PRIMARY KEY, + server_id TEXT NOT NULL, + server_name TEXT NOT NULL, + timestamp INTEGER NOT NULL, + result TEXT NOT NULL, + error_message TEXT NOT NULL DEFAULT '', + duration_ms INTEGER NOT NULL +) WITHOUT ROWID; +'''); + // Every read is "this server, newest first", and the pruning below is the + // same order with a LIMIT. + _db.execute( + 'CREATE INDEX IF NOT EXISTS idx_conn_stat_server_ts ' + 'ON conn_stat(server_id, timestamp DESC);', + ); } - void _pruneExcessRecords(List keys) { - if (keys.length <= _maxRecordsPerServer) return; - - final keyStatPairs = <(String, ConnectionStat)>[]; - for (final key in keys) { - final stat = _statOf(key); - if (stat != null) keyStatPairs.add((key, stat)); - } - - keyStatPairs.sort((a, b) => b.$2.timestamp.compareTo(a.$2.timestamp)); - - for (final pair in keyStatPairs.skip(_maxRecordsPerServer)) { - remove(pair.$1); - keys.remove(pair.$1); - } + Future recordConnection(ConnectionStat stat) async { + _insert(_idOf(stat), stat); + _prune(stat.serverId); } - Future recordConnection(ConnectionStat stat) async { - final key = '${stat.serverId}_${stat.timestamp.millisecondsSinceEpoch}'; - set(key, stat); - _updateIndex(stat.serverId, key); + List getConnectionHistory(String serverId) { + final rows = _db.select( + 'SELECT * FROM conn_stat WHERE server_id = ? ORDER BY timestamp DESC;', + [serverId], + ); + return rows.map(_fromRow).toList(); } ServerConnectionStats getServerStats(String serverId, String serverName) { @@ -138,86 +75,74 @@ class ConnectionStatsStore extends SqliteStore { ); } - final totalAttempts = allStats.length; var successCount = 0; DateTime? lastSuccessTime; DateTime? lastFailureTime; final recentConnections = []; for (final stat in allStats) { - final isSuccess = stat.result.isSuccess; - if (isSuccess) { + if (stat.result.isSuccess) { successCount += 1; lastSuccessTime ??= stat.timestamp; } else { lastFailureTime ??= stat.timestamp; } - if (recentConnections.length < 20) { - recentConnections.add(stat); - } + if (recentConnections.length < 20) recentConnections.add(stat); } - final failureCount = totalAttempts - successCount; - final successRate = totalAttempts > 0 - ? (successCount / totalAttempts) - : 0.0; - + final totalAttempts = allStats.length; return ServerConnectionStats( serverId: serverId, serverName: serverName, totalAttempts: totalAttempts, successCount: successCount, - failureCount: failureCount, + failureCount: totalAttempts - successCount, lastSuccessTime: lastSuccessTime, lastFailureTime: lastFailureTime, recentConnections: recentConnections, - successRate: successRate, + successRate: successCount / totalAttempts, ); } - List getConnectionHistory(String serverId) { - final stats = []; - for (final key in _indexKeys(serverId).reversed) { - final stat = _statOf(key); - if (stat != null) stats.add(stat); - } - return stats; - } - List getAllServerStats() { - final allStats = []; - for (final indexKey in indexKeys) { - final serverId = indexKey.substring(4); - final keys = _indexKeys(serverId); - if (keys.isEmpty) continue; - - String? serverName; - for (final key in keys.reversed) { - final stat = _statOf(key); - if (stat != null) { - serverName = stat.serverName; - break; - } - } - - if (serverName == null) continue; - - allStats.add(getServerStats(serverId, serverName)); - } - - return allStats; + // The name comes from the newest row for each server, since a server can be + // renamed and the old rows keep the name it had at the time. `server_name` + // is a bare column beside `MAX`, which SQLite answers from the row the + // maximum came from. + final rows = _db.select( + 'SELECT server_id, server_name, MAX(timestamp) AS ts FROM conn_stat ' + 'GROUP BY server_id;', + ); + return [ + for (final row in rows) + getServerStats(row['server_id'] as String, row['server_name'] as String), + ]; } Future clearAll() async { - clear(); - _index.clear(); + _db.execute('DELETE FROM conn_stat;'); } Future clearServerStats(String serverId) async { - for (final key in _indexKeys(serverId)) { - remove(key); - } - _index.remove('idx_$serverId'); + _db.execute('DELETE FROM conn_stat WHERE server_id = ?;', [serverId]); + } + + /// Drops what is past either limit. + /// + /// Both bounds in one place, run on insert. The K-V version could only apply + /// the age bound during a full rebuild at launch, so a long-running app kept + /// expired rows until it was restarted. + void _prune(String serverId) { + _db.execute('DELETE FROM conn_stat WHERE timestamp < ?;', [ + DateTime.now().subtract(_retention).millisecondsSinceEpoch, + ]); + _db.execute( + 'DELETE FROM conn_stat WHERE server_id = ? AND id NOT IN (' + ' SELECT id FROM conn_stat WHERE server_id = ? ' + ' ORDER BY timestamp DESC LIMIT ?' + ');', + [serverId, serverId, _maxRecordsPerServer], + ); } Future compact() async { @@ -231,26 +156,60 @@ class ConnectionStatsStore extends SqliteStore { } } - Iterable get indexKeys => - _index.keys().where((k) => k.startsWith('idx_')); - - List _indexKeys(String serverId) => - _index.get('idx_$serverId')?.cast().toList() ?? []; + /// Size of the whole store database, not of this table. + /// + /// Every store shares one file, so there is no per-table number to report and + /// the compaction this feeds is `VACUUM` on that file. + Future dbSizeAsync() => SqliteDb.size(); - ConnectionStat? _statOf(String key) { - final raw = get(key); - if (raw == null) return null; + /// Takes one row out of the Hive box this table replaced. + /// + /// Used only by `HiveImport`. It writes the record under the key it had, so + /// re-running the import overwrites rather than duplicates. + bool importRow(String key, Object value) { + if (value is! Map) return false; try { - return ConnectionStat.fromJson(Map.from(raw)); + _insert(key, ConnectionStat.fromJson(Map.from(value))); + return true; } catch (e) { - dprint('Parsing ConnectionStat from JSON', e); - return null; + dprint('Importing ConnectionStat', e); + return false; } } - /// Size of the whole store database, not of this store's rows. - /// - /// Every store shares one file, so there is no per-store number to report and - /// the compaction this feeds is `VACUUM` on that file. - Future dbSizeAsync() => SqliteDb.size(); + void _insert(String id, ConnectionStat stat) { + _db.execute( + 'INSERT INTO conn_stat ' + '(id, server_id, server_name, timestamp, result, error_message, duration_ms) ' + 'VALUES (?, ?, ?, ?, ?, ?, ?) ' + 'ON CONFLICT (id) DO UPDATE SET ' + 'server_name = excluded.server_name, timestamp = excluded.timestamp, ' + 'result = excluded.result, error_message = excluded.error_message, ' + 'duration_ms = excluded.duration_ms;', + [ + id, + stat.serverId, + stat.serverName, + stat.timestamp.millisecondsSinceEpoch, + stat.result.name, + stat.errorMessage, + stat.durationMs, + ], + ); + } + + static String _idOf(ConnectionStat stat) => + '${stat.serverId}_${stat.timestamp.millisecondsSinceEpoch}'; + + static ConnectionStat _fromRow(Row row) => ConnectionStat( + serverId: row['server_id'] as String, + serverName: row['server_name'] as String, + timestamp: DateTime.fromMillisecondsSinceEpoch(row['timestamp'] as int), + result: ConnectionResult.values.firstWhere( + (e) => e.name == row['result'], + orElse: () => ConnectionResult.unknownError, + ), + errorMessage: row['error_message'] as String? ?? '', + durationMs: row['duration_ms'] as int, + ); } diff --git a/lib/data/store/migrations/m003_hive_to_sqlite.dart b/lib/data/store/migrations/m003_hive_to_sqlite.dart index 4b4143a06..16e932d3c 100644 --- a/lib/data/store/migrations/m003_hive_to_sqlite.dart +++ b/lib/data/store/migrations/m003_hive_to_sqlite.dart @@ -20,23 +20,33 @@ abstract final class HiveImport { /// Internal, so it stays out of backups and out of `lastUpdateTs`. static const _markerKey = '${StoreDefaults.prefixKey}hiveImported'; - /// Box name -> the store that now holds it. + /// Box name -> what takes one of its rows. /// - /// `conn_stats_index` is deliberately absent: it is derived from the records, - /// `Stores.init` rebuilds it when it is empty, and it is the one box that was - /// never encrypted — so it is deleted below rather than carried across. - static Map get _boxes => { - 'setting': Stores.setting, - 'server': Stores.server, - 'docker': Stores.container, - 'key': Stores.key, - 'snippet': Stores.snippet, - 'history': Stores.history, - 'connection_stats': Stores.connectionStats, - 'port_forward': Stores.portForward, - 'agent_conversation': Stores.agentConversation, + /// Most go to a K-V store under the same key. The last two own tables now, so + /// they take the row apart themselves. + /// + /// `conn_stats_index` is deliberately absent: it held nothing that is not + /// derivable from the records, and it is the one box that was never + /// encrypted — so it is deleted below rather than carried across. + static Map get _boxes => { + 'setting': _intoKv(Stores.setting), + 'server': _intoKv(Stores.server), + 'docker': _intoKv(Stores.container), + 'key': _intoKv(Stores.key), + 'snippet': _intoKv(Stores.snippet), + 'history': _intoKv(Stores.history), + 'port_forward': _intoKv(Stores.portForward), + 'connection_stats': Stores.connectionStats.importRow, + 'agent_conversation': Stores.agentConversation.importRow, }; + /// `updateLastUpdateTsOnSet: false`: the timestamps are copied across with + /// everything else, and stamping each row as it lands would overwrite them + /// with "now" and tell the next sync that this device holds the newer copy of + /// data it has just finished reading off its own disk. + static bool Function(String, Object) _intoKv(SqliteStore store) => + (key, value) => store.set(key, value, updateLastUpdateTsOnSet: false); + /// Runs the import if this device has data in Hive and none in SQLite yet. /// /// Safe to re-run: nothing is deleted from Hive, so a crash part-way through @@ -76,7 +86,10 @@ abstract final class HiveImport { Stores.setting.set(_markerKey, true); } - static Future _importBox(String name, SqliteStore target) async { + static Future _importBox( + String name, + bool Function(String, Object) into, + ) async { final legacy = HiveStore(name); try { await legacy.init(); @@ -94,11 +107,7 @@ abstract final class HiveImport { if (raw == null) continue; final value = _toSpi(raw) ?? raw; - // `updateLastUpdateTsOnSet: false` throughout: the timestamps are copied - // across with everything else, and stamping each row as it lands would - // overwrite them with "now" and tell the next sync that this device holds - // the newer copy of data it has just finished reading off its own disk. - final ok = target.set(key, value as Object, updateLastUpdateTsOnSet: false); + final ok = into(key, _jsonSafe(value as Object)); if (ok) { copied++; } else { @@ -119,6 +128,25 @@ abstract final class HiveImport { static Object? _toSpi(Object raw) => raw is LegacySpiV2 ? raw.toSpi() : null; + /// The value as something made of maps, lists and primitives. + /// + /// A Hive box hands back whatever its adapter decoded — a `ConnectionStat`, + /// not a map. The K-V stores would encode that on write, but the two + /// table-backed stores parse what they are given with `fromJson`, so both + /// kinds of destination are handed the same shape. + static Object _jsonSafe(Object value) { + if (value is Map || value is List || value is Enum) return value; + if (value is num || value is String || value is bool) return value; + try { + final json = (value as dynamic).toJson(); + if (json is Object) return json; + } catch (_) { + // No `toJson`. Leave it be: the destination reports what it could not + // take, naming the type. + } + return value; + } + /// Removes the one box that was never encrypted. /// /// The other files are kept so a bad import can be rolled back to, but this diff --git a/test/agent_conversation_store_test.dart b/test/agent_conversation_store_test.dart index 15120de7e..4064f2a0e 100644 --- a/test/agent_conversation_store_test.dart +++ b/test/agent_conversation_store_test.dart @@ -7,9 +7,10 @@ import 'package:server_box/data/store/agent_conversation.dart'; void main() { late AgentConversationStore store; - setUp(() { + setUp(() async { SqliteDb.openInMemory(); store = AgentConversationStore.forTest(); + await store.init(); }); tearDown(SqliteDb.close); @@ -241,4 +242,85 @@ void main() { expect(store.fetchForServer('server-a'), isEmpty); expect(store.fetchForServer('server-b').single.id, other.id); }); + + group('the queries the tables replaced a full scan with', () { + AgentConversation conv(String id, String serverId, DateTime updatedAt) => + AgentConversation( + id: id, + serverId: serverId, + title: 't-$id', + createdAt: updatedAt, + updatedAt: updatedAt, + protocol: AskAiProtocol.responses, + providerBaseUrl: 'https://x', + model: 'm', + items: const [], + ); + + final base = DateTime.fromMillisecondsSinceEpoch(1000); + + test('a server list comes back newest first', () { + store.save(conv('a1', 'srv-a', base), setActive: false); + store.save( + conv('a3', 'srv-a', base.add(const Duration(minutes: 2))), + setActive: false, + ); + store.save( + conv('a2', 'srv-a', base.add(const Duration(minutes: 1))), + setActive: false, + ); + + // Ordered by the index rather than by an in-memory sort of every + // conversation in the app, which is what the K-V version did. + expect(store.fetchForServer('srv-a').map((e) => e.id), [ + 'a3', + 'a2', + 'a1', + ]); + }); + + test('the active conversation is per server', () { + store.save(conv('a1', 'srv-a', base)); + store.save(conv('b1', 'srv-b', base)); + + expect(store.activeConversationId('srv-a'), 'a1'); + expect(store.activeConversationId('srv-b'), 'b1'); + expect(store.fetchActive('srv-a')?.id, 'a1'); + }); + + test('another server cannot be made active on this one', () { + store.save(conv('a1', 'srv-a', base), setActive: false); + expect(store.setActive('srv-b', 'a1'), isFalse); + expect(store.activeConversationId('srv-b'), isNull); + }); + + test('deleting the last conversation leaves nothing active', () { + store.save(conv('a1', 'srv-a', base)); + store.deleteConversation('srv-a', 'a1'); + expect(store.activeConversationId('srv-a'), isNull); + }); + + test('clearing a server leaves the others alone', () { + store.save(conv('a1', 'srv-a', base)); + store.save(conv('b1', 'srv-b', base)); + + store.clearServer('srv-a'); + + expect(store.fetchForServer('srv-a'), isEmpty); + expect(store.activeConversationId('srv-a'), isNull); + expect(store.fetchForServer('srv-b'), hasLength(1)); + expect(store.activeConversationId('srv-b'), 'b1'); + }); + + test('a write fires the change stream', () async { + final seen = []; + final sub = store.watch().listen(seen.add); + addTearDown(sub.cancel); + + store.save(conv('a1', 'srv-a', base)); + await Future.delayed(Duration.zero); + + expect(seen, hasLength(1)); + }); + }); } diff --git a/test/agent_shell_view_test.dart b/test/agent_shell_view_test.dart index b5b6ccbf5..dddcb09c0 100644 --- a/test/agent_shell_view_test.dart +++ b/test/agent_shell_view_test.dart @@ -40,7 +40,7 @@ void main() { // test here writes. getIt.registerSingleton(SettingStore.forTest()); getIt.registerSingleton( - AgentConversationStore.forTest(), + AgentConversationStore.forTest()..init(), ); }); diff --git a/test/agent_view_test.dart b/test/agent_view_test.dart index ea885c17a..3ebdc3335 100644 --- a/test/agent_view_test.dart +++ b/test/agent_view_test.dart @@ -46,7 +46,7 @@ void main() { // blocks forever, with no failure to say which file did it. getIt.registerSingleton(SettingStore.forTest()); getIt.registerSingleton( - AgentConversationStore.forTest(), + AgentConversationStore.forTest()..init(), ); }); diff --git a/test/connection_stats_store_test.dart b/test/connection_stats_store_test.dart new file mode 100644 index 000000000..bf8e56afe --- /dev/null +++ b/test/connection_stats_store_test.dart @@ -0,0 +1,176 @@ +import 'package:fl_lib/fl_lib.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:server_box/data/model/server/connection_stat.dart'; +import 'package:server_box/data/store/connection_stats.dart'; + +/// The bounds this table keeps itself inside, which used to be four hand-written +/// passes over a K-V store and are now two `DELETE`s. +void main() { + late ConnectionStatsStore store; + + /// Anchored to now, not to a literal date: rows older than 30 days are + /// dropped on the next write, so a fixed date silently empties the table as + /// soon as it is more than a month in the past. + /// Truncated to milliseconds, which is what the column holds. A connection + /// attempt does not need microseconds, but a test comparing `DateTime`s does + /// need to compare the same precision. + final base = DateTime.fromMillisecondsSinceEpoch( + DateTime.now() + .subtract(const Duration(days: 1)) + .millisecondsSinceEpoch, + ); + + setUp(() async { + SqliteDb.openInMemory(); + store = ConnectionStatsStore.instance; + await store.init(); + await store.clearAll(); + }); + + tearDown(SqliteDb.close); + + ConnectionStat stat( + String serverId, { + required DateTime at, + ConnectionResult result = ConnectionResult.success, + String name = 'srv', + }) => ConnectionStat( + serverId: serverId, + serverName: name, + timestamp: at, + result: result, + durationMs: 1, + ); + + test('history comes back newest first', () async { + for (var i = 0; i < 3; i++) { + await store.recordConnection( + stat('a', at: base.add(Duration(minutes: i))), + ); + } + + final history = store.getConnectionHistory('a'); + expect(history.length, 3); + expect(history.first.timestamp, base.add(const Duration(minutes: 2))); + expect(history.last.timestamp, base); + }); + + test('one server does not see another', () async { + await store.recordConnection(stat('a', at: base)); + await store.recordConnection(stat('b', at: base)); + + expect(store.getConnectionHistory('a').single.serverId, 'a'); + expect(store.getConnectionHistory('b').single.serverId, 'b'); + }); + + test('a server keeps its newest 100 and no more', () async { + for (var i = 0; i < 130; i++) { + await store.recordConnection( + stat('a', at: base.add(Duration(minutes: i))), + ); + } + + final history = store.getConnectionHistory('a'); + expect(history.length, 100); + // The 30 oldest went, not an arbitrary 30. + expect(history.last.timestamp, base.add(const Duration(minutes: 30))); + expect(history.first.timestamp, base.add(const Duration(minutes: 129))); + }); + + test('the cap is per server, not overall', () async { + for (var i = 0; i < 100; i++) { + await store.recordConnection( + stat('a', at: base.add(Duration(minutes: i))), + ); + } + await store.recordConnection(stat('b', at: base)); + + expect(store.getConnectionHistory('a').length, 100); + expect(store.getConnectionHistory('b').length, 1); + }); + + test('anything older than 30 days is dropped as it is written', () async { + // The age bound runs on every write, so an already-expired record does not + // survive its own insert. The K-V version could only apply this during a + // full rebuild at launch, so a long-running app kept expired rows until it + // was restarted. + await store.recordConnection( + stat('a', at: DateTime.now().subtract(const Duration(days: 31))), + ); + expect(store.getConnectionHistory('a'), isEmpty); + + await store.recordConnection(stat('a', at: base)); + expect(store.getConnectionHistory('a'), hasLength(1)); + }); + + test('recording the same attempt twice does not double it', () async { + final at = base; + await store.recordConnection(stat('a', at: at)); + await store.recordConnection(stat('a', at: at, name: 'renamed')); + + final history = store.getConnectionHistory('a'); + expect(history.length, 1); + expect(history.single.serverName, 'renamed'); + }); + + test('the summary counts both outcomes', () async { + await store.recordConnection(stat('a', at: base)); + await store.recordConnection( + stat( + 'a', + at: base.add(const Duration(minutes: 1)), + result: ConnectionResult.timeout, + ), + ); + await store.recordConnection( + stat('a', at: base.add(const Duration(minutes: 2))), + ); + + final summary = store.getServerStats('a', 'srv'); + expect(summary.totalAttempts, 3); + expect(summary.successCount, 2); + expect(summary.failureCount, 1); + expect(summary.successRate, closeTo(2 / 3, 1e-9)); + expect(summary.lastSuccessTime, base.add(const Duration(minutes: 2))); + expect(summary.lastFailureTime, base.add(const Duration(minutes: 1))); + }); + + test('a server with no attempts summarises as empty, not as an error', () { + final summary = store.getServerStats('nobody', 'srv'); + expect(summary.totalAttempts, 0); + expect(summary.successRate, 0.0); + expect(summary.recentConnections, isEmpty); + }); + + test('the overall list names each server as it was named last', () async { + await store.recordConnection(stat('a', at: base, name: 'old-name')); + await store.recordConnection( + stat('a', at: base.add(const Duration(minutes: 1)), name: 'new-name'), + ); + await store.recordConnection(stat('b', at: base, name: 'other')); + + final all = store.getAllServerStats(); + expect(all.length, 2); + expect( + all.firstWhere((e) => e.serverId == 'a').serverName, + 'new-name', + reason: 'a rename leaves the old name on the older rows', + ); + }); + + test('clearing one server leaves the others', () async { + await store.recordConnection(stat('a', at: base)); + await store.recordConnection(stat('b', at: base)); + + await store.clearServerStats('a'); + + expect(store.getConnectionHistory('a'), isEmpty); + expect(store.getConnectionHistory('b'), hasLength(1)); + }); + + test('clearing everything leaves nothing', () async { + await store.recordConnection(stat('a', at: base)); + await store.clearAll(); + expect(store.getAllServerStats(), isEmpty); + }); +} diff --git a/test/hive_import_test.dart b/test/hive_import_test.dart index 97657c6f9..f6bb09c15 100644 --- a/test/hive_import_test.dart +++ b/test/hive_import_test.dart @@ -211,12 +211,21 @@ void main() { ); }); - test('the index is rebuilt from the records it no longer ships with', - () async { + test('connection stats land in their table, not in kv', () async { await seedHive(); await Stores.init(); - expect(Stores.connectionStats.indexKeys, contains('idx_srv-1')); + // The box held one row per attempt plus a second, unencrypted box of key + // lists. Both are one table now, so nothing of it should be left in `kv`. + final kv = SqliteDb.instance.select( + "SELECT count(*) AS n FROM kv WHERE store LIKE 'conn%';", + ).single['n']; + expect(kv, 0); + + final rows = SqliteDb.instance.select( + 'SELECT server_id FROM conn_stat;', + ); + expect(rows.single['server_id'], 'srv-1'); }); test('a fresh install imports nothing and is already current', () async { From 488b96d483aa0ee85c54d03045f44073ec5fd4b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Wed, 19 Aug 2026 01:53:18 +0800 Subject: [PATCH 04/16] migrate(ffi): build sbm_ffi with a Dart build hook, drop cargokit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cargokit drove cargo from four per-platform integrations: a CocoaPods `script_phase` on iOS and macOS, CMake on Linux and Windows, and a gradle plugin on Android. The Apple one had no Swift Package Manager equivalent — a SwiftPM build tool plugin runs in a sandbox that denies writes to the project directory, so cargo can write neither `target/` nor `~/.cargo` from one, and Xcode has no way to pass `--disable-sandbox`. That mattered because the CocoaPods registry goes read-only on 2026-12-02 and Flutter removes its fallback some time after. cargokit itself was archived 2026-03-26, so the SwiftPM issue filed against it will not be answered. A build hook sidesteps the question rather than answering it: it is neither a pod nor a Swift package, so it produces no podspec and no Package.swift, and one file covers all five platforms. `crates/sbm_ffi` stops being a Flutter plugin entirely — the app does not depend on it as a package, the hook compiles the crate. Verified on macOS and iOS: both build, both bundle `sbm_ffi.framework`, and both `Podfile.lock`s are down to `flutter_pty`, which ships no Package.swift of its own and is now the only thing keeping CocoaPods in the build. Android, Linux and Windows are unverified — they each had their own cargokit integration and now share this one. The Dart side needed no change beyond the codegen version: the native assets backend generates the same `ExternalLibraryLoaderConfig` shape as cargokit did, not `@Native(assetId:)`. FRB is pinned to 2.13.0-beta.6 because the backend requires >= 2.13.0-beta.2 and 2.13.0 has no stable release. This is the only prerelease dependency in the project. `rust-toolchain.toml` is required by native_toolchain_rust and scoped to the crate rather than the workspace: sbm_parser, sbm_native and monitor are built by cargo directly and have no reason to follow the app's build hook. --- Cargo.lock | 8 +- TODOS.md | 26 +- crates/sbm_ffi/Cargo.toml | 2 +- crates/sbm_ffi/android/.gitignore | 9 - crates/sbm_ffi/android/build.gradle | 56 - crates/sbm_ffi/android/settings.gradle | 1 - .../android/src/main/AndroidManifest.xml | 3 - crates/sbm_ffi/cargokit/.gitignore | 4 - crates/sbm_ffi/cargokit/LICENSE | 42 - crates/sbm_ffi/cargokit/README | 11 - crates/sbm_ffi/cargokit/build_pod.sh | 58 - crates/sbm_ffi/cargokit/build_tool/README.md | 5 - .../cargokit/build_tool/analysis_options.yaml | 34 - .../cargokit/build_tool/bin/build_tool.dart | 9 - .../cargokit/build_tool/lib/build_tool.dart | 9 - .../lib/src/android_environment.dart | 196 --- .../lib/src/artifacts_provider.dart | 267 --- .../build_tool/lib/src/build_cmake.dart | 41 - .../build_tool/lib/src/build_gradle.dart | 50 - .../build_tool/lib/src/build_pod.dart | 90 - .../build_tool/lib/src/build_tool.dart | 277 ---- .../cargokit/build_tool/lib/src/builder.dart | 210 --- .../cargokit/build_tool/lib/src/cargo.dart | 49 - .../build_tool/lib/src/crate_hash.dart | 125 -- .../build_tool/lib/src/environment.dart | 69 - .../cargokit/build_tool/lib/src/logging.dart | 53 - .../cargokit/build_tool/lib/src/options.dart | 310 ---- .../lib/src/precompile_binaries.dart | 206 --- .../cargokit/build_tool/lib/src/rustup.dart | 150 -- .../cargokit/build_tool/lib/src/target.dart | 148 -- .../cargokit/build_tool/lib/src/util.dart | 173 -- .../build_tool/lib/src/verify_binaries.dart | 85 - .../sbm_ffi/cargokit/build_tool/pubspec.lock | 453 ----- .../sbm_ffi/cargokit/build_tool/pubspec.yaml | 33 - crates/sbm_ffi/cargokit/cmake/cargokit.cmake | 99 -- .../cargokit/cmake/resolve_symlinks.ps1 | 34 - crates/sbm_ffi/cargokit/gradle/plugin.gradle | 184 --- crates/sbm_ffi/cargokit/run_build_tool.cmd | 91 - crates/sbm_ffi/cargokit/run_build_tool.sh | 99 -- crates/sbm_ffi/ios/Classes/dummy_file.c | 1 - crates/sbm_ffi/ios/sbm_ffi.podspec | 45 - crates/sbm_ffi/linux/CMakeLists.txt | 19 - crates/sbm_ffi/macos/Classes/dummy_file.c | 1 - crates/sbm_ffi/macos/sbm_ffi.podspec | 48 - crates/sbm_ffi/pubspec.yaml | 34 - crates/sbm_ffi/rust-toolchain.toml | 26 + crates/sbm_ffi/src/frb_generated.rs | 1460 ++++++----------- crates/sbm_ffi/windows/.gitignore | 17 - crates/sbm_ffi/windows/CMakeLists.txt | 20 - hook/build.dart | 24 + ios/Podfile.lock | 6 - .../xcshareddata/swiftpm/Package.resolved | 59 - lib/src/rust/api/parser.dart | 2 +- lib/src/rust/api/script.dart | 2 +- lib/src/rust/frb_generated.dart | 4 +- lib/src/rust/frb_generated.io.dart | 2 +- lib/src/rust/frb_generated.web.dart | 2 +- linux/flutter/generated_plugins.cmake | 1 - macos/Podfile.lock | 6 - pubspec.lock | 35 +- pubspec.yaml | 7 +- windows/flutter/generated_plugins.cmake | 1 - 62 files changed, 617 insertions(+), 4974 deletions(-) delete mode 100644 crates/sbm_ffi/android/.gitignore delete mode 100644 crates/sbm_ffi/android/build.gradle delete mode 100644 crates/sbm_ffi/android/settings.gradle delete mode 100644 crates/sbm_ffi/android/src/main/AndroidManifest.xml delete mode 100644 crates/sbm_ffi/cargokit/.gitignore delete mode 100644 crates/sbm_ffi/cargokit/LICENSE delete mode 100644 crates/sbm_ffi/cargokit/README delete mode 100755 crates/sbm_ffi/cargokit/build_pod.sh delete mode 100644 crates/sbm_ffi/cargokit/build_tool/README.md delete mode 100644 crates/sbm_ffi/cargokit/build_tool/analysis_options.yaml delete mode 100644 crates/sbm_ffi/cargokit/build_tool/bin/build_tool.dart delete mode 100644 crates/sbm_ffi/cargokit/build_tool/lib/build_tool.dart delete mode 100644 crates/sbm_ffi/cargokit/build_tool/lib/src/android_environment.dart delete mode 100644 crates/sbm_ffi/cargokit/build_tool/lib/src/artifacts_provider.dart delete mode 100644 crates/sbm_ffi/cargokit/build_tool/lib/src/build_cmake.dart delete mode 100644 crates/sbm_ffi/cargokit/build_tool/lib/src/build_gradle.dart delete mode 100644 crates/sbm_ffi/cargokit/build_tool/lib/src/build_pod.dart delete mode 100644 crates/sbm_ffi/cargokit/build_tool/lib/src/build_tool.dart delete mode 100644 crates/sbm_ffi/cargokit/build_tool/lib/src/builder.dart delete mode 100644 crates/sbm_ffi/cargokit/build_tool/lib/src/cargo.dart delete mode 100644 crates/sbm_ffi/cargokit/build_tool/lib/src/crate_hash.dart delete mode 100644 crates/sbm_ffi/cargokit/build_tool/lib/src/environment.dart delete mode 100644 crates/sbm_ffi/cargokit/build_tool/lib/src/logging.dart delete mode 100644 crates/sbm_ffi/cargokit/build_tool/lib/src/options.dart delete mode 100644 crates/sbm_ffi/cargokit/build_tool/lib/src/precompile_binaries.dart delete mode 100644 crates/sbm_ffi/cargokit/build_tool/lib/src/rustup.dart delete mode 100644 crates/sbm_ffi/cargokit/build_tool/lib/src/target.dart delete mode 100644 crates/sbm_ffi/cargokit/build_tool/lib/src/util.dart delete mode 100644 crates/sbm_ffi/cargokit/build_tool/lib/src/verify_binaries.dart delete mode 100644 crates/sbm_ffi/cargokit/build_tool/pubspec.lock delete mode 100644 crates/sbm_ffi/cargokit/build_tool/pubspec.yaml delete mode 100644 crates/sbm_ffi/cargokit/cmake/cargokit.cmake delete mode 100644 crates/sbm_ffi/cargokit/cmake/resolve_symlinks.ps1 delete mode 100644 crates/sbm_ffi/cargokit/gradle/plugin.gradle delete mode 100755 crates/sbm_ffi/cargokit/run_build_tool.cmd delete mode 100755 crates/sbm_ffi/cargokit/run_build_tool.sh delete mode 100644 crates/sbm_ffi/ios/Classes/dummy_file.c delete mode 100644 crates/sbm_ffi/ios/sbm_ffi.podspec delete mode 100644 crates/sbm_ffi/linux/CMakeLists.txt delete mode 100644 crates/sbm_ffi/macos/Classes/dummy_file.c delete mode 100644 crates/sbm_ffi/macos/sbm_ffi.podspec delete mode 100644 crates/sbm_ffi/pubspec.yaml create mode 100644 crates/sbm_ffi/rust-toolchain.toml delete mode 100644 crates/sbm_ffi/windows/.gitignore delete mode 100644 crates/sbm_ffi/windows/CMakeLists.txt create mode 100644 hook/build.dart delete mode 100644 ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/Cargo.lock b/Cargo.lock index 3e1aa9a4f..5608678e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1347,9 +1347,9 @@ dependencies = [ [[package]] name = "flutter_rust_bridge" -version = "2.12.0" +version = "2.13.0-beta.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0884853aae8a6517b5b58cf36f55da487f2fe110e1686938eb29b6640aae4a5" +checksum = "2fcb811bbf084de059c7ca7e977531a01fcae1a2b9277a771dfbf1d01ab57bd1" dependencies = [ "allo-isolate", "android_logger", @@ -1376,9 +1376,9 @@ dependencies = [ [[package]] name = "flutter_rust_bridge_macros" -version = "2.12.0" +version = "2.13.0-beta.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b5ce32f35f710ced8c5aa557f023f1a624e737b5460cee2b70fcd3a8df09e1b" +checksum = "d562a203133e4e36459b02acdec2747c0c9b95069e5cd90f1d47a29a9c656555" dependencies = [ "hex", "md-5 0.10.6", diff --git a/TODOS.md b/TODOS.md index 92caaae14..61caf8887 100644 --- a/TODOS.md +++ b/TODOS.md @@ -106,7 +106,31 @@ relay 服务,而不是(或者除了)在本地直接对外提供面板访问;面 决定放哪个 crate/仓库、怎么部署、TLS/域名怎么搞,这和现在"每个 agent 一个 单体二进制"的假设不一样,是本仓库目前唯一的例外 -## sbm_ffi:脱离 CocoaPods,改走 Dart build hooks +## sbm_ffi:脱离 CocoaPods,改走 Dart build hooks(已做,但只验过两个平台) + +**状态(2026-08-19):已合入。** cargokit 五个接入点和整个 `cargokit/` 目录已删, +`crates/sbm_ffi` 不再是 Flutter plugin,改由根目录 `hook/build.dart` 编译。 +FRB 升到 `2.13.0-beta.6`(Rust 与 pubspec 两处)。 + +已验证:macOS 与 iOS 构建通过,产物里有 `sbm_ffi.framework`,两个 +`Podfile.lock` 里都只剩 `flutter_pty`。`flutter test test/frb_parser_test.dart` +与 `cargo test --workspace` 通过。 + +**未验证:Android / Linux / Windows。** 这三个平台原先分别走 cargokit 的 +gradle plugin 和 CMake,现在改走同一个 hook,但本机没跑过。要在 CI 或对应机器上 +各跑一次 `dart run fl_build -p `。 + +两条注意事项: +- **不要用 `flutter_rust_bridge_codegen integrate`。** 它是给新项目的脚手架: + 在本仓库上跑会重新格式化 188 个文件、脚手架出一个新的 `rust/` crate、`hook/`、 + `test_driver/`,并且把 7 个 submodule 也一起格式化。`generate` 是安全的 + (它的 `dart fix` 只作用于生成目录,实测不外溢)。 +- **FRB 2.13.0 至今只有 beta。** native-assets 后端要求 `>= 2.13.0-beta.2`, + stable 仍停在 2.12.0。这是本项目目前唯一钉在 prerelease 上的依赖。 + +以下是当初的分析,留作记录。 + +## sbm_ffi:脱离 CocoaPods 的原分析 Flutter 3.44 起 SPM 是默认路径。SPM 本身已经生效——13 个 plugin 走的是生成的 `{ios,macos}/Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage/Package.swift` diff --git a/crates/sbm_ffi/Cargo.toml b/crates/sbm_ffi/Cargo.toml index 79a7def4b..1d1ba14a7 100644 --- a/crates/sbm_ffi/Cargo.toml +++ b/crates/sbm_ffi/Cargo.toml @@ -7,7 +7,7 @@ edition = "2021" crate-type = ["cdylib", "staticlib"] [dependencies] -flutter_rust_bridge = "=2.12.0" +flutter_rust_bridge = "=2.13.0-beta.6" sbm_parser = { path = "../sbm_parser" } serde_json = "1.0" diff --git a/crates/sbm_ffi/android/.gitignore b/crates/sbm_ffi/android/.gitignore deleted file mode 100644 index 161bdcdaf..000000000 --- a/crates/sbm_ffi/android/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -*.iml -.gradle -/local.properties -/.idea/workspace.xml -/.idea/libraries -.DS_Store -/build -/captures -.cxx diff --git a/crates/sbm_ffi/android/build.gradle b/crates/sbm_ffi/android/build.gradle deleted file mode 100644 index 64d5526a0..000000000 --- a/crates/sbm_ffi/android/build.gradle +++ /dev/null @@ -1,56 +0,0 @@ -// The Android Gradle Plugin builds the native code with the Android NDK. - -group 'com.flutter_rust_bridge.sbm_ffi' -version '1.0' - -buildscript { - repositories { - google() - mavenCentral() - } - - dependencies { - // The Android Gradle Plugin knows how to build native code with the NDK. - classpath 'com.android.tools.build:gradle:7.3.0' - } -} - -rootProject.allprojects { - repositories { - google() - mavenCentral() - } -} - -apply plugin: 'com.android.library' - -android { - if (project.android.hasProperty("namespace")) { - namespace 'com.flutter_rust_bridge.sbm_ffi' - } - - // Bumping the plugin compileSdkVersion requires all clients of this plugin - // to bump the version in their app. - compileSdkVersion 33 - - // Use the NDK version - // declared in /android/app/build.gradle file of the Flutter project. - // Replace it with a version number if this plugin requires a specfic NDK version. - // (e.g. ndkVersion "23.1.7779620") - ndkVersion android.ndkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - defaultConfig { - minSdkVersion 19 - } -} - -apply from: "../cargokit/gradle/plugin.gradle" -cargokit { - manifestDir = ".." - libname = "sbm_ffi" -} diff --git a/crates/sbm_ffi/android/settings.gradle b/crates/sbm_ffi/android/settings.gradle deleted file mode 100644 index 80764c1a0..000000000 --- a/crates/sbm_ffi/android/settings.gradle +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = 'sbm_ffi' diff --git a/crates/sbm_ffi/android/src/main/AndroidManifest.xml b/crates/sbm_ffi/android/src/main/AndroidManifest.xml deleted file mode 100644 index 820f2e5f0..000000000 --- a/crates/sbm_ffi/android/src/main/AndroidManifest.xml +++ /dev/null @@ -1,3 +0,0 @@ - - diff --git a/crates/sbm_ffi/cargokit/.gitignore b/crates/sbm_ffi/cargokit/.gitignore deleted file mode 100644 index cf7bb868c..000000000 --- a/crates/sbm_ffi/cargokit/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -target -.dart_tool -*.iml -!pubspec.lock diff --git a/crates/sbm_ffi/cargokit/LICENSE b/crates/sbm_ffi/cargokit/LICENSE deleted file mode 100644 index d33a5fea5..000000000 --- a/crates/sbm_ffi/cargokit/LICENSE +++ /dev/null @@ -1,42 +0,0 @@ -/// This is copied from Cargokit (which is the official way to use it currently) -/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin - -Copyright 2022 Matej Knopp - -================================================================================ - -MIT LICENSE - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies -of the Software, and to permit persons to whom the Software is furnished to do -so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS -OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -================================================================================ - -APACHE LICENSE, VERSION 2.0 - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. - diff --git a/crates/sbm_ffi/cargokit/README b/crates/sbm_ffi/cargokit/README deleted file mode 100644 index 398474dbc..000000000 --- a/crates/sbm_ffi/cargokit/README +++ /dev/null @@ -1,11 +0,0 @@ -/// This is copied from Cargokit (which is the official way to use it currently) -/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin - -Experimental repository to provide glue for seamlessly integrating cargo build -with flutter plugins and packages. - -See https://matejknopp.com/post/flutter_plugin_in_rust_with_no_prebuilt_binaries/ -for a tutorial on how to use Cargokit. - -Example plugin available at https://github.com/irondash/hello_rust_ffi_plugin. - diff --git a/crates/sbm_ffi/cargokit/build_pod.sh b/crates/sbm_ffi/cargokit/build_pod.sh deleted file mode 100755 index ed0e0d987..000000000 --- a/crates/sbm_ffi/cargokit/build_pod.sh +++ /dev/null @@ -1,58 +0,0 @@ -#!/bin/sh -set -e - -BASEDIR=$(dirname "$0") - -# Workaround for https://github.com/dart-lang/pub/issues/4010 -BASEDIR=$(cd "$BASEDIR" ; pwd -P) - -# Remove XCode SDK from path. Otherwise this breaks tool compilation when building iOS project -NEW_PATH=`echo $PATH | tr ":" "\n" | grep -v "Contents/Developer/" | tr "\n" ":"` - -export PATH=${NEW_PATH%?} # remove trailing : - -env - -# Platform name (macosx, iphoneos, iphonesimulator) -export CARGOKIT_DARWIN_PLATFORM_NAME=$PLATFORM_NAME - -# Arctive architectures (arm64, armv7, x86_64), space separated. -export CARGOKIT_DARWIN_ARCHS=$ARCHS - -# Current build configuration (Debug, Release) -export CARGOKIT_CONFIGURATION=$CONFIGURATION - -# Path to directory containing Cargo.toml. -export CARGOKIT_MANIFEST_DIR=$PODS_TARGET_SRCROOT/$1 - -# Temporary directory for build artifacts. -export CARGOKIT_TARGET_TEMP_DIR=$TARGET_TEMP_DIR - -# Output directory for final artifacts. -export CARGOKIT_OUTPUT_DIR=$PODS_CONFIGURATION_BUILD_DIR/$PRODUCT_NAME - -# Directory to store built tool artifacts. -export CARGOKIT_TOOL_TEMP_DIR=$TARGET_TEMP_DIR/build_tool - -# Directory inside root project. Not necessarily the top level directory of root project. -export CARGOKIT_ROOT_PROJECT_DIR=$SRCROOT - -FLUTTER_EXPORT_BUILD_ENVIRONMENT=( - "$PODS_ROOT/../Flutter/ephemeral/flutter_export_environment.sh" # macOS - "$PODS_ROOT/../Flutter/flutter_export_environment.sh" # iOS -) - -for path in "${FLUTTER_EXPORT_BUILD_ENVIRONMENT[@]}" -do - if [[ -f "$path" ]]; then - source "$path" - fi -done - -sh "$BASEDIR/run_build_tool.sh" build-pod "$@" - -# Make a symlink from built framework to phony file, which will be used as input to -# build script. This should force rebuild (podspec currently doesn't support alwaysOutOfDate -# attribute on custom build phase) -ln -fs "$OBJROOT/XCBuildData/build.db" "${BUILT_PRODUCTS_DIR}/cargokit_phony" -ln -fs "${BUILT_PRODUCTS_DIR}/${EXECUTABLE_PATH}" "${BUILT_PRODUCTS_DIR}/cargokit_phony_out" diff --git a/crates/sbm_ffi/cargokit/build_tool/README.md b/crates/sbm_ffi/cargokit/build_tool/README.md deleted file mode 100644 index a878c2796..000000000 --- a/crates/sbm_ffi/cargokit/build_tool/README.md +++ /dev/null @@ -1,5 +0,0 @@ -/// This is copied from Cargokit (which is the official way to use it currently) -/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin - -A sample command-line application with an entrypoint in `bin/`, library code -in `lib/`, and example unit test in `test/`. diff --git a/crates/sbm_ffi/cargokit/build_tool/analysis_options.yaml b/crates/sbm_ffi/cargokit/build_tool/analysis_options.yaml deleted file mode 100644 index 0e16a8b09..000000000 --- a/crates/sbm_ffi/cargokit/build_tool/analysis_options.yaml +++ /dev/null @@ -1,34 +0,0 @@ -# This is copied from Cargokit (which is the official way to use it currently) -# Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin - -# This file configures the static analysis results for your project (errors, -# warnings, and lints). -# -# This enables the 'recommended' set of lints from `package:lints`. -# This set helps identify many issues that may lead to problems when running -# or consuming Dart code, and enforces writing Dart using a single, idiomatic -# style and format. -# -# If you want a smaller set of lints you can change this to specify -# 'package:lints/core.yaml'. These are just the most critical lints -# (the recommended set includes the core lints). -# The core lints are also what is used by pub.dev for scoring packages. - -include: package:lints/recommended.yaml - -# Uncomment the following section to specify additional rules. - -linter: - rules: - - prefer_relative_imports - - directives_ordering - -# analyzer: -# exclude: -# - path/to/excluded/files/** - -# For more information about the core and recommended set of lints, see -# https://dart.dev/go/core-lints - -# For additional information about configuring this file, see -# https://dart.dev/guides/language/analysis-options diff --git a/crates/sbm_ffi/cargokit/build_tool/bin/build_tool.dart b/crates/sbm_ffi/cargokit/build_tool/bin/build_tool.dart deleted file mode 100644 index 97535c1dd..000000000 --- a/crates/sbm_ffi/cargokit/build_tool/bin/build_tool.dart +++ /dev/null @@ -1,9 +0,0 @@ -/// This is copied from Cargokit (which is the official way to use it currently) -/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin -library; - -import 'package:build_tool/build_tool.dart' as build_tool; - -void main(List arguments) { - build_tool.runMain(arguments); -} diff --git a/crates/sbm_ffi/cargokit/build_tool/lib/build_tool.dart b/crates/sbm_ffi/cargokit/build_tool/lib/build_tool.dart deleted file mode 100644 index 191a11c37..000000000 --- a/crates/sbm_ffi/cargokit/build_tool/lib/build_tool.dart +++ /dev/null @@ -1,9 +0,0 @@ -/// This is copied from Cargokit (which is the official way to use it currently) -/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin -library; - -import 'src/build_tool.dart' as build_tool; - -Future runMain(List args) async { - return build_tool.runMain(args); -} diff --git a/crates/sbm_ffi/cargokit/build_tool/lib/src/android_environment.dart b/crates/sbm_ffi/cargokit/build_tool/lib/src/android_environment.dart deleted file mode 100644 index ab7f9c318..000000000 --- a/crates/sbm_ffi/cargokit/build_tool/lib/src/android_environment.dart +++ /dev/null @@ -1,196 +0,0 @@ -/// This is copied from Cargokit (which is the official way to use it currently) -/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin -library; - -import 'dart:io'; -import 'dart:isolate'; -import 'dart:math' as math; - -import 'package:collection/collection.dart'; -import 'package:path/path.dart' as path; -import 'package:version/version.dart'; - -import 'target.dart'; -import 'util.dart'; - -class AndroidEnvironment { - AndroidEnvironment({ - required this.sdkPath, - required this.ndkVersion, - required this.minSdkVersion, - required this.targetTempDir, - required this.target, - }); - - static void clangLinkerWrapper(List args) { - final clang = Platform.environment['_CARGOKIT_NDK_LINK_CLANG']; - if (clang == null) { - throw Exception( - "cargo-ndk rustc linker: didn't find _CARGOKIT_NDK_LINK_CLANG env var"); - } - final target = Platform.environment['_CARGOKIT_NDK_LINK_TARGET']; - if (target == null) { - throw Exception( - "cargo-ndk rustc linker: didn't find _CARGOKIT_NDK_LINK_TARGET env var"); - } - - runCommand(clang, [ - target, - ...args, - ]); - } - - /// Full path to Android SDK. - final String sdkPath; - - /// Full version of Android NDK. - final String ndkVersion; - - /// Minimum supported SDK version. - final int minSdkVersion; - - /// Target directory for build artifacts. - final String targetTempDir; - - /// Target being built. - final Target target; - - bool ndkIsInstalled() { - final ndkPath = path.join(sdkPath, 'ndk', ndkVersion); - final ndkPackageXml = File(path.join(ndkPath, 'package.xml')); - return ndkPackageXml.existsSync(); - } - - void installNdk({ - required String javaHome, - }) { - final sdkManagerExtension = Platform.isWindows ? '.bat' : ''; - final sdkManager = path.join( - sdkPath, - 'cmdline-tools', - 'latest', - 'bin', - 'sdkmanager$sdkManagerExtension', - ); - - log.info('Installing NDK $ndkVersion'); - runCommand(sdkManager, [ - '--install', - 'ndk;$ndkVersion', - ], environment: { - 'JAVA_HOME': javaHome, - }); - } - - Future> buildEnvironment() async { - final hostArch = Platform.isMacOS - ? "darwin-x86_64" - : (Platform.isLinux ? "linux-x86_64" : "windows-x86_64"); - - final ndkPath = path.join(sdkPath, 'ndk', ndkVersion); - final toolchainPath = path.join( - ndkPath, - 'toolchains', - 'llvm', - 'prebuilt', - hostArch, - 'bin', - ); - - final minSdkVersion = - math.max(target.androidMinSdkVersion!, this.minSdkVersion); - - final exe = Platform.isWindows ? '.exe' : ''; - - final arKey = 'AR_${target.rust}'; - final arValue = ['${target.rust}-ar', 'llvm-ar', 'llvm-ar.exe'] - .map((e) => path.join(toolchainPath, e)) - .firstWhereOrNull((element) => File(element).existsSync()); - if (arValue == null) { - throw Exception('Failed to find ar for $target in $toolchainPath'); - } - - final targetArg = '--target=${target.rust}$minSdkVersion'; - - final ccKey = 'CC_${target.rust}'; - final ccValue = path.join(toolchainPath, 'clang$exe'); - final cfFlagsKey = 'CFLAGS_${target.rust}'; - final cFlagsValue = targetArg; - - final cxxKey = 'CXX_${target.rust}'; - final cxxValue = path.join(toolchainPath, 'clang++$exe'); - final cxxFlagsKey = 'CXXFLAGS_${target.rust}'; - final cxxFlagsValue = targetArg; - - final linkerKey = - 'cargo_target_${target.rust.replaceAll('-', '_')}_linker'.toUpperCase(); - - final ranlibKey = 'RANLIB_${target.rust}'; - final ranlibValue = path.join(toolchainPath, 'llvm-ranlib$exe'); - - final ndkVersionParsed = Version.parse(ndkVersion); - final rustFlagsKey = 'CARGO_ENCODED_RUSTFLAGS'; - final rustFlagsValue = _libGccWorkaround(targetTempDir, ndkVersionParsed); - - final runRustTool = - Platform.isWindows ? 'run_build_tool.cmd' : 'run_build_tool.sh'; - - final packagePath = (await Isolate.resolvePackageUri( - Uri.parse('package:build_tool/buildtool.dart')))! - .toFilePath(); - final selfPath = path.canonicalize(path.join( - packagePath, - '..', - '..', - '..', - runRustTool, - )); - - // Make sure that run_build_tool is working properly even initially launched directly - // through dart run. - final toolTempDir = - Platform.environment['CARGOKIT_TOOL_TEMP_DIR'] ?? targetTempDir; - - return { - arKey: arValue, - ccKey: ccValue, - cfFlagsKey: cFlagsValue, - cxxKey: cxxValue, - cxxFlagsKey: cxxFlagsValue, - ranlibKey: ranlibValue, - rustFlagsKey: rustFlagsValue, - linkerKey: selfPath, - // Recognized by main() so we know when we're acting as a wrapper - '_CARGOKIT_NDK_LINK_TARGET': targetArg, - '_CARGOKIT_NDK_LINK_CLANG': ccValue, - 'CARGOKIT_TOOL_TEMP_DIR': toolTempDir, - }; - } - - // Workaround for libgcc missing in NDK23, inspired by cargo-ndk - String _libGccWorkaround(String buildDir, Version ndkVersion) { - final workaroundDir = path.join( - buildDir, - 'cargokit', - 'libgcc_workaround', - '${ndkVersion.major}', - ); - Directory(workaroundDir).createSync(recursive: true); - if (ndkVersion.major >= 23) { - File(path.join(workaroundDir, 'libgcc.a')) - .writeAsStringSync('INPUT(-lunwind)'); - } else { - // Other way around, untested, forward libgcc.a from libunwind once Rust - // gets updated for NDK23+. - File(path.join(workaroundDir, 'libunwind.a')) - .writeAsStringSync('INPUT(-lgcc)'); - } - - var rustFlags = Platform.environment['CARGO_ENCODED_RUSTFLAGS'] ?? ''; - if (rustFlags.isNotEmpty) { - rustFlags = '$rustFlags\x1f'; - } - rustFlags = '$rustFlags-L\x1f$workaroundDir'; - return rustFlags; - } -} diff --git a/crates/sbm_ffi/cargokit/build_tool/lib/src/artifacts_provider.dart b/crates/sbm_ffi/cargokit/build_tool/lib/src/artifacts_provider.dart deleted file mode 100644 index c09c362f5..000000000 --- a/crates/sbm_ffi/cargokit/build_tool/lib/src/artifacts_provider.dart +++ /dev/null @@ -1,267 +0,0 @@ -/// This is copied from Cargokit (which is the official way to use it currently) -/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin -library; - -import 'dart:io'; - -import 'package:ed25519_edwards/ed25519_edwards.dart'; -import 'package:http/http.dart'; -import 'package:logging/logging.dart'; -import 'package:path/path.dart' as path; - -import 'builder.dart'; -import 'crate_hash.dart'; -import 'options.dart'; -import 'precompile_binaries.dart'; -import 'rustup.dart'; -import 'target.dart'; - -class Artifact { - /// File system location of the artifact. - final String path; - - /// Actual file name that the artifact should have in destination folder. - final String finalFileName; - - AritifactType get type { - if (finalFileName.endsWith('.dll') || - finalFileName.endsWith('.dll.lib') || - finalFileName.endsWith('.pdb') || - finalFileName.endsWith('.so') || - finalFileName.endsWith('.dylib')) { - return AritifactType.dylib; - } else if (finalFileName.endsWith('.lib') || finalFileName.endsWith('.a')) { - return AritifactType.staticlib; - } else { - throw Exception('Unknown artifact type for $finalFileName'); - } - } - - Artifact({ - required this.path, - required this.finalFileName, - }); -} - -final _log = Logger('artifacts_provider'); - -class ArtifactProvider { - ArtifactProvider({ - required this.environment, - required this.userOptions, - }); - - final BuildEnvironment environment; - final CargokitUserOptions userOptions; - - Future>> getArtifacts(List targets) async { - final result = await _getPrecompiledArtifacts(targets); - - final pendingTargets = List.of(targets); - pendingTargets.removeWhere((element) => result.containsKey(element)); - - if (pendingTargets.isEmpty) { - return result; - } - - final rustup = Rustup(); - for (final target in targets) { - final builder = RustBuilder(target: target, environment: environment); - builder.prepare(rustup); - _log.info('Building ${environment.crateInfo.packageName} for $target'); - final targetDir = await builder.build(); - // For local build accept both static and dynamic libraries. - final artifactNames = { - ...getArtifactNames( - target: target, - libraryName: environment.crateInfo.packageName, - aritifactType: AritifactType.dylib, - remote: false, - ), - ...getArtifactNames( - target: target, - libraryName: environment.crateInfo.packageName, - aritifactType: AritifactType.staticlib, - remote: false, - ) - }; - final artifacts = artifactNames - .map((artifactName) => Artifact( - path: path.join(targetDir, artifactName), - finalFileName: artifactName, - )) - .where((element) => File(element.path).existsSync()) - .toList(); - result[target] = artifacts; - } - return result; - } - - Future>> _getPrecompiledArtifacts( - List targets) async { - if (userOptions.usePrecompiledBinaries == false) { - _log.info('Precompiled binaries are disabled'); - return {}; - } - if (environment.crateOptions.precompiledBinaries == null) { - _log.fine('Precompiled binaries not enabled for this crate'); - return {}; - } - - final start = Stopwatch()..start(); - final crateHash = CrateHash.compute(environment.manifestDir, - tempStorage: environment.targetTempDir); - _log.fine( - 'Computed crate hash $crateHash in ${start.elapsedMilliseconds}ms'); - - final downloadedArtifactsDir = - path.join(environment.targetTempDir, 'precompiled', crateHash); - Directory(downloadedArtifactsDir).createSync(recursive: true); - - final res = >{}; - - for (final target in targets) { - final requiredArtifacts = getArtifactNames( - target: target, - libraryName: environment.crateInfo.packageName, - remote: true, - ); - final artifactsForTarget = []; - - for (final artifact in requiredArtifacts) { - final fileName = PrecompileBinaries.fileName(target, artifact); - final downloadedPath = path.join(downloadedArtifactsDir, fileName); - if (!File(downloadedPath).existsSync()) { - final signatureFileName = - PrecompileBinaries.signatureFileName(target, artifact); - await _tryDownloadArtifacts( - crateHash: crateHash, - fileName: fileName, - signatureFileName: signatureFileName, - finalPath: downloadedPath, - ); - } - if (File(downloadedPath).existsSync()) { - artifactsForTarget.add(Artifact( - path: downloadedPath, - finalFileName: artifact, - )); - } else { - break; - } - } - - // Only provide complete set of artifacts. - if (artifactsForTarget.length == requiredArtifacts.length) { - _log.fine('Found precompiled artifacts for $target'); - res[target] = artifactsForTarget; - } - } - - return res; - } - - static Future _get(Uri url, {Map? headers}) async { - int attempt = 0; - const maxAttempts = 10; - while (true) { - try { - return await get(url, headers: headers); - } on SocketException catch (e) { - // Try to detect reset by peer error and retry. - if (attempt++ < maxAttempts && - (e.osError?.errorCode == 54 || e.osError?.errorCode == 10054)) { - _log.severe( - 'Failed to download $url: $e, attempt $attempt of $maxAttempts, will retry...'); - await Future.delayed(Duration(seconds: 1)); - continue; - } else { - rethrow; - } - } - } - } - - Future _tryDownloadArtifacts({ - required String crateHash, - required String fileName, - required String signatureFileName, - required String finalPath, - }) async { - final precompiledBinaries = environment.crateOptions.precompiledBinaries!; - final prefix = precompiledBinaries.uriPrefix; - final url = Uri.parse('$prefix$crateHash/$fileName'); - final signatureUrl = Uri.parse('$prefix$crateHash/$signatureFileName'); - _log.fine('Downloading signature from $signatureUrl'); - final signature = await _get(signatureUrl); - if (signature.statusCode == 404) { - _log.warning( - 'Precompiled binaries not available for crate hash $crateHash ($fileName)'); - return; - } - if (signature.statusCode != 200) { - _log.severe( - 'Failed to download signature $signatureUrl: status ${signature.statusCode}'); - return; - } - _log.fine('Downloading binary from $url'); - final res = await _get(url); - if (res.statusCode != 200) { - _log.severe('Failed to download binary $url: status ${res.statusCode}'); - return; - } - if (verify( - precompiledBinaries.publicKey, res.bodyBytes, signature.bodyBytes)) { - File(finalPath).writeAsBytesSync(res.bodyBytes); - } else { - _log.shout('Signature verification failed! Ignoring binary.'); - } - } -} - -enum AritifactType { - staticlib, - dylib, -} - -AritifactType artifactTypeForTarget(Target target) { - if (target.darwinPlatform != null) { - return AritifactType.staticlib; - } else { - return AritifactType.dylib; - } -} - -List getArtifactNames({ - required Target target, - required String libraryName, - required bool remote, - AritifactType? aritifactType, -}) { - aritifactType ??= artifactTypeForTarget(target); - if (target.darwinArch != null) { - if (aritifactType == AritifactType.staticlib) { - return ['lib$libraryName.a']; - } else { - return ['lib$libraryName.dylib']; - } - } else if (target.rust.contains('-windows-')) { - if (aritifactType == AritifactType.staticlib) { - return ['$libraryName.lib']; - } else { - return [ - '$libraryName.dll', - '$libraryName.dll.lib', - if (!remote) '$libraryName.pdb' - ]; - } - } else if (target.rust.contains('-linux-')) { - if (aritifactType == AritifactType.staticlib) { - return ['lib$libraryName.a']; - } else { - return ['lib$libraryName.so']; - } - } else { - throw Exception("Unsupported target: ${target.rust}"); - } -} diff --git a/crates/sbm_ffi/cargokit/build_tool/lib/src/build_cmake.dart b/crates/sbm_ffi/cargokit/build_tool/lib/src/build_cmake.dart deleted file mode 100644 index 2bc16f93d..000000000 --- a/crates/sbm_ffi/cargokit/build_tool/lib/src/build_cmake.dart +++ /dev/null @@ -1,41 +0,0 @@ -/// This is copied from Cargokit (which is the official way to use it currently) -/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin -library; - -import 'dart:io'; - -import 'package:path/path.dart' as path; - -import 'artifacts_provider.dart'; -import 'builder.dart'; -import 'environment.dart'; -import 'options.dart'; -import 'target.dart'; - -class BuildCMake { - final CargokitUserOptions userOptions; - - BuildCMake({required this.userOptions}); - - Future build() async { - final targetPlatform = Environment.targetPlatform; - final target = Target.forFlutterName(Environment.targetPlatform); - if (target == null) { - throw Exception("Unknown target platform: $targetPlatform"); - } - - final environment = BuildEnvironment.fromEnvironment(isAndroid: false); - final provider = - ArtifactProvider(environment: environment, userOptions: userOptions); - final artifacts = await provider.getArtifacts([target]); - - final libs = artifacts[target]!; - - for (final lib in libs) { - if (lib.type == AritifactType.dylib) { - File(lib.path) - .copySync(path.join(Environment.outputDir, lib.finalFileName)); - } - } - } -} diff --git a/crates/sbm_ffi/cargokit/build_tool/lib/src/build_gradle.dart b/crates/sbm_ffi/cargokit/build_tool/lib/src/build_gradle.dart deleted file mode 100644 index 394f60c97..000000000 --- a/crates/sbm_ffi/cargokit/build_tool/lib/src/build_gradle.dart +++ /dev/null @@ -1,50 +0,0 @@ -/// This is copied from Cargokit (which is the official way to use it currently) -/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin -library; - -import 'dart:io'; - -import 'package:logging/logging.dart'; -import 'package:path/path.dart' as path; - -import 'artifacts_provider.dart'; -import 'builder.dart'; -import 'environment.dart'; -import 'options.dart'; -import 'target.dart'; - -final log = Logger('build_gradle'); - -class BuildGradle { - BuildGradle({required this.userOptions}); - - final CargokitUserOptions userOptions; - - Future build() async { - final targets = Environment.targetPlatforms.map((arch) { - final target = Target.forFlutterName(arch); - if (target == null) { - throw Exception( - "Unknown darwin target or platform: $arch, ${Environment.darwinPlatformName}"); - } - return target; - }).toList(); - - final environment = BuildEnvironment.fromEnvironment(isAndroid: true); - final provider = - ArtifactProvider(environment: environment, userOptions: userOptions); - final artifacts = await provider.getArtifacts(targets); - - for (final target in targets) { - final libs = artifacts[target]!; - final outputDir = path.join(Environment.outputDir, target.android!); - Directory(outputDir).createSync(recursive: true); - - for (final lib in libs) { - if (lib.type == AritifactType.dylib) { - File(lib.path).copySync(path.join(outputDir, lib.finalFileName)); - } - } - } - } -} diff --git a/crates/sbm_ffi/cargokit/build_tool/lib/src/build_pod.dart b/crates/sbm_ffi/cargokit/build_tool/lib/src/build_pod.dart deleted file mode 100644 index ce482eca2..000000000 --- a/crates/sbm_ffi/cargokit/build_tool/lib/src/build_pod.dart +++ /dev/null @@ -1,90 +0,0 @@ -/// This is copied from Cargokit (which is the official way to use it currently) -/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin -library; - -import 'dart:io'; - -import 'package:path/path.dart' as path; - -import 'artifacts_provider.dart'; -import 'builder.dart'; -import 'environment.dart'; -import 'options.dart'; -import 'target.dart'; -import 'util.dart'; - -class BuildPod { - BuildPod({required this.userOptions}); - - final CargokitUserOptions userOptions; - - Future build() async { - final targets = Environment.darwinArchs.map((arch) { - final target = Target.forDarwin( - platformName: Environment.darwinPlatformName, darwinAarch: arch); - if (target == null) { - throw Exception( - "Unknown darwin target or platform: $arch, ${Environment.darwinPlatformName}"); - } - return target; - }).toList(); - - final environment = BuildEnvironment.fromEnvironment(isAndroid: false); - final provider = - ArtifactProvider(environment: environment, userOptions: userOptions); - final artifacts = await provider.getArtifacts(targets); - - void performLipo(String targetFile, Iterable sourceFiles) { - runCommand("lipo", [ - '-create', - ...sourceFiles, - '-output', - targetFile, - ]); - } - - final outputDir = Environment.outputDir; - - Directory(outputDir).createSync(recursive: true); - - final staticLibs = artifacts.values - .expand((element) => element) - .where((element) => element.type == AritifactType.staticlib) - .toList(); - final dynamicLibs = artifacts.values - .expand((element) => element) - .where((element) => element.type == AritifactType.dylib) - .toList(); - - final libName = environment.crateInfo.packageName; - - // If there is static lib, use it and link it with pod - if (staticLibs.isNotEmpty) { - final finalTargetFile = path.join(outputDir, "lib$libName.a"); - performLipo(finalTargetFile, staticLibs.map((e) => e.path)); - } else { - // Otherwise try to replace bundle dylib with our dylib - final bundlePaths = [ - '$libName.framework/Versions/A/$libName', - '$libName.framework/$libName', - ]; - - for (final bundlePath in bundlePaths) { - final targetFile = path.join(outputDir, bundlePath); - if (File(targetFile).existsSync()) { - performLipo(targetFile, dynamicLibs.map((e) => e.path)); - - // Replace absolute id with @rpath one so that it works properly - // when moved to Frameworks. - runCommand("install_name_tool", [ - '-id', - '@rpath/$bundlePath', - targetFile, - ]); - return; - } - } - throw Exception('Unable to find bundle for dynamic library'); - } - } -} diff --git a/crates/sbm_ffi/cargokit/build_tool/lib/src/build_tool.dart b/crates/sbm_ffi/cargokit/build_tool/lib/src/build_tool.dart deleted file mode 100644 index 0cf015dfd..000000000 --- a/crates/sbm_ffi/cargokit/build_tool/lib/src/build_tool.dart +++ /dev/null @@ -1,277 +0,0 @@ -/// This is copied from Cargokit (which is the official way to use it currently) -/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin -library; - -import 'dart:io'; - -import 'package:args/command_runner.dart'; -import 'package:ed25519_edwards/ed25519_edwards.dart'; -import 'package:github/github.dart'; -import 'package:hex/hex.dart'; -import 'package:logging/logging.dart'; - -import 'android_environment.dart'; -import 'build_cmake.dart'; -import 'build_gradle.dart'; -import 'build_pod.dart'; -import 'logging.dart'; -import 'options.dart'; -import 'precompile_binaries.dart'; -import 'target.dart'; -import 'util.dart'; -import 'verify_binaries.dart'; - -final log = Logger('build_tool'); - -abstract class BuildCommand extends Command { - Future runBuildCommand(CargokitUserOptions options); - - @override - Future run() async { - final options = CargokitUserOptions.load(); - - if (options.verboseLogging || - Platform.environment['CARGOKIT_VERBOSE'] == '1') { - enableVerboseLogging(); - } - - await runBuildCommand(options); - } -} - -class BuildPodCommand extends BuildCommand { - @override - final name = 'build-pod'; - - @override - final description = 'Build cocoa pod library'; - - @override - Future runBuildCommand(CargokitUserOptions options) async { - final build = BuildPod(userOptions: options); - await build.build(); - } -} - -class BuildGradleCommand extends BuildCommand { - @override - final name = 'build-gradle'; - - @override - final description = 'Build android library'; - - @override - Future runBuildCommand(CargokitUserOptions options) async { - final build = BuildGradle(userOptions: options); - await build.build(); - } -} - -class BuildCMakeCommand extends BuildCommand { - @override - final name = 'build-cmake'; - - @override - final description = 'Build CMake library'; - - @override - Future runBuildCommand(CargokitUserOptions options) async { - final build = BuildCMake(userOptions: options); - await build.build(); - } -} - -class GenKeyCommand extends Command { - @override - final name = 'gen-key'; - - @override - final description = 'Generate key pair for signing precompiled binaries'; - - @override - void run() { - final kp = generateKey(); - final private = HEX.encode(kp.privateKey.bytes); - final public = HEX.encode(kp.publicKey.bytes); - print("Private Key: $private"); - print("Public Key: $public"); - } -} - -class PrecompileBinariesCommand extends Command { - PrecompileBinariesCommand() { - argParser - ..addOption( - 'repository', - mandatory: true, - help: 'Github repository slug in format owner/name', - ) - ..addOption( - 'manifest-dir', - mandatory: true, - help: 'Directory containing Cargo.toml', - ) - ..addMultiOption('target', - help: 'Rust target triple of artifact to build.\n' - 'Can be specified multiple times or omitted in which case\n' - 'all targets for current platform will be built.') - ..addOption( - 'android-sdk-location', - help: 'Location of Android SDK (if available)', - ) - ..addOption( - 'android-ndk-version', - help: 'Android NDK version (if available)', - ) - ..addOption( - 'android-min-sdk-version', - help: 'Android minimum rquired version (if available)', - ) - ..addOption( - 'temp-dir', - help: 'Directory to store temporary build artifacts', - ) - ..addOption( - 'glibc-version', - help: 'GLIBC version to use for linux builds', - ) - ..addFlag( - "verbose", - abbr: "v", - defaultsTo: false, - help: "Enable verbose logging", - ); - } - - @override - final name = 'precompile-binaries'; - - @override - final description = 'Prebuild and upload binaries\n' - 'Private key must be passed through PRIVATE_KEY environment variable. ' - 'Use gen_key through generate priave key.\n' - 'Github token must be passed as GITHUB_TOKEN environment variable.\n'; - - @override - Future run() async { - final verbose = argResults!['verbose'] as bool; - if (verbose) { - enableVerboseLogging(); - } - - final privateKeyString = Platform.environment['PRIVATE_KEY']; - if (privateKeyString == null) { - throw ArgumentError('Missing PRIVATE_KEY environment variable'); - } - final githubToken = Platform.environment['GITHUB_TOKEN']; - if (githubToken == null) { - throw ArgumentError('Missing GITHUB_TOKEN environment variable'); - } - final privateKey = HEX.decode(privateKeyString); - if (privateKey.length != 64) { - throw ArgumentError('Private key must be 64 bytes long'); - } - final manifestDir = argResults!['manifest-dir'] as String; - if (!Directory(manifestDir).existsSync()) { - throw ArgumentError('Manifest directory does not exist: $manifestDir'); - } - String? androidMinSdkVersionString = - argResults!['android-min-sdk-version'] as String?; - int? androidMinSdkVersion; - if (androidMinSdkVersionString != null) { - androidMinSdkVersion = int.tryParse(androidMinSdkVersionString); - if (androidMinSdkVersion == null) { - throw ArgumentError( - 'Invalid android-min-sdk-version: $androidMinSdkVersionString'); - } - } - final targetStrigns = argResults!['target'] as List; - final targets = targetStrigns.map((target) { - final res = Target.forRustTriple(target); - if (res == null) { - throw ArgumentError('Invalid target: $target'); - } - return res; - }).toList(growable: false); - final precompileBinaries = PrecompileBinaries( - privateKey: PrivateKey(privateKey), - githubToken: githubToken, - manifestDir: manifestDir, - repositorySlug: RepositorySlug.full(argResults!['repository'] as String), - targets: targets, - androidSdkLocation: argResults!['android-sdk-location'] as String?, - androidNdkVersion: argResults!['android-ndk-version'] as String?, - androidMinSdkVersion: androidMinSdkVersion, - tempDir: argResults!['temp-dir'] as String?, - glibcVersion: argResults!['glibc-version'] as String?, - ); - - await precompileBinaries.run(); - } -} - -class VerifyBinariesCommand extends Command { - VerifyBinariesCommand() { - argParser.addOption( - 'manifest-dir', - mandatory: true, - help: 'Directory containing Cargo.toml', - ); - } - - @override - final name = "verify-binaries"; - - @override - final description = 'Verifies published binaries\n' - 'Checks whether there is a binary published for each targets\n' - 'and checks the signature.'; - - @override - Future run() async { - final manifestDir = argResults!['manifest-dir'] as String; - final verifyBinaries = VerifyBinaries( - manifestDir: manifestDir, - ); - await verifyBinaries.run(); - } -} - -Future runMain(List args) async { - try { - // Init logging before options are loaded - initLogging(); - - if (Platform.environment['_CARGOKIT_NDK_LINK_TARGET'] != null) { - return AndroidEnvironment.clangLinkerWrapper(args); - } - - final runner = CommandRunner('build_tool', 'Cargokit built_tool') - ..addCommand(BuildPodCommand()) - ..addCommand(BuildGradleCommand()) - ..addCommand(BuildCMakeCommand()) - ..addCommand(GenKeyCommand()) - ..addCommand(PrecompileBinariesCommand()) - ..addCommand(VerifyBinariesCommand()); - - await runner.run(args); - } on ArgumentError catch (e) { - stderr.writeln(e.toString()); - exit(1); - } catch (e, s) { - log.severe(kDoubleSeparator); - log.severe('Cargokit BuildTool failed with error:'); - log.severe(kSeparator); - log.severe(e); - // This tells user to install Rust, there's no need to pollute the log with - // stack trace. - if (e is! RustupNotFoundException) { - log.severe(kSeparator); - log.severe(s); - log.severe(kSeparator); - log.severe('BuildTool arguments: $args'); - } - log.severe(kDoubleSeparator); - exit(1); - } -} diff --git a/crates/sbm_ffi/cargokit/build_tool/lib/src/builder.dart b/crates/sbm_ffi/cargokit/build_tool/lib/src/builder.dart deleted file mode 100644 index 070fc0812..000000000 --- a/crates/sbm_ffi/cargokit/build_tool/lib/src/builder.dart +++ /dev/null @@ -1,210 +0,0 @@ -/// This is copied from Cargokit (which is the official way to use it currently) -/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin -library; - -import 'package:collection/collection.dart'; -import 'package:logging/logging.dart'; -import 'package:path/path.dart' as path; - -import 'android_environment.dart'; -import 'cargo.dart'; -import 'environment.dart'; -import 'options.dart'; -import 'rustup.dart'; -import 'target.dart'; -import 'util.dart'; - -final _log = Logger('builder'); - -enum BuildConfiguration { - debug, - release, - profile, -} - -extension on BuildConfiguration { - bool get isDebug => this == BuildConfiguration.debug; - String get rustName => switch (this) { - BuildConfiguration.debug => 'debug', - BuildConfiguration.release => 'release', - BuildConfiguration.profile => 'release', - }; -} - -class BuildException implements Exception { - final String message; - - BuildException(this.message); - - @override - String toString() { - return 'BuildException: $message'; - } -} - -class BuildEnvironment { - final BuildConfiguration configuration; - final CargokitCrateOptions crateOptions; - final String targetTempDir; - final String manifestDir; - final CrateInfo crateInfo; - - final bool isAndroid; - final String? androidSdkPath; - final String? androidNdkVersion; - final int? androidMinSdkVersion; - final String? javaHome; - - final String? glibcVersion; - - BuildEnvironment({ - required this.configuration, - required this.crateOptions, - required this.targetTempDir, - required this.manifestDir, - required this.crateInfo, - required this.isAndroid, - this.androidSdkPath, - this.androidNdkVersion, - this.androidMinSdkVersion, - this.javaHome, - this.glibcVersion, - }); - - static BuildConfiguration parseBuildConfiguration(String value) { - // XCode configuration adds the flavor to configuration name. - final firstSegment = value.split('-').first; - final buildConfiguration = BuildConfiguration.values.firstWhereOrNull( - (e) => e.name == firstSegment, - ); - if (buildConfiguration == null) { - _log.warning('Unknown build configuraiton $value, will assume release'); - return BuildConfiguration.release; - } - return buildConfiguration; - } - - static BuildEnvironment fromEnvironment({ - required bool isAndroid, - }) { - final buildConfiguration = - parseBuildConfiguration(Environment.configuration); - final manifestDir = Environment.manifestDir; - final crateOptions = CargokitCrateOptions.load( - manifestDir: manifestDir, - ); - final crateInfo = CrateInfo.load(manifestDir); - return BuildEnvironment( - configuration: buildConfiguration, - crateOptions: crateOptions, - targetTempDir: Environment.targetTempDir, - manifestDir: manifestDir, - crateInfo: crateInfo, - isAndroid: isAndroid, - androidSdkPath: isAndroid ? Environment.sdkPath : null, - androidNdkVersion: isAndroid ? Environment.ndkVersion : null, - androidMinSdkVersion: - isAndroid ? int.parse(Environment.minSdkVersion) : null, - javaHome: isAndroid ? Environment.javaHome : null, - ); - } -} - -class RustBuilder { - final Target target; - final BuildEnvironment environment; - - RustBuilder({ - required this.target, - required this.environment, - }); - - void prepare( - Rustup rustup, - ) { - final toolchain = _toolchain; - if (rustup.installedTargets(toolchain) == null) { - rustup.installToolchain(toolchain); - } - if (toolchain == 'nightly') { - rustup.installRustSrcForNightly(); - } - if (!rustup.installedTargets(toolchain)!.contains(target.rust)) { - rustup.installTarget(target.rust, toolchain: toolchain); - } - if (environment.glibcVersion != null) { - rustup.installZigBuild(toolchain); - } - } - - CargoBuildOptions? get _buildOptions => - environment.crateOptions.cargo[environment.configuration]; - - String get _toolchain => _buildOptions?.toolchain.name ?? 'stable'; - - /// Returns the path of directory containing build artifacts. - Future build() async { - final extraArgs = _buildOptions?.flags ?? []; - final manifestPath = path.join(environment.manifestDir, 'Cargo.toml'); - runCommand( - 'rustup', - [ - 'run', - _toolchain, - 'cargo', - (target.android == null && environment.glibcVersion != null) - ? 'zigbuild' - : 'build', - ...extraArgs, - '--manifest-path', - manifestPath, - '-p', - environment.crateInfo.packageName, - if (!environment.configuration.isDebug) '--release', - '--target', - target.rust + - ((target.android == null && environment.glibcVersion != null) - ? '.${environment.glibcVersion!}' - : ""), - '--target-dir', - environment.targetTempDir, - ], - environment: await _buildEnvironment(), - ); - return path.join( - environment.targetTempDir, - target.rust, - environment.configuration.rustName, - ); - } - - Future> _buildEnvironment() async { - if (target.android == null) { - return {}; - } else { - final sdkPath = environment.androidSdkPath; - final ndkVersion = environment.androidNdkVersion; - final minSdkVersion = environment.androidMinSdkVersion; - if (sdkPath == null) { - throw BuildException('androidSdkPath is not set'); - } - if (ndkVersion == null) { - throw BuildException('androidNdkVersion is not set'); - } - if (minSdkVersion == null) { - throw BuildException('androidMinSdkVersion is not set'); - } - final env = AndroidEnvironment( - sdkPath: sdkPath, - ndkVersion: ndkVersion, - minSdkVersion: minSdkVersion, - targetTempDir: environment.targetTempDir, - target: target, - ); - if (!env.ndkIsInstalled() && environment.javaHome != null) { - env.installNdk(javaHome: environment.javaHome!); - } - return env.buildEnvironment(); - } - } -} diff --git a/crates/sbm_ffi/cargokit/build_tool/lib/src/cargo.dart b/crates/sbm_ffi/cargokit/build_tool/lib/src/cargo.dart deleted file mode 100644 index c6785fa22..000000000 --- a/crates/sbm_ffi/cargokit/build_tool/lib/src/cargo.dart +++ /dev/null @@ -1,49 +0,0 @@ -/// This is copied from Cargokit (which is the official way to use it currently) -/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin -library; - -import 'dart:io'; - -import 'package:path/path.dart' as path; -import 'package:toml/toml.dart'; - -class ManifestException { - ManifestException(this.message, {required this.fileName}); - - final String? fileName; - final String message; - - @override - String toString() { - if (fileName != null) { - return 'Failed to parse package manifest at $fileName: $message'; - } else { - return 'Failed to parse package manifest: $message'; - } - } -} - -class CrateInfo { - CrateInfo({required this.packageName}); - - final String packageName; - - static CrateInfo parseManifest(String manifest, {final String? fileName}) { - final toml = TomlDocument.parse(manifest); - final package = toml.toMap()['package']; - if (package == null) { - throw ManifestException('Missing package section', fileName: fileName); - } - final name = package['name']; - if (name == null) { - throw ManifestException('Missing package name', fileName: fileName); - } - return CrateInfo(packageName: name); - } - - static CrateInfo load(String manifestDir) { - final manifestFile = File(path.join(manifestDir, 'Cargo.toml')); - final manifest = manifestFile.readAsStringSync(); - return parseManifest(manifest, fileName: manifestFile.path); - } -} diff --git a/crates/sbm_ffi/cargokit/build_tool/lib/src/crate_hash.dart b/crates/sbm_ffi/cargokit/build_tool/lib/src/crate_hash.dart deleted file mode 100644 index 2dc20ee11..000000000 --- a/crates/sbm_ffi/cargokit/build_tool/lib/src/crate_hash.dart +++ /dev/null @@ -1,125 +0,0 @@ -/// This is copied from Cargokit (which is the official way to use it currently) -/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin -library; - -import 'dart:convert'; -import 'dart:io'; -import 'dart:typed_data'; - -import 'package:collection/collection.dart'; -import 'package:convert/convert.dart'; -import 'package:crypto/crypto.dart'; -import 'package:path/path.dart' as path; - -class CrateHash { - /// Computes a hash uniquely identifying crate content. This takes into account - /// content all all .rs files inside the src directory, as well as Cargo.toml, - /// Cargo.lock, build.rs and cargokit.yaml. - /// - /// If [tempStorage] is provided, computed hash is stored in a file in that directory - /// and reused on subsequent calls if the crate content hasn't changed. - static String compute(String manifestDir, {String? tempStorage}) { - return CrateHash._( - manifestDir: manifestDir, - tempStorage: tempStorage, - )._compute(); - } - - CrateHash._({ - required this.manifestDir, - required this.tempStorage, - }); - - String _compute() { - final files = getFiles(); - final tempStorage = this.tempStorage; - if (tempStorage != null) { - final quickHash = _computeQuickHash(files); - final quickHashFolder = Directory(path.join(tempStorage, 'crate_hash')); - quickHashFolder.createSync(recursive: true); - final quickHashFile = File(path.join(quickHashFolder.path, quickHash)); - if (quickHashFile.existsSync()) { - return quickHashFile.readAsStringSync(); - } - final hash = _computeHash(files); - quickHashFile.writeAsStringSync(hash); - return hash; - } else { - return _computeHash(files); - } - } - - /// Computes a quick hash based on files stat (without reading contents). This - /// is used to cache the real hash, which is slower to compute since it involves - /// reading every single file. - String _computeQuickHash(List files) { - final output = AccumulatorSink(); - final input = sha256.startChunkedConversion(output); - - final data = ByteData(8); - for (final file in files) { - input.add(utf8.encode(file.path)); - final stat = file.statSync(); - data.setUint64(0, stat.size); - input.add(data.buffer.asUint8List()); - data.setUint64(0, stat.modified.millisecondsSinceEpoch); - input.add(data.buffer.asUint8List()); - } - - input.close(); - return base64Url.encode(output.events.single.bytes); - } - - String _computeHash(List files) { - final output = AccumulatorSink(); - final input = sha256.startChunkedConversion(output); - - void addTextFile(File file) { - // text Files are hashed by lines in case we're dealing with github checkout - // that auto-converts line endings. - final splitter = LineSplitter(); - if (file.existsSync()) { - final data = file.readAsStringSync(); - final lines = splitter.convert(data); - for (final line in lines) { - input.add(utf8.encode(line)); - } - } - } - - for (final file in files) { - addTextFile(file); - } - - input.close(); - final res = output.events.single; - - // Truncate to 128bits. - final hash = res.bytes.sublist(0, 16); - return hex.encode(hash); - } - - List getFiles() { - final src = Directory(path.join(manifestDir, 'src')); - final files = src - .listSync(recursive: true, followLinks: false) - .whereType() - .toList(); - files.sortBy((element) => element.path); - void addFile(String relative) { - final file = File(path.join(manifestDir, relative)); - if (file.existsSync()) { - files.add(file); - } - } - - addFile('Cargo.toml'); - addFile('Cargo.lock'); - addFile('build.rs'); - addFile('cargokit.yaml'); - return files; - } - - final String manifestDir; - final String? tempStorage; -} diff --git a/crates/sbm_ffi/cargokit/build_tool/lib/src/environment.dart b/crates/sbm_ffi/cargokit/build_tool/lib/src/environment.dart deleted file mode 100644 index d99f54a3d..000000000 --- a/crates/sbm_ffi/cargokit/build_tool/lib/src/environment.dart +++ /dev/null @@ -1,69 +0,0 @@ -/// This is copied from Cargokit (which is the official way to use it currently) -/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin -library; - -import 'dart:io'; - -extension on String { - String resolveSymlink() => File(this).resolveSymbolicLinksSync(); -} - -class Environment { - /// Current build configuration (debug or release). - static String get configuration => - _getEnv("CARGOKIT_CONFIGURATION").toLowerCase(); - - static bool get isDebug => configuration == 'debug'; - static bool get isRelease => configuration == 'release'; - - /// Temporary directory where Rust build artifacts are placed. - static String get targetTempDir => _getEnv("CARGOKIT_TARGET_TEMP_DIR"); - - /// Final output directory where the build artifacts are placed. - static String get outputDir => _getEnvPath('CARGOKIT_OUTPUT_DIR'); - - /// Path to the crate manifest (containing Cargo.toml). - static String get manifestDir => _getEnvPath('CARGOKIT_MANIFEST_DIR'); - - /// Directory inside root project. Not necessarily root folder. Symlinks are - /// not resolved on purpose. - static String get rootProjectDir => _getEnv('CARGOKIT_ROOT_PROJECT_DIR'); - - // Pod - - /// Platform name (macosx, iphoneos, iphonesimulator). - static String get darwinPlatformName => - _getEnv("CARGOKIT_DARWIN_PLATFORM_NAME"); - - /// List of architectures to build for (arm64, armv7, x86_64). - static List get darwinArchs => - _getEnv("CARGOKIT_DARWIN_ARCHS").split(' '); - - // Gradle - static String get minSdkVersion => _getEnv("CARGOKIT_MIN_SDK_VERSION"); - static String get ndkVersion => _getEnv("CARGOKIT_NDK_VERSION"); - static String get sdkPath => _getEnvPath("CARGOKIT_SDK_DIR"); - static String get javaHome => _getEnvPath("CARGOKIT_JAVA_HOME"); - static List get targetPlatforms => - _getEnv("CARGOKIT_TARGET_PLATFORMS").split(','); - - // CMAKE - static String get targetPlatform => _getEnv("CARGOKIT_TARGET_PLATFORM"); - - static String _getEnv(String key) { - final res = Platform.environment[key]; - if (res == null) { - throw Exception("Missing environment variable $key"); - } - return res; - } - - static String _getEnvPath(String key) { - final res = _getEnv(key); - if (Directory(res).existsSync()) { - return res.resolveSymlink(); - } else { - return res; - } - } -} diff --git a/crates/sbm_ffi/cargokit/build_tool/lib/src/logging.dart b/crates/sbm_ffi/cargokit/build_tool/lib/src/logging.dart deleted file mode 100644 index 42b6acc59..000000000 --- a/crates/sbm_ffi/cargokit/build_tool/lib/src/logging.dart +++ /dev/null @@ -1,53 +0,0 @@ -/// This is copied from Cargokit (which is the official way to use it currently) -/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin -library; - -import 'dart:io'; - -import 'package:logging/logging.dart'; - -const String kSeparator = "--"; -const String kDoubleSeparator = "=="; - -bool _lastMessageWasSeparator = false; - -void _log(LogRecord rec) { - final prefix = '${rec.level.name}: '; - final out = rec.level == Level.SEVERE ? stderr : stdout; - if (rec.message == kSeparator) { - if (!_lastMessageWasSeparator) { - out.write(prefix); - out.writeln('-' * 80); - _lastMessageWasSeparator = true; - } - return; - } else if (rec.message == kDoubleSeparator) { - out.write(prefix); - out.writeln('=' * 80); - _lastMessageWasSeparator = true; - return; - } - out.write(prefix); - out.writeln(rec.message); - _lastMessageWasSeparator = false; -} - -void initLogging() { - Logger.root.level = Level.INFO; - Logger.root.onRecord.listen((LogRecord rec) { - final lines = rec.message.split('\n'); - for (final line in lines) { - if (line.isNotEmpty || lines.length == 1 || line != lines.last) { - _log(LogRecord( - rec.level, - line, - rec.loggerName, - )); - } - } - }); -} - -void enableVerboseLogging() { - Logger.root.level = Level.ALL; -} diff --git a/crates/sbm_ffi/cargokit/build_tool/lib/src/options.dart b/crates/sbm_ffi/cargokit/build_tool/lib/src/options.dart deleted file mode 100644 index 856b03f26..000000000 --- a/crates/sbm_ffi/cargokit/build_tool/lib/src/options.dart +++ /dev/null @@ -1,310 +0,0 @@ -/// This is copied from Cargokit (which is the official way to use it currently) -/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin -library; - -import 'dart:io'; - -import 'package:collection/collection.dart'; -import 'package:ed25519_edwards/ed25519_edwards.dart'; -import 'package:hex/hex.dart'; -import 'package:logging/logging.dart'; -import 'package:path/path.dart' as path; -import 'package:source_span/source_span.dart'; -import 'package:yaml/yaml.dart'; - -import 'builder.dart'; -import 'environment.dart'; -import 'rustup.dart'; - -final _log = Logger('options'); - -/// A class for exceptions that have source span information attached. -class SourceSpanException implements Exception { - // This is a getter so that subclasses can override it. - /// A message describing the exception. - String get message => _message; - final String _message; - - // This is a getter so that subclasses can override it. - /// The span associated with this exception. - /// - /// This may be `null` if the source location can't be determined. - SourceSpan? get span => _span; - final SourceSpan? _span; - - SourceSpanException(this._message, this._span); - - /// Returns a string representation of `this`. - /// - /// [color] may either be a [String], a [bool], or `null`. If it's a string, - /// it indicates an ANSI terminal color escape that should be used to - /// highlight the span's text. If it's `true`, it indicates that the text - /// should be highlighted using the default color. If it's `false` or `null`, - /// it indicates that the text shouldn't be highlighted. - @override - String toString({Object? color}) { - if (span == null) return message; - return 'Error on ${span!.message(message, color: color)}'; - } -} - -enum Toolchain { - stable, - beta, - nightly, -} - -class CargoBuildOptions { - final Toolchain toolchain; - final List flags; - - CargoBuildOptions({ - required this.toolchain, - required this.flags, - }); - - static Toolchain _toolchainFromNode(YamlNode node) { - if (node case YamlScalar(value: String name)) { - final toolchain = - Toolchain.values.firstWhereOrNull((element) => element.name == name); - if (toolchain != null) { - return toolchain; - } - } - throw SourceSpanException( - 'Unknown toolchain. Must be one of ${Toolchain.values.map((e) => e.name)}.', - node.span); - } - - static CargoBuildOptions parse(YamlNode node) { - if (node is! YamlMap) { - throw SourceSpanException('Cargo options must be a map', node.span); - } - Toolchain toolchain = Toolchain.stable; - List flags = []; - for (final MapEntry(:key, :value) in node.nodes.entries) { - if (key case YamlScalar(value: 'toolchain')) { - toolchain = _toolchainFromNode(value); - } else if (key case YamlScalar(value: 'extra_flags')) { - if (value case YamlList(nodes: List list)) { - if (list.every((element) { - if (element case YamlScalar(value: String _)) { - return true; - } - return false; - })) { - flags = list.map((e) => e.value as String).toList(); - continue; - } - } - throw SourceSpanException( - 'Extra flags must be a list of strings', value.span); - } else { - throw SourceSpanException( - 'Unknown cargo option type. Must be "toolchain" or "extra_flags".', - key.span); - } - } - return CargoBuildOptions(toolchain: toolchain, flags: flags); - } -} - -extension on YamlMap { - /// Map that extracts keys so that we can do map case check on them. - Map get valueMap => - nodes.map((key, value) => MapEntry(key.value, value)); -} - -class PrecompiledBinaries { - final String uriPrefix; - final PublicKey publicKey; - - PrecompiledBinaries({ - required this.uriPrefix, - required this.publicKey, - }); - - static PublicKey _publicKeyFromHex(String key, SourceSpan? span) { - final bytes = HEX.decode(key); - if (bytes.length != 32) { - throw SourceSpanException( - 'Invalid public key. Must be 32 bytes long.', span); - } - return PublicKey(bytes); - } - - static PrecompiledBinaries parse(YamlNode node) { - if (node case YamlMap(valueMap: Map map)) { - if (map - case { - 'url_prefix': YamlNode urlPrefixNode, - 'public_key': YamlNode publicKeyNode, - }) { - final urlPrefix = switch (urlPrefixNode) { - YamlScalar(value: String urlPrefix) => urlPrefix, - _ => throw SourceSpanException( - 'Invalid URL prefix value.', urlPrefixNode.span), - }; - final publicKey = switch (publicKeyNode) { - YamlScalar(value: String publicKey) => - _publicKeyFromHex(publicKey, publicKeyNode.span), - _ => throw SourceSpanException( - 'Invalid public key value.', publicKeyNode.span), - }; - return PrecompiledBinaries( - uriPrefix: urlPrefix, - publicKey: publicKey, - ); - } - } - throw SourceSpanException( - 'Invalid precompiled binaries value. ' - 'Expected Map with "url_prefix" and "public_key".', - node.span); - } -} - -/// Cargokit options specified for Rust crate. -class CargokitCrateOptions { - CargokitCrateOptions({ - this.cargo = const {}, - this.precompiledBinaries, - }); - - final Map cargo; - final PrecompiledBinaries? precompiledBinaries; - - static CargokitCrateOptions parse(YamlNode node) { - if (node is! YamlMap) { - throw SourceSpanException('Cargokit options must be a map', node.span); - } - final options = {}; - PrecompiledBinaries? precompiledBinaries; - - for (final entry in node.nodes.entries) { - if (entry - case MapEntry( - key: YamlScalar(value: 'cargo'), - value: YamlNode node, - )) { - if (node is! YamlMap) { - throw SourceSpanException('Cargo options must be a map', node.span); - } - for (final MapEntry(:YamlNode key, :value) in node.nodes.entries) { - if (key case YamlScalar(value: String name)) { - final configuration = BuildConfiguration.values - .firstWhereOrNull((element) => element.name == name); - if (configuration != null) { - options[configuration] = CargoBuildOptions.parse(value); - continue; - } - } - throw SourceSpanException( - 'Unknown build configuration. Must be one of ${BuildConfiguration.values.map((e) => e.name)}.', - key.span); - } - } else if (entry.key case YamlScalar(value: 'precompiled_binaries')) { - precompiledBinaries = PrecompiledBinaries.parse(entry.value); - } else { - throw SourceSpanException( - 'Unknown cargokit option type. Must be "cargo" or "precompiled_binaries".', - entry.key.span); - } - } - return CargokitCrateOptions( - cargo: options, - precompiledBinaries: precompiledBinaries, - ); - } - - static CargokitCrateOptions load({ - required String manifestDir, - }) { - final uri = Uri.file(path.join(manifestDir, "cargokit.yaml")); - final file = File.fromUri(uri); - if (file.existsSync()) { - final contents = loadYamlNode(file.readAsStringSync(), sourceUrl: uri); - return parse(contents); - } else { - return CargokitCrateOptions(); - } - } -} - -class CargokitUserOptions { - // When Rustup is installed always build locally unless user opts into - // using precompiled binaries. - static bool defaultUsePrecompiledBinaries() { - return Rustup.executablePath() == null; - } - - CargokitUserOptions({ - required this.usePrecompiledBinaries, - required this.verboseLogging, - }); - - CargokitUserOptions._() - : usePrecompiledBinaries = defaultUsePrecompiledBinaries(), - verboseLogging = false; - - static CargokitUserOptions parse(YamlNode node) { - if (node is! YamlMap) { - throw SourceSpanException('Cargokit options must be a map', node.span); - } - bool usePrecompiledBinaries = defaultUsePrecompiledBinaries(); - bool verboseLogging = false; - - for (final entry in node.nodes.entries) { - if (entry.key case YamlScalar(value: 'use_precompiled_binaries')) { - if (entry.value case YamlScalar(value: bool value)) { - usePrecompiledBinaries = value; - continue; - } - throw SourceSpanException( - 'Invalid value for "use_precompiled_binaries". Must be a boolean.', - entry.value.span); - } else if (entry.key case YamlScalar(value: 'verbose_logging')) { - if (entry.value case YamlScalar(value: bool value)) { - verboseLogging = value; - continue; - } - throw SourceSpanException( - 'Invalid value for "verbose_logging". Must be a boolean.', - entry.value.span); - } else { - throw SourceSpanException( - 'Unknown cargokit option type. Must be "use_precompiled_binaries" or "verbose_logging".', - entry.key.span); - } - } - return CargokitUserOptions( - usePrecompiledBinaries: usePrecompiledBinaries, - verboseLogging: verboseLogging, - ); - } - - static CargokitUserOptions load() { - String fileName = "cargokit_options.yaml"; - var userProjectDir = Directory(Environment.rootProjectDir); - - while (userProjectDir.parent.path != userProjectDir.path) { - final configFile = File(path.join(userProjectDir.path, fileName)); - if (configFile.existsSync()) { - final contents = loadYamlNode( - configFile.readAsStringSync(), - sourceUrl: configFile.uri, - ); - final res = parse(contents); - if (res.verboseLogging) { - _log.info('Found user options file at ${configFile.path}'); - } - return res; - } - userProjectDir = userProjectDir.parent; - } - return CargokitUserOptions._(); - } - - final bool usePrecompiledBinaries; - final bool verboseLogging; -} diff --git a/crates/sbm_ffi/cargokit/build_tool/lib/src/precompile_binaries.dart b/crates/sbm_ffi/cargokit/build_tool/lib/src/precompile_binaries.dart deleted file mode 100644 index f1d0a81f5..000000000 --- a/crates/sbm_ffi/cargokit/build_tool/lib/src/precompile_binaries.dart +++ /dev/null @@ -1,206 +0,0 @@ -/// This is copied from Cargokit (which is the official way to use it currently) -/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin -library; - -import 'dart:io'; - -import 'package:ed25519_edwards/ed25519_edwards.dart'; -import 'package:github/github.dart'; -import 'package:logging/logging.dart'; -import 'package:path/path.dart' as path; - -import 'artifacts_provider.dart'; -import 'builder.dart'; -import 'cargo.dart'; -import 'crate_hash.dart'; -import 'options.dart'; -import 'rustup.dart'; -import 'target.dart'; - -final _log = Logger('precompile_binaries'); - -class PrecompileBinaries { - PrecompileBinaries({ - required this.privateKey, - required this.githubToken, - required this.repositorySlug, - required this.manifestDir, - required this.targets, - this.androidSdkLocation, - this.androidNdkVersion, - this.androidMinSdkVersion, - this.tempDir, - this.glibcVersion, - }); - - final PrivateKey privateKey; - final String githubToken; - final RepositorySlug repositorySlug; - final String manifestDir; - final List targets; - final String? androidSdkLocation; - final String? androidNdkVersion; - final int? androidMinSdkVersion; - final String? tempDir; - final String? glibcVersion; - - static String fileName(Target target, String name) { - return '${target.rust}_$name'; - } - - static String signatureFileName(Target target, String name) { - return '${target.rust}_$name.sig'; - } - - Future run() async { - final crateInfo = CrateInfo.load(manifestDir); - - final targets = List.of(this.targets); - if (targets.isEmpty) { - targets.addAll([ - ...Target.buildableTargets(), - if (androidSdkLocation != null) ...Target.androidTargets(), - ]); - } - - _log.info('Precompiling binaries for $targets'); - - final hash = CrateHash.compute(manifestDir); - _log.info('Computed crate hash: $hash'); - - final String tagName = 'precompiled_$hash'; - - final github = GitHub(auth: Authentication.withToken(githubToken)); - final repo = github.repositories; - final release = await _getOrCreateRelease( - repo: repo, - tagName: tagName, - packageName: crateInfo.packageName, - hash: hash, - ); - - final tempDir = this.tempDir != null - ? Directory(this.tempDir!) - : Directory.systemTemp.createTempSync('precompiled_'); - - tempDir.createSync(recursive: true); - - final crateOptions = CargokitCrateOptions.load( - manifestDir: manifestDir, - ); - - final buildEnvironment = BuildEnvironment( - configuration: BuildConfiguration.release, - crateOptions: crateOptions, - targetTempDir: tempDir.path, - manifestDir: manifestDir, - crateInfo: crateInfo, - isAndroid: androidSdkLocation != null, - androidSdkPath: androidSdkLocation, - androidNdkVersion: androidNdkVersion, - androidMinSdkVersion: androidMinSdkVersion, - glibcVersion: glibcVersion, - ); - - final rustup = Rustup(); - - for (final target in targets) { - final artifactNames = getArtifactNames( - target: target, - libraryName: crateInfo.packageName, - remote: true, - ); - - if (artifactNames.every((name) { - final fileName = PrecompileBinaries.fileName(target, name); - return (release.assets ?? []).any((e) => e.name == fileName); - })) { - _log.info("All artifacts for $target already exist - skipping"); - continue; - } - - _log.info('Building for $target'); - - final builder = - RustBuilder(target: target, environment: buildEnvironment); - builder.prepare(rustup); - final res = await builder.build(); - - final assets = []; - for (final name in artifactNames) { - final file = File(path.join(res, name)); - if (!file.existsSync()) { - throw Exception('Missing artifact: ${file.path}'); - } - - final data = file.readAsBytesSync(); - final create = CreateReleaseAsset( - name: PrecompileBinaries.fileName(target, name), - contentType: "application/octet-stream", - assetData: data, - ); - final signature = sign(privateKey, data); - final signatureCreate = CreateReleaseAsset( - name: signatureFileName(target, name), - contentType: "application/octet-stream", - assetData: signature, - ); - bool verified = verify(public(privateKey), data, signature); - if (!verified) { - throw Exception('Signature verification failed'); - } - assets.add(create); - assets.add(signatureCreate); - } - _log.info('Uploading assets: ${assets.map((e) => e.name)}'); - for (final asset in assets) { - // This seems to be failing on CI so do it one by one - int retryCount = 0; - while (true) { - try { - await repo.uploadReleaseAssets(release, [asset]); - break; - } on Exception catch (e) { - if (retryCount == 10) { - rethrow; - } - ++retryCount; - _log.shout( - 'Upload failed (attempt $retryCount, will retry): ${e.toString()}'); - await Future.delayed(Duration(seconds: 2)); - } - } - } - } - - _log.info('Cleaning up'); - tempDir.deleteSync(recursive: true); - } - - Future _getOrCreateRelease({ - required RepositoriesService repo, - required String tagName, - required String packageName, - required String hash, - }) async { - Release release; - try { - _log.info('Fetching release $tagName'); - release = await repo.getReleaseByTagName(repositorySlug, tagName); - } on ReleaseNotFound { - _log.info('Release not found - creating release $tagName'); - release = await repo.createRelease( - repositorySlug, - CreateRelease.from( - tagName: tagName, - name: 'Precompiled binaries ${hash.substring(0, 8)}', - targetCommitish: null, - isDraft: false, - isPrerelease: false, - body: 'Precompiled binaries for crate $packageName, ' - 'crate hash $hash.', - )); - } - return release; - } -} diff --git a/crates/sbm_ffi/cargokit/build_tool/lib/src/rustup.dart b/crates/sbm_ffi/cargokit/build_tool/lib/src/rustup.dart deleted file mode 100644 index a5aa5333f..000000000 --- a/crates/sbm_ffi/cargokit/build_tool/lib/src/rustup.dart +++ /dev/null @@ -1,150 +0,0 @@ -/// This is copied from Cargokit (which is the official way to use it currently) -/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin -library; - -import 'dart:io'; - -import 'package:collection/collection.dart'; -import 'package:path/path.dart' as path; - -import 'util.dart'; - -class _Toolchain { - _Toolchain( - this.name, - this.targets, - ); - - final String name; - final List targets; -} - -class Rustup { - List? installedTargets(String toolchain) { - final targets = _installedTargets(toolchain); - return targets != null ? List.unmodifiable(targets) : null; - } - - void installToolchain(String toolchain) { - log.info("Installing Rust toolchain: $toolchain"); - runCommand("rustup", ['toolchain', 'install', toolchain]); - _installedToolchains - .add(_Toolchain(toolchain, _getInstalledTargets(toolchain))); - } - - void installTarget( - String target, { - required String toolchain, - }) { - log.info("Installing Rust target: $target"); - runCommand("rustup", ['target', 'add', '--toolchain', toolchain, target]); - _installedTargets(toolchain)?.add(target); - } - - bool _didInstallZigBuild = false; - - void installZigBuild(String toolchain) { - if (_didInstallZigBuild) { - return; - } - - log.info("Installing Zig build"); - runCommand("rustup", [ - 'run', - toolchain, - 'cargo', - 'install', - '--locked', - 'cargo-zigbuild', - ]); - _didInstallZigBuild = true; - } - - final List<_Toolchain> _installedToolchains; - - Rustup() : _installedToolchains = _getInstalledToolchains(); - - List? _installedTargets(String toolchain) => _installedToolchains - .firstWhereOrNull( - (e) => e.name == toolchain || e.name.startsWith('$toolchain-')) - ?.targets; - - static List<_Toolchain> _getInstalledToolchains() { - String extractToolchainName(String line) { - // ignore (default) after toolchain name - final parts = line.split(' '); - return parts[0]; - } - - final res = runCommand("rustup", ['toolchain', 'list']); - - // To list all non-custom toolchains, we need to filter out lines that - // don't start with "stable", "beta", or "nightly". - Pattern nonCustom = RegExp(r"^(stable|beta|nightly)"); - final lines = res.stdout - .toString() - .split('\n') - .where((e) => e.isNotEmpty && e.startsWith(nonCustom)) - .map(extractToolchainName) - .toList(growable: true); - - return lines - .map( - (name) => _Toolchain( - name, - _getInstalledTargets(name), - ), - ) - .toList(growable: true); - } - - static List _getInstalledTargets(String toolchain) { - final res = runCommand("rustup", [ - 'target', - 'list', - '--toolchain', - toolchain, - '--installed', - ]); - final lines = res.stdout - .toString() - .split('\n') - .where((e) => e.isNotEmpty) - .toList(growable: true); - return lines; - } - - bool _didInstallRustSrcForNightly = false; - - void installRustSrcForNightly() { - if (_didInstallRustSrcForNightly) { - return; - } - // Useful for -Z build-std - runCommand( - "rustup", - ['component', 'add', 'rust-src', '--toolchain', 'nightly'], - ); - _didInstallRustSrcForNightly = true; - } - - static String? executablePath() { - final envPath = Platform.environment['PATH']; - final envPathSeparator = Platform.isWindows ? ';' : ':'; - final home = Platform.isWindows - ? Platform.environment['USERPROFILE'] - : Platform.environment['HOME']; - final paths = [ - if (home != null) path.join(home, '.cargo', 'bin'), - if (envPath != null) ...envPath.split(envPathSeparator), - ]; - for (final p in paths) { - final rustup = Platform.isWindows ? 'rustup.exe' : 'rustup'; - final rustupPath = path.join(p, rustup); - if (File(rustupPath).existsSync()) { - return rustupPath; - } - } - return null; - } -} diff --git a/crates/sbm_ffi/cargokit/build_tool/lib/src/target.dart b/crates/sbm_ffi/cargokit/build_tool/lib/src/target.dart deleted file mode 100644 index b662c42f1..000000000 --- a/crates/sbm_ffi/cargokit/build_tool/lib/src/target.dart +++ /dev/null @@ -1,148 +0,0 @@ -/// This is copied from Cargokit (which is the official way to use it currently) -/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin -library; - -import 'dart:io'; - -import 'package:collection/collection.dart'; - -import 'util.dart'; - -class Target { - Target({ - required this.rust, - this.flutter, - this.android, - this.androidMinSdkVersion, - this.darwinPlatform, - this.darwinArch, - }); - - static final all = [ - Target( - rust: 'armv7-linux-androideabi', - flutter: 'android-arm', - android: 'armeabi-v7a', - androidMinSdkVersion: 16, - ), - Target( - rust: 'aarch64-linux-android', - flutter: 'android-arm64', - android: 'arm64-v8a', - androidMinSdkVersion: 21, - ), - Target( - rust: 'i686-linux-android', - flutter: 'android-x86', - android: 'x86', - androidMinSdkVersion: 16, - ), - Target( - rust: 'x86_64-linux-android', - flutter: 'android-x64', - android: 'x86_64', - androidMinSdkVersion: 21, - ), - Target( - rust: 'x86_64-pc-windows-msvc', - flutter: 'windows-x64', - ), - Target( - rust: 'aarch64-pc-windows-msvc', - flutter: 'windows-arm64', - ), - Target( - rust: 'x86_64-unknown-linux-gnu', - flutter: 'linux-x64', - ), - Target( - rust: 'aarch64-unknown-linux-gnu', - flutter: 'linux-arm64', - ), - Target(rust: 'riscv64gc-unknown-linux-gnu', flutter: 'linux-riscv64'), - Target( - rust: 'x86_64-apple-darwin', - darwinPlatform: 'macosx', - darwinArch: 'x86_64', - ), - Target( - rust: 'aarch64-apple-darwin', - darwinPlatform: 'macosx', - darwinArch: 'arm64', - ), - Target( - rust: 'aarch64-apple-ios', - darwinPlatform: 'iphoneos', - darwinArch: 'arm64', - ), - Target( - rust: 'aarch64-apple-ios-sim', - darwinPlatform: 'iphonesimulator', - darwinArch: 'arm64', - ), - Target( - rust: 'x86_64-apple-ios', - darwinPlatform: 'iphonesimulator', - darwinArch: 'x86_64', - ), - ]; - - static Target? forFlutterName(String flutterName) { - return all.firstWhereOrNull((element) => element.flutter == flutterName); - } - - static Target? forDarwin({ - required String platformName, - required String darwinAarch, - }) { - return all.firstWhereOrNull((element) => // - element.darwinPlatform == platformName && - element.darwinArch == darwinAarch); - } - - static Target? forRustTriple(String triple) { - return all.firstWhereOrNull((element) => element.rust == triple); - } - - static List androidTargets() { - return all - .where((element) => element.android != null) - .toList(growable: false); - } - - /// Returns buildable targets on current host platform ignoring Android targets. - static List buildableTargets() { - if (Platform.isLinux) { - // Right now we don't support cross-compiling on Linux. So we just return - // the host target. - final arch = (runCommand('arch', []).stdout as String).trim(); - if (arch == 'aarch64') { - return [Target.forRustTriple('aarch64-unknown-linux-gnu')!]; - } else if (arch == 'riscv64') { - return [Target.forRustTriple('riscv64gc-unknown-linux-gnu')!]; - } else { - return [Target.forRustTriple('x86_64-unknown-linux-gnu')!]; - } - } - return all.where((target) { - if (Platform.isWindows) { - return target.rust.contains('-windows-'); - } else if (Platform.isMacOS) { - return target.darwinPlatform != null; - } - return false; - }).toList(growable: false); - } - - @override - String toString() { - return rust; - } - - final String? flutter; - final String rust; - final String? android; - final int? androidMinSdkVersion; - final String? darwinPlatform; - final String? darwinArch; -} diff --git a/crates/sbm_ffi/cargokit/build_tool/lib/src/util.dart b/crates/sbm_ffi/cargokit/build_tool/lib/src/util.dart deleted file mode 100644 index e25cad8d0..000000000 --- a/crates/sbm_ffi/cargokit/build_tool/lib/src/util.dart +++ /dev/null @@ -1,173 +0,0 @@ -/// This is copied from Cargokit (which is the official way to use it currently) -/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin -library; - -import 'dart:convert'; -import 'dart:io'; - -import 'package:logging/logging.dart'; -import 'package:path/path.dart' as path; - -import 'logging.dart'; -import 'rustup.dart'; - -final log = Logger("process"); - -class CommandFailedException implements Exception { - final String executable; - final List arguments; - final ProcessResult result; - - CommandFailedException({ - required this.executable, - required this.arguments, - required this.result, - }); - - @override - String toString() { - final stdout = result.stdout.toString().trim(); - final stderr = result.stderr.toString().trim(); - return [ - "External Command: $executable ${arguments.map((e) => '"$e"').join(' ')}", - "Returned Exit Code: ${result.exitCode}", - kSeparator, - "STDOUT:", - if (stdout.isNotEmpty) stdout, - kSeparator, - "STDERR:", - if (stderr.isNotEmpty) stderr, - ].join('\n'); - } -} - -class TestRunCommandArgs { - final String executable; - final List arguments; - final String? workingDirectory; - final Map? environment; - final bool includeParentEnvironment; - final bool runInShell; - final Encoding? stdoutEncoding; - final Encoding? stderrEncoding; - - TestRunCommandArgs({ - required this.executable, - required this.arguments, - this.workingDirectory, - this.environment, - this.includeParentEnvironment = true, - this.runInShell = false, - this.stdoutEncoding, - this.stderrEncoding, - }); -} - -class TestRunCommandResult { - TestRunCommandResult({ - this.pid = 1, - this.exitCode = 0, - this.stdout = '', - this.stderr = '', - }); - - final int pid; - final int exitCode; - final String stdout; - final String stderr; -} - -TestRunCommandResult Function(TestRunCommandArgs args)? testRunCommandOverride; - -ProcessResult runCommand( - String executable, - List arguments, { - String? workingDirectory, - Map? environment, - bool includeParentEnvironment = true, - bool runInShell = false, - Encoding? stdoutEncoding = systemEncoding, - Encoding? stderrEncoding = systemEncoding, -}) { - if (testRunCommandOverride != null) { - final result = testRunCommandOverride!(TestRunCommandArgs( - executable: executable, - arguments: arguments, - workingDirectory: workingDirectory, - environment: environment, - includeParentEnvironment: includeParentEnvironment, - runInShell: runInShell, - stdoutEncoding: stdoutEncoding, - stderrEncoding: stderrEncoding, - )); - return ProcessResult( - result.pid, - result.exitCode, - result.stdout, - result.stderr, - ); - } - log.finer('Running command $executable ${arguments.join(' ')}'); - final res = Process.runSync( - _resolveExecutable(executable), - arguments, - workingDirectory: workingDirectory, - environment: environment, - includeParentEnvironment: includeParentEnvironment, - runInShell: runInShell, - stderrEncoding: stderrEncoding, - stdoutEncoding: stdoutEncoding, - ); - if (res.exitCode != 0) { - throw CommandFailedException( - executable: executable, - arguments: arguments, - result: res, - ); - } else { - return res; - } -} - -class RustupNotFoundException implements Exception { - @override - String toString() { - return [ - ' ', - 'rustup not found in PATH.', - ' ', - 'Maybe you need to install Rust? It only takes a minute:', - ' ', - if (Platform.isWindows) 'https://www.rust-lang.org/tools/install', - if (hasHomebrewRustInPath()) ...[ - '\$ brew unlink rust # Unlink homebrew Rust from PATH', - ], - if (!Platform.isWindows) - "\$ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh", - ' ', - ].join('\n'); - } - - static bool hasHomebrewRustInPath() { - if (!Platform.isMacOS) { - return false; - } - final envPath = Platform.environment['PATH'] ?? ''; - final paths = envPath.split(':'); - return paths.any((p) { - return p.contains('homebrew') && File(path.join(p, 'rustc')).existsSync(); - }); - } -} - -String _resolveExecutable(String executable) { - if (executable == 'rustup') { - final resolved = Rustup.executablePath(); - if (resolved != null) { - return resolved; - } - throw RustupNotFoundException(); - } else { - return executable; - } -} diff --git a/crates/sbm_ffi/cargokit/build_tool/lib/src/verify_binaries.dart b/crates/sbm_ffi/cargokit/build_tool/lib/src/verify_binaries.dart deleted file mode 100644 index 4e212e2f2..000000000 --- a/crates/sbm_ffi/cargokit/build_tool/lib/src/verify_binaries.dart +++ /dev/null @@ -1,85 +0,0 @@ -/// This is copied from Cargokit (which is the official way to use it currently) -/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin -library; - -import 'dart:io'; - -import 'package:ed25519_edwards/ed25519_edwards.dart'; -import 'package:http/http.dart'; - -import 'artifacts_provider.dart'; -import 'cargo.dart'; -import 'crate_hash.dart'; -import 'options.dart'; -import 'precompile_binaries.dart'; -import 'target.dart'; - -class VerifyBinaries { - VerifyBinaries({ - required this.manifestDir, - }); - - final String manifestDir; - - Future run() async { - final crateInfo = CrateInfo.load(manifestDir); - - final config = CargokitCrateOptions.load(manifestDir: manifestDir); - final precompiledBinaries = config.precompiledBinaries; - if (precompiledBinaries == null) { - stdout.writeln('Crate does not support precompiled binaries.'); - } else { - final crateHash = CrateHash.compute(manifestDir); - stdout.writeln('Crate hash: $crateHash'); - - for (final target in Target.all) { - final message = 'Checking ${target.rust}...'; - stdout.write(message.padRight(40)); - stdout.flush(); - - final artifacts = getArtifactNames( - target: target, - libraryName: crateInfo.packageName, - remote: true, - ); - - final prefix = precompiledBinaries.uriPrefix; - - bool ok = true; - - for (final artifact in artifacts) { - final fileName = PrecompileBinaries.fileName(target, artifact); - final signatureFileName = - PrecompileBinaries.signatureFileName(target, artifact); - - final url = Uri.parse('$prefix$crateHash/$fileName'); - final signatureUrl = - Uri.parse('$prefix$crateHash/$signatureFileName'); - - final signature = await get(signatureUrl); - if (signature.statusCode != 200) { - stdout.writeln('MISSING'); - ok = false; - break; - } - final asset = await get(url); - if (asset.statusCode != 200) { - stdout.writeln('MISSING'); - ok = false; - break; - } - - if (!verify(precompiledBinaries.publicKey, asset.bodyBytes, - signature.bodyBytes)) { - stdout.writeln('INVALID SIGNATURE'); - ok = false; - } - } - - if (ok) { - stdout.writeln('OK'); - } - } - } - } -} diff --git a/crates/sbm_ffi/cargokit/build_tool/pubspec.lock b/crates/sbm_ffi/cargokit/build_tool/pubspec.lock deleted file mode 100644 index 343bdd369..000000000 --- a/crates/sbm_ffi/cargokit/build_tool/pubspec.lock +++ /dev/null @@ -1,453 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - _fe_analyzer_shared: - dependency: transitive - description: - name: _fe_analyzer_shared - sha256: eb376e9acf6938204f90eb3b1f00b578640d3188b4c8a8ec054f9f479af8d051 - url: "https://pub.dev" - source: hosted - version: "64.0.0" - adaptive_number: - dependency: transitive - description: - name: adaptive_number - sha256: "3a567544e9b5c9c803006f51140ad544aedc79604fd4f3f2c1380003f97c1d77" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - analyzer: - dependency: transitive - description: - name: analyzer - sha256: "69f54f967773f6c26c7dcb13e93d7ccee8b17a641689da39e878d5cf13b06893" - url: "https://pub.dev" - source: hosted - version: "6.2.0" - args: - dependency: "direct main" - description: - name: args - sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596 - url: "https://pub.dev" - source: hosted - version: "2.4.2" - async: - dependency: transitive - description: - name: async - sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" - url: "https://pub.dev" - source: hosted - version: "2.11.0" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" - url: "https://pub.dev" - source: hosted - version: "2.1.1" - collection: - dependency: "direct main" - description: - name: collection - sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a - url: "https://pub.dev" - source: hosted - version: "1.18.0" - convert: - dependency: "direct main" - description: - name: convert - sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" - url: "https://pub.dev" - source: hosted - version: "3.1.1" - coverage: - dependency: transitive - description: - name: coverage - sha256: "2fb815080e44a09b85e0f2ca8a820b15053982b2e714b59267719e8a9ff17097" - url: "https://pub.dev" - source: hosted - version: "1.6.3" - crypto: - dependency: "direct main" - description: - name: crypto - sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab - url: "https://pub.dev" - source: hosted - version: "3.0.3" - ed25519_edwards: - dependency: "direct main" - description: - name: ed25519_edwards - sha256: "6ce0112d131327ec6d42beede1e5dfd526069b18ad45dcf654f15074ad9276cd" - url: "https://pub.dev" - source: hosted - version: "0.3.1" - file: - dependency: transitive - description: - name: file - sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d" - url: "https://pub.dev" - source: hosted - version: "6.1.4" - fixnum: - dependency: transitive - description: - name: fixnum - sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - frontend_server_client: - dependency: transitive - description: - name: frontend_server_client - sha256: "408e3ca148b31c20282ad6f37ebfa6f4bdc8fede5b74bc2f08d9d92b55db3612" - url: "https://pub.dev" - source: hosted - version: "3.2.0" - github: - dependency: "direct main" - description: - name: github - sha256: "9966bc13bf612342e916b0a343e95e5f046c88f602a14476440e9b75d2295411" - url: "https://pub.dev" - source: hosted - version: "9.17.0" - glob: - dependency: transitive - description: - name: glob - sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - hex: - dependency: "direct main" - description: - name: hex - sha256: "4e7cd54e4b59ba026432a6be2dd9d96e4c5205725194997193bf871703b82c4a" - url: "https://pub.dev" - source: hosted - version: "0.2.0" - http: - dependency: "direct main" - description: - name: http - sha256: "759d1a329847dd0f39226c688d3e06a6b8679668e350e2891a6474f8b4bb8525" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - http_multi_server: - dependency: transitive - description: - name: http_multi_server - sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" - url: "https://pub.dev" - source: hosted - version: "3.2.1" - http_parser: - dependency: transitive - description: - name: http_parser - sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" - url: "https://pub.dev" - source: hosted - version: "4.0.2" - io: - dependency: transitive - description: - name: io - sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" - url: "https://pub.dev" - source: hosted - version: "1.0.4" - js: - dependency: transitive - description: - name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 - url: "https://pub.dev" - source: hosted - version: "0.6.7" - json_annotation: - dependency: transitive - description: - name: json_annotation - sha256: b10a7b2ff83d83c777edba3c6a0f97045ddadd56c944e1a23a3fdf43a1bf4467 - url: "https://pub.dev" - source: hosted - version: "4.8.1" - lints: - dependency: "direct dev" - description: - name: lints - sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452" - url: "https://pub.dev" - source: hosted - version: "2.1.1" - logging: - dependency: "direct main" - description: - name: logging - sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" - url: "https://pub.dev" - source: hosted - version: "1.2.0" - matcher: - dependency: transitive - description: - name: matcher - sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" - url: "https://pub.dev" - source: hosted - version: "0.12.16" - meta: - dependency: transitive - description: - name: meta - sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3" - url: "https://pub.dev" - source: hosted - version: "1.9.1" - mime: - dependency: transitive - description: - name: mime - sha256: e4ff8e8564c03f255408decd16e7899da1733852a9110a58fe6d1b817684a63e - url: "https://pub.dev" - source: hosted - version: "1.0.4" - node_preamble: - dependency: transitive - description: - name: node_preamble - sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" - url: "https://pub.dev" - source: hosted - version: "2.0.2" - package_config: - dependency: transitive - description: - name: package_config - sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" - url: "https://pub.dev" - source: hosted - version: "2.1.0" - path: - dependency: "direct main" - description: - name: path - sha256: "2ad4cddff7f5cc0e2d13069f2a3f7a73ca18f66abd6f5ecf215219cdb3638edb" - url: "https://pub.dev" - source: hosted - version: "1.8.0" - petitparser: - dependency: transitive - description: - name: petitparser - sha256: cb3798bef7fc021ac45b308f4b51208a152792445cce0448c9a4ba5879dd8750 - url: "https://pub.dev" - source: hosted - version: "5.4.0" - pool: - dependency: transitive - description: - name: pool - sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" - url: "https://pub.dev" - source: hosted - version: "1.5.1" - pub_semver: - dependency: transitive - description: - name: pub_semver - sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - shelf: - dependency: transitive - description: - name: shelf - sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 - url: "https://pub.dev" - source: hosted - version: "1.4.1" - shelf_packages_handler: - dependency: transitive - description: - name: shelf_packages_handler - sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" - url: "https://pub.dev" - source: hosted - version: "3.0.2" - shelf_static: - dependency: transitive - description: - name: shelf_static - sha256: a41d3f53c4adf0f57480578c1d61d90342cd617de7fc8077b1304643c2d85c1e - url: "https://pub.dev" - source: hosted - version: "1.1.2" - shelf_web_socket: - dependency: transitive - description: - name: shelf_web_socket - sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" - url: "https://pub.dev" - source: hosted - version: "1.0.4" - source_map_stack_trace: - dependency: transitive - description: - name: source_map_stack_trace - sha256: "84cf769ad83aa6bb61e0aa5a18e53aea683395f196a6f39c4c881fb90ed4f7ae" - url: "https://pub.dev" - source: hosted - version: "2.1.1" - source_maps: - dependency: transitive - description: - name: source_maps - sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703" - url: "https://pub.dev" - source: hosted - version: "0.10.12" - source_span: - dependency: "direct main" - description: - name: source_span - sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" - url: "https://pub.dev" - source: hosted - version: "1.10.0" - stack_trace: - dependency: transitive - description: - name: stack_trace - sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" - url: "https://pub.dev" - source: hosted - version: "1.11.1" - stream_channel: - dependency: transitive - description: - name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 - url: "https://pub.dev" - source: hosted - version: "2.1.2" - string_scanner: - dependency: transitive - description: - name: string_scanner - sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" - url: "https://pub.dev" - source: hosted - version: "1.2.0" - term_glyph: - dependency: transitive - description: - name: term_glyph - sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 - url: "https://pub.dev" - source: hosted - version: "1.2.1" - test: - dependency: "direct dev" - description: - name: test - sha256: "9b0dd8e36af4a5b1569029949d50a52cb2a2a2fdaa20cebb96e6603b9ae241f9" - url: "https://pub.dev" - source: hosted - version: "1.24.6" - test_api: - dependency: transitive - description: - name: test_api - sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" - url: "https://pub.dev" - source: hosted - version: "0.6.1" - test_core: - dependency: transitive - description: - name: test_core - sha256: "4bef837e56375537055fdbbbf6dd458b1859881f4c7e6da936158f77d61ab265" - url: "https://pub.dev" - source: hosted - version: "0.5.6" - toml: - dependency: "direct main" - description: - name: toml - sha256: "157c5dca5160fced243f3ce984117f729c788bb5e475504f3dbcda881accee44" - url: "https://pub.dev" - source: hosted - version: "0.14.0" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c - url: "https://pub.dev" - source: hosted - version: "1.3.2" - version: - dependency: "direct main" - description: - name: version - sha256: "2307e23a45b43f96469eeab946208ed63293e8afca9c28cd8b5241ff31c55f55" - url: "https://pub.dev" - source: hosted - version: "3.0.0" - vm_service: - dependency: transitive - description: - name: vm_service - sha256: "0fae432c85c4ea880b33b497d32824b97795b04cdaa74d270219572a1f50268d" - url: "https://pub.dev" - source: hosted - version: "11.9.0" - watcher: - dependency: transitive - description: - name: watcher - sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - web_socket_channel: - dependency: transitive - description: - name: web_socket_channel - sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b - url: "https://pub.dev" - source: hosted - version: "2.4.0" - webkit_inspection_protocol: - dependency: transitive - description: - name: webkit_inspection_protocol - sha256: "67d3a8b6c79e1987d19d848b0892e582dbb0c66c57cc1fef58a177dd2aa2823d" - url: "https://pub.dev" - source: hosted - version: "1.2.0" - yaml: - dependency: "direct main" - description: - name: yaml - sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" - url: "https://pub.dev" - source: hosted - version: "3.1.2" -sdks: - dart: ">=3.0.0 <4.0.0" diff --git a/crates/sbm_ffi/cargokit/build_tool/pubspec.yaml b/crates/sbm_ffi/cargokit/build_tool/pubspec.yaml deleted file mode 100644 index 18c61e338..000000000 --- a/crates/sbm_ffi/cargokit/build_tool/pubspec.yaml +++ /dev/null @@ -1,33 +0,0 @@ -# This is copied from Cargokit (which is the official way to use it currently) -# Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin - -name: build_tool -description: Cargokit build_tool. Facilitates the build of Rust crate during Flutter application build. -publish_to: none -version: 1.0.0 - -environment: - sdk: ">=3.0.0 <4.0.0" - -# Add regular dependencies here. -dependencies: - # these are pinned on purpose because the bundle_tool_runner doesn't have - # pubspec.lock. See run_build_tool.sh - logging: 1.2.0 - path: 1.8.0 - version: 3.0.0 - collection: 1.18.0 - ed25519_edwards: 0.3.1 - hex: 0.2.0 - yaml: 3.1.2 - source_span: 1.10.0 - github: 9.17.0 - args: 2.4.2 - crypto: 3.0.3 - convert: 3.1.1 - http: 1.1.0 - toml: 0.14.0 - -dev_dependencies: - lints: ^2.1.0 - test: ^1.24.0 diff --git a/crates/sbm_ffi/cargokit/cmake/cargokit.cmake b/crates/sbm_ffi/cargokit/cmake/cargokit.cmake deleted file mode 100644 index ddd05df9b..000000000 --- a/crates/sbm_ffi/cargokit/cmake/cargokit.cmake +++ /dev/null @@ -1,99 +0,0 @@ -SET(cargokit_cmake_root "${CMAKE_CURRENT_LIST_DIR}/..") - -# Workaround for https://github.com/dart-lang/pub/issues/4010 -get_filename_component(cargokit_cmake_root "${cargokit_cmake_root}" REALPATH) - -if(WIN32) - # REALPATH does not properly resolve symlinks on windows :-/ - execute_process(COMMAND powershell -ExecutionPolicy Bypass -File "${CMAKE_CURRENT_LIST_DIR}/resolve_symlinks.ps1" "${cargokit_cmake_root}" OUTPUT_VARIABLE cargokit_cmake_root OUTPUT_STRIP_TRAILING_WHITESPACE) -endif() - -# Arguments -# - target: CMAKE target to which rust library is linked -# - manifest_dir: relative path from current folder to directory containing cargo manifest -# - lib_name: cargo package name -# - any_symbol_name: name of any exported symbol from the library. -# used on windows to force linking with library. -function(apply_cargokit target manifest_dir lib_name any_symbol_name) - - set(CARGOKIT_LIB_NAME "${lib_name}") - set(CARGOKIT_LIB_FULL_NAME "${CMAKE_SHARED_MODULE_PREFIX}${CARGOKIT_LIB_NAME}${CMAKE_SHARED_MODULE_SUFFIX}") - if (CMAKE_CONFIGURATION_TYPES) - set(CARGOKIT_OUTPUT_DIR "${CMAKE_CURRENT_BINARY_DIR}/$") - set(OUTPUT_LIB "${CMAKE_CURRENT_BINARY_DIR}/$/${CARGOKIT_LIB_FULL_NAME}") - else() - set(CARGOKIT_OUTPUT_DIR "${CMAKE_CURRENT_BINARY_DIR}") - set(OUTPUT_LIB "${CMAKE_CURRENT_BINARY_DIR}/${CARGOKIT_LIB_FULL_NAME}") - endif() - set(CARGOKIT_TEMP_DIR "${CMAKE_CURRENT_BINARY_DIR}/cargokit_build") - - if (FLUTTER_TARGET_PLATFORM) - set(CARGOKIT_TARGET_PLATFORM "${FLUTTER_TARGET_PLATFORM}") - else() - set(CARGOKIT_TARGET_PLATFORM "windows-x64") - endif() - - set(CARGOKIT_ENV - "CARGOKIT_CMAKE=${CMAKE_COMMAND}" - "CARGOKIT_CONFIGURATION=$" - "CARGOKIT_MANIFEST_DIR=${CMAKE_CURRENT_SOURCE_DIR}/${manifest_dir}" - "CARGOKIT_TARGET_TEMP_DIR=${CARGOKIT_TEMP_DIR}" - "CARGOKIT_OUTPUT_DIR=${CARGOKIT_OUTPUT_DIR}" - "CARGOKIT_TARGET_PLATFORM=${CARGOKIT_TARGET_PLATFORM}" - "CARGOKIT_TOOL_TEMP_DIR=${CARGOKIT_TEMP_DIR}/tool" - "CARGOKIT_ROOT_PROJECT_DIR=${CMAKE_SOURCE_DIR}" - ) - - if (WIN32) - set(SCRIPT_EXTENSION ".cmd") - set(IMPORT_LIB_EXTENSION ".lib") - else() - set(SCRIPT_EXTENSION ".sh") - set(IMPORT_LIB_EXTENSION "") - execute_process(COMMAND chmod +x "${cargokit_cmake_root}/run_build_tool${SCRIPT_EXTENSION}") - endif() - - # Using generators in custom command is only supported in CMake 3.20+ - if (CMAKE_CONFIGURATION_TYPES AND ${CMAKE_VERSION} VERSION_LESS "3.20.0") - foreach(CONFIG IN LISTS CMAKE_CONFIGURATION_TYPES) - add_custom_command( - OUTPUT - "${CMAKE_CURRENT_BINARY_DIR}/${CONFIG}/${CARGOKIT_LIB_FULL_NAME}" - "${CMAKE_CURRENT_BINARY_DIR}/_phony_" - COMMAND ${CMAKE_COMMAND} -E env ${CARGOKIT_ENV} - "${cargokit_cmake_root}/run_build_tool${SCRIPT_EXTENSION}" build-cmake - VERBATIM - ) - endforeach() - else() - add_custom_command( - OUTPUT - ${OUTPUT_LIB} - "${CMAKE_CURRENT_BINARY_DIR}/_phony_" - COMMAND ${CMAKE_COMMAND} -E env ${CARGOKIT_ENV} - "${cargokit_cmake_root}/run_build_tool${SCRIPT_EXTENSION}" build-cmake - VERBATIM - ) - endif() - - - set_source_files_properties("${CMAKE_CURRENT_BINARY_DIR}/_phony_" PROPERTIES SYMBOLIC TRUE) - - if (TARGET ${target}) - # If we have actual cmake target provided create target and make existing - # target depend on it - add_custom_target("${target}_cargokit" DEPENDS ${OUTPUT_LIB}) - add_dependencies("${target}" "${target}_cargokit") - target_link_libraries("${target}" PRIVATE "${OUTPUT_LIB}${IMPORT_LIB_EXTENSION}") - if(WIN32) - target_link_options(${target} PRIVATE "/INCLUDE:${any_symbol_name}") - endif() - else() - # Otherwise (FFI) just use ALL to force building always - add_custom_target("${target}_cargokit" ALL DEPENDS ${OUTPUT_LIB}) - endif() - - # Allow adding the output library to plugin bundled libraries - set("${target}_cargokit_lib" ${OUTPUT_LIB} PARENT_SCOPE) - -endfunction() diff --git a/crates/sbm_ffi/cargokit/cmake/resolve_symlinks.ps1 b/crates/sbm_ffi/cargokit/cmake/resolve_symlinks.ps1 deleted file mode 100644 index 2ac593a1f..000000000 --- a/crates/sbm_ffi/cargokit/cmake/resolve_symlinks.ps1 +++ /dev/null @@ -1,34 +0,0 @@ -function Resolve-Symlinks { - [CmdletBinding()] - [OutputType([string])] - param( - [Parameter(Position = 0, Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] - [string] $Path - ) - - [string] $separator = '/' - [string[]] $parts = $Path.Split($separator) - - [string] $realPath = '' - foreach ($part in $parts) { - if ($realPath -and !$realPath.EndsWith($separator)) { - $realPath += $separator - } - - $realPath += $part.Replace('\', '/') - - # The slash is important when using Get-Item on Drive letters in pwsh. - if (-not($realPath.Contains($separator)) -and $realPath.EndsWith(':')) { - $realPath += '/' - } - - $item = Get-Item $realPath - if ($item.LinkTarget) { - $realPath = $item.LinkTarget.Replace('\', '/') - } - } - $realPath -} - -$path = Resolve-Symlinks -Path $args[0] -Write-Host $path diff --git a/crates/sbm_ffi/cargokit/gradle/plugin.gradle b/crates/sbm_ffi/cargokit/gradle/plugin.gradle deleted file mode 100644 index 68ff64991..000000000 --- a/crates/sbm_ffi/cargokit/gradle/plugin.gradle +++ /dev/null @@ -1,184 +0,0 @@ -/// This is copied from Cargokit (which is the official way to use it currently) -/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin - -import java.nio.file.Paths -import org.apache.tools.ant.taskdefs.condition.Os - -CargoKitPlugin.file = buildscript.sourceFile - -apply plugin: CargoKitPlugin - -class CargoKitExtension { - String manifestDir; // Relative path to folder containing Cargo.toml - String libname; // Library name within Cargo.toml. Must be a cdylib -} - -abstract class CargoKitBuildTask extends DefaultTask { - - @Input - String buildMode - - @Input - String buildDir - - @Input - String outputDir - - @Input - String ndkVersion - - @Input - String sdkDirectory - - @Input - int compileSdkVersion; - - @Input - int minSdkVersion; - - @Input - String pluginFile - - @Input - List targetPlatforms - - @TaskAction - def build() { - if (project.cargokit.manifestDir == null) { - throw new GradleException("Property 'manifestDir' must be set on cargokit extension"); - } - - if (project.cargokit.libname == null) { - throw new GradleException("Property 'libname' must be set on cargokit extension"); - } - - def executableName = Os.isFamily(Os.FAMILY_WINDOWS) ? "run_build_tool.cmd" : "run_build_tool.sh" - def path = Paths.get(new File(pluginFile).parent, "..", executableName); - - def manifestDir = Paths.get(project.buildscript.sourceFile.parent, project.cargokit.manifestDir) - - def rootProjectDir = project.rootProject.projectDir - - if (!Os.isFamily(Os.FAMILY_WINDOWS)) { - project.exec { - commandLine 'chmod', '+x', path - } - } - - project.exec { - executable path - args "build-gradle" - environment "CARGOKIT_ROOT_PROJECT_DIR", rootProjectDir - environment "CARGOKIT_TOOL_TEMP_DIR", "${buildDir}/build_tool" - environment "CARGOKIT_MANIFEST_DIR", manifestDir - environment "CARGOKIT_CONFIGURATION", buildMode - environment "CARGOKIT_TARGET_TEMP_DIR", buildDir - environment "CARGOKIT_OUTPUT_DIR", outputDir - environment "CARGOKIT_NDK_VERSION", ndkVersion - environment "CARGOKIT_SDK_DIR", sdkDirectory - environment "CARGOKIT_COMPILE_SDK_VERSION", compileSdkVersion - environment "CARGOKIT_MIN_SDK_VERSION", minSdkVersion - environment "CARGOKIT_TARGET_PLATFORMS", targetPlatforms.join(",") - environment "CARGOKIT_JAVA_HOME", System.properties['java.home'] - } - } -} - -class CargoKitPlugin implements Plugin { - - static String file; - - private Plugin findFlutterPlugin(Project rootProject) { - _findFlutterPlugin(rootProject.childProjects) - } - - private Plugin _findFlutterPlugin(Map projects) { - for (project in projects) { - for (plugin in project.value.getPlugins()) { - if (plugin.class.name == "com.flutter.gradle.FlutterPlugin" || plugin.class.name == "FlutterPlugin") { - return plugin; - } - } - def plugin = _findFlutterPlugin(project.value.childProjects); - if (plugin != null) { - return plugin; - } - } - return null; - } - - @Override - void apply(Project project) { - def plugin = findFlutterPlugin(project.rootProject); - - project.extensions.create("cargokit", CargoKitExtension) - - if (plugin == null) { - print("Flutter plugin not found, CargoKit plugin will not be applied.") - return; - } - - def cargoBuildDir = "${project.buildDir}/build" - - // Determine if the project is an application or library - def isApplication = plugin.project.plugins.hasPlugin('com.android.application') - def variants = isApplication ? plugin.project.android.applicationVariants : plugin.project.android.libraryVariants - - variants.all { variant -> - - final buildType = variant.buildType.name - - def cargoOutputDir = "${project.buildDir}/jniLibs/${buildType}"; - def jniLibs = project.android.sourceSets.maybeCreate(buildType).jniLibs; - jniLibs.srcDir(new File(cargoOutputDir)) - - def List platforms - try { - platforms = com.flutter.gradle.FlutterPluginUtils.getTargetPlatforms(project).collect() - } catch (Exception ignored) { - platforms = plugin.getTargetPlatforms().collect() - } - - // Same thing addFlutterDependencies does in flutter.gradle - if (buildType == "debug") { - platforms.add("android-x86") - platforms.add("android-x64") - } - - // The task name depends on plugin properties, which are not available - // at this point - project.getGradle().afterProject { - def taskName = "cargokitCargoBuild${project.cargokit.libname.capitalize()}${buildType.capitalize()}"; - - if (project.tasks.findByName(taskName)) { - return - } - - if (plugin.project.android.ndkVersion == null) { - throw new GradleException("Please set 'android.ndkVersion' in 'app/build.gradle'.") - } - - def task = project.tasks.create(taskName, CargoKitBuildTask.class) { - buildMode = variant.buildType.name - buildDir = cargoBuildDir - outputDir = cargoOutputDir - ndkVersion = plugin.project.android.ndkVersion - sdkDirectory = plugin.project.android.sdkDirectory - minSdkVersion = plugin.project.android.defaultConfig.minSdkVersion.apiLevel as int - compileSdkVersion = plugin.project.android.compileSdkVersion.substring(8) as int - targetPlatforms = platforms - pluginFile = CargoKitPlugin.file - } - def onTask = { newTask -> - if (newTask.name == "merge${buildType.capitalize()}NativeLibs") { - newTask.dependsOn task - // Fix gradle 7.4.2 not picking up JNI library changes - newTask.outputs.upToDateWhen { false } - } - } - project.tasks.each onTask - project.tasks.whenTaskAdded onTask - } - } - } -} diff --git a/crates/sbm_ffi/cargokit/run_build_tool.cmd b/crates/sbm_ffi/cargokit/run_build_tool.cmd deleted file mode 100755 index c45d0aa8b..000000000 --- a/crates/sbm_ffi/cargokit/run_build_tool.cmd +++ /dev/null @@ -1,91 +0,0 @@ -@echo off -setlocal - -setlocal ENABLEDELAYEDEXPANSION - -SET BASEDIR=%~dp0 - -if not exist "%CARGOKIT_TOOL_TEMP_DIR%" ( - mkdir "%CARGOKIT_TOOL_TEMP_DIR%" -) -cd /D "%CARGOKIT_TOOL_TEMP_DIR%" - -SET BUILD_TOOL_PKG_DIR=%BASEDIR%build_tool -SET DART=%FLUTTER_ROOT%\bin\cache\dart-sdk\bin\dart - -set BUILD_TOOL_PKG_DIR_POSIX=%BUILD_TOOL_PKG_DIR:\=/% - -( - echo name: build_tool_runner - echo version: 1.0.0 - echo publish_to: none - echo. - echo environment: - echo sdk: '^>=3.0.0 ^<4.0.0' - echo. - echo dependencies: - echo build_tool: - echo path: %BUILD_TOOL_PKG_DIR_POSIX% -) >pubspec.yaml - -if not exist bin ( - mkdir bin -) - -( - echo import 'package:build_tool/build_tool.dart' as build_tool; - echo void main^(List^ args^) ^{ - echo build_tool.runMain^(args^); - echo ^} -) >bin\build_tool_runner.dart - -SET PRECOMPILED=bin\build_tool_runner.dill - -REM To detect changes in package we compare output of DIR /s (recursive) -set PREV_PACKAGE_INFO=.dart_tool\package_info.prev -set CUR_PACKAGE_INFO=.dart_tool\package_info.cur - -DIR "%BUILD_TOOL_PKG_DIR%" /s > "%CUR_PACKAGE_INFO%_orig" - -REM Last line in dir output is free space on harddrive. That is bound to -REM change between invocation so we need to remove it -( - Set "Line=" - For /F "UseBackQ Delims=" %%A In ("%CUR_PACKAGE_INFO%_orig") Do ( - SetLocal EnableDelayedExpansion - If Defined Line Echo !Line! - EndLocal - Set "Line=%%A") -) >"%CUR_PACKAGE_INFO%" -DEL "%CUR_PACKAGE_INFO%_orig" - -REM Compare current directory listing with previous -FC /B "%CUR_PACKAGE_INFO%" "%PREV_PACKAGE_INFO%" > nul 2>&1 - -If %ERRORLEVEL% neq 0 ( - REM Changed - copy current to previous and remove precompiled kernel - if exist "%PREV_PACKAGE_INFO%" ( - DEL "%PREV_PACKAGE_INFO%" - ) - MOVE /Y "%CUR_PACKAGE_INFO%" "%PREV_PACKAGE_INFO%" - if exist "%PRECOMPILED%" ( - DEL "%PRECOMPILED%" - ) -) - -REM There is no CUR_PACKAGE_INFO it was renamed in previous step to %PREV_PACKAGE_INFO% -REM which means we need to do pub get and precompile -if not exist "%PRECOMPILED%" ( - echo Running pub get in "%cd%" - "%DART%" pub get --no-precompile - "%DART%" compile kernel bin/build_tool_runner.dart -) - -"%DART%" "%PRECOMPILED%" %* - -REM 253 means invalid snapshot version. -If %ERRORLEVEL% equ 253 ( - "%DART%" pub get --no-precompile - "%DART%" compile kernel bin/build_tool_runner.dart - "%DART%" "%PRECOMPILED%" %* -) diff --git a/crates/sbm_ffi/cargokit/run_build_tool.sh b/crates/sbm_ffi/cargokit/run_build_tool.sh deleted file mode 100755 index 24b0ed89d..000000000 --- a/crates/sbm_ffi/cargokit/run_build_tool.sh +++ /dev/null @@ -1,99 +0,0 @@ -#!/usr/bin/env bash - -set -e - -BASEDIR=$(dirname "$0") - -mkdir -p "$CARGOKIT_TOOL_TEMP_DIR" - -cd "$CARGOKIT_TOOL_TEMP_DIR" - -# Write a very simple bin package in temp folder that depends on build_tool package -# from Cargokit. This is done to ensure that we don't pollute Cargokit folder -# with .dart_tool contents. - -BUILD_TOOL_PKG_DIR="$BASEDIR/build_tool" - -if [[ -z $FLUTTER_ROOT ]]; then # not defined - DART=dart -else - DART="$FLUTTER_ROOT/bin/cache/dart-sdk/bin/dart" -fi - -cat << EOF > "pubspec.yaml" -name: build_tool_runner -version: 1.0.0 -publish_to: none - -environment: - sdk: '>=3.0.0 <4.0.0' - -dependencies: - build_tool: - path: "$BUILD_TOOL_PKG_DIR" -EOF - -mkdir -p "bin" - -cat << EOF > "bin/build_tool_runner.dart" -import 'package:build_tool/build_tool.dart' as build_tool; -void main(List args) { - build_tool.runMain(args); -} -EOF - -# Create alias for `shasum` if it does not exist and `sha1sum` exists -if ! [ -x "$(command -v shasum)" ] && [ -x "$(command -v sha1sum)" ]; then - shopt -s expand_aliases - alias shasum="sha1sum" -fi - -# Dart run will not cache any package that has a path dependency, which -# is the case for our build_tool_runner. So instead we precompile the package -# ourselves. -# To invalidate the cached kernel we use the hash of ls -LR of the build_tool -# package directory. This should be good enough, as the build_tool package -# itself is not meant to have any path dependencies. - -if [[ "$OSTYPE" == "darwin"* ]]; then - PACKAGE_HASH=$(ls -lTR "$BUILD_TOOL_PKG_DIR" | shasum) -else - PACKAGE_HASH=$(ls -lR --full-time "$BUILD_TOOL_PKG_DIR" | shasum) -fi - -PACKAGE_HASH_FILE=".package_hash" - -if [ -f "$PACKAGE_HASH_FILE" ]; then - EXISTING_HASH=$(cat "$PACKAGE_HASH_FILE") - if [ "$PACKAGE_HASH" != "$EXISTING_HASH" ]; then - rm "$PACKAGE_HASH_FILE" - fi -fi - -# Run pub get if needed. -if [ ! -f "$PACKAGE_HASH_FILE" ]; then - "$DART" pub get --no-precompile - "$DART" compile kernel bin/build_tool_runner.dart - echo "$PACKAGE_HASH" > "$PACKAGE_HASH_FILE" -fi - -# Rebuild the tool if it was deleted by Android Studio -if [ ! -f "bin/build_tool_runner.dill" ]; then - "$DART" compile kernel bin/build_tool_runner.dart -fi - -set +e - -"$DART" bin/build_tool_runner.dill "$@" - -exit_code=$? - -# 253 means invalid snapshot version. -if [ $exit_code == 253 ]; then - "$DART" pub get --no-precompile - "$DART" compile kernel bin/build_tool_runner.dart - "$DART" bin/build_tool_runner.dill "$@" - exit_code=$? -fi - -exit $exit_code diff --git a/crates/sbm_ffi/ios/Classes/dummy_file.c b/crates/sbm_ffi/ios/Classes/dummy_file.c deleted file mode 100644 index e06dab996..000000000 --- a/crates/sbm_ffi/ios/Classes/dummy_file.c +++ /dev/null @@ -1 +0,0 @@ -// This is an empty file to force CocoaPods to create a framework. diff --git a/crates/sbm_ffi/ios/sbm_ffi.podspec b/crates/sbm_ffi/ios/sbm_ffi.podspec deleted file mode 100644 index 4063ce577..000000000 --- a/crates/sbm_ffi/ios/sbm_ffi.podspec +++ /dev/null @@ -1,45 +0,0 @@ -# -# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. -# Run `pod lib lint sbm_ffi.podspec` to validate before publishing. -# -Pod::Spec.new do |s| - s.name = 'sbm_ffi' - s.version = '0.0.1' - s.summary = 'A new Flutter FFI plugin project.' - s.description = <<-DESC -A new Flutter FFI plugin project. - DESC - s.homepage = 'http://example.com' - s.license = { :file => '../LICENSE' } - s.author = { 'Your Company' => 'email@example.com' } - - # This will ensure the source files in Classes/ are included in the native - # builds of apps using this FFI plugin. Podspec does not support relative - # paths, so Classes contains a forwarder C file that relatively imports - # `../src/*` so that the C sources can be shared among all target platforms. - s.source = { :path => '.' } - s.source_files = 'Classes/**/*' - s.dependency 'Flutter' - s.platform = :ios, '11.0' - - # Flutter.framework does not contain a i386 slice. - s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } - s.swift_version = '5.0' - - s.script_phase = { - :name => 'Build Rust library', - # First argument is relative path to the `rust` folder, second is name of rust library - :script => 'sh "$PODS_TARGET_SRCROOT/../cargokit/build_pod.sh" .. sbm_ffi', - :execution_position => :before_compile, - :input_files => ['${BUILT_PRODUCTS_DIR}/cargokit_phony'], - # Let XCode know that the static library referenced in -force_load below is - # created by this build step. - :output_files => ["${BUILT_PRODUCTS_DIR}/libsbm_ffi.a"], - } - s.pod_target_xcconfig = { - 'DEFINES_MODULE' => 'YES', - # Flutter.framework does not contain a i386 slice. - 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386', - 'OTHER_LDFLAGS' => '-force_load ${BUILT_PRODUCTS_DIR}/libsbm_ffi.a', - } -end \ No newline at end of file diff --git a/crates/sbm_ffi/linux/CMakeLists.txt b/crates/sbm_ffi/linux/CMakeLists.txt deleted file mode 100644 index 6efc4233b..000000000 --- a/crates/sbm_ffi/linux/CMakeLists.txt +++ /dev/null @@ -1,19 +0,0 @@ -# The Flutter tooling requires that developers have CMake 3.10 or later -# installed. You should not increase this version, as doing so will cause -# the plugin to fail to compile for some customers of the plugin. -cmake_minimum_required(VERSION 3.10) - -# Project-level configuration. -set(PROJECT_NAME "sbm_ffi") -project(${PROJECT_NAME} LANGUAGES CXX) - -include("../cargokit/cmake/cargokit.cmake") -apply_cargokit(${PROJECT_NAME} .. sbm_ffi "") - -# List of absolute paths to libraries that should be bundled with the plugin. -# This list could contain prebuilt libraries, or libraries created by an -# external build triggered from this build file. -set(sbm_ffi_bundled_libraries - "${${PROJECT_NAME}_cargokit_lib}" - PARENT_SCOPE -) diff --git a/crates/sbm_ffi/macos/Classes/dummy_file.c b/crates/sbm_ffi/macos/Classes/dummy_file.c deleted file mode 100644 index e06dab996..000000000 --- a/crates/sbm_ffi/macos/Classes/dummy_file.c +++ /dev/null @@ -1 +0,0 @@ -// This is an empty file to force CocoaPods to create a framework. diff --git a/crates/sbm_ffi/macos/sbm_ffi.podspec b/crates/sbm_ffi/macos/sbm_ffi.podspec deleted file mode 100644 index 32455ad58..000000000 --- a/crates/sbm_ffi/macos/sbm_ffi.podspec +++ /dev/null @@ -1,48 +0,0 @@ -# -# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. -# Run `pod lib lint sbm_ffi.podspec` to validate before publishing. -# -Pod::Spec.new do |s| - s.name = 'sbm_ffi' - s.version = '0.0.1' - s.summary = 'A new Flutter FFI plugin project.' - s.description = <<-DESC -A new Flutter FFI plugin project. - DESC - s.homepage = 'http://example.com' - s.license = { :file => '../LICENSE' } - s.author = { 'Your Company' => 'email@example.com' } - - # This will ensure the source files in Classes/ are included in the native - # builds of apps using this FFI plugin. Podspec does not support relative - # paths, so Classes contains a forwarder C file that relatively imports - # `../src/*` so that the C sources can be shared among all target platforms. - s.source = { :path => '.' } - s.source_files = 'Classes/**/*' - s.dependency 'FlutterMacOS' - - # The app's own floor (macos/Podfile, MACOSX_DEPLOYMENT_TARGET). CocoaPods - # raises this for us at build time, so a lower number changes nothing that - # ships — it only tells anyone reading the podspec that the Rust half is - # built against a macOS nobody supports. - s.platform = :osx, '12.0' - s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } - s.swift_version = '5.0' - - s.script_phase = { - :name => 'Build Rust library', - # First argument is relative path to the `rust` folder, second is name of rust library - :script => 'sh "$PODS_TARGET_SRCROOT/../cargokit/build_pod.sh" .. sbm_ffi', - :execution_position => :before_compile, - :input_files => ['${BUILT_PRODUCTS_DIR}/cargokit_phony'], - # Let XCode know that the static library referenced in -force_load below is - # created by this build step. - :output_files => ["${BUILT_PRODUCTS_DIR}/libsbm_ffi.a"], - } - s.pod_target_xcconfig = { - 'DEFINES_MODULE' => 'YES', - # Flutter.framework does not contain a i386 slice. - 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386', - 'OTHER_LDFLAGS' => '-force_load ${BUILT_PRODUCTS_DIR}/libsbm_ffi.a', - } -end \ No newline at end of file diff --git a/crates/sbm_ffi/pubspec.yaml b/crates/sbm_ffi/pubspec.yaml deleted file mode 100644 index 6b69bae56..000000000 --- a/crates/sbm_ffi/pubspec.yaml +++ /dev/null @@ -1,34 +0,0 @@ -name: sbm_ffi -description: "Utility to build Rust code" -version: 0.0.1 -publish_to: none - -environment: - sdk: '>=3.3.0 <4.0.0' - flutter: '>=3.3.0' - -dependencies: - flutter: - sdk: flutter - plugin_platform_interface: ^2.0.2 - -dev_dependencies: - ffi: ^2.0.2 - ffigen: ^11.0.0 - flutter_test: - sdk: flutter - flutter_lints: ^2.0.0 - -flutter: - plugin: - platforms: - android: - ffiPlugin: true - ios: - ffiPlugin: true - linux: - ffiPlugin: true - macos: - ffiPlugin: true - windows: - ffiPlugin: true diff --git a/crates/sbm_ffi/rust-toolchain.toml b/crates/sbm_ffi/rust-toolchain.toml new file mode 100644 index 000000000..f4da8a8c6 --- /dev/null +++ b/crates/sbm_ffi/rust-toolchain.toml @@ -0,0 +1,26 @@ +# `native_toolchain_rust` wants an exact channel rather than `stable`, so that +# the library shipped in a release is built by a toolchain the version control +# names. +# +# Scoped to this crate rather than the workspace root: `sbm_parser`, +# `sbm_native` and `monitor` are built by cargo directly and have no reason to +# be pinned to whatever the app's build hook happens to need. +# +# `targets` is every platform the app ships to. A target missing here is not a +# build error, it is a build that silently falls back to the host. +[toolchain] +channel = "1.97.1" +targets = [ + "aarch64-apple-darwin", + "x86_64-apple-darwin", + "aarch64-apple-ios", + "aarch64-apple-ios-sim", + "x86_64-apple-ios", + "aarch64-linux-android", + "armv7-linux-androideabi", + "x86_64-linux-android", + "aarch64-unknown-linux-gnu", + "x86_64-unknown-linux-gnu", + "aarch64-pc-windows-msvc", + "x86_64-pc-windows-msvc", +] diff --git a/crates/sbm_ffi/src/frb_generated.rs b/crates/sbm_ffi/src/frb_generated.rs index e2bab9555..76ffd56ca 100644 --- a/crates/sbm_ffi/src/frb_generated.rs +++ b/crates/sbm_ffi/src/frb_generated.rs @@ -1,5 +1,5 @@ // This file is automatically generated, so please do not edit it. -// @generated by `flutter_rust_bridge`@ 2.12.0. +// @generated by `flutter_rust_bridge`@ 2.13.0-beta.6. #![allow( non_camel_case_types, @@ -21,24 +21,26 @@ clippy::explicit_auto_deref, clippy::borrow_deref_ref, clippy::uninlined_format_args, - clippy::needless_borrow + clippy::needless_borrow, + mismatched_lifetime_syntaxes )] // Section: imports -use flutter_rust_bridge::for_generated::byteorder::{NativeEndian, ReadBytesExt, WriteBytesExt}; -use flutter_rust_bridge::for_generated::{transform_result_dco, Lifetimeable, Lockable}; use flutter_rust_bridge::{Handler, IntoIntoDart}; +use flutter_rust_bridge::for_generated::{Lockable, transform_result_dco, Lifetimeable}; +use flutter_rust_bridge::for_generated::byteorder::{NativeEndian, WriteBytesExt, ReadBytesExt}; // Section: boilerplate flutter_rust_bridge::frb_generated_boilerplate!( - default_stream_sink_codec = SseCodec, - default_rust_opaque = RustOpaqueMoi, - default_rust_auto_opaque = RustAutoOpaqueMoi, -); -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0"; -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -397184763; + default_stream_sink_codec = SseCodec, + default_rust_opaque = RustOpaqueMoi, + default_rust_auto_opaque = RustAutoOpaqueMoi, + ); + pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.13.0-beta.6"; + pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -397184763; + // Section: executor @@ -46,1003 +48,543 @@ flutter_rust_bridge::frb_generated_default_handler!(); // Section: wire_funcs -fn wire__crate__api__script__build_script_impl( - ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, - rust_vec_len_: i32, - data_len_: i32, -) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { - FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( - flutter_rust_bridge::for_generated::TaskInfo { - debug_name: "build_script", - port: None, - mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, - }, - move || { - let message = unsafe { - flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( - ptr_, - rust_vec_len_, - data_len_, - ) - }; - let mut deserializer = - flutter_rust_bridge::for_generated::SseDeserializer::new(message); +fn wire__crate__api__script__build_script_impl(ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,rust_vec_len_: i32,data_len_: i32) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::(flutter_rust_bridge::for_generated::TaskInfo{ debug_name: "build_script", port: None, mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync }, move || { + let message = unsafe { flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(ptr_, rust_vec_len_, data_len_) }; + let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); let api_system = ::sse_decode(&mut deserializer); - let api_disabled = >::sse_decode(&mut deserializer); - let api_build_number = ::sse_decode(&mut deserializer); - deserializer.end(); - transform_result_sse::<_, String>((move || { - let output_ok = - crate::api::script::build_script(api_system, api_disabled, api_build_number)?; - Ok(output_ok) - })()) - }, - ) -} -fn wire__crate__api__parser__command_specs_impl( - ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, - rust_vec_len_: i32, - data_len_: i32, -) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { - FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( - flutter_rust_bridge::for_generated::TaskInfo { - debug_name: "command_specs", - port: None, - mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, - }, - move || { - let message = unsafe { - flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( - ptr_, - rust_vec_len_, - data_len_, - ) - }; - let mut deserializer = - flutter_rust_bridge::for_generated::SseDeserializer::new(message); - let api_system = ::sse_decode(&mut deserializer); - deserializer.end(); - transform_result_sse::<_, String>((move || { - let output_ok = crate::api::parser::command_specs(api_system)?; - Ok(output_ok) - })()) - }, - ) -} -fn wire__crate__api__script__exec_command_impl( - ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, - rust_vec_len_: i32, - data_len_: i32, -) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { - FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( - flutter_rust_bridge::for_generated::TaskInfo { - debug_name: "exec_command", - port: None, - mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, - }, - move || { - let message = unsafe { - flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( - ptr_, - rust_vec_len_, - data_len_, - ) - }; - let mut deserializer = - flutter_rust_bridge::for_generated::SseDeserializer::new(message); +let api_disabled = >::sse_decode(&mut deserializer); +let api_build_number = ::sse_decode(&mut deserializer);deserializer.end(); + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::script::build_script(api_system, api_disabled, api_build_number)?; std::result::Result::Ok(output_ok) + })()) }) + }fn wire__crate__api__parser__command_specs_impl(ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,rust_vec_len_: i32,data_len_: i32) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::(flutter_rust_bridge::for_generated::TaskInfo{ debug_name: "command_specs", port: None, mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync }, move || { + let message = unsafe { flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(ptr_, rust_vec_len_, data_len_) }; + let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_system = ::sse_decode(&mut deserializer);deserializer.end(); + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::parser::command_specs(api_system)?; std::result::Result::Ok(output_ok) + })()) }) + }fn wire__crate__api__script__exec_command_impl(ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,rust_vec_len_: i32,data_len_: i32) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::(flutter_rust_bridge::for_generated::TaskInfo{ debug_name: "exec_command", port: None, mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync }, move || { + let message = unsafe { flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(ptr_, rust_vec_len_, data_len_) }; + let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); let api_system = ::sse_decode(&mut deserializer); - let api_script_path = ::sse_decode(&mut deserializer); - let api_func = ::sse_decode(&mut deserializer); - deserializer.end(); - transform_result_sse::<_, String>((move || { - let output_ok = - crate::api::script::exec_command(api_system, api_script_path, api_func)?; - Ok(output_ok) - })()) - }, - ) -} -fn wire__crate__api__parser__init_app_impl( - port_: flutter_rust_bridge::for_generated::MessagePort, - ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, - rust_vec_len_: i32, - data_len_: i32, -) { - FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( - flutter_rust_bridge::for_generated::TaskInfo { - debug_name: "init_app", - port: Some(port_), - mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, - }, - move || { - let message = unsafe { - flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( - ptr_, - rust_vec_len_, - data_len_, - ) - }; - let mut deserializer = - flutter_rust_bridge::for_generated::SseDeserializer::new(message); - deserializer.end(); - move |context| { - transform_result_sse::<_, ()>((move || { - let output_ok = Result::<_, ()>::Ok({ - crate::api::parser::init_app(); - })?; - Ok(output_ok) - })()) - } - }, - ) -} -fn wire__crate__api__script__install_command_impl( - ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, - rust_vec_len_: i32, - data_len_: i32, -) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { - FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( - flutter_rust_bridge::for_generated::TaskInfo { - debug_name: "install_command", - port: None, - mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, - }, - move || { - let message = unsafe { - flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( - ptr_, - rust_vec_len_, - data_len_, - ) - }; - let mut deserializer = - flutter_rust_bridge::for_generated::SseDeserializer::new(message); +let api_script_path = ::sse_decode(&mut deserializer); +let api_func = ::sse_decode(&mut deserializer);deserializer.end(); + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::script::exec_command(api_system, api_script_path, api_func)?; std::result::Result::Ok(output_ok) + })()) }) + }fn wire__crate__api__parser__init_app_impl(port_: flutter_rust_bridge::for_generated::MessagePort,ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,rust_vec_len_: i32,data_len_: i32) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::(flutter_rust_bridge::for_generated::TaskInfo{ debug_name: "init_app", port: Some(port_), mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal }, move || { + let message = unsafe { flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(ptr_, rust_vec_len_, data_len_) }; + let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); move |context| { + transform_result_sse::<_, ()>((move || { + let output_ok = Ok::<_, ()>({ crate::api::parser::init_app(); })?; std::result::Result::Ok(output_ok) + })()) + } }) + }fn wire__crate__api__script__install_command_impl(ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,rust_vec_len_: i32,data_len_: i32) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::(flutter_rust_bridge::for_generated::TaskInfo{ debug_name: "install_command", port: None, mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync }, move || { + let message = unsafe { flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(ptr_, rust_vec_len_, data_len_) }; + let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); let api_system = ::sse_decode(&mut deserializer); - let api_script_dir = ::sse_decode(&mut deserializer); - let api_script_path = ::sse_decode(&mut deserializer); - deserializer.end(); - transform_result_sse::<_, String>((move || { - let output_ok = crate::api::script::install_command( - api_system, - api_script_dir, - api_script_path, - )?; - Ok(output_ok) - })()) - }, - ) -} -fn wire__crate__api__script__install_custom_cmds_command_impl( - ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, - rust_vec_len_: i32, - data_len_: i32, -) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { - FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( - flutter_rust_bridge::for_generated::TaskInfo { - debug_name: "install_custom_cmds_command", - port: None, - mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, - }, - move || { - let message = unsafe { - flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( - ptr_, - rust_vec_len_, - data_len_, - ) - }; - let mut deserializer = - flutter_rust_bridge::for_generated::SseDeserializer::new(message); +let api_script_dir = ::sse_decode(&mut deserializer); +let api_script_path = ::sse_decode(&mut deserializer);deserializer.end(); + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::script::install_command(api_system, api_script_dir, api_script_path)?; std::result::Result::Ok(output_ok) + })()) }) + }fn wire__crate__api__script__install_custom_cmds_command_impl(ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,rust_vec_len_: i32,data_len_: i32) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::(flutter_rust_bridge::for_generated::TaskInfo{ debug_name: "install_custom_cmds_command", port: None, mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync }, move || { + let message = unsafe { flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(ptr_, rust_vec_len_, data_len_) }; + let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); let api_system = ::sse_decode(&mut deserializer); - let api_cmds = >::sse_decode(&mut deserializer); - deserializer.end(); - transform_result_sse::<_, String>((move || { - let output_ok = - crate::api::script::install_custom_cmds_command(api_system, api_cmds)?; - Ok(output_ok) - })()) - }, - ) -} -fn wire__crate__api__script__install_payload_impl( - ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, - rust_vec_len_: i32, - data_len_: i32, -) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { - FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( - flutter_rust_bridge::for_generated::TaskInfo { - debug_name: "install_payload", - port: None, - mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, - }, - move || { - let message = unsafe { - flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( - ptr_, - rust_vec_len_, - data_len_, - ) - }; - let mut deserializer = - flutter_rust_bridge::for_generated::SseDeserializer::new(message); +let api_cmds = >::sse_decode(&mut deserializer);deserializer.end(); + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::script::install_custom_cmds_command(api_system, api_cmds)?; std::result::Result::Ok(output_ok) + })()) }) + }fn wire__crate__api__script__install_payload_impl(ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,rust_vec_len_: i32,data_len_: i32) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::(flutter_rust_bridge::for_generated::TaskInfo{ debug_name: "install_payload", port: None, mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync }, move || { + let message = unsafe { flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(ptr_, rust_vec_len_, data_len_) }; + let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); let api_system = ::sse_decode(&mut deserializer); - let api_content = ::sse_decode(&mut deserializer); - deserializer.end(); - transform_result_sse::<_, String>((move || { - let output_ok = crate::api::script::install_payload(api_system, api_content)?; - Ok(output_ok) - })()) - }, - ) -} -fn wire__crate__api__script__parse_custom_cmds_listing_impl( - ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, - rust_vec_len_: i32, - data_len_: i32, -) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { - FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( - flutter_rust_bridge::for_generated::TaskInfo { - debug_name: "parse_custom_cmds_listing", - port: None, - mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, - }, - move || { - let message = unsafe { - flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( - ptr_, - rust_vec_len_, - data_len_, - ) - }; - let mut deserializer = - flutter_rust_bridge::for_generated::SseDeserializer::new(message); - let api_raw = ::sse_decode(&mut deserializer); - deserializer.end(); - transform_result_sse::<_, ()>((move || { - let output_ok = - Result::<_, ()>::Ok(crate::api::script::parse_custom_cmds_listing(api_raw))?; - Ok(output_ok) - })()) - }, - ) -} -fn wire__crate__api__script__parse_script_segments_impl( - port_: flutter_rust_bridge::for_generated::MessagePort, - ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, - rust_vec_len_: i32, - data_len_: i32, -) { - FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( - flutter_rust_bridge::for_generated::TaskInfo { - debug_name: "parse_script_segments", - port: Some(port_), - mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, - }, - move || { - let message = unsafe { - flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( - ptr_, - rust_vec_len_, - data_len_, - ) - }; - let mut deserializer = - flutter_rust_bridge::for_generated::SseDeserializer::new(message); - let api_raw = ::sse_decode(&mut deserializer); - deserializer.end(); - move |context| { +let api_content = ::sse_decode(&mut deserializer);deserializer.end(); + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::script::install_payload(api_system, api_content)?; std::result::Result::Ok(output_ok) + })()) }) + }fn wire__crate__api__script__parse_custom_cmds_listing_impl(ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,rust_vec_len_: i32,data_len_: i32) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::(flutter_rust_bridge::for_generated::TaskInfo{ debug_name: "parse_custom_cmds_listing", port: None, mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync }, move || { + let message = unsafe { flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(ptr_, rust_vec_len_, data_len_) }; + let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_raw = ::sse_decode(&mut deserializer);deserializer.end(); transform_result_sse::<_, ()>((move || { - let output_ok = - Result::<_, ()>::Ok(crate::api::script::parse_script_segments(api_raw))?; - Ok(output_ok) - })()) - } - }, - ) -} -fn wire__crate__api__parser__parse_status_json_impl( - port_: flutter_rust_bridge::for_generated::MessagePort, - ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, - rust_vec_len_: i32, - data_len_: i32, -) { - FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( - flutter_rust_bridge::for_generated::TaskInfo { - debug_name: "parse_status_json", - port: Some(port_), - mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, - }, - move || { - let message = unsafe { - flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( - ptr_, - rust_vec_len_, - data_len_, - ) - }; - let mut deserializer = - flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let output_ok = Ok::<_, ()>(crate::api::script::parse_custom_cmds_listing(api_raw))?; std::result::Result::Ok(output_ok) + })()) }) + }fn wire__crate__api__script__parse_script_segments_impl(port_: flutter_rust_bridge::for_generated::MessagePort,ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,rust_vec_len_: i32,data_len_: i32) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::(flutter_rust_bridge::for_generated::TaskInfo{ debug_name: "parse_script_segments", port: Some(port_), mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal }, move || { + let message = unsafe { flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(ptr_, rust_vec_len_, data_len_) }; + let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_raw = ::sse_decode(&mut deserializer);deserializer.end(); move |context| { + transform_result_sse::<_, ()>((move || { + let output_ok = Ok::<_, ()>(crate::api::script::parse_script_segments(api_raw))?; std::result::Result::Ok(output_ok) + })()) + } }) + }fn wire__crate__api__parser__parse_status_json_impl(port_: flutter_rust_bridge::for_generated::MessagePort,ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,rust_vec_len_: i32,data_len_: i32) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::(flutter_rust_bridge::for_generated::TaskInfo{ debug_name: "parse_status_json", port: Some(port_), mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal }, move || { + let message = unsafe { flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(ptr_, rust_vec_len_, data_len_) }; + let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); let api_system = ::sse_decode(&mut deserializer); - let api_raw = - >::sse_decode(&mut deserializer); - let api_temp_divisor = ::sse_decode(&mut deserializer); - deserializer.end(); - move |context| { +let api_raw = >::sse_decode(&mut deserializer); +let api_temp_divisor = ::sse_decode(&mut deserializer);deserializer.end(); move |context| { + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::parser::parse_status_json(api_system, api_raw, api_temp_divisor)?; std::result::Result::Ok(output_ok) + })()) + } }) + }fn wire__crate__api__parser__parse_windows_net_speed_json_impl(ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,rust_vec_len_: i32,data_len_: i32) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::(flutter_rust_bridge::for_generated::TaskInfo{ debug_name: "parse_windows_net_speed_json", port: None, mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync }, move || { + let message = unsafe { flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(ptr_, rust_vec_len_, data_len_) }; + let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_raw = ::sse_decode(&mut deserializer);deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Ok::<_, ()>(crate::api::parser::parse_windows_net_speed_json(api_raw))?; std::result::Result::Ok(output_ok) + })()) }) + }fn wire__crate__api__script__read_custom_cmds_command_impl(ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,rust_vec_len_: i32,data_len_: i32) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::(flutter_rust_bridge::for_generated::TaskInfo{ debug_name: "read_custom_cmds_command", port: None, mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync }, move || { + let message = unsafe { flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(ptr_, rust_vec_len_, data_len_) }; + let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_system = ::sse_decode(&mut deserializer);deserializer.end(); transform_result_sse::<_, String>((move || { - let output_ok = crate::api::parser::parse_status_json( - api_system, - api_raw, - api_temp_divisor, - )?; - Ok(output_ok) - })()) - } - }, - ) -} -fn wire__crate__api__parser__parse_windows_net_speed_json_impl( - ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, - rust_vec_len_: i32, - data_len_: i32, -) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { - FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( - flutter_rust_bridge::for_generated::TaskInfo { - debug_name: "parse_windows_net_speed_json", - port: None, - mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, - }, - move || { - let message = unsafe { - flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( - ptr_, - rust_vec_len_, - data_len_, - ) - }; - let mut deserializer = - flutter_rust_bridge::for_generated::SseDeserializer::new(message); - let api_raw = ::sse_decode(&mut deserializer); + let output_ok = crate::api::script::read_custom_cmds_command(api_system)?; std::result::Result::Ok(output_ok) + })()) }) + }fn wire__crate__api__parser__separator_impl(ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,rust_vec_len_: i32,data_len_: i32) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::(flutter_rust_bridge::for_generated::TaskInfo{ debug_name: "separator", port: None, mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync }, move || { + let message = unsafe { flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(ptr_, rust_vec_len_, data_len_) }; + let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); deserializer.end(); - transform_result_sse::<_, ()>((move || { - let output_ok = - Result::<_, ()>::Ok(crate::api::parser::parse_windows_net_speed_json(api_raw))?; - Ok(output_ok) - })()) - }, - ) -} -fn wire__crate__api__script__read_custom_cmds_command_impl( - ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, - rust_vec_len_: i32, - data_len_: i32, -) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { - FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( - flutter_rust_bridge::for_generated::TaskInfo { - debug_name: "read_custom_cmds_command", - port: None, - mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, - }, - move || { - let message = unsafe { - flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( - ptr_, - rust_vec_len_, - data_len_, - ) - }; - let mut deserializer = - flutter_rust_bridge::for_generated::SseDeserializer::new(message); - let api_system = ::sse_decode(&mut deserializer); - deserializer.end(); - transform_result_sse::<_, String>((move || { - let output_ok = crate::api::script::read_custom_cmds_command(api_system)?; - Ok(output_ok) - })()) - }, - ) -} -fn wire__crate__api__parser__separator_impl( - ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, - rust_vec_len_: i32, - data_len_: i32, -) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { - FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( - flutter_rust_bridge::for_generated::TaskInfo { - debug_name: "separator", - port: None, - mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, - }, - move || { - let message = unsafe { - flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( - ptr_, - rust_vec_len_, - data_len_, - ) - }; - let mut deserializer = - flutter_rust_bridge::for_generated::SseDeserializer::new(message); - deserializer.end(); - transform_result_sse::<_, ()>((move || { - let output_ok = Result::<_, ()>::Ok(crate::api::parser::separator())?; - Ok(output_ok) - })()) - }, - ) -} -fn wire__crate__api__script__shell_func_flag_impl( - ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, - rust_vec_len_: i32, - data_len_: i32, -) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { - FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( - flutter_rust_bridge::for_generated::TaskInfo { - debug_name: "shell_func_flag", - port: None, - mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, - }, - move || { - let message = unsafe { - flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( - ptr_, - rust_vec_len_, - data_len_, - ) - }; - let mut deserializer = - flutter_rust_bridge::for_generated::SseDeserializer::new(message); - let api_func = ::sse_decode(&mut deserializer); - deserializer.end(); - transform_result_sse::<_, ()>((move || { - let output_ok = Result::<_, ()>::Ok(crate::api::script::shell_func_flag(api_func))?; - Ok(output_ok) - })()) - }, - ) -} + transform_result_sse::<_, ()>((move || { + let output_ok = Ok::<_, ()>(crate::api::parser::separator())?; std::result::Result::Ok(output_ok) + })()) }) + }fn wire__crate__api__script__shell_func_flag_impl(ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,rust_vec_len_: i32,data_len_: i32) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::(flutter_rust_bridge::for_generated::TaskInfo{ debug_name: "shell_func_flag", port: None, mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync }, move || { + let message = unsafe { flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(ptr_, rust_vec_len_, data_len_) }; + let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_func = ::sse_decode(&mut deserializer);deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Ok::<_, ()>(crate::api::script::shell_func_flag(api_func))?; std::result::Result::Ok(output_ok) + })()) }) + } // Section: dart2rust -impl SseDecode for std::collections::HashMap { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { - let mut inner = >::sse_decode(deserializer); - return inner.into_iter().collect(); - } -} - -impl SseDecode for String { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { - let mut inner = >::sse_decode(deserializer); - return String::from_utf8(inner).unwrap(); - } -} - -impl SseDecode for crate::api::parser::CommandSpec { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { - let mut var_key = ::sse_decode(deserializer); - let mut var_cmd = ::sse_decode(deserializer); - return crate::api::parser::CommandSpec { - key: var_key, - cmd: var_cmd, - }; - } -} -impl SseDecode for crate::api::script::CustomCmd { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { - let mut var_name = ::sse_decode(deserializer); - let mut var_cmd = ::sse_decode(deserializer); - return crate::api::script::CustomCmd { - name: var_name, - cmd: var_cmd, - }; - } -} - -impl SseDecode for f64 { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { - deserializer.cursor.read_f64::().unwrap() - } -} - -impl SseDecode for i32 { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { - deserializer.cursor.read_i32::().unwrap() - } -} - -impl SseDecode for Vec { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { - let mut len_ = ::sse_decode(deserializer); + impl SseDecode for std::collections::HashMap { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {let mut inner = >::sse_decode(deserializer); + return inner.into_iter().collect();} + } + + impl SseDecode for String { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {let mut inner = >::sse_decode(deserializer); + return String::from_utf8(inner).unwrap();} + } + + impl SseDecode for crate::api::parser::CommandSpec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {let mut var_key = ::sse_decode(deserializer); +let mut var_cmd = ::sse_decode(deserializer); +return crate::api::parser::CommandSpec{key: var_key, cmd: var_cmd};} + } + + impl SseDecode for crate::api::script::CustomCmd { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {let mut var_name = ::sse_decode(deserializer); +let mut var_cmd = ::sse_decode(deserializer); +return crate::api::script::CustomCmd{name: var_name, cmd: var_cmd};} + } + + impl SseDecode for f64 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {deserializer.cursor.read_f64::().unwrap()} + } + + impl SseDecode for i32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {deserializer.cursor.read_i32::().unwrap()} + } + + impl SseDecode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {let mut len_ = ::sse_decode(deserializer); let mut ans_ = Vec::with_capacity(len_ as usize); - for idx_ in 0..len_ { - ans_.push(::sse_decode(deserializer)); - } - return ans_; - } -} - -impl SseDecode for Vec { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { - let mut len_ = ::sse_decode(deserializer); + for idx_ in 0..len_ { ans_.push(::sse_decode(deserializer)); } + return ans_;} + } + + impl SseDecode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {let mut len_ = ::sse_decode(deserializer); let mut ans_ = Vec::with_capacity(len_ as usize); - for idx_ in 0..len_ { - ans_.push(::sse_decode(deserializer)); - } - return ans_; - } -} - -impl SseDecode for Vec { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { - let mut len_ = ::sse_decode(deserializer); + for idx_ in 0..len_ { ans_.push(::sse_decode(deserializer)); } + return ans_;} + } + + impl SseDecode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {let mut len_ = ::sse_decode(deserializer); let mut ans_ = Vec::with_capacity(len_ as usize); - for idx_ in 0..len_ { - ans_.push(::sse_decode(deserializer)); - } - return ans_; - } -} - -impl SseDecode for Vec { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { - let mut len_ = ::sse_decode(deserializer); + for idx_ in 0..len_ { ans_.push(::sse_decode(deserializer)); } + return ans_;} + } + + impl SseDecode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {let mut len_ = ::sse_decode(deserializer); let mut ans_ = Vec::with_capacity(len_ as usize); - for idx_ in 0..len_ { - ans_.push(::sse_decode(deserializer)); - } - return ans_; - } -} - -impl SseDecode for Vec<(String, String)> { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { - let mut len_ = ::sse_decode(deserializer); + for idx_ in 0..len_ { ans_.push(::sse_decode(deserializer)); } + return ans_;} + } + + impl SseDecode for Vec<(String,String,)> { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {let mut len_ = ::sse_decode(deserializer); let mut ans_ = Vec::with_capacity(len_ as usize); - for idx_ in 0..len_ { - ans_.push(<(String, String)>::sse_decode(deserializer)); - } - return ans_; - } -} - -impl SseDecode for Vec { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { - let mut len_ = ::sse_decode(deserializer); + for idx_ in 0..len_ { ans_.push(<(String,String,)>::sse_decode(deserializer)); } + return ans_;} + } + + impl SseDecode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {let mut len_ = ::sse_decode(deserializer); let mut ans_ = Vec::with_capacity(len_ as usize); - for idx_ in 0..len_ { - ans_.push(::sse_decode( - deserializer, - )); - } - return ans_; - } -} - -impl SseDecode for Option> { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { - if (::sse_decode(deserializer)) { - return Some(>::sse_decode( - deserializer, - )); - } else { - return None; - } - } -} - -impl SseDecode for (String, String) { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { - let mut var_field0 = ::sse_decode(deserializer); - let mut var_field1 = ::sse_decode(deserializer); - return (var_field0, var_field1); - } -} - -impl SseDecode for crate::api::script::ScriptSegment { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { - let mut var_key = ::sse_decode(deserializer); - let mut var_value = ::sse_decode(deserializer); - return crate::api::script::ScriptSegment { - key: var_key, - value: var_value, - }; - } -} - -impl SseDecode for crate::api::script::ShellFuncKind { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { - let mut inner = ::sse_decode(deserializer); + for idx_ in 0..len_ { ans_.push(::sse_decode(deserializer)); } + return ans_;} + } + + impl SseDecode for Option> { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {if (::sse_decode(deserializer)) { + return Some(>::sse_decode(deserializer)); + } else { + return None; + }} + } + + impl SseDecode for (String,String,) { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {let mut var_field0 = ::sse_decode(deserializer); +let mut var_field1 = ::sse_decode(deserializer); +return (var_field0, var_field1);} + } + + impl SseDecode for crate::api::script::ScriptSegment { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {let mut var_key = ::sse_decode(deserializer); +let mut var_value = ::sse_decode(deserializer); +return crate::api::script::ScriptSegment{key: var_key, value: var_value};} + } + + impl SseDecode for crate::api::script::ShellFuncKind { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {let mut inner = ::sse_decode(deserializer); return match inner { 0 => crate::api::script::ShellFuncKind::Status, - 1 => crate::api::script::ShellFuncKind::StatusExt, - 2 => crate::api::script::ShellFuncKind::Process, - 3 => crate::api::script::ShellFuncKind::Shutdown, - 4 => crate::api::script::ShellFuncKind::Reboot, - 5 => crate::api::script::ShellFuncKind::Suspend, +1 => crate::api::script::ShellFuncKind::StatusExt, +2 => crate::api::script::ShellFuncKind::Process, +3 => crate::api::script::ShellFuncKind::Shutdown, +4 => crate::api::script::ShellFuncKind::Reboot, +5 => crate::api::script::ShellFuncKind::Suspend, _ => unreachable!("Invalid variant for ShellFuncKind: {}", inner), - }; - } -} - -impl SseDecode for u8 { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { - deserializer.cursor.read_u8().unwrap() - } -} - -impl SseDecode for () { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {} -} - -impl SseDecode for bool { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { - deserializer.cursor.read_u8().unwrap() != 0 - } -} - -fn pde_ffi_dispatcher_primary_impl( - func_id: i32, - port: flutter_rust_bridge::for_generated::MessagePort, - ptr: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, - rust_vec_len: i32, - data_len: i32, -) { - // Codec=Pde (Serialization + dispatch), see doc to use other codecs - match func_id { - 4 => wire__crate__api__parser__init_app_impl(port, ptr, rust_vec_len, data_len), - 9 => { - wire__crate__api__script__parse_script_segments_impl(port, ptr, rust_vec_len, data_len) - } - 10 => wire__crate__api__parser__parse_status_json_impl(port, ptr, rust_vec_len, data_len), - _ => unreachable!(), - } -} - -fn pde_ffi_dispatcher_sync_impl( - func_id: i32, - ptr: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, - rust_vec_len: i32, - data_len: i32, -) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { - // Codec=Pde (Serialization + dispatch), see doc to use other codecs - match func_id { - 1 => wire__crate__api__script__build_script_impl(ptr, rust_vec_len, data_len), - 2 => wire__crate__api__parser__command_specs_impl(ptr, rust_vec_len, data_len), - 3 => wire__crate__api__script__exec_command_impl(ptr, rust_vec_len, data_len), - 5 => wire__crate__api__script__install_command_impl(ptr, rust_vec_len, data_len), - 6 => { - wire__crate__api__script__install_custom_cmds_command_impl(ptr, rust_vec_len, data_len) - } - 7 => wire__crate__api__script__install_payload_impl(ptr, rust_vec_len, data_len), - 8 => wire__crate__api__script__parse_custom_cmds_listing_impl(ptr, rust_vec_len, data_len), - 11 => { - wire__crate__api__parser__parse_windows_net_speed_json_impl(ptr, rust_vec_len, data_len) - } - 12 => wire__crate__api__script__read_custom_cmds_command_impl(ptr, rust_vec_len, data_len), - 13 => wire__crate__api__parser__separator_impl(ptr, rust_vec_len, data_len), - 14 => wire__crate__api__script__shell_func_flag_impl(ptr, rust_vec_len, data_len), - _ => unreachable!(), - } -} + };} + } + + impl SseDecode for u8 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {deserializer.cursor.read_u8().unwrap()} + } + + impl SseDecode for () { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {} + } + + impl SseDecode for bool { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {deserializer.cursor.read_u8().unwrap() != 0} + } + + fn pde_ffi_dispatcher_primary_impl( + func_id: i32,port: flutter_rust_bridge::for_generated::MessagePort, + ptr: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len: i32, + data_len: i32, + ) { + // Codec=Pde (Serialization + dispatch), see doc to use other codecs + match func_id { + 4 => wire__crate__api__parser__init_app_impl(port, ptr, rust_vec_len, data_len), +9 => wire__crate__api__script__parse_script_segments_impl(port, ptr, rust_vec_len, data_len), +10 => wire__crate__api__parser__parse_status_json_impl(port, ptr, rust_vec_len, data_len), + _ => unreachable!(), + } + } + + fn pde_ffi_dispatcher_sync_impl( + func_id: i32, + ptr: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len: i32, + data_len: i32, + ) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + // Codec=Pde (Serialization + dispatch), see doc to use other codecs + match func_id { + 1 => wire__crate__api__script__build_script_impl(ptr, rust_vec_len, data_len), +2 => wire__crate__api__parser__command_specs_impl(ptr, rust_vec_len, data_len), +3 => wire__crate__api__script__exec_command_impl(ptr, rust_vec_len, data_len), +5 => wire__crate__api__script__install_command_impl(ptr, rust_vec_len, data_len), +6 => wire__crate__api__script__install_custom_cmds_command_impl(ptr, rust_vec_len, data_len), +7 => wire__crate__api__script__install_payload_impl(ptr, rust_vec_len, data_len), +8 => wire__crate__api__script__parse_custom_cmds_listing_impl(ptr, rust_vec_len, data_len), +11 => wire__crate__api__parser__parse_windows_net_speed_json_impl(ptr, rust_vec_len, data_len), +12 => wire__crate__api__script__read_custom_cmds_command_impl(ptr, rust_vec_len, data_len), +13 => wire__crate__api__parser__separator_impl(ptr, rust_vec_len, data_len), +14 => wire__crate__api__script__shell_func_flag_impl(ptr, rust_vec_len, data_len), + _ => unreachable!(), + } + } + // Section: rust2dart // Codec=Dco (DartCObject based), see doc to use other codecs -impl flutter_rust_bridge::IntoDart for crate::api::parser::CommandSpec { - fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { - [ - self.key.into_into_dart().into_dart(), - self.cmd.into_into_dart().into_dart(), - ] - .into_dart() - } -} -impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive - for crate::api::parser::CommandSpec -{ -} -impl flutter_rust_bridge::IntoIntoDart - for crate::api::parser::CommandSpec -{ - fn into_into_dart(self) -> crate::api::parser::CommandSpec { - self - } -} + impl flutter_rust_bridge::IntoDart for crate::api::parser::CommandSpec { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.key.into_into_dart().into_dart(), +self.cmd.into_into_dart().into_dart() + ].into_dart() + } + } + impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive for crate::api::parser::CommandSpec {} +impl flutter_rust_bridge::IntoIntoDart for crate::api::parser::CommandSpec { + fn into_into_dart(self) -> crate::api::parser::CommandSpec { + self + } + } // Codec=Dco (DartCObject based), see doc to use other codecs -impl flutter_rust_bridge::IntoDart for crate::api::script::CustomCmd { - fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { - [ - self.name.into_into_dart().into_dart(), - self.cmd.into_into_dart().into_dart(), - ] - .into_dart() - } -} -impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive for crate::api::script::CustomCmd {} -impl flutter_rust_bridge::IntoIntoDart - for crate::api::script::CustomCmd -{ - fn into_into_dart(self) -> crate::api::script::CustomCmd { - self - } -} + impl flutter_rust_bridge::IntoDart for crate::api::script::CustomCmd { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.name.into_into_dart().into_dart(), +self.cmd.into_into_dart().into_dart() + ].into_dart() + } + } + impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive for crate::api::script::CustomCmd {} +impl flutter_rust_bridge::IntoIntoDart for crate::api::script::CustomCmd { + fn into_into_dart(self) -> crate::api::script::CustomCmd { + self + } + } // Codec=Dco (DartCObject based), see doc to use other codecs -impl flutter_rust_bridge::IntoDart for crate::api::script::ScriptSegment { - fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { - [ - self.key.into_into_dart().into_dart(), - self.value.into_into_dart().into_dart(), - ] - .into_dart() - } -} -impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive - for crate::api::script::ScriptSegment -{ -} -impl flutter_rust_bridge::IntoIntoDart - for crate::api::script::ScriptSegment -{ - fn into_into_dart(self) -> crate::api::script::ScriptSegment { - self - } -} + impl flutter_rust_bridge::IntoDart for crate::api::script::ScriptSegment { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.key.into_into_dart().into_dart(), +self.value.into_into_dart().into_dart() + ].into_dart() + } + } + impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive for crate::api::script::ScriptSegment {} +impl flutter_rust_bridge::IntoIntoDart for crate::api::script::ScriptSegment { + fn into_into_dart(self) -> crate::api::script::ScriptSegment { + self + } + } // Codec=Dco (DartCObject based), see doc to use other codecs -impl flutter_rust_bridge::IntoDart for crate::api::script::ShellFuncKind { - fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { - match self { - Self::Status => 0.into_dart(), - Self::StatusExt => 1.into_dart(), - Self::Process => 2.into_dart(), - Self::Shutdown => 3.into_dart(), - Self::Reboot => 4.into_dart(), - Self::Suspend => 5.into_dart(), - _ => unreachable!(), + impl flutter_rust_bridge::IntoDart for crate::api::script::ShellFuncKind { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + match self { + Self::Status => 0.into_dart(), +Self::StatusExt => 1.into_dart(), +Self::Process => 2.into_dart(), +Self::Shutdown => 3.into_dart(), +Self::Reboot => 4.into_dart(), +Self::Suspend => 5.into_dart(), + _ => unreachable!(), + } + } + } + impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive for crate::api::script::ShellFuncKind {} +impl flutter_rust_bridge::IntoIntoDart for crate::api::script::ShellFuncKind { + fn into_into_dart(self) -> crate::api::script::ShellFuncKind { + self + } } - } -} -impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive - for crate::api::script::ShellFuncKind -{ -} -impl flutter_rust_bridge::IntoIntoDart - for crate::api::script::ShellFuncKind -{ - fn into_into_dart(self) -> crate::api::script::ShellFuncKind { - self - } -} -impl SseEncode for std::collections::HashMap { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { - >::sse_encode(self.into_iter().collect(), serializer); - } -} - -impl SseEncode for String { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { - >::sse_encode(self.into_bytes(), serializer); - } -} - -impl SseEncode for crate::api::parser::CommandSpec { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { - ::sse_encode(self.key, serializer); - ::sse_encode(self.cmd, serializer); - } -} + impl SseEncode for std::collections::HashMap { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {>::sse_encode(self.into_iter().collect(), serializer);} + } + + impl SseEncode for String { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {>::sse_encode(self.into_bytes(), serializer);} + } + + impl SseEncode for crate::api::parser::CommandSpec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {::sse_encode(self.key, serializer); +::sse_encode(self.cmd, serializer);} + } + + impl SseEncode for crate::api::script::CustomCmd { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {::sse_encode(self.name, serializer); +::sse_encode(self.cmd, serializer);} + } + + impl SseEncode for f64 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {serializer.cursor.write_f64::(self).unwrap();} + } + + impl SseEncode for i32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {serializer.cursor.write_i32::(self).unwrap();} + } + + impl SseEncode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {::sse_encode(self.len() as _, serializer); + for item in self { ::sse_encode(item, serializer); }} + } + + impl SseEncode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {::sse_encode(self.len() as _, serializer); + for item in self { ::sse_encode(item, serializer); }} + } + + impl SseEncode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {::sse_encode(self.len() as _, serializer); + for item in self { ::sse_encode(item, serializer); }} + } + + impl SseEncode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {::sse_encode(self.len() as _, serializer); + for item in self { ::sse_encode(item, serializer); }} + } + + impl SseEncode for Vec<(String,String,)> { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {::sse_encode(self.len() as _, serializer); + for item in self { <(String,String,)>::sse_encode(item, serializer); }} + } + + impl SseEncode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {::sse_encode(self.len() as _, serializer); + for item in self { ::sse_encode(item, serializer); }} + } + + impl SseEncode for Option> { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {::sse_encode(self.is_some(), serializer); + if let Some(value) = self { + >::sse_encode(value, serializer); + }} + } + + impl SseEncode for (String,String,) { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {::sse_encode(self.0, serializer); +::sse_encode(self.1, serializer);} + } + + impl SseEncode for crate::api::script::ScriptSegment { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {::sse_encode(self.key, serializer); +::sse_encode(self.value, serializer);} + } + + impl SseEncode for crate::api::script::ShellFuncKind { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {::sse_encode(match self {crate::api::script::ShellFuncKind::Status => { 0 } +crate::api::script::ShellFuncKind::StatusExt => { 1 } +crate::api::script::ShellFuncKind::Process => { 2 } +crate::api::script::ShellFuncKind::Shutdown => { 3 } +crate::api::script::ShellFuncKind::Reboot => { 4 } +crate::api::script::ShellFuncKind::Suspend => { 5 } + _ => { unimplemented!(""); }}, serializer);} + } + + impl SseEncode for u8 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {serializer.cursor.write_u8(self).unwrap();} + } + + impl SseEncode for () { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {} + } + + impl SseEncode for bool { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {serializer.cursor.write_u8(self as _).unwrap();} + } + -impl SseEncode for crate::api::script::CustomCmd { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { - ::sse_encode(self.name, serializer); - ::sse_encode(self.cmd, serializer); - } -} -impl SseEncode for f64 { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { - serializer.cursor.write_f64::(self).unwrap(); - } -} + + + #[cfg(not(target_family = "wasm"))] + mod io { + // This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.13.0-beta.6. -impl SseEncode for i32 { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { - serializer.cursor.write_i32::(self).unwrap(); - } -} -impl SseEncode for Vec { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { - ::sse_encode(self.len() as _, serializer); - for item in self { - ::sse_encode(item, serializer); - } - } -} -impl SseEncode for Vec { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { - ::sse_encode(self.len() as _, serializer); - for item in self { - ::sse_encode(item, serializer); - } - } -} +// Section: imports -impl SseEncode for Vec { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { - ::sse_encode(self.len() as _, serializer); - for item in self { - ::sse_encode(item, serializer); - } - } -} +use flutter_rust_bridge::{Handler, IntoIntoDart}; +use flutter_rust_bridge::for_generated::{Lockable, transform_result_dco, Lifetimeable}; +use flutter_rust_bridge::for_generated::byteorder::{NativeEndian, WriteBytesExt, ReadBytesExt};use super::*; -impl SseEncode for Vec { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { - ::sse_encode(self.len() as _, serializer); - for item in self { - ::sse_encode(item, serializer); - } - } -} +// Section: boilerplate -impl SseEncode for Vec<(String, String)> { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { - ::sse_encode(self.len() as _, serializer); - for item in self { - <(String, String)>::sse_encode(item, serializer); - } - } -} +flutter_rust_bridge::frb_generated_boilerplate_io!(); -impl SseEncode for Vec { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { - ::sse_encode(self.len() as _, serializer); - for item in self { - ::sse_encode(item, serializer); - } - } -} -impl SseEncode for Option> { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { - ::sse_encode(self.is_some(), serializer); - if let Some(value) = self { - >::sse_encode(value, serializer); } - } -} - -impl SseEncode for (String, String) { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { - ::sse_encode(self.0, serializer); - ::sse_encode(self.1, serializer); - } -} - -impl SseEncode for crate::api::script::ScriptSegment { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { - ::sse_encode(self.key, serializer); - ::sse_encode(self.value, serializer); - } -} - -impl SseEncode for crate::api::script::ShellFuncKind { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { - ::sse_encode( - match self { - crate::api::script::ShellFuncKind::Status => 0, - crate::api::script::ShellFuncKind::StatusExt => 1, - crate::api::script::ShellFuncKind::Process => 2, - crate::api::script::ShellFuncKind::Shutdown => 3, - crate::api::script::ShellFuncKind::Reboot => 4, - crate::api::script::ShellFuncKind::Suspend => 5, - _ => { - unimplemented!(""); - } - }, - serializer, - ); - } -} + #[cfg(not(target_family = "wasm"))] + pub use io::*; + + + /// cbindgen:ignore + #[cfg(target_family = "wasm")] + mod web { + // This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.13.0-beta.6. -impl SseEncode for u8 { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { - serializer.cursor.write_u8(self).unwrap(); - } -} -impl SseEncode for () { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {} -} -impl SseEncode for bool { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { - serializer.cursor.write_u8(self as _).unwrap(); - } -} - -#[cfg(not(target_family = "wasm"))] -mod io { - // This file is automatically generated, so please do not edit it. - // @generated by `flutter_rust_bridge`@ 2.12.0. - - // Section: imports - - use super::*; - use flutter_rust_bridge::for_generated::byteorder::{ - NativeEndian, ReadBytesExt, WriteBytesExt, - }; - use flutter_rust_bridge::for_generated::{transform_result_dco, Lifetimeable, Lockable}; - use flutter_rust_bridge::{Handler, IntoIntoDart}; - - // Section: boilerplate +// Section: imports - flutter_rust_bridge::frb_generated_boilerplate_io!(); -} -#[cfg(not(target_family = "wasm"))] -pub use io::*; +use flutter_rust_bridge::{Handler, IntoIntoDart}; +use flutter_rust_bridge::for_generated::{Lockable, transform_result_dco, Lifetimeable}; +use flutter_rust_bridge::for_generated::byteorder::{NativeEndian, WriteBytesExt, ReadBytesExt};use super::*; + use flutter_rust_bridge::for_generated::wasm_bindgen; + use flutter_rust_bridge::for_generated::wasm_bindgen::prelude::*; -/// cbindgen:ignore -#[cfg(target_family = "wasm")] -mod web { - // This file is automatically generated, so please do not edit it. - // @generated by `flutter_rust_bridge`@ 2.12.0. - // Section: imports +// Section: boilerplate - use super::*; - use flutter_rust_bridge::for_generated::byteorder::{ - NativeEndian, ReadBytesExt, WriteBytesExt, - }; - use flutter_rust_bridge::for_generated::wasm_bindgen; - use flutter_rust_bridge::for_generated::wasm_bindgen::prelude::*; - use flutter_rust_bridge::for_generated::{transform_result_dco, Lifetimeable, Lockable}; - use flutter_rust_bridge::{Handler, IntoIntoDart}; +flutter_rust_bridge::frb_generated_boilerplate_web!(); - // Section: boilerplate - flutter_rust_bridge::frb_generated_boilerplate_web!(); -} -#[cfg(target_family = "wasm")] -pub use web::*; + } + #[cfg(target_family = "wasm")] + pub use web::*; + \ No newline at end of file diff --git a/crates/sbm_ffi/windows/.gitignore b/crates/sbm_ffi/windows/.gitignore deleted file mode 100644 index b3eb2be16..000000000 --- a/crates/sbm_ffi/windows/.gitignore +++ /dev/null @@ -1,17 +0,0 @@ -flutter/ - -# Visual Studio user-specific files. -*.suo -*.user -*.userosscache -*.sln.docstates - -# Visual Studio build-related files. -x64/ -x86/ - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!*.[Cc]ache/ diff --git a/crates/sbm_ffi/windows/CMakeLists.txt b/crates/sbm_ffi/windows/CMakeLists.txt deleted file mode 100644 index d55959f76..000000000 --- a/crates/sbm_ffi/windows/CMakeLists.txt +++ /dev/null @@ -1,20 +0,0 @@ -# The Flutter tooling requires that developers have a version of Visual Studio -# installed that includes CMake 3.14 or later. You should not increase this -# version, as doing so will cause the plugin to fail to compile for some -# customers of the plugin. -cmake_minimum_required(VERSION 3.14) - -# Project-level configuration. -set(PROJECT_NAME "sbm_ffi") -project(${PROJECT_NAME} LANGUAGES CXX) - -include("../cargokit/cmake/cargokit.cmake") -apply_cargokit(${PROJECT_NAME} ../../../../../../crates/sbm_ffi sbm_ffi "") - -# List of absolute paths to libraries that should be bundled with the plugin. -# This list could contain prebuilt libraries, or libraries created by an -# external build triggered from this build file. -set(sbm_ffi_bundled_libraries - "${${PROJECT_NAME}_cargokit_lib}" - PARENT_SCOPE -) diff --git a/hook/build.dart b/hook/build.dart new file mode 100644 index 000000000..2df0763e4 --- /dev/null +++ b/hook/build.dart @@ -0,0 +1,24 @@ +import 'package:flutter_rust_bridge_hooks/flutter_rust_bridge_hooks.dart'; + +/// Builds `crates/sbm_ffi` and hands the result to the Dart/Flutter SDK as a +/// code asset. +/// +/// This replaces cargokit, which drove cargo from a CocoaPods `script_phase` on +/// Apple platforms, a CMake step on Linux and Windows, and a gradle plugin on +/// Android — four integrations, one per platform, and the Apple one had no +/// Swift Package Manager equivalent: a SwiftPM build tool plugin runs in a +/// sandbox that denies writes to the project directory, so cargo cannot write +/// `target/` or `~/.cargo` from one. That mattered because CocoaPods' registry +/// goes read-only on 2026-12-02 and Flutter's fallback to it is removed some +/// time after. +/// +/// A build hook sidesteps the question rather than answering it: it is neither +/// a pod nor a Swift package, so it produces no `.podspec` and no +/// `Package.swift`, and the same file covers all five platforms. +void main(List args) async { + await build(args, (input, output) async { + await const FlutterRustBridgeNativeAssetsBuilder( + cratePath: 'crates/sbm_ffi', + ).run(input: input, output: output); + }); +} diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 61179edec..10ec838ca 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -2,26 +2,20 @@ PODS: - Flutter (1.0.0) - flutter_pty (0.0.1): - Flutter - - sbm_ffi (0.0.1): - - Flutter DEPENDENCIES: - Flutter (from `Flutter`) - flutter_pty (from `.symlinks/plugins/flutter_pty/ios`) - - sbm_ffi (from `.symlinks/plugins/sbm_ffi/ios`) EXTERNAL SOURCES: Flutter: :path: Flutter flutter_pty: :path: ".symlinks/plugins/flutter_pty/ios" - sbm_ffi: - :path: ".symlinks/plugins/sbm_ffi/ios" SPEC CHECKSUMS: Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47 flutter_pty: 206742da6092b413cb683de17436c16d1f3c7997 - sbm_ffi: 00a2185752f2a9d3a3f0d3ab4b4d5cb29729aa30 PODFILE CHECKSUM: 033fcc5039cba02092526aeac31fc4c94faa91b1 diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index 4d7193eeb..000000000 --- a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,59 +0,0 @@ -{ - "pins" : [ - { - "identity" : "dkcamera", - "kind" : "remoteSourceControl", - "location" : "https://github.com/zhangao0086/DKCamera", - "state" : { - "branch" : "master", - "revision" : "5c691d11014b910aff69f960475d70e65d9dcc96" - } - }, - { - "identity" : "dkimagepickercontroller", - "kind" : "remoteSourceControl", - "location" : "https://github.com/zhangao0086/DKImagePickerController", - "state" : { - "branch" : "4.3.9", - "revision" : "0bdfeacefa308545adde07bef86e349186335915" - } - }, - { - "identity" : "dkphotogallery", - "kind" : "remoteSourceControl", - "location" : "https://github.com/zhangao0086/DKPhotoGallery", - "state" : { - "branch" : "master", - "revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d" - } - }, - { - "identity" : "sdwebimage", - "kind" : "remoteSourceControl", - "location" : "https://github.com/SDWebImage/SDWebImage", - "state" : { - "revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0", - "version" : "5.21.7" - } - }, - { - "identity" : "swiftygif", - "kind" : "remoteSourceControl", - "location" : "https://github.com/kirualex/SwiftyGif.git", - "state" : { - "revision" : "4430cbc148baa3907651d40562d96325426f409a", - "version" : "5.4.5" - } - }, - { - "identity" : "tocropviewcontroller", - "kind" : "remoteSourceControl", - "location" : "https://github.com/TimOliver/TOCropViewController", - "state" : { - "revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e", - "version" : "2.8.0" - } - } - ], - "version" : 2 -} diff --git a/lib/src/rust/api/parser.dart b/lib/src/rust/api/parser.dart index 11f325056..386e136fa 100644 --- a/lib/src/rust/api/parser.dart +++ b/lib/src/rust/api/parser.dart @@ -1,5 +1,5 @@ // This file is automatically generated, so please do not edit it. -// @generated by `flutter_rust_bridge`@ 2.12.0. +// @generated by `flutter_rust_bridge`@ 2.13.0-beta.6. // ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import diff --git a/lib/src/rust/api/script.dart b/lib/src/rust/api/script.dart index 99b69ccda..ca48eae86 100644 --- a/lib/src/rust/api/script.dart +++ b/lib/src/rust/api/script.dart @@ -1,5 +1,5 @@ // This file is automatically generated, so please do not edit it. -// @generated by `flutter_rust_bridge`@ 2.12.0. +// @generated by `flutter_rust_bridge`@ 2.13.0-beta.6. // ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import diff --git a/lib/src/rust/frb_generated.dart b/lib/src/rust/frb_generated.dart index f105a7539..bac0f284b 100644 --- a/lib/src/rust/frb_generated.dart +++ b/lib/src/rust/frb_generated.dart @@ -1,5 +1,5 @@ // This file is automatically generated, so please do not edit it. -// @generated by `flutter_rust_bridge`@ 2.12.0. +// @generated by `flutter_rust_bridge`@ 2.13.0-beta.6. // ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field @@ -65,7 +65,7 @@ class RustLib extends BaseEntrypoint { kDefaultExternalLibraryLoaderConfig; @override - String get codegenVersion => '2.12.0'; + String get codegenVersion => '2.13.0-beta.6'; @override int get rustContentHash => -397184763; diff --git a/lib/src/rust/frb_generated.io.dart b/lib/src/rust/frb_generated.io.dart index f338c5668..edaae6810 100644 --- a/lib/src/rust/frb_generated.io.dart +++ b/lib/src/rust/frb_generated.io.dart @@ -1,5 +1,5 @@ // This file is automatically generated, so please do not edit it. -// @generated by `flutter_rust_bridge`@ 2.12.0. +// @generated by `flutter_rust_bridge`@ 2.13.0-beta.6. // ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field diff --git a/lib/src/rust/frb_generated.web.dart b/lib/src/rust/frb_generated.web.dart index 1ff0fdb63..286d1f5f3 100644 --- a/lib/src/rust/frb_generated.web.dart +++ b/lib/src/rust/frb_generated.web.dart @@ -1,5 +1,5 @@ // This file is automatically generated, so please do not edit it. -// @generated by `flutter_rust_bridge`@ 2.12.0. +// @generated by `flutter_rust_bridge`@ 2.13.0-beta.6. // ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 9a75aad58..d9545c0db 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -14,7 +14,6 @@ list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST flutter_pty jni - sbm_ffi ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 541ca60c0..0b627fc41 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -2,26 +2,20 @@ PODS: - flutter_pty (0.0.1): - FlutterMacOS - FlutterMacOS (1.0.0) - - sbm_ffi (0.0.1): - - FlutterMacOS DEPENDENCIES: - flutter_pty (from `Flutter/ephemeral/.symlinks/plugins/flutter_pty/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - - sbm_ffi (from `Flutter/ephemeral/.symlinks/plugins/sbm_ffi/macos`) EXTERNAL SOURCES: flutter_pty: :path: Flutter/ephemeral/.symlinks/plugins/flutter_pty/macos FlutterMacOS: :path: Flutter/ephemeral - sbm_ffi: - :path: Flutter/ephemeral/.symlinks/plugins/sbm_ffi/macos SPEC CHECKSUMS: flutter_pty: 1e360feaf8b1a213d2d50da3c076005b8eef1eee FlutterMacOS: c232990155153907050900a2e175c7773903ba4e - sbm_ffi: a03f641ae260971838739d773a55cddda61ba73e PODFILE CHECKSUM: 2190e15ede1572f0b104dd8ad4ae165b26ca5bfa diff --git a/pubspec.lock b/pubspec.lock index 8b918a690..e806e69cd 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -601,10 +601,18 @@ packages: dependency: "direct main" description: name: flutter_rust_bridge - sha256: e87d6b9ee934dcd24a128ccb2bd91905d2d5fe5c06245d6a8f5477d4907a437a + sha256: "7e8f565714dcb06197d29c74a559ee826c4eab4d586d7ccf9826aa5dc9478015" url: "https://pub.dev" source: hosted - version: "2.12.0" + version: "2.13.0-beta.6" + flutter_rust_bridge_hooks: + dependency: "direct main" + description: + name: flutter_rust_bridge_hooks + sha256: a950952d2de2ffc2464e336bd04fe16124b863a27ed547c41b94ad635d07bfd1 + url: "https://pub.dev" + source: hosted + version: "2.13.0-beta.6" flutter_secure_storage: dependency: transitive description: @@ -1082,6 +1090,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.19.3" + native_toolchain_rust: + dependency: transitive + description: + name: native_toolchain_rust + sha256: faa57d2258a3b0fd2a634054f54e4496c9fcbd971977e7d2b7e6916d56892857 + url: "https://pub.dev" + source: hosted + version: "1.0.4+0" nested: dependency: transitive description: @@ -1394,13 +1410,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.4" - sbm_ffi: - dependency: "direct main" - description: - path: "crates/sbm_ffi" - relative: true - source: path - version: "0.0.1" screen_retriever: dependency: transitive description: @@ -1678,6 +1687,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.18" + toml: + dependency: transitive + description: + name: toml + sha256: "35a35f782228656a2af31e8c73d1353cc4ef3d683fd68af1111b44631879c05e" + url: "https://pub.dev" + source: hosted + version: "0.18.0" tuple: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b47724018..667d3badf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -66,9 +66,10 @@ dependencies: path: packages/plain_notification_token fl_lib: path: packages/fl_lib - sbm_ffi: - path: crates/sbm_ffi - flutter_rust_bridge: 2.12.0 + flutter_rust_bridge: 2.13.0-beta.6 + # Drives the build hook below. `sbm_ffi` is no longer a Flutter plugin — the + # hook compiles the crate directly, so there is nothing to depend on. + flutter_rust_bridge_hooks: 2.13.0-beta.6 desktop_drop: ^0.7.1 dev_dependencies: diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index aed579f6f..206b92827 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -16,7 +16,6 @@ list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST flutter_pty jni - sbm_ffi ) set(PLUGIN_BUNDLED_LIBRARIES) From 1e10534e7363d73d6ef7368983b5bf87ca01de05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Wed, 19 Aug 2026 01:54:05 +0800 Subject: [PATCH 05/16] docs: bring CLAUDE.md in line with SQLite storage and the FFI build hook Also records the two traps found on the way: `integrate` reformats the whole project and every submodule (`generate` does not), and the two flutter_rust_bridge pins have to match or `RustLib.init` throws at startup. --- CLAUDE.md | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d5f099be3..105ad29d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,15 +30,20 @@ A `Makefile` wraps most common tasks — run `make help` for the full list. Pref - `flutter test test/disk_test.dart` - Run specific test file (or `make test-one TEST=test/disk_test.dart`) - `cargo test --workspace` - Run all Rust tests (parser, FFI shell, monitor) - SSH e2e (opt-in): set `SBM_E2E_SSH_HOST=` in the workspace-root `.env`, then `cargo test -p sbm_parser --test ssh_e2e` — uploads the generated script to the remote, runs it, and compares the parsed result against direct command output; silently skipped when unset -- A widget test whose tree writes to a store must open the Hive box **in memory**: `Hive.openBox('setting_test', bytes: Uint8List(0))`. A `testWidgets` body runs in a fake-async zone, and a real file write started there completes on a callback that zone is no longer pumping — so the box's write lock is never released, `box.close()` in `tearDown` blocks forever, and `flutter test` waits on that process. One such test hangs the whole run, with no failure and no output to say which file did it. Widgets that persist on their own (the floating Agent writes its mode on every change, panes write their width on every drag) hit this without the test writing anything itself. +- A widget test whose tree writes to a store opens the database **in memory**: `SqliteDb.openInMemory()` in `setUp`, `SqliteDb.close` in `tearDown`, and the store's `forTest()` constructor (which only differs by using a distinct store name). Widgets that persist on their own — the floating Agent writes its mode on every change, panes write their width on every drag — write without the test asking them to, so this applies to more trees than it looks like. + - This is also why: a `testWidgets` body runs in a fake-async zone, and a real *file* write started there completes on a callback that zone is no longer pumping. Under Hive that left the box's write lock held, `close()` in `tearDown` blocked forever, and `flutter test` waited on the process — one such test hung the whole run with no failure and no output naming the file. An in-memory database has no such lock, but keeping the writes off disk is still the rule. - Size the view, not the surface, when a test depends on a breakpoint: `tester.view.physicalSize` + `devicePixelRatio`. `setSurfaceSize` changes what the tree is laid out in but not what `MediaQuery` reports, so a "phone" test written that way silently exercises the desktop rendering. - `pumpAndSettle` is not usable on a tree containing a text field or another always-scheduling widget: it waits for a frame in which nothing is scheduled, and then gives up after its 10-minute default. Count the frames out with `pump(duration)` instead. `--timeout 30s` keeps any such mistake from costing ten minutes. ### Rust / FFI - `cargo build -p sbm_ffi` - Build the FFI crate; required before running `flutter test test/frb_parser_test.dart` (`test/rust_lib_helper.dart` loads the dylib from `target/`) -- `flutter_rust_bridge_codegen generate` - Regenerate FRB bindings after changing `crates/sbm_ffi/src/api` (config: `flutter_rust_bridge.yaml`; Dart output `lib/src/rust/`, do not edit generated code) -- App builds link Rust via cargokit inside `crates/sbm_ffi/` (the crate and the Flutter FFI plugin glue share one directory), pinned to `flutter_rust_bridge: 2.12.0` in pubspec +- `flutter_rust_bridge_codegen generate` - Regenerate FRB bindings after changing `crates/sbm_ffi/src/api` (config: `flutter_rust_bridge.yaml`; Dart output `lib/src/rust/`, do not edit generated code). Its `dart fix`/`dart format` pass is scoped to that output directory, so it is safe to run. + - **Never run `flutter_rust_bridge_codegen integrate`.** It is greenfield scaffolding: on this repo it reformats the whole project *and every submodule*, and writes a second Rust crate at `rust/` beside the real one. To see what a template looks like, run `create` in a temp directory instead. +- App builds compile Rust through `hook/build.dart` (Dart build hooks, via `flutter_rust_bridge_hooks` → `native_toolchain_rust`). `crates/sbm_ffi` is **not** a Flutter plugin and the app does not depend on it as a package — the hook names the crate path. So it produces no podspec and no `Package.swift`, and one file covers all five platforms. + - `flutter_rust_bridge` is pinned to `2.13.0-beta.6` in both `pubspec.yaml` and `crates/sbm_ffi/Cargo.toml`, and the two must match or `RustLib.init` throws at startup. The native-assets backend needs `>= 2.13.0-beta.2` and 2.13.0 has no stable release yet; this is the project's only prerelease dependency. + - `crates/sbm_ffi/rust-toolchain.toml` pins the channel and lists every shipped target, which `native_toolchain_rust` requires. A target missing from that list is not an error, it is a silent fallback to the host. + - `flutter_pty` is the only remaining pod, on iOS and macOS. It ships no `Package.swift`, so `Podfile`/`Pods` cannot go until that changes upstream or it is forked. ## Architecture @@ -50,7 +55,7 @@ This is a Flutter application for managing Linux servers with the following key - Parsing is pure functions: parsers emit raw counters; diff/windowed computation (speeds etc.) is provided as pure functions, mutable time-series state stays on the caller side. The FFI boundary holds no mutable state. - The command manifest (cmd name → per-platform command, `SrvBoxSep.` segmenting) lives here too; the `commands::EXTENDED` keys (smartctl, AMD GPU) are split out of the fast status function into `SbStatusExt`, which both callers run minutes apart — smartctl at poll frequency keeps a disk from staying spun down - Script generation is shared as well (`script.rs`: build/install/exec commands + output splitting, locked by `tests/script_compat.rs`); the app calls it via FFI and merges the two functions' output, the monitor executes the script locally on its extended cycle -- `crates/sbm_ffi/` - flutter_rust_bridge binding crate + cargokit Flutter plugin glue in one directory (Dart side generated into `lib/src/rust/`) +- `crates/sbm_ffi/` - flutter_rust_bridge binding crate, built by the root `hook/build.dart` (Dart side generated into `lib/src/rust/`) - `crates/sbm_native/` - Native per-platform sampler, **monitor only** — the app always collects over SSH and has no way to run syscalls on a remote host. `sample()` covers cpu/mem/swap/disks/diskio/net/uptime/host/sys via `sysinfo` (BSD/Windows) or direct procfs/sysfs reads feeding `sbm_parser::linux::parse_*` (Linux); amd/sensors/SMART/battery stay on the shared script, which genuinely needs CLI tools - `monitor/` - Server-side monitoring service (Rust + Svelte frontend), has its own `monitor/CLAUDE.md` - Besides status, it serves the endpoints the app uses for a monitor-backed server — `POST /exec`, `/terminal/ws`, `/fs/*` — plus an in-browser terminal for its own panel. All of them are off by default and configured only in `config.toml`; see the "Remote access" section there for the security model @@ -65,10 +70,10 @@ This is a Flutter application for managing Linux servers with the following key - `lib/data/` - Data layer with models, providers, and storage - `model/` - Data models organized by feature (server, container, ssh, etc.) - `provider/` - Riverpod providers for state management - - `store/` - Local storage implementations using Hive + - `store/` - Local storage implementations over `SqliteStore` (fl_lib) - `lib/view/` - UI layer with pages and widgets - `lib/generated/` - Generated localization files -- `lib/hive/` - Hive adapters for local storage +- `lib/hive/` - Hive adapters, kept only so `HiveImport` can read an upgrading install's old boxes. Nothing writes Hive; the whole directory goes when that import does (TODO in the code) - `lib/src/rust/` - Generated FRB bindings (do not edit) - `packages/` - Vendored Dart forks referenced by path from pubspec (dartssh2, xterm, fl_lib, fl_build, etc.), each a submodule. The exception is `packages/webui`, an in-repo Svelte package (`@serverbox/webui`) of shared UI primitives and design tokens, consumed as a `file:` dependency by both `monitor/frontend` and `website/` - `third_party/ish-arm64` - The iOS Linux engine, a submodule of the `lollipopkit/ShellBox` fork. Not in `packages/` because it is C built by meson and consumed by the Xcode project rather than by pubspec. Which revision builds is the gitlink, not a hash in a script: move it with `git submodule update --remote third_party/ish-arm64` and `git add`. `scripts/build-ish-ios.sh` builds it out of tree into `build/ish/build-/`, so a build never leaves the submodule dirty @@ -77,7 +82,7 @@ This is a Flutter application for managing Linux servers with the following key ### Key Technologies - **State Management**: Riverpod with code generation (riverpod_annotation) -- **Local Storage**: Hive for persistent data with generated adapters +- **Local Storage**: one encrypted SQLite file (`store.db`) via `package:sqlite3`, bundled through Dart build hooks with `source: sqlite3mc` - **SSH/SFTP**: Custom dartssh2 fork for server connections - **Terminal**: Custom xterm.dart fork for SSH terminal interface - **Networking**: dio for HTTP requests @@ -151,7 +156,7 @@ ends, or restore a consumer. - Uses Riverpod providers for dependency injection and state management - Uses Freezed for immutable state models - Providers are organized by feature in `lib/data/provider/` -- State is often persisted using Hive stores in `lib/data/store/` +- State is often persisted using the stores in `lib/data/store/` ### Build System @@ -168,8 +173,10 @@ ends, or restore a consumer. - AGAIN, NEVER run code formatting commands. - USE dependency injection via GetIt for services like Stores, Services and etc. - Generate all l10n files using `flutter gen-l10n` command after modifying ARB files. -- USE `hive_ce` not `hive` package for Hive integration. - - Which no need to config `HiveField` and `HiveType` manually. +- Storage is SQLite, not Hive. `Store` in fl_lib is `sealed`, so a new backend has to be added there as another `part of 'iface.dart'`, not in this repo. + - Most stores are rows in one shared `kv(store, key, value, updated_at)` table, with `value` as JSON — so `get` returns what `jsonDecode` produced and the `fromObj` hook rebuilds the model. `connection_stats` and `agent_conversation` own real tables instead, because every read of them is a range over one server. + - Enums are stored **by name**, never by index: an index silently changes meaning when a case is inserted, and these values outlive the build that wrote them. + - A write that should not count as a user edit — a migration flag, a restore, device-local bookkeeping — passes `updateLastUpdateTsOnSet: false`. `Stores.lastModTime` is read off those timestamps and decides which side of a sync wins. - USE widgets and utilities from `fl_lib` package for common functionalities. - Such as `CustomAppBar`, `context.showRoundDialog`, `Input`, `Btnx.cancelOk`, etc. - You can use context7 MCP to search `lppcg fl_lib KEYWORD` to find relevant widgets and utilities. From 8882dc07b78fecfd775f1027f25845849f27c31b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Wed, 19 Aug 2026 02:09:10 +0800 Subject: [PATCH 06/16] migrate(ios,macos): fork flutter_pty onto a build hook, ending the pods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `flutter_pty` was the last third-party pod. Upstream's last release is 0.4.2 (January 2025), it ships no `Package.swift`, and the one open PR for that (#21, May 2026) covers macOS only and has had no maintainer response — so waiting was not a plan. The fork does to it what the previous commit did to `sbm_ffi`: five per-platform build integrations become one `hook/build.dart`, here over `native_toolchain_c` rather than `native_toolchain_rust`. The Dart API is untouched, and the code asset lands under the same names `DynamicLibrary.open` already looks for. Both `Podfile.lock`s are now down to Flutter itself, and `flutter build` reports "All plugins found are Swift Packages". Xcode dropped the `[CP] Embed Pods Frameworks` phase from both projects on its own, there being nothing left to embed. Verified beyond building: `integration_test/local_shell_test.dart` on macOS, which spawns a real shell through the PTY, runs commands and reads back what they printed. That file exists precisely because this is FFI over a plugin and the unit suite cannot reach it. Android, Linux and Windows are unverified. Deintegrating CocoaPods altogether is now possible and deliberately not done here: the Podfile is non-standard because of the Watch app and the widget extensions, and that needs verifying against those targets rather than being tacked onto this. --- .gitmodules | 4 ++ CLAUDE.md | 3 +- TODOS.md | 15 +++++ ios/Podfile.lock | 6 -- ios/Runner.xcodeproj/project.pbxproj | 74 ++++++++++--------------- linux/flutter/generated_plugins.cmake | 1 - macos/Podfile.lock | 6 -- macos/Runner.xcodeproj/project.pbxproj | 18 ------ packages/flutter_pty | 1 + pubspec.lock | 7 +-- pubspec.yaml | 5 +- windows/flutter/generated_plugins.cmake | 1 - 12 files changed, 57 insertions(+), 84 deletions(-) create mode 160000 packages/flutter_pty diff --git a/.gitmodules b/.gitmodules index 9701e0bb7..7247d9d2f 100644 --- a/.gitmodules +++ b/.gitmodules @@ -30,3 +30,7 @@ path = third_party/ish-arm64 url = https://github.com/lollipopkit/ShellBox branch = main +[submodule "packages/flutter_pty"] + path = packages/flutter_pty + url = git@github.com:lollipopkit/flutter_pty.git + branch = main diff --git a/CLAUDE.md b/CLAUDE.md index 105ad29d2..e0fbe1c1c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,8 @@ A `Makefile` wraps most common tasks — run `make help` for the full list. Pref - App builds compile Rust through `hook/build.dart` (Dart build hooks, via `flutter_rust_bridge_hooks` → `native_toolchain_rust`). `crates/sbm_ffi` is **not** a Flutter plugin and the app does not depend on it as a package — the hook names the crate path. So it produces no podspec and no `Package.swift`, and one file covers all five platforms. - `flutter_rust_bridge` is pinned to `2.13.0-beta.6` in both `pubspec.yaml` and `crates/sbm_ffi/Cargo.toml`, and the two must match or `RustLib.init` throws at startup. The native-assets backend needs `>= 2.13.0-beta.2` and 2.13.0 has no stable release yet; this is the project's only prerelease dependency. - `crates/sbm_ffi/rust-toolchain.toml` pins the channel and lists every shipped target, which `native_toolchain_rust` requires. A target missing from that list is not an error, it is a silent fallback to the host. - - `flutter_pty` is the only remaining pod, on iOS and macOS. It ships no `Package.swift`, so `Podfile`/`Pods` cannot go until that changes upstream or it is forked. + - `packages/flutter_pty` is a fork carrying the same change, for the same reason: upstream ships no `Package.swift` and was the last third-party pod. It uses `native_toolchain_c` rather than `native_toolchain_rust`, and is otherwise identical to upstream 0.4.2. + - **No third-party pod remains.** `flutter build` now reports "All plugins found are Swift Packages, but your project still has CocoaPods integration" — deintegrating (`pod deintegrate` plus removing the `Pods-Runner` xcconfig includes) is possible but not done: the Podfile is non-standard because of the Watch app and widget extensions, and that has to be verified against those targets. ## Architecture diff --git a/TODOS.md b/TODOS.md index 61caf8887..e217b2cb1 100644 --- a/TODOS.md +++ b/TODOS.md @@ -116,10 +116,25 @@ FRB 升到 `2.13.0-beta.6`(Rust 与 pubspec 两处)。 `Podfile.lock` 里都只剩 `flutter_pty`。`flutter test test/frb_parser_test.dart` 与 `cargo test --workspace` 通过。 +`flutter_pty` 也一起做了:`packages/flutter_pty` 是 +[TerminalStudio/flutter_pty](https://github.com/TerminalStudio/flutter_pty) 的 +fork,把同样的五个平台构建集成换成一个 `hook/build.dart`(用 `native_toolchain_c`)。 +上游最后一个版本是 0.4.2(2025-01),没有 `Package.swift`,唯一那个 SwiftPM PR +(#21)只做了 macOS 且无人回应。 + +**结果:两个 `Podfile.lock` 里第三方 pod 归零**,只剩 Flutter 自身。 +`flutter build` 现在会提示 "All plugins found are Swift Packages, but your +project still has CocoaPods integration"。 + **未验证:Android / Linux / Windows。** 这三个平台原先分别走 cargokit 的 gradle plugin 和 CMake,现在改走同一个 hook,但本机没跑过。要在 CI 或对应机器上 各跑一次 `dart run fl_build -p `。 +**下一步(现在才有可能做):彻底 deintegrate CocoaPods。** Flutter 列出的步骤是 +`pod deintegrate` 加上从 `{ios,macos}/Flutter/*.xcconfig` 里移除 `Pods-Runner` +的 include。没做的原因:Podfile 因为 Watch app 和 widget extension 而是 +non-standard,这两个 target 得单独验过。 + 两条注意事项: - **不要用 `flutter_rust_bridge_codegen integrate`。** 它是给新项目的脚手架: 在本仓库上跑会重新格式化 188 个文件、脚手架出一个新的 `rust/` crate、`hook/`、 diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 10ec838ca..a8cd270ad 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,21 +1,15 @@ PODS: - Flutter (1.0.0) - - flutter_pty (0.0.1): - - Flutter DEPENDENCIES: - Flutter (from `Flutter`) - - flutter_pty (from `.symlinks/plugins/flutter_pty/ios`) EXTERNAL SOURCES: Flutter: :path: Flutter - flutter_pty: - :path: ".symlinks/plugins/flutter_pty/ios" SPEC CHECKSUMS: Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47 - flutter_pty: 206742da6092b413cb683de17436c16d1f3c7997 PODFILE CHECKSUM: 033fcc5039cba02092526aeac31fc4c94faa91b1 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 33b40c4ac..3e7a9ab16 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -9,11 +9,11 @@ /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - 5B1A0002200001000000ISH0 /* sbm_ish.c in Sources */ = {isa = PBXBuildFile; fileRef = 5B1A0001200001000000ISH0 /* sbm_ish.c */; }; 4A2DCD6B2E4B127100CF68B7 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCD692E4B127100CF68B7 /* LiveActivityManager.swift */; }; 4A2DCD6C2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCD6A2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift */; }; 4A2DCD6F2E4B128100CF68B7 /* TerminalLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCD6D2E4B128100CF68B7 /* TerminalLiveActivity.swift */; }; 4A2DCD702E4B128100CF68B7 /* TerminalLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCD6E2E4B128100CF68B7 /* TerminalLiveActivityAttributes.swift */; }; + 5B1A0002200001000000ISH0 /* sbm_ish.c in Sources */ = {isa = PBXBuildFile; fileRef = 5B1A0001200001000000ISH0 /* sbm_ish.c */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 7538AEC32BB83FAB002AB82A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 7538AEC22BB83FAB002AB82A /* PrivacyInfo.xcprivacy */; }; 7538AEC52BB83FC8002AB82A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 7538AEC42BB83FC8002AB82A /* PrivacyInfo.xcprivacy */; }; @@ -131,6 +131,8 @@ 4A2DCD6A2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalLiveActivityAttributes.swift; sourceTree = ""; }; 4A2DCD6D2E4B128100CF68B7 /* TerminalLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalLiveActivity.swift; sourceTree = ""; }; 4A2DCD6E2E4B128100CF68B7 /* TerminalLiveActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalLiveActivityAttributes.swift; sourceTree = ""; }; + 5B1A0001200001000000ISH0 /* sbm_ish.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = sbm_ish.c; sourceTree = ""; }; + 5B1A0003200001000000ISH0 /* sbm_ish.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = sbm_ish.h; sourceTree = ""; }; 668FBB3ED2E093A4068CAF18 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -173,8 +175,6 @@ E39A76AB2AB9A2F70067C641 /* Info-Debug.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "Info-Debug.plist"; sourceTree = ""; }; E39A76AC2AB9A2F70067C641 /* Info-Release.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "Info-Release.plist"; sourceTree = ""; }; E39A76AD2AB9A2F70067C641 /* Info-Profile.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "Info-Profile.plist"; sourceTree = ""; }; - 5B1A0001200001000000ISH0 /* sbm_ish.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = sbm_ish.c; sourceTree = ""; }; - 5B1A0003200001000000ISH0 /* sbm_ish.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = sbm_ish.h; sourceTree = ""; }; E3AE8AE92AB601DB000A6459 /* Utils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Utils.swift; sourceTree = ""; }; E3D26BC22B99637800D83425 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Main.strings"; sourceTree = ""; }; E3D26BC32B99637900D83425 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/LaunchScreen.strings"; sourceTree = ""; }; @@ -221,9 +221,9 @@ FA7C40000000000000000004 /* WatchStatusWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchStatusWidget.swift; sourceTree = ""; }; FA7C40000000000000000005 /* WatchStatusWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchStatusWidgetBundle.swift; sourceTree = ""; }; FA7C40000000000000000006 /* WatchWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WatchWidget.entitlements; sourceTree = ""; }; - FA7C40000000000000000009 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; FA7C40000000000000000007 /* WatchWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WatchWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; FA7C40000000000000000008 /* StatusWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = StatusWidget.entitlements; sourceTree = ""; }; + FA7C40000000000000000009 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -262,6 +262,15 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 5B1A0004200001000000ISH0 /* ish */ = { + isa = PBXGroup; + children = ( + 5B1A0003200001000000ISH0 /* sbm_ish.h */, + 5B1A0001200001000000ISH0 /* sbm_ish.c */, + ); + path = ish; + sourceTree = ""; + }; 7D922B9C06363731246B6815 /* Pods */ = { isa = PBXGroup; children = ( @@ -360,17 +369,6 @@ path = StatusWidget; sourceTree = ""; }; - FA7C40000000000000000010 /* WatchWidget */ = { - isa = PBXGroup; - children = ( - FA7C40000000000000000004 /* WatchStatusWidget.swift */, - FA7C40000000000000000005 /* WatchStatusWidgetBundle.swift */, - FA7C40000000000000000009 /* Info.plist */, - FA7C40000000000000000006 /* WatchWidget.entitlements */, - ); - path = WatchWidget; - sourceTree = ""; - }; E39515C82AB5AD62003602C1 /* WatchApp */ = { isa = PBXGroup; children = ( @@ -387,13 +385,15 @@ path = WatchApp; sourceTree = ""; }; - 5B1A0004200001000000ISH0 /* ish */ = { + FA7C40000000000000000010 /* WatchWidget */ = { isa = PBXGroup; children = ( - 5B1A0003200001000000ISH0 /* sbm_ish.h */, - 5B1A0001200001000000ISH0 /* sbm_ish.c */, + FA7C40000000000000000004 /* WatchStatusWidget.swift */, + FA7C40000000000000000005 /* WatchStatusWidgetBundle.swift */, + FA7C40000000000000000009 /* Info.plist */, + FA7C40000000000000000006 /* WatchWidget.entitlements */, ); - path = ish; + path = WatchWidget; sourceTree = ""; }; /* End PBXGroup section */ @@ -413,7 +413,6 @@ E39515D52AB5AD64003602C1 /* Embed Watch Content */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, F3A15C782F6E1D8B00A1C001 /* Generate Missing Framework dSYMs */, - D910A6AD158B759A8CE71D99 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -638,23 +637,6 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; - D910A6AD158B759A8CE71D99 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; F3A15C782F6E1D8B00A1C001 /* Generate Missing Framework dSYMs */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -1086,6 +1068,7 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = StatusWidget/StatusWidget.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1491; DEVELOPMENT_TEAM = BA88US33G6; @@ -1103,7 +1086,6 @@ MARKETING_VERSION = 1.0.1491; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; - CODE_SIGN_ENTITLEMENTS = StatusWidget/StatusWidget.entitlements; PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -1126,6 +1108,7 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = StatusWidget/StatusWidget.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1491; DEVELOPMENT_TEAM = BA88US33G6; @@ -1142,7 +1125,6 @@ ); MARKETING_VERSION = 1.0.1491; MTL_FAST_MATH = YES; - CODE_SIGN_ENTITLEMENTS = StatusWidget/StatusWidget.entitlements; PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -1163,6 +1145,7 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = StatusWidget/StatusWidget.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1491; DEVELOPMENT_TEAM = BA88US33G6; @@ -1179,7 +1162,6 @@ ); MARKETING_VERSION = 1.0.1491; MTL_FAST_MATH = YES; - CODE_SIGN_ENTITLEMENTS = StatusWidget/StatusWidget.entitlements; PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -1200,6 +1182,7 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = WatchApp/WatchApp.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1491; DEVELOPMENT_ASSET_PATHS = ""; @@ -1216,7 +1199,6 @@ MARKETING_VERSION = 1.0.1491; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; - CODE_SIGN_ENTITLEMENTS = WatchApp/WatchApp.entitlements; PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.WatchEnd; PRODUCT_NAME = ServerBox; SDKROOT = watchos; @@ -1242,6 +1224,7 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = WatchApp/WatchApp.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1491; DEVELOPMENT_ASSET_PATHS = ""; @@ -1257,7 +1240,6 @@ ); MARKETING_VERSION = 1.0.1491; MTL_FAST_MATH = YES; - CODE_SIGN_ENTITLEMENTS = WatchApp/WatchApp.entitlements; PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.WatchEnd; PRODUCT_NAME = ServerBox; SDKROOT = watchos; @@ -1281,6 +1263,7 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = WatchApp/WatchApp.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1491; DEVELOPMENT_ASSET_PATHS = ""; @@ -1296,7 +1279,6 @@ ); MARKETING_VERSION = 1.0.1491; MTL_FAST_MATH = YES; - CODE_SIGN_ENTITLEMENTS = WatchApp/WatchApp.entitlements; PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.WatchEnd; PRODUCT_NAME = ServerBox; SDKROOT = watchos; @@ -1335,19 +1317,19 @@ "@executable_path/../../Frameworks", ); MARKETING_VERSION = 1.0.1466; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.WatchEnd.WatchWidget; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "watchsimulator watchos"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 4; WATCHOS_DEPLOYMENT_TARGET = 9.0; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; name = Debug; }; diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index d9545c0db..1d8b825d6 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -12,7 +12,6 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST - flutter_pty jni ) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 0b627fc41..8b57715e4 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -1,20 +1,14 @@ PODS: - - flutter_pty (0.0.1): - - FlutterMacOS - FlutterMacOS (1.0.0) DEPENDENCIES: - - flutter_pty (from `Flutter/ephemeral/.symlinks/plugins/flutter_pty/macos`) - FlutterMacOS (from `Flutter/ephemeral`) EXTERNAL SOURCES: - flutter_pty: - :path: Flutter/ephemeral/.symlinks/plugins/flutter_pty/macos FlutterMacOS: :path: Flutter/ephemeral SPEC CHECKSUMS: - flutter_pty: 1e360feaf8b1a213d2d50da3c076005b8eef1eee FlutterMacOS: c232990155153907050900a2e175c7773903ba4e PODFILE CHECKSUM: 2190e15ede1572f0b104dd8ad4ae165b26ca5bfa diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 992680647..20fc01a34 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -218,7 +218,6 @@ 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, A1B2C3D4E5F60718293A4B5C /* Fix Objective-C Framework Resources */, - D031AF8C21824AF8F0134BDE /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -381,23 +380,6 @@ shellPath = /bin/sh; shellScript = "FRAMEWORK=\"$TARGET_BUILD_DIR/$FRAMEWORKS_FOLDER_PATH/objective_c.framework\"\nif [ -d \"$FRAMEWORK/Versions\" ]; then\n if [ ! -L \"$FRAMEWORK/Versions/Current\" ]; then\n (cd \"$FRAMEWORK/Versions\" && ln -sf A Current)\n fi\n rm -f \"$FRAMEWORK/Resources\"\n ln -sf Versions/Current/Resources \"$FRAMEWORK/Resources\"\nfi\n"; }; - D031AF8C21824AF8F0134BDE /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ diff --git a/packages/flutter_pty b/packages/flutter_pty new file mode 160000 index 000000000..792a46a73 --- /dev/null +++ b/packages/flutter_pty @@ -0,0 +1 @@ +Subproject commit 792a46a738b689a208163909a2ffa902d07d22ad diff --git a/pubspec.lock b/pubspec.lock index e806e69cd..5b94beae2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -584,10 +584,9 @@ packages: flutter_pty: dependency: "direct main" description: - name: flutter_pty - sha256: c2f3b3160b519ac820fa3f6ef175361f2dfc52c557465643589542e9f229ad66 - url: "https://pub.dev" - source: hosted + path: "packages/flutter_pty" + relative: true + source: path version: "0.4.2" flutter_riverpod: dependency: "direct main" diff --git a/pubspec.yaml b/pubspec.yaml index 667d3badf..ec890f4be 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -24,7 +24,6 @@ dependencies: flutter_highlight: ^0.7.0 flutter_displaymode: ^0.7.0 flutter_markdown_plus: ^1.0.12 - flutter_pty: ^0.4.2 flutter_svg: ^2.2.1 fl_chart: ^1.2.0 freezed_annotation: ^3.0.0 @@ -66,6 +65,10 @@ dependencies: path: packages/plain_notification_token fl_lib: path: packages/fl_lib + # Forked to swap five per-platform build integrations for one build hook, + # which is what takes the last pod out of the iOS and macOS builds. + flutter_pty: + path: packages/flutter_pty flutter_rust_bridge: 2.13.0-beta.6 # Drives the build hook below. `sbm_ffi` is no longer a Flutter plugin — the # hook compiles the crate directly, so there is nothing to depend on. diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 206b92827..e734d0049 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -14,7 +14,6 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST - flutter_pty jni ) From b4c91514fc012a07c52b552e09d91d4ca7ea8ee4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Wed, 19 Aug 2026 02:21:10 +0800 Subject: [PATCH 07/16] migrate(ios,macos): deintegrate CocoaPods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing needed it any more. Both Podfiles were the stock Flutter template — no pods of their own, no custom logic beyond `flutter_additional_*_build_settings` — so with the last third-party plugin moved to a build hook there was nothing left for them to install. `pod deintegrate` in both, the `Pods-Runner` includes out of the four xcconfigs, the `Pods.xcodeproj` reference out of both workspaces, and an empty `Pods` group `pod deintegrate` left behind in the macOS project. `Podfile`, `Podfile.lock` and `Pods/` are gone. `ios/Flutter/Ish.xcconfig` is untouched and still included. Checked rather than assumed, because it is what decides whether the iOS Linux engine is linked: `xcodebuild -showBuildSettings` resolves `SBM_ISH = 1`, `SBM_ISH_ENABLED=1` and all three engine archives in `OTHER_LDFLAGS`, and a build with the switch on carries `libsqlite3` and 117 engine strings. Verified: macOS debug and release build, iOS builds with the Watch app and the widget extension in the bundle, and `integration_test/local_shell_test.dart` still spawns a real shell through the PTY on macOS. The macOS workflow gains `hook/**` in its trigger paths. That file is what compiles the Rust library into the app now, and a change to it would otherwise not have built anything. --- .github/workflows/macos.yml | 10 ++-- docs/src/content/docs/development/building.md | 1 - .../content/docs/principles/architecture.md | 4 +- .../content/docs/zh/development/building.md | 1 - .../docs/zh/principles/architecture.md | 4 +- ios/Flutter/Debug.xcconfig | 1 - ios/Flutter/Release.xcconfig | 1 - ios/Podfile | 40 ------------- ios/Podfile.lock | 16 ------ ios/Runner.xcodeproj/project.pbxproj | 42 -------------- .../contents.xcworkspacedata | 3 - macos/Flutter/Flutter-Debug.xcconfig | 1 - macos/Flutter/Flutter-Release.xcconfig | 1 - macos/Podfile | 43 -------------- macos/Podfile.lock | 16 ------ macos/Runner.xcodeproj/project.pbxproj | 57 ------------------- .../contents.xcworkspacedata | 3 - 17 files changed, 10 insertions(+), 234 deletions(-) delete mode 100644 ios/Podfile delete mode 100644 ios/Podfile.lock delete mode 100644 macos/Podfile delete mode 100644 macos/Podfile.lock diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index dc845e4e7..a240553a9 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -34,14 +34,16 @@ on: push: branches: [ "main" ] paths: &macos_paths - # The Xcode project, the entitlements, the Podfile + # The Xcode project and the entitlements - 'macos/**' - # cargokit builds and links these into the app; nothing else in CI links - # the Rust library on macOS + # `hook/build.dart` is what compiles the Rust library into the app now, + # and nothing else in CI links it on macOS + - 'hook/**' - 'crates/**' - 'Cargo.toml' - 'Cargo.lock' - # A plugin added or moved changes what pod install resolves and links + # A dependency added or moved changes what the build hooks produce and + # what gets bundled - 'pubspec.yaml' - 'pubspec.lock' # The vendored packages are submodules, so a gitlink move here is a diff --git a/docs/src/content/docs/development/building.md b/docs/src/content/docs/development/building.md index 37f0f800b..1ba077581 100644 --- a/docs/src/content/docs/development/building.md +++ b/docs/src/content/docs/development/building.md @@ -48,7 +48,6 @@ dart run fl_build -p ios Requires: - macOS with Xcode -- CocoaPods - Apple Developer account for signing ### Android diff --git a/docs/src/content/docs/principles/architecture.md b/docs/src/content/docs/principles/architecture.md index 121647e11..65c4c3da3 100644 --- a/docs/src/content/docs/principles/architecture.md +++ b/docs/src/content/docs/principles/architecture.md @@ -132,9 +132,9 @@ Flutter plugins provide platform integration: | Platform | Integration Method | |----------|-------------------| -| iOS | CocoaPods, Swift/Obj-C | +| iOS | Swift Package Manager, Swift/Obj-C | | Android | Gradle, Kotlin/Java | -| macOS | CocoaPods, Swift | +| macOS | Swift Package Manager, Swift | | Linux | CMake, C++ | | Windows | CMake, C++ | diff --git a/docs/src/content/docs/zh/development/building.md b/docs/src/content/docs/zh/development/building.md index 4cd197db3..0ecf45211 100644 --- a/docs/src/content/docs/zh/development/building.md +++ b/docs/src/content/docs/zh/development/building.md @@ -47,7 +47,6 @@ dart run fl_build -p ios 需要: - 安装了 Xcode 的 macOS -- CocoaPods - 用于签名的 Apple Developer 账号 ### Android diff --git a/docs/src/content/docs/zh/principles/architecture.md b/docs/src/content/docs/zh/principles/architecture.md index 19835d11a..acbd02914 100644 --- a/docs/src/content/docs/zh/principles/architecture.md +++ b/docs/src/content/docs/zh/principles/architecture.md @@ -110,9 +110,9 @@ Flutter 插件提供平台集成: | 平台 | 集成方式 | |----------|-------------------| -| iOS | CocoaPods, Swift/Obj-C | +| iOS | Swift Package Manager, Swift/Obj-C | | Android | Gradle, Kotlin/Java | -| macOS | CocoaPods, Swift | +| macOS | Swift Package Manager, Swift | | Linux | CMake, C++ | | Windows | CMake, C++ | diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig index b2f378926..23e8b084b 100644 --- a/ios/Flutter/Debug.xcconfig +++ b/ios/Flutter/Debug.xcconfig @@ -1,3 +1,2 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" #include "Ish.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig index f4d23c9b1..23e8b084b 100644 --- a/ios/Flutter/Release.xcconfig +++ b/ios/Flutter/Release.xcconfig @@ -1,3 +1,2 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" #include "Ish.xcconfig" diff --git a/ios/Podfile b/ios/Podfile deleted file mode 100644 index 573136610..000000000 --- a/ios/Podfile +++ /dev/null @@ -1,40 +0,0 @@ -# Uncomment this line to define a global platform for your project -# platform :ios, '15.0' - -# CocoaPods analytics sends network stats synchronously affecting flutter build latency. -ENV['COCOAPODS_DISABLE_STATS'] = 'true' - -project 'Runner', { - 'Debug' => :debug, - 'Profile' => :release, - 'Release' => :release, -} - -def flutter_root - generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) - unless File.exist?(generated_xcode_build_settings_path) - raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" - end - - File.foreach(generated_xcode_build_settings_path) do |line| - matches = line.match(/FLUTTER_ROOT\=(.*)/) - return matches[1].strip if matches - end - raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" -end - -require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) - -flutter_ios_podfile_setup - -target 'Runner' do - use_frameworks! - - flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) -end - -post_install do |installer| - installer.pods_project.targets.each do |target| - flutter_additional_ios_build_settings(target) - end -end diff --git a/ios/Podfile.lock b/ios/Podfile.lock deleted file mode 100644 index a8cd270ad..000000000 --- a/ios/Podfile.lock +++ /dev/null @@ -1,16 +0,0 @@ -PODS: - - Flutter (1.0.0) - -DEPENDENCIES: - - Flutter (from `Flutter`) - -EXTERNAL SOURCES: - Flutter: - :path: Flutter - -SPEC CHECKSUMS: - Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47 - -PODFILE CHECKSUM: 033fcc5039cba02092526aeac31fc4c94faa91b1 - -COCOAPODS: 1.17.0 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 3e7a9ab16..40bd9071e 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -21,7 +21,6 @@ 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; - DEB04D99AB3357C5060135CC /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8181E2FE60383BB01C8D74CB /* Pods_Runner.framework */; }; E33A3E372A626DCD009744AB /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E33A3E362A626DCD009744AB /* WidgetKit.framework */; }; E33A3E392A626DCD009744AB /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E33A3E382A626DCD009744AB /* SwiftUI.framework */; }; E33A3E3C2A626DCE009744AB /* StatusWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E33A3E3B2A626DCE009744AB /* StatusWidgetBundle.swift */; }; @@ -125,7 +124,6 @@ /* Begin PBXFileReference section */ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; - 2026F2298051173C0B212E05 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 4A2DCD692E4B127100CF68B7 /* LiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = ""; }; 4A2DCD6A2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalLiveActivityAttributes.swift; sourceTree = ""; }; @@ -133,7 +131,6 @@ 4A2DCD6E2E4B128100CF68B7 /* TerminalLiveActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalLiveActivityAttributes.swift; sourceTree = ""; }; 5B1A0001200001000000ISH0 /* sbm_ish.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = sbm_ish.c; sourceTree = ""; }; 5B1A0003200001000000ISH0 /* sbm_ish.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = sbm_ish.h; sourceTree = ""; }; - 668FBB3ED2E093A4068CAF18 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7538AEC22BB83FAB002AB82A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; @@ -141,8 +138,6 @@ 7538AEC62BB83FD3002AB82A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; - 8181E2FE60383BB01C8D74CB /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 92ED9819CC2FDBA815166FC2 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -232,7 +227,6 @@ buildActionMask = 2147483647; files = ( 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, - DEB04D99AB3357C5060135CC /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -271,17 +265,6 @@ path = ish; sourceTree = ""; }; - 7D922B9C06363731246B6815 /* Pods */ = { - isa = PBXGroup; - children = ( - 92ED9819CC2FDBA815166FC2 /* Pods-Runner.debug.xcconfig */, - 668FBB3ED2E093A4068CAF18 /* Pods-Runner.release.xcconfig */, - 2026F2298051173C0B212E05 /* Pods-Runner.profile.xcconfig */, - ); - name = Pods; - path = Pods; - sourceTree = ""; - }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -304,7 +287,6 @@ FA7C40000000000000000010 /* WatchWidget */, 97C146EF1CF9000F007C117D /* Products */, D242A20E381A343934B6A7B6 /* Frameworks */, - 7D922B9C06363731246B6815 /* Pods */, ); sourceTree = ""; }; @@ -348,7 +330,6 @@ children = ( E33A3E362A626DCD009744AB /* WidgetKit.framework */, E33A3E382A626DCD009744AB /* SwiftUI.framework */, - 8181E2FE60383BB01C8D74CB /* Pods_Runner.framework */, ); name = Frameworks; sourceTree = ""; @@ -403,7 +384,6 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( - 63F6DF79DA2F47E8D2660970 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, @@ -600,28 +580,6 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; - 63F6DF79DA2F47E8D2660970 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata index 21a3cc14c..1d526a16e 100644 --- a/ios/Runner.xcworkspace/contents.xcworkspacedata +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -4,7 +4,4 @@ - - diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig index 4b81f9b2d..c2efd0b60 100644 --- a/macos/Flutter/Flutter-Debug.xcconfig +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -1,2 +1 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig index 5caa9d157..c2efd0b60 100644 --- a/macos/Flutter/Flutter-Release.xcconfig +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -1,2 +1 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Podfile b/macos/Podfile deleted file mode 100644 index 165b114a4..000000000 --- a/macos/Podfile +++ /dev/null @@ -1,43 +0,0 @@ -platform :osx, '12.0' - -# CocoaPods analytics sends network stats synchronously affecting flutter build latency. -ENV['COCOAPODS_DISABLE_STATS'] = 'true' - -project 'Runner', { - 'Debug' => :debug, - 'Profile' => :release, - 'Release' => :release, -} - -def flutter_root - generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) - unless File.exist?(generated_xcode_build_settings_path) - raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" - end - - File.foreach(generated_xcode_build_settings_path) do |line| - matches = line.match(/FLUTTER_ROOT\=(.*)/) - return matches[1].strip if matches - end - raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" -end - -require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) - -flutter_macos_podfile_setup - -target 'Runner' do - use_frameworks! - use_modular_headers! - - flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) - # target 'RunnerTests' do - # inherit! :search_paths - # end -end - -post_install do |installer| - installer.pods_project.targets.each do |target| - flutter_additional_macos_build_settings(target) - end -end diff --git a/macos/Podfile.lock b/macos/Podfile.lock deleted file mode 100644 index 8b57715e4..000000000 --- a/macos/Podfile.lock +++ /dev/null @@ -1,16 +0,0 @@ -PODS: - - FlutterMacOS (1.0.0) - -DEPENDENCIES: - - FlutterMacOS (from `Flutter/ephemeral`) - -EXTERNAL SOURCES: - FlutterMacOS: - :path: Flutter/ephemeral - -SPEC CHECKSUMS: - FlutterMacOS: c232990155153907050900a2e175c7773903ba4e - -PODFILE CHECKSUM: 2190e15ede1572f0b104dd8ad4ae165b26ca5bfa - -COCOAPODS: 1.17.0 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 20fc01a34..13dad5d8c 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -27,7 +27,6 @@ 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; - CB890EE4310DAC9F20C4B22D /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B4428BFC11DD7EDA01918FBB /* Pods_Runner.framework */; }; E372ECA42BB94B360078B9D4 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = E372ECA32BB94B360078B9D4 /* PrivacyInfo.xcprivacy */; }; /* End PBXBuildFile section */ @@ -55,8 +54,6 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 15B02E741770D3594400C89E /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; - 18C5D7F664081E15D4F73847 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; @@ -72,15 +69,9 @@ 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; - 61D014A5BEAA3F969F06061C /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; - 7E4B8E0435A8CD429FA930EA /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; - 97047891D5556990B2D8BDF7 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; - B4428BFC11DD7EDA01918FBB /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - BFB2C4CAFA221419C2D3A5FB /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; - C80488AEAD11AE69952819A3 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; E372ECA32BB94B360078B9D4 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; E3D26BD42B99689700D83425 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/MainMenu.strings"; sourceTree = ""; }; E3D26BD52B99689B00D83425 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/MainMenu.strings"; sourceTree = ""; }; @@ -99,7 +90,6 @@ buildActionMask = 2147483647; files = ( 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, - CB890EE4310DAC9F20C4B22D /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -132,8 +122,6 @@ 33CEB47122A05771004F2AC0 /* Flutter */, 331C80D6294CF71000263BE5 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, - D73912EC22F37F3D000D13A0 /* Frameworks */, - B8A24A574E1520AC66526B2B /* Pods */, ); sourceTree = ""; }; @@ -182,28 +170,6 @@ path = Runner; sourceTree = ""; }; - B8A24A574E1520AC66526B2B /* Pods */ = { - isa = PBXGroup; - children = ( - 18C5D7F664081E15D4F73847 /* Pods-Runner.debug.xcconfig */, - C80488AEAD11AE69952819A3 /* Pods-Runner.release.xcconfig */, - BFB2C4CAFA221419C2D3A5FB /* Pods-Runner.profile.xcconfig */, - 7E4B8E0435A8CD429FA930EA /* Pods-RunnerTests.debug.xcconfig */, - 97047891D5556990B2D8BDF7 /* Pods-RunnerTests.release.xcconfig */, - 15B02E741770D3594400C89E /* Pods-RunnerTests.profile.xcconfig */, - ); - path = Pods; - sourceTree = ""; - }; - D73912EC22F37F3D000D13A0 /* Frameworks */ = { - isa = PBXGroup; - children = ( - B4428BFC11DD7EDA01918FBB /* Pods_Runner.framework */, - 61D014A5BEAA3F969F06061C /* Pods_RunnerTests.framework */, - ); - name = Frameworks; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -211,7 +177,6 @@ isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( - 906584B20D0BBB0E924799BF /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, @@ -340,28 +305,6 @@ shellPath = /bin/sh; shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; }; - 906584B20D0BBB0E924799BF /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; A1B2C3D4E5F60718293A4B5C /* Fix Objective-C Framework Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata index 21a3cc14c..1d526a16e 100644 --- a/macos/Runner.xcworkspace/contents.xcworkspacedata +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -4,7 +4,4 @@ - - From 0e87d1c89a904a922149f5bfb0cc079189e8de00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Wed, 19 Aug 2026 02:21:30 +0800 Subject: [PATCH 08/16] docs: record that CocoaPods is gone, and how to check the ish switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The symbol and `otool -L` checks in `Ish.xcconfig`'s own comments are written for a release build. Pointed at a debug build they read as "engine not linked", because the app code is in `Runner.debug.dylib` there and `Runner` is a stub — which is exactly the wrong answer to get about the switch that exists for App Store review. --- CLAUDE.md | 3 ++- TODOS.md | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e0fbe1c1c..cec9b683b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,7 +44,8 @@ A `Makefile` wraps most common tasks — run `make help` for the full list. Pref - `flutter_rust_bridge` is pinned to `2.13.0-beta.6` in both `pubspec.yaml` and `crates/sbm_ffi/Cargo.toml`, and the two must match or `RustLib.init` throws at startup. The native-assets backend needs `>= 2.13.0-beta.2` and 2.13.0 has no stable release yet; this is the project's only prerelease dependency. - `crates/sbm_ffi/rust-toolchain.toml` pins the channel and lists every shipped target, which `native_toolchain_rust` requires. A target missing from that list is not an error, it is a silent fallback to the host. - `packages/flutter_pty` is a fork carrying the same change, for the same reason: upstream ships no `Package.swift` and was the last third-party pod. It uses `native_toolchain_c` rather than `native_toolchain_rust`, and is otherwise identical to upstream 0.4.2. - - **No third-party pod remains.** `flutter build` now reports "All plugins found are Swift Packages, but your project still has CocoaPods integration" — deintegrating (`pod deintegrate` plus removing the `Pods-Runner` xcconfig includes) is possible but not done: the Podfile is non-standard because of the Watch app and widget extensions, and that has to be verified against those targets. + - **CocoaPods is gone from iOS and macOS.** No `Podfile`, no `Pods/`, no `Pods-Runner` include in the xcconfigs, nothing named Pods in either `project.pbxproj`. Do not add a pod back without a reason: the CocoaPods registry is read-only from 2026-12-02. + - `ios/Flutter/Ish.xcconfig` is still included from `Debug.xcconfig` and `Release.xcconfig` and is what decides whether the iOS Linux engine links. To check it end to end, `xcodebuild -project ios/Runner.xcodeproj -target Runner -configuration Debug -showBuildSettings | rg "SBM_ISH|OTHER_LDFLAGS"`. In a **debug** build the app code is in `Runner.debug.dylib`, not `Runner` — the symbol and `otool -L` checks in that file's own comments are written for a release build and read as "not linked" if pointed at the debug stub. ## Architecture diff --git a/TODOS.md b/TODOS.md index e217b2cb1..946fe5505 100644 --- a/TODOS.md +++ b/TODOS.md @@ -130,10 +130,10 @@ project still has CocoaPods integration"。 gradle plugin 和 CMake,现在改走同一个 hook,但本机没跑过。要在 CI 或对应机器上 各跑一次 `dart run fl_build -p `。 -**下一步(现在才有可能做):彻底 deintegrate CocoaPods。** Flutter 列出的步骤是 -`pod deintegrate` 加上从 `{ios,macos}/Flutter/*.xcconfig` 里移除 `Pods-Runner` -的 include。没做的原因:Podfile 因为 Watch app 和 widget extension 而是 -non-standard,这两个 target 得单独验过。 +**CocoaPods 已彻底移除。** `Podfile`、`Podfile.lock`、`Pods/`、xcconfig 里的 +`Pods-Runner` include、workspace 里的 `Pods.xcodeproj` 引用、两个 pbxproj 里所有 +Pods 条目都没了。原先担心的 "non-standard Podfile" 其实只是 Flutter 标准模板, +没有自定义 pod 也没有额外逻辑。Watch app 和 widget extension 都验过在 iOS 产物里。 两条注意事项: - **不要用 `flutter_rust_bridge_codegen integrate`。** 它是给新项目的脚手架: From 3320d8d40e9b6875ff66e217f0b1096930135b88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Wed, 19 Aug 2026 02:21:47 +0800 Subject: [PATCH 09/16] docs: fold the CocoaPods removal into one coherent section --- TODOS.md | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/TODOS.md b/TODOS.md index 946fe5505..3eb34a96b 100644 --- a/TODOS.md +++ b/TODOS.md @@ -106,14 +106,14 @@ relay 服务,而不是(或者除了)在本地直接对外提供面板访问;面 决定放哪个 crate/仓库、怎么部署、TLS/域名怎么搞,这和现在"每个 agent 一个 单体二进制"的假设不一样,是本仓库目前唯一的例外 -## sbm_ffi:脱离 CocoaPods,改走 Dart build hooks(已做,但只验过两个平台) +## 原生构建全部改走 Dart build hooks,CocoaPods 已移除(只验过 macOS / iOS) **状态(2026-08-19):已合入。** cargokit 五个接入点和整个 `cargokit/` 目录已删, `crates/sbm_ffi` 不再是 Flutter plugin,改由根目录 `hook/build.dart` 编译。 FRB 升到 `2.13.0-beta.6`(Rust 与 pubspec 两处)。 已验证:macOS 与 iOS 构建通过,产物里有 `sbm_ffi.framework`,两个 -`Podfile.lock` 里都只剩 `flutter_pty`。`flutter test test/frb_parser_test.dart` +平台都不再有 `Podfile`。`flutter test test/frb_parser_test.dart` 与 `cargo test --workspace` 通过。 `flutter_pty` 也一起做了:`packages/flutter_pty` 是 @@ -122,10 +122,6 @@ fork,把同样的五个平台构建集成换成一个 `hook/build.dart`(用 `nat 上游最后一个版本是 0.4.2(2025-01),没有 `Package.swift`,唯一那个 SwiftPM PR (#21)只做了 macOS 且无人回应。 -**结果:两个 `Podfile.lock` 里第三方 pod 归零**,只剩 Flutter 自身。 -`flutter build` 现在会提示 "All plugins found are Swift Packages, but your -project still has CocoaPods integration"。 - **未验证:Android / Linux / Windows。** 这三个平台原先分别走 cargokit 的 gradle plugin 和 CMake,现在改走同一个 hook,但本机没跑过。要在 CI 或对应机器上 各跑一次 `dart run fl_build -p `。 From 6c571690a33ca72d3e9fee213536e4ebf3c85425 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Wed, 19 Aug 2026 02:43:15 +0800 Subject: [PATCH 10/16] fix(store): the crash on cold launch, and six more from review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Launch.** `Stores.init` batched `connectionStats.init()` and `agentConversation.init()` — which create their tables, so they reach the database synchronously — with the K-V stores that were still opening the file. A `Future.wait` invokes every element before awaiting any, so those two hit a null database and threw on every cold launch. Opening is now an explicit first step. No test caught it because every suite called `SqliteDb.openInMemory()` in `setUp`, which makes `init` return at its `isOpen` guard — `test/stores_init_test.dart` takes the path a launch takes. **Sandbox import was blind to `store.db`.** Its predicates matched `.hive` and `app.db` (a name that appears nowhere else in the repo). So "this install already has data" and "the container has data" were both decided on files that are on their way out, and `undo()` did not delete the database — main.dart's recovery reopened the very file that had just failed to open, this time outside the `try`. **`schemaVersion` travelled in backups.** `getAllMap` excludes only internal keys and this was a plain one, contrary to what schema.dart claims about it. Restoring a backup from a device still on the previous release wrote v3 back, and the next launch found no migration registered for it and threw a `StateError` nothing catches. It is an internal key now, and `removeRetiredKeys` drops the plain copy a Hive import brings across. **`get` always returned null** at four call sites: values come back as decoded JSON and no `fromObj` was passed, so the Watch payload, the iOS accessory widget URL, the accessory server picker and the jump-id rewrite in `migrateIds` all silently resolved nothing. The other stores were converted to `fetchOneRaw`; these were missed. **The Hive import** detected legacy data by `_enc.hive` alone, so an install predating box encryption — which has only the plain files, and which `HiveStore.init` handles perfectly well — read as a fresh install and lost everything. It also wrote the "done" marker even when every box had failed to open, which a briefly unavailable keychain will do, leaving an empty app that never retries. And it had no per-record guard, so one undecodable value failed the launch permanently. Each record is caught now, as the v2 -> v3 migration it replaced did for the same stated reason. **Restores and imports run in one transaction** rather than a commit per key. Also: the raw settings editor now clears a key set to `null` instead of silently keeping the old value; deleting an agent conversation notifies exactly once on each path, where it notified twice when promoting a replacement and not at all otherwise; `connection_stats` gains an index on `timestamp` and sweeps by age at init rather than scanning the whole table on every connection attempt; and `CachedSqliteStore._loadAll` reads its store in one query. `.gitmodules` points `flutter_pty` at HTTPS like the other eight. An SSH URL breaks anonymous clones and `actions/checkout`, and since the package moved to a `path:` dependency that would have failed `flutter pub get`, not just the platform builds. --- .gitmodules | 2 +- lib/core/chan.dart | 2 +- lib/core/service/watch_sync.dart | 2 +- lib/core/utils/sandbox_import.dart | 32 +++-- lib/data/model/app/bak/backup.dart | 34 ++--- lib/data/res/store.dart | 9 +- lib/data/store/agent_conversation.dart | 19 ++- lib/data/store/cached_store.dart | 17 ++- lib/data/store/connection_stats.dart | 34 +++-- .../store/migrations/m003_hive_to_sqlite.dart | 82 ++++++++---- lib/data/store/server.dart | 2 +- lib/data/store/setting.dart | 29 ++++- lib/view/page/setting/entries/app.dart | 9 +- lib/view/page/setting/platform/ios.dart | 2 +- packages/fl_lib | 2 +- test/connection_stats_store_test.dart | 20 +-- test/sqlite_store_test.dart | 65 +++++++++- test/stores_init_test.dart | 118 ++++++++++++++++++ 18 files changed, 394 insertions(+), 86 deletions(-) create mode 100644 test/stores_init_test.dart diff --git a/.gitmodules b/.gitmodules index 7247d9d2f..84a0ba62e 100644 --- a/.gitmodules +++ b/.gitmodules @@ -32,5 +32,5 @@ branch = main [submodule "packages/flutter_pty"] path = packages/flutter_pty - url = git@github.com:lollipopkit/flutter_pty.git + url = https://github.com/lollipopkit/flutter_pty branch = main diff --git a/lib/core/chan.dart b/lib/core/chan.dart index be5c2fd5a..f546454a6 100644 --- a/lib/core/chan.dart +++ b/lib/core/chan.dart @@ -67,7 +67,7 @@ abstract final class MethodChans { static Future syncAccessoryWidgetUrl() async { if (!isIOS) return; final id = Stores.setting.accessoryWidgetServerId.fetch(); - final spi = id.isEmpty ? null : Stores.server.get(id); + final spi = id.isEmpty ? null : Stores.server.fetchOneRaw(id); await setAccessoryWidgetUrl(spi?.monitorStatusUrl); } diff --git a/lib/core/service/watch_sync.dart b/lib/core/service/watch_sync.dart index 481b60d2c..dbfd64876 100644 --- a/lib/core/service/watch_sync.dart +++ b/lib/core/service/watch_sync.dart @@ -102,7 +102,7 @@ final class WatchSync { /// What the watch app is told to display. Map buildPayload() => payloadFrom( selectedIds: Stores.setting.watchServerIds.fetch(), - lookup: (id) => Stores.server.get(id), + lookup: (id) => Stores.server.fetchOneRaw(id), // TODO: drop with `SettingStore.watchLegacyUrls`. legacyUrls: Stores.setting.watchLegacyUrls.fetch(), ); diff --git a/lib/core/utils/sandbox_import.dart b/lib/core/utils/sandbox_import.dart index b7ebe51bf..b5016383f 100644 --- a/lib/core/utils/sandbox_import.dart +++ b/lib/core/utils/sandbox_import.dart @@ -156,7 +156,7 @@ abstract final class SandboxImport { final destNames = await _names(dest) ?? const []; if (destNames.contains(doneMarker)) return SandboxImportResult.skipped; final resuming = destNames.contains(busyMarker); - if (!resuming && destNames.any(_isBox)) return SandboxImportResult.skipped; + if (!resuming && destNames.any(_isData)) return SandboxImportResult.skipped; final List srcNames; try { @@ -176,7 +176,7 @@ abstract final class SandboxImport { return SandboxImportResult.notFound; } - if (!srcNames.any(_isBox)) return SandboxImportResult.notFound; + if (!srcNames.any(_isData)) return SandboxImportResult.notFound; // Before deciding whether the boxes are readable: an old enough install // keeps the key here, and that copy is as good as the keychain's. @@ -286,7 +286,23 @@ abstract final class SandboxImport { } } - static bool _isBox(String name) => name.endsWith('.hive'); + /// Whether [name] is a file this app keeps its data in. + /// + /// Both engines, because an install can be on either: a Hive box until it has + /// launched the build that migrates, the SQLite database afterwards. Deciding + /// "this install already has data" or "the container has data" on `.hive` + /// alone stopped being true the moment anything shipped on SQLite, and + /// `_clear` skipping the database is what made the recovery in `main.dart` + /// reopen the very file that had just failed to open. + static bool _isData(String name) => + name.endsWith('.hive') || name == SqliteDb.fileName; + + /// [_isData], plus the files SQLite keeps beside the database. + /// + /// Only for deleting. A `-wal` on its own is not data, but leaving one behind + /// next to a database that was removed is worse than removing both. + static bool _isDataOrSidecar(String name) => + _isData(name) || name.startsWith('${SqliteDb.fileName}-'); static String _parentOf(String path) { final parts = path.split(Pfs.seperator)..removeLast(); @@ -301,11 +317,7 @@ abstract final class SandboxImport { final entities = await _names(dir); if (entities == null) return; for (final name in entities) { - if (!_isBox(name) && - !name.endsWith('.lock') && - !name.startsWith('app.db')) { - continue; - } + if (!_isDataOrSidecar(name) && !name.endsWith('.lock')) continue; try { await File(dir.path.joinPath(name)).delete(); } catch (e) { @@ -372,8 +384,8 @@ abstract final class SandboxImport { // database open, not to the database. Carried across it describes a // WAL index that no longer exists, and sqlite either rebuilds it or // refuses — the first is wasted, the second is a broken app. It is - // rebuilt from `app.db-wal`, which does come across. - if (name.endsWith('.db-shm')) continue; + // rebuilt from the `-wal`, which does come across. + if (name.endsWith('-shm')) continue; await entity.copy(dest.path.joinPath(name)); _copiedBytes += await entity.length(); diff --git a/lib/data/model/app/bak/backup.dart b/lib/data/model/app/bak/backup.dart index 9248fdf1b..33165d8e2 100644 --- a/lib/data/model/app/bak/backup.dart +++ b/lib/data/model/app/bak/backup.dart @@ -78,20 +78,26 @@ class Backup implements Mergeable { return; } - _restoreInto( - Stores.snippet, - {for (final s in snippets) s.name: s}, - force: force, - ); - _restoreInto(Stores.server, {for (final s in spis) s.id: s}, force: force); - _restoreInto(Stores.key, {for (final s in keys) s.id: s}, force: force); - _restoreInto(Stores.history, history, force: force); - _restoreInto(Stores.container, container, force: force); - - final settings_ = settings; - if (settings_ != null) { - _restoreInto(Stores.setting, settings_, force: force); - } + // One transaction for the whole merge. Per-key commits would leave a + // restore that was interrupted — process killed, device out of battery — + // with servers deleted whose replacements were never written, and no way to + // tell that had happened. + SqliteStore.transact(() { + _restoreInto( + Stores.snippet, + {for (final s in snippets) s.name: s}, + force: force, + ); + _restoreInto(Stores.server, {for (final s in spis) s.id: s}, force: force); + _restoreInto(Stores.key, {for (final s in keys) s.id: s}, force: force); + _restoreInto(Stores.history, history, force: force); + _restoreInto(Stores.container, container, force: force); + + final settings_ = settings; + if (settings_ != null) { + _restoreInto(Stores.setting, settings_, force: force); + } + }); Provider.reload(); RNodes.app.notify(); diff --git a/lib/data/res/store.dart b/lib/data/res/store.dart index 3e090d4b5..e8d50653d 100644 --- a/lib/data/res/store.dart +++ b/lib/data/res/store.dart @@ -62,9 +62,16 @@ abstract final class Stores { () => PortForwardStore.instance, ); + // First and on its own. `connectionStats` and `agentConversation` create + // their tables, which means reaching the database synchronously — and a + // `Future.wait` invokes every element before awaiting any of them, so + // batching them with the stores that are still opening the file would have + // them reach a database that is still null. It did, on every cold launch. + await SqliteStore.openDatabase(); + await Future.wait([ ..._kvStores.map((store) => store.init()), - // Their own tables rather than rows in `kv`, so they create those instead. + // Their own tables rather than rows in `kv`, so they create those. connectionStats.init(), agentConversation.init(), ]); diff --git a/lib/data/store/agent_conversation.dart b/lib/data/store/agent_conversation.dart index d360eb4b7..68f157057 100644 --- a/lib/data/store/agent_conversation.dart +++ b/lib/data/store/agent_conversation.dart @@ -163,6 +163,7 @@ class AgentConversationStore { return true; } + bool rename(String conversationId, String title) { final conversation = fetch(conversationId); if (conversation == null) return false; @@ -176,12 +177,18 @@ class AgentConversationStore { final conversation = fetch(conversationId); if (conversation == null || conversation.serverId != serverId) return; _db.execute('DELETE FROM $_conv WHERE id = ?;', [conversationId]); - if (activeConversationId(serverId) != conversationId) return; - final remaining = fetchForServer(serverId); - if (remaining.isEmpty) { - _db.execute('DELETE FROM $_active WHERE server_id = ?;', [serverId]); - } else { - setActive(serverId, remaining.first.id); + + // Exactly once, whichever way this returns. Deleting a conversation that + // was not the active one used to return before notifying at all, leaving + // the list showing a row that is gone; promoting a replacement notified + // twice, because `setActive` notifies too. + if (activeConversationId(serverId) == conversationId) { + final remaining = fetchForServer(serverId); + if (remaining.isEmpty) { + _db.execute('DELETE FROM $_active WHERE server_id = ?;', [serverId]); + } else { + _setActiveRow(serverId, remaining.first.id); + } } _changes.add(null); } diff --git a/lib/data/store/cached_store.dart b/lib/data/store/cached_store.dart index 84e84c302..63644ef1a 100644 --- a/lib/data/store/cached_store.dart +++ b/lib/data/store/cached_store.dart @@ -62,11 +62,22 @@ abstract class CachedSqliteStore extends SqliteStore { /// cache everyone else reads. List fetch() => List.from(_cache ??= _loadAll()); + /// One query, not one per key. + /// + /// Under Hive `box.get` was a map lookup, so reading each key in turn was + /// free; each is a prepared-statement round trip now, and this runs on every + /// cache miss — which is every write, since the write methods drop the cache. List _loadAll() { final result = []; - for (final key in keys()) { - final item = fetchOneRaw(key); - if (item != null) result.add(item); + for (final entry in getAllMap().entries) { + final raw = entry.value; + if (raw is! Map) continue; + try { + final item = fromJson(Map.from(raw)); + if (item != null) result.add(item); + } catch (e) { + dprint('Parsing $T from JSON', e); + } } return result; } diff --git a/lib/data/store/connection_stats.dart b/lib/data/store/connection_stats.dart index 9bb2c5889..3ab0c65e1 100644 --- a/lib/data/store/connection_stats.dart +++ b/lib/data/store/connection_stats.dart @@ -39,12 +39,19 @@ CREATE TABLE IF NOT EXISTS conn_stat ( duration_ms INTEGER NOT NULL ) WITHOUT ROWID; '''); - // Every read is "this server, newest first", and the pruning below is the - // same order with a LIMIT. + // Every read is "this server, newest first", and the per-server cap below + // is the same order with a LIMIT. _db.execute( 'CREATE INDEX IF NOT EXISTS idx_conn_stat_server_ts ' 'ON conn_stat(server_id, timestamp DESC);', ); + // The age sweep asks about `timestamp` alone, which the index above cannot + // serve — its leading column is `server_id`. + _db.execute( + 'CREATE INDEX IF NOT EXISTS idx_conn_stat_ts ON conn_stat(timestamp);', + ); + + _expire(); } Future recordConnection(ConnectionStat stat) async { @@ -127,15 +134,11 @@ CREATE TABLE IF NOT EXISTS conn_stat ( _db.execute('DELETE FROM conn_stat WHERE server_id = ?;', [serverId]); } - /// Drops what is past either limit. + /// Drops everything past the per-server cap. /// - /// Both bounds in one place, run on insert. The K-V version could only apply - /// the age bound during a full rebuild at launch, so a long-running app kept - /// expired rows until it was restarted. + /// Served by `idx_conn_stat_server_ts`, and bounded by one server's rows. + /// Cheap enough to run on every insert, which is how the cap stays exact. void _prune(String serverId) { - _db.execute('DELETE FROM conn_stat WHERE timestamp < ?;', [ - DateTime.now().subtract(_retention).millisecondsSinceEpoch, - ]); _db.execute( 'DELETE FROM conn_stat WHERE server_id = ? AND id NOT IN (' ' SELECT id FROM conn_stat WHERE server_id = ? ' @@ -145,6 +148,19 @@ CREATE TABLE IF NOT EXISTS conn_stat ( ); } + /// Drops everything past the age bound. + /// + /// At [init], not on every insert. Recording a connection happens on every + /// attempt against every server — the status page refreshes on a timer — and + /// the common case is that nothing has expired, so paying for it each time + /// bought nothing. The bound is about not keeping a month-old record for + /// ever, which a sweep per launch satisfies. + void _expire() { + _db.execute('DELETE FROM conn_stat WHERE timestamp < ?;', [ + DateTime.now().subtract(_retention).millisecondsSinceEpoch, + ]); + } + Future compact() async { Loggers.app.info('Start compacting the store database...'); try { diff --git a/lib/data/store/migrations/m003_hive_to_sqlite.dart b/lib/data/store/migrations/m003_hive_to_sqlite.dart index 16e932d3c..1332e1114 100644 --- a/lib/data/store/migrations/m003_hive_to_sqlite.dart +++ b/lib/data/store/migrations/m003_hive_to_sqlite.dart @@ -59,8 +59,17 @@ abstract final class HiveImport { // recomputed: the macOS sandbox and the mobile platforms each answer this // differently, and looking in the wrong one reads as "fresh install". final dir = await HiveStore.boxDir; + // Both names. `HiveStore.init` opens `_enc` and *then* folds an + // existing plain `.hive` into it, so an install old enough to predate + // box encryption has only the plain files — and looking for `_enc` alone + // read that device as a fresh install and dropped everything it had. + // `_importBox` goes through `HiveStore`, so it handles either. final present = _boxes.keys - .where((name) => File(dir.joinPath('${name}_enc.hive')).existsSync()) + .where( + (name) => + File(dir.joinPath('${name}_enc.hive')).existsSync() || + File(dir.joinPath('$name.hive')).existsSync(), + ) .toList(); if (present.isEmpty) { // A fresh install. Record the current layout so the migrator does not @@ -72,10 +81,25 @@ abstract final class HiveImport { Loggers.app.info('Importing ${present.length} Hive boxes into SQLite'); var copied = 0; + var failed = 0; for (final name in present) { - copied += await _importBox(name, _boxes[name]!); + final result = await _importBox(name, _boxes[name]!); + copied += result.copied; + if (!result.opened) failed++; + } + Loggers.app.info('Imported $copied rows from Hive, $failed boxes unread'); + + // Not marked done if nothing could be read. A box fails to open when the + // keychain is briefly unavailable — the device still locked at launch, on + // iOS — and writing the marker anyway would leave the user with an empty + // app and no launch that ever retries, because the marker is the first + // thing checked. Their data is still on disk; this just tries again. + if (failed == present.length && copied == 0) { + Loggers.app.warning( + 'No Hive box could be read; leaving the import to the next launch', + ); + return; } - Loggers.app.info('Imported $copied rows from Hive'); _dropPlaintextIndex(dir); @@ -86,7 +110,7 @@ abstract final class HiveImport { Stores.setting.set(_markerKey, true); } - static Future _importBox( + static Future<({bool opened, int copied})> _importBox( String name, bool Function(String, Object) into, ) async { @@ -97,28 +121,44 @@ abstract final class HiveImport { // One box that will not open must not stop the others: the alternative is // an install that keeps all of its data and can reach none of it. Loggers.app.warning('Hive box "$name" did not open; skipped', e, s); - return 0; + return (opened: false, copied: 0); } var copied = 0; - for (final key in legacy.box.keys) { - if (key is! String) continue; - final raw = legacy.box.get(key); - if (raw == null) continue; - - final value = _toSpi(raw) ?? raw; - final ok = into(key, _jsonSafe(value as Object)); - if (ok) { - copied++; - } else { - Loggers.app.warning( - 'Could not import "$name/$key" (${value.runtimeType})', - ); + try { + // One transaction per box. A connection-stats box can hold thousands of + // rows, and a commit each would be thousands of durability barriers. + SqliteStore.transact(() { + for (final key in legacy.box.keys) { + if (key is! String) continue; + // Per record, because reading one goes through a `TypeAdapter`: a value + // written under a typeId this build no longer registers, or a truncated + // one, throws. Letting that escape would fail the launch — and fail it + // again on every launch after, since the marker stays unwritten. The + // v2 -> v3 migration this replaced caught per record for the same + // reason. + try { + final raw = legacy.box.get(key); + if (raw == null) continue; + + final value = _toSpi(raw) ?? raw; + final ok = into(key, _jsonSafe(value as Object)); + if (ok) { + copied++; + } else { + Loggers.app.warning( + 'Could not import "$name/$key" (${value.runtimeType})', + ); + } + } catch (e, s) { + Loggers.app.warning('Skipping unreadable record "$name/$key"', e, s); + } } + }); + } finally { + await legacy.box.close(); } - - await legacy.box.close(); - return copied; + return (opened: true, copied: copied); } /// A pre-v3 server record, nested into the current shape. diff --git a/lib/data/store/server.dart b/lib/data/store/server.dart index 2c318d24c..46215b2de 100644 --- a/lib/data/store/server.dart +++ b/lib/data/store/server.dart @@ -98,7 +98,7 @@ class ServerStore extends CachedSqliteStore { srvOrderChanged = true; } - final spi = get(newId); + final spi = fetchOneRaw(newId); if (spi != null) { final newSpi = _replaceJumpIds(spi, idMap); if (newSpi != null) { diff --git a/lib/data/store/setting.dart b/lib/data/store/setting.dart index 18edf8e13..004667d79 100644 --- a/lib/data/store/setting.dart +++ b/lib/data/store/setting.dart @@ -329,12 +329,22 @@ class SettingStore extends SqliteStore { /// flat SSH layout plus `monitorHttp`). A fresh install overwrites this with /// [SchemaVersion.current] before any migration runs. /// - /// Never stamps `lastUpdateTs`. It describes this device's storage and is - /// left out of backups, so counting a migration writing it as a user edit - /// would make a device that has only just upgraded claim the newer copy of - /// everything at the next sync. + /// An **internal** key, so `getAllMap` leaves it out of a backup and `clear` + /// leaves it alone. Under a plain key it travelled: restoring a backup taken + /// on a device still on the previous release wrote that device's version + /// back, and the next launch found a version with no migration registered for + /// it and threw `SchemaTooNewException`'s counterpart — a `StateError` that + /// nothing catches. + /// + /// Being internal also means it never stamps `lastUpdateTs`, which it must + /// not: it describes this device's storage, so counting a migration writing + /// it as a user edit would make a device that has only just upgraded claim + /// the newer copy of everything at the next sync. + /// + /// TODO: drop `schemaVersion` from `removeRetiredKeys` once no install can + /// still carry the plain-key copy this replaced. late final schemaVersion = propertyDefault( - 'schemaVersion', + '${StoreDefaults.prefixKey}schemaVersion', 2, updateLastModified: false, ); @@ -483,7 +493,14 @@ class SettingStore extends SqliteStore { /// installs are cleaned without another migration flag becoming permanent /// state of its own. Future removeRetiredKeys() async { - for (final key in const ['moveOutServerTabFuncBtns', 'forceSinglePane']) { + for (final key in const [ + 'moveOutServerTabFuncBtns', + 'forceSinglePane', + // The plain-key schema version. It moved to an internal key so that a + // backup stops carrying it; this drops the copy a Hive import brought + // across, which nothing reads and a backup would still export. + 'schemaVersion', + ]) { remove(key, updateLastUpdateTsOnRemove: false); } } diff --git a/lib/view/page/setting/entries/app.dart b/lib/view/page/setting/entries/app.dart index 1d8c4ddae..e4dc19151 100644 --- a/lib/view/page/setting/entries/app.dart +++ b/lib/view/page/setting/entries/app.dart @@ -508,7 +508,14 @@ extension _App on _AppSettingsPageState { } else { for (final entry in newSettings.entries) { final value = entry.value; - if (value == null) continue; + // A key set to null means "clear this". Skipping it instead left + // the previous value in place, and the key being present kept it + // out of `removedKeys` below too — so the edit reported success and + // changed nothing. + if (value == null) { + Stores.setting.remove(entry.key, updateLastUpdateTsOnRemove: false); + continue; + } Stores.setting.set( entry.key, value as Object, diff --git a/lib/view/page/setting/platform/ios.dart b/lib/view/page/setting/platform/ios.dart index da5435083..8e8aad788 100644 --- a/lib/view/page/setting/platform/ios.dart +++ b/lib/view/page/setting/platform/ios.dart @@ -168,7 +168,7 @@ extension _Actions on _IosSettingsPageState { Spi? get _accessoryServer { final id = Stores.setting.accessoryWidgetServerId.fetch(); - return id.isEmpty ? null : Stores.server.get(id); + return id.isEmpty ? null : Stores.server.fetchOneRaw(id); } void _onTapAccessoryWidgetServer() async { diff --git a/packages/fl_lib b/packages/fl_lib index 3903e04ff..e7bba5aab 160000 --- a/packages/fl_lib +++ b/packages/fl_lib @@ -1 +1 @@ -Subproject commit 3903e04ff03ef324b953f7d73e60f4efeb1efe01 +Subproject commit e7bba5aab06d635243726d93da190750c213dec4 diff --git a/test/connection_stats_store_test.dart b/test/connection_stats_store_test.dart index bf8e56afe..5974e67d4 100644 --- a/test/connection_stats_store_test.dart +++ b/test/connection_stats_store_test.dart @@ -89,18 +89,22 @@ void main() { expect(store.getConnectionHistory('b').length, 1); }); - test('anything older than 30 days is dropped as it is written', () async { - // The age bound runs on every write, so an already-expired record does not - // survive its own insert. The K-V version could only apply this during a - // full rebuild at launch, so a long-running app kept expired rows until it - // was restarted. + test('anything older than 30 days is swept at init', () async { + // The age bound is not applied per write: recording happens on every + // connection attempt against every server, and paying for a whole-table + // sweep each time bought nothing in the common case where nothing has + // expired. It runs once per launch instead. await store.recordConnection( stat('a', at: DateTime.now().subtract(const Duration(days: 31))), ); - expect(store.getConnectionHistory('a'), isEmpty); - await store.recordConnection(stat('a', at: base)); - expect(store.getConnectionHistory('a'), hasLength(1)); + expect(store.getConnectionHistory('a'), hasLength(2)); + + await store.init(); + + final kept = store.getConnectionHistory('a'); + expect(kept, hasLength(1)); + expect(kept.single.timestamp, base); }); test('recording the same attempt twice does not double it', () async { diff --git a/test/sqlite_store_test.dart b/test/sqlite_store_test.dart index b49613d10..2db6e68b7 100644 --- a/test/sqlite_store_test.dart +++ b/test/sqlite_store_test.dart @@ -265,6 +265,68 @@ void main() { expect(json.decode(raw), {'x': 1}); }); }); + + group('SqliteStore: what the review found', () { + late SqliteStore store; + + setUp(() { + SqliteDb.openInMemory(); + store = SqliteStore('r'); + }); + tearDown(SqliteDb.close); + + test('clear keeps internal keys, so a migration marker survives', () { + const marker = '${StoreDefaults.prefixKey}someMigrationDone'; + store.set(marker, true); + store.set('user-data', 1); + + store.clear(); + + expect(store.get('user-data'), isNull); + expect( + store.get(marker), + isTrue, + reason: 'a "delete all settings" that erases "this migration already ' + 'ran" makes it run again over whatever replaced the data', + ); + }); + + test('transact commits as one unit', () { + SqliteStore.transact(() { + store.set('a', 1); + store.set('b', 2); + }); + expect(store.get('a'), 1); + expect(store.get('b'), 2); + }); + + test('a throw inside transact rolls the whole thing back', () { + store.set('before', 0); + expect( + () => SqliteStore.transact(() { + store.set('a', 1); + throw StateError('interrupted'); + }), + throwsStateError, + ); + expect(store.get('a'), isNull); + expect(store.get('before'), 0); + }); + + test('getAllMap reads every key in one pass', () { + store.set('a', 1); + store.set('m', {'x': 1}); + + expect(store.getAllMap(), { + 'a': 1, + 'm': {'x': 1}, + }); + expect( + store.getAllMap(includeInternalKeys: true).keys, + contains(store.lastUpdateTsKey), + ); + }); + }); } enum _Fruit { apple, pear } @@ -276,4 +338,5 @@ class _Point { Map toJson() => {'x': x, 'y': y}; } -class _NoJson {} +class _NoJson { +} diff --git a/test/stores_init_test.dart b/test/stores_init_test.dart new file mode 100644 index 000000000..cb070e3c8 --- /dev/null +++ b/test/stores_init_test.dart @@ -0,0 +1,118 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:fl_lib/fl_lib.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:server_box/data/res/store.dart'; +import 'package:server_box/data/store/schema.dart'; +import 'package:server_box/hive/hive_registrar.g.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// `Stores.init` against a database that is not open yet. +/// +/// Every other suite calls `SqliteDb.openInMemory()` in `setUp`, which makes +/// `SqliteStore.init` return at its `isOpen` guard — so none of them exercise +/// the path a cold launch actually takes, where the file has to be opened +/// first. That gap hid a crash on every launch. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + + setUpAll(() async { + tempDir = await Directory.systemTemp.createTemp('sbm-stores-init-'); + // `late final`, so once per process. + Paths.doc = tempDir.path; + + // `HiveImport` runs inside `Stores.init` and opens boxes through + // `HiveStore`; registering twice throws, so this is once per process too. + Hive.init(tempDir.path); + Hive.registerAdapters(); + }); + + tearDownAll(() => tempDir.delete(recursive: true)); + + setUp(() async { + FlutterSecureStorage.setMockInitialValues({ + 'hivePwd': base64UrlEncode( + Uint8List.fromList( + List.generate(32, (_) => Random(7).nextInt(256)), + ), + ), + }); + SharedPreferences.setMockInitialValues({}); + await PrefStore.shared.init(); + + }); + + tearDown(() async { + await getIt.reset(); + await SqliteDb.close(); + }); + + test('it opens the database before any store reads it', () async { + // The whole test. `ConnectionStatsStore.init` and + // `AgentConversationStore.init` reach `SqliteDb.instance` synchronously to + // create their tables, so they cannot be started in the same batch as the + // K-V stores that are still opening the file. + await Stores.init(); + + expect(SqliteDb.isOpen, isTrue); + expect(SqliteDb.path, tempDir.path.joinPath(SqliteDb.fileName)); + }); + + test('every store is usable once init returns', () async { + await Stores.init(); + + Stores.setting.timeout.put(11); + expect(Stores.setting.timeout.get(), 11); + + // The two table-backed stores answer only if their tables were created. + expect(Stores.connectionStats.getAllServerStats(), isEmpty); + expect(Stores.agentConversation.fetchForServer('srv'), isEmpty); + }); + + test('a second launch reopens the same file', () async { + // Deliberately not deleting the file between the two `Stores.init` calls. + await Stores.init(); + Stores.setting.timeout.put(23); + + await getIt.reset(); + await SqliteDb.close(); + + await Stores.init(); + expect(Stores.setting.timeout.get(), 23); + }); + + test('the schema version does not travel in a backup', () async { + await Stores.init(); + + // It describes this device's storage. Carried in a backup, restoring one + // taken on a device still on the previous release wrote that version back, + // and the next launch found no migration registered for it and threw a + // StateError nothing catches. + expect(Stores.setting.getAllMap().keys, isNot(contains('schemaVersion'))); + expect( + Stores.setting.getAllMap().keys.any((k) => k.contains('schemaVersion')), + isFalse, + ); + expect(SchemaVersion.stored, SchemaVersion.current); + }); + + test('clearing the settings does not make the import run again', () async { + await Stores.init(); + Stores.setting.clear(); + + // The marker is internal, so `clear` leaves it. Were it dropped, the next + // launch would copy the retained Hive boxes back over everything. + await getIt.reset(); + await SqliteDb.close(); + await Stores.init(); + + expect(SchemaVersion.stored, SchemaVersion.current); + }); +} From f265ff16f49d7aca7650739c81690254a6d09732 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Wed, 19 Aug 2026 02:52:52 +0800 Subject: [PATCH 11/16] perf(store): make the connection stats summary two queries, not N+1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `getAllServerStats` enumerated servers with a `GROUP BY` and then read one server's entire history per row, decoding up to 2000 records to draw 20 summary cards. The counters are an aggregate, and the only rows that reach the UI are the newest 20 per server — which `ROW_NUMBER() OVER (PARTITION BY ...)` bounds in the database instead of after decoding everything. Two queries now, whatever the server count. Measured over 20 servers at the 100-row cap: 3.00ms -> 2.16ms per call. The absolute numbers are small because the cap already bounds the data; what changes is that it stops growing with the number of servers. The name still comes from the newest row, since a server can be renamed and the older rows keep what it was called at the time — it is read off the first recent row per group rather than a bare column beside `MAX`, which only answers for one aggregate and there are three here. A test asserts the aggregate agrees with `getServerStats` field by field, which is the property that matters and the one an aggregate rewrite is most likely to get wrong. --- lib/data/store/connection_stats.dart | 82 +++++++++++++++++++++++---- packages/fl_lib | 2 +- test/connection_stats_store_test.dart | 78 +++++++++++++++++++++++++ test/sqlite_store_test.dart | 16 ++++++ 4 files changed, 165 insertions(+), 13 deletions(-) diff --git a/lib/data/store/connection_stats.dart b/lib/data/store/connection_stats.dart index 3ab0c65e1..d3eec8f55 100644 --- a/lib/data/store/connection_stats.dart +++ b/lib/data/store/connection_stats.dart @@ -25,6 +25,10 @@ class ConnectionStatsStore { /// Dropped regardless of the per-server count. static const _retention = Duration(days: 30); + /// How many recent attempts a summary carries. The list card shows three of + /// them and the detail dialog shows the rest, both off the same object. + static const _recentPerServer = 20; + Database get _db => SqliteDb.instance; Future init() async { @@ -94,7 +98,9 @@ CREATE TABLE IF NOT EXISTS conn_stat ( } else { lastFailureTime ??= stat.timestamp; } - if (recentConnections.length < 20) recentConnections.add(stat); + if (recentConnections.length < _recentPerServer) { + recentConnections.add(stat); + } } final totalAttempts = allStats.length; @@ -111,21 +117,73 @@ CREATE TABLE IF NOT EXISTS conn_stat ( ); } + /// Every server's summary, in two queries. + /// + /// One `GROUP BY` to enumerate servers and then a full history read per + /// server meant 21 queries and up to 2000 decoded records to draw 20 summary + /// cards. The counters are an aggregate, and the only rows that reach the UI + /// are the newest [_recentPerServer] per server — which a window function + /// bounds in the database rather than after decoding everything. List getAllServerStats() { - // The name comes from the newest row for each server, since a server can be - // renamed and the old rows keep the name it had at the time. `server_name` - // is a bare column beside `MAX`, which SQLite answers from the row the - // maximum came from. - final rows = _db.select( - 'SELECT server_id, server_name, MAX(timestamp) AS ts FROM conn_stat ' - 'GROUP BY server_id;', + const success = 'success'; + assert(ConnectionResult.success.name == success); + + final totals = _db.select( + 'SELECT server_id,' + ' COUNT(*) AS total,' + " SUM(CASE WHEN result = '$success' THEN 1 ELSE 0 END) AS successes," + " MAX(CASE WHEN result = '$success' THEN timestamp END) AS last_ok," + " MAX(CASE WHEN result <> '$success' THEN timestamp END) AS last_bad " + 'FROM conn_stat GROUP BY server_id;', + ); + if (totals.isEmpty) return const []; + + // Newest first within each server, so the first row of a group is also + // where the current name comes from — a server can be renamed, and the + // older rows keep whatever it was called at the time. + final recentRows = _db.select( + 'SELECT * FROM (' + ' SELECT *, ROW_NUMBER() OVER (' + ' PARTITION BY server_id ORDER BY timestamp DESC' + ' ) AS rn FROM conn_stat' + ') WHERE rn <= ? ORDER BY server_id, timestamp DESC;', + [_recentPerServer], ); - return [ - for (final row in rows) - getServerStats(row['server_id'] as String, row['server_name'] as String), - ]; + + final recent = >{}; + for (final row in recentRows) { + (recent[row['server_id'] as String] ??= []).add(_fromRow(row)); + } + + final result = []; + for (final row in totals) { + final serverId = row['server_id'] as String; + final recentConnections = recent[serverId] ?? const []; + if (recentConnections.isEmpty) continue; + + final total = row['total'] as int; + final successCount = (row['successes'] as num?)?.toInt() ?? 0; + result.add( + ServerConnectionStats( + serverId: serverId, + serverName: recentConnections.first.serverName, + totalAttempts: total, + successCount: successCount, + failureCount: total - successCount, + lastSuccessTime: _timeOf(row['last_ok']), + lastFailureTime: _timeOf(row['last_bad']), + recentConnections: recentConnections, + successRate: total > 0 ? successCount / total : 0.0, + ), + ); + } + return result; } + static DateTime? _timeOf(Object? millis) => millis is int + ? DateTime.fromMillisecondsSinceEpoch(millis) + : null; + Future clearAll() async { _db.execute('DELETE FROM conn_stat;'); } diff --git a/packages/fl_lib b/packages/fl_lib index e7bba5aab..ed048cab6 160000 --- a/packages/fl_lib +++ b/packages/fl_lib @@ -1 +1 @@ -Subproject commit e7bba5aab06d635243726d93da190750c213dec4 +Subproject commit ed048cab63c82b89a30f796820d3733228cbcfce diff --git a/test/connection_stats_store_test.dart b/test/connection_stats_store_test.dart index 5974e67d4..346ffba4b 100644 --- a/test/connection_stats_store_test.dart +++ b/test/connection_stats_store_test.dart @@ -177,4 +177,82 @@ void main() { await store.clearAll(); expect(store.getAllServerStats(), isEmpty); }); + + group('the overall list, which is two queries regardless of server count', () { + test('it agrees with reading each server separately', () async { + for (var server = 0; server < 4; server++) { + for (var i = 0; i < 25; i++) { + await store.recordConnection( + stat( + 's$server', + at: base.add(Duration(minutes: i)), + result: i.isEven + ? ConnectionResult.success + : ConnectionResult.timeout, + name: 'name-$server', + ), + ); + } + } + + final all = { + for (final e in store.getAllServerStats()) e.serverId: e, + }; + expect(all.keys, hasLength(4)); + + // The aggregate has to answer exactly what the per-server read does. + for (var server = 0; server < 4; server++) { + final id = 's$server'; + final one = store.getServerStats(id, 'name-$server'); + final many = all[id]!; + + expect(many.serverName, one.serverName, reason: id); + expect(many.totalAttempts, one.totalAttempts, reason: id); + expect(many.successCount, one.successCount, reason: id); + expect(many.failureCount, one.failureCount, reason: id); + expect(many.successRate, closeTo(one.successRate, 1e-12), reason: id); + expect(many.lastSuccessTime, one.lastSuccessTime, reason: id); + expect(many.lastFailureTime, one.lastFailureTime, reason: id); + expect( + many.recentConnections.map((e) => e.timestamp), + one.recentConnections.map((e) => e.timestamp), + reason: id, + ); + } + }); + + test('each server carries at most 20 recent attempts, newest first', () async { + for (var i = 0; i < 40; i++) { + await store.recordConnection( + stat('a', at: base.add(Duration(minutes: i))), + ); + } + + final recent = store.getAllServerStats().single.recentConnections; + expect(recent, hasLength(20)); + expect(recent.first.timestamp, base.add(const Duration(minutes: 39))); + expect(recent.last.timestamp, base.add(const Duration(minutes: 20))); + }); + + test('a rename shows the newest name, not the oldest row\'s', () async { + await store.recordConnection(stat('a', at: base, name: 'before')); + await store.recordConnection( + stat('a', at: base.add(const Duration(minutes: 1)), name: 'after'), + ); + + expect(store.getAllServerStats().single.serverName, 'after'); + }); + + test('a server with only failures reports no last success', () async { + await store.recordConnection( + stat('a', at: base, result: ConnectionResult.authFailed), + ); + + final summary = store.getAllServerStats().single; + expect(summary.successCount, 0); + expect(summary.successRate, 0.0); + expect(summary.lastSuccessTime, isNull); + expect(summary.lastFailureTime, base); + }); + }); } diff --git a/test/sqlite_store_test.dart b/test/sqlite_store_test.dart index 2db6e68b7..106770b23 100644 --- a/test/sqlite_store_test.dart +++ b/test/sqlite_store_test.dart @@ -313,6 +313,22 @@ void main() { expect(store.get('before'), 0); }); + test('a cached statement is rebuilt when the database is replaced', () { + // Statements are kept across calls because preparing one is most of what + // a read costs. They belong to the database that made them, which + // disposes them with itself — so a store that outlives an open/close + // cycle must not reuse or re-dispose them. + store.set('a', 1); + expect(store.get('a'), 1); + + SqliteDb.close(); + SqliteDb.openInMemory(); + + expect(store.get('a'), isNull); + store.set('a', 2); + expect(store.get('a'), 2); + }); + test('getAllMap reads every key in one pass', () { store.set('a', 1); store.set('m', {'x': 1}); From 299028bd8c3f319bb0a1cfafb4eb06d1e4c17258 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Wed, 19 Aug 2026 03:24:13 +0800 Subject: [PATCH 12/16] fix(store): review follow-ups, and a private-key page that asked the wrong store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_autoAddSystemPriavteKey` gated on `Stores.snippet.keys().isEmpty` where its own comment says "no private key saved". Predates this branch, but the line was touched here. It now asks `Stores.key`. The raw settings editor could delete an internal key. It reads with `includeInternalKeys: true`, so those are in `initialKeys`; a key dropped from the edited JSON was removed, and one of them records that the Hive import already ran. Same class as the `clear()` fix. Its writes also run in one transaction now, as `Backup.merge` does. The sandbox import skipped any file ending in `-shm`, which is a file of the user's if it is not the database's. Bound to the database name, like the other predicates in that file. `AgentConversationStore.save` returns false on a failure anywhere in the write, not only in the upsert — the caller treats the bool as "saved". `CachedSqliteStore.update` no longer deletes and reinserts under the same key. Every caller but the id migrations edits in place, and delete-then- insert leaves a window where the record is neither version. `SqliteStore. transact` is not reentrant, so a transaction here would break inside a restore; skipping the delete removes the window without one. `_jsonSafe` logs why a value had no JSON form. "No `toJson`" and "`toJson` threw" looked identical, and the destination's warning only names the type. `PortForwardStore.fetch` reads its store in one query. Tests: the seeded `Random` for the mock key was constructed inside `List.generate`, so all 32 bytes were the same value. The fixture comments still explained a Hive write lock that no longer exists. The import-marker test asserts the marker rather than inferring it from the schema version, and the sandbox test uses the real database name instead of the drift-era `app.db`, plus a user file that merely ends in `-shm`. --- TODOS.md | 8 ++++--- lib/core/utils/sandbox_import.dart | 5 ++++- lib/data/store/agent_conversation.dart | 7 +++++-- lib/data/store/cached_store.dart | 10 +++++++-- .../store/migrations/m003_hive_to_sqlite.dart | 8 ++++--- lib/data/store/port_forward.dart | 7 ++++--- lib/view/page/private_key/list.dart | 2 +- lib/view/page/server/edit/actions.dart | 2 +- lib/view/page/server/edit/edit.dart | 1 - lib/view/page/setting/entries/app.dart | 12 ++++++++++- test/agent_shell_view_test.dart | 9 +++----- test/agent_view_test.dart | 7 ++----- test/file_tab_restore_test.dart | 5 ++--- test/hive_import_test.dart | 7 ++++--- test/identity_file_key_test.dart | 4 ++-- test/pane_width_test.dart | 5 ++--- test/sandbox_import_test.dart | 19 ++++++++++------- test/server_card_gesture_test.dart | 4 ++-- test/server_func_btn_test.dart | 11 +++++----- test/ssh_tab_restore_test.dart | 7 ++----- test/stores_init_test.dart | 21 ++++++++++++++----- 21 files changed, 97 insertions(+), 64 deletions(-) diff --git a/TODOS.md b/TODOS.md index 3eb34a96b..d51626bf9 100644 --- a/TODOS.md +++ b/TODOS.md @@ -387,7 +387,7 @@ provider、backup、SFTP 页面、server 编辑页全被卷进去了。 第一阶段只动引擎,`Store` 接口不变: -``` +```sql kv(store TEXT, key TEXT, value TEXT /* JSON */, updated_at INTEGER, PRIMARY KEY(store, key)) ``` @@ -410,11 +410,13 @@ kv(store TEXT, key TEXT, value TEXT /* JSON */, updated_at INTEGER, PRIMARY KEY( `view/page/setting/entries/app.dart`(备份加密,同 backup.dart)、 `data/store/migrations/m002_nest_ssh.dart`。 -序列化随之从 TypeAdapter 二进制换成 JSON,17 个 adapter 全部消失 -(`lib/hive/` 整个目录 + `hive_ce*` 依赖)。覆盖情况已逐个核对:9 个类都有 +序列化随之从 TypeAdapter 二进制换成 JSON。覆盖情况已逐个核对:9 个类都有 `fromJson`(freezed 模型自带 `toJson`),7 个是 enum(按 name 存),setting 里的 virt keys 本来就存 `List`,`agent_conversation` 本来就存 JSON Map。 +**但 17 个 adapter 和 `lib/hive/` 没有删** —— 运行时不再用它们,`HiveImport` +读旧盒子还要靠它们解码。删除时机见本节开头的"未做 / 遗留"。 + 第二阶段只关系化真正需要 SQL 的: - **`connection_stats`**:现在为了做时间窗口查询,额外开了一个 `conn_stats_index` diff --git a/lib/core/utils/sandbox_import.dart b/lib/core/utils/sandbox_import.dart index b5016383f..2a62730be 100644 --- a/lib/core/utils/sandbox_import.dart +++ b/lib/core/utils/sandbox_import.dart @@ -385,7 +385,10 @@ abstract final class SandboxImport { // WAL index that no longer exists, and sqlite either rebuilds it or // refuses — the first is wasted, the second is a broken app. It is // rebuilt from the `-wal`, which does come across. - if (name.endsWith('-shm')) continue; + // + // By exact name, not by suffix: a file of the user's own that happens + // to end in `-shm` is theirs and should come across. + if (name == '${SqliteDb.fileName}-shm') continue; await entity.copy(dest.path.joinPath(name)); _copiedBytes += await entity.length(); diff --git a/lib/data/store/agent_conversation.dart b/lib/data/store/agent_conversation.dart index 68f157057..3abb0a3bf 100644 --- a/lib/data/store/agent_conversation.dart +++ b/lib/data/store/agent_conversation.dart @@ -143,14 +143,17 @@ class AgentConversationStore { title: _normalizeTitle(conversation.title, conversation.items), items: trimItemsForStorage(conversation.items), ); + // The whole write, not just the upsert: the caller treats `false` as "not + // saved", and a failure in the active-row write or the prune would + // otherwise escape as an exception it does not expect. try { _upsert(normalized); + if (setActive) _setActiveRow(normalized.serverId, normalized.id); + _pruneServer(normalized.serverId); } catch (e) { dprint('Saving AgentConversation', e); return false; } - if (setActive) _setActiveRow(normalized.serverId, normalized.id); - _pruneServer(normalized.serverId); _changes.add(null); return true; } diff --git a/lib/data/store/cached_store.dart b/lib/data/store/cached_store.dart index 63644ef1a..ddec557f6 100644 --- a/lib/data/store/cached_store.dart +++ b/lib/data/store/cached_store.dart @@ -99,11 +99,17 @@ abstract class CachedSqliteStore extends SqliteStore { void delete(T item) => deleteById(getKey(item)); void update(T old, T newItem) { + final oldKey = getKey(old); if (!have(old)) { throw Exception('Old $T: $old not found'); } - remove(getKey(old)); - set(getKey(newItem), newItem); + final newKey = getKey(newItem); + // Only when the key actually moved. Every caller but the id migrations + // edits a record in place, and a delete-then-insert of the same key leaves + // a window — inside a restore's transaction or a crash — where the record + // is neither the old one nor the new one. An upsert has no such window. + if (oldKey != newKey) remove(oldKey); + set(newKey, newItem); } bool have(T item) => get(getKey(item)) != null; diff --git a/lib/data/store/migrations/m003_hive_to_sqlite.dart b/lib/data/store/migrations/m003_hive_to_sqlite.dart index 1332e1114..282564fbb 100644 --- a/lib/data/store/migrations/m003_hive_to_sqlite.dart +++ b/lib/data/store/migrations/m003_hive_to_sqlite.dart @@ -180,9 +180,11 @@ abstract final class HiveImport { try { final json = (value as dynamic).toJson(); if (json is Object) return json; - } catch (_) { - // No `toJson`. Leave it be: the destination reports what it could not - // take, naming the type. + } catch (e) { + // Usually just "no `toJson`", but not always — a model whose `toJson` + // throws looks the same from here, and the destination's own warning + // only names the type. + Loggers.app.warning('No JSON form for ${value.runtimeType}', e); } return value; } diff --git a/lib/data/store/port_forward.dart b/lib/data/store/port_forward.dart index b255a3164..475a34988 100644 --- a/lib/data/store/port_forward.dart +++ b/lib/data/store/port_forward.dart @@ -12,9 +12,10 @@ class PortForwardStore extends SqliteStore { List fetch(String serverId) { final configs = []; - for (final key in keys()) { - final raw = get(key); - if (raw == null) continue; + // One query, not one per key: `getAllMap` reads the store in a single + // statement. + for (final raw in getAllMap().values) { + if (raw is! Map) continue; final PortForwardConfig config; try { config = PortForwardConfig.fromJson(Map.from(raw)); diff --git a/lib/view/page/private_key/list.dart b/lib/view/page/private_key/list.dart index 8f12f27d9..0868718cf 100644 --- a/lib/view/page/private_key/list.dart +++ b/lib/view/page/private_key/list.dart @@ -69,7 +69,7 @@ class _PrivateKeyListState extends ConsumerState extension on _PrivateKeyListState { void _autoAddSystemPriavteKey() async { // Only trigger on desktop platform and no private key saved - if (isDesktop && Stores.snippet.keys().isEmpty) { + if (isDesktop && Stores.key.keys().isEmpty) { final home = Pfs.homeDir; if (home == null) return; final idRsaFile = File(home.joinPath('.ssh/id_rsa')); diff --git a/lib/view/page/server/edit/actions.dart b/lib/view/page/server/edit/actions.dart index a829f9140..5175fcffd 100644 --- a/lib/view/page/server/edit/actions.dart +++ b/lib/view/page/server/edit/actions.dart @@ -370,7 +370,7 @@ extension _Actions on _ServerEditPageState { } if (this.spi == null) { - final existsIds = ServerStore.instance.keys(); + final existsIds = Stores.server.keys(); if (existsIds.contains(spi.id)) { Toast.show('${l10n.sameIdServerExist}: ${spi.id}'); return; diff --git a/lib/view/page/server/edit/edit.dart b/lib/view/page/server/edit/edit.dart index 15c950642..0f5d191d7 100644 --- a/lib/view/page/server/edit/edit.dart +++ b/lib/view/page/server/edit/edit.dart @@ -23,7 +23,6 @@ import 'package:server_box/data/model/server/wol_cfg.dart'; import 'package:server_box/data/provider/private_key.dart'; import 'package:server_box/data/provider/server/all.dart'; import 'package:server_box/data/res/store.dart'; -import 'package:server_box/data/store/server.dart'; import 'package:server_box/view/page/private_key/edit.dart'; import 'package:server_box/view/page/server/custom_cmds.dart'; import 'package:server_box/view/widget/page_columns.dart'; diff --git a/lib/view/page/setting/entries/app.dart b/lib/view/page/setting/entries/app.dart index e4dc19151..83ab00b6b 100644 --- a/lib/view/page/setting/entries/app.dart +++ b/lib/view/page/setting/entries/app.dart @@ -506,6 +506,9 @@ extension _App on _AppSettingsPageState { updateLastUpdateTsOnSet: false, ); } else { + // One transaction, as `Backup.merge` does: this rewrites the whole + // settings store, and half of an edit is not a state to leave behind. + SqliteStore.transact(() { for (final entry in newSettings.entries) { final value = entry.value; // A key set to null means "clear this". Skipping it instead left @@ -523,10 +526,17 @@ extension _App on _AppSettingsPageState { ); } final newKeys = newSettings.keys.toSet(); - final removedKeys = initialKeys.where((e) => !newKeys.contains(e)); + // Internal keys are shown by the editor (it reads with + // `includeInternalKeys: true`) but are not the user's to delete: one + // of them records that the Hive import already ran, and dropping it + // makes the next launch copy the retained boxes back over everything. + final removedKeys = initialKeys.where( + (e) => !newKeys.contains(e) && !Stores.setting.isInternalKey(e), + ); for (final key in removedKeys) { Stores.setting.remove(key, updateLastUpdateTsOnRemove: false); } + }); } } catch (e, trace) { context.showRoundDialog( diff --git a/test/agent_shell_view_test.dart b/test/agent_shell_view_test.dart index dddcb09c0..44bb12435 100644 --- a/test/agent_shell_view_test.dart +++ b/test/agent_shell_view_test.dart @@ -32,12 +32,9 @@ void main() { setUp(() async { tempDir = await Directory.systemTemp.createTemp('server-box-agent-shell-'); SqliteDb.openInMemory(); - // In memory rather than on disk. A widget test body runs in a fake-async - // zone; a real file write started there completes on a callback that zone - // is no longer pumping, so the box's write lock is never released and - // closing it in `tearDown` blocks — which hangs the whole run, not just - // this file. The shell persists its mode the moment it changes, so every - // test here writes. + // In memory rather than on disk: the shell persists its mode the moment it + // changes, so every test here writes, and none of them should leave a + // database behind. getIt.registerSingleton(SettingStore.forTest()); getIt.registerSingleton( AgentConversationStore.forTest()..init(), diff --git a/test/agent_view_test.dart b/test/agent_view_test.dart index 3ebdc3335..c44cf6b2d 100644 --- a/test/agent_view_test.dart +++ b/test/agent_view_test.dart @@ -39,11 +39,8 @@ void main() { setUp(() async { tempDir = await Directory.systemTemp.createTemp('server-box-agent-view-'); SqliteDb.openInMemory(); - // In memory: the return-key tests below write the setting they are - // about, and a real file write started inside a `testWidgets` body - // completes on a callback the fake-async zone is no longer pumping — - // so the box's write lock is never released and `close()` in tearDown - // blocks forever, with no failure to say which file did it. + // In memory: the return-key tests below write the setting they are about, + // and none of them should leave a database behind. getIt.registerSingleton(SettingStore.forTest()); getIt.registerSingleton( AgentConversationStore.forTest()..init(), diff --git a/test/file_tab_restore_test.dart b/test/file_tab_restore_test.dart index c7f903317..589cec990 100644 --- a/test/file_tab_restore_test.dart +++ b/test/file_tab_restore_test.dart @@ -35,9 +35,8 @@ void main() { setUp(() async { tempDir = await Directory.systemTemp.createTemp('server-box-filetab-'); SqliteDb.openInMemory(); - // In memory: this page saves on every change, and a file write started in - // a `testWidgets` body never releases the box's lock — `close()` in - // `tearDown` then hangs the whole run with no output. + // In memory: this tree writes as it builds, and a test has no + // business leaving a database behind. getIt.registerSingleton(SettingStore.forTest()); getIt.registerSingleton(ServerStore.forTest()); getIt.registerSingleton(PrivateKeyStore.forTest()); diff --git a/test/hive_import_test.dart b/test/hive_import_test.dart index f6bb09c15..a53815403 100644 --- a/test/hive_import_test.dart +++ b/test/hive_import_test.dart @@ -37,11 +37,12 @@ void main() { // path_provider and would write into the real documents directory. Paths.doc = tempDir.path; + // One seeded generator reused, not a new one per byte — `Random(1)` inside + // the closure would have produced the same value 32 times. + final rng = Random(1); FlutterSecureStorage.setMockInitialValues({ 'hivePwd': base64UrlEncode( - Uint8List.fromList( - List.generate(32, (_) => Random(1).nextInt(256)), - ), + Uint8List.fromList(List.generate(32, (_) => rng.nextInt(256))), ), }); SharedPreferences.setMockInitialValues({}); diff --git a/test/identity_file_key_test.dart b/test/identity_file_key_test.dart index 1bb72d3f0..aaad09bc7 100644 --- a/test/identity_file_key_test.dart +++ b/test/identity_file_key_test.dart @@ -71,8 +71,8 @@ void main() { late PrivateKeyStore keys; setUp(() { - // In memory: a real write started in a test body completes on a callback - // the fake-async zone is no longer pumping, and `close()` then blocks + // In memory: this tree writes as it builds, and a test has no + // business leaving a database behind. SqliteDb.openInMemory(); servers = ServerStore.forTest(); keys = PrivateKeyStore.forTest(); diff --git a/test/pane_width_test.dart b/test/pane_width_test.dart index 82513d657..63e624aee 100644 --- a/test/pane_width_test.dart +++ b/test/pane_width_test.dart @@ -36,9 +36,8 @@ void main() { setUp(() async { tempDir = await Directory.systemTemp.createTemp('server-box-pane-'); SqliteDb.openInMemory(); - // In memory: this page persists tags, order and the column's own width, - // and a real write started in a `testWidgets` body never lets go of the - // box's lock. + // In memory: this tree writes as it builds, and a test has no + // business leaving a database behind. getIt.registerSingleton(SettingStore.forTest()); getIt.registerSingleton(ServerStore.forTest()); getIt.registerSingleton(PrivateKeyStore.forTest()); diff --git a/test/sandbox_import_test.dart b/test/sandbox_import_test.dart index 254b7b5f3..78c7e3dd5 100644 --- a/test/sandbox_import_test.dart +++ b/test/sandbox_import_test.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:fl_lib/fl_lib.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:server_box/core/utils/sandbox_import.dart'; @@ -138,16 +139,20 @@ void main() { // It describes a WAL index belonging to whatever process had the database // open. Copied, it either costs a rebuild or makes sqlite refuse; the WAL // itself is what the data is in, and that does come across. - await write(src, 'box.hive'); - await write(src, 'app.db'); - await write(src, 'app.db-wal'); - await write(src, 'app.db-shm'); + const db = SqliteDb.fileName; + await write(src, db); + await write(src, '$db-wal'); + await write(src, '$db-shm'); + // Not ours, and it only looks like a sidecar. Skipping by suffix took a + // file of the user's with it. + await write(src, 'notes-shm'); expect(await import(), SandboxImportResult.imported); - expect(exists('app.db'), isTrue); - expect(exists('app.db-wal'), isTrue); - expect(exists('app.db-shm'), isFalse); + expect(exists(db), isTrue); + expect(exists('$db-wal'), isTrue); + expect(exists('$db-shm'), isFalse); + expect(exists('notes-shm'), isTrue); }); test('what the user downloaded stays where it is, and is named', () async { diff --git a/test/server_card_gesture_test.dart b/test/server_card_gesture_test.dart index feda9b84f..913e5d3e1 100644 --- a/test/server_card_gesture_test.dart +++ b/test/server_card_gesture_test.dart @@ -31,8 +31,8 @@ void main() { setUp(() async { tempDir = await Directory.systemTemp.createTemp('server-box-card-'); SqliteDb.openInMemory(); - // In memory: this page persists tags and order, and a real write started - // in a `testWidgets` body never lets go of the box's lock. + // In memory: this tree writes as it builds, and a test has no + // business leaving a database behind. getIt.registerSingleton(SettingStore.forTest()); getIt.registerSingleton(ServerStore.forTest()); getIt.registerSingleton(PrivateKeyStore.forTest()); diff --git a/test/server_func_btn_test.dart b/test/server_func_btn_test.dart index 4097428c5..65bdd9b45 100644 --- a/test/server_func_btn_test.dart +++ b/test/server_func_btn_test.dart @@ -21,11 +21,11 @@ void main() { }); /// The stored row, as indices — what the setting actually holds. - List row() => setting.get('serverBtns')?.cast() ?? const []; + List row() => setting.serverFuncBtns.get(); test('adds every entry that shipped during the upgrade', () async { // A row from before systemd (1058), portForward (1340) and power (1481). - setting.set('serverBtns', [ + setting.serverFuncBtns.put([ ServerFuncBtn.terminal.index, ServerFuncBtn.files.index, ]); @@ -42,7 +42,7 @@ void main() { }); test('adds nothing for an upgrade that shipped no new entry', () async { - setting.set('serverBtns', [ServerFuncBtn.terminal.index]); + setting.serverFuncBtns.put([ServerFuncBtn.terminal.index]); ServerFuncBtn.autoAddNewFuncs(1481, 1600); @@ -54,7 +54,7 @@ void main() { // install has been running 1500, and the user took it out of the row. An // upgrade to 1600 must not put it back — and would have, when the rule was // `to >= addedVersion` alone. - setting.set('serverBtns', [ + setting.serverFuncBtns.put([ ServerFuncBtn.terminal.index, ServerFuncBtn.systemd.index, ]); @@ -65,7 +65,7 @@ void main() { }); test('an entry already in the row is not added twice', () async { - setting.set('serverBtns', [ + setting.serverFuncBtns.put([ ServerFuncBtn.power.index, ServerFuncBtn.terminal.index, ]); @@ -89,6 +89,7 @@ void main() { isNull, reason: 'nothing was written, so the defaults still apply', ); + expect(row(), ServerFuncBtn.defaultIdxs); expect( ServerFuncBtn.defaultIdxs, containsAll([ diff --git a/test/ssh_tab_restore_test.dart b/test/ssh_tab_restore_test.dart index ee2c6670d..ae8700174 100644 --- a/test/ssh_tab_restore_test.dart +++ b/test/ssh_tab_restore_test.dart @@ -32,11 +32,8 @@ void main() { setUp(() async { tempDir = await Directory.systemTemp.createTemp('server-box-sshtab-'); SqliteDb.openInMemory(); - // In memory, all four. This page writes its tab set on every change, and - // a real file write started inside a `testWidgets` body completes on a - // callback that zone is no longer pumping — so the box's write lock is - // never released and `close()` in `tearDown` blocks for ever, with no - // failure and no output to say which file did it. + // In memory: this tree writes as it builds, and a test has no + // business leaving a database behind. getIt.registerSingleton(SettingStore.forTest()); getIt.registerSingleton(ServerStore.forTest()); getIt.registerSingleton(PrivateKeyStore.forTest()); diff --git a/test/stores_init_test.dart b/test/stores_init_test.dart index cb070e3c8..9cda7335c 100644 --- a/test/stores_init_test.dart +++ b/test/stores_init_test.dart @@ -37,11 +37,12 @@ void main() { tearDownAll(() => tempDir.delete(recursive: true)); setUp(() async { + // One seeded generator reused, not a new one per byte — `Random(7)` inside + // the closure would have produced the same value 32 times. + final rng = Random(7); FlutterSecureStorage.setMockInitialValues({ 'hivePwd': base64UrlEncode( - Uint8List.fromList( - List.generate(32, (_) => Random(7).nextInt(256)), - ), + Uint8List.fromList(List.generate(32, (_) => rng.nextInt(256))), ), }); SharedPreferences.setMockInitialValues({}); @@ -104,15 +105,25 @@ void main() { }); test('clearing the settings does not make the import run again', () async { + const marker = '${StoreDefaults.prefixKey}hiveImported'; + await Stores.init(); + expect(Stores.setting.get(marker), isTrue); + Stores.setting.timeout.put(31); + Stores.setting.clear(); - // The marker is internal, so `clear` leaves it. Were it dropped, the next - // launch would copy the retained Hive boxes back over everything. + // The marker is internal, so `clear` leaves it — directly, not inferred + // from the schema version. Were it dropped, the next launch would copy the + // retained Hive boxes back over everything the user has since changed. + expect(Stores.setting.timeout.get(), isNot(31), reason: 'settings cleared'); + expect(Stores.setting.get(marker), isTrue); + await getIt.reset(); await SqliteDb.close(); await Stores.init(); + expect(Stores.setting.get(marker), isTrue); expect(SchemaVersion.stored, SchemaVersion.current); }); } From 93b2426015d17d459c9580f745908daee155663a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Wed, 19 Aug 2026 03:33:18 +0800 Subject: [PATCH 13/16] fix(store): make the two multi-statement writes atomic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AgentConversationStore.save` wrote the conversation, the active row and the prune as three statements. A failure part way left a conversation stored but not active, or stored without the over-cap ones dropped, while the caller — which reads the bool as "saved" — was told it had failed. One unit now, and the change notification fires after it commits rather than before, so nothing is told to re-read a state that was undone. `CachedSqliteStore.update` splits on whether the key moved. In place is an upsert and needs no transaction. A moved key is a delete and an insert, and a crash between them left the record under neither — those are now one unit. This is what the previous commit's nesting is for: `update` is reachable from a backup restore, which has already opened one. The cache stays invalidated eagerly by `set`/`remove`. A rollback then costs one reload, where deferring it would mean those overrides not invalidating for every other caller. --- lib/data/store/agent_conversation.dart | 16 +++++---- lib/data/store/cached_store.dart | 19 ++++++---- packages/fl_lib | 2 +- test/sqlite_store_test.dart | 49 ++++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 13 deletions(-) diff --git a/lib/data/store/agent_conversation.dart b/lib/data/store/agent_conversation.dart index 3abb0a3bf..f404ad2bc 100644 --- a/lib/data/store/agent_conversation.dart +++ b/lib/data/store/agent_conversation.dart @@ -143,17 +143,21 @@ class AgentConversationStore { title: _normalizeTitle(conversation.title, conversation.items), items: trimItemsForStorage(conversation.items), ); - // The whole write, not just the upsert: the caller treats `false` as "not - // saved", and a failure in the active-row write or the prune would - // otherwise escape as an exception it does not expect. + // One unit, and the caller treats `false` as "not saved". Three statements + // otherwise: a conversation could be stored but not made active, or stored + // without the over-cap ones being dropped, and the caller would be told it + // failed while part of it stood. try { - _upsert(normalized); - if (setActive) _setActiveRow(normalized.serverId, normalized.id); - _pruneServer(normalized.serverId); + SqliteStore.transact(() { + _upsert(normalized); + if (setActive) _setActiveRow(normalized.serverId, normalized.id); + _pruneServer(normalized.serverId); + }); } catch (e) { dprint('Saving AgentConversation', e); return false; } + // After it commits, so nothing is told to re-read a state that was undone. _changes.add(null); return true; } diff --git a/lib/data/store/cached_store.dart b/lib/data/store/cached_store.dart index ddec557f6..711f56c49 100644 --- a/lib/data/store/cached_store.dart +++ b/lib/data/store/cached_store.dart @@ -104,12 +104,19 @@ abstract class CachedSqliteStore extends SqliteStore { throw Exception('Old $T: $old not found'); } final newKey = getKey(newItem); - // Only when the key actually moved. Every caller but the id migrations - // edits a record in place, and a delete-then-insert of the same key leaves - // a window — inside a restore's transaction or a crash — where the record - // is neither the old one nor the new one. An upsert has no such window. - if (oldKey != newKey) remove(oldKey); - set(newKey, newItem); + if (oldKey == newKey) { + // In place, which is what every caller but the id migrations does. An + // upsert is one statement and needs no transaction around it. + set(newKey, newItem); + return; + } + // The key moved, so this is a delete and an insert. As one unit: a crash + // between them would leave the record under neither key. `transact` nests, + // so this is also safe if a caller has already opened one. + SqliteStore.transact(() { + remove(oldKey); + set(newKey, newItem); + }); } bool have(T item) => get(getKey(item)) != null; diff --git a/packages/fl_lib b/packages/fl_lib index ed048cab6..2ae82bab4 160000 --- a/packages/fl_lib +++ b/packages/fl_lib @@ -1 +1 @@ -Subproject commit ed048cab63c82b89a30f796820d3733228cbcfce +Subproject commit 2ae82bab4affabc860bb60d75c1132a47ec2ff12 diff --git a/test/sqlite_store_test.dart b/test/sqlite_store_test.dart index 106770b23..2f2dcfbaa 100644 --- a/test/sqlite_store_test.dart +++ b/test/sqlite_store_test.dart @@ -313,6 +313,55 @@ void main() { expect(store.get('before'), 0); }); + test('transact nests, and an inner failure undoes only the inner part', () { + store.set('outer', 0); + + SqliteStore.transact(() { + store.set('outer', 1); + try { + SqliteStore.transact(() { + store.set('inner', 1); + throw StateError('inner failed'); + }); + } catch (_) { + // The outer unit decides to carry on without the inner one. + } + store.set('after', 1); + }); + + expect(store.get('outer'), 1); + expect(store.get('inner'), isNull); + expect(store.get('after'), 1); + }); + + test('a throw through both levels undoes both', () { + store.set('before', 0); + expect( + () => SqliteStore.transact(() { + store.set('outer', 1); + SqliteStore.transact(() => store.set('inner', 1)); + throw StateError('outer failed'); + }), + throwsStateError, + ); + + expect(store.get('outer'), isNull); + expect(store.get('inner'), isNull); + expect(store.get('before'), 0); + }); + + test('a nested transact leaves nothing open behind it', () { + SqliteStore.transact(() { + SqliteStore.transact(() => store.set('a', 1)); + }); + // A leaked savepoint would make this throw "cannot start a transaction + // within a transaction" — or silently join the previous one. + SqliteStore.transact(() => store.set('b', 2)); + + expect(store.get('a'), 1); + expect(store.get('b'), 2); + }); + test('a cached statement is rebuilt when the database is replaced', () { // Statements are kept across calls because preparing one is most of what // a read costs. They belong to the database that made them, which From ce41dd63347e0ea82bc522a71a28ed90ccae1f2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:05:31 +0800 Subject: [PATCH 14/16] bump(fl_lib): update check offers what this platform can install Picks up lollipopkit/fl_lib#41. A release published for some platforms only no longer mutes the check for the rest, and iOS no longer prompts for a tag the App Store has not finished reviewing. --- packages/fl_lib | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/fl_lib b/packages/fl_lib index 2ae82bab4..458b54ccb 160000 --- a/packages/fl_lib +++ b/packages/fl_lib @@ -1 +1 @@ -Subproject commit 2ae82bab4affabc860bb60d75c1132a47ec2ff12 +Subproject commit 458b54ccb1fce59c316cdb27af4381f0a35c8cd0 From 1fbeab6b03c8c157af0dd91d633da2b7bdb6adb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:35:11 +0800 Subject: [PATCH 15/16] fix(store): record the Hive import per box, not all-or-nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The import was marked done unless *every* box failed and nothing at all was copied. One box failing while another succeeded fell through that guard, wrote the marker and deleted `conn_stats_index`; `runIfNeeded` checks the marker first, so no later launch ever read the boxes that had not opened. Their data stayed on disk and became unreachable. Reachable rather than theoretical: `HiveStore.init` opens `_enc` and folds an existing plain `.hive` into it, so an install old enough to predate box encryption has some boxes that need the keychain and some that do not. The keychain being briefly unavailable at launch — a locked iOS device — then fails exactly some of them. Not simply "retry unless everything succeeded" either: the app is usable between launches with the marker unwritten, so re-copying a box that did land would overwrite whatever the user changed since. Which boxes were copied is now recorded, so each is copied once and one that could not be read is retried until it can. The schema version is set as soon as anything lands, since what lands is already in the current shape. The regression test replaces a box file with a directory. Corrupting its bytes does not work — Hive recovers such a box as an empty one rather than failing to open it, which is a quieter version of the same data loss. --- .../store/migrations/m003_hive_to_sqlite.dart | 65 ++++++++++++++----- test/hive_import_test.dart | 46 +++++++++++++ 2 files changed, 96 insertions(+), 15 deletions(-) diff --git a/lib/data/store/migrations/m003_hive_to_sqlite.dart b/lib/data/store/migrations/m003_hive_to_sqlite.dart index 282564fbb..c4859a4f1 100644 --- a/lib/data/store/migrations/m003_hive_to_sqlite.dart +++ b/lib/data/store/migrations/m003_hive_to_sqlite.dart @@ -20,6 +20,11 @@ abstract final class HiveImport { /// Internal, so it stays out of backups and out of `lastUpdateTs`. static const _markerKey = '${StoreDefaults.prefixKey}hiveImported'; + /// Which boxes have been copied, for an import finishing across launches. + /// + /// Removed once [_markerKey] is written, since it answers nothing after that. + static const _doneKey = '${StoreDefaults.prefixKey}hiveImportedBoxes'; + /// Box name -> what takes one of its rows. /// /// Most go to a K-V store under the same key. The last two own tables now, so @@ -50,8 +55,17 @@ abstract final class HiveImport { /// Runs the import if this device has data in Hive and none in SQLite yet. /// /// Safe to re-run: nothing is deleted from Hive, so a crash part-way through - /// leaves the marker unwritten and the next launch copies everything again, - /// overwriting whatever the interrupted attempt had managed to write. + /// leaves the marker unwritten and the next launch copies what it had not + /// got to yet. + /// + /// Progress is per box rather than all-or-nothing, because neither end of + /// that choice is safe. Marking the import done when only some boxes opened + /// drops the rest for good — the marker is the first thing checked, so no + /// later launch retries them. Leaving the marker unwritten instead re-copies + /// the boxes that *did* open, and the app is usable in the meantime, so that + /// overwrites whatever the user changed between the two launches. Recording + /// which boxes landed avoids both: a box is copied once, and one that could + /// not be read is retried until it can. static Future runIfNeeded() async { if (Stores.setting.get(_markerKey) == true) return; @@ -79,28 +93,39 @@ abstract final class HiveImport { return; } - Loggers.app.info('Importing ${present.length} Hive boxes into SQLite'); + final done = _doneBoxes(); + final pending = present.where((name) => !done.contains(name)).toList(); + Loggers.app.info( + 'Importing ${pending.length} Hive boxes into SQLite, ' + '${done.length} already copied', + ); + var copied = 0; - var failed = 0; - for (final name in present) { + for (final name in pending) { final result = await _importBox(name, _boxes[name]!); copied += result.copied; - if (!result.opened) failed++; + if (result.opened) done.add(name); } - Loggers.app.info('Imported $copied rows from Hive, $failed boxes unread'); - - // Not marked done if nothing could be read. A box fails to open when the - // keychain is briefly unavailable — the device still locked at launch, on - // iOS — and writing the marker anyway would leave the user with an empty - // app and no launch that ever retries, because the marker is the first - // thing checked. Their data is still on disk; this just tries again. - if (failed == present.length && copied == 0) { + + final unread = present.where((name) => !done.contains(name)).toList(); + if (unread.isNotEmpty) { + // A box fails to open when the keychain is briefly unavailable — the + // device still locked at launch, on iOS — and an install old enough to + // predate box encryption has some boxes that need it and some that do + // not, so this is reached with part of the data across and part not. + _setDoneBoxes(done); Loggers.app.warning( - 'No Hive box could be read; leaving the import to the next launch', + 'Imported $copied rows from Hive; $unread unread, ' + 'left to the next launch', ); + // What did land is already in the current shape, so the version is set + // now: a launch in this state runs the migrator like any other, and the + // step for a shape this data no longer has must not be applied to it. + if (done.isNotEmpty) SchemaVersion.initFresh(); return; } + Loggers.app.info('Imported $copied rows from Hive, every box read'); _dropPlaintextIndex(dir); // The copy nests a pre-v3 server record on the way across, which is what @@ -108,8 +133,18 @@ abstract final class HiveImport { // construction. SchemaVersion.initFresh(); Stores.setting.set(_markerKey, true); + Stores.setting.remove(_doneKey); + } + + static Set _doneBoxes() { + final raw = Stores.setting.get(_doneKey); + if (raw == null) return {}; + return raw.whereType().toSet(); } + static void _setDoneBoxes(Set names) => + Stores.setting.set(_doneKey, names.toList()); + static Future<({bool opened, int copied})> _importBox( String name, bool Function(String, Object) into, diff --git a/test/hive_import_test.dart b/test/hive_import_test.dart index a53815403..21d2bd0d1 100644 --- a/test/hive_import_test.dart +++ b/test/hive_import_test.dart @@ -237,6 +237,52 @@ void main() { expect(Stores.server.fetch(), isEmpty); }); + test('a box that could not be read is retried, the rest are not recopied', + () async { + await seedHive(); + + // A box that will not open. In the field this is the keychain being + // briefly unavailable at launch on a locked iOS device, which hits the + // encrypted boxes and not the plaintext ones. Here the box file is a + // directory, which `Hive.openBox` cannot read either — corrupting the + // bytes instead does not work, because Hive recovers such a box as an + // empty one rather than failing to open it. + final encPath = tempDir.path.joinPath('snippet_enc.hive'); + final intact = File(encPath).readAsBytesSync(); + File(encPath).deleteSync(); + Directory(encPath).createSync(); + addTearDown(() { + final dir = Directory(encPath); + if (dir.existsSync()) dir.deleteSync(recursive: true); + }); + // `runIfNeeded` looks for either name, so the box still counts as present. + final plain = File(tempDir.path.joinPath('snippet.hive'))..createSync(); + + await Stores.init(); + + expect(Stores.server.fetchOneRaw('srv-1')?.name, 'prod', + reason: 'a box that opened is across'); + expect(Stores.snippet.fetchOneRaw('uptime'), isNull, + reason: 'the box that did not open has nothing across'); + + // Stands in for a user edit between the two launches. The app is usable + // with the import unfinished, so copying every box again would put the old + // value back. + Stores.setting.timeout.put(42); + await getIt.reset(); + + // The box is readable again. + Directory(encPath).deleteSync(recursive: true); + plain.deleteSync(); + File(encPath).writeAsBytesSync(intact); + await Stores.init(); + + expect(Stores.snippet.fetchOneRaw('uptime')?.script, 'w', + reason: 'the unread box is retried'); + expect(Stores.setting.timeout.get(), 42, + reason: 'a box already copied is not copied a second time'); + }); + test('importing does not present itself as a local edit', () async { await seedHive(); await Stores.init(); From 2f8932f2a66d43e1215fcd5b736624dd8d43ca71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:45:42 +0800 Subject: [PATCH 16/16] test(store): pin that a rejected record does not hold its box open Covers an opened box carrying one record the destination refuses, and asserts the import still completes. The behaviour is deliberate and the comment says why, so it is worth a test that fails if someone makes box completion depend on every record landing. --- test/hive_import_test.dart | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/test/hive_import_test.dart b/test/hive_import_test.dart index 21d2bd0d1..317466b90 100644 --- a/test/hive_import_test.dart +++ b/test/hive_import_test.dart @@ -283,6 +283,34 @@ void main() { reason: 'a box already copied is not copied a second time'); }); + test('a record the destination rejects does not hold its box open', () async { + await seedHive(); + // `importRow` takes a map, so a String is rejected. Seeded here rather + // than in `seedHive`, which the other tests share. + final stats = HiveStore('connection_stats'); + await stats.init(); + await stats.box.put('bad-row', 'not a record'); + await Hive.close(); + + await Stores.init(); + + expect(Stores.connectionStats.getConnectionHistory('srv-1').length, 1, + reason: 'the readable record still lands'); + + // Deliberate, and the opposite of an unopenable box: what makes a record + // fail here — an unregistered typeId, a truncated value, a shape the + // destination will not take — gives the same answer on every later launch. + // Holding the box open for it would leave the marker unwritten for good, + // so the import would re-run every launch and `conn_stats_index` would + // stay on disk in plaintext, which is the thing deleting it exists to + // avoid. + expect( + Stores.setting.get('${StoreDefaults.prefixKey}hiveImported'), + true, + reason: 'the box is done; the rejected record is not retried', + ); + }); + test('importing does not present itself as a local edit', () async { await seedHive(); await Stores.init();