diff --git a/Package.resolved b/Package.resolved index 061dd0c7..309695f6 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "a67130742a321d259d3288347f30ac3f5520a64d98c183a85694b6bca44c4a64", + "originHash" : "9ec3992e7de864bad568faa552cdf4c9fff40eebb41c0c05faf964bc172df5d4", "pins" : [ { "identity" : "aexml", @@ -379,6 +379,15 @@ "version" : "1.6.4" } }, + { + "identity" : "swift-tools-protocols", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-tools-protocols", + "state" : { + "revision" : "d81024558da3bcd53748c4cfdaba10a49242442e", + "version" : "0.0.10" + } + }, { "identity" : "swiftcli", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 75b84658..85d7409a 100644 --- a/Package.swift +++ b/Package.swift @@ -23,7 +23,7 @@ let package = Package( name: "xtool", platforms: [ .iOS(.v16), - .macOS(.v13), + .macOS(.v14), ], products: [ .library( @@ -56,6 +56,7 @@ let package = Package( .package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.0.0"), .package(url: "https://github.com/swiftlang/swift-subprocess", .upToNextMinor(from: "0.4.0")), + .package(url: "https://github.com/swiftlang/swift-tools-protocols", exact: "0.0.10"), .package(url: "https://github.com/swift-server/async-http-client.git", from: "1.23.0"), .package(url: "https://github.com/swift-server/swift-openapi-async-http-client", from: "1.0.0"), @@ -159,6 +160,7 @@ let package = Package( .product(name: "NIOPosix", package: "swift-nio"), .product(name: "Version", package: "Version"), .product(name: "libunxip", package: "unxip"), + .product(name: "LanguageServerProtocolTransport", package: "swift-tools-protocols"), ], cSettings: cSettings ), diff --git a/Sources/CXKit/include/CXKit.h b/Sources/CXKit/include/CXKit.h new file mode 100644 index 00000000..30f0c7d5 --- /dev/null +++ b/Sources/CXKit/include/CXKit.h @@ -0,0 +1,4 @@ +#include "inotify_shim.h" +#include "stdout_shim.h" +#include "version.h" +#include "mobileprovision.h" diff --git a/Sources/CXKit/include/inotify_shim.h b/Sources/CXKit/include/inotify_shim.h new file mode 100644 index 00000000..6e576400 --- /dev/null +++ b/Sources/CXKit/include/inotify_shim.h @@ -0,0 +1,21 @@ +#ifndef CINOTIFY_SHIMS_H +#define CINOTIFY_SHIMS_H + +#ifdef __linux__ + +#include +#include +#include + +static inline const char *cin_event_name(const struct inotify_event *event) { + if (event->len) + return event->name; + else + return NULL; +} + +static const uint32_t cin_all_events = IN_ALL_EVENTS; + +#endif + +#endif /* CINOTIFY_SHIMS_H */ diff --git a/Sources/PackLib/BuildSettings.swift b/Sources/PackLib/BuildSettings.swift index 4ba0efc2..3d24528d 100644 --- a/Sources/PackLib/BuildSettings.swift +++ b/Sources/PackLib/BuildSettings.swift @@ -1,6 +1,7 @@ import Foundation import Subprocess import XUtils +import Superutils public struct BuildSettings: Sendable { private static let customBinDir = @@ -44,9 +45,29 @@ public struct BuildSettings: Sendable { return result.standardOutput?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" } - private static let swiftURL = Task { + private static let _swiftURL = Task { try await FilePath(xcrun(["-f", "swift"])) } + + public static func swiftURL() async throws -> FilePath { + try await _swiftURL.value + } + + private static let _swiftcURL = Task { + try await FilePath(xcrun(["-f", "swiftc"])) + } + + public static func swiftcURL() async throws -> FilePath { + try await _swiftcURL.value + } + #else + public static func swiftURL() async throws -> FilePath { + try await FilePath(ToolRegistry.locate("swift")).orThrow(StringError("Got bad path for swift executable")) + } + + public static func swiftcURL() async throws -> FilePath { + try await FilePath(ToolRegistry.locate("swiftc")).orThrow(StringError("Got bad path for swiftc executable")) + } #endif public func swiftPMInvocation( @@ -66,7 +87,7 @@ public struct BuildSettings: Sendable { // 1) invoking the real swift executable (located with `xcrun -f`) and // 2) explicitly removing SDKROOT from the env, as it may be inherited // through the `swift run pack` invocation. - executable = .path(try await Self.swiftURL.value) + executable = .path(try await Self.swiftURL()) #else executable = .name("swift") #endif @@ -78,22 +99,34 @@ public struct BuildSettings: Sendable { "--configuration", configuration.rawValue, ] - var rawEnv = ProcessInfo.processInfo.environment - rawEnv.removeValue(forKey: "SDKROOT") - let env = Dictionary(uniqueKeysWithValues: rawEnv.map { - (Environment.Key(rawValue: $0)!, $1) - }) - return Configuration( executable, arguments: .init(baseArguments + extraArguments + sdkOptions + options + arguments), - environment: .custom(env), + environment: .inherit.updating(["SDKROOT": nil]), platformOptions: .withGracefulShutDown, ) } + + public var buildServerArguments: [String] { + return [ + "package", "experimental-build-server", + "--package-path", "\(FileManager.default.currentDirectoryPath)/xtool/.xtool-tmp", + "--build-system", "swiftbuild", + "--disable-automatic-resolution", + // TODO: once https://github.com/swiftlang/swift-package-manager/pull/9819 makes it into a release + // (Swift 6.4), pass --experimental-skip-acquiring-lock + ] + sdkOptions + options + } } public enum BuildConfiguration: String, CaseIterable, Sendable { case debug case release + + var swiftBuildValue: String { + switch self { + case .debug: "Debug" + case .release: "Release" + } + } } diff --git a/Sources/PackLib/Packer.swift b/Sources/PackLib/Packer.swift index 6b7417ec..549145d0 100644 --- a/Sources/PackLib/Packer.swift +++ b/Sources/PackLib/Packer.swift @@ -3,12 +3,38 @@ import XUtils import Subprocess public struct Packer: Sendable { + public enum BuildSystem: Sendable { + case swiftPM + case swiftBuild + + public static var `default`: Self { + #if os(macOS) + return .swiftBuild + #else + return .swiftPM + #endif + } + + fileprivate var pmName: String { + switch self { + case .swiftPM: "native" + case .swiftBuild: "swiftbuild" + } + } + } + public let buildSettings: BuildSettings public let plan: Plan + public let buildSystem: BuildSystem - public init(buildSettings: BuildSettings, plan: Plan) { + public init( + buildSettings: BuildSettings, + plan: Plan, + buildSystem: BuildSystem = .default, + ) { self.plan = plan self.buildSettings = buildSettings + self.buildSystem = buildSystem } private func build() async throws { @@ -67,6 +93,7 @@ public struct Packer: Sendable { // in order to dump the plan, so we can skip resolution here to skirt // the issue. "--disable-automatic-resolution", + "--build-system", buildSystem.pmName, ] ) try await Subprocess.run( @@ -83,8 +110,16 @@ public struct Packer: Sendable { let outputURL = output.url + let binPath: String + switch buildSystem { + case .swiftPM: + binPath = "\(buildSettings.triple)/\(buildSettings.configuration.rawValue)" + case .swiftBuild: + let platformName = buildSettings.triple.contains("simulator") ? "iphonesimulator" : "iphoneos" + binPath = "out/Products/\(buildSettings.configuration.swiftBuildValue)-\(platformName)" + } let binDir = URL( - fileURLWithPath: ".build/\(buildSettings.triple)/\(buildSettings.configuration.rawValue)", + fileURLWithPath: ".build/\(binPath)", isDirectory: true ) diff --git a/Sources/XToolSupport/DevBSPCommand.swift b/Sources/XToolSupport/DevBSPCommand.swift new file mode 100644 index 00000000..8588909b --- /dev/null +++ b/Sources/XToolSupport/DevBSPCommand.swift @@ -0,0 +1,30 @@ +import ArgumentParser +import Foundation +import XKit +import PackLib +import Subprocess + +struct DevBSPCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "build-server", + abstract: "Run build server", + ) + + @Option + var triple: String? + + func run() async throws { + let settings = try await BuildSettings( + configuration: .debug, + triple: triple ?? PackOperation.defaultTriple + ) + try await Subprocess.run( + .path(BuildSettings.swiftURL()), + arguments: .init(settings.buildServerArguments), + input: .standardInput, + output: .standardOutput, + error: .standardError, + ) + .checkSuccess() + } +} diff --git a/Sources/XToolSupport/DevCommand.swift b/Sources/XToolSupport/DevCommand.swift index a8273540..8de2fe9b 100644 --- a/Sources/XToolSupport/DevCommand.swift +++ b/Sources/XToolSupport/DevCommand.swift @@ -23,10 +23,30 @@ struct PackOperation { var triple: String? var buildOptions = BuildOptions(configuration: .debug) + var extraOptions: [String] = [] + var watchMode = false var xcode = false + func buildSettings() async throws -> BuildSettings { + try await BuildSettings( + configuration: buildOptions.configuration, + triple: triple ?? Self.defaultTriple, + options: extraOptions + ( + watchMode + ? [ + "-Xlinker", "-interposable", + // https://kyleye.top/posts/debugreplaceableview-multiple-type-erasers/ + "-Xswiftc", "-enable-experimental-feature", "-Xswiftc", "OpaqueTypeErasure", + // https://www.guardsquare.com/blog/behind-swiftui-previews + "-Xswiftc", "-Xfrontend", "-Xswiftc", "-enable-private-imports", + ] + : [] + ) + ) + } + @discardableResult - func run() async throws -> URL { + func run() async throws -> (Plan, URL) { print("Planning...") let schema: PackSchema @@ -41,11 +61,7 @@ struct PackOperation { """) } - let buildSettings = try await BuildSettings( - configuration: buildOptions.configuration, - triple: triple ?? Self.defaultTriple, - options: [] - ) + let buildSettings = try await buildSettings() let planner = Planner( buildSettings: buildSettings, @@ -55,7 +71,8 @@ struct PackOperation { #if os(macOS) if xcode { - return try await XcodePacker(plan: plan).createProject() + let url = try await XcodePacker(plan: plan).createProject() + return (plan, url) } #endif @@ -68,28 +85,26 @@ struct PackOperation { let productsWithEntitlements = plan .allProducts .compactMap { p in p.entitlementsPath.map { (p, $0) } } - if !productsWithEntitlements.isEmpty { - let mapping = try await withThrowingTaskGroup(of: (URL, Entitlements).self) { group in - for (product, path) in productsWithEntitlements { - group.addTask { - let data = try await Data(reading: URL(fileURLWithPath: path)) - let decoder = PropertyListDecoder() - let entitlements = try decoder.decode(Entitlements.self, from: data) - return (product.directory(inApp: bundle), entitlements) - } + let mapping = try await withThrowingTaskGroup(of: (URL, Entitlements?).self) { group in + for (product, path) in productsWithEntitlements { + group.addTask { + let data = try await Data(reading: URL(fileURLWithPath: path)) + let decoder = PropertyListDecoder() + let entitlements = try decoder.decode(Entitlements.self, from: data) + return (product.directory(inApp: bundle), entitlements) } - return try await group.reduce(into: [:]) { $0[$1.0] = $1.1 } } - print("Applying entitlements...") - try await Signer.first().sign( - app: bundle, - identity: .adhoc, - entitlementMapping: mapping, - progress: { _ in } - ) + return try await group.reduce(into: [:]) { $0[$1.0] = $1.1 } } + print("Applying entitlements...") + try await Signer.first().sign( + app: bundle, + identity: .adhoc, + entitlementMapping: mapping, + progress: { _ in } + ) - return bundle + return (plan, bundle) } } @@ -144,7 +159,7 @@ struct DevBuildCommand: AsyncParsableCommand { signingAuthToken = nil } - let url = try await PackOperation( + let (_, url) = try await PackOperation( triple: triple, buildOptions: packOptions ).run() @@ -209,6 +224,11 @@ struct DevRunCommand: AsyncParsableCommand { help: "Target the iOS Simulator" ) var simulator = false + @Flag( + name: .shortAndLong, + help: "Hot reload files on change. Requires simulator." + ) var watch = false + var triple: String? { if simulator { #if arch(arm64) @@ -222,17 +242,36 @@ struct DevRunCommand: AsyncParsableCommand { return nil } #else + var watch: Bool { false } var triple: String? { nil } #endif @OptionGroup var connectionOptions: ConnectionOptions + func validate() throws { + #if os(macOS) + if watch && !simulator { + throw ValidationError("--watch requires --simulator") + } + #endif + } + func run() async throws { - let output = try await PackOperation(triple: triple, buildOptions: packOptions).run() + let operation = PackOperation( + triple: triple, + buildOptions: packOptions, + watchMode: watch, + ) + let (plan, output) = try await operation.run() #if os(macOS) if simulator { - try await SimInstallOperation(path: output).run() + try await SimInstallOperation( + operation: operation, + plan: plan, + path: output, + watch: watch, + ).run() return } #endif @@ -274,30 +313,10 @@ struct DevCommand: AsyncParsableCommand { DevXcodeCommand.self, DevBuildCommand.self, DevRunCommand.self, + DevBSPCommand.self, ], defaultSubcommand: DevRunCommand.self ) } extension BuildConfiguration: ExpressibleByArgument {} - -#if os(macOS) -import Subprocess - -struct SimInstallOperation { - var path: URL - - // TODO: allow customizing this - var simulator = "booted" - - func run() async throws { - try await Subprocess.run( - .path("/usr/bin/xcrun"), - arguments: ["simctl", "install", simulator, path.path], - output: .discarded - ) - .checkSuccess() - print("Installed to simulator") - } -} -#endif diff --git a/Sources/XToolSupport/FileSystemMonitor.swift b/Sources/XToolSupport/FileSystemMonitor.swift new file mode 100644 index 00000000..12eca627 --- /dev/null +++ b/Sources/XToolSupport/FileSystemMonitor.swift @@ -0,0 +1,1039 @@ +// swiftlint:disable all + +import Dependencies +import XUtils + +struct FileSystemMonitor: Sendable { + var watch: @Sendable (_ directory: FilePath) async throws -> FileSystemEvents +} + +struct FileSystemChangeEvent: Sendable, Hashable { + let file: FilePath +} + +extension FileSystemMonitor: TestDependencyKey { + static let testValue = FileSystemMonitor( + watch: unimplemented(), + ) +} + +extension DependencyValues { + var fileSystemMonitor: FileSystemMonitor { + get { self[FileSystemMonitor.self] } + set { self[FileSystemMonitor.self] = newValue } + } +} + +struct FileSystemEvents: AsyncSequence, Sendable { + private let makeIterator: @Sendable () -> AsyncStream.AsyncIterator + + init( + makeAsyncIterator: @Sendable @escaping () -> AsyncStream.AsyncIterator + ) { + self.makeIterator = makeAsyncIterator + } + + func makeAsyncIterator() -> AsyncStream.AsyncIterator { + makeIterator() + } +} + +#if os(macOS) + +// https://github.com/jgvanwyk/SwiftFileSystemEvents + +import Foundation +import CoreServices.FSEvents + +extension FileSystemMonitor: DependencyKey { + static let liveValue = FileSystemMonitor { directory in + guard let url = URL(filePath: directory) else { + throw Console.Error("Could not start FS monitor: bad file path: \(directory)") + } + let (events, cont) = AsyncStream.makeStream() + let stream = FileSystemEventStream( + directoriesToWatch: [url], + flags: .fileEvents, + handler: { + guard let file = FilePath($0.url) else { return } + cont.yield(.init(file: file)) + } + ) + let queue = DispatchQueue(label: "fsevents-queue") + stream.setDispatchQueue(queue) + try stream.start() + cont.onTermination = { _ in + stream.invalidate() + } + let onDeinit = OnDeinit { cont.finish() } + return FileSystemEvents { + _ = onDeinit + return events.makeAsyncIterator() + } + } +} + +private final class OnDeinit: Sendable { + let perform: @Sendable () -> Void + init(perform: @Sendable @escaping () -> Void) { + self.perform = perform + } + deinit { perform() } +} + +// MARK: FileSystemEventStream + +/// Register for a stream of notifications of file system events in a list of directories. +final class FileSystemEventStream: @unchecked Sendable { + + private var streamRef: FSEventStreamRef! // Will be non-nil after initialisation completes. + private let handler: (FileSystemEvent) -> Void + + /// Creates a new file system event stream with the given parameters. + /// + /// This calls `FSEventStreamCreate(_:_:_:_:_:_:_:)`. + /// + /// - Parameters: + /// - directoriesToWatch: An array of URLs representing the directories you wish to + /// monitor. + /// - sinceWhen: The service will supply events that have happened after the given + /// event ID. To ask for events since now pass ``FileSystemEvent/ID-swift.struct/now``. + /// Defaults to ``FileSystemEvent/ID-swift.struct/now``. + /// - latency: The number of seconds the service should wait after hearing about an + /// event from the kernel before passing it to the handler. Specifying a larger + /// value may result in more effective temporal coalescing, resulting in fewer + /// callbacks and greater overall efficiency. Defaults to 0. + /// - flags: Flags that modify the behaviour of the stream being created. See + /// ``FileSystemEventStream/Flags``. Defaults to `[]`. + /// - handler: A block that will be called on each event that occurs in the + /// directories being monitored. + @available(macOS 10.5, *) + init(directoriesToWatch: [URL], + sinceWhen: FileSystemEvent.ID = .now, + latency: TimeInterval = 0, + flags: Flags = [], + handler: @escaping (FileSystemEvent) -> Void) { + self.handler = handler + let pathsToWatch: CFArray + if #available(macOS 13.0, *) { + pathsToWatch = directoriesToWatch.map { $0.path(percentEncoded: false) } as CFArray + } else { + pathsToWatch = directoriesToWatch.map { $0.path } as CFArray + } + // We pass an unmanaged pointer to `self` as context info to the stream. + // `FileSystemEventStream.callback` uses this to call `handler` with each event. + // As the memory for `self` is managed by Swift, we pass `nil` for both `retain` + // and `release`. + var context = FSEventStreamContext(version: 0, + info: Unmanaged.passUnretained(self).toOpaque(), + retain: nil, + release: nil, + copyDescription: nil) + // While the return value of `FSEventStreamCreate` is imported in Swift as + // `FSEventStreamRef?`, the documentation for `FSEventStreamCreate` asserts that + // its return value will always be a valid `FSEventStreamRef`, so we unwrap the + // return value here. + self.streamRef = FSEventStreamCreate(kCFAllocatorDefault, + Self.callback, + &context, + pathsToWatch, + sinceWhen.rawValue, + latency, + flags.rawValue)! + } + + deinit { + FSEventStreamRelease(streamRef) + } + + private static let callback: FSEventStreamCallback = { _, info, numEvents, eventPaths, eventFlags, eventIDs in + guard let info = info else { return } + let eventPaths = eventPaths.assumingMemoryBound(to: UnsafeMutablePointer.self) + let stream = Unmanaged.fromOpaque(info).takeUnretainedValue() + for index in 0.. FileSystemEvent.ID { + FileSystemEvent.ID(rawValue: FSEventStreamFlushAsync(streamRef)) + } + + /// Asks the File System Events service to flush out any events that have occurred + /// but have not yet been delivered. + /// + /// Events may be delayed due to the latency parameter that was supplied when the stream + /// was created. This flushing occurs synchronously -- by the time this call returns, + /// your handler will have been invoked for every event that had already/ occurred at + /// the time you made this call. + /// + /// This may only be called after you have started the stream with ``start()``. + /// + /// This calls `FSEventStreamFlushSync(_:)`. + @available(macOS 10.5, *) + func flushSync() { + FSEventStreamFlushSync(streamRef) + } + + /// Unregisters with the File System Events service. + /// + /// Your handler will not be called for this stream while it is stopped. This can only + /// be called if the stream has been started via ``FileSystemEventStream/start()``. + /// Once stopped, the stream can be restarted via ``FileSystemEventStream/start()``, at + /// which point it will resume receiving events from where it left off ("sinceWhen"). + /// + /// This calls `FSEventStreamStop(_:)`. + @available(macOS 10.5, *) + func stop() { + FSEventStreamStop(streamRef) + } + + /// Prints a description of the supplied stream to stderr. + /// + /// For debugging only. + /// + /// This calls `FSEventStreamShow()`. + @available(macOS 10.5, *) + func show() { + FSEventStreamShow(streamRef) + } + + /// Sets directories to be filtered from the event stream. + /// + /// A maximum of eight directories may be specified. + /// + /// This calls `FSEventStreamSetExclusionPaths(_:,_:)`. + @available(macOS 10.9, *) + func setExclusionDirectories(_ directoryURLs: [URL]) throws { + let paths: CFArray + if #available(macOS 13.0, *) { + paths = directoryURLs.map { $0.path(percentEncoded: false) } as CFArray + } else { + paths = directoryURLs.map { $0.path } as CFArray + } + guard FSEventStreamSetExclusionPaths(streamRef, paths) else { throw Error.couldNotExcludeDirectories } + } + + /// Errors that may be thrown by ``FileSystemEventStream`` methods. + enum Error: Swift.Error { + /// Thrown by ``FileSystemEventStream/start()`` if the stream could not be + /// started. + case couldNotStartStream + case couldNotExcludeDirectories + } + + /// Flags that can be passed to the file system event stream to modify its behaviour. + /// + /// This wraps `FSEventStreamCreateFlags`. + struct Flags: OptionSet, Sendable { + let rawValue: FSEventStreamCreateFlags + + init(rawValue: FSEventStreamCreateFlags) { + self.rawValue = rawValue + } + + /// The default. + /// + /// This wraps `kFSEventStreamCreateFlagNone`. + static let none = Self.init(rawValue: FSEventStreamCreateFlags(kFSEventStreamCreateFlagNone)) + + // static let useCFTypes = Self.init(rawValue: FSEventStreamCreateFlags(kFSEventStreamCreateFlagUseCFTypes)) + + /// Change the meaning of the latency parameter. + /// + /// If you specify this flag and more than latency seconds have elapsed since the + /// last event, your app will receive the event immediately. The delivery of the + /// event resets the latency timer and any further events will be delivered after + /// latency seconds have elapsed. This flag is useful for apps that are interactive + /// and want to react immediately to changes but avoid getting swamped by + /// notifications when changes are occurringin rapid succession. If you do not + /// specify this flag, then when an event occurs after a period of no events, the + /// latency timer is started. Any events that occur during the next latency seconds + /// will be delivered as one group (including that first event). The delivery of the + /// group of events resets the latency timer and any further events will be + /// delivered after latency seconds. This is the default behavior and is more + /// appropriate for background, daemon or batch processing apps. + static let noDefer = Self.init(rawValue: FSEventStreamCreateFlags(kFSEventStreamCreateFlagNoDefer)) + + /// Request notifications of changes along the path to the directory or directories + /// being watched. + /// + /// For example, with this flag, if you watch `/foo/bar` and it is renamed to + /// `/foo/bar.old`, you would receive a RootChanged event. The same is true if the + /// directory `/foo` were renamed. The event you receive is a special event: the URL + /// for the event is the original URL you specified, the flag + /// `FileSystemEvent.Flags.rootChanged` is set, and the event ID `FileSystemEvent.ID` + /// is zero. RootChanged events are useful to indicate that you should rescan a + /// particular hierarchy because it changed completely (as opposed to the things + /// inside of it changing). If you want to track the current location of a directory, + /// it is best to open the directory before creating the stream so that you have a + /// file descriptor for it and can issue an `F_GETPATH` `fcntl()` to find the current + /// path. + static let watchRoot = Self.init(rawValue: FSEventStreamCreateFlags(kFSEventStreamCreateFlagWatchRoot)) + + /// Do not send events that were triggered by the current process. + /// + /// This is useful for reducing the volume of events that are sent. It is only + /// useful if your process might modify the file system hierarchy beneath the + /// path or paths being monitored. This has no effect on historical events, i.e., + /// those delivered before the HistoryDone sentinel event. Also, this does not apply + /// to RootChanged events because the WatchRoot feature uses a separate mechanism + /// that is unable to provide information about the responsible process. + @available(macOS 10.6, *) + static let ignoreSelf = Self.init(rawValue: FSEventStreamCreateFlags(kFSEventStreamCreateFlagIgnoreSelf)) + + /// Request file-level notifications. + /// + /// Your stream will receive events about individual files in the hierarchy you are + /// watching instead of only receiving directory level notifications. Use this flag + /// with care as it will generate significantly more events than without it. + @available(macOS 10.7, *) + static let fileEvents = Self.init(rawValue: FSEventStreamCreateFlags(kFSEventStreamCreateFlagFileEvents)) + + /// Tag events that were triggered by the current process with the "OwnEvent" flag. + /// + /// This is only useful if your process might modify the file system hierarchy + /// beneath the path(s) being monitored and you wish to know which events were + /// triggered by your process. Note: this has no effect on historical events, i.e., + /// those delivered before the HistoryDone sentinel event. + @available(macOS 10.9, *) + static let markSelf = Self.init(rawValue: FSEventStreamCreateFlags(kFSEventStreamCreateFlagMarkSelf)) + + // @available(macOS 10.13, *) + // static let useExtendedData = Self.init(rawValue: FSEventStreamEventFlags(kFSEventStreamCreateFlagUseExtendedData)) + + /// Reguest full event history. + /// + /// When requesting historical events it is possible that some events may get + /// skipped due to the way they are stored. With this flag all historical events + /// in a given chunk are returned even if their event ID is less than the + /// `sinceWhen` ID. Put another way, deliver all the events in the first chunk of + /// historical events that contains the `sinceWhen` ID so that none are skipped even + /// if their id is less than the `sinceWhen` ID. This overlap avoids any issue with + /// missing events that happened at/near the time of an unclean restart of the + /// client process. + @available(macOS 10.15, *) + static let fullHistory = Self.init(rawValue: FSEventStreamEventFlags(kFSEventStreamCreateFlagFullHistory)) + } + +} + +// MARK: FileSystemEvent + +/// A file system event. +/// +/// Whenever an event occurs in a directory being watched by +/// ``FileSystemEventStream``, the handler passed to the stream is called with a +/// ``FileSystemEvent`` encapsulating the event. +struct FileSystemEvent: Hashable, Sendable { + + /// The URL of the directory in which the event occured. + let url: URL + + + /// The ID for the event. + let id: ID + + /// Flags set for the event. + /// + /// If no flags are set, then there was some change in the directory in which + /// the event occured. + let flags: Flags + + /// The ID of a file system event. + /// + /// This wraps `FSEventStreamID`. Each file system event has a unique ID. Event IDs + /// all come from a single global source. They are monotonically increasing per + /// system, even across reboots and drives coming and going. An event ID may be + /// passed as the `sinceWhen` parameter to + /// ``FileSystemEventStream/init(directoriesToWatch:sinceWhen:latency:flags:handler:)`` + /// to register the stream for notifications of all events after the event with the + /// given ID. + /// + /// `FSEventStreamID` is just a `UInt64`, so integer wrapping may occur. See + /// ``Flags-swift.struct/eventIdsWrapped``. + struct ID: RawRepresentable, Hashable, Comparable, Sendable { + let rawValue: FSEventStreamEventId + + static func < (lhs: FileSystemEvent.ID, rhs: FileSystemEvent.ID) -> Bool { + lhs.rawValue < rhs.rawValue + } + + init(rawValue: FSEventStreamEventId) { + self.rawValue = rawValue + } + + static let zero = Self.init(rawValue: 0) + + /// A special event ID that may be passed as the `sinceWhen` parameter to + /// ``FileSystemEventStream/init(directoriesToWatch:sinceWhen:latency:flags:handler:)`` + /// in order to receive notifications of all events "since now". + static let now = Self.init(rawValue: FSEventStreamEventId(kFSEventStreamEventIdSinceNow)) + + /// The most recently generated event ID. + /// + /// This fetches the most recently generated event ID, system-wide. By the time the ID is + /// fetched, you have already received events with newer IDs. + static var current: Self { + Self.init(rawValue: FSEventsGetCurrentEventId()) + } + } + + /// Possible flags for a file system event. + /// + /// This wraps `FSEventStreamEventFlags`. + struct Flags: OptionSet, Hashable, Sendable { + let rawValue: FSEventStreamEventFlags + + init(rawValue: FSEventStreamEventFlags) { + self.rawValue = rawValue + } + + /// There was some change in the directory at the specific URL supplied in this event. + /// + /// This wraps `kFSEventStreamEventFlagNone`. + static let none = Self.init(rawValue: FSEventStreamEventFlags(kFSEventStreamEventFlagNone)) + + /// Your application must rescan not just the directory given in the event, but all + /// its children, recursively. + /// + /// This can happen if there was a problem whereby events were coalesced + /// hierarchically. For example, an event in `/Users/jsmith/Music` and an event in + /// `/Users/jsmith/Pictures` might be coalesced into an event with this flag set + /// and path `/Users/jsmith`. If this flag is set you may be able to get an idea of + /// whether the bottleneck happened in the kernel (less likely) or in your client + /// (more likely) by checking for the presence of the informational flags + /// `userDropped` or `kernelDropped`. + /// + /// This wraps `kFSEventStreamEventFlagMustScanSubDirs`. + static let mustScanSubDirs = Self.init(rawValue: FSEventStreamEventFlags(kFSEventStreamEventFlagMustScanSubDirs)) + + /// A problem occured in buffering the event in user space. + /// + /// See ``mustScanSubDirs``. + /// + /// This wraps `kFSEventStreamEventFlagUserDropped`. + static let userDropped = Self.init(rawValue: FSEventStreamEventFlags(kFSEventStreamEventFlagUserDropped)) + + /// A problem occured in buffering the event in kernel space. + /// + /// See ``mustScanSubDirs``. + /// + /// This wraps `kFSEventStreamEventFlagKernelDropped`. + static let kernelDropped = Self.init(rawValue: FSEventStreamEventFlags(kFSEventStreamEventFlagKernelDropped)) + + /// The 64-bit event ID counter wrapped around. + /// + /// If this flag is present, previously-issued event ID's are no longer valid + /// values for the `sinceWhen` parameter to + /// ``FileSystemEventStream/init(directoriesToWatch:sinceWhen:latency:flags:handler:)``. + /// + /// This wraps `kFSEventStreamEventFlagEventIdsWrapped`. + static let eventIdsWrapped = Self.init(rawValue: FSEventStreamEventFlags(kFSEventStreamEventFlagEventIdsWrapped)) + + /// Marks a sentinel event sent to mark the end of the historical events. + /// + /// If a ``FileSystemEvent/ID-swift.struct`` was passed as the `sinceWhen` parameter + /// to the call to + /// ``FileSystemEventStream/init(directoriesToWatch:sinceWhen:latency:flags:handler:)`` + /// that created this stream, and this value was not + /// ``FileSystemEvent/ID-swift.struct/now``, then the handler will be called with + /// each event before `now` (the "historial events"). Once this is finised, the + /// handler will be invoked with an event (the "history sentinel event") with this + /// flag set. The URL provided with this event should be ignored. + /// + /// This wraps `kFSEventStreamEventFlagHistoryDone`. + static let historyDone = Self.init(rawValue: FSEventStreamEventFlags(kFSEventStreamEventFlagHistoryDone)) + + /// Marks a special event sent when there is a change to one of the directories + /// along the path to one of the directories you asked to watch. + /// + /// When this flag is set, the event ID is zero and the path corresponds to one of + /// the paths you asked to watch (specifically, the one that changed). The path may + /// no longer exist because it or one of its parents was deleted or renamed. Events + /// with this flag set will only be sent if you passed the + /// ``FileSystemEventStream/Flags/watchRoot`` when creating the stream with + /// ``FileSystemEventStream/init(directoriesToWatch:sinceWhen:latency:flags:handler:)``. + /// + /// This wraps `kFSEventStreamEventFlagRootChanged`. + static let rootChanged = Self.init(rawValue: FSEventStreamEventFlags(kFSEventStreamEventFlagRootChanged)) + + /// Marks a special event sent when a volume is mounted underneath one of the paths + /// being monitored. + /// The `URL` represents the path to the newly-mounted volume. You will receive + /// one of these notifications for every volume mount event inside the kernel + /// (independent of DiskArbitration). + /// + /// This wraps `kFSEventStreamEventFlagMount`. + static let mount = Self.init(rawValue: FSEventStreamEventFlags(kFSEventStreamEventFlagMount)) + + /// Marks a special event sent when a volume is unmounted underneath one of the + /// paths being monitored. + /// + /// The path in the event is the path to the directory from which the volume was + /// unmounted. You will receive one of these notifications for every volume unmount + /// event inside the kernel. + /// + /// This wraps `kFSEventStreamEventFlagUnmount`. + static let unmount = Self.init(rawValue: FSEventStreamEventFlags(kFSEventStreamEventFlagUnmount)) + + /// A file system object was created at the specific URL supplied in this event. + /// + /// This flag is only ever set if you specified the ``FileSystemEventStream/Flags/fileEvents`` + /// flag when creating the stream. + /// + /// This wraps `kFSEventStreamEventFlagItemCreated`. + @available(macOS 10.7, *) + static let itemCreated = Self.init(rawValue: FSEventStreamEventFlags(kFSEventStreamEventFlagItemCreated)) + + /// A file system object was removed at the specific URL supplied in this event. + /// + /// This flag is only ever set if you specified the ``FileSystemEventStream/Flags/fileEvents`` + /// flag when creating the stream. + /// + /// This wraps `kFSEventStreamEventFlagItemRemoved`. + @available(macOS 10.7, *) + static let itemRemoved = Self.init(rawValue: FSEventStreamEventFlags(kFSEventStreamEventFlagItemRemoved)) + + /// A file system object at the specific URL supplied in this event had its metadata modified. + /// + /// This flag is only ever set if you specified the ``FileSystemEventStream/Flags/fileEvents`` + /// flag when creating the stream. + /// + /// This wraps `kFSEventStreamEventFlagItemInodeMetaMod`. + @available(macOS 10.7, *) + static let itemInodeMetaMod = Self.init(rawValue: FSEventStreamEventFlags(kFSEventStreamEventFlagItemInodeMetaMod)) + + /// A file system object was renamed at the specific URL supplied in this event. + /// + /// This flag is only ever set if you specified the ``FileSystemEventStream/Flags/fileEvents`` + /// flag when creating the stream. + /// + /// This wraps `kFSEventStreamEventFlagItemRenamed`. + @available(macOS 10.7, *) + static let itemRenamed = Self.init(rawValue: FSEventStreamEventFlags(kFSEventStreamEventFlagItemRenamed)) + + /// A file system object at the specific URL supplied in this event had its data modified. + /// + /// This flag is only ever set if you specified the ``FileSystemEventStream/Flags/fileEvents`` + /// flag when creating the stream. + /// + /// This wraps `kFSEventStreamEventFlagItemModified`. + @available(macOS 10.7, *) + static let itemModified = Self.init(rawValue: FSEventStreamEventFlags(kFSEventStreamEventFlagItemModified)) + + /// A file system object at the specific URL supplied in this event had its + /// FinderInfo data modified. + /// + /// This flag is only ever set if you specified the ``FileSystemEventStream/Flags/fileEvents`` + /// flag when creating the stream. + /// + /// This wraps `kFSEventStreamEventFlagItemFinderInfoMod`. + @available(macOS 10.7, *) + static let itemFinderInfoMod = Self.init(rawValue: FSEventStreamEventFlags(kFSEventStreamEventFlagItemFinderInfoMod)) + + /// A file system object at the specific URL supplied in this event had its + /// ownership changed. + /// + /// This flag is only ever set if you specified the ``FileSystemEventStream/Flags/fileEvents`` + /// flag when creating the stream. + /// + /// This wraps `kFSEventStreamEventFlagItemChangeOwner`. + @available(macOS 10.7, *) + static let itemChangeOwner = Self.init(rawValue: FSEventStreamEventFlags(kFSEventStreamEventFlagItemChangeOwner)) + + /// A file system object at the specific URL supplied in this event had its + /// extended attributes modified. + /// + /// This flag is only ever set if you specified the ``FileSystemEventStream/Flags/fileEvents`` + /// flag when creating the stream. + /// + /// This wraps `kFSEventStreamEventFlagItemXattrMod`. + @available(macOS 10.7, *) + static let itemXattrMod = Self.init(rawValue: FSEventStreamEventFlags(kFSEventStreamEventFlagItemXattrMod)) + + /// The file system object at the specific URL supplied in this event is a regular file. + /// + /// This flag is only ever set if you specified the ``FileSystemEventStream/Flags/fileEvents`` + /// flag when creating the stream. + /// + /// This wraps `kFSEventStreamEventFlagItemIsFile`. + @available(macOS 10.7, *) + static let itemIsFile = Self.init(rawValue: FSEventStreamEventFlags(kFSEventStreamEventFlagItemIsFile)) + + /// The file system object at the specific URL supplied in this event is a directory. + /// + /// This flag is only ever set if you specified the ``FileSystemEventStream/Flags/fileEvents`` + /// flag when creating the stream. + /// + /// This wraps `kFSEventStreamEventFlagItemIsDir`. + @available(macOS 10.7, *) + static let itemIsDir = Self.init(rawValue: FSEventStreamEventFlags(kFSEventStreamEventFlagItemIsDir)) + + /// The file system object at the specific URL supplied in this event is a symbolic link. + /// + /// This flag is only ever set if you specified the ``FileSystemEventStream/Flags/fileEvents`` + /// flag when creating the stream. + /// + /// This wraps `kFSEventStreamEventFlagItemIsSymlink`. + @available(macOS 10.7, *) + static let itemIsSymlink = Self.init(rawValue: FSEventStreamEventFlags(kFSEventStreamEventFlagItemIsSymlink)) + + /// Indicates the event was triggered by the current process. + /// + /// This flag is only ever set if you specified the ``FileSystemEventStream/Flags/markSelf`` + /// flag when creating the stream. + /// + /// This wraps `kFSEventStreamEventFlagOwnEvent`. + @available(macOS 10.9, *) + static let ownEvent = Self.init(rawValue: FSEventStreamEventFlags(kFSEventStreamEventFlagOwnEvent)) + + /// The file system object at the specific URL supplied in this event is a hard link. + /// + /// This flag is only ever set if you specified the ``FileSystemEventStream/Flags/fileEvents`` + /// flag when creating the stream. + /// + /// This wraps `kFSEventStreamEventFlagItemIsHardlink`. + @available(macOS 10.10, *) + static let itemIsHardlink = Self.init(rawValue: FSEventStreamEventFlags(kFSEventStreamEventFlagItemIsHardlink)) + + /// The file system object at the specific URL supplied in this event was the last hard link. + /// + /// This flag is only ever set if you specified the ``FileSystemEventStream/Flags/fileEvents`` + /// flag when creating the stream. + /// + /// This wraps `kFSEventStreamEventFlagItemIsLastHardlink`. + @available(macOS 10.10, *) + static let itemIsLastHardlink = Self.init(rawValue: FSEventStreamEventFlags(kFSEventStreamEventFlagItemIsLastHardlink)) + + /// The file system object at the specific path supplied in this event is a clone or was cloned. + /// + /// This flag is only ever set if you specified the ``FileSystemEventStream/Flags/fileEvents`` + /// flag when creating the stream. + /// + /// This wraps `kFSEventStreamEventFlagItemCloned`. + @available(macOS 10.13, *) + static let itemCloned = Self.init(rawValue: FSEventStreamEventFlags(kFSEventStreamEventFlagItemCloned)) + } + +} + +#else + +import Foundation +import XKit +import CXKit +import SystemPackage + +// https://github.com/sersoft-gmbh/swift-inotify + +extension FileSystemMonitor { + static let liveValue = FileSystemMonitor { directory in + let notifier = try Inotifier() + let events = try await notifier.events(for: directory) + .compactMap { $0.path.map { FileSystemChangeEvent(file: $0) } } + .eraseToStream() + return FileSystemEvents { + _ = notifier + return events.makeAsyncIterator() + } + } +} + +/// The notifier object. +final actor Inotifier { + /// An asynchronous sequence of events for a certain file path. + struct PathEvents: AsyncSequence, Sendable { + @usableFromInline + let stream: AsyncStream + + @usableFromInline + init(stream: AsyncStream) { + self.stream = stream + } + + @inlinable + func makeAsyncIterator() -> AsyncStream.AsyncIterator { + stream.makeAsyncIterator() + } + } + + private let fileDescriptor: FileDescriptor + private var streamTask: Task? + private var watches = Dictionary.Continuation>>() + + /// Creates a new instance. + init() throws { + guard case let fd = inotify_init1(0), fd != -1 else { throw Errno(rawValue: errno) } + fileDescriptor = .init(rawValue: fd) + } + + deinit { + streamTask?.cancel() + streamTask = nil + try? fileDescriptor.close() + } + + /// Closes this inotify instance. All further calls to this instance will fail. + func close() throws { + stopStreaming() + try fileDescriptor.close() + } + + /// Returns the asynchronous events sequence for the given file path. + /// - Parameters: + /// - filePath: The file path to watch. + /// - Returns: The asynchronous sequence of events for the given file path. + func events(for filePath: FilePath) throws -> PathEvents { + let wd = filePath.withCString { + inotify_add_watch(fileDescriptor.rawValue, $0, cin_all_events) + } + guard wd != -1 else { throw Errno(rawValue: errno) } + if streamTask == nil { + startStreaming() + } + let stream = AsyncStream { continuation in + let sequenceID = UUID() + watches[wd, default: [:]][sequenceID] = continuation + continuation.onTermination = { [weak self] _ in + Task { [weak self] in + try await self?.removeWatch(forDescriptor: wd, sequenceID: sequenceID) + } + } + } + return PathEvents(stream: stream) + } + + private func startStreaming(restart: Bool = false) { + assert(restart || streamTask == nil) + if restart { + streamTask?.cancel() + } + streamTask = Task.detached { [fileDescriptor, weak self] in + do { + for try await event in FileStream(fileDescriptor: fileDescriptor) { + guard !Task.isCancelled, let self else { return } + await self.handle(event) + } + } catch is CancellationError { + } catch { + print("[INOTIFY] Error: \(error)") + print("[INOTIFY] Restarting stream...") + await self?.startStreaming(restart: true) + } + } + } + + private func handle(_ cEvent: inotify_event) { + guard var watchesToNotify = watches[cEvent.wd] else { return } + defer { + if watchesToNotify.isEmpty { + watches.removeValue(forKey: cEvent.wd) + } else { + watches[cEvent.wd] = watchesToNotify + } + } + // FIXME: Deal with connected events using `event.cookie`. + let event = InotifyEvent(cEvent: cEvent) + for (watchID, continuation) in watchesToNotify { + if case .terminated = continuation.yield(event) { + watchesToNotify.removeValue(forKey: watchID) + } + } + } + + private func stopStreaming() { + streamTask?.cancel() + streamTask = nil + } + + private func removeWatch(forDescriptor wd: CInt, sequenceID: UUID) throws { + let status = inotify_rm_watch(fileDescriptor.rawValue, wd) + guard status != -1 else { throw Errno(rawValue: errno) } + guard var watchSequences = watches[wd] else { return } + watchSequences.removeValue(forKey: sequenceID) + guard watchSequences.isEmpty else { return } + watches.removeValue(forKey: wd) + guard watches.isEmpty else { return } + stopStreaming() + } +} + +/// An event sent by inotify. +struct InotifyEvent: Equatable, Sendable { + /// The file path of the event. If nil, the event is not for a file inside of the watch. + let path: FilePath? + /// The flags of the event. + let flags: Flags + + init(cEvent event: inotify_event) { + path = withUnsafePointer(to: event) { + cin_event_name($0).map { FilePath(platformString: $0) } + } + flags = .init(rawValue: event.mask) + } +} + +extension InotifyEvent { + /// A set of flags that can be set on an event. + struct Flags: OptionSet, Hashable, Sendable { + typealias RawValue = UInt32 + + let rawValue: RawValue + + init(rawValue: RawValue) { + self.rawValue = rawValue + } + } +} + +extension InotifyEvent.Flags { + /// File was accessed. + static let accessed = InotifyEvent.Flags(rawValue: numericCast(IN_ACCESS)) + /// File was modified. + static let modified = InotifyEvent.Flags(rawValue: numericCast(IN_MODIFY)) + /// Metadata changed. + static let attributesChanged = InotifyEvent.Flags(rawValue: numericCast(IN_ATTRIB)) + /// A writeable file was closed. + static let writableFileClosed = InotifyEvent.Flags(rawValue: numericCast(IN_CLOSE_WRITE)) + /// A non-writable file was closed. + static let nonWritableFileClosed = InotifyEvent.Flags(rawValue: numericCast(IN_CLOSE_NOWRITE)) + /// File was opened. + static let opened = InotifyEvent.Flags(rawValue: numericCast(IN_OPEN)) + /// File was moved from X. + static let movedFrom = InotifyEvent.Flags(rawValue: numericCast(IN_MOVED_FROM)) + /// File was moved to Y. + static let movedTo = InotifyEvent.Flags(rawValue: numericCast(IN_MOVED_TO)) + /// File was created inside the watched path. + static let fileCreated = InotifyEvent.Flags(rawValue: numericCast(IN_CREATE)) + /// File was deleted inside the watched path. + static let fileDeleted = InotifyEvent.Flags(rawValue: numericCast(IN_DELETE)) + /// The watched path was deleted. + static let selfDeleted = InotifyEvent.Flags(rawValue: numericCast(IN_DELETE_SELF)) + /// The watched path was moved. + static let selfMoved = InotifyEvent.Flags(rawValue: numericCast(IN_MOVE_SELF)) + + /// Event occurred against a directory. + static let isDirectory = InotifyEvent.Flags(rawValue: numericCast(IN_ISDIR)) +} + +/// An async sequence that continously streams the generic `Element` type from a given file. +/// The `Failure` type describes the errors thrown for the sequence. The ``FailureBehavior`` is used to handle errors. +struct FileStream: AsyncSequence { + @usableFromInline + let _stream: AsyncThrowingStream + + /// Creates a new file stream for the given `fileDescriptor`. + /// The `failureBehavior` defines how errors are handled. + /// - Parameters: + /// - fileDescriptor: The file descriptor to stream from. + /// - failureBehavior: How to handle failures of the underlying stream. + init(fileDescriptor: FileDescriptor) { + _stream = .init(Element.self, { Self._gcdImplementation(for: fileDescriptor, using: $0) }) + } + + @inlinable + func makeAsyncIterator() -> AsyncThrowingStream.AsyncIterator { + _stream.makeAsyncIterator() + } +} + +extension FileStream: Sendable where Element: Sendable {} + +extension FileStream { + private static func _gcdImplementation(for fileDescriptor: FileDescriptor, + using cont: AsyncThrowingStream.Continuation) { + let source = _inactiveSource(from: fileDescriptor) { + cont.yield($0) + } onFailure: { + cont.finish(throwing: $0) + } + cont.onTermination = { _ in source.cancel() } + source.activate() + } +} + +#if swift(>=6.2) && canImport(Darwin) +fileprivate typealias SendableDispatchSource = any DispatchSourceRead +#else +fileprivate struct SendableDispatchSource: @unchecked Sendable { + let source: any DispatchSourceRead + + func activate() { + source.activate() + } + + func cancel() { + source.cancel() + } +} +#endif + +extension FileStream { + private static func _inactiveSource(from fileDesc: FileDescriptor, + onElement elementCallback: @escaping @Sendable (sending Element) -> (), + onFailure failureCallback: @escaping @Sendable (any Error) -> ()) -> SendableDispatchSource { +#if compiler(>=6.2) + let unsafeCallback = unsafe unsafeBitCast(elementCallback, to: (@Sendable (Element) -> ()).self) +#else + let unsafeCallback = unsafeBitCast(elementCallback, to: (@Sendable (Element) -> ()).self) +#endif + @Sendable + func send(_ value: Element) { + unsafeCallback(value) + } + + let workerQueue = DispatchQueue(label: "de.sersoft.filestreamer.filestream.gcd.worker") + let source = DispatchSource.makeReadSource(fileDescriptor: fileDesc.rawValue, queue: workerQueue) + let rawSize = MemoryLayout.size + let rawSize64 = UInt64(rawSize) + var remainingData: UInt64 = 0 + source.setEventHandler { + do { + remainingData += .init(source.data) + guard case let capacity = remainingData / rawSize64, capacity > 0 else { return } + let buffer = UnsafeMutableBufferPointer.allocate(capacity: .init(capacity)) +#if compiler(>=6.2) + defer { unsafe buffer.deallocate() } + let bytesRead = unsafe try fileDesc.read(into: UnsafeMutableRawBufferPointer(buffer)) +#else + defer { buffer.deallocate() } + let bytesRead = try fileDesc.read(into: UnsafeMutableRawBufferPointer(buffer)) +#endif + if case let noOfValues = bytesRead / rawSize, noOfValues > 0 { +#if compiler(>=6.2) + for unsafe value in unsafe buffer.prefix(noOfValues) { + send(value) + } +#else + for value in buffer.prefix(noOfValues) { + send(value) + } +#endif + } + let leftOverBytes = bytesRead % rawSize + remainingData -= .init(bytesRead - leftOverBytes) + if leftOverBytes > 0 { + do { + try fileDesc.seek(offset: .init(-leftOverBytes), from: .current) + } catch { + // If we failed to seek, we need to drop the left-over bytes. + remainingData -= .init(leftOverBytes) + throw error // Re-throw to land it in the failureCallback below + } + } + } catch { + failureCallback(error) + } + } +#if swift(>=6.2) && canImport(Darwin) + return source +#else + return .init(source: source) +#endif + } +} + +#endif diff --git a/Sources/XToolSupport/SimInstallOperation.swift b/Sources/XToolSupport/SimInstallOperation.swift new file mode 100644 index 00000000..5bfb3879 --- /dev/null +++ b/Sources/XToolSupport/SimInstallOperation.swift @@ -0,0 +1,131 @@ +#if os(macOS) +import Subprocess +import PackLib +import Foundation +import XUtils +import Dependencies +import XKit + +struct SimInstallOperation { + var operation: PackOperation + var plan: Plan + var path: URL + + var watch = false + + // TODO: allow customizing this + var simulator = "booted" + + func run() async throws { + try await Subprocess.run( + .path("/usr/bin/xcrun"), + arguments: ["simctl", "install", simulator, path.path], + output: .discarded + ) + .checkSuccess() + + print("Installed to simulator") + + let tmp = URL(filePath: FileManager.default.currentDirectoryPath) + .appending(path: "xtool/.xload") + try? FileManager.default.removeItem(at: tmp) + defer { try? FileManager.default.removeItem(at: tmp) } + + let watchTask: Task? + let envVars: [Environment.Key: String?] + if watch { + let xLoadPath = try await getXLoad(platform: "iphonesimulator") + + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + guard let temporaryDirectoryPath = FilePath(tmp) else { + throw Console.Error("Bad temporaryDirectory path") + } + + let buildDirectory = temporaryDirectoryPath.appending("build") + try FileManager.default.createDirectory(at: URL(filePath: buildDirectory)!, withIntermediateDirectories: true) + + let outDirectory = temporaryDirectoryPath.appending("out") + try FileManager.default.createDirectory(at: URL(filePath: outDirectory)!, withIntermediateDirectories: true) + + watchTask = Task { + do { + try await watch(buildDirectory: buildDirectory, outDirectory: outDirectory) + } catch { + print("Error: watch task failed: \(error)") + } + } + + envVars = [ + "SIMCTL_CHILD_DYLD_INSERT_LIBRARIES": xLoadPath.path, + "SIMCTL_CHILD_XLOAD_WATCH_DIR": outDirectory.string, + ] + } else { + watchTask = nil + envVars = [:] + } + + try await Subprocess.run( + .path("/usr/bin/xcrun"), + arguments: [ + "simctl", "launch", + "--console-pty", + simulator, plan.app.bundleID, + ], + environment: .inherit.updating(envVars), + platformOptions: .withGracefulShutDown, + output: .standardOutput, + error: .standardError, + ) + .checkSuccess() + + watchTask?.cancel() + try await watchTask?.value + } + + private func watch( + buildDirectory: FilePath, + outDirectory: FilePath, + ) async throws { + let settings = try await operation.buildSettings() + let workspace = try await SwiftWorkspace( + buildDirectory: buildDirectory, + outDirectory: outDirectory, + buildSettings: settings, + ) + + @Dependency(\.fileSystemMonitor) var fileSystemMonitor + let events = try await fileSystemMonitor.watch(FilePath(FileManager.default.currentDirectoryPath)) + + for await event in events { + do { + try await workspace.fileDidChange(event.file) + } catch { + print("Reload failed: \(error)") + } + } + } + + private func getXLoad(platform: String) async throws -> URL { + let version = "0.1.0" + let remote = URL(string: "https://github.com/xtool-org/xload/releases/download/v\(version)/libXLoad.\(platform).dylib")! + + @Dependency(\.httpClient) var httpClient + @Dependency(\.persistentDirectory) var persistentDirectory + + let platformDir = persistentDirectory.appending(path: "xload/\(platform)") + let versionDir = platformDir.appending(path: version) + let local = versionDir.appending(path: "libXLoad.dylib") + if local.exists { return local } + + print("Downloading xload...") + + try? FileManager.default.removeItem(at: platformDir) + try FileManager.default.createDirectory(at: versionDir, withIntermediateDirectories: true) + + let data = try await httpClient.makeRequest(HTTPRequest(url: remote)).body + try data.write(to: local) + + return local + } +} +#endif diff --git a/Sources/XToolSupport/SwiftWorkspace.swift b/Sources/XToolSupport/SwiftWorkspace.swift new file mode 100644 index 00000000..fbc673a4 --- /dev/null +++ b/Sources/XToolSupport/SwiftWorkspace.swift @@ -0,0 +1,315 @@ +import Foundation +import XUtils +import PackLib +import BuildServerProtocol +import LanguageServerProtocol +import LanguageServerProtocolTransport +import Subprocess +import Dependencies + +actor SwiftWorkspace { + private let connection: JSONRPCConnection + private let buildDirectory: FilePath + private let outDirectory: FilePath + private var counter = 0 + + struct FileInfo { + let file: FilePath + let target: BuildTarget + let sources: SourcesItem + let source: SourceItem + + init?(target: BuildTarget, sources: SourcesItem, source: SourceItem) { + guard let path = FilePath(source.uri.arbitrarySchemeURL) else { return nil } + self.file = path + self.target = target + self.sources = sources + self.source = source + } + } + + private var filesToWatch: [FilePath: [FileInfo]] = [:] + private var sources: [(BuildTarget, SourcesItem)] { + didSet { updateFilesToWatch() } + } + private var cachedCommands: [FilePath: FrontendCommand] = [:] + + struct FrontendCommand { + let commandLine: CommandInvocation + let output: FilePath + let module: String + } + + init( + buildDirectory: FilePath, + outDirectory: FilePath, + buildSettings: BuildSettings, + ) async throws { + guard let swiftURL = URL(filePath: try await BuildSettings.swiftURL()) else { + throw Console.Error("Swift URL invalid") + } + + self.buildDirectory = buildDirectory + self.outDirectory = outDirectory + + let handler = StreamingMessageHandler() + + let (connection, _) = try JSONRPCConnection.start( + executable: swiftURL, + arguments: buildSettings.buildServerArguments, + name: "xtool", + protocol: .bspProtocol, + stderrLoggingCategory: "xtool-error", + client: handler, + terminationHandler: { print("Terminated: \($0)") } + ) + self.connection = connection + + _ = try await connection.send(InitializeBuildRequest( + displayName: "xtool", + version: "1.0.0", + bspVersion: "2.2.0", + rootUri: .init(URL(filePath: FileManager.default.currentDirectoryPath)), + capabilities: BuildClientCapabilities( + languageIds: [.swift, .c, .cpp, .objective_c, .objective_cpp], + ) + )) + + connection.send(OnBuildInitializedNotification()) + + for await case _ as OnBuildTargetDidChangeNotification in handler.notifications { break } + + let result = try await connection.send(WorkspaceBuildTargetsRequest()) + + let targets = Dictionary(uniqueKeysWithValues: result.targets.map { ($0.id, $0) }) + + let targetInfos = try await connection.send(BuildTargetSourcesRequest( + targets: result.targets.map(\.id) + )) + sources = targetInfos.items.compactMap { + guard let target = targets[$0.target] else { return nil } + return (target, $0) + } + + updateFilesToWatch() + } + + deinit { + connection.close() + } + + func updateFilesToWatch() { + filesToWatch = Dictionary( + grouping: sources.flatMap { target, sources in + sources.sources.compactMap { source in + // guard !target.id.uri.stringValue.contains("&targetGUID=PACKAGE-TARGET:") else { return nil } + FileInfo(target: target, sources: sources, source: source) + } + }, + by: \.file + ) + } + + func fileDidChange(_ path: FilePath) async throws { + guard filesToWatch[path] != nil else { return } + print("Reloading \(path)") + _ = try await rebuild(path: path) + } + + @discardableResult + func rebuild(path: FilePath) async throws -> FilePath { + let command = try await frontendCommand(for: path) + + try await Subprocess.run( + .path(FilePath(String(command.commandLine.command))), + arguments: .init(command.commandLine.arguments.map { String($0) }), + output: .standardOutput, + error: .standardError, + ) + .checkSuccess() + + let sdk = try command.commandLine.value(for: "-sdk") + .orThrow(Console.Error("Could not find -sdk")) + + let target = try command.commandLine.value(for: "-target") + .orThrow(Console.Error("Could not find -target")) + + counter += 1 + + let outputFile = outDirectory + .appending("lib\(path.lastComponent!.stem).\(counter).dylib") + + // link + try await Subprocess.run( + .path(try await BuildSettings.swiftcURL()), + arguments: [ + command.output.string, + "-emit-library", + "-sdk", sdk, "-target", target, + "-Xlinker", "-undefined", "-Xlinker", "dynamic_lookup", + "-o", outputFile.string + ], + output: .standardOutput, + error: .standardError, + ) + .checkSuccess() + + return outputFile + } + + func frontendCommand(for path: FilePath) async throws -> FrontendCommand { + if let cached = cachedCommands[path] { + return cached + } + + let targets = filesToWatch[path] ?? [] + + let target: FileInfo + switch targets.count { + case 0: + throw Console.Error("No targets for file") + case 1: + target = targets[0] + default: + print("warning: multiple targets contain this file: \(targets.map(\.target.id.uri))") + target = targets[0] + } + + guard let fileURL = URL(filePath: path)?.absoluteURL, let lastComponent = path.lastComponent else { + throw Console.Error("Bad file path \(path.string)") + } + + let options = try await connection.send(TextDocumentSourceKitOptionsRequest( + textDocument: .init(target.source.uri), + target: target.target.id, + language: .swift, + )) + .orThrow(Console.Error("No options")) + + guard let moduleNameIndex = options.compilerArguments.firstIndex(of: "-module-name"), + moduleNameIndex < options.compilerArguments.count - 2 else { + throw Console.Error("Could not determine module name for file at \(path)") + } + let moduleName = options.compilerArguments[moduleNameIndex + 1] + let moduleBuildDirectory = buildDirectory.appending(moduleName) + let moduleBuildDirectoryURL = try URL(filePath: moduleBuildDirectory).orThrow(Console.Error("Bad module name")) + try? FileManager.default.createDirectory(at: moduleBuildDirectoryURL, withIntermediateDirectories: true) + + let swiftcOutput = try await Subprocess.run( + .path(try await BuildSettings.swiftcURL()), + arguments: .init( + ["-c", "-driver-print-jobs"] + + options.compilerArguments + + ["-working-directory", moduleBuildDirectory.string] + ), + output: .string(limit: .max), + error: .standardError + ).checkSuccess() + + let frontendCommands = (swiftcOutput.standardOutput ?? "").split(separator: "\n") + guard let invocation = frontendCommands.lazy.compactMap({ line -> CommandInvocation? in + guard let invocation = CommandInvocation(line) else { return nil } + guard invocation.value(for: "-primary-file") == fileURL.path else { return nil } + return invocation + }).first else { throw Console.Error("Could not find frontend command") } + + let command = FrontendCommand( + commandLine: invocation, + output: moduleBuildDirectory.appending("\(lastComponent.stem).o"), + module: moduleName, + ) + cachedCommands[path] = command + return command + } + + private final class StreamingMessageHandler: MessageHandler { + private let (_notifications, onNotification) = AsyncStream.makeStream() + + var notifications: AsyncStream { _notifications } + + deinit { onNotification.finish() } + + func handle(_ notification: some NotificationType) { + onNotification.yield(notification) + } + + func handle( + _ request: Request, + id: LanguageServerProtocol.RequestID, + reply: @escaping @Sendable (LanguageServerProtocol.LSPResult) -> Void + ) { + fatalError("Can't handle request \(Request.method)") + } + } +} + +struct CommandInvocation { + var command: String + var arguments: [String] + + init?(_ string: some StringProtocol) { + guard let all = try? CommandParser.parse(string), !all.isEmpty else { return nil } + command = all[0] + arguments = Array(all.dropFirst()) + } + + func value(for option: some StringProtocol) -> String? { + guard let optionIndex = arguments.firstIndex(where: { $0 == option }) else { return nil } + guard optionIndex < arguments.count - 2 else { return nil } + return arguments[optionIndex + 1] + } +} + +enum CommandParser { + enum Errors: Error, Hashable { + case unclosedQuote + case unpairedEscape + } + + static func parse(_ string: some StringProtocol) throws -> [String] { + try sequence( + state: ( + current: string[...], + isInsideQuote: false, + ) + ) { state -> Token? in + while !state.current.isEmpty { + switch state.current.removeFirst() { + case "'": + state.isInsideQuote.toggle() + case "\\" where !state.isInsideQuote: + guard !state.current.isEmpty else { return .error(.unpairedEscape) } + let actual = state.current.removeFirst() + return .value(actual) + case " " where !state.isInsideQuote: + return .sentinelSpace + case let value: + return .value(value) + } + } + guard !state.isInsideQuote else { + state.isInsideQuote = false + return .error(.unclosedQuote) + } + return nil + } + .lazy + .split(separator: .sentinelSpace) + .map { + let characters = try $0.compactMap { token -> Character? in + switch token { + case .sentinelSpace: nil + case .error(let error): throw error + case .value(let value): value + } + } + return String(characters) + } + } + + private enum Token: Hashable { + case sentinelSpace + case value(Character) + case error(Errors) + } +} diff --git a/Tests/XToolTests/CommandInvocationParsingTests.swift b/Tests/XToolTests/CommandInvocationParsingTests.swift new file mode 100644 index 00000000..1530f089 --- /dev/null +++ b/Tests/XToolTests/CommandInvocationParsingTests.swift @@ -0,0 +1,25 @@ +import Testing +@testable import XToolSupport + +@Test func commandParser() throws { + func sut(_ string: some StringProtocol) throws -> [String] { + try CommandParser.parse(string) + } + + #expect(try sut(#""#).isEmpty) + #expect(try sut(#"foo"#) == ["foo"]) + #expect(try sut(#"foo "#) == ["foo"]) + #expect(try sut(#"foo one two"#) == ["foo", "one", "two"]) + #expect(try sut(#"foo 'one two' three"#) == ["foo", "one two", "three"]) + #expect(try sut(#"foo 'one!two' three"#) == ["foo", "one!two", "three"]) + #expect(try sut(#"foo one\!two three"#) == ["foo", "one!two", "three"]) + #expect(try sut(#"foo one\'two three"#) == ["foo", "one'two", "three"]) + #expect(try sut(#"foo one'two three'four five"#) == ["foo", "onetwo threefour", "five"]) + + #expect(throws: CommandParser.Errors.unclosedQuote) { + try sut(#"foo one two' three"#) + } + #expect(throws: CommandParser.Errors.unpairedEscape) { + try sut(#"foo a b \"#) + } +} diff --git a/macOS/project.yml b/macOS/project.yml index 0bb70ca5..dbaeefe6 100644 --- a/macOS/project.yml +++ b/macOS/project.yml @@ -11,7 +11,7 @@ targets: XToolMac: type: application platform: macOS - deploymentTarget: "13.0" + deploymentTarget: "14.0" sources: - path: XToolMac - path: Resources/xtool