authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-01 10:51:25-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-01 10:51:25-07:00
log1e9c36023c898bfcab6dce862e105441b85ec22e
tree151f7f871051b5a232759be980759ddadbc76ebd
parent9962e6986db68cd5537d23153e7898d6b3696b85
signature Signed by PGP key B5690EEEBB952194

refactor(mobile): adopt Expo Modules 2.0 for function-only native members (#12051)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

2 files changed, 102 insertions(+), 99 deletions(-)

apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift+10-6
...@@ -36,14 +36,18 @@ enum T3ComposerClipboard {...@@ -36,14 +36,18 @@ enum T3ComposerClipboard {
36 }36 }
37}37}
3838
39// Expo Modules 2.0: the clipboard function is a @JS member; the view stays in
40// definition() until the 2.0 view API lands.
41@ExpoModule("T3ComposerEditor")
39public class T3ComposerEditorModule: Module {42public class T3ComposerEditorModule: Module {
40 public func definition() -> ModuleDefinition {43 // UIPasteboard is main-thread only, so the member is main-actor isolated.
41 Name("T3ComposerEditor")44 @JS
4245 @MainActor
43 AsyncFunction("writeContextClipboard") { (text: String, fragment: String) in46 func writeContextClipboard(text: String, fragment: String) async {
44 T3ComposerClipboard.write(text: text, fragment: fragment)47 T3ComposerClipboard.write(text: text, fragment: fragment)
45 }.runOnQueue(.main)48 }
4649
50 public func definition() -> ModuleDefinition {
47 View(T3ComposerEditorView.self) {51 View(T3ComposerEditorView.self) {
48 Prop("controlledDocumentJson") { (view: T3ComposerEditorView, documentJson: String) in52 Prop("controlledDocumentJson") { (view: T3ComposerEditorView, documentJson: String) in
49 view.setControlledDocumentJson(documentJson)53 view.setControlledDocumentJson(documentJson)
apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift+92-93
...@@ -2,14 +2,15 @@ import ExpoModulesCore...@@ -2,14 +2,15 @@ import ExpoModulesCore
2import Security2import Security
3import UIKit3import UIKit
44
5// Expo Modules 2.0: plain members marked @JS are bound by the macro, while the
6// views and main-queue presenters stay in definition(). Both are merged.
7@ExpoModule("T3NativeControls")
5public final class T3NativeControlsModule: Module {8public final class T3NativeControlsModule: Module {
6 private let presentationSources = T3PresentationSources()9 private let presentationSources = T3PresentationSources()
7 private var videoPresentation: T3NativeVideoPresentation?10 private var videoPresentation: T3NativeVideoPresentation?
8 private var filePresentation: T3NativeFilePresentation?11 private var filePresentation: T3NativeFilePresentation?
912
10 public func definition() -> ModuleDefinition {13 public func definition() -> ModuleDefinition {
11 Name("T3NativeControls")
12
13 AsyncFunction("presentVideo") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in14 AsyncFunction("presentVideo") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in
14 try self.presentVideo(15 try self.presentVideo(
15 url: url,16 url: url,
...@@ -20,19 +21,11 @@ public final class T3NativeControlsModule: Module {...@@ -20,19 +21,11 @@ public final class T3NativeControlsModule: Module {
20 )21 )
21 }.runOnQueue(.main)22 }.runOnQueue(.main)
2223
23 AsyncFunction("dismissVideo") { (identifier: String) in
24 self.dismissVideo(identifier: identifier)
25 }.runOnQueue(.main)
26
27 AsyncFunction("presentFile") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in24 AsyncFunction("presentFile") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in
28 try self.presentFile(url: url, title: title, sourceIdentifier: sourceIdentifier,25 try self.presentFile(url: url, title: title, sourceIdentifier: sourceIdentifier,
29 identifier: identifier, promise: promise)26 identifier: identifier, promise: promise)
30 }.runOnQueue(.main)27 }.runOnQueue(.main)
3128
32 AsyncFunction("dismissFile") { (identifier: String) in
33 self.dismissFile(identifier: identifier)
34 }.runOnQueue(.main)
35
36 OnDestroy {29 OnDestroy {
37 let presentation = self.videoPresentation30 let presentation = self.videoPresentation
38 let file = self.filePresentation31 let file = self.filePresentation
...@@ -60,103 +53,109 @@ public final class T3NativeControlsModule: Module {...@@ -60,103 +53,109 @@ public final class T3NativeControlsModule: Module {
60 AsyncFunction("shareFileFromSource") { (url: URL, title: String, identifier: String, promise: Promise) in53 AsyncFunction("shareFileFromSource") { (url: URL, title: String, identifier: String, promise: Promise) in
61 try self.shareFile(url: url, title: title, sourceIdentifier: identifier, promise: promise)54 try self.shareFile(url: url, title: title, sourceIdentifier: identifier, promise: promise)
62 }.runOnQueue(.main)55 }.runOnQueue(.main)
56 }
6357
64 Function("getShowcasePairingUrl") {58 // UIKit presenters are main-actor isolated rather than run on the JS thread
65 let arguments = ProcessInfo.processInfo.arguments59 // the macro would otherwise pick.
66 guard60 @JS
67 let flagIndex = arguments.firstIndex(of: "--showcasePairingUrl"),61 @MainActor
68 arguments.indices.contains(flagIndex + 1)62 func dismissVideo(identifier: String) async {
69 else {63 dismissVideoPresentation(identifier: identifier)
70 return nil as String?64 }
71 }65
72 return arguments[flagIndex + 1]66 @JS
73 }67 @MainActor
68 func dismissFile(identifier: String) async {
69 dismissFilePresentation(identifier: identifier)
70 }
7471
75 Function("getShowcaseScene") { () -> String? in72 // Rotates the interface without Simulator menu UI scripting, which CI
76 let scenePath = NSHomeDirectory() + "/Library/Caches/T3ShowcaseScene"73 // runners cannot perform (osascript is denied Accessibility access there).
77 if let storedScene = try? String(contentsOfFile: scenePath, encoding: .utf8)74 @JS
78 .trimmingCharacters(in: .whitespacesAndNewlines), !storedScene.isEmpty {75 @MainActor
79 return storedScene76 func applyShowcaseOrientation(orientation: String) async {
77 let mask: UIInterfaceOrientationMask = orientation == "landscape" ? .landscapeRight : .portrait
78 for case let windowScene as UIWindowScene in UIApplication.shared.connectedScenes {
79 windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) { error in
80 NSLog("T3NativeControls applyShowcaseOrientation(\(orientation)) failed: \(error)")
80 }81 }
81 let arguments = ProcessInfo.processInfo.arguments82 for window in windowScene.windows {
82 guard83 window.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations()
83 let flagIndex = arguments.firstIndex(of: "--showcaseScene"),
84 arguments.indices.contains(flagIndex + 1)
85 else {
86 return nil as String?
87 }84 }
88 return arguments[flagIndex + 1]
89 }85 }
86 }
9087
91 // The palette is fixed for the whole capture, so it only ever arrives as a88 // The geometry request above can fail transiently (for example before the
92 // launch argument — unlike the scene, which the runner rewrites in place.89 // scene is foreground-active), so callers poll this until it settles.
93 Function("getShowcaseTheme") { () -> String? in90 // Screen bounds — not the scene's interface orientation — decide the
94 let arguments = ProcessInfo.processInfo.arguments91 // answer because they match the captured framebuffer: with iPadOS
95 guard92 // windowing active, a floating landscape window still reports a portrait
96 let flagIndex = arguments.firstIndex(of: "--showcaseTheme"),93 // screen, and screenshots would come out portrait.
97 arguments.indices.contains(flagIndex + 1)94 @JS
98 else {95 @MainActor
99 return nil as String?96 func getInterfaceOrientation() async -> String {
100 }97 guard
101 return arguments[flagIndex + 1]98 let windowScene = UIApplication.shared.connectedScenes
99 .compactMap({ $0 as? UIWindowScene })
100 .first
101 else {
102 return "unknown"
102 }103 }
104 let bounds = windowScene.screen.coordinateSpace.bounds
105 return bounds.width > bounds.height ? "landscape" : "portrait"
106 }
103107
104 Function("getShowcaseOrientation") { () -> String? in108 private func launchArgument(_ flag: String) -> String? {
105 let arguments = ProcessInfo.processInfo.arguments109 let arguments = ProcessInfo.processInfo.arguments
106 guard110 guard
107 let flagIndex = arguments.firstIndex(of: "--showcaseOrientation"),111 let flagIndex = arguments.firstIndex(of: flag),
108 arguments.indices.contains(flagIndex + 1)112 arguments.indices.contains(flagIndex + 1)
109 else {113 else {
110 return nil as String?114 return nil
111 }
112 return arguments[flagIndex + 1]
113 }115 }
116 return arguments[flagIndex + 1]
117 }
114118
115 // Rotates the interface without Simulator menu UI scripting, which CI119 @JS
116 // runners cannot perform (osascript is denied Accessibility access there).120 func getShowcasePairingUrl() -> String? {
117 AsyncFunction("applyShowcaseOrientation") { (orientation: String) in121 launchArgument("--showcasePairingUrl")
118 guard #available(iOS 16.0, *) else { return }122 }
119 let mask: UIInterfaceOrientationMask = orientation == "landscape" ? .landscapeRight : .portrait
120 for case let windowScene as UIWindowScene in UIApplication.shared.connectedScenes {
121 windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) { error in
122 NSLog("T3NativeControls applyShowcaseOrientation(\(orientation)) failed: \(error)")
123 }
124 for window in windowScene.windows {
125 window.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations()
126 }
127 }
128 }.runOnQueue(.main)
129
130 // The geometry request above can fail transiently (for example before the
131 // scene is foreground-active), so callers poll this until it settles.
132 // Screen bounds — not the scene's interface orientation — decide the
133 // answer because they match the captured framebuffer: with iPadOS
134 // windowing active, a floating landscape window still reports a portrait
135 // screen, and screenshots would come out portrait.
136 AsyncFunction("getInterfaceOrientation") { () -> String in
137 guard
138 let windowScene = UIApplication.shared.connectedScenes
139 .compactMap({ $0 as? UIWindowScene })
140 .first
141 else {
142 return "unknown"
143 }
144 let bounds = windowScene.screen.coordinateSpace.bounds
145 return bounds.width > bounds.height ? "landscape" : "portrait"
146 }.runOnQueue(.main)
147123
148 Function("prepareShowcaseCapture") {124 @JS
149 for itemClass in [kSecClassGenericPassword, kSecClassInternetPassword] {125 func getShowcaseScene() -> String? {
150 SecItemDelete([kSecClass as String: itemClass] as CFDictionary)126 let scenePath = NSHomeDirectory() + "/Library/Caches/T3ShowcaseScene"
151 }127 if let storedScene = try? String(contentsOfFile: scenePath, encoding: .utf8)
128 .trimmingCharacters(in: .whitespacesAndNewlines), !storedScene.isEmpty {
129 return storedScene
152 }130 }
131 return launchArgument("--showcaseScene")
132 }
133
134 // The palette is fixed for the whole capture, so it only ever arrives as a
135 // launch argument — unlike the scene, which the runner rewrites in place.
136 @JS
137 func getShowcaseTheme() -> String? {
138 launchArgument("--showcaseTheme")
139 }
153140
154 Function("markShowcaseReady") { (scene: String) in141 @JS
155 let readyPath = NSHomeDirectory() + "/Library/Caches/T3ShowcaseReadyScene"142 func getShowcaseOrientation() -> String? {
156 try? scene.write(toFile: readyPath, atomically: true, encoding: .utf8)143 launchArgument("--showcaseOrientation")
144 }
145
146 @JS
147 func prepareShowcaseCapture() {
148 for itemClass in [kSecClassGenericPassword, kSecClassInternetPassword] {
149 SecItemDelete([kSecClass as String: itemClass] as CFDictionary)
157 }150 }
158 }151 }
159152
153 @JS(.concurrent)
154 func markShowcaseReady(scene: String) async {
155 let readyPath = NSHomeDirectory() + "/Library/Caches/T3ShowcaseReadyScene"
156 try? scene.write(toFile: readyPath, atomically: true, encoding: .utf8)
157 }
158
160 private func presentVideo(url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) throws {159 private func presentVideo(url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) throws {
161 let isPlayableURL = url.isFileURL160 let isPlayableURL = url.isFileURL
162 ? FileManager.default.isReadableFile(atPath: url.path)161 ? FileManager.default.isReadableFile(atPath: url.path)
...@@ -179,7 +178,7 @@ public final class T3NativeControlsModule: Module {...@@ -179,7 +178,7 @@ public final class T3NativeControlsModule: Module {
179 presentation.present(from: presenter, sources: presentationSources, sourceIdentifier: sourceIdentifier)178 presentation.present(from: presenter, sources: presentationSources, sourceIdentifier: sourceIdentifier)
180 }179 }
181180
182 private func dismissVideo(identifier: String) {181 private func dismissVideoPresentation(identifier: String) {
183 if videoPresentation?.identifier == identifier { videoPresentation?.dismiss() }182 if videoPresentation?.identifier == identifier { videoPresentation?.dismiss() }
184 }183 }
185184
...@@ -197,7 +196,7 @@ public final class T3NativeControlsModule: Module {...@@ -197,7 +196,7 @@ public final class T3NativeControlsModule: Module {
197 file.present(url: url, title: title, from: presenter)196 file.present(url: url, title: title, from: presenter)
198 }197 }
199198
200 private func dismissFile(identifier: String) {199 private func dismissFilePresentation(identifier: String) {
201 if filePresentation?.identifier == identifier { filePresentation?.dismiss() }200 if filePresentation?.identifier == identifier { filePresentation?.dismiss() }
202 }201 }
203202