Skip to main content

dart_smb2 0.2.0 copy "dart_smb2: ^0.2.0" to clipboard
dart_smb2: ^0.2.0 copied to clipboard

Dart client for SMB2/3 built on libsmb2. Listings, reads, streaming, atomic downloads, writes and cancellation from worker isolates. Targets macOS, Windows, Linux, iOS and Android.

dart_smb2 #

SMB2/3 client for Dart and Flutter.

logo dart_smb2 is a Dart client for SMB2 and SMB3 file shares, built on libsmb2. It lists, reads, streams, downloads and writes files on Samba, Windows and NAS shares from worker isolates that reconnect on their own.

Installation #

Add dart_smb2 to your pubspec.yaml:

dependencies:
  dart_smb2: ^0.2.0

Platform requirements #

Platform Minimum Architecture Size Device Emulator
Android 7.0 (SDK 24) arm64-v8a, armeabi-v7a, x86_64 246, 233, 253 KB Yes Yes
iOS 13.0 arm64, x86_64 271, 235 KB Yes Yes
macOS 10.15 arm64, x86_64 257, 239 KB Yes -
Windows 10 arm64, x86_64 238, 256 KB Yes -
Linux glibc 2.25, built on 2.31 (Ubuntu 20.04, Debian 11) aarch64, x86_64 259, 268 KB Yes -

The package needs Dart 3.10, and Flutter 3.38 in a Flutter app. It is a plain Dart package: command-line tools and servers use it as well.

How libsmb2 reaches the app #

A Dart build hook downloads the libsmb2 build for each target from this repository's releases, checks it against a pinned SHA-256 and bundles it with the app, as libdart_smb2.so on Android and Linux, dart_smb2.dll on Windows and libdart_smb2.framework on iOS and macOS. The first build of each target needs network access; later builds of the same project reuse the download, until flutter clean (or a fresh clone) removes it.

For offline builds or a libsmb2 of your own, point the hook at local files in your app's pubspec.yaml:

hooks:
  user_defines:
    dart_smb2:
      libsmb2: path/to/libsmb2

The path can be a directory holding files named like the release assets (libsmb2_android-arm64-v8a.so, libsmb2_ios.xcframework.zip and so on), such as the release_builds/ folder of libsmb2-scripts, and each target takes its own. A single file is bundled for every target, so it only suits a build for one target and architecture. Local files are bundled without a checksum, and must be builds of the pinned release, libsmb2-r9 from libsmb2-scripts 0.2.0 or later: an older libsmb2 loads, but reads the stat fields wrong and lacks the lock that lets the workers connect in parallel.


Contents #


Features #

Pure Dart
no Flutter plugin: a build hook bundles libsmb2 for Android, iOS, Linux, macOS and Windows, checked against pinned SHA-256s.
Worker isolates
Smb2Pool runs one connection per isolate and sends each call to the least busy one; the UI isolate never waits on the network.
Reconnects
a dropped connection is reconnected once for every caller, the call repeated when that is safe, and open files reopened.
Streaming and downloads
streamFile() reads one chunk ahead; downloadToFile() writes at the disk's pace and renames into place only when complete.
Cancellation
every call on a connected pool but the closing ones takes an Smb2CancelToken, the same as dart_plex's and dart_jellyfin's.
Typed errors
every failure is a Smb2Exception classified from the server's NT status: auth, not found, network, transient, cancelled.
No extra copies
reads land in native buffers that reach your isolate without a copy.
SMB3 security
NTLM sign-in, encryption and signing, and the dialect you choose, up to SMB 3.1.1.
Files and directories
listings with metadata, ranges, handles, writes, renames, timestamps, share space and share enumeration.
Synchronous client
Smb2Client, the client the workers run, for code that manages its own isolates.

Quick start #

import 'package:dart_smb2/dart_smb2.dart';

Future<void> main() async {
  final pool = await Smb2Pool.connect(
    host: '192.168.1.10',
    share: 'Music',
    user: 'alice',
    password: 'secret',
  );
  try {
    // List the share's root.
    for (final entry in await pool.listDirectory('')) {
      print('${entry.name}  ${entry.isDirectory ? 'dir' : '${entry.size} B'}');
    }

    // Read a small file whole.
    final cover = await pool.readFile('Album/cover.jpg');
    print('cover: ${cover.length} bytes');

    // Download a large one to disk.
    final bytes = await pool.downloadToFile(
      'Album/01 - Intro.flac',
      '/tmp/intro.flac',
      onProgress: (received, total) => print('$received / $total'),
    );
    print('downloaded $bytes bytes');
  } on Smb2Exception catch (e) {
    print('SMB error: ${e.type.name}: ${e.message}');
  } finally {
    await pool.disconnect();
  }
}

