refactor(apple): Migrate iOS/macOS clients to UniFFI (#10368)

Replace callback-based Adapter with event polling-based AdapterUniFfi

This change improves reliability by eliminating callback lifetime
issues.
This commit is contained in:
Mariusz Klochowicz
2025-10-14 09:43:52 +10:30
committed by GitHub
parent 039d0be7b8
commit cb50800d52
36 changed files with 920 additions and 1053 deletions

View File

@@ -21,12 +21,12 @@ outputs:
value: ${{
(runner.os == 'Linux' && '--workspace') ||
(runner.os == 'macOS' && '--workspace') ||
(runner.os == 'Windows' && '--workspace --exclude ebpf-turn-router --exclude apple-client-ffi --exclude client-ffi') }}
(runner.os == 'Windows' && '--workspace --exclude ebpf-turn-router --exclude client-ffi') }}
test-packages:
description: Testable packages for the current OS
value: ${{
(runner.os == 'Linux' && '--workspace') ||
(runner.os == 'macOS' && '-p apple-client-ffi -p client-shared -p firezone-tunnel -p snownet') ||
(runner.os == 'macOS' && '-p client-ffi -p client-shared -p firezone-tunnel -p snownet') ||
(runner.os == 'Windows' && '-p client-shared -p connlib-model -p firezone-bin-shared -p firezone-gui-client -p firezone-headless-client -p firezone-logging -p firezone-telemetry -p firezone-tunnel -p gui-smoke-test -p http-test-server -p ip-packet -p phoenix-channel -p snownet -p socket-factory -p tun') }}
nightly_version:
description: The nightly version of Rust

3
.gitignore vendored
View File

@@ -27,5 +27,8 @@ gha-creds-*.json
# Ignore local `direnv` cache
.direnv
# Ignore personal spelling dictionary
/cspell.json
# Ignore build data for sourcekit-lsp (Swift code)
buildServer.json

78
rust/Cargo.lock generated
View File

@@ -311,38 +311,6 @@ version = "1.0.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100"
[[package]]
name = "apple-client-ffi"
version = "1.5.9"
dependencies = [
"anyhow",
"backoff",
"client-shared",
"connlib-model",
"dns-types",
"firezone-logging",
"firezone-telemetry",
"futures",
"ip-packet",
"ip_network",
"libc",
"oslog",
"phoenix-channel",
"rustls",
"secrecy",
"serde_json",
"socket-factory",
"swift-bridge",
"swift-bridge-build",
"tokio",
"tokio-util",
"tracing",
"tracing-appender",
"tracing-subscriber",
"tun",
"url",
]
[[package]]
name = "arbitrary"
version = "1.4.2"
@@ -1340,6 +1308,7 @@ dependencies = [
"ip_network",
"libc",
"log",
"oslog",
"phoenix-channel",
"rustls",
"secrecy",
@@ -7336,51 +7305,6 @@ dependencies = [
"is_ci",
]
[[package]]
name = "swift-bridge"
version = "0.1.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dca240710850bfee64549b2f86cd2c6fbbb3de64ce5f3da764cb1ecec5c6cc37"
dependencies = [
"swift-bridge-build",
"swift-bridge-macro",
]
[[package]]
name = "swift-bridge-build"
version = "0.1.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d0ea3c38460a65d975df382b71edbc0e22942d937710d63144267d6609ce738"
dependencies = [
"proc-macro2",
"swift-bridge-ir",
"syn 1.0.109",
"tempfile",
]
[[package]]
name = "swift-bridge-ir"
version = "0.1.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5ea2dcd83a40a918fb26a1bb90187691aa0ae8f2c96e8ea5e6963207bc65676"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "swift-bridge-macro"
version = "0.1.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c9b93285ca24a3fd596ecd316b217c4a0fd78c629e09efb1b4990fab1478183"
dependencies = [
"proc-macro2",
"quote",
"swift-bridge-ir",
"syn 1.0.109",
]
[[package]]
name = "swift-rs"
version = "1.0.7"

View File

@@ -1,6 +1,5 @@
[workspace]
members = [
"apple-client-ffi",
"bin-shared",
"client-ffi",
"client-shared",
@@ -42,7 +41,6 @@ edition = "2024"
[workspace.dependencies]
admx-macro = { path = "gui-client/src-admx-macro" }
anyhow = "1.0.99"
apple-client-ffi = { path = "apple-client-ffi" }
arbitrary = "1.4.2"
arboard = { version = "3.6.1", default-features = false }
async-trait = { version = "0.1", default-features = false }

View File

@@ -1,31 +0,0 @@
.DS_Store
# Rust
/target
Cargo.lock
### Xcode ###
xcuserdata/
/.build
/Packages
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
*.xcarchive/
*.xcframework/
*.checksum.txt
### Xcode Patch ###
*.xcodeproj/*
!*.xcodeproj/project.pbxproj
!*.xcodeproj/xcshareddata/
!*.xcodeproj/project.xcworkspace/
!*.xcworkspace/contents.xcworkspacedata
/*.gcno
**/xcshareddata/WorkspaceSettings.xcsettings
## Xcode 8 and earlier
*.xcscmblueprint
*.xccheckout

View File

@@ -1,46 +0,0 @@
[package]
name = "apple-client-ffi"
# mark:next-apple-version
version = "1.5.9"
edition = { workspace = true }
license = { workspace = true }
[lib]
name = "connlib"
crate-type = ["staticlib"]
doc = false
[dependencies]
anyhow = { workspace = true }
backoff = { workspace = true }
client-shared = { workspace = true }
connlib-model = { workspace = true }
dns-types = { workspace = true }
firezone-logging = { workspace = true }
firezone-telemetry = { workspace = true }
futures = { workspace = true }
ip-packet = { workspace = true }
ip_network = { workspace = true }
libc = { workspace = true }
phoenix-channel = { workspace = true }
rustls = { workspace = true }
secrecy = { workspace = true }
serde_json = { workspace = true }
socket-factory = { workspace = true }
swift-bridge = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "sync"] }
tokio-util = { workspace = true }
tracing = { workspace = true }
tracing-appender = { workspace = true }
tracing-subscriber = { workspace = true }
tun = { workspace = true }
url = { workspace = true }
[target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies]
oslog = { version = "0.2.0", default-features = false }
[build-dependencies]
swift-bridge-build = { workspace = true }
[lints]
workspace = true

View File

@@ -1,6 +0,0 @@
# Connlib Apple Wrapper
Apple Package wrapper for Connlib for inclusion in the Firezone Apple
client.
This is built as part of the Apple client build.

View File

@@ -1,117 +0,0 @@
#!/bin/bash
##################################################
# We call this from an Xcode run script.
##################################################
set -euo pipefail
cmd=${1:-""}
# Sanitize the environment to prevent Xcode's shenanigans from leaking
# into our highly evolved Rust-based build system.
for var in $(env | awk -F= '{print $1}'); do
if [[ "$var" != "HOME" ]] &&
[[ "$var" != "MACOSX_DEPLOYMENT_TARGET" ]] &&
[[ "$var" != "IPHONEOS_DEPLOYMENT_TARGET" ]] &&
[[ "$var" != "USER" ]] &&
[[ "$var" != "LOGNAME" ]] &&
[[ "$var" != "TERM" ]] &&
[[ "$var" != "PWD" ]] &&
[[ "$var" != "SHELL" ]] &&
[[ "$var" != "TMPDIR" ]] &&
[[ "$var" != "XPC_FLAGS" ]] &&
[[ "$var" != "XPC_SERVICE_NAME" ]] &&
[[ "$var" != "PLATFORM_NAME" ]] &&
[[ "$var" != "CONFIGURATION" ]] &&
[[ "$var" != "NATIVE_ARCH" ]] &&
[[ "$var" != "ONLY_ACTIVE_ARCH" ]] &&
[[ "$var" != "ARCHS" ]] &&
[[ "$var" != "SDKROOT" ]] &&
[[ "$var" != "OBJROOT" ]] &&
[[ "$var" != "SYMROOT" ]] &&
[[ "$var" != "SRCROOT" ]] &&
[[ "$var" != "TARGETED_DEVICE_FAMILY" ]] &&
[[ "$var" != "RUSTC_WRAPPER" ]] &&
[[ "$var" != "RUST_TOOLCHAIN" ]] &&
[[ "$var" != "SCCACHE_GCS_BUCKET" ]] &&
[[ "$var" != "SCCACHE_GCS_RW_MODE" ]] &&
[[ "$var" != "GOOGLE_CLOUD_PROJECT" ]] &&
[[ "$var" != "GCP_PROJECT" ]] &&
[[ "$var" != "GCLOUD_PROJECT" ]] &&
[[ "$var" != "CLOUDSDK_PROJECT" ]] &&
[[ "$var" != "CLOUDSDK_CORE_PROJECT" ]] &&
[[ "$var" != "GOOGLE_GHA_CREDS_PATH" ]] &&
[[ "$var" != "GOOGLE_APPLICATION_CREDENTIALS" ]] &&
[[ "$var" != "CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE" ]] &&
[[ "$var" != "ACTIONS_CACHE_URL" ]] &&
[[ "$var" != "ACTIONS_RUNTIME_TOKEN" ]] &&
[[ "$var" != "CARGO_INCREMENTAL" ]] &&
[[ "$var" != "CARGO_TERM_COLOR" ]] &&
[[ "$var" != "FIREZONE_PACKAGE_VERSION" ]] &&
[[ "$var" != "CONNLIB_TARGET_DIR" ]]; then
unset "$var"
fi
done
# Use pristine path; the PATH from Xcode is polluted with stuff we don't want which can
# confuse rustc.
export PATH="/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:$HOME/.cargo/bin:/run/current-system/sw/bin/"
if [[ $cmd == "clean" ]]; then
echo "Skipping build during 'clean'"
exit 0
fi
if [[ -z "$PLATFORM_NAME" ]]; then
echo "PLATFORM_NAME is not set"
exit 1
fi
TARGETS=""
if [[ "$PLATFORM_NAME" = "macosx" ]]; then
if [[ $CONFIGURATION == "Release" ]] || [[ -z "$NATIVE_ARCH" ]]; then
TARGETS=("aarch64-apple-darwin" "x86_64-apple-darwin")
else
if [[ $NATIVE_ARCH == "arm64" ]]; then
TARGETS=("aarch64-apple-darwin")
else
if [[ $NATIVE_ARCH == "x86_64" ]]; then
TARGETS=("x86_64-apple-darwin")
else
echo "Unsupported native arch for $PLATFORM_NAME: $NATIVE_ARCH"
fi
fi
fi
else
if [[ "$PLATFORM_NAME" = "iphoneos" ]]; then
TARGETS=("aarch64-apple-ios")
else
echo "Unsupported platform: $PLATFORM_NAME"
exit 1
fi
fi
MESSAGE="Building Connlib"
CONFIGURATION_ARGS=""
if [[ $CONFIGURATION == "Release" ]]; then
echo "${MESSAGE} for Release"
CONFIGURATION_ARGS="--release"
else
echo "${MESSAGE} for Debug"
fi
if [[ -n "$CONNLIB_TARGET_DIR" ]]; then
export CARGO_TARGET_DIR=$CONNLIB_TARGET_DIR
fi
target_list=""
for target in "${TARGETS[@]}"; do
target_list+="--target $target "
done
target_list="${target_list% }"
# Build the library
cargo build --verbose $target_list $CONFIGURATION_ARGS

View File

@@ -1,14 +0,0 @@
const XCODE_CONFIGURATION_ENV: &str = "CONFIGURATION";
fn main() {
let out_dir = "../../swift/apple/FirezoneNetworkExtension/Connlib/Generated";
let bridges = vec!["src/lib.rs"];
for path in &bridges {
println!("cargo:rerun-if-changed={path}");
}
println!("cargo:rerun-if-env-changed={XCODE_CONFIGURATION_ENV}");
swift_bridge_build::parse_bridges(bridges)
.write_all_concatenated(out_dir, env!("CARGO_PKG_NAME"));
}

View File

