diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift index 6f2428a6a7de24adcfcadd67a2b36c14a1d5e46c..94eb066715586575d94cc54b8b992a4dfa00c33f 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift @@ -36,14 +36,18 @@ enum T3ComposerClipboard { } } +// Expo Modules 2.0: the clipboard function is a @JS member; the view stays in +// definition() until the 2.0 view API lands. +@ExpoModule("T3ComposerEditor") public class T3ComposerEditorModule: Module { + // UIPasteboard is main-thread only, so the member is main-actor isolated. + @JS + @MainActor + func writeContextClipboard(text: String, fragment: String) async { + T3ComposerClipboard.write(text: text, fragment: fragment) + } + public func definition() -> ModuleDefinition { - Name("T3ComposerEditor") - - AsyncFunction("writeContextClipboard") { (text: String, fragment: String) in - T3ComposerClipboard.write(text: text, fragment: fragment) - }.runOnQueue(.main) - View(T3ComposerEditorView.self) { Prop("controlledDocumentJson") { (view: T3ComposerEditorView, documentJson: String) in view.setControlledDocumentJson(documentJson) diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift index a7bf5337e8f125aa7ea5f59efc416db923d798db..a5813c8a0e8b651745fdfe26241fb42091c5f2e5 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -2,14 +2,15 @@ import ExpoModulesCore import Security import UIKit +// Expo Modules 2.0: plain members marked @JS are bound by the macro, while the +// views and main-queue presenters stay in definition(). Both are merged. +@ExpoModule("T3NativeControls") public final class T3NativeControlsModule: Module { private let presentationSources = T3PresentationSources() private var videoPresentation: T3NativeVideoPresentation? private var filePresentation: T3NativeFilePresentation? public func definition() -> ModuleDefinition { - Name("T3NativeControls") - AsyncFunction("presentVideo") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in try self.presentVideo( url: url, @@ -20,19 +21,11 @@ public final class T3NativeControlsModule: Module { ) }.runOnQueue(.main) - AsyncFunction("dismissVideo") { (identifier: String) in - self.dismissVideo(identifier: identifier) - }.runOnQueue(.main) - AsyncFunction("presentFile") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in try self.presentFile(url: url, title: title, sourceIdentifier: sourceIdentifier, identifier: identifier, promise: promise) }.runOnQueue(.main) - AsyncFunction("dismissFile") { (identifier: String) in - self.dismissFile(identifier: identifier) - }.runOnQueue(.main) - OnDestroy { let presentation = self.videoPresentation let file = self.filePresentation @@ -60,103 +53,109 @@ public final class T3NativeControlsModule: Module { AsyncFunction("shareFileFromSource") { (url: URL, title: String, identifier: String, promise: Promise) in try self.shareFile(url: url, title: title, sourceIdentifier: identifier, promise: promise) }.runOnQueue(.main) + } - Function("getShowcasePairingUrl") { - let arguments = ProcessInfo.processInfo.arguments - guard - let flagIndex = arguments.firstIndex(of: "--showcasePairingUrl"), - arguments.indices.contains(flagIndex + 1) - else { - return nil as String? - } - return arguments[flagIndex + 1] - } + // UIKit presenters are main-actor isolated rather than run on the JS thread + // the macro would otherwise pick. + @JS + @MainActor + func dismissVideo(identifier: String) async { + dismissVideoPresentation(identifier: identifier) + } + + @JS + @MainActor + func dismissFile(identifier: String) async { + dismissFilePresentation(identifier: identifier) + } - Function("getShowcaseScene") { () -> String? in - let scenePath = NSHomeDirectory() + "/Library/Caches/T3ShowcaseScene" - if let storedScene = try? String(contentsOfFile: scenePath, encoding: .utf8) - .trimmingCharacters(in: .whitespacesAndNewlines), !storedScene.isEmpty { - return storedScene + // Rotates the interface without Simulator menu UI scripting, which CI + // runners cannot perform (osascript is denied Accessibility access there). + @JS + @MainActor + func applyShowcaseOrientation(orientation: String) async { + let mask: UIInterfaceOrientationMask = orientation == "landscape" ? .landscapeRight : .portrait + for case let windowScene as UIWindowScene in UIApplication.shared.connectedScenes { + windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) { error in + NSLog("T3NativeControls applyShowcaseOrientation(\(orientation)) failed: \(error)") } - let arguments = ProcessInfo.processInfo.arguments - guard - let flagIndex = arguments.firstIndex(of: "--showcaseScene"), - arguments.indices.contains(flagIndex + 1) - else { - return nil as String? + for window in windowScene.windows { + window.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations() } - return arguments[flagIndex + 1] } + } - // The palette is fixed for the whole capture, so it only ever arrives as a - // launch argument — unlike the scene, which the runner rewrites in place. - Function("getShowcaseTheme") { () -> String? in - let arguments = ProcessInfo.processInfo.arguments - guard - let flagIndex = arguments.firstIndex(of: "--showcaseTheme"), - arguments.indices.contains(flagIndex + 1) - else { - return nil as String? - } - return arguments[flagIndex + 1] + // The geometry request above can fail transiently (for example before the + // scene is foreground-active), so callers poll this until it settles. + // Screen bounds — not the scene's interface orientation — decide the + // answer because they match the captured framebuffer: with iPadOS + // windowing active, a floating landscape window still reports a portrait + // screen, and screenshots would come out portrait. + @JS + @MainActor + func getInterfaceOrientation() async -> String { + guard + let windowScene = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .first + else { + return "unknown" } + let bounds = windowScene.screen.coordinateSpace.bounds + return bounds.width > bounds.height ? "landscape" : "portrait" + } - Function("getShowcaseOrientation") { () -> String? in - let arguments = ProcessInfo.processInfo.arguments - guard - let flagIndex = arguments.firstIndex(of: "--showcaseOrientation"), - arguments.indices.contains(flagIndex + 1) - else { - return nil as String? - } - return arguments[flagIndex + 1] + private func launchArgument(_ flag: String) -> String? { + let arguments = ProcessInfo.processInfo.arguments + guard + let flagIndex = arguments.firstIndex(of: flag), + arguments.indices.contains(flagIndex + 1) + else { + return nil } + return arguments[flagIndex + 1] + } - // Rotates the interface without Simulator menu UI scripting, which CI - // runners cannot perform (osascript is denied Accessibility access there). - AsyncFunction("applyShowcaseOrientation") { (orientation: String) in - guard #available(iOS 16.0, *) else { return } - let mask: UIInterfaceOrientationMask = orientation == "landscape" ? .landscapeRight : .portrait - for case let windowScene as UIWindowScene in UIApplication.shared.connectedScenes { - windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) { error in - NSLog("T3NativeControls applyShowcaseOrientation(\(orientation)) failed: \(error)") - } - for window in windowScene.windows { - window.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations() - } - } - }.runOnQueue(.main) - - // The geometry request above can fail transiently (for example before the - // scene is foreground-active), so callers poll this until it settles. - // Screen bounds — not the scene's interface orientation — decide the - // answer because they match the captured framebuffer: with iPadOS - // windowing active, a floating landscape window still reports a portrait - // screen, and screenshots would come out portrait. - AsyncFunction("getInterfaceOrientation") { () -> String in - guard - let windowScene = UIApplication.shared.connectedScenes - .compactMap({ $0 as? UIWindowScene }) - .first - else { - return "unknown" - } - let bounds = windowScene.screen.coordinateSpace.bounds - return bounds.width > bounds.height ? "landscape" : "portrait" - }.runOnQueue(.main) + @JS + func getShowcasePairingUrl() -> String? { + launchArgument("--showcasePairingUrl") + } - Function("prepareShowcaseCapture") { - for itemClass in [kSecClassGenericPassword, kSecClassInternetPassword] { - SecItemDelete([kSecClass as String: itemClass] as CFDictionary) - } + @JS + func getShowcaseScene() -> String? { + let scenePath = NSHomeDirectory() + "/Library/Caches/T3ShowcaseScene" + if let storedScene = try? String(contentsOfFile: scenePath, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines), !storedScene.isEmpty { + return storedScene } + return launchArgument("--showcaseScene") + } - Function("markShowcaseReady") { (scene: String) in - let readyPath = NSHomeDirectory() + "/Library/Caches/T3ShowcaseReadyScene" - try? scene.write(toFile: readyPath, atomically: true, encoding: .utf8) + // The palette is fixed for the whole capture, so it only ever arrives as a + // launch argument — unlike the scene, which the runner rewrites in place. + @JS + func getShowcaseTheme() -> String? { + launchArgument("--showcaseTheme") + } + + @JS + func getShowcaseOrientation() -> String? { + launchArgument("--showcaseOrientation") + } + + @JS + func prepareShowcaseCapture() { + for itemClass in [kSecClassGenericPassword, kSecClassInternetPassword] { + SecItemDelete([kSecClass as String: itemClass] as CFDictionary) } } + @JS(.concurrent) + func markShowcaseReady(scene: String) async { + let readyPath = NSHomeDirectory() + "/Library/Caches/T3ShowcaseReadyScene" + try? scene.write(toFile: readyPath, atomically: true, encoding: .utf8) + } + private func presentVideo(url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) throws { let isPlayableURL = url.isFileURL ? FileManager.default.isReadableFile(atPath: url.path) @@ -179,7 +178,7 @@ public final class T3NativeControlsModule: Module { presentation.present(from: presenter, sources: presentationSources, sourceIdentifier: sourceIdentifier) } - private func dismissVideo(identifier: String) { + private func dismissVideoPresentation(identifier: String) { if videoPresentation?.identifier == identifier { videoPresentation?.dismiss() } } @@ -197,7 +196,7 @@ public final class T3NativeControlsModule: Module { file.present(url: url, title: title, from: presenter) } - private func dismissFile(identifier: String) { + private func dismissFilePresentation(identifier: String) { if filePresentation?.identifier == identifier { filePresentation?.dismiss() } }