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; } +