expo-modules-macros 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/.github/workflows/publish.yml +93 -7
  2. package/.github/workflows/swift.yml +2 -2
  3. package/.github/workflows/windows.yml +144 -0
  4. package/AGENTS.md +13 -7
  5. package/README.md +15 -3
  6. package/apple/ExpoModulesMacros +0 -0
  7. package/apple/Package.resolved +3 -3
  8. package/apple/Package.swift +1 -1
  9. package/apple/Sources/ExpoModulesMacros/DecorateModuleBuilder.swift +7 -7
  10. package/apple/Sources/ExpoModulesMacros/ExpoModulesOptimizedMacro.swift +0 -1
  11. package/apple/Sources/ExpoModulesMacros/FreeFormTypes.swift +10 -7
  12. package/apple/Sources/ExpoModulesMacros/JSConstructor.swift +1 -1
  13. package/apple/Sources/ExpoModulesMacros/JSMacro.swift +4 -5
  14. package/apple/Sources/ExpoModulesMacros/Plugin.swift +8 -1
  15. package/apple/Sources/ExpoModulesMacros/RecordMacro.swift +1 -1
  16. package/apple/Sources/ExpoModulesMacros/ViewPropsMacro.swift +14 -3
  17. package/apple/Sources/ExpoModulesScanner/CLI.swift +8 -13
  18. package/apple/Sources/ExpoModulesScanner/Core/Detection.swift +0 -2
  19. package/apple/Sources/ExpoModulesScanner/Core/ScanBuildConfiguration.swift +5 -0
  20. package/apple/Sources/ExpoModulesScanner/Core/SourceScan.swift +110 -29
  21. package/apple/Sources/ExpoModulesScanner/Core/StandardStreams.swift +45 -0
  22. package/apple/Sources/ExpoModulesScanner/Exports/ExportedSurface.swift +0 -2
  23. package/apple/Sources/ExpoModulesScanner/Exports/ResolveRefs.swift +0 -2
  24. package/apple/Sources/ExpoModulesScanner/Exports/ScanExports.swift +6 -3
  25. package/apple/Sources/ExpoModulesScanner/Modules/ScanModules.swift +6 -3
  26. package/build/index.d.ts +11 -2
  27. package/build/index.js +40 -4
  28. package/package.json +6 -2
  29. package/apple/build.js +0 -138
@@ -1,4 +1,3 @@
1
- import Foundation
2
1
  import SwiftDiagnostics
3
2
  import SwiftSyntax
4
3
  import SwiftSyntaxBuilder
@@ -248,10 +247,22 @@ private struct ViewProp {
248
247
  /// The name with any escaping backticks removed: the JS-visible key, so the enum's raw value and
249
248
  /// `_eventNames` both read `"default"`, never `` "`default`" ``.
250
249
  var wireName: String {
251
- return name.trimmingCharacters(in: CharacterSet(charactersIn: "`"))
250
+ return withoutBackticks(name)
252
251
  }
253
252
  }
254
253
 