Guide #

Smb2Pool is the entry point. It runs a few worker isolates, each with its own connection to the share and its own Smb2Client, the synchronous client over libsmb2. Your code calls the pool from any isolate; each call goes to the least busy worker and its result comes back without blocking the caller.

Architecture: your code calls Smb2Pool, which sends each request to one of its worker isolates; each worker runs an Smb2Client over a libsmb2 context of its own and holds a connection to the SMB server

1. Connection and lifecycle #

Pool lifecycle: Smb2Pool.connect, calls, withFile and streamFile, disconnect

1.1 Connecting a pool

final pool = await Smb2Pool.connect(
  host: 'nas.local',
  share: 'Music',
  user: 'alice',
  password: 'secret',
  workers: 4,
);

The workers connect in parallel. When one of them cannot, the others are closed and connect throws that worker's Smb2Exception: auth for wrong credentials, notFound for a share that does not exist, a network failure for a host that does not answer.

1.2 Connection options

Argument Default Description
user, password, domain none NTLM credentials. No user means guest
workers 4 Isolates, each with its own connection
timeout 30 s Bounds every request, the connection included
seal false Require SMB3 encryption
signing false Require signed messages
version Smb2Version.any The dialect to negotiate, from v202 to v311
final secure = await Smb2Pool.connect(
  host: 'nas.local',
  share: 'Private',
  user: 'alice',
  password: 'secret',
  seal: true,
  version: Smb2Version.v311,
  timeout: const Duration(seconds: 10),
);

1.3 Listing the shares of a server

listSharesOn needs no pool: it connects to IPC$ from a short-lived isolate, lists the shares and disconnects.

final shares = await Smb2Pool.listSharesOn(
  host: 'nas.local',
  user: 'alice',
  password: 'secret',
);
for (final share in shares.where((s) => s.isDisk && !s.isHidden)) {
  print('${share.name}  ${share.remark}');
}

1.4 Disconnecting

await pool.disconnect();

Each worker finishes the requests it has already been sent, closes its open files and logs off, and its isolate ends; a worker that does not finish within 5 seconds is killed, and its requests fail with a connection error. A reconnect in progress is aborted. Calls waiting for a reconnect, and later calls, fail with a state error. Nothing outlives the call. A read the killed worker was in the middle of loses its buffer, up to the length it asked for.

2. Paths #

Paths are relative to the share: '' is its root, Album/cover.jpg a file in it. A leading slash is dropped and backslashes become slashes, so /Album\cover.jpg names the same file. A path with a NUL character fails with invalidParam before it reaches the server.

3. Directories and metadata #

3.1 Listing a directory

final entries = await pool.listDirectory('Album');
for (final e in entries) {
  print('${e.name} ${e.stat.modified} ${e.isFile ? e.size : '-'}');
}

The listing carries each entry's metadata, so no request per entry is needed. . and .. are left out. Smb2Stat.isHidden reports the server's hidden attribute, as the server sets it: Samba sets it on dot files by default (hide dot files), and the library adds no rule of its own.

3.2 Stat, size and existence

final stat = await pool.stat('Album/01 - Intro.flac');
print('${stat.size} bytes, modified ${stat.modified}');

final size = await pool.fileSize('Album/01 - Intro.flac');
final there = await pool.exists('Album/booklet.pdf');

Smb2Stat holds the type, the size, the modified, created, accessed and changed times (UTC, to the microsecond) and the file attributes. exists returns false only for a missing path; any other failure throws.

3.3 Space on the share

final vfs = await pool.statvfs('');
print('${vfs.availableSize} of ${vfs.totalSize} bytes free');

4. Reading files #

4.1 Whole files and ranges

final cover = await pool.readFile('Album/cover.jpg');
final header = await pool.readFileRange(
  'Album/01 - Intro.flac',
  length: 64 * 1024,
);

A range returns fewer bytes than asked only when the file ends first. Reads larger than what the server accepts in one request are split for you.

4.2 A file open for several reads

withFile opens a file once, hands it to your callback and closes it on every exit path.

final tag = await pool.withFile('Album/01 - Intro.flac', (file) async {
  final head = await file.read(length: 10);
  final tail = await file.read(offset: file.size - 128, length: 128);
  return (head, tail);
});

Pass knownSize when a listing already gave the size: the open then skips the size request.

4.3 Handles

For files that stay open across calls, open a handle and close it yourself.

final (handle, size) = await pool.openFileWithSize('Album/01 - Intro.flac');
try {
  final first = await pool.readFromHandle(handle, length: 4096);
  final last = await pool.readFromHandle(handle, offset: size - 4096, length: 4096);
} finally {
  await pool.closeHandle(handle);
}

