init static method

void init({
  1. required String stateDir,
  2. required String appId,
  3. TailscaleLogLevel logLevel = TailscaleLogLevel.silent,
  4. bool noLogsNoSupport = false,
})

Configures the Tailscale library. Call this once at app startup, alongside other library initializers.

appId is the embedding application's stable identifier, such as its reverse-DNS bundle or application ID. Tailscale reserves the dedicated <appId>.tailscale Keybay namespace for the StateStore encryption key. It must remain unchanged for the lifetime of stateDir. Constructing this binding does not access Keybay; secure-state lifecycle operations resolve it lazily.

stateDir is the app-owned base directory for persistent package state. The logical Tailscale StateStore is one authenticated encrypted file under its owner-only tailscale/ subtree. One random 32-byte DEK is held by Keybay and retained in memory for the runtime lifetime. Missing custody, unsafe paths or permissions, tampering, and pre-launch SQLite/FileStore layouts fail closed; legacy identities are not migrated. After the first successful persistent up, later launches can reconnect without an auth key.

This encrypts StateStore data, not the entire subtree. Upstream logs, log configuration, and TLS/certificate sidecars can remain outside that encryption boundary. Owner-only permissions and backup exclusion are therefore still required.

Pick a durable application-support directory, not a user documents directory, and exclude it from cloud backup. Mark the directory excluded: NSURLIsExcludedFromBackupKey on iOS; dataExtractionRules / fullBackupContent rules on Android. Persistent Android nodes require API 31+; persistent Linux nodes require desktop secret-tool and an available, unlocked Secret Service. Older Android and headless Linux can use explicit ephemeral mode, which uses an in-memory StateStore and never accesses Keybay.

Upstream tsnet uploads diagnostic logs to Tailscale by default, independently of logLevel and even when using a Headscale control server. Set noLogsNoSupport to opt this process out before its first runtime starts. This also opts out of support that depends on those logs. The choice is immutable for the process, matching the other initialization settings. Local owner-only log/config sidecars may still be created.

Implementation

static void init({
  required String stateDir,
  required String appId,
  TailscaleLogLevel logLevel = TailscaleLogLevel.silent,
  bool noLogsNoSupport = false,
}) {
  if (stateDir.trim().isEmpty) {
    throw const TailscaleUsageException('stateDir must not be empty.');
  }
  final custody = KeybayStateCustodyBinding(hostAppId: appId);
  try {
    ensurePosixFdTransportAvailable();
  } catch (error) {
    throw TailscaleUsageException(
      'POSIX fd transport is not available on this platform.',
      cause: error,
    );
  }

  final stateDirPtr = stateDir.toNativeUtf8();
  final keybayNamespacePtr = custody.keybayNamespace.toNativeUtf8();
  final resultPtr = native.duneConfigure(
    stateDirPtr,
    keybayNamespacePtr,
    logLevel.nativeValue,
    noLogsNoSupport ? 1 : 0,
  );
  // Ephemeral scratch must live in a platform-writable temporary location.
  // Dart resolves the app's real one (Go's os.TempDir() fallback is not
  // app-writable on Android); native ignores empty and repeated values.
  final scratchParentPtr = Directory.systemTemp.path.toNativeUtf8();
  try {
    native.duneSetEphemeralScratchParent(scratchParentPtr);
  } finally {
    calloc.free(scratchParentPtr);
  }
  try {
    final decoded = jsonDecode(resultPtr.toDartString());
    if (decoded is! Map<String, dynamic>) {
      throw const TailscaleConfigurationException(
        'Native runtime returned an invalid initialization response.',
      );
    }
    final error = decoded['error'] as String?;
    if (error != null) {
      throw TailscaleConfigurationException(error);
    }
    final canonicalStateDir = decoded['stateDir'] as String?;
    if (canonicalStateDir == null || canonicalStateDir.isEmpty) {
      throw const TailscaleConfigurationException(
        'Native runtime did not return a canonical state directory.',
      );
    }
    final candidate = _TailscaleInitialization(
      canonicalStateBaseDir: canonicalStateDir,
      logLevel: logLevel,
      noLogsNoSupport: noLogsNoSupport,
      keybay: custody,
    );
    final configured = _initialization;
    if (configured == null) {
      _initialization = candidate;
    } else if (!configured.hasSameIdentity(candidate)) {
      throw const TailscaleConfigurationException(
        'Native and Dart Tailscale initialization identities diverged.',
      );
    }
  } on TailscaleConfigurationException {
    rethrow;
  } catch (error) {
    throw TailscaleConfigurationException(
      'Failed to configure the native Tailscale runtime.',
      cause: error,
    );
  } finally {
    native.duneFree(resultPtr);
    calloc.free(stateDirPtr);
    calloc.free(keybayNamespacePtr);
  }
}