254
+ /// `name` without the backticks that escape it: `` `default` `` becomes `default`.
255
+ private func withoutBackticks(_ name: String) -> String {
256
+ var name = Substring(name)
257
+ while name.first == "`" {
258
+ name.removeFirst()
259
+ }
260
+ while name.last == "`" {
261
+ name.removeLast()
262
+ }
263
+ return String(name)
264
+ }
265
+
255
266
  private struct ViewPropsModel {
256
267
  /// Value props, in declaration order. The order is the bit order, so it is part of the ABI
257
268
  /// between a compiled view and the runtime that fills the mask.
@@ -344,7 +355,7 @@ private func validatedViewProps(of declaration: some DeclGroupSyntax) throws ->
344
355
  // `PropSet` stores its mask in `rawValue`, so a value prop of that name would emit a static
345
356
  // member shadowing it and the option set would not compile ("circular reference"). The error
346
357
  // would point at generated code, so catch it here and name the property.
347
- if !isEvent && name.trimmingCharacters(in: CharacterSet(charactersIn: "`")) == "rawValue" {
358
+ if !isEvent && withoutBackticks(name) == "rawValue" {
348
359
  throw MacroExpansionErrorMessage(
349
360
  "'rawValue' can't be used as a prop name — it collides with the synthesized PropSet's storage. Rename the property"
350
361
  )
@@ -1,5 +1,3 @@
1
- import Foundation
2
-
3
1
  /// Command-line front end for the scanner: parses the subcommand and paths, then delegates to the
4
2
  /// matching `Scanner` entry (which runs the scan and writes its JSON output). Lives in the library
5
3
  /// so the macro plugin executable can dispatch into it: the compiler always launches that executable
@@ -16,12 +14,12 @@ public enum ScannerCLI {
16
14
  public static func run(arguments: [String]) -> Int32 {
17
15
  // `-h`/`--help` anywhere is treated as a help request: print usage to stdout and exit 0.
18
16
  if arguments.contains(where: { $0 == "-h" || $0 == "--help" }) {
19
- printUsage(to: .standardOutput)
17
+ writeToStandardOutput(usageText)
20
18
  return 0
21
19
  }
22
20
 
23
21
  guard let subcommand = arguments.first else {
24
- printUsage()
22
+ writeToStandardError(usageText)
25
23
  return 2
26
24
  }
27
25
 
@@ -79,7 +77,11 @@ public enum ScannerCLI {
79
77
  /// The invoked executable's basename, so the usage text matches however the tool was launched
80
78
  /// (the `ExpoModulesMacros` shipped in the package, or a locally built `ExpoModulesMacros-tool`).
81
79
  private var toolName: String {
82
- return (CommandLine.arguments.first as NSString?)?.lastPathComponent ?? "ExpoModulesScanner"
80
+ guard let path = CommandLine.arguments.first else {
81
+ return "ExpoModulesScanner"
82
+ }
83
+ // Windows paths may use either separator.
84
+ return String(path.split(whereSeparator: { $0 == "/" || $0 == "\\" }).last ?? Substring(path))
83
85
  }
84
86
 
85
87
  private var usageText: String {
@@ -100,15 +102,8 @@ private var usageText: String {
100
102
  """
101
103
  }
102
104
 
103
- /// Prints the usage text to the given handle. Goes to stdout when help was explicitly requested
104
- /// (a successful action), stderr when it accompanies a usage error.
105
- private func printUsage(to handle: FileHandle = .standardError) {
106
- handle.write(Data(usageText.utf8))
107
- }
108
-
109
105
  /// Reports a usage error on stderr, followed by the usage text, and returns the usage exit code.
110
106
  private func usageError(_ message: String) -> Int32 {
111
- FileHandle.standardError.write(Data("error: \(message)\n".utf8))
112
- printUsage()
107
+ writeToStandardError("error: \(message)\n" + usageText)
113
108
  return 2
114
109
  }
@@ -1,5 +1,3 @@
1
- import Foundation
2
-
3
1
  /// Which Expo macro was found on a declaration. The scanner recognizes the entry-point macros that
4
2
  /// mark a type or member as part of a module's JS surface, plus `@Record` for convertible types.
5
3
  enum DetectedMacro: String, Codable, CaseIterable {
@@ -23,6 +23,11 @@ struct ScanBuildConfiguration: BuildConfiguration {
23
23
  return name.lowercased() == platform.lowercased()
24
24
  }
25
25
 
26
+ /// The scan evaluates only Apple OSes, which all produce Mach-O, as `objectFormat(...)` spells it.
27
+ func isActiveTargetObjectFormat(name: String) throws -> Bool {
28
+ return name == "MachO"
29
+ }
30
+
26
31
  // MARK: - Unanswerable conditions
27
32
 
28
33
  // These vary within a single platform's build (device vs simulator, arm64 vs x86_64) or depend
@@ -1,4 +1,8 @@
1
+ #if canImport(FoundationEssentials)
2
+ import FoundationEssentials
3
+ #else
1
4
  import Foundation
5
+ #endif
2
6
  import SwiftParser
3
7
  import SwiftSyntax
4
8
 
@@ -21,12 +25,12 @@ func scanFiles(
21
25
  var filesScanned = 0
22
26
  var filesParsed = 0
23
27
 
24
- // Compile the pre-filter regex once per run, not once per file.
25
- let prefilter = macroAttributeRegex(for: macros, conformances: conformances)
28
+ // Build the pre-filter once per run, not once per file.
29
+ let prefilter = MacroPrefilter(macros: macros, conformances: conformances)
26
30
 
27
31
  for file in swiftFiles(in: paths) {
28
32
  guard let source = try? String(contentsOfFile: file, encoding: .utf8) else {
29
- FileHandle.standardError.write(Data("warning: could not read \(file)\n".utf8))
33
+ writeToStandardError("warning: could not read \(file)\n")
30
34
  continue
31
35
  }
32
36
  filesScanned += 1
@@ -81,27 +85,57 @@ func detect(
81
85
 
82
86
  // MARK: - Pre-filter
83
87
 
84
- /// Builds the pre-filter regex for a macro set, e.g. `@(ExpoModule)` for a `modules` scan or
85
- /// `@(ExpoModule|JS|Record|SharedObject)` for an `exports` scan. A precompiled `NSRegularExpression`
86
- /// benchmarked ~20x faster over a large source tree than calling `String.contains` once per macro
87
- /// name, because it scans each file in a single pass. Compiled once per run and reused per file.
88
+ /// The spelled forms whose presence makes a file worth parsing: each macro's attribute right after an
89
+ /// `@` (`@ExpoModule`, `@JS`, …), plus bare names for the types recognized by a conformance rather than
90
+ /// an attribute (`Enumerable` for `scan-exports`). A bare name matches more loosely than an
91
+ /// `@`-prefixed one, which costs a wasted parse and never a miss.
88
92
  ///
89
- /// `conformances` adds bare (unprefixed) alternatives for types recognized by conformance rather than
90
- /// by an attribute (`Enumerable` for `scan-exports`). They join the same alternation so the scan stays
91
- /// one pass; a bare name matches more loosely than an `@`-prefixed one, which costs a wasted parse and
92
- /// never a miss.
93
- func macroAttributeRegex(
94
- for macros: Set<DetectedMacro>,
95
- conformances: Set<String> = []
96
- ) -> NSRegularExpression {
97
- // Sort for a stable pattern regardless of the set's iteration order.
98
- let attributes = macros.map(\.rawValue).sorted().joined(separator: "|")
99
- var alternatives: [String] = []
100
- if !attributes.isEmpty {
101
- alternatives.append("@(\(attributes))")
93
+ /// Matching is a single pass over the UTF-8 bytes, checking the names only where an `@` or a bare
94
+ /// name's first byte occurs, rather than one search per name. It runs on every file a whole-tree scan
95
+ /// reads, far more than it parses, so it has to stay cheap. Built once per run and reused per file.
96
+ struct MacroPrefilter {
97
+ /// The attribute names matched right after an `@`, as UTF-8.
98
+ private let attributes: [[UInt8]]
99
+
100
+ /// The names matched anywhere, as UTF-8.
101
+ private let bareNames: [[UInt8]]
102
+
103
+ init(macros: Set<DetectedMacro>, conformances: Set<String> = []) {
104
+ attributes = macros.map { Array($0.rawValue.utf8) }
105
+ bareNames = conformances.map { Array($0.utf8) }
106
+ }
107
+
108
+ /// True if `source` contains one of the attributes after an `@`, or one of the bare names.
109
+ func matches(_ source: String) -> Bool {
110
+ var source = source
111
+ return source.withUTF8 { bytes in
112
+ for index in bytes.indices {
113
+ let byte = bytes[index]
114
+ if byte == UInt8(ascii: "@") {
115
+ for name in attributes where bytes.hasBytes(name, at: index + 1) {
116
+ return true
117
+ }
118
+ }
119
+ for name in bareNames where name.first == byte && bytes.hasBytes(name, at: index) {
120
+ return true
121
+ }
122
+ }
123
+ return false
124
+ }
125
+ }
126
+ }
127
+
128
+ extension UnsafeBufferPointer<UInt8> {
129
+ /// True if the bytes starting at `index` begin with `prefix`.
130
+ fileprivate func hasBytes(_ prefix: [UInt8], at index: Int) -> Bool {
131
+ guard index + prefix.count <= count else {
132
+ return false
133
+ }
134
+ for offset in prefix.indices where self[index + offset] != prefix[offset] {
135
+ return false
136
+ }
137
+ return true
102
138
  }
103
- alternatives.append(contentsOf: conformances.sorted())
104
- return try! NSRegularExpression(pattern: alternatives.joined(separator: "|"))
105
139
  }
106
140
 
107
141
  /// True if the source text contains one of the pre-filter's spelled macro attributes, so it's worth
@@ -109,9 +143,8 @@ func macroAttributeRegex(
109
143
  /// in which case the file is parsed and correctly yields no detections — a wasted parse, never a
110
144
  /// missed module. It assumes the attribute is written with no space after `@` (`@ExpoModule`, not
111
145
  /// `@ ExpoModule`), which is universal in practice; the rare spaced form would be skipped.
112
- func mightContainMacro(in source: String, prefilter: NSRegularExpression) -> Bool {
113
- let range = NSRange(source.startIndex..., in: source)
114
- return prefilter.firstMatch(in: source, range: range) != nil
146
+ func mightContainMacro(in source: String, prefilter: MacroPrefilter) -> Bool {
147
+ return prefilter.matches(source)
115
148
  }
116
149
 
117
150
  // MARK: - File discovery
@@ -144,13 +177,12 @@ func swiftFiles(in paths: [String]) -> [String] {
144
177
  var result: [String] = []
145
178
 
146
179
  for path in paths {
147
- var isDirectory: ObjCBool = false
148
- guard fileManager.fileExists(atPath: path, isDirectory: &isDirectory) else {
149
- FileHandle.standardError.write(Data("warning: no such path \(path)\n".utf8))
180
+ guard let isDirectory = isDirectory(atPath: path, fileManager: fileManager) else {
181
+ writeToStandardError("warning: no such path \(path)\n")
150
182
  continue
151
183
  }
152
184
 
153
- if isDirectory.boolValue {
185
+ if isDirectory {
154
186
  result.append(contentsOf: swiftFiles(inDirectory: URL(fileURLWithPath: path), fileManager: fileManager))
155
187
  } else if path.hasSuffix(".swift") {
156
188
  // A directory walk already yields absolute paths; resolve a directly-passed file the same way
@@ -162,6 +194,54 @@ func swiftFiles(in paths: [String]) -> [String] {
162
194
  return result.sorted()
163
195
  }
164
196
 
197
+ /// Whether `path` is a directory (following symbolic links), or `nil` if nothing exists there.
198
+ private func isDirectory(atPath path: String, fileManager: FileManager) -> Bool? {
199
+ #if canImport(FoundationEssentials)
200
+ var isDirectory = false
201
+ #else
202
+ var isDirectory: ObjCBool = false
203
+ #endif
204
+ guard fileManager.fileExists(atPath: path, isDirectory: &isDirectory) else {
205
+ return nil
206
+ }
207
+ #if canImport(FoundationEssentials)
208
+ return isDirectory
209
+ #else
210
+ return isDirectory.boolValue
211
+ #endif
212
+ }
213
+
214
+ #if canImport(FoundationEssentials)
215
+ /// Recursively lists the `.swift` files under a directory, without descending into pruned directories.
216
+ /// `FoundationEssentials`, used outside Apple platforms, has no directory enumerator, so each directory
217
+ /// is listed on its own and the walk recurses into the subdirectories that aren't pruned. Like the
218
+ /// enumerator walk on Apple platforms, it skips names starting with a dot and doesn't follow symbolic
219
+ /// links to directories.
220
+ private func swiftFiles(inDirectory directory: URL, fileManager: FileManager) -> [String] {
221
+ return swiftFiles(inDirectoryAtPath: directory.path, fileManager: fileManager)
222
+ }
223
+
224
+ private func swiftFiles(inDirectoryAtPath directory: String, fileManager: FileManager) -> [String] {
225
+ guard let names = try? fileManager.contentsOfDirectory(atPath: directory) else {
226
+ return []
227
+ }
228
+
229
+ var result: [String] = []
230
+ for name in names where !name.hasPrefix(".") {
231
+ let path = directory + "/" + name
232
+ // `attributesOfItem` doesn't follow a symbolic link, so a link reads as `.typeSymbolicLink`.
233
+ let type = (try? fileManager.attributesOfItem(atPath: path))?[.type] as? FileAttributeType
234
+ if type == .typeDirectory {
235
+ if !prunedDirectoryNames.contains(name) {
236
+ result.append(contentsOf: swiftFiles(inDirectoryAtPath: path, fileManager: fileManager))
237
+ }
238
+ } else if name.hasSuffix(".swift") {
239
+ result.append(path)
240
+ }
241
+ }
242
+ return result
243
+ }
244
+ #else
165
245
  /// Recursively enumerates `.swift` files under a directory, calling `skipDescendants()` on any
166
246
  /// pruned directory so its subtree is never read. Uses the URL enumerator (rather than the
167
247
  /// path-based one) precisely because it supports skipping a subtree mid-walk.
@@ -191,3 +271,4 @@ private func swiftFiles(inDirectory directory: URL, fileManager: FileManager) ->
191
271
  }
192
272
  return result
193
273
  }
274
+ #endif
@@ -0,0 +1,45 @@
1
+ #if canImport(Darwin)
2
+ import Darwin
3
+ #elseif canImport(Glibc)
4
+ import Glibc
5
+ #elseif os(Windows)
6
+ import CRT
7
+ #endif
8
+
9
+ // Output goes straight to the file descriptors rather than through Foundation's `FileHandle`, which
10
+ // `FoundationEssentials` doesn't have. Outside Apple platforms the scanner uses only
11
+ // `FoundationEssentials`, so the binary doesn't link full Foundation and its ICU data.
12
+
13
+ /// Writes `text` to stdout as is, without adding a newline.
14
+ func writeToStandardOutput(_ text: String) {
15
+ writeAll(text, toFileDescriptor: 1)
16
+ }
17
+
18
+ /// Writes `text` to stderr as is, without adding a newline.
19
+ func writeToStandardError(_ text: String) {
20
+ writeAll(text, toFileDescriptor: 2)
21
+ }
22
+
23
+ /// Writes the UTF-8 bytes of `text` to `descriptor`, repeating the write until every byte is out. Retries
24
+ /// a write that a signal interrupted, and stops early if one fails otherwise, since there is nowhere
25
+ /// left to report that.
26
+ private func writeAll(_ text: String, toFileDescriptor descriptor: Int32) {
27
+ var text = text
28
+ text.withUTF8 { bytes in
29
+ var remaining = UnsafeRawBufferPointer(bytes)
30
+ while !remaining.isEmpty {
31
+ #if os(Windows)
32
+ let written = Int(_write(descriptor, remaining.baseAddress, CUnsignedInt(remaining.count)))
33
+ #else
34
+ let written = write(descriptor, remaining.baseAddress, remaining.count)
35
+ #endif
36
+ if written < 0 && errno == EINTR {
37
+ continue
38
+ }
39
+ guard written > 0 else {
40
+ return
41
+ }
42
+ remaining = UnsafeRawBufferPointer(rebasing: remaining[written...])
43
+ }
44
+ }
45
+ }
@@ -1,5 +1,3 @@
1
- import Foundation
2
-
3
1
  /// The deep-scan surface: the full JS-exported shape of every `@ExpoModule`, `@SharedObject`, and
4
2
  /// `@Record` type, with the per-member detail a TypeScript type generator needs. Read syntactically
5
3
  /// (like the macros): each boundary type becomes a structured `TypeNode` tree so the consumer walks a
@@ -1,5 +1,3 @@
1
- import Foundation
2
-
3
1
  /// Fills in each `.ref`'s `refKind` once the whole scan is done.
4
2
  ///
5
3
  /// A `TypeNode` is parsed from one `TypeSyntax` in isolation, so at parse time a name like `Status` is
@@ -1,4 +1,8 @@
1
+ #if canImport(FoundationEssentials)
2
+ import FoundationEssentials
3
+ #else
1
4
  import Foundation
5
+ #endif
2
6
  import SwiftParser
3
7
  import SwiftSyntax
4
8
 
@@ -12,11 +16,10 @@ extension Scanner {
12
16
  let encoder = JSONEncoder()
13
17
  encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
14
18
  let data = try encoder.encode(result)
15
- FileHandle.standardOutput.write(data)
16
- FileHandle.standardOutput.write(Data("\n".utf8))
19
+ writeToStandardOutput(String(decoding: data, as: UTF8.self) + "\n")
17
20
  return 0
18
21
  } catch {
19
- FileHandle.standardError.write(Data("error: failed to encode results: \(error)\n".utf8))
22
+ writeToStandardError("error: failed to encode results: \(error)\n")
20
23
  return 1
21
24
  }
22
25
  }
@@ -1,4 +1,8 @@
1
+ #if canImport(FoundationEssentials)
2
+ import FoundationEssentials
3
+ #else
1
4
  import Foundation
5
+ #endif
2
6
  import SwiftParser
3
7
 
4
8
  /// The scanner's public entry point. Argument parsing, subcommand dispatch, and usage text live in
@@ -20,11 +24,10 @@ public enum Scanner {
20
24
  let encoder = JSONEncoder()
21
25
  encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
22
26
  let data = try encoder.encode(result)
23
- FileHandle.standardOutput.write(data)
24
- FileHandle.standardOutput.write(Data("\n".utf8))
27
+ writeToStandardOutput(String(decoding: data, as: UTF8.self) + "\n")
25
28
  return 0
26
29
  } catch {
27
- FileHandle.standardError.write(Data("error: failed to encode results: \(error)\n".utf8))
30
+ writeToStandardError("error: failed to encode results: \(error)\n")
28
31
  return 1
29
32
  }
30
33
  }
package/build/index.d.ts CHANGED
@@ -29,8 +29,17 @@ export declare class ScannerSchemaVersionError extends Error {
29
29
  constructor(command: string, found: number, expected: number);
30
30
  }
31
31
  /**
32
- * Absolute path to the scanner binary shipped with this package. It doubles as the macro plugin
33
- * executable, so it lives next to the Swift package rather than in a `bin` directory.
32
+ * Absolute path to the scanner binary for the current platform. It doubles as the macro plugin
33
+ * executable.
34
+ *
35
+ * - macOS: the universal binary shipped in this package, next to the Swift package rather than in a
36
+ * `bin` directory.
37
+ * - Windows: `ExpoModulesMacros.exe` from the package for the current architecture (see
38
+ * `windowsBinaryPackageName`). When that package isn't installed, for example in this repository,
39
+ * it falls back to the executable that `npm run build` writes to `platforms/win32-<arch>`.
40
+ *
41
+ * The path isn't checked here; running a missing binary fails with a `ScannerError` that says how to
42
+ * get it.
34
43
  */
35
44
  export declare function getScannerBinaryPath(): string;
36
45
  /**
package/build/index.js CHANGED
@@ -51,11 +51,47 @@ class ScannerSchemaVersionError extends Error {
51
51
  }
52
52
  exports.ScannerSchemaVersionError = ScannerSchemaVersionError;
53
53
  /**
54
- * Absolute path to the scanner binary shipped with this package. It doubles as the macro plugin
55
- * executable, so it lives next to the Swift package rather than in a `bin` directory.
54
+ * The package with the Windows binary for the current architecture, for example
55
+ * `@expo/modules-macros-win32-x64`. It is an optional dependency of this package, and its `os` and
56
+ * `cpu` fields make package managers install it only on a matching Windows machine.
57
+ */
58
+ function windowsBinaryPackageName() {
59
+ return `@expo/modules-macros-win32-${process.arch}`;
60
+ }
61
+ /**
62
+ * Absolute path to the scanner binary for the current platform. It doubles as the macro plugin
63
+ * executable.
64
+ *
65
+ * - macOS: the universal binary shipped in this package, next to the Swift package rather than in a
66
+ * `bin` directory.
67
+ * - Windows: `ExpoModulesMacros.exe` from the package for the current architecture (see
68
+ * `windowsBinaryPackageName`). When that package isn't installed, for example in this repository,
69
+ * it falls back to the executable that `npm run build` writes to `platforms/win32-<arch>`.
70
+ *
71
+ * The path isn't checked here; running a missing binary fails with a `ScannerError` that says how to
72
+ * get it.
56
73
  */
57
74
  function getScannerBinaryPath() {
58
- return node_path_1.default.join(__dirname, '..', 'apple', 'ExpoModulesMacros');
75
+ if (process.platform !== 'win32') {
76
+ return node_path_1.default.join(__dirname, '..', 'apple', 'ExpoModulesMacros');
77
+ }
78
+ try {
79
+ const packageJson = require.resolve(`${windowsBinaryPackageName()}/package.json`);
80
+ return node_path_1.default.join(node_path_1.default.dirname(packageJson), 'ExpoModulesMacros.exe');
81
+ }
82
+ catch {
83
+ return node_path_1.default.join(__dirname, '..', 'platforms', `win32-${process.arch}`, 'ExpoModulesMacros.exe');
84
+ }
85
+ }
86
+ /** The message for a scanner binary that doesn't exist at `binaryPath`. */
87
+ function missingBinaryMessage(binaryPath) {
88
+ if (process.platform === 'win32') {
89
+ return (`The scanner binary is missing at ${binaryPath}. On Windows it comes from the optional package ` +
90
+ `${windowsBinaryPackageName()}, which your package manager installs only on Windows x64 and arm64. ` +
91
+ 'Reinstall dependencies without skipping optional ones (for example without `--omit=optional`), ' +
92
+ 'or run `npm run build` in this package to build it locally.');
93
+ }
94
+ return `The scanner binary is missing at ${binaryPath}. Run \`npm run build\` in this package to build it.`;
59
95
  }
60
96
  /**
61
97
  * Runs a scanner subcommand and parses its JSON report.
@@ -92,7 +128,7 @@ function runScanner(binaryPath, args) {
92
128
  // number. Only the latter is an exit status.
93
129
  const exitCode = typeof error.code === 'number' ? error.code : null;
94
130
  if (error.code === 'ENOENT') {
95
- reject(new ScannerError(`The scanner binary is missing at ${binaryPath}. Run \`npm run build\` in this package to build it.`, null, stderr));
131
+ reject(new ScannerError(missingBinaryMessage(binaryPath), null, stderr));
96
132
  return;
97
133
  }
98
134
  reject(new ScannerError(`\`${node_path_1.default.basename(binaryPath)} ${args.join(' ')}\` failed: ${stderr.trim() || error.message}`, exitCode, stderr));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-modules-macros",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Swift macro plugin for Expo modules",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
@@ -12,7 +12,7 @@
12
12
  "url": "git+https://github.com/expo/expo-modules-macros.git"
13
13
  },
14
14
  "scripts": {
15
- "build": "node apple/build.js",
15
+ "build": "node scripts/build.js",
16
16
  "build:ts": "tsc -p tsconfig.json",
17
17
  "clean": "rm -rf build",
18
18
  "typecheck": "tsc -p tsconfig.json --noEmit",
@@ -21,5 +21,9 @@
21
21
  "devDependencies": {
22
22
  "@types/node": "^20.11.0",
23
23
  "typescript": "^5.4.0"
24
+ },
25
+ "optionalDependencies": {
26
+ "@expo/modules-macros-win32-arm64": "0.17.0",
27
+ "@expo/modules-macros-win32-x64": "0.17.0"
24
28
  }
25
29
  }
package/apple/build.js DELETED
@@ -1,138 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- const childProcess = require('node:child_process');
4
- const fs = require('node:fs/promises');
5
- const os = require('node:os');
6
- const path = require('node:path');
7
- const { promisify } = require('node:util');
8
-
9
- const execFile = promisify(childProcess.execFile);
10
-
11
- /**
12
- * Runs a command and resolves when it exits successfully.
13
- */
14
- function spawnAsync(command, args, options = {}) {
15
- return new Promise((resolve, reject) => {
16
- const child = childProcess.spawn(command, args, { stdio: 'inherit', ...options });
17
- child.once('error', reject);
18
- child.once('close', (code, signal) => {
19
- if (code === 0) {
20
- resolve();
21
- } else {
22
- reject(new Error(`\`${command} ${args.join(' ')}\` exited with ${signal ?? `code ${code}`}`));
23
- }
24
- });
25
- });
26
- }
27
-
28
- /**
29
- * Builds the macro plugin for the given architecture and returns the path to the built binary.
30
- * SwiftPM always builds macro tools for the host architecture, so the x86_64 slice
31
- * is built by running the whole toolchain under Rosetta with `arch -${arch}`.
32
- */
33
- async function buildForArch(arch) {
34
- const buildArgs = ['build', '-c', 'release'];
35
- if (arch === os.machine()) {
36
- await spawnAsync('swift', buildArgs, { cwd: __dirname });
37
- } else {
38
- await spawnAsync('arch', [`-${arch}`, 'swift', ...buildArgs], { cwd: __dirname });
39
- }
40
-
41
- const binPath = path.join(__dirname, `.build/${arch}-apple-macosx/release/ExpoModulesMacros-tool`);
42
- try {
43
- await fs.access(binPath);
44
- } catch {
45
- throw new Error(`Could not find the built ExpoModulesMacros-tool at ${binPath}`);
46
- }
47
- return binPath;
48
- }
49
-
50
- /**
51
- * Checks whether the Swift toolchain actually runs for the given architecture by
52
- * verifying the host target triple it reports, e.g. `Target: x86_64-apple-macosx26.0`
53
- * under Rosetta. Exit status alone is not enough: if `arch` ever fell back to the
54
- * native architecture, the probe would pass but the build would produce a wrong slice.
55
- */
56
- async function canRunSwiftForArch(arch) {
57
- try {
58
- const { stdout } = await execFile('arch', [`-${arch}`, 'swift', '--version']);
59
- return stdout.includes(`${arch}-apple`);
60
- } catch {
61
- return false;
62
- }
63
- }
64
-
65
- /**
66
- * Checks whether the toolchain can build for the given architecture.
67
- * Building x86_64 on Apple Silicon requires Rosetta, so when it is missing,
68
- * try to install it before giving up.
69
- */
70
- async function canBuildForArch(arch) {
71
- if (arch === os.machine()) {
72
- return true;
73
- }
74
- if (await canRunSwiftForArch(arch)) {
75
- return true;
76
- }
77
- if (arch === 'x86_64' && os.machine() === 'arm64') {
78
- try {
79
- console.log('Installing Rosetta to build the x86_64 slice...');
80
- await spawnAsync('sudo', ['softwareupdate', '--install-rosetta', '--agree-to-license']);
81
- return await canRunSwiftForArch(arch);
82
- } catch {
83
- // No sudo access or the install failed - fall through to the unsupported arch warning.
84
- }
85
- }
86
- return false;
87
- }
88
-
89
- /**
90
- * Asserts that the built binary contains a slice for each of the given architectures.
91
- */
92
- async function verifyArchs(binaryPath, archs) {
93
- const { stdout } = await execFile('lipo', ['-archs', binaryPath]);
94
- const builtArchs = stdout.trim().split(/\s+/);
95
- for (const arch of archs) {
96
- if (!builtArchs.includes(arch)) {
97
- throw new Error(`The built binary at ${binaryPath} is missing the ${arch} slice`);
98
- }
99
- }
100
- }
101
-
102
- async function main() {
103
- const outputPath = path.join(__dirname, 'ExpoModulesMacros');
104
-
105
- const archs = [];
106
- for (const arch of ['arm64', 'x86_64']) {
107
- if (await canBuildForArch(arch)) {
108
- archs.push(arch);
109
- } else {
110
- console.warn(`The Swift toolchain cannot run for ${arch} - the built binary will not support ${arch} Macs.`);
111
- }
112
- }
113
- if (archs.length === 0) {
114
- throw new Error('The Swift toolchain is not available for any supported architecture');
115
- }
116
-
117
- // Builds run sequentially as SwiftPM locks the shared .build directory.
118
- const binPaths = [];
119
- for (const arch of archs) {
120
- binPaths.push(await buildForArch(arch));
121
- }
122
-
123
- await fs.rm(outputPath, { force: true });
124
- if (binPaths.length > 1) {
125
- await spawnAsync('lipo', ['-create', ...binPaths, '-output', outputPath]);
126
- } else {
127
- await fs.copyFile(binPaths[0], outputPath);
128
- }
129
-
130
- await spawnAsync('strip', [outputPath]);
131
- await verifyArchs(outputPath, archs);
132
- await spawnAsync('lipo', ['-info', outputPath]);
133
- }
134
-
135
- main().catch((error) => {
136
- console.error('Build failed:', error.message);
137
- process.exit(1);
138
- });