@@ -1,411 +0,0 @@
// Swift bridge generated code triggers this below
#![allow(clippy::unnecessary_cast, improper_ctypes, non_camel_case_types)]
#![cfg(unix)]
mod make_writer;
mod tun;
use anyhow::Context;
use anyhow::Result;
use backoff::ExponentialBackoffBuilder;
use client_shared::{DisconnectError, Event, Session, V4RouteList, V6RouteList};
use connlib_model::ResourceView;
use dns_types::DomainName;
use firezone_logging::err_with_src;
use firezone_logging::sentry_layer;
use firezone_telemetry::APPLE_DSN;
use firezone_telemetry::Telemetry;
use firezone_telemetry::analytics;
use ip_network::{Ipv4Network, Ipv6Network};
use phoenix_channel::LoginUrl;
use phoenix_channel::PhoenixChannel;
use phoenix_channel::get_user_agent;
use secrecy::{Secret, SecretString};
use std::sync::OnceLock;
use std::{
net::{IpAddr, Ipv4Addr, Ipv6Addr},
path::PathBuf,
sync::Arc,
time::Duration,
};
use tokio::runtime::Runtime;
use tokio::task::JoinHandle;
use tracing_subscriber::prelude::*;
use tun::Tun;
/// The Apple client implements reconnect logic in the upper layer using OS provided
/// APIs to detect network connectivity changes. The reconnect timeout here only
/// applies only in the following conditions:
///
/// * That reconnect logic fails to detect network changes (not expected to happen)
/// * The portal is DOWN
///
/// Hopefully we aren't down for more than 24 hours.
const MAX_PARTITION_TIME: Duration = Duration::from_secs(60 * 60 * 24);
/// The Sentry release.
///
/// This module is only responsible for the connlib part of the MacOS/iOS app.
/// Bugs within the MacOS/iOS app itself may use the same DSN but a different component as part of the version string.
const RELEASE: &str = concat!("connlib-apple@", env!("CARGO_PKG_VERSION"));
#[swift_bridge::bridge]
mod ffi {
extern "Rust" {
type WrappedSession;
type DisconnectError;
#[swift_bridge(associated_to = WrappedSession, return_with = err_to_string)]
fn connect(
api_url: String,
token: String,
device_id: String,
account_slug: String,
device_name_override: Option<String>,
os_version_override: Option<String>,
log_dir: String,
log_filter: String,
is_internet_resource_active: bool,
callback_handler: CallbackHandler,
device_info: String,
) -> Result<WrappedSession, String>;
fn reset(self: &mut WrappedSession, reason: String);
// Set system DNS resolvers
//
// `dns_servers` must not have any IPv6 scopes
// <https://github.com/firezone/firezone/issues/4350>
#[swift_bridge(swift_name = "setDns", return_with = err_to_string)]
fn set_dns(self: &mut WrappedSession, dns_servers: String) -> Result<(), String>;
#[swift_bridge(swift_name = "setInternetResourceState")]
fn set_internet_resource_state(self: &mut WrappedSession, active: bool);
#[swift_bridge(swift_name = "setLogDirectives", return_with = err_to_string)]
fn set_log_directives(self: &mut WrappedSession, directives: String) -> Result<(), String>;
#[swift_bridge(swift_name = "isAuthenticationError")]
fn is_authentication_error(self: &DisconnectError) -> bool;
#[swift_bridge(swift_name = "toString")]
fn to_string(self: &DisconnectError) -> String;
}
extern "Swift" {
type CallbackHandler;
#[swift_bridge(swift_name = "onSetInterfaceConfig")]
fn on_set_interface_config(
&self,
tunnelAddressIPv4: String,
tunnelAddressIPv6: String,
searchDomain: Option<String>,
dnsAddresses: String,
routeListv4: String,
routeListv6: String,
);
#[swift_bridge(swift_name = "onUpdateResources")]
fn on_update_resources(&self, resourceList: String);
#[swift_bridge(swift_name = "onDisconnect")]
fn on_disconnect(&self, error: DisconnectError);
}
}
/// This is used by the apple client to interact with our code.
pub struct WrappedSession {
inner: Session,
runtime: Option<Runtime>,
event_stream_handler: Option<JoinHandle<()>>,
telemetry: Telemetry,
}
// SAFETY: `CallbackHandler.swift` promises to be thread-safe.
// TODO: Uphold that promise!
unsafe impl Send for ffi::CallbackHandler {}
unsafe impl Sync for ffi::CallbackHandler {}
pub struct CallbackHandler {
// Generated Swift opaque type wrappers have a `Drop` impl that decrements the
// refcount, but there's no way to generate a `Clone` impl that increments the
// recount. Instead, we just wrap it in an `Arc`.
inner: ffi::CallbackHandler,
}
impl CallbackHandler {
fn on_set_interface_config(
&self,
tunnel_address_v4: Ipv4Addr,
tunnel_address_v6: Ipv6Addr,
dns_addresses: Vec<IpAddr>,
search_domain: Option<DomainName>,
route_list_v4: impl IntoIterator<Item = Ipv4Network>,
route_list_v6: impl IntoIterator<Item = Ipv6Network>,
) {
match (
serde_json::to_string(&dns_addresses),
serde_json::to_string(&V4RouteList::new(route_list_v4)),
serde_json::to_string(&V6RouteList::new(route_list_v6)),
) {
(Ok(dns_addresses), Ok(route_list_4), Ok(route_list_6)) => {
self.inner.on_set_interface_config(
tunnel_address_v4.to_string(),
tunnel_address_v6.to_string(),
search_domain.map(|s| s.to_string()),
dns_addresses,
route_list_4,
route_list_6,
);
}
(Err(e), _, _) | (_, Err(e), _) | (_, _, Err(e)) => {
tracing::error!("Failed to serialize to JSON: {}", err_with_src(&e));
}
}
}
fn on_update_resources(&self, resource_list: Vec<ResourceView>) {
let resource_list = match serde_json::to_string(&resource_list) {
Ok(resource_list) => resource_list,
Err(e) => {
tracing::error!("Failed to serialize resource list: {}", err_with_src(&e));
return;
}
};
self.inner.on_update_resources(resource_list);
}
fn on_disconnect(&self, error: DisconnectError) {
if !error.is_authentication_error() {
tracing::error!("{error}")
}
self.inner.on_disconnect(error);
}
}
static LOGGER_STATE: OnceLock<(
firezone_logging::file::Handle,
firezone_logging::FilterReloadHandle,
)> = OnceLock::new();
/// Initialises a global logger with the specified log filter.
///
/// A global logger can only be set once, hence this function uses `static` state to check whether a logger has already been set.
/// If so, the new `log_filter` will be applied to the existing logger but a different `log_dir` won't have any effect.
///
/// From within the FFI module, we have no control over our memory lifecycle and we may get initialised multiple times within the same process.
fn init_logging(log_dir: PathBuf, log_filter: String) -> Result<()> {
if let Some((_, reload_handle)) = LOGGER_STATE.get() {
reload_handle
.reload(&log_filter)
.context("Failed to apply new log-filter")?;
return Ok(());
}
let (file_log_filter, file_reload_handle) = firezone_logging::try_filter(&log_filter)?;
let (oslog_log_filter, oslog_reload_handle) = firezone_logging::try_filter(&log_filter)?;
let (file_layer, handle) = firezone_logging::file::layer(&log_dir, "connlib");
let subscriber = tracing_subscriber::registry()
.with(file_layer.with_filter(file_log_filter))
.with(
tracing_subscriber::fmt::layer()
.with_ansi(false)
.event_format(
firezone_logging::Format::new()
.without_timestamp()
.without_level(),
)
.with_writer(make_writer::MakeWriter::new(
"dev.firezone.firezone",
"connlib",
))
.with_filter(oslog_log_filter),
)
.with(sentry_layer());
let reload_handle = file_reload_handle.merge(oslog_reload_handle);
firezone_logging::init(subscriber)?;
LOGGER_STATE
.set((handle, reload_handle))
.expect("logger state should only ever be initialised once");
Ok(())
}
impl WrappedSession {
fn connect(
api_url: String,
token: String,
device_id: String,
account_slug: String,
device_name_override: Option<String>,
os_version_override: Option<String>,
log_dir: String,
log_filter: String,
is_internet_resource_active: bool,
callback_handler: ffi::CallbackHandler,
device_info: String,
) -> Result<Self> {
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.thread_name("connlib")
.enable_all()
.build()?;
let mut telemetry = Telemetry::new();
runtime.block_on(telemetry.start(&api_url, RELEASE, APPLE_DSN, device_id.clone()));
Telemetry::set_account_slug(account_slug.clone());
analytics::identify(RELEASE.to_owned(), Some(account_slug));
init_logging(log_dir.into(), log_filter)?;
install_rustls_crypto_provider();
let secret = SecretString::from(token);
let device_info =
serde_json::from_str(&device_info).context("Failed to deserialize `DeviceInfo`")?;
let url = LoginUrl::client(
api_url.as_str(),
&secret,
device_id.clone(),
device_name_override,
device_info,
)?;
let _guard = runtime.enter(); // Constructing `PhoenixChannel` requires a runtime context.
let portal = PhoenixChannel::disconnected(
Secret::new(url),
get_user_agent(
os_version_override,
"apple-client",
env!("CARGO_PKG_VERSION"),
),
"client",
(),
|| {
ExponentialBackoffBuilder::default()
.with_max_elapsed_time(Some(MAX_PARTITION_TIME))
.build()
},
Arc::new(socket_factory::tcp),
)?;
let (session, mut event_stream) = Session::connect(
Arc::new(socket_factory::tcp),
Arc::new(socket_factory::udp),
portal,
is_internet_resource_active,
runtime.handle().clone(),
);
session.set_tun(Box::new(Tun::new()?));
analytics::new_session(device_id, api_url.to_string());
let event_stream_handler = runtime.spawn(async move {
let callback_handler = CallbackHandler {
inner: callback_handler,
};
while let Some(event) = event_stream.next().await {
match event {
Event::TunInterfaceUpdated(config) => {
callback_handler.on_set_interface_config(
config.ip.v4,
config.ip.v6,
config.dns_sentinel_ips(),
config.search_domain,
config.ipv4_routes,
config.ipv6_routes,
);
}
Event::ResourcesUpdated(resource_views) => {
callback_handler.on_update_resources(resource_views);
}
Event::Disconnected(error) => {
callback_handler.on_disconnect(error);
}
}
}
});
Ok(Self {
inner: session,
runtime: Some(runtime),
event_stream_handler: Some(event_stream_handler),
telemetry,
})
}
fn reset(&mut self, reason: String) {
self.inner.reset(reason)
}
fn set_dns(&mut self, dns_servers: String) -> Result<()> {
tracing::debug!(%dns_servers);
let dns_servers = serde_json::from_str(&dns_servers)
.context("Failed to deserialize DNS servers from JSON")?;
self.inner.set_dns(dns_servers);
Ok(())
}
fn set_internet_resource_state(&mut self, active: bool) {
self.inner.set_internet_resource_state(active);
}
fn set_log_directives(&mut self, directives: String) -> Result<()> {
let (_, handle) = LOGGER_STATE.get().context("Logger is not initialised")?;
handle.reload(&directives)?;
Ok(())
}
}
impl Drop for WrappedSession {
fn drop(&mut self) {
let Some(runtime) = self.runtime.take() else {
return;
};
self.inner.stop(); // Instruct the event-loop to shut down.
runtime.block_on(async {
self.telemetry.stop().await;
// The `event_stream_handler` task will exit once the stream is drained.
// That only happens once the event-loop has fully shut down.
// Hence, waiting for this task here allows us to wait for the graceful shutdown to complete.
let Some(event_stream_handler) = self.event_stream_handler.take() else {
return;
};
let _ = tokio::time::timeout(Duration::from_secs(1), event_stream_handler).await;
});
runtime.shutdown_timeout(Duration::from_secs(1)); // Ensure we don't block forever on a task in the blocking pool.
}
}
fn err_to_string<T>(result: Result<T>) -> Result<T, String> {
result.map_err(|e| format!("{e:#}"))
}
/// Installs the `ring` crypto provider for rustls.
fn install_rustls_crypto_provider() {
let existing = rustls::crypto::ring::default_provider().install_default();
if existing.is_err() {
tracing::debug!("Skipping install of crypto provider because we already have one.");
}
}

View File

@@ -0,0 +1,12 @@
# Cargo configuration for client-ffi
# This helps prevent rebuilds due to environment variable changes
[env]
# Set a consistent macOS deployment target to prevent rebuilds
# when switching between Xcode and command-line builds
# This must match the Xcode project setting (12.4)
MACOSX_DEPLOYMENT_TARGET = "12.4"
[build]
# Use incremental compilation for faster rebuilds
incremental = true

View File

@@ -38,6 +38,9 @@ tun = { workspace = true }
uniffi = { workspace = true }
url = { workspace = true }
[target.'cfg(any(target_os = "ios", target_os = "macos"))'.dependencies]
oslog = { version = "0.2.0", default-features = false }
[target.'cfg(target_os = "android")'.dependencies]
android_log-sys = "0.3.2"