A handle stays on the worker that opened it. If that worker reconnects, the handle is opened again before its next call. Closing a handle twice does nothing; any other use of a closed handle throws a state error. A handle dropped without being closed is closed when it is garbage collected.

5. Streaming and downloads #

Streaming: the worker reads the next chunk while your isolate writes the current one to a part file, which is renamed to the destination at the end

5.1 Streaming a file

final stream = pool.streamFile(
  'Album/01 - Intro.flac',
  onProgress: (received, total) => print('$received / $total'),
);
await for (final chunk in stream) {
  print('${chunk.length} bytes');
}

The stream keeps one file handle open and reads the next chunk while you handle the current one. It waits while its listener is paused, so the stream holds at most two chunks (4 MiB each by default, see chunkSize); a chunk you are done with is freed when it is garbage collected, so many streams at once can use a multiple of that for a while. Cancelling the subscription, or the token, closes the handle and stops the read in flight; the subscription's future completes without error.

5.2 Downloading to disk

final written = await pool.downloadToFile(
  'Album/01 - Intro.flac',
  '/storage/music/01 - Intro.flac',
  onProgress: (received, total) => print('${received * 100 ~/ total}%'),
);

The data goes to a temporary file next to the destination as it arrives, at the pace the disk takes it, and is renamed to the destination only once complete. A failure or a cancellation removes the temporary file and leaves the destination as it was.

6. Writing files #

6.1 Whole files and ranges

await pool.writeFile('Notes/todo.txt', utf8.encode('buy milk\n'));
await pool.writeFileRange('Notes/todo.txt', utf8.encode('eggs\n'), offset: 9);

writeFile replaces the file, creating it if needed; writeFileRange writes at an offset and leaves the rest of the file as it is. The data is copied once into a native buffer in the calling isolate, at memory speed (about 9 ms for 64 MiB); for large writes from the UI isolate, prefer streamWrite with chunks of a few MiB.

6.2 Streaming a write

final upload = File('/storage/music/01 - Intro.flac')
    .openRead()
    .map(Uint8List.fromList);
await pool.streamWrite('Backup/01 - Intro.flac', upload);

The chunks go through one handle, each at its own offset, so only one is in memory at a time and a reconnect repeats just the chunk that failed.

6.3 Write handles

final out = await pool.openFileWrite('Backup/log.bin', truncate: true);
try {
  await pool.writeToHandle(out, Uint8List.fromList([1, 2, 3]));
  await pool.writeToHandle(out, Uint8List.fromList([4]), offset: 3);
  await pool.fsyncHandle(out);
} finally {
  await pool.closeHandle(out);
}

ftruncateHandle cuts or extends the file of a handle.

7. Managing files and directories #

await pool.mkdir('Backup/2026');
await pool.rename('Backup/log.bin', 'Backup/2026/log.bin');
await pool.truncate('Backup/2026/log.bin', 2);
await pool.setFileTimes(
  'Backup/2026/log.bin',
  modified: DateTime.utc(2026, 1, 1),
);
await pool.deleteFile('Backup/2026/log.bin');
await pool.rmdir('Backup/2026');

rmdir fails with notEmpty while the directory has entries, and with notADirectory on a file; deleteFile fails with isADirectory on a directory and on a link to one: remove those with rmdir (Windows junctions and directory symlinks), though Samba cannot remove a directory symlink over SMB2 at all. Set times after the file's write handles are closed: the server refreshes the modified time on write and close.

8. Cancellation #

Every call on a connected pool takes an Smb2CancelToken, except closeHandle, disconnect and the deprecated listShares. A cancelled call throws an Smb2Exception of type cancelled at once, also while it waits for a reconnect; a request the worker already sent finishes on the server and its result is dropped, and a file it opened is closed.

final token = Smb2CancelToken();
final download = pool.downloadToFile(
  'Album/01 - Intro.flac',
  '/tmp/intro.flac',
  cancelToken: token,
);

// The user pressed stop:
token.cancel();

try {
  await download;
} on Smb2Exception catch (e) {
  if (!e.isCancelled) rethrow;
}

One token cancels any number of calls, once. Make a new token for the next page, folder or download.

9. Errors and reconnects #

9.1 Error types

Every failure is an Smb2Exception. Its type comes from the NT status the server answered with, from the errno libsmb2 reported, or from a transport failure, and reads the same on every platform.

