diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index c33e2e7ff1..f58607f692 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -2823,7 +2823,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
- "socket2 0.5.10",
+ "socket2 0.6.5",
"system-configuration",
"tokio",
"tower-service",
@@ -3892,6 +3892,18 @@ dependencies = [
"objc2-foundation",
]
+[[package]]
+name = "objc2-call-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e33ab4dde42f7a6955a9ed8af82b9ff7d163b3791f5cf6288312742dc874403"
+dependencies = [
+ "block2",
+ "dispatch2",
+ "objc2",
+ "objc2-foundation",
+]
+
[[package]]
name = "objc2-cloud-kit"
version = "0.3.2"
@@ -4823,7 +4835,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls",
- "socket2 0.5.10",
+ "socket2 0.6.5",
"thiserror 2.0.19",
"tokio",
"tracing",
@@ -4861,7 +4873,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
- "socket2 0.5.10",
+ "socket2 0.6.5",
"tracing",
"windows-sys 0.61.2",
]
@@ -5391,6 +5403,7 @@ dependencies = [
"log",
"objc2",
"objc2-avf-audio",
+ "objc2-call-kit",
"objc2-foundation",
"objc2-photos",
"percent-encoding",
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index ee6cfdbb3e..c8c27dac89 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -127,7 +127,13 @@ tauri-plugin-sharekit = { git = "https://github.com/Choochmeque/tauri-plugin-sha
[target.'cfg(target_os = "ios")'.dependencies]
objc2 = "0.6"
-objc2-foundation = { version = "0.3", features = ["NSError", "NSFileManager", "NSString", "NSURL"] }
+objc2-foundation = { version = "0.3", features = [
+ "NSError",
+ "NSFileManager",
+ "NSString",
+ "NSURL",
+ "NSUUID",
+] }
objc2-photos = { version = "0.3.2", default-features = false, features = [
"std",
"PHPhotoLibrary",
@@ -145,6 +151,24 @@ objc2-avf-audio = { version = "0.3", default-features = false, features = [
"AVAudioSession",
"AVAudioSessionTypes",
] }
+objc2-call-kit = { version = "0.3", default-features = false, features = [
+ "std",
+ "alloc",
+ "block2",
+ "dispatch2",
+ "CXAction",
+ "CXProvider",
+ "CXProviderConfiguration",
+ "CXCallUpdate",
+ "CXHandle",
+ "CXCall",
+ "CXCallAction",
+ "CXStartCallAction",
+ "CXEndCallAction",
+ "CXSetMutedCallAction",
+ "CXTransaction",
+ "CXCallController",
+] }
[target.'cfg(target_os = "android")'.dependencies]
tauri-plugin-android-fs = { version = "=28.4.0", features = ["legacy-storage-permission"] }
diff --git a/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/src-tauri/gen/android/app/src/main/AndroidManifest.xml
index ce9f7cdf2b..7ada59c8d5 100644
--- a/src-tauri/gen/android/app/src/main/AndroidManifest.xml
+++ b/src-tauri/gen/android/app/src/main/AndroidManifest.xml
@@ -17,6 +17,8 @@
+
+
diff --git a/src-tauri/gen/android/app/src/main/java/moe/sable/client/CallForegroundService.kt b/src-tauri/gen/android/app/src/main/java/moe/sable/client/CallForegroundService.kt
index 45b8cb55b0..86f53694d9 100644
--- a/src-tauri/gen/android/app/src/main/java/moe/sable/client/CallForegroundService.kt
+++ b/src-tauri/gen/android/app/src/main/java/moe/sable/client/CallForegroundService.kt
@@ -6,13 +6,20 @@ import android.app.NotificationManager
import android.app.Service
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
+import android.media.AudioAttributes
+import android.media.AudioFocusRequest
+import android.media.AudioManager
import android.os.Build
import android.os.IBinder
+import android.os.PowerManager
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
class CallForegroundService : Service() {
+ private var wakeLock: PowerManager.WakeLock? = null
+ private var audioFocusRequest: AudioFocusRequest? = null
+
override fun onBind(intent: android.content.Intent?): IBinder? = null
override fun onStartCommand(intent: android.content.Intent?, flags: Int, startId: Int): Int {
@@ -47,14 +54,52 @@ class CallForegroundService : Service() {
return START_NOT_STICKY
}
+ acquireWakeLock()
+ requestAudioFocus()
+
return START_NOT_STICKY
}
override fun onDestroy() {
+ releaseAudioFocus()
+ releaseWakeLock()
stopForeground(STOP_FOREGROUND_REMOVE)
super.onDestroy()
}
+ private fun acquireWakeLock() {
+ if (wakeLock?.isHeld == true) return
+ val pm = getSystemService(POWER_SERVICE) as PowerManager
+ wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "Sable:CallForeground")
+ wakeLock?.acquire(WAKE_LOCK_TIMEOUT_MS)
+ }
+
+ private fun releaseWakeLock() {
+ wakeLock?.let { if (it.isHeld) it.release() }
+ wakeLock = null
+ }
+
+ private fun requestAudioFocus() {
+ val am = getSystemService(AUDIO_SERVICE) as AudioManager
+ val attributes = AudioAttributes.Builder()
+ .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
+ .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
+ .build()
+ audioFocusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
+ .setAudioAttributes(attributes)
+ .setOnAudioFocusChangeListener { _ -> }
+ .build()
+ am.requestAudioFocus(audioFocusRequest!!)
+ }
+
+ private fun releaseAudioFocus() {
+ audioFocusRequest?.let {
+ val am = getSystemService(AUDIO_SERVICE) as AudioManager
+ am.abandonAudioFocusRequest(it)
+ }
+ audioFocusRequest = null
+ }
+
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
@@ -70,5 +115,6 @@ class CallForegroundService : Service() {
const val CHANNEL_ID = "call_foreground"
const val NOTIFICATION_ID = 1395
const val TAG = "CallForegroundService"
+ const val WAKE_LOCK_TIMEOUT_MS = 60L * 60L * 1000L
}
}
diff --git a/src-tauri/ios-project.yml b/src-tauri/ios-project.yml
index 07080a98ba..298ff0cb6d 100644
--- a/src-tauri/ios-project.yml
+++ b/src-tauri/ios-project.yml
@@ -92,7 +92,7 @@ targets:
properties:
LSRequiresIPhoneOS: true
UILaunchStoryboardName: LaunchScreen
- UIBackgroundModes: [audio]
+ UIBackgroundModes: [audio, voip]
NSMicrophoneUsageDescription: Sable uses your microphone for calls.
NSCameraUsageDescription: Sable uses your camera for video calls.
UIRequiredDeviceCapabilities: [arm64, metal]
@@ -107,7 +107,7 @@ targets:
- UIInterfaceOrientationLandscapeRight
CFBundleShortVersionString: {{apple.bundle-version-short}}
CFBundleVersion: "{{apple.bundle-version}}"
- UIBackgroundModes: [audio]
+ UIBackgroundModes: [audio, voip]
NSPhotoLibraryAddUsageDescription: Sable saves images you download to your photo library.
# Mirrors plugins.deep-link in tauri.conf.json: the plugin only patches
# these in when cargo reruns its build script, so a fresh `tauri ios
@@ -159,7 +159,9 @@ targets:
- sdk: MetalKit.framework
- sdk: QuartzCore.framework
- sdk: Security.framework
- - sdk: UIKit.framework{{#if this.ios-frameworks}}{{~#each ios-frameworks}}
+ - sdk: UIKit.framework
+ - sdk: CallKit.framework
+ - sdk: AVFoundation.framework{{#if this.ios-frameworks}}{{~#each ios-frameworks}}
- sdk: {{this}}.framework{{/each}}{{/if}}
- sdk: WebKit.framework
preBuildScripts:
diff --git a/src-tauri/src/ios.rs b/src-tauri/src/ios.rs
index 281f523aca..ac88bc6295 100644
--- a/src-tauri/src/ios.rs
+++ b/src-tauri/src/ios.rs
@@ -7,11 +7,16 @@ use std::ffi::CString;
use std::sync::OnceLock;
use objc2::rc::{Allocated, Retained};
-use objc2::runtime::{AnyClass, AnyObject, ClassBuilder, Sel};
-use objc2::{msg_send, sel};
-use objc2_avf_audio::{
- AVAudioSession, AVAudioSessionCategoryOptions, AVAudioSessionCategoryPlayAndRecord,
+use objc2::runtime::{
+ AnyClass, AnyObject, ClassBuilder, NSObject, NSObjectProtocol, ProtocolObject, Sel,
};
+use objc2::{define_class, msg_send, sel, AnyThread, ClassType};
+use objc2_avf_audio::AVAudioSession;
+use objc2_call_kit::{
+ CXCallController, CXCallUpdate, CXEndCallAction, CXHandle, CXHandleType, CXProvider,
+ CXProviderConfiguration, CXProviderDelegate, CXStartCallAction, CXTransaction,
+};
+use objc2_foundation::{NSString, NSUUID};
use tauri::webview::WebviewWindow;
extern "C-unwind" fn input_accessory_view_nil(_this: &AnyObject, _cmd: Sel) -> *mut AnyObject {
@@ -61,22 +66,199 @@ pub fn haptic_feedback(style: String) {
#[tauri::command]
pub fn activate_call_audio_session() -> Result<(), String> {
- let session = unsafe { AVAudioSession::sharedInstance() };
- unsafe {
- session.setCategory_withOptions_error(
- AVAudioSessionCategoryPlayAndRecord.expect("PlayAndRecord category is unavailable"),
- AVAudioSessionCategoryOptions::DefaultToSpeaker
- | AVAudioSessionCategoryOptions::AllowBluetoothHFP,
- )
- }
- .map_err(|error| error.to_string())?;
- unsafe { session.setActive_error(true) }.map_err(|error| error.to_string())
+ callkit::start_call()
}
#[tauri::command]
pub fn deactivate_call_audio_session() -> Result<(), String> {
- let session = unsafe { AVAudioSession::sharedInstance() };
- unsafe { session.setActive_error(false) }.map_err(|error| error.to_string())
+ callkit::end_call()
+}
+
+// CallKit owns the audio session and keeps the process alive in the
+// background via the `voip` UIBackgroundMode (Apple rejects `voip` without it).
+
+use std::cell::RefCell;
+
+thread_local! {
+ static CALLKIT: RefCell = RefCell::new(CallKitState::default());
+}
+
+#[derive(Default)]
+struct CallKitState {
+ provider: Option>,
+ controller: Option>,
+ uuid: Option>,
+}
+
+// Minimal CXProviderDelegate: calls are controlled from Sable's UI, so the
+// perform* methods just fulfill.
+define_class!(
+ #[unsafe(super(NSObject))]
+ #[name = "SableCallKitDelegate"]
+ struct SableCallKitDelegate;
+
+ unsafe impl NSObjectProtocol for SableCallKitDelegate {}
+
+ unsafe impl CXProviderDelegate for SableCallKitDelegate {
+ #[unsafe(method(providerDidReset:))]
+ fn providerDidReset(&self, _provider: &CXProvider) {
+ CALLKIT.with(|s| s.borrow_mut().uuid = None);
+ }
+
+ #[unsafe(method(provider:didActivateAudioSession:))]
+ fn provider_didActivateAudioSession(
+ &self,
+ _provider: &CXProvider,
+ _session: &AVAudioSession,
+ ) {
+ }
+
+ #[unsafe(method(provider:didDeactivateAudioSession:))]
+ fn provider_didDeactivateAudioSession(
+ &self,
+ _provider: &CXProvider,
+ _session: &AVAudioSession,
+ ) {
+ }
+
+ #[unsafe(method(provider:performStartCallAction:))]
+ fn provider_performStartCallAction(
+ &self,
+ _provider: &CXProvider,
+ action: &CXStartCallAction,
+ ) {
+ unsafe { action.fulfill() };
+ }
+
+ #[unsafe(method(provider:performEndCallAction:))]
+ fn provider_performEndCallAction(&self, _provider: &CXProvider, action: &CXEndCallAction) {
+ unsafe { action.fulfill() };
+ }
+ }
+);
+
+mod callkit {
+ use super::*;
+
+ fn ensure_provider() -> Result<(), String> {
+ CALLKIT.with(|s| -> Result<(), String> {
+ let mut s = s.borrow_mut();
+ if s.provider.is_some() && s.controller.is_some() {
+ return Ok(());
+ }
+
+ let config = unsafe {
+ let allocated = CXProviderConfiguration::alloc();
+ let cfg = CXProviderConfiguration::init(allocated);
+ cfg.setSupportsVideo(true);
+ cfg.setMaximumCallsPerCallGroup(1);
+ cfg.setMaximumCallGroups(1);
+ cfg.setIncludesCallsInRecents(true);
+ cfg
+ };
+
+ let delegate = {
+ let allocated = SableCallKitDelegate::alloc();
+ let delegate: Retained =
+ unsafe { msg_send![allocated, init] };
+ ProtocolObject::from_retained(delegate)
+ };
+
+ let provider = unsafe {
+ let allocated = CXProvider::alloc();
+ CXProvider::initWithConfiguration(allocated, &config)
+ };
+ unsafe { provider.setDelegate_queue(Some(&delegate), None) };
+
+ let controller = unsafe {
+ let allocated = CXCallController::alloc();
+ CXCallController::init(allocated)
+ };
+
+ s.provider = Some(provider);
+ s.controller = Some(controller);
+ Ok(())
+ })
+ }
+
+ pub fn start_call() -> Result<(), String> {
+ ensure_provider()?;
+
+ CALLKIT.with(|s| -> Result<(), String> {
+ let mut s = s.borrow_mut();
+
+ if s.uuid.is_none() {
+ s.uuid = Some(NSUUID::from_bytes(random_uuid_bytes()));
+ }
+ let uuid = s.uuid.as_ref().unwrap().clone();
+ let controller = s.controller.as_ref().unwrap().clone();
+
+ let handle = unsafe {
+ let allocated = CXHandle::alloc();
+ CXHandle::initWithType_value(
+ allocated,
+ CXHandleType::Generic,
+ &NSString::from_str("Sable"),
+ )
+ };
+ let action = unsafe {
+ let allocated = CXStartCallAction::alloc();
+ CXStartCallAction::initWithCallUUID_handle(allocated, &uuid, &handle)
+ };
+ let transaction = unsafe {
+ let allocated = CXTransaction::alloc();
+ CXTransaction::initWithAction(allocated, &action)
+ };
+
+ let completion: RcBlock =
+ RcBlock::new(move |_error: *mut objc2_foundation::NSError| {});
+ unsafe {
+ controller.requestTransactionWithAction_completion(&action, &completion);
+ }
+ Ok(())
+ })
+ }
+
+ pub fn end_call() -> Result<(), String> {
+ CALLKIT.with(|s| -> Result<(), String> {
+ let mut s = s.borrow_mut();
+ let uuid = match s.uuid.take() {
+ Some(uuid) => uuid,
+ None => return Ok(()),
+ };
+ let controller = match s.controller.as_ref() {
+ Some(c) => c.clone(),
+ None => return Ok(()),
+ };
+
+ let action = unsafe {
+ let allocated = CXEndCallAction::alloc();
+ CXEndCallAction::initWithCallUUID(allocated, &uuid)
+ };
+ let completion: RcBlock =
+ RcBlock::new(move |_error: *mut objc2_foundation::NSError| {});
+ unsafe {
+ controller.requestTransactionWithAction_completion(&action, &completion);
+ }
+ Ok(())
+ })
+ }
+
+ fn random_uuid_bytes() -> [u8; 16] {
+ use std::time::{SystemTime, UNIX_EPOCH};
+ let mut bytes = [0u8; 16];
+ let nanos = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_nanos())
+ .unwrap_or(0);
+ let n = nanos.to_le_bytes();
+ for i in 0..16 {
+ bytes[i] = n[i % 16].wrapping_add(i as u8);
+ }
+ bytes[6] = (bytes[6] & 0x0F) | 0x40; // RFC 4122 v4
+ bytes[8] = (bytes[8] & 0x3F) | 0x80;
+ bytes
+ }
}
pub fn hide_form_accessory_bar(window: &WebviewWindow) {
@@ -113,7 +295,7 @@ pub fn hide_form_accessory_bar(window: &WebviewWindow) {
// WKWebView plays HTMLAudioElement on .playback, ignoring the silent switch.
// AudioServicesPlaySystemSound respects the switch and uses ringer volume.
-use objc2_foundation::{NSString, NSURL};
+use objc2_foundation::NSURL;
// This library is linked by Cargo before Xcode creates the application bundle,
// so declaring AudioToolbox only in tauri.conf.json is not sufficient.
diff --git a/src/app/generated/tauri/commands.ts b/src/app/generated/tauri/commands.ts
index f402febf8e..1dd132bbba 100644
--- a/src/app/generated/tauri/commands.ts
+++ b/src/app/generated/tauri/commands.ts
@@ -1,7 +1,7 @@
/**
* Auto-generated TypeScript bindings for Tauri commands
* Generated by tauri-typegen v0.5.0
- * Generated at: 2026-07-29T09:24:56.411984+00:00
+ * Generated at: 2026-07-31T11:14:58.599659+00:00
* Generator: none
*
* Do not edit manually - regenerate using: cargo tauri-typegen generate
@@ -22,6 +22,10 @@ export async function activateCallAudioSession(): Promise {
return invoke('activate_call_audio_session');
}
+export async function buildDiagnosticsArchive(params: types.BuildDiagnosticsArchiveParams): Promise {
+ return invoke('build_diagnostics_archive', params);
+}
+
export async function clearMediaSession(): Promise {
return invoke('clear_media_session');
}
@@ -66,6 +70,10 @@ export async function saveDownload(params: types.SaveDownloadParams): Promise {
+ return invoke('save_media_to_photos', params);
+}
+
export async function setMediaEncryption(params: types.SetMediaEncryptionParams): Promise {
return invoke('set_media_encryption', params);
}
diff --git a/src/app/generated/tauri/events.ts b/src/app/generated/tauri/events.ts
index 5d4703b024..0260d39ed0 100644
--- a/src/app/generated/tauri/events.ts
+++ b/src/app/generated/tauri/events.ts
@@ -1,7 +1,7 @@
/**
* Auto-generated TypeScript bindings for Tauri commands
* Generated by tauri-typegen v0.5.0
- * Generated at: 2026-07-29T09:24:56.415138+00:00
+ * Generated at: 2026-07-31T11:14:58.602356+00:00
* Generator: none
*
* Do not edit manually - regenerate using: cargo tauri-typegen generate
diff --git a/src/app/generated/tauri/index.ts b/src/app/generated/tauri/index.ts
index c15cf28d9e..727bb5d08e 100644
--- a/src/app/generated/tauri/index.ts
+++ b/src/app/generated/tauri/index.ts
@@ -1,7 +1,7 @@
/**
* Auto-generated TypeScript bindings for Tauri commands
* Generated by tauri-typegen v0.5.0
- * Generated at: 2026-07-29T09:24:56.415651+00:00
+ * Generated at: 2026-07-31T11:14:58.603222+00:00
* Generator: none
*
* Do not edit manually - regenerate using: cargo tauri-typegen generate
diff --git a/src/app/generated/tauri/types.ts b/src/app/generated/tauri/types.ts
index 83aef83d84..5c41032aff 100644
--- a/src/app/generated/tauri/types.ts
+++ b/src/app/generated/tauri/types.ts
@@ -1,7 +1,7 @@
/**
* Auto-generated TypeScript bindings for Tauri commands
* Generated by tauri-typegen v0.5.0
- * Generated at: 2026-07-29T09:24:56.409726+00:00
+ * Generated at: 2026-07-31T11:14:58.596290+00:00
* Generator: none
*
* Do not edit manually - regenerate using: cargo tauri-typegen generate
@@ -73,6 +73,11 @@ export interface AbortNativeUploadParams {
[key: string]: unknown;
}
+export interface BuildDiagnosticsArchiveParams {
+ frontendLogs?: string | null;
+ [key: string]: unknown;
+}
+
export interface ExportDiagnosticsParams {
frontendLogs?: string | null;
[key: string]: unknown;
@@ -108,6 +113,13 @@ export interface SaveDownloadParams {
[key: string]: unknown;
}
+export interface SaveMediaToPhotosParams {
+ bytes: number[];
+ filename: string;
+ mimeType: string;
+ [key: string]: unknown;
+}
+
export interface SetMediaEncryptionParams {
url: string;
key: string;
@@ -171,3 +183,4 @@ export interface UploadWriteChunkParams {
chunk: string;
[key: string]: unknown;
}
+