View File

@@ -1,8 +1,8 @@
mod platform;
use std::{
fmt, io,
os::fd::{AsRawFd as _, RawFd},
fmt,
os::fd::RawFd,
path::{Path, PathBuf},
sync::{Arc, OnceLock},
time::Duration,
@@ -32,7 +32,7 @@ pub struct Session {
#[derive(uniffi::Object, thiserror::Error, Debug)]
#[error("{0:#}")]
pub struct Error(anyhow::Error);
pub struct ConnlibError(anyhow::Error);
#[derive(uniffi::Error, thiserror::Error, Debug)]
pub enum CallbackError {
@@ -78,6 +78,7 @@ pub trait ProtectSocket: Send + Sync + fmt::Debug {
}
#[uniffi::export]
#[cfg(target_os = "android")]
impl Session {
#[uniffi::constructor]
#[expect(
@@ -96,7 +97,7 @@ impl Session {
device_info: String,
is_internet_resource_active: bool,
protect_socket: Arc<dyn ProtectSocket>,
) -> Result<Self, Error> {
) -> Result<Self, ConnlibError> {
let udp_socket_factory = Arc::new(protected_udp_socket_factory(protect_socket.clone()));
let tcp_socket_factory = Arc::new(protected_tcp_socket_factory(protect_socket));
@@ -115,8 +116,94 @@ impl Session {
udp_socket_factory,
)
}
}
pub fn disconnect(&self) -> Result<(), Error> {
#[uniffi::export]
#[cfg(any(target_os = "ios", target_os = "macos"))]
impl Session {
#[uniffi::constructor]
#[expect(
clippy::too_many_arguments,
reason = "This is the API we want to expose over FFI."
)]
pub fn new_apple(
api_url: String,
token: String,
device_id: String,
account_slug: String,
device_name: Option<String>,
os_version: Option<String>,
log_dir: String,
log_filter: String,
device_info: String,
is_internet_resource_active: bool,
) -> Result<Self, ConnlibError> {
// iOS doesn't need socket protection like Android
let tcp_socket_factory = Arc::new(socket_factory::tcp);
let udp_socket_factory = Arc::new(socket_factory::udp);
let session = connect(
api_url,
token,
device_id,
account_slug,
device_name,
os_version,
log_dir,
log_filter,
device_info,
is_internet_resource_active,
tcp_socket_factory,
udp_socket_factory,
)?;
set_tun_from_search(&session)?;
Ok(session)
}
}
/// Set up TUN device with retry logic.
///
/// Retries a few times with a small delay, as the NetworkExtension
/// might still be setting up the TUN interface.
#[cfg(any(target_os = "ios", target_os = "macos"))]
fn set_tun_from_search(session: &Session) -> Result<(), ConnlibError> {
const MAX_TUN_SETUP_ATTEMPTS: u32 = 5;
const TUN_SETUP_RETRY_DELAY_MS: u64 = 100;
let runtime = session.runtime.as_ref().context("No runtime")?;
let mut last_error = None;
for attempt in 1..=MAX_TUN_SETUP_ATTEMPTS {
tracing::debug!("Attempting to find TUN device (attempt {})", attempt);
match platform::Tun::new(runtime.handle()) {
Ok(tun) => {
tracing::debug!("Successfully found and set TUN device");
session.inner.set_tun(Box::new(tun));
return Ok(());
}
Err(e) => {
tracing::warn!("Attempt {} failed: {}", attempt, e);
last_error = Some(e);
if attempt < MAX_TUN_SETUP_ATTEMPTS {
std::thread::sleep(std::time::Duration::from_millis(TUN_SETUP_RETRY_DELAY_MS));
}
}
}
}
Err(anyhow::anyhow!(
"Failed to find TUN device after {} attempts: {}",
MAX_TUN_SETUP_ATTEMPTS,
last_error.map_or_else(|| "unknown error".to_string(), |e| e.to_string())
)
.into())
}
#[uniffi::export]
impl Session {
pub fn disconnect(&self) -> Result<(), ConnlibError> {
let runtime = self.runtime.as_ref().context("No runtime")?;
runtime.block_on(async {
@@ -131,7 +218,7 @@ impl Session {
self.inner.set_internet_resource_state(active);
}
pub fn set_dns(&self, dns_servers: String) -> Result<(), Error> {
pub fn set_dns(&self, dns_servers: String) -> Result<(), ConnlibError> {
let dns_servers =
serde_json::from_str(&dns_servers).context("Failed to deserialize DNS servers")?;
@@ -144,7 +231,7 @@ impl Session {
self.inner.reset(reason)
}
pub fn set_log_directives(&self, directives: String) -> Result<(), Error> {
pub fn set_log_directives(&self, directives: String) -> Result<(), ConnlibError> {
let (_, reload_handle) = LOGGER_STATE.get().context("Logger not yet initialised")?;
reload_handle
@@ -154,17 +241,19 @@ impl Session {
Ok(())
}
pub fn set_tun(&self, fd: RawFd) -> Result<(), Error> {
let _guard = self.runtime.as_ref().context("No runtime")?.enter();
pub fn set_tun(&self, fd: RawFd) -> Result<(), ConnlibError> {
let runtime = self.runtime.as_ref().context("No runtime")?;
// SAFETY: FD must be open.
let tun = unsafe { platform::Tun::from_fd(fd).context("Failed to create new Tun")? };
let tun = unsafe {
platform::Tun::from_fd(fd, runtime.handle()).context("Failed to create new Tun")?
};
self.inner.set_tun(Box::new(tun));
Ok(())
}
pub async fn next_event(&self) -> Result<Option<Event>, Error> {
pub async fn next_event(&self) -> Result<Option<Event>, ConnlibError> {
match self.events.lock().await.next().await {
Some(client_shared::Event::TunInterfaceUpdated(config)) => {
let dns = serde_json::to_string(
@@ -232,7 +321,7 @@ fn connect(
is_internet_resource_active: bool,
tcp_socket_factory: Arc<dyn SocketFactory<TcpSocket>>,
udp_socket_factory: Arc<dyn SocketFactory<UdpSocket>>,
) -> Result<Session, Error> {
) -> Result<Session, ConnlibError> {
let device_info =
serde_json::from_str(&device_info).context("Failed to deserialize `DeviceInfo`")?;
let secret = SecretString::from(token);
@@ -338,23 +427,27 @@ fn init_logging(log_dir: &Path, log_filter: String) -> Result<()> {
Ok(())
}
#[cfg(target_os = "android")]
fn protected_tcp_socket_factory(callback: Arc<dyn ProtectSocket>) -> impl SocketFactory<TcpSocket> {
move |addr| {
let socket = socket_factory::tcp(addr)?;
use std::os::fd::AsRawFd;
callback
.protect_socket(socket.as_raw_fd())
.map_err(io::Error::other)?;
.map_err(std::io::Error::other)?;
Ok(socket)
}
}
#[cfg(target_os = "android")]
fn protected_udp_socket_factory(callback: Arc<dyn ProtectSocket>) -> impl SocketFactory<UdpSocket> {
move |addr| {
let socket = socket_factory::udp(addr)?;
use std::os::fd::AsRawFd;
callback
.protect_socket(socket.as_raw_fd())
.map_err(io::Error::other)?;
.map_err(std::io::Error::other)?;
Ok(socket)
}
@@ -369,7 +462,7 @@ fn install_rustls_crypto_provider() {
}
}
impl From<anyhow::Error> for Error {
impl From<anyhow::Error> for ConnlibError {
fn from(value: anyhow::Error) -> Self {
Self(value)
}

View File

@@ -1,21 +1,17 @@
#[cfg(any(
target_os = "linux",
target_os = "windows",
target_os = "macos",
target_os = "ios"
))]
#[cfg(any(target_os = "linux", target_os = "windows"))]
mod fallback;
#[cfg(target_os = "android")]
mod android;
#[cfg(any(target_os = "ios", target_os = "macos"))]
mod apple;
#[cfg(target_os = "android")]
pub use android::*;
#[cfg(any(
target_os = "linux",
target_os = "windows",
target_os = "macos",
target_os = "ios"
))]
#[cfg(any(target_os = "ios", target_os = "macos"))]
pub use apple::*;
#[cfg(any(target_os = "linux", target_os = "windows"))]
pub use fallback::*;

View File

@@ -54,20 +54,20 @@ impl Tun {
///
/// - The file descriptor must be open.
/// - The file descriptor must not get closed by anyone else.
pub unsafe fn from_fd(fd: RawFd) -> io::Result<Self> {
pub unsafe fn from_fd(fd: RawFd, runtime: &tokio::runtime::Handle) -> io::Result<Self> {
let name = unsafe { interface_name(fd)? };
let (inbound_tx, inbound_rx) = mpsc::channel(QUEUE_SIZE);
let (outbound_tx, outbound_rx) = mpsc::channel(QUEUE_SIZE);
tokio::spawn(otel::metrics::periodic_system_queue_length(
runtime.spawn(otel::metrics::periodic_system_queue_length(
outbound_tx.downgrade(),
[
otel::attr::queue_item_ip_packet(),
otel::attr::network_io_direction_transmit(),
],
));
tokio::spawn(otel::metrics::periodic_system_queue_length(
runtime.spawn(otel::metrics::periodic_system_queue_length(
inbound_tx.downgrade(),
[
otel::attr::queue_item_ip_packet(),

View File

@@ -0,0 +1,28 @@
use firezone_telemetry::Dsn;
use std::time::Duration;
mod make_writer;
mod tun;
// mark:next-apple-version
pub const RELEASE: &str = "connlib-apple@1.5.8";
// mark:next-apple-version
pub const VERSION: &str = "1.5.8";
pub const COMPONENT: &str = "apple-client";
/// The Apple client implements reconnect logic in the upper layer using OS provided
/// APIs to detect network connectivity changes. The reconnect timeout here only
/// applies only in the following conditions:
///
/// * That reconnect logic fails to detect network changes (not expected to happen)
/// * The portal is DOWN
///
/// Hopefully we aren't down for more than 24 hours.
pub const MAX_PARTITION_TIME: Duration = Duration::from_secs(60 * 60 * 24);
pub const DSN: Dsn = firezone_telemetry::APPLE_DSN;
pub(crate) use make_writer::MakeWriter;
pub(crate) use tun::Tun;

View File

@@ -24,6 +24,12 @@ impl MakeWriter {
}
}
impl Default for MakeWriter {
fn default() -> Self {
Self::new("dev.firezone.firezone", "connlib")
}
}
impl<'l> tracing_subscriber::fmt::MakeWriter<'l> for MakeWriter {
type Writer = Writer<'l>;

View File

@@ -20,22 +20,38 @@ pub struct Tun {
}
impl Tun {
pub fn new() -> io::Result<Self> {
pub fn new(runtime: &tokio::runtime::Handle) -> io::Result<Self> {
let fd = search_for_tun_fd()?;
set_non_blocking(fd)?;
Self::from_fd_inner(fd, runtime)
}
/// Create a new [`Tun`] from a raw file descriptor.
///
/// # Safety
///
/// - The file descriptor must be open.
/// - The file descriptor must not get closed by anyone else.
/// - On iOS/macOS, the NetworkExtension owns the fd, so we don't take ownership.
pub unsafe fn from_fd(fd: RawFd, runtime: &tokio::runtime::Handle) -> io::Result<Self> {
set_non_blocking(fd)?;
Self::from_fd_inner(fd, runtime)
}
fn from_fd_inner(fd: RawFd, runtime: &tokio::runtime::Handle) -> io::Result<Self> {
let name = name(fd)?;
let (inbound_tx, inbound_rx) = mpsc::channel(QUEUE_SIZE);
let (outbound_tx, outbound_rx) = mpsc::channel(QUEUE_SIZE);
tokio::spawn(otel::metrics::periodic_system_queue_length(
runtime.spawn(otel::metrics::periodic_system_queue_length(
outbound_tx.downgrade(),
[
otel::attr::queue_item_ip_packet(),
otel::attr::network_io_direction_transmit(),
],
));
tokio::spawn(otel::metrics::periodic_system_queue_length(
runtime.spawn(otel::metrics::periodic_system_queue_length(
inbound_tx.downgrade(),
[
otel::attr::queue_item_ip_packet(),

View File

@@ -40,7 +40,7 @@ impl io::Write for DevNull {
pub struct Tun;
impl Tun {
pub unsafe fn from_fd(_: RawFd) -> io::Result<Self> {
pub unsafe fn from_fd(_: RawFd, _: &tokio::runtime::Handle) -> io::Result<Self> {
Err(io::Error::other("Stub!"))
}
}

View File

@@ -0,0 +1,16 @@
# UniFFI Configuration for client-ffi
[bindings.swift]
# Enable experimental features for better async support
experimental_sendable_value_types = true
# Configure async runtime for Swift
# This enables proper async/await bridging from Rust to Swift
async_runtime = "tokio"
# Generate proper Swift concurrency annotations
generate_immutable_records = true
[bindings.kotlin]
# Kotlin configuration (already working)
async_runtime = "tokio"

View File

@@ -10,28 +10,25 @@
05CF1CF1290B1CEE00CF4755 /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 05D3BB1628FDBD8A00BC3727 /* NetworkExtension.framework */; };
05CF1D17290B1FE700CF4755 /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05833DFA28F73B070008FAB0 /* PacketTunnelProvider.swift */; };
05D3BB2128FDE9C000BC3727 /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 05D3BB1628FDBD8A00BC3727 /* NetworkExtension.framework */; };
6FE454F62A5BFB93006549B1 /* Adapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE454EA2A5BFABA006549B1 /* Adapter.swift */; };
6FE455092A5D110D006549B1 /* CallbackHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE455082A5D110D006549B1 /* CallbackHandler.swift */; };
6FE4550C2A5D111E006549B1 /* SwiftBridgeCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE4550B2A5D111D006549B1 /* SwiftBridgeCore.swift */; };
6FE4550F2A5D112C006549B1 /* apple-client-ffi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE4550E2A5D112C006549B1 /* apple-client-ffi.swift */; };
6F0EDF1F2E79A13800D6D632 /* Adapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 177FB893F19457A113042247 /* Adapter.swift */; };
6F0EDF212E79A15700D6D632 /* connlib.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2847ACC9D73EA6F356F2DEE1 /* connlib.swift */; };
6FE93AFB2A738D7E002D278A /* NetworkSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE93AFA2A738D7E002D278A /* NetworkSettings.swift */; };
794C38152970A2660029F38F /* FirezoneKit in Frameworks */ = {isa = PBXBuildFile; productRef = 794C38142970A2660029F38F /* FirezoneKit */; };
79756C6629704A7A0018E2D5 /* FirezoneKit in Frameworks */ = {isa = PBXBuildFile; productRef = 79756C6529704A7A0018E2D5 /* FirezoneKit */; };
8D4087D52D24653B005B2BAF /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 8D4087D42D24653B005B2BAF /* Sentry */; };
8D4087D92D246541005B2BAF /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 8D4087D82D246541005B2BAF /* Sentry */; };
8D4087DD2D246651005B2BAF /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 8D4087DC2D246651005B2BAF /* Sentry */; };
858AA13A09A55E3B1DD5DEDA /* Adapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 177FB893F19457A113042247 /* Adapter.swift */; };
8D41B9A52D15DD6800D16065 /* TunnelLogArchive.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D41B9A42D15DD6800D16065 /* TunnelLogArchive.swift */; };
8D41B9A62D15DD6800D16065 /* TunnelLogArchive.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D41B9A42D15DD6800D16065 /* TunnelLogArchive.swift */; };
8D5047F82CE6AA22009802E9 /* FirezoneKit in Frameworks */ = {isa = PBXBuildFile; productRef = 8D5047F72CE6AA22009802E9 /* FirezoneKit */; };
8D5047FB2CE6AA37009802E9 /* FirezoneNetworkExtension-Bridging-Header.h in Sources */ = {isa = PBXBuildFile; fileRef = 6FE455112A5D13A2006549B1 /* FirezoneNetworkExtension-Bridging-Header.h */; };
8D5047FC2CE6AA47009802E9 /* CallbackHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE455082A5D110D006549B1 /* CallbackHandler.swift */; };
8D5047FD2CE6AA47009802E9 /* apple-client-ffi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE4550E2A5D112C006549B1 /* apple-client-ffi.swift */; };
8D5047FE2CE6AA54009802E9 /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05833DFA28F73B070008FAB0 /* PacketTunnelProvider.swift */; };
8D5047FF2CE6AA54009802E9 /* Adapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE454EA2A5BFABA006549B1 /* Adapter.swift */; };
8D5048002CE6AA60009802E9 /* SystemConfigurationResolvers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D6939312BA2521A00AF4396 /* SystemConfigurationResolvers.swift */; };
8D5048012CE6AA60009802E9 /* NetworkSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE93AFA2A738D7E002D278A /* NetworkSettings.swift */; };
8D5048042CE6B0AE009802E9 /* SwiftBridgeCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE4550B2A5D111D006549B1 /* SwiftBridgeCore.swift */; };
8D69392C2BA24FE600AF4396 /* BindResolvers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D69392B2BA24FE600AF4396 /* BindResolvers.swift */; };
146C8E809FF744D18C053A79 /* Channel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C09DC14A4A04715A37BC04C /* Channel.swift */; };
C1892134991C462C8E137DE3 /* Channel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C09DC14A4A04715A37BC04C /* Channel.swift */; };
C4B08E1145E04823BC25E00D /* SessionEventLoop.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14DC5E933BD4F63A844A8A6 /* SessionEventLoop.swift */; };
6571861AB9324D6A9395BDB3 /* SessionEventLoop.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14DC5E933BD4F63A844A8A6 /* SessionEventLoop.swift */; };
8DC08BD72B297DB400675F46 /* FirezoneNetworkExtension-Bridging-Header.h in Sources */ = {isa = PBXBuildFile; fileRef = 6FE455112A5D13A2006549B1 /* FirezoneNetworkExtension-Bridging-Header.h */; };
8DC1699D2CFF77D1006801B5 /* dev.firezone.firezone.network-extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 05CF1CF0290B1CEE00CF4755 /* dev.firezone.firezone.network-extension.appex */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
8DC169A02CFF77D1006801B5 /* dev.firezone.firezone.network-extension.systemextension in Embed System Extensions */ = {isa = PBXBuildFile; fileRef = 8D5047E32CE6A8F4009802E9 /* dev.firezone.firezone.network-extension.systemextension */; platformFilters = (macos, ); settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
@@ -44,6 +41,8 @@
8DCC022A28D512AE007E12D2 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8DCC022928D512AE007E12D2 /* Preview Assets.xcassets */; };
8DE452A82CE6C194004CEDF9 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D5047E82CE6A8F4009802E9 /* main.swift */; };
8DFDEAC72D2CEBA500615095 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 8DA12C322BB7DA04007D91EB /* PrivacyInfo.xcprivacy */; };
CCC04BEF428B758D9BB5F842 /* connlib.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2847ACC9D73EA6F356F2DEE1 /* connlib.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -89,14 +88,14 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
05833DFA28F73B070008FAB0 /* PacketTunnelProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelProvider.swift; sourceTree = "<group>"; };
05CF1CF0290B1CEE00CF4755 /* dev.firezone.firezone.network-extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "dev.firezone.firezone.network-extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
05CF1CF6290B1CEE00CF4755 /* FirezoneNetworkExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FirezoneNetworkExtension.entitlements; sourceTree = "<group>"; };
05D3BB1628FDBD8A00BC3727 /* NetworkExtension.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = NetworkExtension.framework; path = System/Library/Frameworks/NetworkExtension.framework; sourceTree = SDKROOT; };
6FE454EA2A5BFABA006549B1 /* Adapter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Adapter.swift; sourceTree = "<group>"; };
6FE455082A5D110D006549B1 /* CallbackHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CallbackHandler.swift; sourceTree = "<group>"; };
6FE4550B2A5D111D006549B1 /* SwiftBridgeCore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SwiftBridgeCore.swift; path = Connlib/Generated/SwiftBridgeCore.swift; sourceTree = "<group>"; };
6FE4550E2A5D112C006549B1 /* apple-client-ffi.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "apple-client-ffi.swift"; path = "Connlib/Generated/apple-client-ffi/apple-client-ffi.swift"; sourceTree = "<group>"; };
177FB893F19457A113042247 /* Adapter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Adapter.swift; sourceTree = "<group>"; };
2847ACC9D73EA6F356F2DEE1 /* connlib.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = connlib.swift; path = FirezoneNetworkExtension/Connlib/Generated/connlib.swift; sourceTree = "<group>"; };
6FB20C422E7049A300E41294 /* ConnlibFFI.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = ConnlibFFI.xcframework; path = Frameworks/ConnlibFFI.xcframework; sourceTree = "<group>"; };
6FE455112A5D13A2006549B1 /* FirezoneNetworkExtension-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "FirezoneNetworkExtension-Bridging-Header.h"; sourceTree = "<group>"; };
6FE93AFA2A738D7E002D278A /* NetworkSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkSettings.swift; sourceTree = "<group>"; };
6FFECD5B2AD6998400E00273 /* SystemConfiguration.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SystemConfiguration.framework; path = System/Library/Frameworks/SystemConfiguration.framework; sourceTree = SDKROOT; };
@@ -106,6 +105,8 @@
8D5047E32CE6A8F4009802E9 /* dev.firezone.firezone.network-extension.systemextension */ = {isa = PBXFileReference; explicitFileType = "wrapper.system-extension"; includeInIndex = 0; path = "dev.firezone.firezone.network-extension.systemextension"; sourceTree = BUILT_PRODUCTS_DIR; };
8D5047E82CE6A8F4009802E9 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
8D69392B2BA24FE600AF4396 /* BindResolvers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BindResolvers.swift; sourceTree = "<group>"; };
6C09DC14A4A04715A37BC04C /* Channel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Channel.swift; sourceTree = "<group>"; };
E14DC5E933BD4F63A844A8A6 /* SessionEventLoop.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SessionEventLoop.swift; sourceTree = "<group>"; };
8D6939312BA2521A00AF4396 /* SystemConfigurationResolvers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SystemConfigurationResolvers.swift; sourceTree = "<group>"; };
8DA12C322BB7DA04007D91EB /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
8DC08BCC2B296C5900675F46 /* libresolv.9.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libresolv.9.tbd; path = usr/lib/libresolv.9.tbd; sourceTree = SDKROOT; };
@@ -129,7 +130,6 @@
files = (
8DC333F92D2FA85200E627D5 /* libresolv.tbd in Frameworks */,
8DC333FA2D2FA85200E627D5 /* libresolv.9.tbd in Frameworks */,
8D4087D52D24653B005B2BAF /* Sentry in Frameworks */,
794C38152970A2660029F38F /* FirezoneKit in Frameworks */,
05CF1CF1290B1CEE00CF4755 /* NetworkExtension.framework in Frameworks */,
);
@@ -142,7 +142,6 @@
8DC333FB2D2FA89100E627D5 /* SystemConfiguration.framework in Frameworks */,
8D5047F82CE6AA22009802E9 /* FirezoneKit in Frameworks */,
8DC9FB852CF5A738001BCE6A /* NetworkExtension.framework in Frameworks */,
8D4087D92D246541005B2BAF /* Sentry in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -152,7 +151,6 @@
files = (
05D3BB2128FDE9C000BC3727 /* NetworkExtension.framework in Frameworks */,
79756C6629704A7A0018E2D5 /* FirezoneKit in Frameworks */,
8D4087DD2D246651005B2BAF /* Sentry in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -166,15 +164,13 @@
8DE1077B2D2313EB00DB5A45 /* Info.macOS.plist */,
8D6939312BA2521A00AF4396 /* SystemConfigurationResolvers.swift */,
8D69392B2BA24FE600AF4396 /* BindResolvers.swift */,
6C09DC14A4A04715A37BC04C /* Channel.swift */,
E14DC5E933BD4F63A844A8A6 /* SessionEventLoop.swift */,
05CF1CF6290B1CEE00CF4755 /* FirezoneNetworkExtension.entitlements */,
8D5047E82CE6A8F4009802E9 /* main.swift */,
05833DFA28F73B070008FAB0 /* PacketTunnelProvider.swift */,
6FE454EA2A5BFABA006549B1 /* Adapter.swift */,
6FE93AFA2A738D7E002D278A /* NetworkSettings.swift */,
6FE455082A5D110D006549B1 /* CallbackHandler.swift */,
6FE4550B2A5D111D006549B1 /* SwiftBridgeCore.swift */,
8D41B9A42D15DD6800D16065 /* TunnelLogArchive.swift */,
6FE4550E2A5D112C006549B1 /* apple-client-ffi.swift */,
6FE455112A5D13A2006549B1 /* FirezoneNetworkExtension-Bridging-Header.h */,
);
path = FirezoneNetworkExtension;
@@ -188,9 +184,28 @@
path = Application;
sourceTree = "<group>";
};
64A772F0917639D7745EA995 /* Connlib */ = {
isa = PBXGroup;
children = (
99553B27AEABC2EA3782DDEA /* Generated */,
);
name = Connlib;
path = FirezoneNetworkExtension/Connlib;
sourceTree = "<group>";
};
8BCDECDF49DAFFD25CF140AF /* FirezoneNetworkExtension */ = {
isa = PBXGroup;
children = (
64A772F0917639D7745EA995 /* Connlib */,
177FB893F19457A113042247 /* Adapter.swift */,
);
path = FirezoneNetworkExtension;
sourceTree = "<group>";
};
8D3F90C328D64FAD00980124 /* Frameworks */ = {
isa = PBXGroup;
children = (
6FB20C422E7049A300E41294 /* ConnlibFFI.xcframework */,
8DC08BD12B297B7B00675F46 /* libresolv.tbd */,
8DC08BCC2B296C5900675F46 /* libresolv.9.tbd */,
8DC333F72D2FA85200E627D5 /* libresolv.tbd */,
@@ -210,6 +225,8 @@
05833DF928F73B070008FAB0 /* FirezoneNetworkExtension */,
8DCC021A28D512AC007E12D2 /* Products */,
8D3F90C328D64FAD00980124 /* Frameworks */,
8BCDECDF49DAFFD25CF140AF /* FirezoneNetworkExtension */,
2847ACC9D73EA6F356F2DEE1 /* connlib.swift */,
);
sourceTree = "<group>";
};
@@ -252,6 +269,14 @@
name = Packages;
sourceTree = "<group>";
};
99553B27AEABC2EA3782DDEA /* Generated */ = {
isa = PBXGroup;
children = (
);
name = Generated;
path = FirezoneNetworkExtension/Connlib/Generated;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -355,8 +380,6 @@
Base,
);
mainGroup = 8DCC021028D512AC007E12D2;
packageReferences = (
);
productRefGroup = 8DCC021A28D512AC007E12D2 /* Products */;
projectDirPath = "";
projectRoot = "";
@@ -452,7 +475,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "cd ../../rust/apple-client-ffi\n./build-rust.sh\n";
shellScript = "export PLATFORM_NAME=\"${PLATFORM_NAME}\"\nexport CONFIGURATION=\"${CONFIGURATION}\"\nexport NATIVE_ARCH=\"${ARCHS}\"\nexport CONNLIB_TARGET_DIR=\"${CONNLIB_TARGET_DIR}\"\n./build-rust.sh\n";
};
8D8555832D0A7CBB00A1EA09 /* Build Connlib */ = {
isa = PBXShellScriptBuildPhase;
@@ -472,7 +495,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "cd ../../rust/apple-client-ffi\n./build-rust.sh\n";
shellScript = "export PLATFORM_NAME=\"${PLATFORM_NAME}\"\nexport CONFIGURATION=\"${CONFIGURATION}\"\nexport NATIVE_ARCH=\"${ARCHS}\"\nexport CONNLIB_TARGET_DIR=\"${CONNLIB_TARGET_DIR}\"\n./build-rust.sh\n";
};
/* End PBXShellScriptBuildPhase section */
@@ -481,14 +504,14 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
6F0EDF212E79A15700D6D632 /* connlib.swift in Sources */,
6F0EDF1F2E79A13800D6D632 /* Adapter.swift in Sources */,
8DC08BD72B297DB400675F46 /* FirezoneNetworkExtension-Bridging-Header.h in Sources */,
6FE455092A5D110D006549B1 /* CallbackHandler.swift in Sources */,
6FE4550F2A5D112C006549B1 /* apple-client-ffi.swift in Sources */,
8D41B9A52D15DD6800D16065 /* TunnelLogArchive.swift in Sources */,
05CF1D17290B1FE700CF4755 /* PacketTunnelProvider.swift in Sources */,
6FE454F62A5BFB93006549B1 /* Adapter.swift in Sources */,
6FE4550C2A5D111E006549B1 /* SwiftBridgeCore.swift in Sources */,
8D69392C2BA24FE600AF4396 /* BindResolvers.swift in Sources */,
146C8E809FF744D18C053A79 /* Channel.swift in Sources */,
C4B08E1145E04823BC25E00D /* SessionEventLoop.swift in Sources */,
6FE93AFB2A738D7E002D278A /* NetworkSettings.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -500,13 +523,13 @@
8D41B9A62D15DD6800D16065 /* TunnelLogArchive.swift in Sources */,
8DE452A82CE6C194004CEDF9 /* main.swift in Sources */,
8D5047FB2CE6AA37009802E9 /* FirezoneNetworkExtension-Bridging-Header.h in Sources */,
8D5048042CE6B0AE009802E9 /* SwiftBridgeCore.swift in Sources */,
8D5047FD2CE6AA47009802E9 /* apple-client-ffi.swift in Sources */,
8D5047FC2CE6AA47009802E9 /* CallbackHandler.swift in Sources */,
8D5047FE2CE6AA54009802E9 /* PacketTunnelProvider.swift in Sources */,
8D5048002CE6AA60009802E9 /* SystemConfigurationResolvers.swift in Sources */,
8D5048012CE6AA60009802E9 /* NetworkSettings.swift in Sources */,
8D5047FF2CE6AA54009802E9 /* Adapter.swift in Sources */,
C1892134991C462C8E137DE3 /* Channel.swift in Sources */,
6571861AB9324D6A9395BDB3 /* SessionEventLoop.swift in Sources */,
858AA13A09A55E3B1DD5DEDA /* Adapter.swift in Sources */,
CCC04BEF428B758D9BB5F842 /* connlib.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -558,11 +581,12 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(CONNLIB_TARGET_DIR)/aarch64-apple-ios/debug";
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(CONNLIB_TARGET_DIR)/aarch64-apple-ios/debug";
MACOSX_DEPLOYMENT_TARGET = 12.4;
MARKETING_VERSION = 1.5.9;
ONLY_ACTIVE_ARCH = YES;
OTHER_LDFLAGS = "-lconnlib";
OTHER_SWIFT_FLAGS = "-D UNIFFI";
PRODUCT_BUNDLE_IDENTIFIER = "$(inherited).network-extension";
PRODUCT_NAME = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PROVISIONING_PROFILE_SPECIFIER = "$(NE_PROFILE_ID)";
@@ -600,10 +624,11 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(CONNLIB_TARGET_DIR)/aarch64-apple-ios/release";
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(CONNLIB_TARGET_DIR)/aarch64-apple-ios/release";
MACOSX_DEPLOYMENT_TARGET = 12.4;
MARKETING_VERSION = 1.5.9;
OTHER_LDFLAGS = "-lconnlib";
OTHER_SWIFT_FLAGS = "-D UNIFFI";
PRODUCT_BUNDLE_IDENTIFIER = "$(inherited).network-extension";
PRODUCT_NAME = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PROVISIONING_PROFILE_SPECIFIER = "$(NE_PROFILE_ID)";
@@ -645,13 +670,16 @@
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(CONNLIB_TARGET_DIR)/x86_64-apple-darwin/debug";
MARKETING_VERSION = 1.5.9;
OTHER_LDFLAGS = "-lconnlib";
OTHER_SWIFT_FLAGS = "-D UNIFFI";
PRODUCT_BUNDLE_IDENTIFIER = "$(inherited).network-extension";
PRODUCT_NAME = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PROVISIONING_PROFILE_SPECIFIER = "$(NE_PROFILE_ID)";
SDKROOT = macosx;
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = macosx;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = UNIFFI;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_INCLUDE_PATHS = "$(PROJECT_DIR)/FirezoneNetworkExtension/Connlib/Generated";
SWIFT_OBJC_BRIDGING_HEADER = "FirezoneNetworkExtension/FirezoneNetworkExtension-Bridging-Header.h";
SWIFT_VERSION = 5.0;
};
@@ -683,13 +711,16 @@
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(CONNLIB_TARGET_DIR)/x86_64-apple-darwin/release";
MARKETING_VERSION = 1.5.9;
OTHER_LDFLAGS = "-lconnlib";
OTHER_SWIFT_FLAGS = "-D UNIFFI";
PRODUCT_BUNDLE_IDENTIFIER = "$(inherited).network-extension";
PRODUCT_NAME = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PROVISIONING_PROFILE_SPECIFIER = "$(NE_PROFILE_ID)";
SDKROOT = macosx;
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = macosx;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = UNIFFI;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_INCLUDE_PATHS = "$(PROJECT_DIR)/FirezoneNetworkExtension/Connlib/Generated";
SWIFT_OBJC_BRIDGING_HEADER = "FirezoneNetworkExtension/FirezoneNetworkExtension-Bridging-Header.h";
SWIFT_VERSION = 5.0;
};
@@ -729,7 +760,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CONNLIB_SOURCE_DIR = "$(PROJECT_DIR)/../../rust/apple-client-ffi";
CONNLIB_SOURCE_DIR = "$(PROJECT_DIR)/../../rust/client-ffi";
CONNLIB_TARGET_DIR = "$(PROJECT_DIR)/../../rust/target";
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
@@ -798,7 +829,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CONNLIB_SOURCE_DIR = "$(PROJECT_DIR)/../../rust/apple-client-ffi";
CONNLIB_SOURCE_DIR = "$(PROJECT_DIR)/../../rust/client-ffi";
CONNLIB_TARGET_DIR = "$(PROJECT_DIR)/../../rust/target";
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
@@ -964,7 +995,6 @@
};
/* End XCConfigurationList section */
/* Begin XCSwiftPackageProductDependency section */
794C38142970A2660029F38F /* FirezoneKit */ = {
isa = XCSwiftPackageProductDependency;

View File

@@ -95,6 +95,19 @@
fatalError("Version should exist in bundle")
}
Log.info(
"Checking system extension - Client version: \(ourBundleShortVersion) (\(ourBundleVersion))"
)
// Log all found extensions for debugging
for sysex in properties {
if sysex.isEnabled {
Log.info(
"Found enabled extension - Version: \(sysex.bundleShortVersion) (\(sysex.bundleVersion))"
)
}
}
// Up to date if version and build number match
let isCurrentVersionInstalled = properties.contains { sysex in
sysex.isEnabled
@@ -109,13 +122,17 @@
// Needs replacement if we found our extension, but its version doesn't match
// Note this can happen for upgrades _or_ downgrades
let isAnyVersionInstalled = properties.contains { $0.isEnabled }
if isAnyVersionInstalled {
let enabledExtension = properties.first { $0.isEnabled }
if let enabledExtension = enabledExtension {
Log.warning(
"Extension version mismatch - Installed: \(enabledExtension.bundleShortVersion) (\(enabledExtension.bundleVersion)), Expected: \(ourBundleShortVersion) (\(ourBundleVersion))"
)
resume(returning: .needsReplacement)
return
}
Log.info("No system extension found - needs install")
resume(returning: .needsInstall)
}

View File

@@ -151,7 +151,9 @@ public final class Store: ObservableObject {
// If already installed but the wrong version, go ahead and install. This shouldn't prompt the user.
if systemExtensionStatus == .needsReplacement {
Log.info("Replacing system extension with current version")
try await systemExtensionRequest(.install)
Log.info("System extension replacement completed successfully")
}
#endif
}

View File

@@ -230,9 +230,10 @@
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (
UNNotificationPresentationOptions
) -> Void
withCompletionHandler completionHandler:
@escaping (
UNNotificationPresentationOptions
) -> Void
) {
// Show the notification even when the app is in the foreground
completionHandler([.badge, .banner, .sound])

View File

@@ -1,6 +1,6 @@
//
// Adapter.swift
// (c) 2024 Firezone, Inc.
// (c) 2024-2025 Firezone, Inc.
// LICENSE: Apache-2.0
//
@@ -14,7 +14,7 @@ import OSLog
enum AdapterError: Error {
/// Failure to perform an operation in such state.
case invalidSession(WrappedSession?)
case invalidSession(Session?)
/// connlib failed to start
case connlibConnectError(String)
@@ -35,17 +35,19 @@ enum AdapterError: Error {
}
// Loosely inspired from WireGuardAdapter from WireGuardKit
class Adapter {
typealias StartTunnelCompletionHandler = ((AdapterError?) -> Void)
class Adapter: @unchecked Sendable {
private var callbackHandler: CallbackHandler
/// Command sender for sending commands to the session
private var commandSender: Sender<SessionCommand>?
private var session: WrappedSession?
/// Task handles for explicit cancellation during cleanup
private var eventLoopTask: Task<Void, Never>?
private var eventConsumerTask: Task<Void, Never>?
// Our local copy of the accountSlug
private var accountSlug: String
private let accountSlug: String
/// Network settings
/// Network settings for tunnel configuration.
private var networkSettings: NetworkSettings?
/// Packet tunnel provider.
@@ -54,11 +56,6 @@ class Adapter {
/// Network routes monitor.
private var networkMonitor: NWPathMonitor?
/// Used to avoid path update callback cycles on iOS
#if os(iOS)
private var gateways: [Network.NWEndpoint] = []
#endif
#if os(macOS)
/// Used for finding system DNS resolvers on macOS when network conditions have changed.
private let systemConfigurationResolvers = SystemConfigurationResolvers()
@@ -130,7 +127,7 @@ class Adapter {
// out of a different interface even when 0.0.0.0 is used as the source.
// If our primary interface changes, we can be certain the old socket shouldn't be
// used anymore.
session?.reset("primary network path changed")
self.sendCommand(.reset("primary network path changed"))
}
setSystemDefaultResolvers(path)
@@ -139,26 +136,22 @@ class Adapter {
}
}
/// Currently disabled resources
/// Internet resource enabled state
private var internetResourceEnabled: Bool
/// Cache of internet resource
private var internetResource: Resource?
/// Keep track of resources
/// Keep track of resources for UI
private var resourceListJSON: String?
/// Starting parameters
private let apiURL: String
private let token: Token
private let id: String
private let deviceId: String
private let logFilter: String
private let connlibLogFolderPath: String
init(
apiURL: String,
token: Token,
id: String,
deviceId: String,
logFilter: String,
accountSlug: String,
internetResourceEnabled: Bool,
@@ -166,58 +159,98 @@ class Adapter {
) {
self.apiURL = apiURL
self.token = token
self.id = id
self.packetTunnelProvider = packetTunnelProvider
self.callbackHandler = CallbackHandler()
self.deviceId = deviceId
self.logFilter = logFilter
self.accountSlug = accountSlug
self.connlibLogFolderPath = SharedAccess.connlibLogFolderURL?.path ?? ""
self.networkSettings = nil
self.internetResourceEnabled = internetResourceEnabled
self.packetTunnelProvider = packetTunnelProvider
}
// Could happen abruptly if the process is killed.
deinit {
Log.log("Adapter.deinit")
// Cancel all Tasks - this triggers cooperative cancellation
// Event loop checks Task.isCancelled in its polling loop
// Event consumer will exit when eventSender.deinit closes the stream
eventLoopTask?.cancel()
eventConsumerTask?.cancel()
// Cancel network monitor
networkMonitor?.cancel()
}
/// Start the tunnel.
func start() throws {
Log.log("Adapter.start")
Log.log("Adapter.start: Starting session for account: \(accountSlug)")
guard session == nil else {
throw AdapterError.invalidSession(session)
}
// Get device metadata
let deviceName = DeviceMetadata.getDeviceName()
let osVersion = DeviceMetadata.getOSVersion()
let deviceInfo = try JSONEncoder().encode(DeviceMetadata.deviceInfo())
let deviceInfoStr = String(data: deviceInfo, encoding: .utf8) ?? "{}"
let logDir = SharedAccess.connlibLogFolderURL?.path ?? "/tmp/firezone"
callbackHandler.delegate = self
Log.log("Adapter.start: Starting connlib")
// Create the session
let session: Session
do {
let jsonEncoder = JSONEncoder()
jsonEncoder.keyEncodingStrategy = .convertToSnakeCase
// Grab a session pointer
session = try WrappedSession.connect(
apiURL,
"\(token)",
"\(id)",
accountSlug,
DeviceMetadata.getDeviceName(),
DeviceMetadata.getOSVersion(),
connlibLogFolderPath,
logFilter,
internetResourceEnabled,
callbackHandler,
String(data: jsonEncoder.encode(DeviceMetadata.deviceInfo()), encoding: .utf8)!
session = try Session.newApple(
apiUrl: apiURL,
token: token.description,
deviceId: deviceId,
accountSlug: accountSlug,
deviceName: deviceName,
osVersion: osVersion,
logDir: logDir,
logFilter: logFilter,
deviceInfo: deviceInfoStr,
isInternetResourceActive: internetResourceEnabled
)
} catch let error {
// `toString` needed to deep copy the string and avoid a possible dangling pointer
let msg = (error as? RustString)?.toString() ?? "Unknown error"
throw AdapterError.connlibConnectError(msg)
} catch {
throw AdapterError.connlibConnectError(String(describing: error))
}
// Create channels - following Rust pattern with separate sender/receiver
let (commandSender, commandReceiver): (Sender<SessionCommand>, Receiver<SessionCommand>) =
Channel.create()
self.commandSender = commandSender
let (eventSender, eventReceiver): (Sender<Event>, Receiver<Event>) = Channel.create()
// Start event loop - owns session, receives commands, sends events
eventLoopTask = Task { [weak self] in
defer {
// ALWAYS cleanup, even if event loop crashes
self?.commandSender = nil
Log.log("Adapter: Event loop finished, session dropped")
}
await runSessionEventLoop(
session: session,
commandReceiver: commandReceiver,
eventSender: eventSender
)
}
// Start event consumer - consumes events from receiver (Rust pattern: receiver outside)
eventConsumerTask = Task { [weak self] in
for await event in eventReceiver.stream {
// Check self on each iteration - if Adapter is deallocated, stop processing events
guard let self = self else {
Log.log("Adapter: Event consumer stopping - Adapter deallocated")
break
}
await self.handleEvent(event)
}
Log.log("Adapter: Event consumer finished")
}
// Configure DNS and path monitoring
startNetworkPathMonitoring()
Log.log("Adapter.start: Session started successfully")
}
/// Final callback called by packetTunnelProvider when tunnel is to be stopped.
@@ -232,11 +265,13 @@ class Adapter {
func stop() {
Log.log("Adapter.stop")
// Assigning `nil` will invoke `Drop` on the Rust side
session = nil
sendCommand(.disconnect)
networkMonitor?.cancel()
networkMonitor = nil
// Tasks will finish naturally after disconnect command is processed
// No need to cancel them here - they'll clean up via their defer blocks
}
/// Get the current set of resources in the completionHandler, only returning
@@ -258,119 +293,91 @@ class Adapter {
}
func reset(reason: String, path: Network.NWPath? = nil) {
session?.reset(reason)
sendCommand(.reset(reason))
if let path = (path ?? lastPath) {
setSystemDefaultResolvers(path)
}
}
func resources() -> [Resource] {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
guard let resourceList = resourceListJSON else { return [] }
return (try? decoder.decode([Resource].self, from: resourceList.data(using: .utf8)!)) ?? []
}
func setInternetResourceEnabled(_ enabled: Bool) {
workQueue.async { [weak self] in
guard let self = self else { return }
self.internetResourceEnabled = enabled
session?.setInternetResourceState(enabled)
self.sendCommand(.setInternetResourceState(enabled))
}
}
}
// MARK: Responding to path updates
// MARK: - Event handling
extension Adapter {
private func beginPathMonitoring() {
let networkMonitor = NWPathMonitor()
networkMonitor.pathUpdateHandler = self.pathUpdateHandler
networkMonitor.start(queue: self.workQueue)
}
}
private func handleEvent(_ event: Event) async {
switch event {
case .tunInterfaceUpdated(
let ipv4, let ipv6, let dns, let searchDomain, let ipv4Routes, let ipv6Routes):
Log.log("Received TunInterfaceUpdated event")
// MARK: Implementing CallbackHandlerDelegate
extension Adapter: CallbackHandlerDelegate {
func onSetInterfaceConfig(
tunnelAddressIPv4: String,
tunnelAddressIPv6: String,
searchDomain: String?,
dnsAddresses: [String],
routeListv4: String,
routeListv6: String
) {
// This is a queued callback to ensure ordering
workQueue.async { [weak self] in
guard let self = self else { return }
let networkSettings =
networkSettings ?? NetworkSettings(packetTunnelProvider: packetTunnelProvider)
guard let data4 = routeListv4.data(using: .utf8),
let data6 = routeListv6.data(using: .utf8),
// Decode all data into local variables first to ensure all parsing succeeds before applying
guard let dnsData = dns.data(using: .utf8),
let dnsAddresses = try? JSONDecoder().decode([String].self, from: dnsData),
let data4 = ipv4Routes.data(using: .utf8),
let data6 = ipv6Routes.data(using: .utf8),
let decoded4 = try? JSONDecoder().decode([NetworkSettings.Cidr].self, from: data4),
let decoded6 = try? JSONDecoder().decode([NetworkSettings.Cidr].self, from: data6)
else {
fatalError("Could not decode route list from connlib")
fatalError("Could not decode network configuration from connlib")
}
let routes4 = decoded4.compactMap({ $0.asNEIPv4Route })
let routes6 = decoded6.compactMap({ $0.asNEIPv6Route })
networkSettings.tunnelAddressIPv4 = tunnelAddressIPv4
networkSettings.tunnelAddressIPv6 = tunnelAddressIPv6
// All decoding succeeded - now apply settings atomically
guard let provider = packetTunnelProvider else {
Log.error(AdapterError.invalidSession(nil))
return
}
Log.log("Setting interface config")
let networkSettings = NetworkSettings(packetTunnelProvider: provider)
networkSettings.tunnelAddressIPv4 = ipv4
networkSettings.tunnelAddressIPv6 = ipv6
networkSettings.dnsAddresses = dnsAddresses
networkSettings.routes4 = routes4
networkSettings.routes6 = routes6
networkSettings.setSearchDomain(domain: searchDomain)
self.networkSettings = networkSettings
// Now that we have our interface configured, start listening for events. The first one will be us applying
// our network settings in the call below. We need the physical interface name macOS chooses for us in order
// to get the correct DNS resolvers on macOS. For that, we need the path parameter from the path update callback.
beginPathMonitoring()
networkSettings.apply()
}
}
func onUpdateResources(resourceList: String) {
// This is a queued callback to ensure ordering
workQueue.async { [weak self] in
guard let self = self else { return }
case .resourcesUpdated(let resources):
Log.log("Received ResourcesUpdated event with \(resources.count) bytes")
if resourceListJSON != resourceList, let networkSettings = self.networkSettings {
// Update resource List. We don't care what's inside.
resourceListJSON = resourceList
// Store resource list
workQueue.async { [weak self] in
guard let self = self else { return }
self.resourceListJSON = resources
}
// Apply network settings to flush DNS cache when resources change
// This ensures new DNS resources are immediately resolvable
// Apply network settings to flush DNS cache when resources change
// This ensures new DNS resources are immediately resolvable
if let networkSettings = networkSettings {
Log.log("Reapplying network settings to flush DNS cache after resource update")
networkSettings.apply()
}
session?.setInternetResourceState(self.internetResourceEnabled)
}
}
case .disconnected(let error):
let errorMessage = error.message()
Log.info("Received Disconnected event: \(errorMessage)")
func onDisconnect(error: DisconnectError) {
// Since connlib has already shutdown by this point, we queue this callback
// to ensure that we can clean up even if connlib exits before we are done.
workQueue.async { [weak self] in
guard let self = self else { return }
// Immediately invalidate our session pointer to prevent workQueue items from trying to use it.
// Assigning to `nil` will invoke `Drop` on the Rust side.
// This must happen asynchronously and not as part of the callback to allow Rust to break
// cyclic dependencies between the runtime and the task that is executing the callback.
self.session = nil
guard let provider = packetTunnelProvider else {
Log.error(AdapterError.invalidSession(nil))
return
}
// If auth expired/is invalid, delete stored token and save the reason why so the GUI can act upon it.
if error.isAuthenticationError() {
// Delete stored token and save the reason for the GUI
do {
try Token.delete()
let reason: NEProviderStopReason = .authenticationCanceled
@@ -379,17 +386,26 @@ extension Adapter: CallbackHandlerDelegate {
} catch {
Log.error(error)
}
#if os(iOS)
// iOS notifications should be shown from the tunnel process
SessionNotification.showSignedOutNotificationiOS()
#endif
} else {
Log.warning("Disconnected with error: \(errorMessage)")
}
// Tell the system to shut us down
self.packetTunnelProvider?.cancelTunnelWithError(nil)
// Handle disconnection
provider.cancelTunnelWithError(nil)
}
}
private func startNetworkPathMonitoring() {
networkMonitor = NWPathMonitor()
networkMonitor?.pathUpdateHandler = self.pathUpdateHandler
networkMonitor?.start(queue: workQueue)
}
private func setSystemDefaultResolvers(_ path: Network.NWPath) {
// Step 1: Get system default resolvers
#if os(macOS)
@@ -448,15 +464,14 @@ extension Adapter: CallbackHandlerDelegate {
}
// Step 4: Send to connlib
do {
Log.log("Sending resolvers to connlib: \(jsonResolvers)")
try session?.setDns(jsonResolvers.intoRustString())
} catch let error {
// `toString` needed to deep copy the string and avoid a possible dangling pointer
let msg = (error as? RustString)?.toString() ?? "Unknown error"
Log.error(AdapterError.setDnsError(msg))
}
Log.log("Sending resolvers to connlib: \(jsonResolvers)")
sendCommand(.setDns(jsonResolvers))
}
private func sendCommand(_ command: SessionCommand) {
commandSender?.send(command)
}
}
// MARK: Getting System Resolvers on iOS

View File

@@ -1,79 +0,0 @@
//
// CallbackHandler.swift
//
import FirezoneKit
import NetworkExtension
import OSLog
// When the FFI changes from the Rust side, change the CallbackHandler
// functions along with that, but not the delegate protocol.
// When the app gets updated to use the FFI, the delegate protocol
// shall get updated.
// This is so that the app stays buildable even when the FFI changes.
// See https://github.com/chinedufn/swift-bridge/issues/150
extension RustString: @unchecked Sendable {}
extension RustString: Error {}
public protocol CallbackHandlerDelegate: AnyObject {
func onSetInterfaceConfig(
tunnelAddressIPv4: String,
tunnelAddressIPv6: String,
searchDomain: String?,
dnsAddresses: [String],
routeListv4: String,
routeListv6: String
)
func onUpdateResources(resourceList: String)
func onDisconnect(error: DisconnectError)
}
public class CallbackHandler {
public weak var delegate: CallbackHandlerDelegate?
func onSetInterfaceConfig(
tunnelAddressIPv4: RustString,
tunnelAddressIPv6: RustString,
searchDomain: RustString?,
dnsAddresses: RustString,
routeListv4: RustString,
routeListv6: RustString
) {
Log.log(
"""
CallbackHandler.onSetInterfaceConfig:
IPv4: \(tunnelAddressIPv4.toString())
IPv6: \(tunnelAddressIPv6.toString())
SearchDomain: \(String(describing: (searchDomain?.toString())))
DNS: \(dnsAddresses.toString())
IPv4 routes: \(routeListv4.toString())
IPv6 routes: \(routeListv6.toString())
""")
guard let dnsData = dnsAddresses.toString().data(using: .utf8),
let dnsArray = try? JSONDecoder().decode([String].self, from: dnsData)
else {
fatalError("Should be able to decode DNS Addresses from connlib")
}
delegate?.onSetInterfaceConfig(
tunnelAddressIPv4: tunnelAddressIPv4.toString(),
tunnelAddressIPv6: tunnelAddressIPv6.toString(),
searchDomain: searchDomain?.toString(),
dnsAddresses: dnsArray,
routeListv4: routeListv4.toString(),
routeListv6: routeListv6.toString()
)
}
func onUpdateResources(resourceList: RustString) {
Log.log("CallbackHandler.onUpdateResources: \(resourceList.toString())")
delegate?.onUpdateResources(resourceList: resourceList.toString())
}
func onDisconnect(error: DisconnectError) {
let errorStr = error.toString()
Log.log("CallbackHandler.onDisconnect: \(errorStr)")
delegate?.onDisconnect(error: error)
}
}

View File

@@ -0,0 +1,82 @@
import Foundation
/// Sender side of a channel - can only send values.
///
/// This wraps AsyncStream.Continuation to provide automatic cleanup via RAII.
/// When the sender is deallocated, the continuation is automatically finished,
/// matching Rust's behaviour where dropping a sender closes the channel.
final class Sender<T: Sendable>: Sendable {
private let continuation: AsyncStream<T>.Continuation
fileprivate init(continuation: AsyncStream<T>.Continuation) {
self.continuation = continuation
}
/// Sends a value into the channel.
@discardableResult
func send(_ value: T) -> AsyncStream<T>.Continuation.YieldResult {
continuation.yield(value)
}
/// Explicitly finishes the channel (optional, as deinit will do this automatically).
func finish() {
continuation.finish()
}
deinit {
continuation.finish()
}
}
/// Receiver side of a channel - can only receive values.
///
/// This wraps AsyncStream to provide the receiving end of the channel.
/// Values can be consumed by iterating over the stream:
///
/// for await value in receiver.stream {
/// // handle value
/// }
///
final class Receiver<T: Sendable>: Sendable {
let stream: AsyncStream<T>
fileprivate init(stream: AsyncStream<T>) {
self.stream = stream
}
}
/// Channel factory - creates sender/receiver pairs matching Rust's channel pattern.
///
/// This provides a type-safe way to create unidirectional communication channels,
/// where the sender can only send and the receiver can only receive.
///
/// Example usage:
///
/// let (sender, receiver) = Channel.create<Event>()
///
/// // Producer task
/// Task {
/// sender.send(someEvent)
/// }
///
/// // Consumer task
/// Task {
/// for await event in receiver.stream {
/// handle(event)
/// }
/// }
///
struct Channel {
/// Creates a sender/receiver pair for type-safe unidirectional communication.
static func create<T: Sendable>() -> (Sender<T>, Receiver<T>) {
var continuation: AsyncStream<T>.Continuation!
let stream = AsyncStream<T> { cont in
continuation = cont
}
let sender = Sender(continuation: continuation)
let receiver = Receiver(stream: stream)
return (sender, receiver)
}
}

View File

@@ -1,5 +1,2 @@
$(PROJECT_DIR)/FirezoneNetworkExtension/Connlib/Generated/apple-client-ffi/apple-client-ffi.swift
$(PROJECT_DIR)/FirezoneNetworkExtension/Connlib/Generated/apple-client-ffi/apple-client-ffi.h
$(PROJECT_DIR)/FirezoneNetworkExtension/Connlib/Generated/SwiftBridgeCore.h
$(PROJECT_DIR)/FirezoneNetworkExtension/Connlib/Generated/SwiftBridgeCore.swift
$(PROJECT_DIR)/FirezoneNetworkExtension/Connlib/connlib.h
$(PROJECT_DIR)/FirezoneNetworkExtension/Connlib/Generated/connlib.swift
$(PROJECT_DIR)/FirezoneNetworkExtension/Connlib/Generated/connlibFFI.h

View File

@@ -0,0 +1,7 @@
#ifndef connlib_Bridging_Header_h
#define connlib_Bridging_Header_h
// Import the generated UniFFI C header
#import "connlibFFI.h"
#endif /* connlib_Bridging_Header_h */

View File

@@ -1,9 +0,0 @@
// Umbrella header for connlib
#ifndef connlib_h
#define connlib_h
#include "Generated/SwiftBridgeCore.h"
#include "Generated/apple-client-ffi/apple-client-ffi.h"
#endif

View File

@@ -2,5 +2,7 @@
// Use this file to import your target's public headers that you would like to expose to Swift.
//
#include "Connlib/connlib.h"
// UniFFI generated header
#include "Connlib/Generated/connlibFFI.h"
#include <resolv.h>

View File

@@ -35,6 +35,14 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
super.init()
// Log version information immediately on startup
let version =
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "unknown"
let bundleId = Bundle.main.bundleIdentifier ?? "unknown"
Log.info(
"NetworkExtension starting - Version: \(version), Build: \(build), Bundle ID: \(bundleId)")
migrateFirezoneId()
self.tunnelConfiguration = TunnelConfiguration.tryLoad()
}
@@ -51,10 +59,17 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
// Dummy start to get the extension running on macOS after upgrade
if options?["dryRun"] as? Bool == true {
Log.info("Dry run startup requested - extension awakened but not starting tunnel")
completionHandler(nil)
return
}
// Log version on actual tunnel start
let version =
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "unknown"
Log.info("Starting tunnel - Version: \(version), Build: \(build)")
// If the tunnel starts up before the GUI after an upgrade crossing the 1.4.15 version boundary,
// the old system settings-based config will still be present and the new configuration will be empty.
// So handle that edge case gracefully.
@@ -93,16 +108,18 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
let internetResourceEnabled =
enabled != nil ? enabled == "true" : (tunnelConfiguration?.internetResourceEnabled ?? false)
// Create the adapter with all configuration
let adapter = Adapter(
apiURL: apiURL,
token: token,
id: id,
deviceId: id,
logFilter: logFilter,
accountSlug: accountSlug,
internetResourceEnabled: internetResourceEnabled,
packetTunnelProvider: self
)
// Start the adapter
try adapter.start()
self.adapter = adapter
@@ -149,11 +166,13 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
case .setConfiguration(let tunnelConfiguration):
tunnelConfiguration.save()
self.tunnelConfiguration = tunnelConfiguration
self.adapter?.setInternetResourceEnabled(tunnelConfiguration.internetResourceEnabled)
completionHandler?(nil)
case .signOut:
do { try Token.delete() } catch { Log.error(error) }
completionHandler?(nil)
case .getResourceList(let hash):
guard let adapter = adapter
else {
@@ -163,6 +182,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
return
}
// Use hash comparison to only return resources if they've changed
adapter.getResourcesIfVersionDifferentFrom(hash: hash) { resourceListJSON in
completionHandler?(resourceListJSON?.data(using: .utf8))
}

View File

@@ -0,0 +1,91 @@
import FirezoneKit
import Foundation
/// Commands that can be sent to the Session.
enum SessionCommand {
case disconnect
case setInternetResourceState(Bool)
case setDns(String)
case reset(String)
}
/// Runs the session event loop, owning the Session lifecycle.
///
/// When either task completes, both are cancelled and the function returns.
/// This ensures the Session's Drop is called on the Rust side.
func runSessionEventLoop(
session: Session,
commandReceiver: Receiver<SessionCommand>,
eventSender: Sender<Event>
) async {
// Multiplex between commands and events
await withTaskGroup(of: Void.self) { group in
// Event polling task - polls Rust for events and sends to eventSender
group.addTask {
while !Task.isCancelled {
do {
// Poll for next event from Rust
guard let event = try await session.nextEvent() else {
// No event returned - session has ended
Log.log("SessionEventLoop: Event stream ended, exiting event loop")
break
}
eventSender.send(event)
} catch {
Log.error(error)
Log.log("SessionEventLoop: Error polling event, continuing")
continue
}
}
Log.log("SessionEventLoop: Event polling finished")
}
// Command handling task - receives commands from commandReceiver
group.addTask {
for await command in commandReceiver.stream {
await handleCommand(command, session: session)
// Exit loop if disconnect command
if case .disconnect = command {
Log.log("SessionEventLoop: Disconnect command received, exiting command loop")
break
}
}
Log.log("SessionEventLoop: Command handling finished")
}
// Wait for first task to complete, then cancel all
_ = await group.next()
Log.log("SessionEventLoop: One task completed, cancelling event loop")
group.cancelAll()
}
}
/// Handles a command by calling the appropriate session method.
private func handleCommand(_ command: SessionCommand, session: Session) async {
switch command {
case .disconnect:
do {
try session.disconnect()
} catch {
Log.error(error)
}
case .setInternetResourceState(let active):
session.setInternetResourceState(active: active)
case .setDns(let servers):
do {
try session.setDns(dnsServers: servers)
} catch {
Log.error(error)
}
case .reset(let reason):
session.reset(reason: reason)
}
}

View File

@@ -1,15 +1,29 @@
# Creates a macOS debug build
PLATFORM=macOS
PLATFORM?=macOS
ARCH=$(shell uname -m)
CONFIGURATION?=Debug
# Path to rust target directory (relative to this Makefile)
RUST_TARGET_DIR=$(abspath ../../rust/target)
# Set consistent environment to prevent Rust rebuilds
# This must match the Xcode project setting
export MACOSX_DEPLOYMENT_TARGET=12.4
build-macos:
echo "Building debug build for ${PLATFORM}, ${ARCH}"
@xcodebuild build -scheme Firezone -sdk macosx -destination 'platform=${PLATFORM},arch=${ARCH}'
# Map architecture names for Rust targets
ifeq ($(ARCH),arm64)
RUST_TARGET=aarch64-apple-darwin
else
RUST_TARGET=x86_64-apple-darwin
endif
# Get the current Git SHA for build identification
GIT_SHA=$(shell git rev-parse HEAD 2>/dev/null || echo "unknown")
# Default target: build and install
.PHONY: all
all: build install
# Info for sourcekit-lsp (LSP server for other IDEs)
lsp:
@@ -17,6 +31,20 @@ lsp:
-project Firezone.xcodeproj \
-scheme Firezone
.PHONY: build
build:
@echo "Building Xcode project for ${PLATFORM}, ${ARCH}"
@echo "Git SHA: ${GIT_SHA}"
@xcodebuild build \
-project Firezone.xcodeproj \
-scheme Firezone \
-configuration $(CONFIGURATION) \
-sdk macosx \
-destination 'platform=${PLATFORM},arch=${ARCH}' \
CONNLIB_TARGET_DIR="${RUST_TARGET_DIR}" \
GIT_SHA="${GIT_SHA}" \
ONLY_ACTIVE_ARCH=YES
.PHONY: install
install:
@echo "Stopping any running Firezone instances..."
@@ -32,9 +60,18 @@ install:
@echo "Launching Firezone..."
@open /Applications/Firezone.app
.PHONY: clean
clean:
@xcodebuild clean -scheme Firezone -sdk macosx -destination 'platform=${PLATFORM},arch=${ARCH}'
cd ../../rust/apple-client-ffi && rm -rf ./Connlib.xcframework
@echo "Cleaning Xcode build"
@xcodebuild clean \
-project Firezone.xcodeproj \
-scheme Firezone \
-configuration $(CONFIGURATION) \
-sdk macosx
@echo "Cleaning Rust build artifacts"
@cd ../../rust/client-ffi && cargo clean
@echo "Removing Generated bindings"
@rm -rf FirezoneNetworkExtension/Connlib/Generated
.PHONY: format
format:
@@ -42,6 +79,14 @@ format:
@find . -name "*.swift" -not -path "./FirezoneNetworkExtension/Connlib/Generated/*" -not -path "./FirezoneKit/.build/*" | xargs swift format format --in-place --parallel
@echo "Linting Swift code..."
@find . -name "*.swift" -not -path "./FirezoneNetworkExtension/Connlib/Generated/*" -not -path "./FirezoneKit/.build/*" | xargs swift format lint --parallel --strict
.PHONY: setup
setup:
@echo "Installing required Rust targets..."
@rustup target add aarch64-apple-darwin x86_64-apple-darwin
@rustup target add aarch64-apple-ios x86_64-apple-ios
@echo "Setup complete!"
.PHONY: show-client-log
show-client-log:
@echo "Opening latest Firezone client log..."

206
swift/apple/build-rust.sh Executable file
View File

@@ -0,0 +1,206 @@
#!/bin/bash
set -euo pipefail
# Error handler
trap 'echo "ERROR: Build script failed at line $LINENO" >&2' ERR
# Get script directory
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
RUST_DIR="$SCRIPT_DIR/../../rust"
GENERATED_DIR="$SCRIPT_DIR/FirezoneNetworkExtension/Connlib/Generated"
# Sanitize the environment to prevent Xcode's shenanigans from leaking
# into our highly evolved Rust-based build system.
for var in $(env | awk -F= '{print $1}'); do
if [[ "$var" != "HOME" ]] &&
[[ "$var" != "MACOSX_DEPLOYMENT_TARGET" ]] &&
[[ "$var" != "IPHONEOS_DEPLOYMENT_TARGET" ]] &&
[[ "$var" != "USER" ]] &&
[[ "$var" != "LOGNAME" ]] &&
[[ "$var" != "TERM" ]] &&
[[ "$var" != "PWD" ]] &&
[[ "$var" != "SHELL" ]] &&
[[ "$var" != "TMPDIR" ]] &&
[[ "$var" != "XPC_FLAGS" ]] &&
[[ "$var" != "XPC_SERVICE_NAME" ]] &&
[[ "$var" != "PLATFORM_NAME" ]] &&
[[ "$var" != "CONFIGURATION" ]] &&
[[ "$var" != "NATIVE_ARCH" ]] &&
[[ "$var" != "ONLY_ACTIVE_ARCH" ]] &&
[[ "$var" != "ARCHS" ]] &&
[[ "$var" != "SDKROOT" ]] &&
[[ "$var" != "OBJROOT" ]] &&
[[ "$var" != "SYMROOT" ]] &&
[[ "$var" != "SRCROOT" ]] &&
[[ "$var" != "TARGETED_DEVICE_FAMILY" ]] &&
[[ "$var" != "RUSTC_WRAPPER" ]] &&
[[ "$var" != "RUST_TOOLCHAIN" ]] &&
[[ "$var" != "SCCACHE_GCS_BUCKET" ]] &&
[[ "$var" != "SCCACHE_GCS_RW_MODE" ]] &&
[[ "$var" != "GOOGLE_CLOUD_PROJECT" ]] &&
[[ "$var" != "GCP_PROJECT" ]] &&
[[ "$var" != "GCLOUD_PROJECT" ]] &&
[[ "$var" != "CLOUDSDK_PROJECT" ]] &&
[[ "$var" != "CLOUDSDK_CORE_PROJECT" ]] &&
[[ "$var" != "GOOGLE_GHA_CREDS_PATH" ]] &&
[[ "$var" != "GOOGLE_APPLICATION_CREDENTIALS" ]] &&
[[ "$var" != "CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE" ]] &&
[[ "$var" != "ACTIONS_CACHE_URL" ]] &&
[[ "$var" != "ACTIONS_RUNTIME_TOKEN" ]] &&
[[ "$var" != "CARGO_INCREMENTAL" ]] &&
[[ "$var" != "CARGO_TERM_COLOR" ]] &&
[[ "$var" != "FIREZONE_PACKAGE_VERSION" ]] &&
[[ "$var" != "CONNLIB_TARGET_DIR" ]]; then
unset "$var"
fi
done
# Use pristine path; the PATH from Xcode is polluted with stuff we don't want which can
# confuse rustc.
export PATH="/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:$HOME/.cargo/bin:/run/current-system/sw/bin/"
# Parse Xcode environment
PLATFORM_NAME="${PLATFORM_NAME:-macosx}"
CONFIGURATION="${CONFIGURATION:-Debug}"
NATIVE_ARCH="${ARCHS:-${NATIVE_ARCH:-$(uname -m)}}"
# Set target directory - use CONNLIB_TARGET_DIR if set, otherwise default
export CARGO_TARGET_DIR="${CONNLIB_TARGET_DIR:-$RUST_DIR/target}"
echo "========================================="
echo "Building Connlib for Xcode"
echo "Platform: $PLATFORM_NAME"
echo "Configuration: $CONFIGURATION"
echo "Architecture: $NATIVE_ARCH"
echo "Target Directory: $CARGO_TARGET_DIR"
echo "========================================="
# Determine Rust targets based on platform and architecture
TARGETS=()
case "$PLATFORM_NAME" in
macosx)
if [[ "$CONFIGURATION" == "Release" ]] || [[ "$CONFIGURATION" == "Profile" ]] || [[ -z "$NATIVE_ARCH" ]]; then
# Build universal binary for Release and Profile
TARGETS=("aarch64-apple-darwin" "x86_64-apple-darwin")
else
# Build only for native arch in Debug
if [[ "$NATIVE_ARCH" == "arm64" ]]; then
TARGETS=("aarch64-apple-darwin")
elif [[ "$NATIVE_ARCH" == "x86_64" ]]; then
TARGETS=("x86_64-apple-darwin")
else
echo "ERROR: Unsupported native arch for $PLATFORM_NAME: $NATIVE_ARCH" >&2
exit 1
fi
fi
;;
iphoneos)
TARGETS=("aarch64-apple-ios")
;;
iphonesimulator)
if [[ "$NATIVE_ARCH" == "arm64" ]]; then
TARGETS=("aarch64-apple-ios-sim")
elif [[ "$NATIVE_ARCH" == "x86_64" ]]; then
TARGETS=("x86_64-apple-ios")
else
echo "ERROR: Unsupported native arch for $PLATFORM_NAME: $NATIVE_ARCH" >&2
exit 1
fi
;;
*)
echo "ERROR: Unknown platform: $PLATFORM_NAME" >&2
exit 1
;;
esac
# Prepare cargo build flags
if [ "$CONFIGURATION" = "Release" ] || [ "$CONFIGURATION" = "Profile" ]; then
CARGO_BUILD_FLAGS="--release"
BUILD_DIR="release"
else
CARGO_BUILD_FLAGS=""
BUILD_DIR="debug"
fi
# Ensure RUSTUP_HOME is set
if [ -z "${RUSTUP_HOME:-}" ] && [ -d "$HOME/.rustup" ]; then
export RUSTUP_HOME="$HOME/.rustup"
fi
# Ensure Rust targets are installed (from rust directory to use correct toolchain)
cd "$RUST_DIR"
for target in "${TARGETS[@]}"; do
if ! rustup target list --installed | grep -q "^$target$"; then
echo "Installing Rust target: $target"
rustup target add "$target"
fi
done
# Step 1: Build Rust library
echo ""
echo "Step 1/3: Building Rust library..."
# Build target list for cargo command
target_list=""
for target in "${TARGETS[@]}"; do
target_list+="--target $target "
done
target_list="${target_list% }"
cd "$RUST_DIR"
cargo build --package client-ffi $target_list $CARGO_BUILD_FLAGS
# Remove any dylib files to ensure static linking
for target in "${TARGETS[@]}"; do
LIBRARY_PATH="$CARGO_TARGET_DIR/$target/$BUILD_DIR"
if [ -f "$LIBRARY_PATH/libconnlib.dylib" ]; then
rm -f "$LIBRARY_PATH/libconnlib.dylib"
fi
done
# Step 2: Generate UniFFI bindings
echo ""
echo "Step 2/3: Generating UniFFI bindings..."
mkdir -p "$GENERATED_DIR"
# Use the first target's library for generating bindings (they're the same for all architectures)
FIRST_TARGET="${TARGETS[0]}"
LIBRARY_PATH="$CARGO_TARGET_DIR/$FIRST_TARGET/$BUILD_DIR"
cargo run -p uniffi-bindgen -- generate \
--library "$LIBRARY_PATH/libconnlib.a" \
--language swift \
--out-dir "$GENERATED_DIR"
# Remove module maps (we use bridging header instead)
rm -f "$GENERATED_DIR"/*.modulemap
# Fix imports in generated Swift file to use bridging header
if [ -f "$GENERATED_DIR/connlib.swift" ]; then
# Comment out the #if canImport(connlibFFI) block
sed -i.bak '/#if canImport(connlibFFI)/,/#endif/s/^/\/\/ /' "$GENERATED_DIR/connlib.swift"
rm -f "$GENERATED_DIR/connlib.swift.bak"
fi
# Step 3: Verify generated files
echo ""
echo "Step 3/3: Verifying generated files..."
if [ ! -f "$GENERATED_DIR/connlib.swift" ]; then
echo "ERROR: Generated Swift file not found" >&2
exit 1
fi
if [ ! -f "$GENERATED_DIR/connlibFFI.h" ]; then
echo "ERROR: Generated header file not found" >&2
exit 1
fi
echo ""
echo "✅ Build completed successfully!"
echo " Swift bindings: $GENERATED_DIR/connlib.swift"
echo " C header: $GENERATED_DIR/connlibFFI.h"
echo " Built libraries:"
for target in "${TARGETS[@]}"; do
echo " - $CARGO_TARGET_DIR/$target/$BUILD_DIR/libconnlib.a"
done
echo "========================================="

View File

@@ -1,30 +0,0 @@
#!/bin/bash
set -e
ARG=$1
if [[ -z "$ARG" ]]; then
ARG="swift"
fi
if [[ $ARG == "all" ]] || [[ $ARG == "swift" ]] || [[ $ARG == "rust" ]]; then
echo "Cleaning up $ARG build artifacts";
else
echo "Usage: $0 [ all | swift | rust ]"
echo " (Default: swift)"
fi
if [[ $ARG == "swift" ]] || [[ $ARG == "all" ]]; then
set -x
xcodebuild clean
rm -rf ./FirezoneNetworkExtension/Connlib
set +x
fi
if [[ $ARG == "rust" ]] || [[ $ARG == "all" ]]; then
set -x
cd ../../rust/apple-client-ffi && cargo clean
cd Sources/Connlib/Generated && git clean -df
set +x
fi