Type Meaning
connection The connection failed or was lost, the host name does not resolve, or the server dropped the session or a handle
timeout The server did not answer within the timeout
auth Wrong credentials, or the account cannot log on. A server that maps unknown users to guest refuses them at the share with accessDenied instead
notFound The path or the share does not exist
accessDenied No permission for the operation
notADirectory, isADirectory A file where a directory was expected, or the opposite
alreadyExists, notEmpty The target exists; the directory has entries
inUse Another client holds the file open or locked, or the server is out of resources for the moment
diskFull Out of space or over quota
io, invalidParam, notSupported Read/write failure, bad argument, unsupported operation
state Closed pool, closed handle, client not connected
cancelled Cancelled through an Smb2CancelToken
unknown Anything else

The predicates group them the way dart_plex and dart_jellyfin do:

try {
  await pool.listDirectory('Album');
} on Smb2Exception catch (e) {
  if (e.isAuthError) {
    print('ask for the password again');
  } else if (e.isNotFound) {
    print('gone');
  } else if (e.isTransient) {
    print('try again later');
  } else if (!e.isCancelled) {
    print('${e.type.name}: ${e.message}');
  }
}

isNetworkFailure covers connection and timeout: the connection, the session or a handle was lost, or the server did not answer, and a reconnect may recover. isTransient adds inUse. ntStatus and errno keep the raw codes: ntStatus only when the server answered this call with it (libsmb2 keeps the status of earlier calls around), and errno for an open or a listing comes from that status. Both are null for a transport failure.

9.2 Reconnects

A reconnect: the worker loses the connection, the pool spawns and connects a new worker and sends the call again, and the result reaches your code

When a call fails with a network failure, the pool replaces the worker with a new connection — once, however many calls saw it fail — and repeats the call when repeating cannot change the outcome: reads, listings, metadata, writes at an offset, whole-file writes, truncates and timestamps. mkdir, rmdir, deleteFile and rename are not repeated, because the server may have done them before the connection dropped; their error reaches you and the next call uses the new connection. Open handles are reopened on the new worker before their next call. When the new connection cannot be made, the call is repeated on another worker, and the slot tries again in the background, after 1, 2, 4 and up to 32 seconds, when the network failed. When the server refused the connection (credentials, the share), the pool keeps that answer for 5 minutes: calls that need a new connection get it at once without asking the server, and one login is tried per interval, however many workers and calls.

10. The synchronous client #

Smb2Client is the client each worker runs. Every call blocks until the server answers, so use it from an isolate of your own.

final summary = await Isolate.run(() {
  final client = Smb2Client()
    ..connect(host: 'nas.local', share: 'Music', user: 'alice', password: 'secret');
  try {
    final entries = client.listDirectory('');
    return '${entries.length} entries, ${client.maxReadSize} B per read';
  } finally {
    client.disconnect();
  }
});

It has the pool's methods, without the Futures and the cancel tokens: openFile, readFromHandle, writeToHandle, closeHandle and so on, plus readFileChunked and writeFileChunked over iterables. Smb2Client.listShares is static and needs no connection. A client dropped without disconnect() frees its connection when it is garbage collected, without a logoff.

11. The native library #

The build hook takes the libsmb2 release binary for the target, checked against its SHA-256, or a local build from hooks.user_defines as it is, and bundles it as libdart_smb2

The binaries come from libsmb2-scripts, which builds the upstream libsmb2 commit it pins with a set of patches, on GitHub's runners, and publishes them with a manifest.json on this repository's libsmb2-rN releases. libsmb2 is licensed under the LGPL-2.1 or later: each release names the libsmb2-scripts commit it was built from, the corresponding source of its binaries, and carries the license texts (COPYING, LICENCE-LGPL-2.1.txt). An app that ships dart_smb2 ships libsmb2 too, so it should include those texts and point to that source (the package's NOTICE says so), and the user_defines override above lets anyone swap in another build.


Permissions #

Android #

<uses-permission android:name="android.permission.INTERNET" />

macOS #

Add to DebugProfile.entitlements and Release.entitlements:

<key>com.apple.security.network.client</key>
<true/>

Project background #

I wrote the bindings, the client, the pool and the build hook with Claude Code. The native library is libsmb2 by Ronnie Sahlberg.

Samba is a trademark of the Software Freedom Conservancy, and Windows a trademark of Microsoft. dart_smb2 is not affiliated with either.


Developed by Alessandro Di Ronza

8
likes
0
points
7.35k
downloads

Publisher

verified publisherales-drnz.com

Weekly Downloads

Dart client for SMB2/3 built on libsmb2. Listings, reads, streaming, atomic downloads, writes and cancellation from worker isolates. Targets macOS, Windows, Linux, iOS and Android.

Repository (GitHub)
View/report issues

Topics

#cross-platform #smb2 #smb3 #samba #libsmb2

License

unknown (license)

Dependencies

code_assets, crypto, ffi, hooks, meta

More

Packages that depend on dart_smb2