From bb8e242fbe6397fb022dec8eb35de99bf6667b06 Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Mon, 29 Jun 2026 20:53:47 +0600 Subject: [PATCH] feat: add native rust core --- CODEOWNERS | 1 + mobile/integration_test/native_core_test.dart | 33 + mobile/pubspec.lock | 23 + mobile/pubspec.yaml | 2 + native/.gitignore | 5 + native/Cargo.lock | 619 ++++++++++++++++++ native/Cargo.toml | 44 ++ native/README.md | 61 ++ native/crates/immich_core/Cargo.toml | 17 + native/crates/immich_core/src/hashing.rs | 75 +++ native/crates/immich_core/src/image.rs | 173 +++++ native/crates/immich_core/src/lib.rs | 26 + native/crates/immich_core_dart/Cargo.toml | 21 + native/crates/immich_core_dart/build.rs | 19 + native/crates/immich_core_dart/cbindgen.toml | 3 + .../immich_core_dart/include/immich_core.h | 60 ++ .../immich_core_dart/rust-toolchain.toml | 18 + native/crates/immich_core_dart/src/lib.rs | 197 ++++++ native/crates/immich_core_napi/Cargo.toml | 19 + native/crates/immich_core_napi/build.rs | 3 + native/crates/immich_core_napi/src/lib.rs | 24 + native/immich_native_core/.gitignore | 33 + native/immich_native_core/README.md | 48 ++ .../immich_native_core/analysis_options.yaml | 4 + native/immich_native_core/ffigen.yaml | 20 + native/immich_native_core/hook/build.dart | 14 + .../lib/immich_native_core.dart | 8 + native/immich_native_core/lib/src/core.dart | 5 + .../lib/src/ffi/bindings.g.dart | 75 +++ .../immich_native_core/lib/src/ffi/ffi.dart | 19 + .../immich_native_core/lib/src/hashing.dart | 34 + native/immich_native_core/lib/src/image.dart | 42 ++ native/immich_native_core/pubspec.yaml | 26 + .../test/native_core_test.dart | 53 ++ .../immich_native_core/test/sha1_bench.dart | 82 +++ native/mise.toml | 68 ++ native/scripts/build-linux.sh | 17 + native/smoke/dart_smoke.dart | 31 + native/smoke/node_smoke.mjs | 17 + 39 files changed, 2039 insertions(+) create mode 100644 mobile/integration_test/native_core_test.dart create mode 100644 native/.gitignore create mode 100644 native/Cargo.lock create mode 100644 native/Cargo.toml create mode 100644 native/README.md create mode 100644 native/crates/immich_core/Cargo.toml create mode 100644 native/crates/immich_core/src/hashing.rs create mode 100644 native/crates/immich_core/src/image.rs create mode 100644 native/crates/immich_core/src/lib.rs create mode 100644 native/crates/immich_core_dart/Cargo.toml create mode 100644 native/crates/immich_core_dart/build.rs create mode 100644 native/crates/immich_core_dart/cbindgen.toml create mode 100644 native/crates/immich_core_dart/include/immich_core.h create mode 100644 native/crates/immich_core_dart/rust-toolchain.toml create mode 100644 native/crates/immich_core_dart/src/lib.rs create mode 100644 native/crates/immich_core_napi/Cargo.toml create mode 100644 native/crates/immich_core_napi/build.rs create mode 100644 native/crates/immich_core_napi/src/lib.rs create mode 100644 native/immich_native_core/.gitignore create mode 100644 native/immich_native_core/README.md create mode 100644 native/immich_native_core/analysis_options.yaml create mode 100644 native/immich_native_core/ffigen.yaml create mode 100644 native/immich_native_core/hook/build.dart create mode 100644 native/immich_native_core/lib/immich_native_core.dart create mode 100644 native/immich_native_core/lib/src/core.dart create mode 100644 native/immich_native_core/lib/src/ffi/bindings.g.dart create mode 100644 native/immich_native_core/lib/src/ffi/ffi.dart create mode 100644 native/immich_native_core/lib/src/hashing.dart create mode 100644 native/immich_native_core/lib/src/image.dart create mode 100644 native/immich_native_core/pubspec.yaml create mode 100644 native/immich_native_core/test/native_core_test.dart create mode 100644 native/immich_native_core/test/sha1_bench.dart create mode 100644 native/mise.toml create mode 100755 native/scripts/build-linux.sh create mode 100644 native/smoke/dart_smoke.dart create mode 100644 native/smoke/node_smoke.mjs diff --git a/CODEOWNERS b/CODEOWNERS index 9418f76c23..1cda052fad 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -5,3 +5,4 @@ /machine-learning/ @mertalev /e2e/ @danieldietzler /mobile/ @shenlong-tanwen @santoshakil +/native/ @santoshakil @mertalev diff --git a/mobile/integration_test/native_core_test.dart b/mobile/integration_test/native_core_test.dart new file mode 100644 index 0000000000..3d65760e31 --- /dev/null +++ b/mobile/integration_test/native_core_test.dart @@ -0,0 +1,33 @@ +// Plumbing check: proves immich_native_core is usable from the real immich app on +// a real device/sim — the build-hook compiled the Rust for this target, the code +// asset bundled into the app, and the @Native symbols resolve at runtime. +// Self-contained: does NOT boot the immich app or need a server. +// +// Run: flutter test integration_test/native_core_test.dart -d +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_native_core/immich_native_core.dart'; +import 'package:integration_test/integration_test.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + test('native core loads: coreVersion is non-empty', () { + expect(coreVersion(), isNotEmpty); + }); + + test('sha1Hex matches the FIPS-180 vector', () { + expect( + sha1Hex(Uint8List.fromList(utf8.encode('abc'))), + 'a9993e364706816aba3e25717850c26c9cd0d89d', + ); + }); + + test('rotateRgba8888 (the PR #29337 algorithm) rotates 180', () { + // 2x1: red, green -> green, red + final src = Uint8List.fromList([255, 0, 0, 255, 0, 255, 0, 255]); + expect(rotateRgba8888(src, 8, 2, 1, 3), [0, 255, 0, 255, 255, 0, 0, 255]); + }); +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 406199185c..48720778fa 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -912,6 +912,13 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.2" + immich_native_core: + dependency: "direct main" + description: + path: "../native/immich_native_core" + relative: true + source: path + version: "0.1.0" immich_ui: dependency: "direct main" description: @@ -1124,6 +1131,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.19.1" + native_toolchain_rust: + dependency: transitive + description: + name: native_toolchain_rust + sha256: faa57d2258a3b0fd2a634054f54e4496c9fcbd971977e7d2b7e6916d56892857 + url: "https://pub.dev" + source: hosted + version: "1.0.4+0" native_video_player: dependency: "direct main" description: @@ -1755,6 +1770,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.9.4" + toml: + dependency: transitive + description: + name: toml + sha256: "35a35f782228656a2af31e8c73d1353cc4ef3d683fd68af1111b44631879c05e" + url: "https://pub.dev" + source: hosted + version: "0.18.0" typed_data: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index c2f99aef66..c8443d757b 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -39,6 +39,8 @@ dependencies: hooks_riverpod: ^2.6.1 http: ^1.6.0 image_picker: ^1.2.1 + immich_native_core: + path: ../native/immich_native_core immich_ui: path: './packages/ui' intl: ^0.20.2 diff --git a/native/.gitignore b/native/.gitignore new file mode 100644 index 0000000000..76ce80b49a --- /dev/null +++ b/native/.gitignore @@ -0,0 +1,5 @@ +/target +smoke/*.node +# generated + committed (regen via `mise run codegen`): +# crates/immich_core_dart/include/immich_core.h (cbindgen) +# immich_native_core/lib/immich_native_core_bindings_generated.dart (ffigen) diff --git a/native/Cargo.lock b/native/Cargo.lock new file mode 100644 index 0000000000..d81bb933da --- /dev/null +++ b/native/Cargo.lock @@ -0,0 +1,619 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "cbindgen" +version = "0.29.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ecb53484c9c167ba674026b656d8a27d7657a58e6066aa902bfb1a4aa00ae20" +dependencies = [ + "heck", + "indexmap", + "log", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn", + "tempfile", + "toml", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "convert_case" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctor" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01334b89b69ff726750c5ce5073fc8bd860e99aa9a8fc5ca11b04730e3aee97a" + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + +[[package]] +name = "immich_core" +version = "0.1.0" +dependencies = [ + "memmap2", + "sha1", +] + +[[package]] +name = "immich_core_dart" +version = "0.1.0" +dependencies = [ + "cbindgen", + "immich_core", +] + +[[package]] +name = "immich_core_napi" +version = "0.1.0" +dependencies = [ + "immich_core", + "napi", + "napi-build", + "napi-derive", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "napi" +version = "3.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b41bda2ac390efb5e8d22025d925ccc3f3807d8c1bea6d19b36127247c4b8f83" +dependencies = [ + "bitflags", + "ctor", + "futures", + "napi-build", + "napi-sys", + "nohash-hasher", + "rustc-hash", +] + +[[package]] +name = "napi-build" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9c366d2c8c60b86fa632df75f745509b52f9128f91a6bad4c796e44abb505e1" + +[[package]] +name = "napi-derive" +version = "3.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61d66f70256ad5aef58659966064471d0ad90e2897bc36a5a5e0389c85aabc1e" +dependencies = [ + "convert_case", + "ctor", + "napi-derive-backend", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "napi-derive-backend" +version = "5.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81b4b08f15eed7a2a20c3f4c6314013fc3ac890a3afa9892b594485299ebdb2d" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "semver", + "syn", +] + +[[package]] +name = "napi-sys" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f5bcdf71abd3a50d00b49c1c2c75251cb3c913777d6139cd37dabc093a5e400" +dependencies = [ + "libloading", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/native/Cargo.toml b/native/Cargo.toml new file mode 100644 index 0000000000..3a0ce14dfb --- /dev/null +++ b/native/Cargo.toml @@ -0,0 +1,44 @@ +[workspace] +resolver = "2" +members = [ + "crates/immich_core", + "crates/immich_core_dart", + "crates/immich_core_napi", +] + +# shared logic lives in immich_core (no binding deps). each binding crate is a +# thin wrapper that picks its own crate-type: immich_core_dart -> cdylib/staticlib +# for dart:ffi (mobile), immich_core_napi -> cdylib (.node) for the node server. +# capabilities (hashing, exif, ...) are cargo features on immich_core so both +# bindings opt into the same set. crate-type can't be feature-gated, which is why +# the bindings are separate crates rather than one crate with feature flags. + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "AGPL-3.0-only" + +# single source of truth for all external dep versions. inner crates reference +# these with `{ workspace = true }` and never hardcode a version. +# default-features = false MUST live here (workspace level) — cargo ignores it if +# set only on the inner crate. inner crates then add the minimal features they need. +[workspace.dependencies] +sha1 = { version = "0.11", default-features = false } +memmap2 = { version = "0.9", default-features = false } +napi = { version = "3", default-features = false } +napi-derive = "3" +napi-build = "2" +cbindgen = { version = "0.29", default-features = false } + +# CI-enforced (not review-hoped): the boundary crate also #![deny]s unwrap/expect. +[workspace.lints.clippy] +undocumented_unsafe_blocks = "deny" + +# NB: no `panic = "abort"` — the FFI boundary relies on catch_unwind, which is a +# no-op under abort. default unwind is what lets a boundary panic become a null +# return instead of taking down the host (Flutter app / node server). +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 +strip = true diff --git a/native/README.md b/native/README.md new file mode 100644 index 0000000000..c02a30886e --- /dev/null +++ b/native/README.md @@ -0,0 +1,61 @@ +# immich_native_core (PoC) + +Shared Rust core consumed by the **mobile** app (Flutter, dart:ffi) and the +**server** (Node, napi `.node` addon). + +Status: **plumbing PoC.** It proves the wiring — Rust → codegen → build-from-source +on each app build → load on both platforms — not a perf win yet. The one capability +(`sha1_hex`) is single-shot in-memory, and the local-sync probe found hashing isn't +the hot path; a measured payload is the next step. `core_version` is a smoke +entrypoint. Mobile is the consumed path; the server napi crate builds and +round-trips but is not wired into the server yet. + +## Layout +``` +crates/ + immich_core pure logic, no binding deps. capabilities = cargo features (hashing). + immich_core_dart cdylib/staticlib + cbindgen header for dart:ffi (mobile) + immich_core_napi cdylib (.node) via napi-rs (server, unwired) +immich_native_core/ the Flutter package mobile depends on. build hook + ffigen @Native bindings. +smoke/ host dart + node roundtrip scripts (no device) +``` +Bindings are separate crates (Cargo can't gate `crate-type` by feature). + +## How the native lib is built (Flutter native assets — no prebuilt, no CI) +`immich_native_core/hook/build.dart` (`native_toolchain_rust`) compiles +`crates/immich_core_dart` **from source on every app build** via rustup and bundles +it as a Flutter *code asset*. The Dart side uses ffigen `@Native` externals bound to +that asset — no `DynamicLibrary`, no prebuilt artifacts, no fetch/publish/separate-repo. + +Native assets is on by default on Flutter stable (3.38+), so a stock `flutter build` +runs the hook. Each builder needs **rustup** (the hook auto-installs the pinned +toolchain + targets from `crates/immich_core_dart/rust-toolchain.toml`). + +## Dev commands (mise) +``` +mise run build cargo build --workspace +mise run test cargo test --workspace (host Rust tests, incl. FFI-boundary) +mise run lint clippy -D warnings (fmt: mise run fmt) +mise run codegen regen cbindgen header + ffigen @Native bindings — commit the result +mise run test:flutter HOST FFI roundtrip through the real build hook (no device) +mise run smoke Rust tests + host dart:ffi + host napi roundtrips +``` + +## Add a capability (end to end) +1. add the logic to `crates/immich_core` (behind a cargo feature if it pulls a dep). +2. expose a C entry in `crates/immich_core_dart/src/lib.rs` — `#[no_mangle] pub extern "C"`, + wrap the body in `guard(...)` (panic at the boundary → null, never unwind into the host), + validate pointers, return Rust-owned memory the caller frees via `immich_core_free_string`. +3. `mise run codegen` — regenerates the committed cbindgen header + ffigen `@Native` bindings. +4. add an ergonomic wrapper + null-check in `immich_native_core/lib/immich_native_core.dart`. +5. (optional) mirror it in `crates/immich_core_napi/src/lib.rs` for the server. +6. `mise run test:flutter` (host) + add a case to `immich_native_core/test/`, and to + `mobile/integration_test/native_core_test.dart` to exercise it on a device. + +## Consume from immich/mobile +`immich_native_core: { path: ../native/immich_native_core }` in `mobile/pubspec.yaml`, +then `dart pub get`. No app-level Gradle/Podfile edits — the hook builds + bundles the +lib. Builders need rustup. See the package README for the iOS App-Extension caveat. + +`/native/` is codeowned by @santoshakil + @mertalev. License: reuses the immich +repo-root AGPL-3.0 (no separate license file). diff --git a/native/crates/immich_core/Cargo.toml b/native/crates/immich_core/Cargo.toml new file mode 100644 index 0000000000..e4b4d5a0d5 --- /dev/null +++ b/native/crates/immich_core/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "immich_core" +version.workspace = true +edition.workspace = true +license.workspace = true + +[features] +default = ["hashing", "image"] +hashing = ["dep:sha1", "dep:memmap2"] +image = [] # pure pixel math, no deps + +[dependencies] +sha1 = { workspace = true, optional = true } +memmap2 = { workspace = true, optional = true } + +[lints] +workspace = true diff --git a/native/crates/immich_core/src/hashing.rs b/native/crates/immich_core/src/hashing.rs new file mode 100644 index 0000000000..52db4d020c --- /dev/null +++ b/native/crates/immich_core/src/hashing.rs @@ -0,0 +1,75 @@ +//! SHA-1 hashing. SHA-1 is immich's asset-identity checksum (server contract): +//! the algorithm is fixed. The win is in HOW it's computed — `sha1_file` mmaps the +//! file and feeds the OS-paged bytes straight to a hardware-accelerated digest, so +//! the whole file never lands in the caller's heap and there's no read+copy hop. + +use sha1::{Digest, Sha1}; +use std::fmt::Write; +use std::fs::File; +use std::io; +use std::path::Path; + +/// Lowercase-hex SHA-1 of a byte slice. +pub fn sha1_hex(bytes: &[u8]) -> String { + let digest = Sha1::digest(bytes); + let mut out = String::with_capacity(40); + for b in digest { + let _ = write!(out, "{b:02x}"); + } + out +} + +/// Lowercase-hex SHA-1 of the file at `path`, read via mmap. The OS pages the file +/// in on demand, so memory stays bounded regardless of file size — no full read +/// into a buffer, no copy. +pub fn sha1_file(path: impl AsRef) -> io::Result { + let file = File::open(path)?; + if file.metadata()?.len() == 0 { + return Ok(sha1_hex(&[])); + } + // SAFETY: the file is opened read-only and the mapping is read as immutable + // bytes for the duration of the hash. immich assets are not mutated in place; + // a concurrent truncation could SIGBUS, which is the documented mmap trade-off. + let mmap = unsafe { memmap2::Mmap::map(&file)? }; + Ok(sha1_hex(&mmap)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sha1_known_vector() { + // FIPS-180 worked example. + assert_eq!(sha1_hex(b"abc"), "a9993e364706816aba3e25717850c26c9cd0d89d"); + } + + #[test] + fn sha1_empty() { + assert_eq!(sha1_hex(b""), "da39a3ee5e6b4b0d3255bfef95601890afd80709"); + } + + #[test] + fn sha1_file_matches_in_memory() { + let dir = std::env::temp_dir(); + let path = dir.join(format!("immich_core_sha1_file_{}.bin", std::process::id())); + let data: Vec = (0..100_000u32).map(|i| (i % 251) as u8).collect(); + std::fs::write(&path, &data).unwrap(); + assert_eq!(sha1_file(&path).unwrap(), sha1_hex(&data)); + std::fs::remove_file(&path).ok(); + } + + #[test] + fn sha1_file_empty() { + let path = + std::env::temp_dir().join(format!("immich_core_empty_{}.bin", std::process::id())); + std::fs::write(&path, b"").unwrap(); + assert_eq!(sha1_file(&path).unwrap(), sha1_hex(b"")); + std::fs::remove_file(&path).ok(); + } + + #[test] + fn sha1_file_missing_errors() { + assert!(sha1_file("/no/such/immich_core/file").is_err()); + } +} diff --git a/native/crates/immich_core/src/image.rs b/native/crates/immich_core/src/image.rs new file mode 100644 index 0000000000..512708dab6 --- /dev/null +++ b/native/crates/immich_core/src/image.rs @@ -0,0 +1,173 @@ +//! EXIF-orientation rotation of RGBA8888 pixel buffers, ported from the Android +//! native_image.c (immich PR #29337). Lives here so the perf-critical pixel math +//! exists once, tested, callable from any platform's decode pipeline (Android RAW +//! today; the algorithm is platform-agnostic). The platform side keeps the bitmap +//! lock + output allocation and calls this to fill the destination buffer. + +// EXIF orientation values (androidx ExifInterface.ORIENTATION_*). +const FLIP_HORIZONTAL: i32 = 2; +const ROTATE_180: i32 = 3; +const FLIP_VERTICAL: i32 = 4; +const TRANSPOSE: i32 = 5; +const ROTATE_90: i32 = 6; +const TRANSVERSE: i32 = 7; +const ROTATE_270: i32 = 8; + +// 32x32 u32 tile = 4KB, L1-resident so a 90/270 transpose's scattered writes stay hot. +const TILE: usize = 32; + +/// Whether the orientation swaps width and height (the 90/270 + transpose family). +pub fn swaps_dims(orientation: i32) -> bool { + matches!(orientation, ROTATE_90 | ROTATE_270 | TRANSPOSE | TRANSVERSE) +} + +// (base, step_x, step_y): src pixel (sx,sy) maps to dst pixel index +// base + sx*step_x + sy*step_y for a destination of width `dw`. Mirrors +// native_image.c affine_for byte-for-byte. i64 so the math stays correct on 32-bit. +fn affine_for(o: i32, sw: i64, sh: i64, dw: i64) -> (i64, i64, i64) { + match o { + ROTATE_90 => (sh - 1, dw, -1), + ROTATE_270 => ((sw - 1) * dw, -dw, 1), + ROTATE_180 => ((sh - 1) * dw + (sw - 1), -1, -dw), + FLIP_HORIZONTAL => (sw - 1, -1, dw), + FLIP_VERTICAL => ((sh - 1) * dw, 1, -dw), + TRANSPOSE => (0, dw, 1), + TRANSVERSE => ((sw - 1) * dw + (sh - 1), -dw, -1), + _ => (0, 1, dw), + } +} + +/// Rotate `src` (RGBA8888, `sh` rows of `src_stride` bytes, `sw` pixels per row) into +/// `dst` (densely packed, `dw*dh*4` bytes) for the given EXIF orientation, where +/// (dw,dh) swap for the 90/270/transpose family. Returns `false` without touching +/// out-of-range memory if the sizes are inconsistent, so the caller can fall back. +/// Indexing is bounds-checked: a bad input fails safe (panic caught at the FFI +/// boundary / false here), never an out-of-bounds write like the raw C. +pub fn rotate_rgba8888( + src: &[u8], + src_stride: usize, + sw: usize, + sh: usize, + orientation: i32, + dst: &mut [u8], +) -> bool { + if sw == 0 || sh == 0 || src_stride < sw * 4 { + return false; + } + let dw = if swaps_dims(orientation) { sh } else { sw }; + let dh = if swaps_dims(orientation) { sw } else { sh }; + if src.len() < src_stride * sh || dst.len() < dw * dh * 4 { + return false; + } + let (base, step_x, step_y) = affine_for(orientation, sw as i64, sh as i64, dw as i64); + for ty in (0..sh).step_by(TILE) { + let y_end = (ty + TILE).min(sh); + for tx in (0..sw).step_by(TILE) { + let x_end = (tx + TILE).min(sw); + for sy in ty..y_end { + let row = sy * src_stride; + let mut idx = base + sy as i64 * step_y + tx as i64 * step_x; + for sx in tx..x_end { + let s = row + sx * 4; + let d = idx as usize * 4; + dst[d..d + 4].copy_from_slice(&src[s..s + 4]); + idx += step_x; + } + } + } + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + + // Independent textbook EXIF transform: src(sx,sy) -> dst(dx,dy). Verifies the + // affine port against orientation *semantics*, not against itself. + fn ref_dst_xy(o: i32, sx: usize, sy: usize, sw: usize, sh: usize) -> (usize, usize) { + match o { + FLIP_HORIZONTAL => (sw - 1 - sx, sy), + ROTATE_180 => (sw - 1 - sx, sh - 1 - sy), + FLIP_VERTICAL => (sx, sh - 1 - sy), + TRANSPOSE => (sy, sx), + ROTATE_90 => (sh - 1 - sy, sx), + TRANSVERSE => (sh - 1 - sy, sw - 1 - sx), + ROTATE_270 => (sy, sw - 1 - sx), + _ => (sx, sy), + } + } + + fn pixel(i: usize) -> [u8; 4] { + [ + (i & 0xff) as u8, + ((i >> 8) & 0xff) as u8, + ((i >> 16) & 0xff) as u8, + 0xff, + ] + } + + fn check(o: i32, sw: usize, sh: usize) { + let mut src = vec![0u8; sw * sh * 4]; + for sy in 0..sh { + for sx in 0..sw { + let i = sy * sw + sx; + src[i * 4..i * 4 + 4].copy_from_slice(&pixel(i)); + } + } + let (dw, dh) = if swaps_dims(o) { (sh, sw) } else { (sw, sh) }; + let mut dst = vec![0u8; dw * dh * 4]; + assert!(rotate_rgba8888(&src, sw * 4, sw, sh, o, &mut dst)); + for sy in 0..sh { + for sx in 0..sw { + let (dx, dy) = ref_dst_xy(o, sx, sy, sw, sh); + let di = dy * dw + dx; + let si = sy * sw + sx; + assert_eq!(&dst[di * 4..di * 4 + 4], &pixel(si), "o={o} src({sx},{sy})"); + } + } + } + + #[test] + fn all_orientations_match_exif_reference() { + for o in [1, 2, 3, 4, 5, 6, 7, 8] { + check(o, 4, 3); + check(o, 1, 5); + check(o, 5, 1); + check(o, 40, 33); // spans multiple tiles + } + } + + #[test] + fn identity_for_normal_orientation() { + let src: Vec = (0..24u8).collect(); // 2x3 RGBA + let mut dst = vec![0u8; 24]; + assert!(rotate_rgba8888(&src, 8, 2, 3, 1, &mut dst)); + assert_eq!(src, dst); + } + + #[test] + fn respects_src_stride_padding() { + let (sw, sh, stride) = (2usize, 2usize, 12usize); // 4 bytes row padding + let mut src = vec![0u8; stride * sh]; + for sy in 0..sh { + for sx in 0..sw { + let i = sy * sw + sx; + src[sy * stride + sx * 4..sy * stride + sx * 4 + 4].copy_from_slice(&pixel(i)); + } + } + let mut dst = vec![0u8; sw * sh * 4]; + assert!(rotate_rgba8888(&src, stride, sw, sh, ROTATE_180, &mut dst)); + for i in 0..4 { + assert_eq!(&dst[i * 4..i * 4 + 4], &pixel(3 - i)); // 180: i -> N-1-i + } + } + + #[test] + fn rejects_bad_sizes() { + let src = vec![0u8; 16]; + let mut small = vec![0u8; 4]; + assert!(!rotate_rgba8888(&src, 8, 2, 2, ROTATE_90, &mut small)); // dst too small + assert!(!rotate_rgba8888(&src, 4, 2, 2, 1, &mut small)); // stride < sw*4 + } +} diff --git a/native/crates/immich_core/src/lib.rs b/native/crates/immich_core/src/lib.rs new file mode 100644 index 0000000000..c42950469f --- /dev/null +++ b/native/crates/immich_core/src/lib.rs @@ -0,0 +1,26 @@ +//! immich_native_core — shared Rust core for the immich server (napi) and mobile (dart:ffi). +//! +//! Pure logic only: no binding or platform deps live here. Each binding crate +//! (`immich_core_dart`, `immich_core_napi`) is a thin wrapper. Capabilities are +//! cargo features (`hashing`, `image`, ...) so every binding opts into the same set. + +#[cfg(feature = "hashing")] +pub mod hashing; + +#[cfg(feature = "image")] +pub mod image; + +/// Version of the native core. Smoke-test entrypoint exercised by every binding. +pub fn core_version() -> &'static str { + env!("CARGO_PKG_VERSION") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn version_is_present() { + assert!(!core_version().is_empty()); + } +} diff --git a/native/crates/immich_core_dart/Cargo.toml b/native/crates/immich_core_dart/Cargo.toml new file mode 100644 index 0000000000..1c6fca7e6d --- /dev/null +++ b/native/crates/immich_core_dart/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "immich_core_dart" +version.workspace = true +edition.workspace = true +license.workspace = true + +# native_toolchain_rust requires cdylib (the bundled lib) + staticlib (iOS). It +# derives the artifact name from [package].name, so no [lib] name override here. +[lib] +crate-type = ["cdylib", "staticlib"] + +# hashing (SHA-1 asset identity) is the reason this lib exists — always on, so the +# cbindgen header + ffigen bindings always match the exported symbols. +[dependencies] +immich_core = { path = "../immich_core", default-features = false, features = ["hashing", "image"] } + +[build-dependencies] +cbindgen = { workspace = true } + +[lints] +workspace = true diff --git a/native/crates/immich_core_dart/build.rs b/native/crates/immich_core_dart/build.rs new file mode 100644 index 0000000000..4ff671d4b3 --- /dev/null +++ b/native/crates/immich_core_dart/build.rs @@ -0,0 +1,19 @@ +use std::path::Path; + +fn main() { + println!("cargo:rerun-if-changed=src/lib.rs"); + println!("cargo:rerun-if-changed=cbindgen.toml"); + + let crate_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap(); + let out = Path::new(&crate_dir).join("include").join("immich_core.h"); + std::fs::create_dir_all(out.parent().unwrap()).ok(); + + // Hard-fail, not a warning: the CI drift gate diffs this header, so a silent + // codegen failure would let a stale header sail through green. + match cbindgen::generate(&crate_dir) { + Ok(bindings) => { + bindings.write_to_file(&out); + } + Err(e) => panic!("cbindgen failed: {e}"), + } +} diff --git a/native/crates/immich_core_dart/cbindgen.toml b/native/crates/immich_core_dart/cbindgen.toml new file mode 100644 index 0000000000..4ceba6deb3 --- /dev/null +++ b/native/crates/immich_core_dart/cbindgen.toml @@ -0,0 +1,3 @@ +language = "C" +pragma_once = true +autogen_warning = "// Generated by cbindgen — do not edit." diff --git a/native/crates/immich_core_dart/include/immich_core.h b/native/crates/immich_core_dart/include/immich_core.h new file mode 100644 index 0000000000..c4ecc748ef --- /dev/null +++ b/native/crates/immich_core_dart/include/immich_core.h @@ -0,0 +1,60 @@ +#pragma once + +// Generated by cbindgen — do not edit. + +#include +#include +#include +#include + +/** + * Native core version as a NUL-terminated UTF-8 string. + * Free the result with [`immich_core_free_string`]. + */ +char *immich_core_version(void); + +/** + * SHA-1 (lowercase hex) of `len` bytes at `ptr`. Returns NULL on a null pointer. + * Free the result with [`immich_core_free_string`]. + * + * # Safety + * `ptr` must be valid for reads of `len` bytes. + */ +char *immich_core_sha1_hex(const unsigned char *ptr, uintptr_t len); + +/** + * SHA-1 (lowercase hex) of the file at `path` (NUL-terminated UTF-8), read via + * mmap — no Dart-side read or copy. Returns NULL on a null path, non-UTF-8 path, + * or any IO error. Free the result with [`immich_core_free_string`]. + * + * # Safety + * `path` must be a valid NUL-terminated C string, or null. + */ +char *immich_core_sha1_file(const char *path); + +/** + * Rotate an RGBA8888 image to the given EXIF `orientation`. `src` is `sh` rows of + * `src_stride` bytes; `dst` is the caller's densely-packed `dw*dh*4` output (dims + * swap for 90/270/transpose). Returns false (a safe no-op) on null pointers or + * inconsistent sizes so the caller can fall back. The platform side owns the + * bitmap lock + the dst allocation; this only fills dst. + * + * # Safety + * `src` must be valid for reads of `src_len` bytes and `dst` for writes of `dst_len`. + */ +bool immich_core_rotate_rgba8888(const uint8_t *src, + uintptr_t src_len, + uintptr_t src_stride, + uint32_t width, + uint32_t height, + int32_t orientation, + uint8_t *dst, + uintptr_t dst_len); + +/** + * Release a string returned by this library. + * + * # Safety + * `ptr` must be a pointer previously returned by this library, or null. + */ +void immich_core_free_string(char *ptr); diff --git a/native/crates/immich_core_dart/rust-toolchain.toml b/native/crates/immich_core_dart/rust-toolchain.toml new file mode 100644 index 0000000000..e59b0ae915 --- /dev/null +++ b/native/crates/immich_core_dart/rust-toolchain.toml @@ -0,0 +1,18 @@ +# The build hook (native_toolchain_rust) drives cargo via rustup and auto-installs +# this toolchain + targets. Pin a version (never bare stable/beta) for reproducible +# builds. Keep the channel in sync with mise.toml's rust pin. +[toolchain] +channel = "1.92.0" +targets = [ + # Android + "armv7-linux-androideabi", + "aarch64-linux-android", + "x86_64-linux-android", + # iOS (device + simulator) + "aarch64-apple-ios", + "aarch64-apple-ios-sim", + "x86_64-apple-ios", + # host (local test / macOS) + "aarch64-apple-darwin", + "x86_64-apple-darwin", +] diff --git a/native/crates/immich_core_dart/src/lib.rs b/native/crates/immich_core_dart/src/lib.rs new file mode 100644 index 0000000000..1b640c73fd --- /dev/null +++ b/native/crates/immich_core_dart/src/lib.rs @@ -0,0 +1,197 @@ +//! dart:ffi binding for immich_core (mobile). +//! +//! Returns heap-allocated C strings the caller must release with +//! `immich_core_free_string`. cbindgen emits `include/immich_core.h` at build time. +#![deny(clippy::unwrap_used, clippy::expect_used)] + +use std::ffi::{c_char, CStr, CString}; +use std::os::raw::c_uchar; +use std::ptr; + +/// Native core version as a NUL-terminated UTF-8 string. +/// Free the result with [`immich_core_free_string`]. +#[no_mangle] +pub extern "C" fn immich_core_version() -> *mut c_char { + guard(ptr::null_mut(), || { + into_c_string(immich_core::core_version().to_owned()) + }) +} + +/// SHA-1 (lowercase hex) of `len` bytes at `ptr`. Returns NULL on a null pointer. +/// Free the result with [`immich_core_free_string`]. +/// +/// # Safety +/// `ptr` must be valid for reads of `len` bytes. +#[no_mangle] +pub unsafe extern "C" fn immich_core_sha1_hex(ptr: *const c_uchar, len: usize) -> *mut c_char { + if ptr.is_null() { + return ptr::null_mut(); + } + // SAFETY: caller guarantees `ptr` is valid for reads of `len` bytes (see # Safety). + let bytes = unsafe { std::slice::from_raw_parts(ptr, len) }; + guard(ptr::null_mut(), || { + into_c_string(immich_core::hashing::sha1_hex(bytes)) + }) +} + +/// SHA-1 (lowercase hex) of the file at `path` (NUL-terminated UTF-8), read via +/// mmap — no Dart-side read or copy. Returns NULL on a null path, non-UTF-8 path, +/// or any IO error. Free the result with [`immich_core_free_string`]. +/// +/// # Safety +/// `path` must be a valid NUL-terminated C string, or null. +#[no_mangle] +pub unsafe extern "C" fn immich_core_sha1_file(path: *const c_char) -> *mut c_char { + if path.is_null() { + return ptr::null_mut(); + } + // SAFETY: caller guarantees `path` is a valid NUL-terminated C string (see # Safety). + let cpath = unsafe { CStr::from_ptr(path) }; + guard(ptr::null_mut(), || match cpath.to_str() { + Ok(s) => match immich_core::hashing::sha1_file(s) { + Ok(hex) => into_c_string(hex), + Err(_) => ptr::null_mut(), + }, + Err(_) => ptr::null_mut(), + }) +} + +/// Rotate an RGBA8888 image to the given EXIF `orientation`. `src` is `sh` rows of +/// `src_stride` bytes; `dst` is the caller's densely-packed `dw*dh*4` output (dims +/// swap for 90/270/transpose). Returns false (a safe no-op) on null pointers or +/// inconsistent sizes so the caller can fall back. The platform side owns the +/// bitmap lock + the dst allocation; this only fills dst. +/// +/// # Safety +/// `src` must be valid for reads of `src_len` bytes and `dst` for writes of `dst_len`. +#[no_mangle] +pub unsafe extern "C" fn immich_core_rotate_rgba8888( + src: *const u8, + src_len: usize, + src_stride: usize, + width: u32, + height: u32, + orientation: i32, + dst: *mut u8, + dst_len: usize, +) -> bool { + if src.is_null() || dst.is_null() { + return false; + } + // SAFETY: caller guarantees `src` is valid for reads of `src_len` bytes (see # Safety). + let src_slice = unsafe { std::slice::from_raw_parts(src, src_len) }; + // SAFETY: caller guarantees `dst` is valid for writes of `dst_len` bytes (see # Safety). + let dst_slice = unsafe { std::slice::from_raw_parts_mut(dst, dst_len) }; + // AssertUnwindSafe: the closure writes through `&mut dst_slice`, which isn't + // UnwindSafe, but a panic mid-rotate only leaves dst partially written — not a + // broken invariant — and we return false so the caller discards the buffer. + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + immich_core::image::rotate_rgba8888( + src_slice, + src_stride, + width as usize, + height as usize, + orientation, + dst_slice, + ) + })) + .unwrap_or(false) +} + +/// Release a string returned by this library. +/// +/// # Safety +/// `ptr` must be a pointer previously returned by this library, or null. +#[no_mangle] +pub unsafe extern "C" fn immich_core_free_string(ptr: *mut c_char) { + if ptr.is_null() { + return; + } + guard((), || { + // SAFETY: `ptr` came from this library's `CString::into_raw` (see # Safety). + let s = unsafe { CString::from_raw(ptr) }; + drop(s); + }); +} + +/// Run `f` at the FFI boundary, turning a panic into `sentinel` rather than +/// unwinding across `extern "C"` into the host. Guards panics only — a bad `len` +/// or a double/foreign free is caller-contract UB that stays the caller's +/// `# Safety` obligation, not something this can catch. +fn guard(sentinel: T, f: impl FnOnce() -> T + std::panic::UnwindSafe) -> T { + std::panic::catch_unwind(f).unwrap_or(sentinel) +} + +fn into_c_string(s: String) -> *mut c_char { + match CString::new(s) { + Ok(c) => c.into_raw(), + Err(_) => ptr::null_mut(), + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + use std::ffi::CStr; + + #[test] + fn version_roundtrips_and_frees() { + let p = immich_core_version(); + assert!(!p.is_null()); + // SAFETY: `p` is a non-null NUL-terminated string from this library. + let s = unsafe { CStr::from_ptr(p) }.to_str().unwrap(); + assert!(!s.is_empty()); + // SAFETY: `p` was returned by this library and is freed exactly once. + unsafe { immich_core_free_string(p) }; + } + + #[test] + fn sha1_null_ptr_returns_null() { + // SAFETY: a null ptr is the documented null-returning case. + let p = unsafe { immich_core_sha1_hex(ptr::null(), 0) }; + assert!(p.is_null()); + } + + #[test] + fn sha1_known_vector_roundtrips_and_frees() { + let input = b"abc"; + // SAFETY: `input` is valid for reads of `input.len()` bytes. + let p = unsafe { immich_core_sha1_hex(input.as_ptr(), input.len()) }; + assert!(!p.is_null()); + // SAFETY: `p` is a non-null NUL-terminated string from this library. + let s = unsafe { CStr::from_ptr(p) }.to_str().unwrap(); + assert_eq!(s, "a9993e364706816aba3e25717850c26c9cd0d89d"); + // SAFETY: `p` was returned by this library and is freed exactly once. + unsafe { immich_core_free_string(p) }; + } + + #[test] + fn free_null_is_noop() { + // SAFETY: free_string explicitly accepts null. + unsafe { immich_core_free_string(ptr::null_mut()) }; + } + + #[test] + fn sha1_file_roundtrips_and_frees() { + let path = std::env::temp_dir().join(format!("immich_core_ffi_{}.bin", std::process::id())); + std::fs::write(&path, b"abc").unwrap(); + let c = std::ffi::CString::new(path.to_str().unwrap()).unwrap(); + // SAFETY: `c` is a valid NUL-terminated path string. + let p = unsafe { immich_core_sha1_file(c.as_ptr()) }; + assert!(!p.is_null()); + // SAFETY: `p` is a non-null string from this library. + let s = unsafe { CStr::from_ptr(p) }.to_str().unwrap(); + assert_eq!(s, "a9993e364706816aba3e25717850c26c9cd0d89d"); + // SAFETY: `p` was returned by this library, freed once. + unsafe { immich_core_free_string(p) }; + std::fs::remove_file(&path).ok(); + } + + #[test] + fn sha1_file_null_returns_null() { + // SAFETY: a null path is the documented null-returning case. + let p = unsafe { immich_core_sha1_file(ptr::null()) }; + assert!(p.is_null()); + } +} diff --git a/native/crates/immich_core_napi/Cargo.toml b/native/crates/immich_core_napi/Cargo.toml new file mode 100644 index 0000000000..6553a373a6 --- /dev/null +++ b/native/crates/immich_core_napi/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "immich_core_napi" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +crate-type = ["cdylib"] + +[dependencies] +immich_core = { path = "../immich_core", default-features = false, features = ["hashing"] } +napi = { workspace = true, features = ["napi4", "dyn-symbols"] } +napi-derive = { workspace = true } + +[build-dependencies] +napi-build = { workspace = true } + +[lints] +workspace = true diff --git a/native/crates/immich_core_napi/build.rs b/native/crates/immich_core_napi/build.rs new file mode 100644 index 0000000000..0f1b01002b --- /dev/null +++ b/native/crates/immich_core_napi/build.rs @@ -0,0 +1,3 @@ +fn main() { + napi_build::setup(); +} diff --git a/native/crates/immich_core_napi/src/lib.rs b/native/crates/immich_core_napi/src/lib.rs new file mode 100644 index 0000000000..36ccece87e --- /dev/null +++ b/native/crates/immich_core_napi/src/lib.rs @@ -0,0 +1,24 @@ +//! napi-rs binding for immich_core (node server). +//! +//! Built as a cdylib loaded as a `.node` addon — the same shape as the server's +//! existing native deps (sharp, bcrypt). + +use napi_derive::napi; + +/// Native core version. JS: `core.coreVersion()`. +#[napi] +pub fn core_version() -> String { + immich_core::core_version().to_owned() +} + +/// SHA-1 (lowercase hex) of a buffer. JS: `core.sha1Hex(Buffer.from(...))`. +#[napi] +pub fn sha1_hex(bytes: napi::bindgen_prelude::Buffer) -> String { + immich_core::hashing::sha1_hex(bytes.as_ref()) +} + +/// SHA-1 (lowercase hex) of a file, read via mmap. JS: `core.sha1File(path)`. +#[napi] +pub fn sha1_file(path: String) -> napi::Result { + immich_core::hashing::sha1_file(&path).map_err(|e| napi::Error::from_reason(e.to_string())) +} diff --git a/native/immich_native_core/.gitignore b/native/immich_native_core/.gitignore new file mode 100644 index 0000000000..b9d7f25b91 --- /dev/null +++ b/native/immich_native_core/.gitignore @@ -0,0 +1,33 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.flutter-plugins-dependencies +/build/ +/coverage/ diff --git a/native/immich_native_core/README.md b/native/immich_native_core/README.md new file mode 100644 index 0000000000..053a156713 --- /dev/null +++ b/native/immich_native_core/README.md @@ -0,0 +1,48 @@ +# immich_native_core (Flutter package) + +dart:ffi bindings to the `immich_native_core` Rust core. The native code is **built +from source on every app build** via a Dart build hook (Flutter native assets) — no +prebuilt binaries, no `DynamicLibrary`, no platform plugin glue. + +## Use it from immich/mobile + +```yaml +# mobile/pubspec.yaml +dependencies: + immich_native_core: + path: ../native/immich_native_core +``` + +`dart pub get`, then call it: + +```dart +import 'package:immich_native_core/immich_native_core.dart'; + +final version = coreVersion(); +final hex = sha1Hex(bytes); // hash large inputs off the main isolate (worker_manager) +``` + +No app-level Gradle/Podfile edits. `hook/build.dart` compiles the Rust crate and +Flutter bundles it as a code asset; the `@Native` bindings resolve against it. +**Requirement:** every machine that builds the app needs [rustup](https://rustup.rs) +— the hook auto-installs the pinned toolchain + targets from the crate's +`rust-toolchain.toml`. + +## Layout + +- `hook/build.dart` — builds `../crates/immich_core_dart` via `native_toolchain_rust`. +- `lib/immich_native_core.dart` — barrel, the public API. +- `lib/src/{core,hashing,image}.dart` — thin wrappers, one file per Rust module. +- `lib/src/ffi/bindings.g.dart` — ffigen `@Native` output (committed; do not edit). +- `ffigen.yaml` — ffi-native mode; asset-id must match the hook's `assetName`. +- `test/` — host FFI roundtrip (`flutter test`); device runs via `mobile/integration_test`. + +## ⚠ iOS App Extensions + +Code assets are bundled into the app's **Runner** target. immich ships a Share +Extension and a Widget Extension — if the core is ever called from one of those, +verify the symbols resolve there (same family as the embed-into-Runner-only gotcha). +Not an issue while only the main app calls it. + +The Rust workspace, the codegen/build/test commands, and the "add a function" loop +live in [`../README.md`](../README.md). diff --git a/native/immich_native_core/analysis_options.yaml b/native/immich_native_core/analysis_options.yaml new file mode 100644 index 0000000000..a5744c1cfb --- /dev/null +++ b/native/immich_native_core/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/native/immich_native_core/ffigen.yaml b/native/immich_native_core/ffigen.yaml new file mode 100644 index 0000000000..e4127e1651 --- /dev/null +++ b/native/immich_native_core/ffigen.yaml @@ -0,0 +1,20 @@ +# Regenerate: `mise run codegen` (cbindgen header -> ffigen @Native bindings). +# ffi-native mode emits top-level @Native externals + a library @DefaultAsset +# pointing at the code asset hook/build.dart produces — no DynamicLibrary loader. +# asset-id MUST equal the generated file's package URI (and the hook's assetName). +name: ImmichNativeCoreBindings +ffi-native: + asset-id: 'package:immich_native_core/src/ffi/bindings.g.dart' +description: 'FFI bindings to immich_native_core — generated, do not edit.' +output: 'lib/src/ffi/bindings.g.dart' +headers: + entry-points: + - '../crates/immich_core_dart/include/immich_core.h' + include-directives: + - '**/immich_core.h' +functions: + include: + - 'immich_core_.*' +comments: + style: any + length: full diff --git a/native/immich_native_core/hook/build.dart b/native/immich_native_core/hook/build.dart new file mode 100644 index 0000000000..6c3c94087b --- /dev/null +++ b/native/immich_native_core/hook/build.dart @@ -0,0 +1,14 @@ +import 'package:hooks/hooks.dart'; +import 'package:native_toolchain_rust/native_toolchain_rust.dart'; + +// Builds crates/immich_core_dart from source on every app build and bundles it as +// a code asset. assetName must match the ffigen output (its package URI is the +// @Native DefaultAsset id). The crate is a sibling, so point cratePath at it. +void main(List args) async { + await build(args, (input, output) async { + await RustBuilder( + assetName: 'src/ffi/bindings.g.dart', + cratePath: '../crates/immich_core_dart', + ).run(input: input, output: output); + }); +} diff --git a/native/immich_native_core/lib/immich_native_core.dart b/native/immich_native_core/lib/immich_native_core.dart new file mode 100644 index 0000000000..006cea0e34 --- /dev/null +++ b/native/immich_native_core/lib/immich_native_core.dart @@ -0,0 +1,8 @@ +/// dart:ffi bindings to the immich_native_core Rust core (built from source via +/// Dart build hooks). Public API only — implementation lives in `src/`, organised +/// to mirror the Rust crate's modules (core / hashing / image). +library; + +export 'src/core.dart'; +export 'src/hashing.dart'; +export 'src/image.dart'; diff --git a/native/immich_native_core/lib/src/core.dart b/native/immich_native_core/lib/src/core.dart new file mode 100644 index 0000000000..f22418fc28 --- /dev/null +++ b/native/immich_native_core/lib/src/core.dart @@ -0,0 +1,5 @@ +import 'ffi/bindings.g.dart' as bindings; +import 'ffi/ffi.dart'; + +/// Version baked into the native core. Cheap — fine on the main isolate. +String coreVersion() => readAndFree(bindings.immich_core_version(), 'core_version'); diff --git a/native/immich_native_core/lib/src/ffi/bindings.g.dart b/native/immich_native_core/lib/src/ffi/bindings.g.dart new file mode 100644 index 0000000000..735e9d5897 --- /dev/null +++ b/native/immich_native_core/lib/src/ffi/bindings.g.dart @@ -0,0 +1,75 @@ +// AUTO GENERATED FILE, DO NOT EDIT. +// +// Generated by `package:ffigen`. +// ignore_for_file: type=lint, unused_import +@ffi.DefaultAsset('package:immich_native_core/src/ffi/bindings.g.dart') +library; + +import 'dart:ffi' as ffi; + +/// Native core version as a NUL-terminated UTF-8 string. +/// Free the result with [`immich_core_free_string`]. +@ffi.Native Function()>() +external ffi.Pointer immich_core_version(); + +/// SHA-1 (lowercase hex) of `len` bytes at `ptr`. Returns NULL on a null pointer. +/// Free the result with [`immich_core_free_string`]. +/// +/// # Safety +/// `ptr` must be valid for reads of `len` bytes. +@ffi.Native< + ffi.Pointer Function(ffi.Pointer, ffi.UintPtr) +>() +external ffi.Pointer immich_core_sha1_hex( + ffi.Pointer ptr, + int len, +); + +/// SHA-1 (lowercase hex) of the file at `path` (NUL-terminated UTF-8), read via +/// mmap — no Dart-side read or copy. Returns NULL on a null path, non-UTF-8 path, +/// or any IO error. Free the result with [`immich_core_free_string`]. +/// +/// # Safety +/// `path` must be a valid NUL-terminated C string, or null. +@ffi.Native Function(ffi.Pointer)>() +external ffi.Pointer immich_core_sha1_file( + ffi.Pointer path, +); + +/// Rotate an RGBA8888 image to the given EXIF `orientation`. `src` is `sh` rows of +/// `src_stride` bytes; `dst` is the caller's densely-packed `dw*dh*4` output (dims +/// swap for 90/270/transpose). Returns false (a safe no-op) on null pointers or +/// inconsistent sizes so the caller can fall back. The platform side owns the +/// bitmap lock + the dst allocation; this only fills dst. +/// +/// # Safety +/// `src` must be valid for reads of `src_len` bytes and `dst` for writes of `dst_len`. +@ffi.Native< + ffi.Bool Function( + ffi.Pointer, + ffi.UintPtr, + ffi.UintPtr, + ffi.Uint32, + ffi.Uint32, + ffi.Int32, + ffi.Pointer, + ffi.UintPtr, + ) +>() +external bool immich_core_rotate_rgba8888( + ffi.Pointer src, + int src_len, + int src_stride, + int width, + int height, + int orientation, + ffi.Pointer dst, + int dst_len, +); + +/// Release a string returned by this library. +/// +/// # Safety +/// `ptr` must be a pointer previously returned by this library, or null. +@ffi.Native)>() +external void immich_core_free_string(ffi.Pointer ptr); diff --git a/native/immich_native_core/lib/src/ffi/ffi.dart b/native/immich_native_core/lib/src/ffi/ffi.dart new file mode 100644 index 0000000000..89cd69b1d5 --- /dev/null +++ b/native/immich_native_core/lib/src/ffi/ffi.dart @@ -0,0 +1,19 @@ +import 'dart:ffi'; + +import 'package:ffi/ffi.dart'; + +import 'bindings.g.dart' as bindings; + +/// Read a C string the core returned into a Dart string and free it. A null +/// return means the native call failed (panic caught at the boundary, or error), +/// so we throw rather than hand back a silent empty value. +String readAndFree(Pointer ptr, String op) { + if (ptr == nullptr) { + throw StateError('immich_native_core: $op returned null'); + } + try { + return ptr.cast().toDartString(); + } finally { + bindings.immich_core_free_string(ptr); + } +} diff --git a/native/immich_native_core/lib/src/hashing.dart b/native/immich_native_core/lib/src/hashing.dart new file mode 100644 index 0000000000..e749db486e --- /dev/null +++ b/native/immich_native_core/lib/src/hashing.dart @@ -0,0 +1,34 @@ +import 'dart:ffi'; +import 'dart:typed_data'; + +import 'package:ffi/ffi.dart'; + +import 'ffi/bindings.g.dart' as bindings; +import 'ffi/ffi.dart'; + +/// Lowercase-hex SHA-1 of [bytes]. Reads every byte natively and blocks the +/// calling thread, so hash large inputs off the main isolate. +String sha1Hex(Uint8List bytes) { + // allocate at least 1 byte — malloc(0) may return null (allocator-defined), + // which package:ffi would reject. The native side still reads only [len] bytes. + final len = bytes.length; + final buf = malloc(len == 0 ? 1 : len); + try { + if (len > 0) buf.asTypedList(len).setAll(0, bytes); + return readAndFree(bindings.immich_core_sha1_hex(buf.cast(), len), 'sha1_hex'); + } finally { + malloc.free(buf); + } +} + +/// Lowercase-hex SHA-1 of the file at [path], hashed natively via mmap — the file +/// is never read into the Dart heap. Blocks the calling thread, so hash large +/// files off the main isolate. Throws if the file is missing/unreadable. +String sha1File(String path) { + final cpath = path.toNativeUtf8(); + try { + return readAndFree(bindings.immich_core_sha1_file(cpath.cast()), 'sha1_file'); + } finally { + malloc.free(cpath); + } +} diff --git a/native/immich_native_core/lib/src/image.dart b/native/immich_native_core/lib/src/image.dart new file mode 100644 index 0000000000..bb8c2e7cba --- /dev/null +++ b/native/immich_native_core/lib/src/image.dart @@ -0,0 +1,42 @@ +import 'dart:ffi'; +import 'dart:typed_data'; + +import 'package:ffi/ffi.dart'; + +import 'ffi/bindings.g.dart' as bindings; + +/// True if [orientation] (EXIF) swaps width and height (the 90/270/transpose family). +bool orientationSwapsDims(int orientation) => + orientation == 5 || orientation == 6 || orientation == 7 || orientation == 8; + +/// Rotate an RGBA8888 image to the given EXIF [orientation], returning a freshly +/// packed buffer (dims swap for 90/270/transpose). [srcStride] is bytes per source +/// row (>= width*4). Returns null if the native rotate declines (bad sizes). +/// +/// The production caller is the platform decode pipeline (it has the locked native +/// bitmap); this Dart entry mirrors that path and is what the host tests exercise. +Uint8List? rotateRgba8888(Uint8List src, int srcStride, int width, int height, int orientation) { + final dw = orientationSwapsDims(orientation) ? height : width; + final dh = orientationSwapsDims(orientation) ? width : height; + final dstLen = dw * dh * 4; + final srcPtr = malloc(src.isEmpty ? 1 : src.length); + final dstPtr = malloc(dstLen == 0 ? 1 : dstLen); + try { + if (src.isNotEmpty) srcPtr.asTypedList(src.length).setAll(0, src); + final ok = bindings.immich_core_rotate_rgba8888( + srcPtr.cast(), + src.length, + srcStride, + width, + height, + orientation, + dstPtr.cast(), + dstLen, + ); + if (!ok) return null; + return Uint8List.fromList(dstPtr.asTypedList(dstLen)); + } finally { + malloc.free(srcPtr); + malloc.free(dstPtr); + } +} diff --git a/native/immich_native_core/pubspec.yaml b/native/immich_native_core/pubspec.yaml new file mode 100644 index 0000000000..f1b49f0e3d --- /dev/null +++ b/native/immich_native_core/pubspec.yaml @@ -0,0 +1,26 @@ +name: immich_native_core +description: "dart:ffi bindings to the immich_native_core Rust core, built from source via Dart build hooks." +version: 0.1.0 +homepage: https://github.com/immich-app/immich +publish_to: none + +environment: + sdk: '>=3.11.0 <4.0.0' + flutter: '>=3.3.0' + +# Not a platform plugin: the native lib is built + bundled by hook/build.dart as a +# code asset (Flutter native assets), so there is no ffiPlugin / android / ios dir. +dependencies: + flutter: + sdk: flutter + ffi: ^2.2.0 + # build-hook deps — run at build time to compile the Rust crate (need rustup). + hooks: ^2.0.2 + native_toolchain_rust: ^1.0.4 + +dev_dependencies: + ffigen: 20.1.1 # pinned exact — a caret bump can re-emit bindings + crypto: ^3.0.7 # pure-Dart SHA-1 baseline for the perf bench only + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 diff --git a/native/immich_native_core/test/native_core_test.dart b/native/immich_native_core/test/native_core_test.dart new file mode 100644 index 0000000000..a119ae072c --- /dev/null +++ b/native/immich_native_core/test/native_core_test.dart @@ -0,0 +1,53 @@ +// Host FFI roundtrip — `flutter test` builds the hook for the host platform and +// resolves the @Native symbols, no device needed. (Device runs: example/integration_test.) +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_native_core/immich_native_core.dart'; + +void main() { + test('coreVersion returns a non-empty version', () { + expect(coreVersion(), isNotEmpty); + }); + + test('sha1Hex matches the FIPS-180 vector for "abc"', () { + expect( + sha1Hex(Uint8List.fromList(utf8.encode('abc'))), + 'a9993e364706816aba3e25717850c26c9cd0d89d', + ); + }); + + test('sha1Hex of empty input', () { + expect(sha1Hex(Uint8List(0)), 'da39a3ee5e6b4b0d3255bfef95601890afd80709'); + }); + + test('sha1File matches the in-memory hash (mmap path)', () { + final tmp = Directory.systemTemp.createTempSync('native_core'); + final path = '${tmp.path}/abc.bin'; + File(path).writeAsBytesSync(utf8.encode('abc')); + expect(sha1File(path), 'a9993e364706816aba3e25717850c26c9cd0d89d'); + tmp.deleteSync(recursive: true); + }); + + test('sha1File throws on a missing file', () { + expect(() => sha1File('/no/such/immich_native_core/file'), throwsStateError); + }); + + test('rotateRgba8888: 180 reverses pixels, 90 swaps dims', () { + // 2x1 image: pixel0 = red, pixel1 = green (RGBA). + final src = Uint8List.fromList([255, 0, 0, 255, 0, 255, 0, 255]); + final r180 = rotateRgba8888(src, 8, 2, 1, 3)!; // ROTATE_180 + expect(r180, [0, 255, 0, 255, 255, 0, 0, 255]); // green, red + + final r90 = rotateRgba8888(src, 8, 2, 1, 6)!; // ROTATE_90 -> 1x2 + expect(r90.length, 8); // dims swapped to 1x2, still 2 pixels + }); + + test('rotateRgba8888 returns null on an undersized result expectation', () { + // width*height*4 mismatch is guarded natively; a 0x0 image yields empty. + final empty = rotateRgba8888(Uint8List(0), 0, 0, 0, 1); + expect(empty, anyOf(isNull, isEmpty)); + }); +} diff --git a/native/immich_native_core/test/sha1_bench.dart b/native/immich_native_core/test/sha1_bench.dart new file mode 100644 index 0000000000..417eea37cb --- /dev/null +++ b/native/immich_native_core/test/sha1_bench.dart @@ -0,0 +1,82 @@ +// SHA-1 perf bench — run explicitly: `flutter test test/sha1_bench.dart`. +// (Not named *_test.dart so it stays out of the default `flutter test` run.) +// +// Three ways to hash a file, to isolate where the win is: +// A) sha1File(path) — Rust: open + mmap + HW-SHA, no Dart read +// B) File.read + sha1Hex(bytes) — Dart reads into heap, Rust HW-SHA the bytes +// C) File.read + crypto.sha1(bytes) — Dart reads into heap, pure-Dart SHA-1 (naive) +// A vs B = mmap/zero-copy win; B vs C = HW-SHA vs pure-Dart; A vs C = total vs naive. +// +// IMPORTANT — C (pure-Dart) is NOT immich's real baseline. immich already hashes +// assets natively + hardware-accelerated on BOTH platforms (Android Kotlin +// MessageDigest SHA-1, iOS Swift CryptoKit Insecure.SHA1), streamed over a read +// buffer, via pigeon. So the real-world comparison is A vs ~B (Rust mmap vs a +// buffered native read with HW-SHA), i.e. roughly the A/B gap (~1.3x), NOT A/C. +// ignore_for_file: avoid_print +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart' as crypto; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_native_core/immich_native_core.dart'; + +void main() { + test('sha1 throughput: mmap(Rust) vs read+Rust vs read+pure-Dart', () { + final tmp = Directory.systemTemp.createTempSync('sha1_bench'); + final sizesMb = [1, 16, 64, 256]; + + double msMin(int iters, void Function() f) { + var best = double.infinity; + for (var i = 0; i < iters; i++) { + final sw = Stopwatch()..start(); + f(); + sw.stop(); + final ms = sw.elapsedMicroseconds / 1000.0; + if (ms < best) best = ms; + } + return best; + } + + String mbps(int mb, double ms) => (mb / (ms / 1000.0)).toStringAsFixed(0); + + print(''); + print('size │ A mmap(Rust) │ B read+Rust │ C read+pureDart │ A vs C'); + print('─────┼─────────────────┼─────────────────┼──────────────────┼───────'); + for (final mb in sizesMb) { + final path = '${tmp.path}/f_$mb.bin'; + final chunk = Uint8List(1 << 20); // 1 MiB pattern + for (var i = 0; i < chunk.length; i++) { + chunk[i] = (i * 31 + 7) & 0xff; + } + final sink = File(path).openSync(mode: FileMode.write); + for (var i = 0; i < mb; i++) { + sink.writeFromSync(chunk); + } + sink.closeSync(); + + final bytes = File(path).readAsBytesSync(); // for B/C; also baked into their totals below + final tRead = msMin(3, () => File(path).readAsBytesSync()); + + final tA = msMin(5, () => sha1File(path)); + final tB = tRead + msMin(5, () => sha1Hex(bytes)); + final tC = tRead + msMin(2, () => crypto.sha1.convert(bytes)); + + final hA = sha1File(path); + final hB = sha1Hex(bytes); + final hC = crypto.sha1.convert(bytes).toString(); + expect(hA, hB); + expect(hA, hC); + + final speedup = (tC / tA).toStringAsFixed(1); + String cell(double ms, int mb) => + '${ms.toStringAsFixed(1)}ms ${mbps(mb, ms).padLeft(5)}MB/s'; + print( + '${mb.toString().padLeft(3)}M │ ${cell(tA, mb)} │ ${cell(tB, mb)} │ ${cell(tC, mb).padRight(16)} │ ${speedup}x', + ); + } + print('(A/B/C identical SHA-1; read time included in B/C totals.)'); + print('(NOTE: immich already hashes natively+HW on both platforms — real'); + print(' baseline ~= B, not C. Rust mmap edge over it is the A/B gap (~1.3x).)'); + tmp.deleteSync(recursive: true); + }, timeout: const Timeout(Duration(minutes: 10))); +} diff --git a/native/mise.toml b/native/mise.toml new file mode 100644 index 0000000000..5600ae6ee5 --- /dev/null +++ b/native/mise.toml @@ -0,0 +1,68 @@ +[tools] +rust = "1.92.0" # keep in sync with rust-toolchain.toml (the build hook uses rustup) + +[tasks.build] +description = "Build all native core crates (host)" +run = "cargo build --workspace" + +[tasks.test] +description = "Run native core Rust tests" +run = "cargo test --workspace" + +[tasks.fmt] +description = "Format all crates" +run = "cargo fmt --all" + +[tasks.lint] +description = "Clippy (warnings = errors)" +run = "cargo clippy --workspace --all-targets -- -D warnings" + +# Regen the committed cbindgen header + ffigen @Native bindings. +[tasks."codegen:ffigen"] +alias = "codegen" +description = "Generate the C header (cbindgen) + Dart @Native bindings (ffigen)" +sources = [ + "crates/immich_core_dart/src/lib.rs", + "crates/immich_core_dart/cbindgen.toml", + "immich_native_core/ffigen.yaml", +] +outputs = [ + "crates/immich_core_dart/include/immich_core.h", + "immich_native_core/lib/immich_native_core_bindings_generated.dart", +] +run = [ + "cargo build -p immich_core_dart", + "cd immich_native_core && dart run ffigen --config ffigen.yaml && dart format lib/immich_native_core_bindings_generated.dart", +] + +# Host FFI roundtrip through the real build hook — no device. Builds the Rust crate +# via rustup + resolves the @Native code asset. +[tasks."test:flutter"] +description = "Host FFI roundtrip via the build hook (flutter test)" +dir = "immich_native_core" +run = "flutter test" + +[tasks."build:dart"] +description = "Build the dart:ffi cdylib directly (host, raw cargo)" +run = "cargo build -p immich_core_dart" + +[tasks."build:napi"] +description = "Build the node addon + stage a .node for require() (server, unwired)" +run = [ + "cargo build -p immich_core_napi --release", + "cp target/release/libimmich_core_napi.dylib smoke/immich_core_napi.node 2>/dev/null || cp target/release/libimmich_core_napi.so smoke/immich_core_napi.node", +] + +[tasks."smoke:dart"] +description = "Host dart:ffi ABI roundtrip (raw DynamicLibrary on the cdylib)" +depends = ["build:dart"] +run = "dart run smoke/dart_smoke.dart target/debug/libimmich_core_dart.dylib" + +[tasks."smoke:node"] +description = "Host napi roundtrip" +depends = ["build:napi"] +run = "node smoke/node_smoke.mjs" + +[tasks.smoke] +description = "Rust tests + host dart:ffi + host napi roundtrips" +depends = ["test", "smoke:dart", "smoke:node"] diff --git a/native/scripts/build-linux.sh b/native/scripts/build-linux.sh new file mode 100755 index 0000000000..2df9df7915 --- /dev/null +++ b/native/scripts/build-linux.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Cross-build the napi addon for Linux server (x86_64 + aarch64) via zigbuild +# (no Docker) and stage as .node under dist/server//. +# In CI you'd build these natively per-arch instead; this is local convenience. +set -euo pipefail +cd "$(dirname "$0")/.." + +CRATE=immich_core_napi + +for t in x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu; do + rustup target add "$t" >/dev/null 2>&1 || true + cargo zigbuild -p "$CRATE" --target "$t" --release + mkdir -p "dist/server/$t" + cp "target/$t/release/lib${CRATE}.so" "dist/server/$t/immich_core_napi.node" +done + +echo "linux -> dist/server/*/immich_core_napi.node" diff --git a/native/smoke/dart_smoke.dart b/native/smoke/dart_smoke.dart new file mode 100644 index 0000000000..ca1d0ad796 --- /dev/null +++ b/native/smoke/dart_smoke.dart @@ -0,0 +1,31 @@ +// Mobile-side roundtrip: open the dart:ffi cdylib and call into the shared core. +// Standalone script (no package:ffi dep) — reads the returned C string by hand. +// +// dart run smoke/dart_smoke.dart target/debug/libimmich_core_dart.dylib + +import 'dart:ffi'; + +typedef _VersionNative = Pointer Function(); +typedef _FreeNative = Void Function(Pointer); +typedef _FreeDart = void Function(Pointer); + +String _readCString(Pointer p) { + final bytes = []; + for (var i = 0; p[i] != 0; i++) { + bytes.add(p[i]); + } + return String.fromCharCodes(bytes); +} + +void main(List args) { + final libPath = args.isNotEmpty ? args.first : 'target/debug/libimmich_core_dart.dylib'; + final lib = DynamicLibrary.open(libPath); + + final version = lib.lookupFunction<_VersionNative, _VersionNative>('immich_core_version'); + final free = lib.lookupFunction<_FreeNative, _FreeDart>('immich_core_free_string'); + + final ptr = version(); + print('DART core_version = ${_readCString(ptr)}'); + free(ptr); + print('DART roundtrip OK'); +} diff --git a/native/smoke/node_smoke.mjs b/native/smoke/node_smoke.mjs new file mode 100644 index 0000000000..d9a054c8cb --- /dev/null +++ b/native/smoke/node_smoke.mjs @@ -0,0 +1,17 @@ +// Server-side roundtrip: load the napi addon and call into the shared core. +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const core = require('./immich_core_napi.node'); + +const version = core.coreVersion(); +console.log(`NAPI core_version = ${version}`); + +const hash = core.sha1Hex(Buffer.from('abc')); +console.log(`NAPI sha1Hex("abc") = ${hash}`); + +if (hash !== 'a9993e364706816aba3e25717850c26c9cd0d89d') { + console.error('NAPI sha1 mismatch'); + process.exit(1); +} +console.log('NAPI roundtrip OK');