Compare commits

...

1 Commits

Author SHA1 Message Date
Santo Shakil bb8e242fbe feat: add native rust core 2026-06-29 20:53:47 +06:00
39 changed files with 2039 additions and 0 deletions
+1
View File
@@ -5,3 +5,4 @@
/machine-learning/ @mertalev
/e2e/ @danieldietzler
/mobile/ @shenlong-tanwen @santoshakil
/native/ @santoshakil @mertalev
@@ -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 <device>
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]);
});
}
+23
View File
@@ -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:
+2
View File
@@ -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
+5
View File
@@ -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)
+619
View File
@@ -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"
+44
View File
@@ -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
+61
View File
@@ -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).
+17
View File
@@ -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
+75
View File
@@ -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<Path>) -> io::Result<String> {
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<u8> = (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());
}
}
+173
View File
@@ -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<u8> = (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
}
}
+26
View File
@@ -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());
}
}
+21
View File
@@ -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
+19
View File
@@ -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}"),
}
}
@@ -0,0 +1,3 @@
language = "C"
pragma_once = true
autogen_warning = "// Generated by cbindgen — do not edit."
@@ -0,0 +1,60 @@
#pragma once
// Generated by cbindgen — do not edit.
#include <stdarg.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
/**
* 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);
@@ -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",
]
+197
View File
@@ -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<T>(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());
}
}
+19
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
fn main() {
napi_build::setup();
}
+24
View File
@@ -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<String> {
immich_core::hashing::sha1_file(&path).map_err(|e| napi::Error::from_reason(e.to_string()))
}
+33
View File
@@ -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/
+48
View File
@@ -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).
@@ -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
+20
View File
@@ -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
+14
View File
@@ -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<String> 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);
});
}
@@ -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';
@@ -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');
@@ -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<ffi.Pointer<ffi.Char> Function()>()
external ffi.Pointer<ffi.Char> 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<ffi.Char> Function(ffi.Pointer<ffi.UnsignedChar>, ffi.UintPtr)
>()
external ffi.Pointer<ffi.Char> immich_core_sha1_hex(
ffi.Pointer<ffi.UnsignedChar> 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<ffi.Pointer<ffi.Char> Function(ffi.Pointer<ffi.Char>)>()
external ffi.Pointer<ffi.Char> immich_core_sha1_file(
ffi.Pointer<ffi.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`.
@ffi.Native<
ffi.Bool Function(
ffi.Pointer<ffi.Uint8>,
ffi.UintPtr,
ffi.UintPtr,
ffi.Uint32,
ffi.Uint32,
ffi.Int32,
ffi.Pointer<ffi.Uint8>,
ffi.UintPtr,
)
>()
external bool immich_core_rotate_rgba8888(
ffi.Pointer<ffi.Uint8> src,
int src_len,
int src_stride,
int width,
int height,
int orientation,
ffi.Pointer<ffi.Uint8> 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<ffi.Void Function(ffi.Pointer<ffi.Char>)>()
external void immich_core_free_string(ffi.Pointer<ffi.Char> ptr);
@@ -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<Char> ptr, String op) {
if (ptr == nullptr) {
throw StateError('immich_native_core: $op returned null');
}
try {
return ptr.cast<Utf8>().toDartString();
} finally {
bindings.immich_core_free_string(ptr);
}
}
@@ -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<Uint8>(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);
}
}
@@ -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<Uint8>(src.isEmpty ? 1 : src.length);
final dstPtr = malloc<Uint8>(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);
}
}
+26
View File
@@ -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
@@ -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));
});
}
@@ -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)));
}
+68
View File
@@ -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"]
+17
View File
@@ -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/<target>/.
# 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"
+31
View File
@@ -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<Uint8> Function();
typedef _FreeNative = Void Function(Pointer<Uint8>);
typedef _FreeDart = void Function(Pointer<Uint8>);
String _readCString(Pointer<Uint8> p) {
final bytes = <int>[];
for (var i = 0; p[i] != 0; i++) {
bytes.add(p[i]);
}
return String.fromCharCodes(bytes);
}
void main(List<String> 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');
}
+17
View File
@@ -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');