diff --git a/include/yr_xtals.h b/include/yr_xtals.h
index b7c9596..e3c001c 100644
--- a/include/yr_xtals.h
+++ b/include/yr_xtals.h
@@ -46,6 +46,9 @@ void viewport_set_library_progress(struct ViewportHandle *handle,
uint32_t current,
uint32_t total);
+/// shows the modal coordinator-wait overlay with a UTF-8 message; null pointer clears.
+void viewport_set_coordinating_message(struct ViewportHandle *handle, const char *msg);
+
/// pushes parallel arrays of placeholder titles and track-number tags (0 = unknown) into the sidebar.
void viewport_set_pending_titles(struct ViewportHandle *handle,
const char *const *titles,
diff --git a/ios/Info.plist b/ios/Info.plist
index 62d2ece..3fd5b81 100644
--- a/ios/Info.plist
+++ b/ios/Info.plist
@@ -32,14 +32,14 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 1.0.1
+ 1.0.2
CFBundleSupportedPlatforms
iPhoneOS
iPhoneSimulator
CFBundleVersion
- 1.0.1
+ 1.0.2
ITSAppUsesNonExemptEncryption
LSSupportsOpeningDocumentsInPlace
diff --git a/ios/src/LibraryController.swift b/ios/src/LibraryController.swift
index cec3459..fa3ed70 100644
--- a/ios/src/LibraryController.swift
+++ b/ios/src/LibraryController.swift
@@ -149,13 +149,27 @@ final class LibraryController: NSObject,
}
#endif
- /// claims the security-scoped folder URL and pushes the path to the Rust library scanner.
+ private var pendingURL: URL?
+ private var coordAttempt: Int = 0
+ private var coordSession: Int = 0
+ private var coordWatchdog: Timer?
+ private var coordCountdown: Timer?
+ private var activeCoordinator: NSFileCoordinator?
+
+ private static let coordRetryWaits = [3, 5, 10]
+ private static let coordAttemptTimeout: TimeInterval = 12.0
+ private static let coordInitialMessage = "File Coordinator has to Cache before we can Access the items. This may take a moment, please wait while loading."
+ private static let coordFailMessage = """
+ The File Coordinator might need to Cache. If you can, try files that are not downloaded from iCloud. Even with Keep Downloaded, the Cache of the URLs happens at varied intervals. Close the app, close files, open the app again, try again. It will work if you wait until the URLs have had time to Cache. There is nothing I can do to change this; it's not my bug.
+ """
+
+ /// claims the security-scoped folder URL and kicks off the coordinator-with-retry pipeline.
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
let kind = pendingKind
pendingKind = 0
print("[YrXtals] documentPicker didPick kind=\(kind) urls=\(urls)")
guard let url = urls.first else { return }
- guard let h = view?.viewportHandle else {
+ guard view?.viewportHandle != nil else {
print("[YrXtals] documentPicker: no viewportHandle, dropping pick")
return
}
@@ -169,25 +183,111 @@ final class LibraryController: NSObject,
if scoped {
scopedURL = url
}
- #if DEBUG
- let fm = FileManager.default
- var isDir: ObjCBool = false
- let exists = fm.fileExists(atPath: url.path, isDirectory: &isDir)
- let reachable = (try? url.checkResourceIsReachable()) ?? false
- let readable = fm.isReadableFile(atPath: url.path)
- print("[YrXtals.dbg] picked path exists=\(exists) isDir=\(isDir.boolValue) reachable=\(reachable) readable=\(readable)")
- if let entries = try? fm.contentsOfDirectory(atPath: url.path) {
- print("[YrXtals.dbg] picked dir entries=\(entries.count) sample=\(entries.prefix(5))")
- } else {
- print("[YrXtals.dbg] picked dir contentsOfDirectory failed")
- }
- #endif
- url.path.withCString { cstr in
- viewport_apply_picked_folder(h, cstr)
+ pendingURL = url
+ coordAttempt = 0
+ setCoordinatingMessage(Self.coordInitialMessage)
+ startCoordinationAttempt()
+ }
+
+ /// pushes a coordinator-status string into the Rust overlay, or clears it on nil.
+ private func setCoordinatingMessage(_ msg: String?) {
+ DispatchQueue.main.async { [weak self] in
+ guard let h = self?.view?.viewportHandle else { return }
+ if let m = msg {
+ m.withCString { cstr in
+ viewport_set_coordinating_message(h, cstr)
+ }
+ } else {
+ viewport_set_coordinating_message(h, nil)
+ }
}
}
+ /// runs one NSFileCoordinator attempt with a watchdog timeout that escalates to a retry on timeout.
+ private func startCoordinationAttempt() {
+ guard let url = pendingURL else { return }
+ coordSession += 1
+ let mySession = coordSession
+
+ if coordAttempt > 0 {
+ setCoordinatingMessage("(\(coordAttempt)/3) Retry — Loading...")
+ }
+
+ coordWatchdog?.invalidate()
+ coordWatchdog = Timer.scheduledTimer(withTimeInterval: Self.coordAttemptTimeout, repeats: false) { [weak self] _ in
+ self?.handleAttemptTimeout(session: mySession)
+ }
+
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
+ let coordinator = NSFileCoordinator()
+ self?.activeCoordinator = coordinator
+ var err: NSError?
+ coordinator.coordinate(readingItemAt: url, options: [], error: &err) { coordinatedURL in
+ DispatchQueue.main.async {
+ guard let self = self else { return }
+ guard self.coordSession == mySession else { return }
+ self.coordWatchdog?.invalidate()
+ self.coordWatchdog = nil
+ self.activeCoordinator = nil
+ self.setCoordinatingMessage(nil)
+ self.pendingURL = nil
+ self.coordAttempt = 0
+ guard let h = self.view?.viewportHandle else { return }
+ coordinatedURL.path.withCString { cstr in
+ viewport_apply_picked_folder(h, cstr)
+ }
+ }
+ }
+ if let e = err {
+ print("[YrXtals] NSFileCoordinator error: \(e.localizedDescription)")
+ }
+ }
+ }
+
+ /// fires when the watchdog elapses without a coordinator success; cancels the in-flight call and starts the retry countdown.
+ private func handleAttemptTimeout(session: Int) {
+ guard session == coordSession else { return }
+ activeCoordinator?.cancel()
+ activeCoordinator = nil
+ coordAttempt += 1
+ if coordAttempt > Self.coordRetryWaits.count {
+ setCoordinatingMessage(nil)
+ pendingURL = nil
+ coordAttempt = 0
+ showCoordFailureAlert()
+ return
+ }
+ let waitSeconds = Self.coordRetryWaits[coordAttempt - 1]
+ startCountdown(seconds: waitSeconds)
+ }
+
+ /// runs the visible per-second countdown banner before the next coordinator attempt.
+ private func startCountdown(seconds: Int) {
+ var remaining = seconds
+ setCoordinatingMessage("(\(coordAttempt)/3) Retry in \(remaining)s...")
+ coordCountdown?.invalidate()
+ coordCountdown = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] timer in
+ guard let self = self else { timer.invalidate(); return }
+ remaining -= 1
+ if remaining <= 0 {
+ timer.invalidate()
+ self.coordCountdown = nil
+ self.startCoordinationAttempt()
+ } else {
+ self.setCoordinatingMessage("(\(self.coordAttempt)/3) Retry in \(remaining)s...")
+ }
+ }
+ }
+
+ /// shows the final-failure alert when all three retries have elapsed without a successful coordinator return.
+ private func showCoordFailureAlert() {
+ guard let root = presentationHost ?? topViewController() else { return }
+ let alert = UIAlertController(title: "Loading Failed", message: Self.coordFailMessage, preferredStyle: .alert)
+ alert.addAction(UIAlertAction(title: "OK", style: .default))
+ root.present(alert, animated: true)
+ }
+
/// clears the pending picker flag after the document picker dismisses without a selection.
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
pendingKind = 0
diff --git a/ios/src/YrXtalsApp.swift b/ios/src/YrXtalsApp.swift
index 7a3ed1c..e7af680 100644
--- a/ios/src/YrXtalsApp.swift
+++ b/ios/src/YrXtalsApp.swift
@@ -1,6 +1,8 @@
import UIKit
+import UniformTypeIdentifiers
import AVFoundation
import MediaPlayer
+import FileProvider
import Darwin
/// configures audio, stderr capture, and scene routing at launch.
@@ -23,6 +25,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
MPMediaLibrary.requestAuthorization { status in
print("[YrXtals] media library authorization: \(status.rawValue)")
}
+ Self.warmDocumentPicker()
#if DEBUG
Self.dumpStartupDiagnostics()
Self.startNotificationSpy()
@@ -30,6 +33,15 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
return true
}
+ /// nudges the FileProvider XPC chain awake at launch so the first user-triggered picker presentation actually delivers events.
+ private static func warmDocumentPicker() {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
+ let warm = UIDocumentPickerViewController(forOpeningContentTypes: [.folder, .audio], asCopy: false)
+ _ = warm.view
+ }
+ NSFileProviderManager.getDomainsWithCompletionHandler { _, _ in }
+ }
+
#if DEBUG
/// dumps bundle id, sandbox container, entitlements, FileProvider availability, and reachable temp dirs at startup.
private static func dumpStartupDiagnostics() {
diff --git a/scripts/ios/release.sh b/scripts/ios/release.sh
index e7593eb..e5049a9 100755
--- a/scripts/ios/release.sh
+++ b/scripts/ios/release.sh
@@ -144,6 +144,7 @@ TEAM_ID="$(/usr/libexec/PlistBuddy -c "Print :com.apple.developer.team-identifie
case "$(/usr/libexec/PlistBuddy -c "Print :application-identifier" "$ENT")" in
*\*) /usr/libexec/PlistBuddy -c "Set :application-identifier ${TEAM_ID}.${BUNDLE_ID}" "$ENT" ;;
esac
+/usr/libexec/PlistBuddy -c "Delete :keychain-access-groups" "$ENT" 2>/dev/null || true
# locates the Apple Distribution identity in the keychain by SHA-matching the profile's certs.
TMPDIR_PROF="$(mktemp -d)"
diff --git a/src/ios.rs b/src/ios.rs
index d7f73f7..91a8a73 100644
--- a/src/ios.rs
+++ b/src/ios.rs
@@ -186,6 +186,23 @@ pub extern "C" fn viewport_set_library_progress(
h.set_library_progress(current, total);
}
+/// shows the coordinator-wait overlay with a UTF-8 message; null pointer clears.
+#[unsafe(no_mangle)]
+pub extern "C" fn viewport_set_coordinating_message(
+ handle: *mut ViewportHandle,
+ msg: *const c_char,
+) {
+ let Some(h) = (unsafe { handle.as_mut() }) else {
+ return;
+ };
+ let s = if msg.is_null() {
+ None
+ } else {
+ unsafe { CStr::from_ptr(msg) }.to_str().ok().map(|s| s.to_string())
+ };
+ h.set_coordinating_message(s);
+}
+
/// seeds the sidebar with placeholder rows of titles plus track-number tags ahead of export completion.
#[unsafe(no_mangle)]
pub extern "C" fn viewport_set_pending_titles(
diff --git a/src/ui/app.rs b/src/ui/app.rs
index d2eb33d..3874643 100644
--- a/src/ui/app.rs
+++ b/src/ui/app.rs
@@ -36,6 +36,9 @@ pub struct App {
/// running count and total of the iOS library import progress.
pub library_progress: Option<(u32, u32)>,
+
+ /// modal status message shown over the UI while waiting on iOS file-coordinator caching.
+ pub coordinating_message: Option,
}
/// every visualizer toggle, slider value, and DSP parameter the settings panel exposes.
@@ -154,6 +157,7 @@ impl App {
next_decode_id: 0,
track_loading: false,
library_progress: None,
+ coordinating_message: None,
}
}
diff --git a/src/ui/player.rs b/src/ui/player.rs
index d5a9822..f2942c6 100644
--- a/src/ui/player.rs
+++ b/src/ui/player.rs
@@ -44,10 +44,38 @@ pub fn view(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
.into()
};
- if app.show_settings {
+ let body = if app.show_settings {
stack![body, settings_overlay(app)].into()
} else {
body
+ };
+
+ if app.coordinating_message.is_some() {
+ stack![body, coordinating_overlay(app)].into()
+ } else {
+ body
+ }
+}
+
+/// dimmed full-screen card showing the file-coordinator wait message while iOS caches the picked URL.
+fn coordinating_overlay(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
+ let msg = app.coordinating_message.clone().unwrap_or_default();
+ let card = container(
+ container(text(msg).size(16).color(palette::text()))
+ .padding(24)
+ .style(panel_style)
+ .max_width(420.0),
+ )
+ .center_x(Length::Fill)
+ .center_y(Length::Fill)
+ .style(scrim_style);
+ card.into()
+}
+
+fn scrim_style(_t: &Theme) -> container::Style {
+ container::Style {
+ background: Some(Background::Color(Color { r: 0.0, g: 0.0, b: 0.0, a: 0.55 })),
+ ..Default::default()
}
}
diff --git a/src/viewport.rs b/src/viewport.rs
index cc22169..fb38778 100644
--- a/src/viewport.rs
+++ b/src/viewport.rs
@@ -292,6 +292,12 @@ impl ViewportHandle {
self.needs_redraw = true;
}
+ /// shows or clears the modal coordinator-wait overlay.
+ pub fn set_coordinating_message(&mut self, msg: Option) {
+ self.state.coordinating_message = msg;
+ self.needs_redraw = true;
+ }
+
/// pushes placeholder track rows from the iOS host ahead of export completion.
pub fn set_pending_titles(&mut self, entries: Vec<(String, Option)>) {
self.state.set_pending_titles(entries);