Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 33 additions & 18 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 13 additions & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,19 @@ 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 = ["NSFileManager", "NSString", "NSURL"] }
objc2-foundation = { version = "0.3", features = ["NSError", "NSFileManager", "NSString", "NSURL"] }
objc2-photos = { version = "0.3.2", default-features = false, features = [
"std",
"PHPhotoLibrary",
"PHChangeRequest",
"PHAssetChangeRequest",
"PHAssetCreationRequest",
"PhotosTypes",
"bitflags",
"block2",
"dispatch2",
] }
block2 = "0.6"
tauri-plugin-fs = "2"

[target.'cfg(target_os = "android")'.dependencies]
Expand Down
1 change: 1 addition & 0 deletions src-tauri/capabilities/android.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"permissions": [
"android-fs:allow-check-public-files-permission",
"android-fs:allow-create-new-public-file",
"android-fs:allow-create-new-public-image-file",
"android-fs:allow-request-public-files-permission",
"android-fs:allow-write-file",
"android-fs:allow-set-public-file-pending",
Expand Down
1 change: 1 addition & 0 deletions src-tauri/ios-project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ targets:
CFBundleShortVersionString: {{apple.bundle-version-short}}
CFBundleVersion: "{{apple.bundle-version}}"
UIBackgroundModes: [audio]
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
# init` silently loses them unless they live here.
Expand Down
136 changes: 136 additions & 0 deletions src-tauri/src/ios.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,3 +159,139 @@ pub(crate) fn play_notification_sound(kind: String) -> Result<(), String> {
unsafe { AudioServicesPlaySystemSound(sound_id) };
Ok(())
}

// PhotoKit lane for saving images to the camera roll with add-only access;
// the prompt text is NSPhotoLibraryAddUsageDescription in ios-project.yml.

// Empty block only to emit the linker directive, same role as AudioToolbox above.
#[link(name = "Photos", kind = "framework")]
extern "C" {}

use std::ffi::OsStr;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;

use block2::RcBlock;
use objc2_photos::{
PHAccessLevel, PHAssetCreationRequest, PHAssetResourceType, PHAuthorizationStatus,
PHPhotoLibrary,
};
use tokio::sync::oneshot;

fn photo_access_allowed(status: PHAuthorizationStatus) -> bool {
// Add-only access: .limited still permits adds (it only restricts reads).
status == PHAuthorizationStatus::Authorized || status == PHAuthorizationStatus::Limited
}

fn photo_authorization_error(status: PHAuthorizationStatus) -> String {
if status == PHAuthorizationStatus::Denied {
"permission denied: allow photo access in Settings > Apps > Sable > Photos".to_string()
} else if status == PHAuthorizationStatus::Restricted {
"saving to Photos is blocked by device management or parental controls".to_string()
} else {
format!("unexpected photo authorization status: {status:?}")
}
}

async fn request_photo_add_authorization() -> Result<(), String> {
let status =
unsafe { PHPhotoLibrary::authorizationStatusForAccessLevel(PHAccessLevel::AddOnly) };
if photo_access_allowed(status) {
return Ok(());
}
if status != PHAuthorizationStatus::NotDetermined {
return Err(photo_authorization_error(status));
}

let (tx, rx) = oneshot::channel::<PHAuthorizationStatus>();
{
let tx = Mutex::new(Some(tx));
let handler: RcBlock<dyn Fn(PHAuthorizationStatus)> = RcBlock::new(move |status| {
if let Ok(mut guard) = tx.lock() {
if let Some(tx) = guard.take() {
let _ = tx.send(status);
}
}
});
unsafe {
PHPhotoLibrary::requestAuthorizationForAccessLevel_handler(
PHAccessLevel::AddOnly,
&handler,
);
}
// PhotoKit copies the handler; keep the !Send RcBlock out of the await below.
}

let granted = rx
.await
.map_err(|_| "photo authorization request was cancelled".to_string())?;
if photo_access_allowed(granted) {
Ok(())
} else {
Err(photo_authorization_error(granted))
}
}

fn write_media_to_photo_library(bytes: &[u8], filename: &str) -> Result<(), String> {
// PhotoKit infers the content type from the file URL, so the temp file
// keeps the extension; `file_name` strips any directory components.
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
let leaf = std::path::Path::new(filename)
.file_name()
.unwrap_or_else(|| OsStr::new("download"));
let mut path = std::env::temp_dir();
path.push(format!(
"sable-photos-{}-{}-{}",
std::process::id(),
TEMP_COUNTER.fetch_add(1, Ordering::Relaxed),
leaf.to_string_lossy()
));

if let Err(error) = std::fs::write(&path, bytes) {
let _ = std::fs::remove_file(&path);
return Err(format!("failed to write {}: {error}", path.display()));
}

let result = unsafe {
let path_str = NSString::from_str(&path.to_string_lossy());
let url = NSURL::fileURLWithPath(&path_str);
let library = PHPhotoLibrary::sharedPhotoLibrary();
// PhotoKit rejects creation requests instantiated outside the change block.
let change: RcBlock<dyn Fn()> = RcBlock::new(move || {
let request = PHAssetCreationRequest::creationRequestForAsset();
request.addResourceWithType_fileURL_options(PHAssetResourceType::Photo, &url, None);
});
library
.performChangesAndWait_error(RcBlock::as_ptr(&change))
.map_err(|error| {
format!(
"failed to save '{filename}' to Photos ({} error {}): {}",
error.domain(),
error.code(),
error.localizedDescription()
)
})
};
let _ = std::fs::remove_file(&path);
result
}

#[tauri::command]
pub async fn save_media_to_photos(
bytes: Vec<u8>,
filename: String,
mime_type: String,
) -> Result<(), String> {
if !mime_type.starts_with("image/") {
return Err(format!(
"unsupported media type '{mime_type}': only images can be saved to Photos"
));
}

request_photo_add_authorization().await?;

// performChangesAndWait blocks until commit; it must not run on the main thread.
tauri::async_runtime::spawn_blocking(move || write_media_to_photo_library(&bytes, &filename))
.await
.map_err(|error| format!("failed to run photo save: {error}"))?
}
2 changes: 2 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,8 @@ pub fn run() {
mobile::stop_call_foreground_service,
#[cfg(target_os = "ios")]
ios::haptic_feedback,
#[cfg(target_os = "ios")]
ios::save_media_to_photos,
#[cfg(any(target_os = "android", target_os = "ios"))]
play_notification_sound,
#[cfg(desktop)]
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
},
"iOS": {
"template": "src-tauri/ios-project.yml",
"frameworks": ["AudioToolbox"]
"frameworks": ["AudioToolbox", "Photos"]
},
"linux": {
"deb": {
Expand Down
Loading
Loading