From 46d564a393abf22633bbf5238a6771669dfc2116 Mon Sep 17 00:00:00 2001 From: banjun Date: Mon, 30 Oct 2023 21:46:28 +0900 Subject: [PATCH 01/17] add pods header map for modules referencing C module installed via cocoapods --- Sources/Reloader.swift | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/Sources/Reloader.swift b/Sources/Reloader.swift index 158266e..4c75ba2 100644 --- a/Sources/Reloader.swift +++ b/Sources/Reloader.swift @@ -15,7 +15,7 @@ public final class Reloader: ObservableObject { private let moduleCachePath: URL private let confBuildDir: URL private let headerSearchPaths: [URL] - private let headerMap: URL + private let headerMaps: [URL] private let buildDir: URL private let targetTriple: String private let sdk: URL @@ -25,9 +25,10 @@ public final class Reloader: ObservableObject { self.targetSwiftFile = targetSwiftFile self.derivedData = derivedData self.moduleCachePath = derivedData.appendingPathComponent("ModuleCache.noindex") - let confBuildDir = derivedData + let intermediatesDir = derivedData .appendingPathComponent(confBuildDirAppRandomString) .appendingPathComponent("Build/Intermediates.noindex") + let confBuildDir = intermediatesDir .appendingPathComponent(mainModule + ".build") .appendingPathComponent(configurationPlatform) self.confBuildDir = confBuildDir @@ -37,9 +38,15 @@ public final class Reloader: ObservableObject { .appendingPathComponent("Objects-normal") .appendingPathComponent(arch) } - self.headerMap = confBuildDir + self.headerMaps = [confBuildDir .appendingPathComponent(mainModule + ".build") .appendingPathComponent("\(mainModule)-project-headers.hmap") + ] + [intermediatesDir + .appendingPathComponent("Pods" + ".build") + .appendingPathComponent(configurationPlatform) + .appendingPathComponent("Pods-\(mainModule)" + ".build") + .appendingPathComponent("Pods_\(mainModule)-project-headers.hmap") + ] self.buildDir = headerSearchPaths.first! self.targetTriple = targetTriple self.sdk = sdk @@ -84,8 +91,9 @@ public final class Reloader: ObservableObject { ["-module-cache-path", moduleCachePath.path], // required in some cases ["-Xlinker", "-undefined", "-Xlinker", "suppress"], // avoid fatal error on the linker ["-Xfrontend", "-disable-access-control"], // with this, internal symbols can be used + ["-Xlinker", "-flat_namespace"], // for Xcode 14 (unneeded for Xcode 15) (headerSearchPaths + importedModuleSearchPaths).flatMap { ["-I", $0.path] }, - ["-Xcc", "-I", "-Xcc", headerMap.path] + headerMaps.flatMap { ["-Xcc", "-I", "-Xcc", $0.path] } ].flatMap { $0 } task.setValue(args, forKey: "arguments") NSLog("%@", "🍓 exec and args = ") From 67f5ba43b9fe8559faa6de2e7b0906b96bed4939 Mon Sep 17 00:00:00 2001 From: banjun Date: Thu, 2 Nov 2023 19:16:33 +0900 Subject: [PATCH 02/17] remove unneeded `@State` as a fake hook to force update SwiftUI views --- Example/ContentView.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Example/ContentView.swift b/Example/ContentView.swift index 3d4988a..467a8c7 100644 --- a/Example/ContentView.swift +++ b/Example/ContentView.swift @@ -11,9 +11,8 @@ import SwiftUI struct ContentView: View { #if DEBUG - // these two lines are fake hook to force update of SwiftUI views + // this is a fake hook to force update of SwiftUI views @ObservedObject private var reloader = App.reloader - @State private var requiredDummyState: Void = () #endif // mark `dynamic` to be replaced runtime From c7fccdc818c0716c56ec8dc1f3312c71ae1265d5 Mon Sep 17 00:00:00 2001 From: banjun Date: Thu, 2 Nov 2023 20:32:14 +0900 Subject: [PATCH 03/17] add initial README --- README.md | 116 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..09df494 --- /dev/null +++ b/README.md @@ -0,0 +1,116 @@ +# SwiftHotReload +[![CI](https://github.com/banjun/SwiftHotReload/actions/workflows/main.yml/badge.svg)](https://github.com/banjun/SwiftHotReload/actions/workflows/main.yml) + +Hot reload on Swift app using `@_dynamicReplacement` + +## 🚧 Concept Implementation + +SwiftHotReload is an experimental project. We investigate a real world application of the `@_dynamicReplacement` feature of Swift 5.1+. Many portions are subject to change, including the library name (it's simple & naive name. we don't plan to publish to `CocoaPods/Specs` before resolving them.) + +## Supported Platforms + +* Xcode 15.x +* Host macOS 13.x, 14.x +* Runtime macOS app +* Runtime simulators for iOS, iPadOS, and possibly visionOS + +## Features + +* Monitor a swift file for trigger a build (standalone, run on the app runtime process) +* Build a swift file and emit dylib (standalone, run on the app runtime process) + * Estimate build environmentd and intermediate interfaces +* Load a dylib while the app on runtime +* Supports apps on macOS and simulators for iOS, iPadOS, and possibly visionOS + * SPM project structures + * CocoaPods project structures +* Update trigger for SwiftUI views + +### TODOs (not yet implemented, nice to have) + +* Helper app on host +* Reload on devices +* Less invasive: be easy to adopt & compatible for App Store submission + * Build settings (-Xfrontend ...) + * Sandbox restrictions for macOS app +* Load history +* In-place editing + +## How to use the Example app + +* Open `SwiftHotReload.xcworkspace` +* Modify `targetSwiftFile:` file path to along with your path in `App.swift` +* Run `SwiftHotReloadExample` on Mac or any simulators +* Edit `ReplaceView.swift` and save + +## Install + +SPM + +``` +https://github.com/banjun/SwiftHotReload.git +``` + +CocoaPods + +``` +pod 'SwiftHotReload', :git => "https://github.com/banjun/SwiftHotReload.git", :branch => "main" +``` + +or manual copy + +## App Implementations & Settings + +Set up app as described below and build & run on a supported platform. + +### Set a target swift file to be monitored: + +```swift +extension App { + static let reloader = Reloader(.init( + // file path to be monitored + targetSwiftFile: Env.shared.estimatedHomeDir! + .appendingPathComponent("path_to_project/RuntimeOverrides.swift") + )) + : + reloader.install() // start a file monitor +} +``` + +### Disable sandbox (only required for macOS app target): + +Modify the app entitlements file: + +``` +App Sandbox = NO +``` + +### (Optionally but recommended) set build settings: + +* Add to `OTHER_SWIFT_FLAGS` of the app target + * `-Xfrontend` `-enable-implicit-dynamic` + * use the flag instead of explicitly marking `dynamic` before `func`s or `var`s + * `-Xfrontend` `-enable-private-imports` + * use the flag instead of making related `func`s or `var`s visible by removing `private` + +### Create `path_to_project/RuntimeOverrides.swift`: + +Any funcs/vars can be replaced (not only for SwiftUI). + +```swift +import AppModuleName + +extension ContentView { // <- typically use extension for a type containing func/var to be replaced + @_dynamicReplacement(for: body) // <- func/var name to be replaced + var body2: some View { // <- use different name than the original + : + } +} +``` + +### (Optionally) to update SwiftUI view after reloadings: + +```swift +@ObservedObject private var reloader = App.reloader +``` + + From 0c8af9aa5a746616c3f430f68c4fe224f96ce4d5 Mon Sep 17 00:00:00 2001 From: banjun Date: Tue, 7 Nov 2023 21:40:35 +0900 Subject: [PATCH 04/17] separate Builder & FileMonitor from Reloader to relax runtime requirements for each tasks --- Example/App.swift | 13 +- Example/ContentView.swift | 1 + .../SwiftHotReload.xcodeproj/project.pbxproj | 20 +- Sources/Builder.swift | 113 ++++++++++++ Sources/FileMonitor.swift | 52 ++++++ Sources/Loader.swift | 25 +++ Sources/Reloader.swift | 172 ------------------ Sources/StandaloneReloader.swift | 54 ++++++ 8 files changed, 265 insertions(+), 185 deletions(-) create mode 100644 Sources/Builder.swift create mode 100644 Sources/FileMonitor.swift create mode 100644 Sources/Loader.swift delete mode 100644 Sources/Reloader.swift create mode 100644 Sources/StandaloneReloader.swift diff --git a/Example/App.swift b/Example/App.swift index 2141e0b..dd5d474 100644 --- a/Example/App.swift +++ b/Example/App.swift @@ -15,15 +15,10 @@ struct App: SwiftUI.App { #if DEBUG // see also ReplaceView.swift - static let reloader: Reloader = { - let reloader = Reloader(.init( - targetSwiftFile: Env.shared.estimatedHomeDir! - .appendingPathComponent("projects/github/SwiftHotReload") - .appendingPathComponent("Example/ReplaceView.swift") - )) - reloader.install() - return reloader - }() + static let reloader = StandaloneReloader(monitoredSwiftFile: Env.shared.estimatedHomeDir! + .appendingPathComponent("projects/github/SwiftHotReload") + .appendingPathComponent("Example/ReplaceView.swift") + ) #endif var body: some Scene { diff --git a/Example/ContentView.swift b/Example/ContentView.swift index 467a8c7..bd843ad 100644 --- a/Example/ContentView.swift +++ b/Example/ContentView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import SwiftHotReload // see also ReplaceView.swift struct ContentView: View { diff --git a/FrameworkTarget/SwiftHotReload.xcodeproj/project.pbxproj b/FrameworkTarget/SwiftHotReload.xcodeproj/project.pbxproj index c4b5eb7..cced618 100644 --- a/FrameworkTarget/SwiftHotReload.xcodeproj/project.pbxproj +++ b/FrameworkTarget/SwiftHotReload.xcodeproj/project.pbxproj @@ -9,7 +9,10 @@ /* Begin PBXBuildFile section */ 630C245E2AEBD4E10012C490 /* TargetSwiftFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 630C245B2AEBD4E10012C490 /* TargetSwiftFile.swift */; }; 630C245F2AEBD4E10012C490 /* Env.swift in Sources */ = {isa = PBXBuildFile; fileRef = 630C245C2AEBD4E10012C490 /* Env.swift */; }; - 630C24602AEBD4E10012C490 /* Reloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 630C245D2AEBD4E10012C490 /* Reloader.swift */; }; + 63458ABD2AFA622E001A5630 /* StandaloneReloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63458ABC2AFA622E001A5630 /* StandaloneReloader.swift */; }; + 63458ABF2AFA6232001A5630 /* Loader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63458ABE2AFA6232001A5630 /* Loader.swift */; }; + 63458AC32AFA6247001A5630 /* Builder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63458AC22AFA6247001A5630 /* Builder.swift */; }; + 63458AC42AFA6247001A5630 /* FileMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63458AC12AFA6247001A5630 /* FileMonitor.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -17,7 +20,10 @@ 630C24522AEBD4780012C490 /* SwiftHotReloadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftHotReloadTests.swift; sourceTree = ""; }; 630C245B2AEBD4E10012C490 /* TargetSwiftFile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TargetSwiftFile.swift; sourceTree = ""; }; 630C245C2AEBD4E10012C490 /* Env.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Env.swift; sourceTree = ""; }; - 630C245D2AEBD4E10012C490 /* Reloader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Reloader.swift; sourceTree = ""; }; + 63458ABC2AFA622E001A5630 /* StandaloneReloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StandaloneReloader.swift; sourceTree = ""; }; + 63458ABE2AFA6232001A5630 /* Loader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Loader.swift; sourceTree = ""; }; + 63458AC12AFA6247001A5630 /* FileMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileMonitor.swift; sourceTree = ""; }; + 63458AC22AFA6247001A5630 /* Builder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Builder.swift; sourceTree = ""; }; 63980F712AEA93310099B122 /* SwiftHotReload.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SwiftHotReload.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -36,7 +42,10 @@ isa = PBXGroup; children = ( 630C245C2AEBD4E10012C490 /* Env.swift */, - 630C245D2AEBD4E10012C490 /* Reloader.swift */, + 63458ABC2AFA622E001A5630 /* StandaloneReloader.swift */, + 63458AC12AFA6247001A5630 /* FileMonitor.swift */, + 63458AC22AFA6247001A5630 /* Builder.swift */, + 63458ABE2AFA6232001A5630 /* Loader.swift */, 630C245B2AEBD4E10012C490 /* TargetSwiftFile.swift */, ); name = Sources; @@ -151,8 +160,11 @@ buildActionMask = 2147483647; files = ( 630C245E2AEBD4E10012C490 /* TargetSwiftFile.swift in Sources */, - 630C24602AEBD4E10012C490 /* Reloader.swift in Sources */, + 63458ABD2AFA622E001A5630 /* StandaloneReloader.swift in Sources */, + 63458AC42AFA6247001A5630 /* FileMonitor.swift in Sources */, 630C245F2AEBD4E10012C490 /* Env.swift in Sources */, + 63458ABF2AFA6232001A5630 /* Loader.swift in Sources */, + 63458AC32AFA6247001A5630 /* Builder.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/Builder.swift b/Sources/Builder.swift new file mode 100644 index 0000000..faaecb1 --- /dev/null +++ b/Sources/Builder.swift @@ -0,0 +1,113 @@ +#if DEBUG +import Foundation + +final actor Builder { + private let targetSwiftFile: URL + private let derivedData: URL + private let moduleCachePath: URL + private let confBuildDir: URL + private let headerSearchPaths: [URL] + private let headerMaps: [URL] + private let buildDir: URL + private let targetTriple: String + private let sdk: URL + private let arch: String + private let platformName: String + + enum Error: Swift.Error { + case cannotBuildOnRuntime(String?) + case noSuchFile(URL) + case swiftcFailure(Int?) + } + + init(targetSwiftFile: URL, env: Env = .shared, derivedData: URL? = nil, confBuildDirAppRandomString: String? = nil, mainModule: String? = nil, modules: [String] = [], configurationPlatform: String? = nil, arch: String? = nil, targetTriple: String? = nil, sdk: URL? = nil, platformName: String? = nil) { + self.targetSwiftFile = targetSwiftFile + let derivedData = derivedData ?? env.estimataedDerivedData! + self.derivedData = derivedData + self.moduleCachePath = derivedData.appendingPathComponent("ModuleCache.noindex") + let confBuildDirAppRandomString = confBuildDirAppRandomString ?? env.estimatedConfigurationBuildRandomString! + let mainModule = mainModule ?? env.estimatedMainModule! + let intermediatesDir = derivedData + .appendingPathComponent(confBuildDirAppRandomString) + .appendingPathComponent("Build/Intermediates.noindex") + let configurationPlatform = configurationPlatform ?? env.estimatedConfigurationPlatform! + let confBuildDir = intermediatesDir + .appendingPathComponent(mainModule + ".build") + .appendingPathComponent(configurationPlatform) + self.confBuildDir = confBuildDir + let arch = arch ?? env.estimatedArch + self.arch = arch + self.headerSearchPaths = ([mainModule] + modules).map { + confBuildDir + .appendingPathComponent($0 + ".build") + .appendingPathComponent("Objects-normal") + .appendingPathComponent(arch) + } + self.headerMaps = [confBuildDir + .appendingPathComponent(mainModule + ".build") + .appendingPathComponent("\(mainModule)-project-headers.hmap") + ] + [intermediatesDir + .appendingPathComponent("Pods" + ".build") + .appendingPathComponent(configurationPlatform) + .appendingPathComponent("Pods-\(mainModule)" + ".build") + .appendingPathComponent("Pods_\(mainModule)-project-headers.hmap") + ] + self.buildDir = headerSearchPaths.first! + self.targetTriple = targetTriple ?? env.estimatedTargetTriple! + self.sdk = sdk ?? env.estimatedSDK! + self.platformName = platformName ?? env.DTPlatformName! + } + + func build(dylibFilename: String) throws -> URL { + let dylibPath = buildDir.appendingPathComponent(dylibFilename) + try build(dylibPath: dylibPath) + return dylibPath + } + + func build(dylibPath: URL) throws { + guard platformName != "iphoneos" else { + NSLog("%@", "🍓 ⚠️ To do hot reloads, the process host should be able to execute swiftc. cancelled building the target swift file. ⚠️") + throw Error.cannotBuildOnRuntime(platformName) + } + + guard let file = try? TargetSwiftFile(targetSwiftFile) else { throw Error.noSuchFile(targetSwiftFile) } + let importedModuleSearchPaths = file.importedModules.map { + confBuildDir + .appendingPathComponent($0 + ".build") + .appendingPathComponent("Objects-normal") + .appendingPathComponent(arch) + } + + let NSTask: AnyClass = NSClassFromString("NSTask")! + // NSLog("%@", "🍓 NSTask = \(NSTask)") + let task = NSTask.value(forKey: "new")! as! NSObject + // NSLog("%@", "🍓 task = \(task)") + task.setValue([:], forKey: "environment") + let launchPath = "/usr/bin/swiftc" + task.setValue(launchPath, forKey: "launchPath") + let args: [String] = [ + ["-emit-library"], // generates dylib + [targetSwiftFile.path], + ["-o", dylibPath.path], + ["-sdk", sdk.path], + ["-target", targetTriple], + ["-module-cache-path", moduleCachePath.path], // required in some cases + ["-Xlinker", "-undefined", "-Xlinker", "suppress"], // avoid fatal error on the linker + ["-Xfrontend", "-disable-access-control"], // with this, internal symbols can be used + ["-Xlinker", "-flat_namespace"], // for Xcode 14 (unneeded for Xcode 15) + (headerSearchPaths + importedModuleSearchPaths).flatMap { ["-I", $0.path] }, + headerMaps.flatMap { ["-Xcc", "-I", "-Xcc", $0.path] } + ].flatMap { $0 } + task.setValue(args, forKey: "arguments") + NSLog("%@", "🍓 exec and args = ") + print("\(launchPath) \(args.joined(separator: " "))") + task.value(forKey: "launch") + task.value(forKey: "waitUntilExit") + + let terminationStatus = task.value(forKey: "terminationStatus") as? Int + // NSLog("%@", "🍓 terminationStatus = \(String(describing: terminationStatus))") + guard terminationStatus == 0 else { throw Error.swiftcFailure(terminationStatus) } + } +} + +#endif diff --git a/Sources/FileMonitor.swift b/Sources/FileMonitor.swift new file mode 100644 index 0000000..b7f8c37 --- /dev/null +++ b/Sources/FileMonitor.swift @@ -0,0 +1,52 @@ +#if DEBUG +import Foundation + +final actor FileMonitor { + private let file: URL + @Published private(set) var fileChanges: Date? + + private var monitor: DispatchSourceFileSystemObject? { + didSet { + oldValue?.cancel() + if let monitor { + monitor.resume() + } + } + } + + private var lastTargetFileContent: String? + + init(file: URL, platformName: String) { + self.file = file + + guard platformName != "iphoneos" else { + NSLog("%@", "🍓 ⚠️ To do hot reloads, the process host should be able to execute swiftc. cancelled installing the file monitor. ⚠️") + return + } + + Task { + await install() + } + } + + private func install() { + let handle = FileHandle(forReadingAtPath: file.path) + monitor = handle.map { DispatchSource.makeFileSystemObjectSource(fileDescriptor: $0.fileDescriptor, eventMask: .all) } + monitor?.setEventHandler { [unowned self] in + let content = try? TargetSwiftFile(file).content + guard content != lastTargetFileContent else { + // NSLog("%@", "🍓 target file change detected but same content. ignored.") + return + } + NSLog("%@", "🍓 target file change detected") + lastTargetFileContent = content + fileChanges = Date() + + self.monitor = nil + handle?.closeFile() + self.install() + } + } +} + +#endif diff --git a/Sources/Loader.swift b/Sources/Loader.swift new file mode 100644 index 0000000..099ec9d --- /dev/null +++ b/Sources/Loader.swift @@ -0,0 +1,25 @@ +#if DEBUG +import Foundation + +final actor Loader { + enum Error: Swift.Error { + case symbol_not_found_in_flat_namespace(String) + case unknown(String) + } + + func load(dylibPath: URL) throws { + let handle = dlopen(dylibPath.path, RTLD_NOW) + NSLog("%@", "🍓 dlopen handle = \(String(describing: handle))") + guard handle != nil else { + let error = String(cString: dlerror()) + NSLog("%@", "🍓 dlerror = \(error)") + if error.contains("symbol not found in flat namespace") { + NSLog("%@", "🍓 possible workarounds: remove `private` from the func, or add `-Xfrontend -enable-private-imports` to OTHER_SWIFT_FLAGS of the module to be overridden") + throw Error.symbol_not_found_in_flat_namespace(error) + } + throw Error.unknown(error) + } + } +} + +#endif diff --git a/Sources/Reloader.swift b/Sources/Reloader.swift deleted file mode 100644 index 4c75ba2..0000000 --- a/Sources/Reloader.swift +++ /dev/null @@ -1,172 +0,0 @@ -#if DEBUG -import Foundation - -public final class Reloader: ObservableObject { - public static var shared: Reloader? - - @Published public private(set) var dateReloaded: Date? - - private let core: Core - public actor Core { - private var counter: Int = 0 - - let targetSwiftFile: URL - private let derivedData: URL - private let moduleCachePath: URL - private let confBuildDir: URL - private let headerSearchPaths: [URL] - private let headerMaps: [URL] - private let buildDir: URL - private let targetTriple: String - private let sdk: URL - private let arch: String - - public init(derivedData: URL = Env.shared.estimataedDerivedData!, targetSwiftFile: URL, confBuildDirAppRandomString: String = Env.shared.estimatedConfigurationBuildRandomString!, mainModule: String = Env.shared.estimatedMainModule!, modules: [String] = [], configurationPlatform: String = Env.shared.estimatedConfigurationPlatform!, arch: String = Env.shared.estimatedArch, targetTriple: String = Env.shared.estimatedTargetTriple!, sdk: URL = Env.shared.estimatedSDK!) { - self.targetSwiftFile = targetSwiftFile - self.derivedData = derivedData - self.moduleCachePath = derivedData.appendingPathComponent("ModuleCache.noindex") - let intermediatesDir = derivedData - .appendingPathComponent(confBuildDirAppRandomString) - .appendingPathComponent("Build/Intermediates.noindex") - let confBuildDir = intermediatesDir - .appendingPathComponent(mainModule + ".build") - .appendingPathComponent(configurationPlatform) - self.confBuildDir = confBuildDir - self.headerSearchPaths = ([mainModule] + modules).map { - confBuildDir - .appendingPathComponent($0 + ".build") - .appendingPathComponent("Objects-normal") - .appendingPathComponent(arch) - } - self.headerMaps = [confBuildDir - .appendingPathComponent(mainModule + ".build") - .appendingPathComponent("\(mainModule)-project-headers.hmap") - ] + [intermediatesDir - .appendingPathComponent("Pods" + ".build") - .appendingPathComponent(configurationPlatform) - .appendingPathComponent("Pods-\(mainModule)" + ".build") - .appendingPathComponent("Pods_\(mainModule)-project-headers.hmap") - ] - self.buildDir = headerSearchPaths.first! - self.targetTriple = targetTriple - self.sdk = sdk - self.arch = arch - } - - func reload() -> Bool { - counter += 1 - - let dylibPath = buildDir.appendingPathComponent("HotReload\(counter).dylib") - return build(dylibPath: dylibPath) - && load(dylibPath: dylibPath) - } - - private func build(dylibPath: URL) -> Bool { - guard Env.shared.DTPlatformName != "iphoneos" else { - NSLog("%@", "🍓 ⚠️ To do hot reloads, the process host should be able to execute swiftc. cancelled building the target swift file. ⚠️") - return false - } - - guard let file = try? TargetSwiftFile(targetSwiftFile) else { return false } - let importedModuleSearchPaths = file.importedModules.map { - confBuildDir - .appendingPathComponent($0 + ".build") - .appendingPathComponent("Objects-normal") - .appendingPathComponent(arch) - } - - let NSTask: AnyClass = NSClassFromString("NSTask")! - // NSLog("%@", "🍓 NSTask = \(NSTask)") - let task = NSTask.value(forKey: "new")! as! NSObject - // NSLog("%@", "🍓 task = \(task)") - task.setValue([:], forKey: "environment") - let launchPath = "/usr/bin/swiftc" - task.setValue(launchPath, forKey: "launchPath") - let args: [String] = [ - ["-emit-library"], // generates dylib - [targetSwiftFile.path], - ["-o", dylibPath.path], - ["-sdk", sdk.path], - ["-target", targetTriple], - ["-module-cache-path", moduleCachePath.path], // required in some cases - ["-Xlinker", "-undefined", "-Xlinker", "suppress"], // avoid fatal error on the linker - ["-Xfrontend", "-disable-access-control"], // with this, internal symbols can be used - ["-Xlinker", "-flat_namespace"], // for Xcode 14 (unneeded for Xcode 15) - (headerSearchPaths + importedModuleSearchPaths).flatMap { ["-I", $0.path] }, - headerMaps.flatMap { ["-Xcc", "-I", "-Xcc", $0.path] } - ].flatMap { $0 } - task.setValue(args, forKey: "arguments") - NSLog("%@", "🍓 exec and args = ") - print("\(launchPath) \(args.joined(separator: " "))") - task.value(forKey: "launch") - task.value(forKey: "waitUntilExit") - - let terminationStatus = task.value(forKey: "terminationStatus") as? Int - // NSLog("%@", "🍓 terminationStatus = \(String(describing: terminationStatus))") - return terminationStatus == 0 - } - - private func load(dylibPath: URL) -> Bool { - let handle = dlopen(dylibPath.path, RTLD_NOW) - NSLog("%@", "🍓 dlopen handle = \(String(describing: handle))") - if handle != nil { - return true - } else { - let error = String(cString: dlerror()) - NSLog("%@", "🍓 dlerror = \(error)") - if error.contains("symbol not found in flat namespace") { - NSLog("%@", "🍓 possible workarounds: remove `private` from the func, or add `-Xfrontend -enable-private-imports` to OTHER_SWIFT_FLAGS of the module to be overridden") - } - return false - } - } - } - - public init(_ core: Core) { - self.core = core - } - - private var monitor: DispatchSourceFileSystemObject? { - didSet { - oldValue?.cancel() - if let monitor { - monitor.resume() - } - } - } - - private var lastTargetFileContent: String? - - public func install() { - guard Env.shared.DTPlatformName != "iphoneos" else { - NSLog("%@", "🍓 ⚠️ To do hot reloads, the process host should be able to execute swiftc. cancelled installing the file monitor. ⚠️") - return - } - - let handle = FileHandle(forReadingAtPath: core.targetSwiftFile.path) - monitor = handle.map { DispatchSource.makeFileSystemObjectSource(fileDescriptor: $0.fileDescriptor, eventMask: .all) } - monitor?.setEventHandler { [unowned self] in - let content = try? TargetSwiftFile(core.targetSwiftFile).content - guard content != lastTargetFileContent else { - // NSLog("%@", "🍓 target file change detected but same content. ignored.") - return - } - NSLog("%@", "🍓 target file change detected") - lastTargetFileContent = content - self.reload() - - self.monitor = nil - handle?.closeFile() - self.install() - } - } - - public func reload() { - Task { @MainActor in - if await core.reload() { - dateReloaded = Date() - } - } - } -} -#endif diff --git a/Sources/StandaloneReloader.swift b/Sources/StandaloneReloader.swift new file mode 100644 index 0000000..5809650 --- /dev/null +++ b/Sources/StandaloneReloader.swift @@ -0,0 +1,54 @@ +#if DEBUG +import Foundation +import Combine + +public final class StandaloneReloader: ObservableObject { + private let fileMonitor: FileMonitor + private let core: Core + + @Published public private(set) var dateReloaded: Date? + private var cancellables: Set = [] + + public init(monitoredSwiftFile: URL, env: Env = .shared, derivedData: URL? = nil, confBuildDirAppRandomString: String? = nil, mainModule: String? = nil, modules: [String] = [], configurationPlatform: String? = nil, arch: String? = nil, targetTriple: String? = nil, sdk: URL? = nil, platformName: String? = nil) { + if env.DTPlatformName == "iphoneos" { + NSLog("%@", "🍓 ⚠️ To do hot reloads standalone, the process host should be able to execute swiftc. ⚠️") + } + + fileMonitor = .init(file: monitoredSwiftFile, platformName: platformName ?? env.DTPlatformName!) + core = .init(builder: .init(targetSwiftFile: monitoredSwiftFile, env: env, derivedData: derivedData, confBuildDirAppRandomString: confBuildDirAppRandomString, mainModule: mainModule, modules: modules, configurationPlatform: configurationPlatform, arch: arch, targetTriple: targetTriple, sdk: sdk, platformName: platformName), loader: .init()) + + Task { + await fileMonitor.$fileChanges.compactMap {$0}.sink { [weak self] _ in + self?.reload() + }.store(in: &cancellables) + } + } + + public actor Core { + private var counter: Int = 0 + + private let builder: Builder + private let loader: Loader + + init(builder: Builder, loader: Loader) { + self.builder = builder + self.loader = loader + } + + func reload() async throws { + counter += 1 + + let dylibPath = try await builder.build(dylibFilename: "HotReload\(counter).dylib") + try await loader.load(dylibPath: dylibPath) + } + } + + public func reload() { + Task { @MainActor in + try await core.reload() + dateReloaded = Date() + } + } +} + +#endif From 0c6213e3c2ee4d1d83719373ef07f31802132be4 Mon Sep 17 00:00:00 2001 From: banjun Date: Wed, 8 Nov 2023 13:09:18 +0900 Subject: [PATCH 05/17] update README for StandaloneReloader --- README.md | 12 ++++++------ SwiftHotReload.xcworkspace/contents.xcworkspacedata | 3 +++ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 09df494..abc6645 100644 --- a/README.md +++ b/README.md @@ -66,13 +66,13 @@ Set up app as described below and build & run on a supported platform. ```swift extension App { - static let reloader = Reloader(.init( - // file path to be monitored - targetSwiftFile: Env.shared.estimatedHomeDir! - .appendingPathComponent("path_to_project/RuntimeOverrides.swift") - )) + static let reloader = StandaloneReloader( + // file path to be monitored + monitoredSwiftFile: Env.shared.estimatedHomeDir! + .appendingPathComponent("path_to_project/RuntimeOverrides.swift") + ) : - reloader.install() // start a file monitor + _ = App.reloader // use to load the lazy static property above and start a file monitor } ``` diff --git a/SwiftHotReload.xcworkspace/contents.xcworkspacedata b/SwiftHotReload.xcworkspace/contents.xcworkspacedata index d34edcd..84efcd4 100644 --- a/SwiftHotReload.xcworkspace/contents.xcworkspacedata +++ b/SwiftHotReload.xcworkspace/contents.xcworkspacedata @@ -4,6 +4,9 @@ + + From c323bc9df17c625644f3f812e10488d5d5d3609c Mon Sep 17 00:00:00 2001 From: banjun Date: Wed, 8 Nov 2023 20:56:47 +0900 Subject: [PATCH 06/17] git now --- Example/App.swift | 19 +- Example/ContentView.swift | 2 +- Example/SwiftHotReloadExample-Info.plist | 11 + .../project.pbxproj | 12 +- .../SwiftHotReload.xcodeproj/project.pbxproj | 8 +- Sources/Builder.swift | 6 +- Sources/Env.swift | 4 +- Sources/Proxy.swift | 365 ++++++++++++++++++ 8 files changed, 414 insertions(+), 13 deletions(-) create mode 100644 Example/SwiftHotReloadExample-Info.plist create mode 100644 Sources/Proxy.swift diff --git a/Example/App.swift b/Example/App.swift index dd5d474..0b4db5f 100644 --- a/Example/App.swift +++ b/Example/App.swift @@ -15,15 +15,26 @@ struct App: SwiftUI.App { #if DEBUG // see also ReplaceView.swift - static let reloader = StandaloneReloader(monitoredSwiftFile: Env.shared.estimatedHomeDir! - .appendingPathComponent("projects/github/SwiftHotReload") - .appendingPathComponent("Example/ReplaceView.swift") - ) +// static let reloader = StandaloneReloader(monitoredSwiftFile: Env.shared.estimatedHomeDir! +// .appendingPathComponent("projects/github/SwiftHotReload") +// .appendingPathComponent("Example/ReplaceView.swift") +// ) + static let reloader = ProxyReloader() + static let builderHelper = BuildHelper(monitoredSwiftFile: Env.shared.estimatedHomeDir! + .appendingPathComponent("projects/github/SwiftHotReload") + .appendingPathComponent("Example/ReplaceView.swift")) #endif var body: some Scene { WindowGroup { ContentView() + .onAppear { +#if os(iOS) + _ = App.reloader // load lazy type property +#else + _ = App.builderHelper // load lazy type property +#endif + } } } } diff --git a/Example/ContentView.swift b/Example/ContentView.swift index bd843ad..e5d2f93 100644 --- a/Example/ContentView.swift +++ b/Example/ContentView.swift @@ -22,7 +22,7 @@ struct ContentView: View { VStack { Image(systemName: "globe") .imageScale(.large) - .foregroundStyle(.tint) +// .foregroundStyle(.tint) Text("Hello, world!") } .padding() diff --git a/Example/SwiftHotReloadExample-Info.plist b/Example/SwiftHotReloadExample-Info.plist new file mode 100644 index 0000000..4ada98b --- /dev/null +++ b/Example/SwiftHotReloadExample-Info.plist @@ -0,0 +1,11 @@ + + + + + NSBonjourServices + + _swifthotreload._tcp + _swifthotreload._udp + + + diff --git a/Example/SwiftHotReloadExample.xcodeproj/project.pbxproj b/Example/SwiftHotReloadExample.xcodeproj/project.pbxproj index c5fbc62..e546dca 100644 --- a/Example/SwiftHotReloadExample.xcodeproj/project.pbxproj +++ b/Example/SwiftHotReloadExample.xcodeproj/project.pbxproj @@ -30,6 +30,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 6355B1112AFB8BE9008C4C50 /* SwiftHotReloadExample-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "SwiftHotReloadExample-Info.plist"; sourceTree = ""; }; 63980F432AEA8DA50099B122 /* SwiftHotReloadExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SwiftHotReloadExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 63980F462AEA8DA50099B122 /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = ""; }; 63980F482AEA8DA50099B122 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -55,6 +56,7 @@ 63980F3A2AEA8DA50099B122 = { isa = PBXGroup; children = ( + 6355B1112AFB8BE9008C4C50 /* SwiftHotReloadExample-Info.plist */, 63980F462AEA8DA50099B122 /* App.swift */, 63980F482AEA8DA50099B122 /* ContentView.swift */, 63980F872AEA95F10099B122 /* ReplaceView.swift */, @@ -296,8 +298,11 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Preview Content\""; + DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "SwiftHotReloadExample-Info.plist"; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "SwiftHotReload Proxy via MultipeerConnectivity"; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; @@ -308,7 +313,7 @@ "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 13.5; @@ -333,8 +338,11 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Preview Content\""; + DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "SwiftHotReloadExample-Info.plist"; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "SwiftHotReload Proxy via MultipeerConnectivity"; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; @@ -345,7 +353,7 @@ "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 13.5; diff --git a/FrameworkTarget/SwiftHotReload.xcodeproj/project.pbxproj b/FrameworkTarget/SwiftHotReload.xcodeproj/project.pbxproj index cced618..acfcdb1 100644 --- a/FrameworkTarget/SwiftHotReload.xcodeproj/project.pbxproj +++ b/FrameworkTarget/SwiftHotReload.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 63458ABF2AFA6232001A5630 /* Loader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63458ABE2AFA6232001A5630 /* Loader.swift */; }; 63458AC32AFA6247001A5630 /* Builder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63458AC22AFA6247001A5630 /* Builder.swift */; }; 63458AC42AFA6247001A5630 /* FileMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63458AC12AFA6247001A5630 /* FileMonitor.swift */; }; + 6355B0F52AFB6899008C4C50 /* Proxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6355B0F42AFB6899008C4C50 /* Proxy.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -24,6 +25,7 @@ 63458ABE2AFA6232001A5630 /* Loader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Loader.swift; sourceTree = ""; }; 63458AC12AFA6247001A5630 /* FileMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileMonitor.swift; sourceTree = ""; }; 63458AC22AFA6247001A5630 /* Builder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Builder.swift; sourceTree = ""; }; + 6355B0F42AFB6899008C4C50 /* Proxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Proxy.swift; sourceTree = ""; }; 63980F712AEA93310099B122 /* SwiftHotReload.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SwiftHotReload.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -44,6 +46,7 @@ 630C245C2AEBD4E10012C490 /* Env.swift */, 63458ABC2AFA622E001A5630 /* StandaloneReloader.swift */, 63458AC12AFA6247001A5630 /* FileMonitor.swift */, + 6355B0F42AFB6899008C4C50 /* Proxy.swift */, 63458AC22AFA6247001A5630 /* Builder.swift */, 63458ABE2AFA6232001A5630 /* Loader.swift */, 630C245B2AEBD4E10012C490 /* TargetSwiftFile.swift */, @@ -162,6 +165,7 @@ 630C245E2AEBD4E10012C490 /* TargetSwiftFile.swift in Sources */, 63458ABD2AFA622E001A5630 /* StandaloneReloader.swift in Sources */, 63458AC42AFA6247001A5630 /* FileMonitor.swift in Sources */, + 6355B0F52AFB6899008C4C50 /* Proxy.swift in Sources */, 630C245F2AEBD4E10012C490 /* Env.swift in Sources */, 63458ABF2AFA6232001A5630 /* Loader.swift in Sources */, 63458AC32AFA6247001A5630 /* Builder.swift in Sources */, @@ -305,7 +309,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "@executable_path/Frameworks", "@loader_path/Frameworks", @@ -345,7 +349,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "@executable_path/Frameworks", "@loader_path/Frameworks", diff --git a/Sources/Builder.swift b/Sources/Builder.swift index faaecb1..997f32b 100644 --- a/Sources/Builder.swift +++ b/Sources/Builder.swift @@ -10,7 +10,7 @@ final actor Builder { private let headerMaps: [URL] private let buildDir: URL private let targetTriple: String - private let sdk: URL + private let sdk: URL? private let arch: String private let platformName: String @@ -54,7 +54,7 @@ final actor Builder { ] self.buildDir = headerSearchPaths.first! self.targetTriple = targetTriple ?? env.estimatedTargetTriple! - self.sdk = sdk ?? env.estimatedSDK! + self.sdk = sdk ?? env.estimatedSDK self.platformName = platformName ?? env.DTPlatformName! } @@ -89,7 +89,7 @@ final actor Builder { ["-emit-library"], // generates dylib [targetSwiftFile.path], ["-o", dylibPath.path], - ["-sdk", sdk.path], + sdk.map {["-sdk", $0.path]} ?? [], ["-target", targetTriple], ["-module-cache-path", moduleCachePath.path], // required in some cases ["-Xlinker", "-undefined", "-Xlinker", "suppress"], // avoid fatal error on the linker diff --git a/Sources/Env.swift b/Sources/Env.swift index c34628c..fea22ab 100644 --- a/Sources/Env.swift +++ b/Sources/Env.swift @@ -1,7 +1,7 @@ #if DEBUG import Foundation -public struct Env { +public struct Env: Codable { public static let shared: Env = .init() /// /Users/username @@ -116,6 +116,7 @@ public struct Env { var MinimumOSVersion: String? var LSMinimumSystemVersion: String? var CFBundleExecutable: String? + var CFBundleIdentifier: String? private init() { let env = ProcessInfo().environment @@ -139,6 +140,7 @@ public struct Env { MinimumOSVersion = info["MinimumOSVersion"] as? String LSMinimumSystemVersion = info["LSMinimumSystemVersion"] as? String CFBundleExecutable = info["CFBundleExecutable"] as? String + CFBundleIdentifier = info["CFBundleIdentifier"] as? String } } #endif diff --git a/Sources/Proxy.swift b/Sources/Proxy.swift new file mode 100644 index 0000000..6d85ed5 --- /dev/null +++ b/Sources/Proxy.swift @@ -0,0 +1,365 @@ +#if DEBUG +import Foundation +import MultipeerConnectivity + +public final class ProxyReloader: ObservableObject { + private let proxy: Proxy = .init() + + @Published public private(set) var dateReloaded: Date? + + public init() { + Task { + await proxy.$receivedDylibFiles.map {_ in Date() }.assign(to: &$dateReloaded) + await proxy.start() + } + } +} + +import Combine +public final class BuildHelper { + private let fileMonitor: FileMonitor + private let proxyBrowser = ProxyBrowser() + private let core: Core + + @Published public private(set) var dateReloaded: Date? + private var cancellables: Set = [] + + public init(monitoredSwiftFile: URL, env: Env = .shared, derivedData: URL? = nil, confBuildDirAppRandomString: String? = nil, mainModule: String? = nil, modules: [String] = [], configurationPlatform: String? = nil, arch: String? = nil, targetTriple: String? = nil, sdk: URL? = nil, platformName: String? = nil) { + if env.DTPlatformName == "iphoneos" { + NSLog("%@", "🍓 ⚠️ To do hot reloads standalone, the process host should be able to execute swiftc. ⚠️") + } + + fileMonitor = .init(file: monitoredSwiftFile, platformName: platformName ?? env.DTPlatformName!) + // TODO: core environments are differ for each peer targets. + core = .init(builder: .init(targetSwiftFile: monitoredSwiftFile, env: env, derivedData: derivedData, confBuildDirAppRandomString: confBuildDirAppRandomString, mainModule: mainModule, modules: modules, configurationPlatform: configurationPlatform, arch: arch, targetTriple: targetTriple, sdk: sdk, platformName: platformName)) + + Task { + await fileMonitor.$fileChanges.compactMap {$0}.sink { [weak self] _ in + self?.reload() + }.store(in: &cancellables) + + await proxyBrowser.$route.sink { [weak self] route in + guard let self else { return } + Task { await self.core.setRoute(route) } + }.store(in: &cancellables) + } + } + + private actor Core { + private var counter: Int = 0 + + private var builder: Builder + private var route: (session: MCSession, server: MCPeerID, env: Env?)? + + init(builder: Builder) { + self.builder = builder + } + + func setRoute(_ route: (session: MCSession, server: MCPeerID, env: Env?)?) { + self.route = route +// self.builder = // TODO: builder parameters should be detemined after target build env is received... + } + + func reload() async throws { + counter += 1 + + let dylibPath = try await builder.build(dylibFilename: "HotReload\(counter).dylib") + guard let session = route?.session, let server = route?.server else { return } + try await withCheckedThrowingContinuation { (c: CheckedContinuation) in + session.sendResource(at: dylibPath, withName: dylibPath.lastPathComponent, toPeer: server) { error in + if let error { c.resume(throwing: error) } + else { c.resume() } + } + } + } + } + + public func reload() { + Task { @MainActor in + try await core.reload() + dateReloaded = Date() + } + } + +} + +final actor ProxyBrowser { + @Published private(set) var route: (session: MCSession, server: MCPeerID, env: Env?)? { + didSet { + route?.session.delegate = sessionDelegate + } + } + private let peerID: MCPeerID + private let browser: MCNearbyServiceBrowser + private let browserDelegate: BrowserDelegate + private let sessionDelegate: SessionDelegate = .init() + + init(hostName: String = ProcessInfo().hostName, bundleID: String = Env.shared.CFBundleIdentifier!, processID: Int32 = ProcessInfo().processIdentifier) { + let displayName = String("Client[\(hostName)] \(bundleID)(\(processID))".utf8.prefix(63))! + self.peerID = MCPeerID(displayName: displayName) + self.browser = MCNearbyServiceBrowser(peer: peerID, serviceType: Proxy.MultipeerConnectivityConstants.serviceType) + self.browserDelegate = BrowserDelegate() + + self.browser.delegate = browserDelegate + Task { + browserDelegate.owner = self + sessionDelegate.owner = self + await start() + } + } + + // MARK: - MCNearbyServiceBrowserDelegate + + private final class BrowserDelegate: NSObject, MCNearbyServiceBrowserDelegate { + unowned var owner: ProxyBrowser? + override init() { super.init() } + + func browser(_ browser: MCNearbyServiceBrowser, foundPeer peerID: MCPeerID, withDiscoveryInfo info: [String : String]?) { + Task { await owner?.browser(browser, foundPeer: peerID, withDiscoveryInfo: info) } + } + + func browser(_ browser: MCNearbyServiceBrowser, lostPeer peerID: MCPeerID) { + Task { await owner?.browser(browser, lostPeer: peerID) } + } + } + + func browser(_ browser: MCNearbyServiceBrowser, foundPeer peerID: MCPeerID, withDiscoveryInfo info: [String : String]?) { + NSLog("%@", "🍓 \(#function) peerID = \(peerID), info = \(String(describing: info))") + guard info == Proxy.MultipeerConnectivityConstants.serverDiscoveryInfo else { + NSLog("%@", "🍓 \(#function) ignore peer \(peerID) as it's not a server") + return + } + guard route == nil else { + NSLog("%@", "🍓 \(#function) ⚠️ TODO: support mutiple sessions") + return + } + + NSLog("%@", "🍓 \(#function) ⚠️ TODO: some auth to refrain from sending secret dylib to the unidentified server") + let session = MCSession(peer: self.peerID, securityIdentity: nil, encryptionPreference: .required) + self.browser.invitePeer(peerID, to: session, withContext: nil, timeout: 30) + route = (session, peerID, nil) + } + + func browser(_ browser: MCNearbyServiceBrowser, lostPeer peerID: MCPeerID) { + NSLog("%@", "🍓 \(#function) peerID = \(peerID)") + if route?.server == peerID { + route = nil + } + } + + // MARK: - MCSessionDelegate + + private final class SessionDelegate: NSObject, MCSessionDelegate { + unowned var owner: ProxyBrowser? + override init() { super.init() } + + func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState) { + NSLog("%@", "🍓 \(#function) peerID = \(peerID), state = \(state)") + } + + func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) { + NSLog("%@", "🍓 \(#function) data = \(data.count) bytes, peerID = \(peerID)") + Task { await owner?.session(session, didReceive: data, fromPeer: peerID) } + } + + func session(_ session: MCSession, didReceive stream: InputStream, withName streamName: String, fromPeer peerID: MCPeerID) { + NSLog("%@", "🍓 \(#function) stream = \(stream), streamName = \(streamName), peerID = \(peerID)") + } + + func session(_ session: MCSession, didStartReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, with progress: Progress) { + NSLog("%@", "🍓 \(#function) resourceName = \(resourceName), peerID = \(peerID), progress = \(progress)") + } + + func session(_ session: MCSession, didFinishReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, at localURL: URL?, withError error: Swift.Error?) { + NSLog("%@", "🍓 \(#function) resourceName = \(resourceName), peerID = \(peerID), localURL = \(String(describing: localURL)), error = \(String(describing: error))") + } + } + + func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) { + do { + let env = try JSONDecoder().decode(Env.self, from: data) + NSLog("%@", "🍓 \(#function) TODO: use env when build for the session: \(env)") + } catch { + NSLog("%@", "🍓 \(#function) error = \(error)") + } + } + + // MARK: - + + func start() { + browser.startBrowsingForPeers() + } + + func stop() { + browser.stopBrowsingForPeers() + } +} + +final actor Proxy { + private let loader: Loader = .init() + @Published private(set) var receivedDylibFiles: [URL] = [] + + private let hostName: String + private let bundleID: String + private let processID: Int32 + + private let peerID: MCPeerID + private let advertiser: MCNearbyServiceAdvertiser + private var session: MCSession? { + didSet { + session?.delegate = sessionDelegate + } + } + private let advertiserDelegate: AdvertiserDelegate + private let sessionDelegate: SessionDelegate + + enum MultipeerConnectivityConstants { + /// MultipeerConnectivity service type + /// The type of service to advertise. This should be a short text string that describes the app's networking protocol, in the same format as a Bonjour service type (without the transport protocol) and meeting the restrictions of RFC 6335 (section 5.1) governing Service Name Syntax. In particular, the string: + /// * Must be 1–15 characters long + /// * Can contain only ASCII lowercase letters, numbers, and hyphens + /// * Must contain at least one ASCII letter + /// * Must not begin or end with a hyphen + /// * Must not contain hyphens adjacent to other hyphens. + static let serviceType = "swifthotreload" + static let serverDiscoveryInfo: [String: String] = ["SwiftHotReloadServer": "1"] + } + + enum Error: Swift.Error { + case invalidFilePath(String) + case fileAlreadyExists(String) + } + + init(hostName: String = ProcessInfo().hostName, bundleID: String = Env.shared.CFBundleIdentifier!, processID: Int32 = ProcessInfo().processIdentifier) { + self.hostName = hostName + self.bundleID = bundleID + self.processID = processID + // the doc: The display name is intended for use in UI elements, and should be short and descriptive of the local peer. The maximum allowable length is 63 bytes in UTF-8 encoding. The displayName parameter may not be nil or an empty string. + let displayName = String("Server[\(hostName)] \(bundleID)(\(processID))".utf8.prefix(63))! + self.peerID = MCPeerID(displayName: displayName) + self.advertiser = MCNearbyServiceAdvertiser(peer: peerID, discoveryInfo: MultipeerConnectivityConstants.serverDiscoveryInfo, serviceType: MultipeerConnectivityConstants.serviceType) + self.advertiserDelegate = AdvertiserDelegate() + self.sessionDelegate = SessionDelegate() + + self.advertiser.delegate = self.advertiserDelegate + + Task { + advertiserDelegate.proxy = self + sessionDelegate.proxy = self + await start() + } + } + + func start() { + advertiser.startAdvertisingPeer() + } + + func stop() { + advertiser.stopAdvertisingPeer() + session?.disconnect() + } + + // MARK: - MCNearbyServiceAdvertiserDelegate + + private final class AdvertiserDelegate: NSObject, MCNearbyServiceAdvertiserDelegate { + unowned var proxy: Proxy? + override init() { super.init() } + func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didReceiveInvitationFromPeer peerID: MCPeerID, withContext context: Data?, invitationHandler: @escaping (Bool, MCSession?) -> Void) { + NSLog("%@", "🍓 \(#function) advertiser = \(advertiser), peerID = \(peerID), context = \(context?.count ?? 0) bytes") + Task { await proxy?.advertiser(advertiser, didReceiveInvitationFromPeer: peerID, withContext: context, invitationHandler: invitationHandler) } + } + func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didNotStartAdvertisingPeer error: Swift.Error) { + NSLog("%@", "🍓 \(#function) advertiser = \(advertiser), error = \(error)") + Task { await proxy?.advertiser(advertiser, didNotStartAdvertisingPeer: error) } + } + } + + private func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didReceiveInvitationFromPeer peerID: MCPeerID, withContext context: Data?, invitationHandler: @escaping (Bool, MCSession?) -> Void) { + NSLog("%@", "🍓 \(#function) advertiser = \(advertiser), peerID = \(peerID), context = \(context?.count ?? 0) bytes") + guard session == nil else { return } + + let session = MCSession(peer: self.peerID, securityIdentity: nil, encryptionPreference: .required) + self.session = session + + NSLog("%@", "🍓 \(#function) ⚠️ TODO: some auth to refrain from loading dylibs sent from the unidentified build helper") + invitationHandler(true, session) // TODO: some auth + } + + private func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didNotStartAdvertisingPeer error: Swift.Error) { + NSLog("%@", "🍓 \(#function) advertiser = \(advertiser), error = \(error)") + } + + // MARK: - MCSessionDelegate + + private final class SessionDelegate: NSObject, MCSessionDelegate { + unowned var proxy: Proxy? + override init() { super.init() } + + func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState) { + NSLog("%@", "🍓 \(#function) peerID = \(peerID), state = \(state)") + Task { await proxy?.session(session, peer: peerID, didChange: state) } + } + + func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) { + NSLog("%@", "🍓 \(#function) data = \(data.count) bytes, peerID = \(peerID)") + } + + func session(_ session: MCSession, didReceive stream: InputStream, withName streamName: String, fromPeer peerID: MCPeerID) { + NSLog("%@", "🍓 \(#function) stream = \(stream), streamName = \(streamName), peerID = \(peerID)") + } + + func session(_ session: MCSession, didStartReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, with progress: Progress) { + NSLog("%@", "🍓 \(#function) resourceName = \(resourceName), peerID = \(peerID), progress = \(progress)") + } + + func session(_ session: MCSession, didFinishReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, at localURL: URL?, withError error: Swift.Error?) { + NSLog("%@", "🍓 \(#function) resourceName = \(resourceName), peerID = \(peerID), localURL = \(String(describing: localURL)), error = \(String(describing: error))") + Task { await proxy?.session(session, didFinishReceivingResourceWithName: resourceName, fromPeer: peerID, at: localURL, withError: error) } + } + } + + private func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState) { + switch state { + case .notConnected: break + case .connecting: break + case .connected: + do { + let payload = try JSONEncoder().encode(Env.shared) // TODO: use parameterized env, or minimal data required for build + try self.session?.send(payload, toPeers: [peerID], with: .reliable) + } catch { + NSLog("%@", "🍓 \(#function) error = \(error)") + } + @unknown default: break + } + } + + private func session(_ session: MCSession, didFinishReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, at localURL: URL?, withError error: Swift.Error?) { + guard let localURL else { return } + let tmpDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("SwiftHotReload") + .appendingPathComponent("dylibs") + guard let filename = resourceName.components(separatedBy: "/").last else { return } // { throw Error.invalidFilePath(resourceName) } + let tmpDylibPath = tmpDir.appendingPathComponent(filename) + + do { + try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true) + guard !FileManager.default.fileExists(atPath: tmpDylibPath.path) else { return } // { throw Error.fileAlreadyExists(tmpDylibPath.path) } + + try FileManager.default.copyItem(at: localURL, to: tmpDylibPath) + } catch { + NSLog("%@", "🍓 \(#function) line \(#line) error = \(error)") + } + Task { + do { + try await loader.load(dylibPath: tmpDylibPath) + receivedDylibFiles.append(tmpDylibPath) + } catch { + NSLog("%@", "🍓 \(#function) line \(#line) error = \(error)") + } + } + } + + // MARK: - +} + +#endif From 3d5de9bd50f4dc8373aa2f58e1567790dcafe3b6 Mon Sep 17 00:00:00 2001 From: banjun Date: Fri, 10 Nov 2023 15:45:50 +0900 Subject: [PATCH 07/17] working example --- .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 58 +++ BuildHelper/Assets.xcassets/Contents.json | 6 + BuildHelper/BuildHelper.entitlements | 14 + .../BuildHelper.xcodeproj/project.pbxproj | 368 ++++++++++++++++++ BuildHelper/BuildHelperApp.swift | 20 + BuildHelper/ContentView.swift | 21 + BuildHelper/Info.plist | 11 + .../Preview Assets.xcassets/Contents.json | 6 + Example/App.swift | 45 +-- .../SwiftHotReload.xcodeproj/project.pbxproj | 76 +++- Sources/BuildHelper/BuildHelper.swift | 83 ++++ Sources/BuildHelper/ProxyBlowser.swift | 122 ++++++ Sources/Builder.swift | 113 ------ Sources/Core/Builder.swift | 150 +++++++ Sources/{ => Core}/Env.swift | 5 +- Sources/{ => Core}/FileMonitor.swift | 4 +- Sources/{ => Core}/Loader.swift | 1 + Sources/Core/NSTaskCommand.swift | 32 ++ Sources/{ => Core}/TargetSwiftFile.swift | 0 Sources/Proxy.swift | 365 ----------------- .../MultipeerConnectivityConstants.swift | 13 + Sources/ProxyReloader/Proxy.swift | 158 ++++++++ Sources/ProxyReloader/ProxyReloader.swift | 19 + Sources/ProxyReloader/RuntimePeer.swift | 13 + .../StandaloneReloader.swift | 4 +- .../contents.xcworkspacedata | 3 + 27 files changed, 1202 insertions(+), 519 deletions(-) create mode 100644 BuildHelper/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 BuildHelper/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 BuildHelper/Assets.xcassets/Contents.json create mode 100644 BuildHelper/BuildHelper.entitlements create mode 100644 BuildHelper/BuildHelper.xcodeproj/project.pbxproj create mode 100644 BuildHelper/BuildHelperApp.swift create mode 100644 BuildHelper/ContentView.swift create mode 100644 BuildHelper/Info.plist create mode 100644 BuildHelper/Preview Content/Preview Assets.xcassets/Contents.json create mode 100644 Sources/BuildHelper/BuildHelper.swift create mode 100644 Sources/BuildHelper/ProxyBlowser.swift delete mode 100644 Sources/Builder.swift create mode 100644 Sources/Core/Builder.swift rename Sources/{ => Core}/Env.swift (95%) rename Sources/{ => Core}/FileMonitor.swift (93%) rename Sources/{ => Core}/Loader.swift (79%) create mode 100644 Sources/Core/NSTaskCommand.swift rename Sources/{ => Core}/TargetSwiftFile.swift (100%) delete mode 100644 Sources/Proxy.swift create mode 100644 Sources/ProxyReloader/MultipeerConnectivityConstants.swift create mode 100644 Sources/ProxyReloader/Proxy.swift create mode 100644 Sources/ProxyReloader/ProxyReloader.swift create mode 100644 Sources/ProxyReloader/RuntimePeer.swift rename Sources/{ => StandaloneReloader}/StandaloneReloader.swift (78%) diff --git a/BuildHelper/Assets.xcassets/AccentColor.colorset/Contents.json b/BuildHelper/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/BuildHelper/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BuildHelper/Assets.xcassets/AppIcon.appiconset/Contents.json b/BuildHelper/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..3f00db4 --- /dev/null +++ b/BuildHelper/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,58 @@ +{ + "images" : [ + { + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BuildHelper/Assets.xcassets/Contents.json b/BuildHelper/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/BuildHelper/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BuildHelper/BuildHelper.entitlements b/BuildHelper/BuildHelper.entitlements new file mode 100644 index 0000000..997a18c --- /dev/null +++ b/BuildHelper/BuildHelper.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-only + + com.apple.security.network.client + + com.apple.security.network.server + + + diff --git a/BuildHelper/BuildHelper.xcodeproj/project.pbxproj b/BuildHelper/BuildHelper.xcodeproj/project.pbxproj new file mode 100644 index 0000000..4ec7f9c --- /dev/null +++ b/BuildHelper/BuildHelper.xcodeproj/project.pbxproj @@ -0,0 +1,368 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + 63A7469A2AFDD742003FA3AC /* BuildHelperApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63A746992AFDD742003FA3AC /* BuildHelperApp.swift */; }; + 63A7469C2AFDD742003FA3AC /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63A7469B2AFDD742003FA3AC /* ContentView.swift */; }; + 63A7469E2AFDD748003FA3AC /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 63A7469D2AFDD748003FA3AC /* Assets.xcassets */; }; + 63A746A12AFDD748003FA3AC /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 63A746A02AFDD748003FA3AC /* Preview Assets.xcassets */; }; + 63A746B12AFDD8F4003FA3AC /* SwiftHotReload.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 63A746B02AFDD8F4003FA3AC /* SwiftHotReload.framework */; }; + 63A746B22AFDD8F4003FA3AC /* SwiftHotReload.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 63A746B02AFDD8F4003FA3AC /* SwiftHotReload.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; +/* End PBXBuildFile section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 63A746B32AFDD8F4003FA3AC /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 63A746B22AFDD8F4003FA3AC /* SwiftHotReload.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 63A746962AFDD742003FA3AC /* BuildHelper.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BuildHelper.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 63A746992AFDD742003FA3AC /* BuildHelperApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildHelperApp.swift; sourceTree = ""; }; + 63A7469B2AFDD742003FA3AC /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 63A7469D2AFDD748003FA3AC /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 63A746A02AFDD748003FA3AC /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + 63A746A22AFDD748003FA3AC /* BuildHelper.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = BuildHelper.entitlements; sourceTree = ""; }; + 63A746AE2AFDD7DC003FA3AC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 63A746B02AFDD8F4003FA3AC /* SwiftHotReload.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = SwiftHotReload.framework; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 63A746932AFDD742003FA3AC /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 63A746B12AFDD8F4003FA3AC /* SwiftHotReload.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 63A7468D2AFDD742003FA3AC = { + isa = PBXGroup; + children = ( + 63A746AE2AFDD7DC003FA3AC /* Info.plist */, + 63A746992AFDD742003FA3AC /* BuildHelperApp.swift */, + 63A7469B2AFDD742003FA3AC /* ContentView.swift */, + 63A7469D2AFDD748003FA3AC /* Assets.xcassets */, + 63A746A22AFDD748003FA3AC /* BuildHelper.entitlements */, + 63A7469F2AFDD748003FA3AC /* Preview Content */, + 63A746972AFDD742003FA3AC /* Products */, + 63A746AF2AFDD8F4003FA3AC /* Frameworks */, + ); + sourceTree = ""; + }; + 63A746972AFDD742003FA3AC /* Products */ = { + isa = PBXGroup; + children = ( + 63A746962AFDD742003FA3AC /* BuildHelper.app */, + ); + name = Products; + sourceTree = ""; + }; + 63A7469F2AFDD748003FA3AC /* Preview Content */ = { + isa = PBXGroup; + children = ( + 63A746A02AFDD748003FA3AC /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; + 63A746AF2AFDD8F4003FA3AC /* Frameworks */ = { + isa = PBXGroup; + children = ( + 63A746B02AFDD8F4003FA3AC /* SwiftHotReload.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 63A746952AFDD742003FA3AC /* BuildHelper */ = { + isa = PBXNativeTarget; + buildConfigurationList = 63A746A52AFDD748003FA3AC /* Build configuration list for PBXNativeTarget "BuildHelper" */; + buildPhases = ( + 63A746922AFDD742003FA3AC /* Sources */, + 63A746932AFDD742003FA3AC /* Frameworks */, + 63A746942AFDD742003FA3AC /* Resources */, + 63A746B32AFDD8F4003FA3AC /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = BuildHelper; + productName = BuildHelper; + productReference = 63A746962AFDD742003FA3AC /* BuildHelper.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 63A7468E2AFDD742003FA3AC /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1500; + LastUpgradeCheck = 1500; + TargetAttributes = { + 63A746952AFDD742003FA3AC = { + CreatedOnToolsVersion = 15.0.1; + }; + }; + }; + buildConfigurationList = 63A746912AFDD742003FA3AC /* Build configuration list for PBXProject "BuildHelper" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 63A7468D2AFDD742003FA3AC; + productRefGroup = 63A746972AFDD742003FA3AC /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 63A746952AFDD742003FA3AC /* BuildHelper */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 63A746942AFDD742003FA3AC /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 63A746A12AFDD748003FA3AC /* Preview Assets.xcassets in Resources */, + 63A7469E2AFDD748003FA3AC /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 63A746922AFDD742003FA3AC /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 63A7469C2AFDD742003FA3AC /* ContentView.swift in Sources */, + 63A7469A2AFDD742003FA3AC /* BuildHelperApp.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 63A746A32AFDD748003FA3AC /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 13.5; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 63A746A42AFDD748003FA3AC /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 13.5; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + }; + name = Release; + }; + 63A746A62AFDD748003FA3AC /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = BuildHelper.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Info.plist; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Build and send executables for nearby devices"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = jp.banjun.SwiftHotReload.BuildHelper; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 63A746A72AFDD748003FA3AC /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = BuildHelper.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Info.plist; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Build and send executables for nearby devices"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = jp.banjun.SwiftHotReload.BuildHelper; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 63A746912AFDD742003FA3AC /* Build configuration list for PBXProject "BuildHelper" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 63A746A32AFDD748003FA3AC /* Debug */, + 63A746A42AFDD748003FA3AC /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 63A746A52AFDD748003FA3AC /* Build configuration list for PBXNativeTarget "BuildHelper" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 63A746A62AFDD748003FA3AC /* Debug */, + 63A746A72AFDD748003FA3AC /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 63A7468E2AFDD742003FA3AC /* Project object */; +} diff --git a/BuildHelper/BuildHelperApp.swift b/BuildHelper/BuildHelperApp.swift new file mode 100644 index 0000000..76a8eef --- /dev/null +++ b/BuildHelper/BuildHelperApp.swift @@ -0,0 +1,20 @@ +import SwiftUI +import SwiftHotReload + +// NOTE: app sandbox is disabled to: +// - monitor any file changes (to trigger build a swift file) +// - run swiftc + +// TODO: user consent on each connection to peers + +@main +struct BuildHelperApp: App { + @ObservedObject private(set) var buildHelper = BuildHelper() + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(buildHelper) + } + } +} diff --git a/BuildHelper/ContentView.swift b/BuildHelper/ContentView.swift new file mode 100644 index 0000000..e7fe71b --- /dev/null +++ b/BuildHelper/ContentView.swift @@ -0,0 +1,21 @@ +import SwiftUI +import SwiftHotReload + +struct ContentView: View { + @EnvironmentObject var buildHelper: BuildHelper + + var body: some View { + VStack(spacing: 20) { + Text("Monitored File" + "\n" + (buildHelper.monitoredFile?.path ?? "Nothing")) + .multilineTextAlignment(.center) + + Text("Date Reloaded" + "\n" + (buildHelper.dateReloaded?.formatted(date: .numeric, time: .complete) ?? "Never")) + .multilineTextAlignment(.center) + } + .padding() + } +} + +#Preview { + ContentView() +} diff --git a/BuildHelper/Info.plist b/BuildHelper/Info.plist new file mode 100644 index 0000000..4ada98b --- /dev/null +++ b/BuildHelper/Info.plist @@ -0,0 +1,11 @@ + + + + + NSBonjourServices + + _swifthotreload._tcp + _swifthotreload._udp + + + diff --git a/BuildHelper/Preview Content/Preview Assets.xcassets/Contents.json b/BuildHelper/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/BuildHelper/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Example/App.swift b/Example/App.swift index 0b4db5f..6052a73 100644 --- a/Example/App.swift +++ b/Example/App.swift @@ -1,10 +1,3 @@ -// -// SwiftHotReloadExampleApp.swift -// SwiftHotReloadExample -// -// Created by BAN Jun on 2023/10/26. -// - import SwiftUI import SwiftHotReload @@ -12,29 +5,31 @@ import SwiftHotReload @main struct App: SwiftUI.App { - #if DEBUG - // see also ReplaceView.swift -// static let reloader = StandaloneReloader(monitoredSwiftFile: Env.shared.estimatedHomeDir! -// .appendingPathComponent("projects/github/SwiftHotReload") -// .appendingPathComponent("Example/ReplaceView.swift") -// ) - static let reloader = ProxyReloader() - static let builderHelper = BuildHelper(monitoredSwiftFile: Env.shared.estimatedHomeDir! - .appendingPathComponent("projects/github/SwiftHotReload") - .appendingPathComponent("Example/ReplaceView.swift")) + // For Simulators and macOS apps: + // just use StandaloneReloader + // + // For iPhone devices: + // use ProxyReloader while running BuildHelper.app on the host Mac + // + // See also `ReplaceView.swift` + // + // ↓ Change true/false to switch StandaloneReloader or ProxyReloader +#if true + // StandaloneReloader + static let reloader = StandaloneReloader(monitoredSwiftFile: URL(fileURLWithPath: #filePath).deletingLastPathComponent() + .appendingPathComponent("ReplaceView.swift") + ) +#else + // ProxyReloader + static let reloader = ProxyReloader(.init(targetSwiftFile: URL(fileURLWithPath: #filePath).deletingLastPathComponent() + .appendingPathComponent("ReplaceView.swift") + )) +#endif #endif - var body: some Scene { WindowGroup { ContentView() - .onAppear { -#if os(iOS) - _ = App.reloader // load lazy type property -#else - _ = App.builderHelper // load lazy type property -#endif - } } } } diff --git a/FrameworkTarget/SwiftHotReload.xcodeproj/project.pbxproj b/FrameworkTarget/SwiftHotReload.xcodeproj/project.pbxproj index acfcdb1..92df435 100644 --- a/FrameworkTarget/SwiftHotReload.xcodeproj/project.pbxproj +++ b/FrameworkTarget/SwiftHotReload.xcodeproj/project.pbxproj @@ -9,11 +9,17 @@ /* Begin PBXBuildFile section */ 630C245E2AEBD4E10012C490 /* TargetSwiftFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 630C245B2AEBD4E10012C490 /* TargetSwiftFile.swift */; }; 630C245F2AEBD4E10012C490 /* Env.swift in Sources */ = {isa = PBXBuildFile; fileRef = 630C245C2AEBD4E10012C490 /* Env.swift */; }; + 6323BB722AFCC1DA005E80DF /* NSTaskCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6323BB712AFCC1DA005E80DF /* NSTaskCommand.swift */; }; 63458ABD2AFA622E001A5630 /* StandaloneReloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63458ABC2AFA622E001A5630 /* StandaloneReloader.swift */; }; 63458ABF2AFA6232001A5630 /* Loader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63458ABE2AFA6232001A5630 /* Loader.swift */; }; 63458AC32AFA6247001A5630 /* Builder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63458AC22AFA6247001A5630 /* Builder.swift */; }; 63458AC42AFA6247001A5630 /* FileMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63458AC12AFA6247001A5630 /* FileMonitor.swift */; }; - 6355B0F52AFB6899008C4C50 /* Proxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6355B0F42AFB6899008C4C50 /* Proxy.swift */; }; + 6355B0F52AFB6899008C4C50 /* ProxyReloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6355B0F42AFB6899008C4C50 /* ProxyReloader.swift */; }; + 63A746B52AFE0122003FA3AC /* BuildHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63A746B42AFE0122003FA3AC /* BuildHelper.swift */; }; + 63A746B82AFE015C003FA3AC /* ProxyBlowser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63A746B72AFE015C003FA3AC /* ProxyBlowser.swift */; }; + 63A746BA2AFE0286003FA3AC /* RuntimePeer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63A746B92AFE0286003FA3AC /* RuntimePeer.swift */; }; + 63A746BC2AFE02EA003FA3AC /* Proxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63A746BB2AFE02EA003FA3AC /* Proxy.swift */; }; + 63A746C12AFE0700003FA3AC /* MultipeerConnectivityConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63A746C02AFE0700003FA3AC /* MultipeerConnectivityConstants.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -21,12 +27,18 @@ 630C24522AEBD4780012C490 /* SwiftHotReloadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftHotReloadTests.swift; sourceTree = ""; }; 630C245B2AEBD4E10012C490 /* TargetSwiftFile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TargetSwiftFile.swift; sourceTree = ""; }; 630C245C2AEBD4E10012C490 /* Env.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Env.swift; sourceTree = ""; }; + 6323BB712AFCC1DA005E80DF /* NSTaskCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSTaskCommand.swift; sourceTree = ""; }; 63458ABC2AFA622E001A5630 /* StandaloneReloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StandaloneReloader.swift; sourceTree = ""; }; 63458ABE2AFA6232001A5630 /* Loader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Loader.swift; sourceTree = ""; }; 63458AC12AFA6247001A5630 /* FileMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileMonitor.swift; sourceTree = ""; }; 63458AC22AFA6247001A5630 /* Builder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Builder.swift; sourceTree = ""; }; - 6355B0F42AFB6899008C4C50 /* Proxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Proxy.swift; sourceTree = ""; }; + 6355B0F42AFB6899008C4C50 /* ProxyReloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyReloader.swift; sourceTree = ""; }; 63980F712AEA93310099B122 /* SwiftHotReload.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SwiftHotReload.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 63A746B42AFE0122003FA3AC /* BuildHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildHelper.swift; sourceTree = ""; }; + 63A746B72AFE015C003FA3AC /* ProxyBlowser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyBlowser.swift; sourceTree = ""; }; + 63A746B92AFE0286003FA3AC /* RuntimePeer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuntimePeer.swift; sourceTree = ""; }; + 63A746BB2AFE02EA003FA3AC /* Proxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Proxy.swift; sourceTree = ""; }; + 63A746C02AFE0700003FA3AC /* MultipeerConnectivityConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultipeerConnectivityConstants.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -43,13 +55,10 @@ 630C244A2AEBD45B0012C490 /* Sources */ = { isa = PBXGroup; children = ( - 630C245C2AEBD4E10012C490 /* Env.swift */, - 63458ABC2AFA622E001A5630 /* StandaloneReloader.swift */, - 63458AC12AFA6247001A5630 /* FileMonitor.swift */, - 6355B0F42AFB6899008C4C50 /* Proxy.swift */, - 63458AC22AFA6247001A5630 /* Builder.swift */, - 63458ABE2AFA6232001A5630 /* Loader.swift */, - 630C245B2AEBD4E10012C490 /* TargetSwiftFile.swift */, + 63A746BD2AFE031C003FA3AC /* Core */, + 63A746BF2AFE034F003FA3AC /* StandaloneReloader */, + 63A746BE2AFE033F003FA3AC /* ProxyReloader */, + 63A746B62AFE0141003FA3AC /* BuildHelper */, ); name = Sources; path = ../Sources; @@ -82,6 +91,47 @@ name = Products; sourceTree = ""; }; + 63A746B62AFE0141003FA3AC /* BuildHelper */ = { + isa = PBXGroup; + children = ( + 63A746B42AFE0122003FA3AC /* BuildHelper.swift */, + 63A746B72AFE015C003FA3AC /* ProxyBlowser.swift */, + ); + path = BuildHelper; + sourceTree = ""; + }; + 63A746BD2AFE031C003FA3AC /* Core */ = { + isa = PBXGroup; + children = ( + 630C245C2AEBD4E10012C490 /* Env.swift */, + 63458AC12AFA6247001A5630 /* FileMonitor.swift */, + 63458AC22AFA6247001A5630 /* Builder.swift */, + 63458ABE2AFA6232001A5630 /* Loader.swift */, + 630C245B2AEBD4E10012C490 /* TargetSwiftFile.swift */, + 6323BB712AFCC1DA005E80DF /* NSTaskCommand.swift */, + ); + path = Core; + sourceTree = ""; + }; + 63A746BE2AFE033F003FA3AC /* ProxyReloader */ = { + isa = PBXGroup; + children = ( + 6355B0F42AFB6899008C4C50 /* ProxyReloader.swift */, + 63A746C02AFE0700003FA3AC /* MultipeerConnectivityConstants.swift */, + 63A746BB2AFE02EA003FA3AC /* Proxy.swift */, + 63A746B92AFE0286003FA3AC /* RuntimePeer.swift */, + ); + path = ProxyReloader; + sourceTree = ""; + }; + 63A746BF2AFE034F003FA3AC /* StandaloneReloader */ = { + isa = PBXGroup; + children = ( + 63458ABC2AFA622E001A5630 /* StandaloneReloader.swift */, + ); + path = StandaloneReloader; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -163,10 +213,16 @@ buildActionMask = 2147483647; files = ( 630C245E2AEBD4E10012C490 /* TargetSwiftFile.swift in Sources */, + 6323BB722AFCC1DA005E80DF /* NSTaskCommand.swift in Sources */, 63458ABD2AFA622E001A5630 /* StandaloneReloader.swift in Sources */, 63458AC42AFA6247001A5630 /* FileMonitor.swift in Sources */, - 6355B0F52AFB6899008C4C50 /* Proxy.swift in Sources */, + 63A746BC2AFE02EA003FA3AC /* Proxy.swift in Sources */, + 63A746BA2AFE0286003FA3AC /* RuntimePeer.swift in Sources */, + 6355B0F52AFB6899008C4C50 /* ProxyReloader.swift in Sources */, + 63A746B52AFE0122003FA3AC /* BuildHelper.swift in Sources */, + 63A746C12AFE0700003FA3AC /* MultipeerConnectivityConstants.swift in Sources */, 630C245F2AEBD4E10012C490 /* Env.swift in Sources */, + 63A746B82AFE015C003FA3AC /* ProxyBlowser.swift in Sources */, 63458ABF2AFA6232001A5630 /* Loader.swift in Sources */, 63458AC32AFA6247001A5630 /* Builder.swift in Sources */, ); diff --git a/Sources/BuildHelper/BuildHelper.swift b/Sources/BuildHelper/BuildHelper.swift new file mode 100644 index 0000000..d58a4b0 --- /dev/null +++ b/Sources/BuildHelper/BuildHelper.swift @@ -0,0 +1,83 @@ +#if os(macOS) +// NOTE: should not be submitted for App Store Review +// Release build is not disabled as BuildHelper.app is to be buildable for generating a mac helper app. +// TODO: BuildHelper may be separated into sub- spec/package +import Foundation +import Combine + +public final class BuildHelper: ObservableObject { + private let proxyBrowser = ProxyBrowser() + + @Published public private(set) var monitoredFile: URL? { + didSet { + fileMonitor = monitoredFile.map { FileMonitor(file: $0) } + } + } + private var fileMonitor: FileMonitor? { + didSet { + Task { + fileMonitorCancellable = await fileMonitor?.$fileChanges.compactMap {$0}.sink { [weak self] _ in + self?.reload() + } + } + } + } + private var fileMonitorCancellable: AnyCancellable? + private let core = Core() + + @Published public private(set) var dateReloaded: Date? + private var cancellables: Set = [] + + public init() { + Task { @MainActor in + await proxyBrowser.$runtimePeer.receive(on: DispatchQueue.main).sink { [weak self] runtimePeer in + guard let self else { return } + monitoredFile = runtimePeer?.builderParams?.targetSwiftFile + Task { await self.core.setRuntimePeer(runtimePeer) } + }.store(in: &cancellables) + } + } + + private actor Core { + private var counter: Int = 0 + + private var builder: Builder? + private var runtimePeer: RuntimePeer? { + didSet { + self.builder = runtimePeer?.builderParams.map(Builder.init) + } + } + + enum Error: Swift.Error { + case builderUninitialized + } + + init() {} + + func setRuntimePeer(_ runtimePeer: RuntimePeer?) { + self.runtimePeer = runtimePeer + } + + func reload() async throws { + guard let builder else { throw Error.builderUninitialized } + counter += 1 + + let dylibPath = try await builder.build(dylibFilename: "HotReload\(counter).dylib", codesignIdentity: "Apple Development: ..... ..... (..........)") // FIXME: hardcoded identity + guard let session = runtimePeer?.session, let server = runtimePeer?.peerID else { return } + try await withCheckedThrowingContinuation { (c: CheckedContinuation) in + session.sendResource(at: dylibPath, withName: dylibPath.lastPathComponent, toPeer: server) { error in + if let error { c.resume(throwing: error) } + else { c.resume() } + } + } + } + } + + public func reload() { + Task { @MainActor in + try await core.reload() + dateReloaded = Date() + } + } +} +#endif diff --git a/Sources/BuildHelper/ProxyBlowser.swift b/Sources/BuildHelper/ProxyBlowser.swift new file mode 100644 index 0000000..75b3583 --- /dev/null +++ b/Sources/BuildHelper/ProxyBlowser.swift @@ -0,0 +1,122 @@ +#if os(macOS) +// NOTE: should not be submitted for App Store Review +// Release build is not disabled as BuildHelper.app is to be buildable for generating a mac helper app. +// TODO: BuildHelper may be separated into sub- spec/package +import Foundation +import MultipeerConnectivity + +final actor ProxyBrowser { + @Published private(set) var runtimePeer: RuntimePeer? { + didSet { + runtimePeer?.session.delegate = sessionDelegate + } + } + private let peerID: MCPeerID + private let browser: MCNearbyServiceBrowser + private let browserDelegate: BrowserDelegate + private let sessionDelegate: SessionDelegate = .init() + + init(hostName: String = ProcessInfo().hostName, bundleID: String = Env.shared.CFBundleIdentifier!, processID: Int32 = ProcessInfo().processIdentifier) { + let displayName = String("Client[\(hostName)] \(bundleID)(\(processID))".utf8.prefix(63))! + self.peerID = MCPeerID(displayName: displayName) + self.browser = MCNearbyServiceBrowser(peer: peerID, serviceType: MultipeerConnectivityConstants.serviceType) + self.browserDelegate = BrowserDelegate() + + self.browser.delegate = browserDelegate + Task { + browserDelegate.owner = self + sessionDelegate.owner = self + await start() + } + } + + // MARK: - MCNearbyServiceBrowserDelegate + + private final class BrowserDelegate: NSObject, MCNearbyServiceBrowserDelegate { + unowned var owner: ProxyBrowser? + override init() { super.init() } + + func browser(_ browser: MCNearbyServiceBrowser, foundPeer peerID: MCPeerID, withDiscoveryInfo info: [String : String]?) { + Task { await owner?.browser(browser, foundPeer: peerID, withDiscoveryInfo: info) } + } + + func browser(_ browser: MCNearbyServiceBrowser, lostPeer peerID: MCPeerID) { + Task { await owner?.browser(browser, lostPeer: peerID) } + } + } + + func browser(_ browser: MCNearbyServiceBrowser, foundPeer peerID: MCPeerID, withDiscoveryInfo info: [String : String]?) { + NSLog("%@", "🍓 \(#function) peerID = \(peerID), info = \(String(describing: info))") + guard info == MultipeerConnectivityConstants.serverDiscoveryInfo else { + NSLog("%@", "🍓 \(#function) ignore peer \(peerID) as it's not a server") + return + } + guard runtimePeer == nil else { + NSLog("%@", "🍓 \(#function) ⚠️ TODO: support mutiple sessions") + return + } + + NSLog("%@", "🍓 \(#function) ⚠️ TODO: some auth to refrain from sending secret dylib to the unidentified server") + let session = MCSession(peer: self.peerID, securityIdentity: nil, encryptionPreference: .required) + self.browser.invitePeer(peerID, to: session, withContext: nil, timeout: 30) + runtimePeer = .init(session: session, peerID: peerID, builderParams: nil) + } + + func browser(_ browser: MCNearbyServiceBrowser, lostPeer peerID: MCPeerID) { + NSLog("%@", "🍓 \(#function) peerID = \(peerID)") + if runtimePeer?.peerID == peerID { + runtimePeer = nil + } + } + + // MARK: - MCSessionDelegate + + private final class SessionDelegate: NSObject, MCSessionDelegate { + unowned var owner: ProxyBrowser? + override init() { super.init() } + + func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState) { + NSLog("%@", "🍓 \(#function) peerID = \(peerID), state = \(state)") + } + + func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) { + NSLog("%@", "🍓 \(#function) data = \(data.count) bytes, peerID = \(peerID)") + Task { await owner?.session(session, didReceive: data, fromPeer: peerID) } + } + + func session(_ session: MCSession, didReceive stream: InputStream, withName streamName: String, fromPeer peerID: MCPeerID) { + NSLog("%@", "🍓 \(#function) stream = \(stream), streamName = \(streamName), peerID = \(peerID)") + } + + func session(_ session: MCSession, didStartReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, with progress: Progress) { + NSLog("%@", "🍓 \(#function) resourceName = \(resourceName), peerID = \(peerID), progress = \(progress)") + } + + func session(_ session: MCSession, didFinishReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, at localURL: URL?, withError error: Swift.Error?) { + NSLog("%@", "🍓 \(#function) resourceName = \(resourceName), peerID = \(peerID), localURL = \(String(describing: localURL)), error = \(String(describing: error))") + } + } + + func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) { + do { + let builderParams = try JSONDecoder().decode(Builder.InputParameters.self, from: data) + NSLog("%@", "🍓 \(#function) using received build parameters when build for the session: \(builderParams)") + var runtimePeer = runtimePeer + runtimePeer?.builderParams = builderParams + self.runtimePeer = runtimePeer + } catch { + NSLog("%@", "🍓 \(#function) error = \(error)") + } + } + + // MARK: - + + func start() { + browser.startBrowsingForPeers() + } + + func stop() { + browser.stopBrowsingForPeers() + } +} +#endif diff --git a/Sources/Builder.swift b/Sources/Builder.swift deleted file mode 100644 index 997f32b..0000000 --- a/Sources/Builder.swift +++ /dev/null @@ -1,113 +0,0 @@ -#if DEBUG -import Foundation - -final actor Builder { - private let targetSwiftFile: URL - private let derivedData: URL - private let moduleCachePath: URL - private let confBuildDir: URL - private let headerSearchPaths: [URL] - private let headerMaps: [URL] - private let buildDir: URL - private let targetTriple: String - private let sdk: URL? - private let arch: String - private let platformName: String - - enum Error: Swift.Error { - case cannotBuildOnRuntime(String?) - case noSuchFile(URL) - case swiftcFailure(Int?) - } - - init(targetSwiftFile: URL, env: Env = .shared, derivedData: URL? = nil, confBuildDirAppRandomString: String? = nil, mainModule: String? = nil, modules: [String] = [], configurationPlatform: String? = nil, arch: String? = nil, targetTriple: String? = nil, sdk: URL? = nil, platformName: String? = nil) { - self.targetSwiftFile = targetSwiftFile - let derivedData = derivedData ?? env.estimataedDerivedData! - self.derivedData = derivedData - self.moduleCachePath = derivedData.appendingPathComponent("ModuleCache.noindex") - let confBuildDirAppRandomString = confBuildDirAppRandomString ?? env.estimatedConfigurationBuildRandomString! - let mainModule = mainModule ?? env.estimatedMainModule! - let intermediatesDir = derivedData - .appendingPathComponent(confBuildDirAppRandomString) - .appendingPathComponent("Build/Intermediates.noindex") - let configurationPlatform = configurationPlatform ?? env.estimatedConfigurationPlatform! - let confBuildDir = intermediatesDir - .appendingPathComponent(mainModule + ".build") - .appendingPathComponent(configurationPlatform) - self.confBuildDir = confBuildDir - let arch = arch ?? env.estimatedArch - self.arch = arch - self.headerSearchPaths = ([mainModule] + modules).map { - confBuildDir - .appendingPathComponent($0 + ".build") - .appendingPathComponent("Objects-normal") - .appendingPathComponent(arch) - } - self.headerMaps = [confBuildDir - .appendingPathComponent(mainModule + ".build") - .appendingPathComponent("\(mainModule)-project-headers.hmap") - ] + [intermediatesDir - .appendingPathComponent("Pods" + ".build") - .appendingPathComponent(configurationPlatform) - .appendingPathComponent("Pods-\(mainModule)" + ".build") - .appendingPathComponent("Pods_\(mainModule)-project-headers.hmap") - ] - self.buildDir = headerSearchPaths.first! - self.targetTriple = targetTriple ?? env.estimatedTargetTriple! - self.sdk = sdk ?? env.estimatedSDK - self.platformName = platformName ?? env.DTPlatformName! - } - - func build(dylibFilename: String) throws -> URL { - let dylibPath = buildDir.appendingPathComponent(dylibFilename) - try build(dylibPath: dylibPath) - return dylibPath - } - - func build(dylibPath: URL) throws { - guard platformName != "iphoneos" else { - NSLog("%@", "🍓 ⚠️ To do hot reloads, the process host should be able to execute swiftc. cancelled building the target swift file. ⚠️") - throw Error.cannotBuildOnRuntime(platformName) - } - - guard let file = try? TargetSwiftFile(targetSwiftFile) else { throw Error.noSuchFile(targetSwiftFile) } - let importedModuleSearchPaths = file.importedModules.map { - confBuildDir - .appendingPathComponent($0 + ".build") - .appendingPathComponent("Objects-normal") - .appendingPathComponent(arch) - } - - let NSTask: AnyClass = NSClassFromString("NSTask")! - // NSLog("%@", "🍓 NSTask = \(NSTask)") - let task = NSTask.value(forKey: "new")! as! NSObject - // NSLog("%@", "🍓 task = \(task)") - task.setValue([:], forKey: "environment") - let launchPath = "/usr/bin/swiftc" - task.setValue(launchPath, forKey: "launchPath") - let args: [String] = [ - ["-emit-library"], // generates dylib - [targetSwiftFile.path], - ["-o", dylibPath.path], - sdk.map {["-sdk", $0.path]} ?? [], - ["-target", targetTriple], - ["-module-cache-path", moduleCachePath.path], // required in some cases - ["-Xlinker", "-undefined", "-Xlinker", "suppress"], // avoid fatal error on the linker - ["-Xfrontend", "-disable-access-control"], // with this, internal symbols can be used - ["-Xlinker", "-flat_namespace"], // for Xcode 14 (unneeded for Xcode 15) - (headerSearchPaths + importedModuleSearchPaths).flatMap { ["-I", $0.path] }, - headerMaps.flatMap { ["-Xcc", "-I", "-Xcc", $0.path] } - ].flatMap { $0 } - task.setValue(args, forKey: "arguments") - NSLog("%@", "🍓 exec and args = ") - print("\(launchPath) \(args.joined(separator: " "))") - task.value(forKey: "launch") - task.value(forKey: "waitUntilExit") - - let terminationStatus = task.value(forKey: "terminationStatus") as? Int - // NSLog("%@", "🍓 terminationStatus = \(String(describing: terminationStatus))") - guard terminationStatus == 0 else { throw Error.swiftcFailure(terminationStatus) } - } -} - -#endif diff --git a/Sources/Core/Builder.swift b/Sources/Core/Builder.swift new file mode 100644 index 0000000..31ef485 --- /dev/null +++ b/Sources/Core/Builder.swift @@ -0,0 +1,150 @@ +#if DEBUG +import Foundation + +public final actor Builder { + private let targetSwiftFile: URL + private let derivedData: URL + private let moduleCachePath: URL + private let confBuildDir: URL + private let headerSearchPaths: [URL] + private let headerMaps: [URL] + private let buildDir: URL + private let targetTriple: String + private let sdk: URL? + private let arch: String + private let platformName: String + + public struct InputParameters: Codable { + public var targetSwiftFile: URL + public var env: Env + public var derivedData: URL? + public var confBuildDirAppRandomString: String? + public var mainModule: String? + public var modules: [String] = [] + public var configurationPlatform: String? + public var arch: String? + public var targetTriple: String? + public var sdk: URL? + public var platformName: String? + + public init(targetSwiftFile: URL, env: Env = .shared, derivedData: URL? = nil, confBuildDirAppRandomString: String? = nil, mainModule: String? = nil, modules: [String] = [], configurationPlatform: String? = nil, arch: String? = nil, targetTriple: String? = nil, sdk: URL? = nil, platformName: String? = nil) { + self.targetSwiftFile = targetSwiftFile + self.env = env + self.derivedData = derivedData + self.confBuildDirAppRandomString = confBuildDirAppRandomString + self.mainModule = mainModule + self.modules = modules + self.configurationPlatform = configurationPlatform + self.arch = arch + self.targetTriple = targetTriple + self.sdk = sdk + self.platformName = platformName + } + } + + enum Error: Swift.Error { + case cannotBuildOnRuntime(String?) + case noSuchFile(URL) + case swiftcFailure(Int?) + } + + init(_ p: InputParameters) { + self.targetSwiftFile = p.targetSwiftFile + let derivedData = p.derivedData ?? p.env.estimataedDerivedData! + self.derivedData = derivedData + self.moduleCachePath = derivedData.appendingPathComponent("ModuleCache.noindex") + let confBuildDirAppRandomString = p.confBuildDirAppRandomString ?? p.env.estimatedConfigurationBuildRandomString! + let mainModule = p.mainModule ?? p.env.estimatedMainModule! + let intermediatesDir = derivedData + .appendingPathComponent(confBuildDirAppRandomString) + .appendingPathComponent("Build/Intermediates.noindex") + let configurationPlatform = p.configurationPlatform ?? p.env.estimatedConfigurationPlatform! + let confBuildDir = intermediatesDir + .appendingPathComponent(mainModule + ".build") + .appendingPathComponent(configurationPlatform) + self.confBuildDir = confBuildDir + let arch = p.arch ?? p.env.estimatedArch + self.arch = arch + self.headerSearchPaths = ([mainModule] + p.modules).map { + confBuildDir + .appendingPathComponent($0 + ".build") + .appendingPathComponent("Objects-normal") + .appendingPathComponent(arch) + } + self.headerMaps = [confBuildDir + .appendingPathComponent(mainModule + ".build") + .appendingPathComponent("\(mainModule)-project-headers.hmap") + ] + [intermediatesDir + .appendingPathComponent("Pods" + ".build") + .appendingPathComponent(configurationPlatform) + .appendingPathComponent("Pods-\(mainModule)" + ".build") + .appendingPathComponent("Pods_\(mainModule)-project-headers.hmap") + ] + self.buildDir = headerSearchPaths.first! + self.targetTriple = p.targetTriple ?? p.env.estimatedTargetTriple! + self.sdk = p.sdk ?? p.env.estimatedSDK ?? Env.shared.estimatedSDK + self.platformName = p.platformName ?? p.env.DTPlatformName! + } + + func build(dylibFilename: String, codesignIdentity: String? = nil) throws -> URL { + let dylibPath = buildDir.appendingPathComponent(dylibFilename) + try build(dylibPath: dylibPath) + if let codesignIdentity { + try codesign(dylibPath: dylibPath, codesignIdentity: codesignIdentity) + } + return dylibPath + } + + func build(dylibPath: URL) throws { + guard Env.shared.DTPlatformName != "iphoneos" else { + NSLog("%@", "🍓 ⚠️ To do hot reloads, the process host should be able to execute swiftc. cancelled building the target swift file. ⚠️") + throw Error.cannotBuildOnRuntime(platformName) + } + + guard let file = try? TargetSwiftFile(targetSwiftFile) else { throw Error.noSuchFile(targetSwiftFile) } + let importedModuleSearchPaths = file.importedModules.map { + confBuildDir + .appendingPathComponent($0 + ".build") + .appendingPathComponent("Objects-normal") + .appendingPathComponent(arch) + } + + let command = NSTaskCommand( + launchPath: "/usr/bin/xcrun", + args: [ + ["--sdk", platformName], // `xcrun --sdk iphoneos swiftc ...` to suppress `clang: warning: using sysroot for 'MacOSX' but targeting 'iPhone' [-Wincompatible-sysroot]` and to set correct VersionSDK for codesign + ["/usr/bin/swiftc"], + ["-emit-library"], // generates dylib + [targetSwiftFile.path], + ["-o", dylibPath.path], + sdk.flatMap { ["-sdk", $0.path] } ?? [], + ["-target", targetTriple], + ["-module-cache-path", moduleCachePath.path], // required in some cases + ["-Xlinker", "-undefined", "-Xlinker", "suppress"], // avoid fatal error on the linker + ["-Xfrontend", "-disable-access-control"], // with this, internal symbols can be used + ["-Xlinker", "-flat_namespace"], // for Xcode 14 (unneeded for Xcode 15) + (headerSearchPaths + importedModuleSearchPaths).flatMap { ["-I", $0.path] }, + headerMaps.flatMap { ["-Xcc", "-I", "-Xcc", $0.path] } + ].flatMap { $0 }) + + NSLog("%@", "🍓 exec and args = ") + print("\(command.launchPath) \(command.args.joined(separator: " "))") + + try command.run() + } + + /// codesign the dylib + /// + /// in case error message on a runtime device on dlopen: + /// > .dylib' not valid for use in process: mapped file has no cdhash, completely unsigned? Code has to be at least ad-hoc signed. + /// + /// ad-hoc sign is not valid for devices + func codesign(dylibPath: URL, codesignIdentity: String) throws { + let command = NSTaskCommand(launchPath: "/usr/bin/codesign", args: [ + "-f", "-s", codesignIdentity, dylibPath.path + ]) + try command.run() + } +} + +#endif diff --git a/Sources/Env.swift b/Sources/Core/Env.swift similarity index 95% rename from Sources/Env.swift rename to Sources/Core/Env.swift index fea22ab..036eb88 100644 --- a/Sources/Env.swift +++ b/Sources/Core/Env.swift @@ -1,7 +1,7 @@ #if DEBUG import Foundation -public struct Env: Codable { +public struct Env: Codable, Equatable { public static let shared: Env = .init() /// /Users/username @@ -10,7 +10,7 @@ public struct Env: Codable { } /// /Users/username/Library/Developer/Xcode/DerivedData/app-abcdefg0123456789/Build/Products/Debug-iphonesimulator var estimatedBuilProductsDir: [URL] { - DYLD_FRAMEWORK_PATH.map(URL.init(fileURLWithPath:)) + DYLD_FRAMEWORK_PATH.filter {!$0.isEmpty}.map(URL.init(fileURLWithPath:)) + [(__XPC_DYLD_FRAMEWORK_PATH ?? __XPC_DYLD_LIBRARY_PATH ?? __XCODE_BUILT_PRODUCTS_DIR_PATHS ?? __XPC_DYLD_LIBRARY_PATH ?? PWD).map(URL.init(fileURLWithPath:))].compactMap {$0} } /// /Users/username/Library/Developer/Xcode/DerivedData @@ -60,6 +60,7 @@ public struct Env: Codable { .reversed().drop {$0 != "Platforms"}.dropFirst().reversed() .joined(separator: "/") }).map(URL.init(fileURLWithPath:)) + ?? (self != .shared ? Env.shared.estimatedDeveloperDir : nil) // on iphoneos, developer dir is not available in env. use host env typically on macOS build helper } /// /Applications/Xcode1501.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator17.0.sdk public var estimatedSDK: URL? { diff --git a/Sources/FileMonitor.swift b/Sources/Core/FileMonitor.swift similarity index 93% rename from Sources/FileMonitor.swift rename to Sources/Core/FileMonitor.swift index b7f8c37..aacd26c 100644 --- a/Sources/FileMonitor.swift +++ b/Sources/Core/FileMonitor.swift @@ -16,10 +16,10 @@ final actor FileMonitor { private var lastTargetFileContent: String? - init(file: URL, platformName: String) { + init(file: URL) { self.file = file - guard platformName != "iphoneos" else { + guard Env.shared.DTPlatformName != "iphoneos" else { NSLog("%@", "🍓 ⚠️ To do hot reloads, the process host should be able to execute swiftc. cancelled installing the file monitor. ⚠️") return } diff --git a/Sources/Loader.swift b/Sources/Core/Loader.swift similarity index 79% rename from Sources/Loader.swift rename to Sources/Core/Loader.swift index 099ec9d..fc1cdbc 100644 --- a/Sources/Loader.swift +++ b/Sources/Core/Loader.swift @@ -17,6 +17,7 @@ final actor Loader { NSLog("%@", "🍓 possible workarounds: remove `private` from the func, or add `-Xfrontend -enable-private-imports` to OTHER_SWIFT_FLAGS of the module to be overridden") throw Error.symbol_not_found_in_flat_namespace(error) } + // TODO: code signature invalid: on device dylibs needs to be signed by Individual or Company identity (it cannot be verified by Personal nor Enterprise identity. see `amfid` process message on the device console) throw Error.unknown(error) } } diff --git a/Sources/Core/NSTaskCommand.swift b/Sources/Core/NSTaskCommand.swift new file mode 100644 index 0000000..1d33411 --- /dev/null +++ b/Sources/Core/NSTaskCommand.swift @@ -0,0 +1,32 @@ +#if DEBUG +import Foundation + +struct NSTaskCommand { + var launchPath: String + var args: [String] + + enum Error: Swift.Error { + case nsTaskUnavailable + case failureStatus(Int?) + } + + func run(clearEnvironments: Bool = true) throws { + let NSTask: AnyClass? = NSClassFromString("NSTask") + let task = NSTask?.value(forKey: "new") as? NSObject + guard let task else { throw Error.nsTaskUnavailable } + + if clearEnvironments { + task.setValue([:], forKey: "environment") + } + + task.setValue(launchPath, forKey: "launchPath") + task.setValue(args, forKey: "arguments") + + task.value(forKey: "launch") + task.value(forKey: "waitUntilExit") + + let terminationStatus = task.value(forKey: "terminationStatus") as? Int + guard terminationStatus == 0 else { throw Error.failureStatus(terminationStatus) } + } +} +#endif diff --git a/Sources/TargetSwiftFile.swift b/Sources/Core/TargetSwiftFile.swift similarity index 100% rename from Sources/TargetSwiftFile.swift rename to Sources/Core/TargetSwiftFile.swift diff --git a/Sources/Proxy.swift b/Sources/Proxy.swift deleted file mode 100644 index 6d85ed5..0000000 --- a/Sources/Proxy.swift +++ /dev/null @@ -1,365 +0,0 @@ -#if DEBUG -import Foundation -import MultipeerConnectivity - -public final class ProxyReloader: ObservableObject { - private let proxy: Proxy = .init() - - @Published public private(set) var dateReloaded: Date? - - public init() { - Task { - await proxy.$receivedDylibFiles.map {_ in Date() }.assign(to: &$dateReloaded) - await proxy.start() - } - } -} - -import Combine -public final class BuildHelper { - private let fileMonitor: FileMonitor - private let proxyBrowser = ProxyBrowser() - private let core: Core - - @Published public private(set) var dateReloaded: Date? - private var cancellables: Set = [] - - public init(monitoredSwiftFile: URL, env: Env = .shared, derivedData: URL? = nil, confBuildDirAppRandomString: String? = nil, mainModule: String? = nil, modules: [String] = [], configurationPlatform: String? = nil, arch: String? = nil, targetTriple: String? = nil, sdk: URL? = nil, platformName: String? = nil) { - if env.DTPlatformName == "iphoneos" { - NSLog("%@", "🍓 ⚠️ To do hot reloads standalone, the process host should be able to execute swiftc. ⚠️") - } - - fileMonitor = .init(file: monitoredSwiftFile, platformName: platformName ?? env.DTPlatformName!) - // TODO: core environments are differ for each peer targets. - core = .init(builder: .init(targetSwiftFile: monitoredSwiftFile, env: env, derivedData: derivedData, confBuildDirAppRandomString: confBuildDirAppRandomString, mainModule: mainModule, modules: modules, configurationPlatform: configurationPlatform, arch: arch, targetTriple: targetTriple, sdk: sdk, platformName: platformName)) - - Task { - await fileMonitor.$fileChanges.compactMap {$0}.sink { [weak self] _ in - self?.reload() - }.store(in: &cancellables) - - await proxyBrowser.$route.sink { [weak self] route in - guard let self else { return } - Task { await self.core.setRoute(route) } - }.store(in: &cancellables) - } - } - - private actor Core { - private var counter: Int = 0 - - private var builder: Builder - private var route: (session: MCSession, server: MCPeerID, env: Env?)? - - init(builder: Builder) { - self.builder = builder - } - - func setRoute(_ route: (session: MCSession, server: MCPeerID, env: Env?)?) { - self.route = route -// self.builder = // TODO: builder parameters should be detemined after target build env is received... - } - - func reload() async throws { - counter += 1 - - let dylibPath = try await builder.build(dylibFilename: "HotReload\(counter).dylib") - guard let session = route?.session, let server = route?.server else { return } - try await withCheckedThrowingContinuation { (c: CheckedContinuation) in - session.sendResource(at: dylibPath, withName: dylibPath.lastPathComponent, toPeer: server) { error in - if let error { c.resume(throwing: error) } - else { c.resume() } - } - } - } - } - - public func reload() { - Task { @MainActor in - try await core.reload() - dateReloaded = Date() - } - } - -} - -final actor ProxyBrowser { - @Published private(set) var route: (session: MCSession, server: MCPeerID, env: Env?)? { - didSet { - route?.session.delegate = sessionDelegate - } - } - private let peerID: MCPeerID - private let browser: MCNearbyServiceBrowser - private let browserDelegate: BrowserDelegate - private let sessionDelegate: SessionDelegate = .init() - - init(hostName: String = ProcessInfo().hostName, bundleID: String = Env.shared.CFBundleIdentifier!, processID: Int32 = ProcessInfo().processIdentifier) { - let displayName = String("Client[\(hostName)] \(bundleID)(\(processID))".utf8.prefix(63))! - self.peerID = MCPeerID(displayName: displayName) - self.browser = MCNearbyServiceBrowser(peer: peerID, serviceType: Proxy.MultipeerConnectivityConstants.serviceType) - self.browserDelegate = BrowserDelegate() - - self.browser.delegate = browserDelegate - Task { - browserDelegate.owner = self - sessionDelegate.owner = self - await start() - } - } - - // MARK: - MCNearbyServiceBrowserDelegate - - private final class BrowserDelegate: NSObject, MCNearbyServiceBrowserDelegate { - unowned var owner: ProxyBrowser? - override init() { super.init() } - - func browser(_ browser: MCNearbyServiceBrowser, foundPeer peerID: MCPeerID, withDiscoveryInfo info: [String : String]?) { - Task { await owner?.browser(browser, foundPeer: peerID, withDiscoveryInfo: info) } - } - - func browser(_ browser: MCNearbyServiceBrowser, lostPeer peerID: MCPeerID) { - Task { await owner?.browser(browser, lostPeer: peerID) } - } - } - - func browser(_ browser: MCNearbyServiceBrowser, foundPeer peerID: MCPeerID, withDiscoveryInfo info: [String : String]?) { - NSLog("%@", "🍓 \(#function) peerID = \(peerID), info = \(String(describing: info))") - guard info == Proxy.MultipeerConnectivityConstants.serverDiscoveryInfo else { - NSLog("%@", "🍓 \(#function) ignore peer \(peerID) as it's not a server") - return - } - guard route == nil else { - NSLog("%@", "🍓 \(#function) ⚠️ TODO: support mutiple sessions") - return - } - - NSLog("%@", "🍓 \(#function) ⚠️ TODO: some auth to refrain from sending secret dylib to the unidentified server") - let session = MCSession(peer: self.peerID, securityIdentity: nil, encryptionPreference: .required) - self.browser.invitePeer(peerID, to: session, withContext: nil, timeout: 30) - route = (session, peerID, nil) - } - - func browser(_ browser: MCNearbyServiceBrowser, lostPeer peerID: MCPeerID) { - NSLog("%@", "🍓 \(#function) peerID = \(peerID)") - if route?.server == peerID { - route = nil - } - } - - // MARK: - MCSessionDelegate - - private final class SessionDelegate: NSObject, MCSessionDelegate { - unowned var owner: ProxyBrowser? - override init() { super.init() } - - func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState) { - NSLog("%@", "🍓 \(#function) peerID = \(peerID), state = \(state)") - } - - func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) { - NSLog("%@", "🍓 \(#function) data = \(data.count) bytes, peerID = \(peerID)") - Task { await owner?.session(session, didReceive: data, fromPeer: peerID) } - } - - func session(_ session: MCSession, didReceive stream: InputStream, withName streamName: String, fromPeer peerID: MCPeerID) { - NSLog("%@", "🍓 \(#function) stream = \(stream), streamName = \(streamName), peerID = \(peerID)") - } - - func session(_ session: MCSession, didStartReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, with progress: Progress) { - NSLog("%@", "🍓 \(#function) resourceName = \(resourceName), peerID = \(peerID), progress = \(progress)") - } - - func session(_ session: MCSession, didFinishReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, at localURL: URL?, withError error: Swift.Error?) { - NSLog("%@", "🍓 \(#function) resourceName = \(resourceName), peerID = \(peerID), localURL = \(String(describing: localURL)), error = \(String(describing: error))") - } - } - - func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) { - do { - let env = try JSONDecoder().decode(Env.self, from: data) - NSLog("%@", "🍓 \(#function) TODO: use env when build for the session: \(env)") - } catch { - NSLog("%@", "🍓 \(#function) error = \(error)") - } - } - - // MARK: - - - func start() { - browser.startBrowsingForPeers() - } - - func stop() { - browser.stopBrowsingForPeers() - } -} - -final actor Proxy { - private let loader: Loader = .init() - @Published private(set) var receivedDylibFiles: [URL] = [] - - private let hostName: String - private let bundleID: String - private let processID: Int32 - - private let peerID: MCPeerID - private let advertiser: MCNearbyServiceAdvertiser - private var session: MCSession? { - didSet { - session?.delegate = sessionDelegate - } - } - private let advertiserDelegate: AdvertiserDelegate - private let sessionDelegate: SessionDelegate - - enum MultipeerConnectivityConstants { - /// MultipeerConnectivity service type - /// The type of service to advertise. This should be a short text string that describes the app's networking protocol, in the same format as a Bonjour service type (without the transport protocol) and meeting the restrictions of RFC 6335 (section 5.1) governing Service Name Syntax. In particular, the string: - /// * Must be 1–15 characters long - /// * Can contain only ASCII lowercase letters, numbers, and hyphens - /// * Must contain at least one ASCII letter - /// * Must not begin or end with a hyphen - /// * Must not contain hyphens adjacent to other hyphens. - static let serviceType = "swifthotreload" - static let serverDiscoveryInfo: [String: String] = ["SwiftHotReloadServer": "1"] - } - - enum Error: Swift.Error { - case invalidFilePath(String) - case fileAlreadyExists(String) - } - - init(hostName: String = ProcessInfo().hostName, bundleID: String = Env.shared.CFBundleIdentifier!, processID: Int32 = ProcessInfo().processIdentifier) { - self.hostName = hostName - self.bundleID = bundleID - self.processID = processID - // the doc: The display name is intended for use in UI elements, and should be short and descriptive of the local peer. The maximum allowable length is 63 bytes in UTF-8 encoding. The displayName parameter may not be nil or an empty string. - let displayName = String("Server[\(hostName)] \(bundleID)(\(processID))".utf8.prefix(63))! - self.peerID = MCPeerID(displayName: displayName) - self.advertiser = MCNearbyServiceAdvertiser(peer: peerID, discoveryInfo: MultipeerConnectivityConstants.serverDiscoveryInfo, serviceType: MultipeerConnectivityConstants.serviceType) - self.advertiserDelegate = AdvertiserDelegate() - self.sessionDelegate = SessionDelegate() - - self.advertiser.delegate = self.advertiserDelegate - - Task { - advertiserDelegate.proxy = self - sessionDelegate.proxy = self - await start() - } - } - - func start() { - advertiser.startAdvertisingPeer() - } - - func stop() { - advertiser.stopAdvertisingPeer() - session?.disconnect() - } - - // MARK: - MCNearbyServiceAdvertiserDelegate - - private final class AdvertiserDelegate: NSObject, MCNearbyServiceAdvertiserDelegate { - unowned var proxy: Proxy? - override init() { super.init() } - func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didReceiveInvitationFromPeer peerID: MCPeerID, withContext context: Data?, invitationHandler: @escaping (Bool, MCSession?) -> Void) { - NSLog("%@", "🍓 \(#function) advertiser = \(advertiser), peerID = \(peerID), context = \(context?.count ?? 0) bytes") - Task { await proxy?.advertiser(advertiser, didReceiveInvitationFromPeer: peerID, withContext: context, invitationHandler: invitationHandler) } - } - func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didNotStartAdvertisingPeer error: Swift.Error) { - NSLog("%@", "🍓 \(#function) advertiser = \(advertiser), error = \(error)") - Task { await proxy?.advertiser(advertiser, didNotStartAdvertisingPeer: error) } - } - } - - private func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didReceiveInvitationFromPeer peerID: MCPeerID, withContext context: Data?, invitationHandler: @escaping (Bool, MCSession?) -> Void) { - NSLog("%@", "🍓 \(#function) advertiser = \(advertiser), peerID = \(peerID), context = \(context?.count ?? 0) bytes") - guard session == nil else { return } - - let session = MCSession(peer: self.peerID, securityIdentity: nil, encryptionPreference: .required) - self.session = session - - NSLog("%@", "🍓 \(#function) ⚠️ TODO: some auth to refrain from loading dylibs sent from the unidentified build helper") - invitationHandler(true, session) // TODO: some auth - } - - private func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didNotStartAdvertisingPeer error: Swift.Error) { - NSLog("%@", "🍓 \(#function) advertiser = \(advertiser), error = \(error)") - } - - // MARK: - MCSessionDelegate - - private final class SessionDelegate: NSObject, MCSessionDelegate { - unowned var proxy: Proxy? - override init() { super.init() } - - func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState) { - NSLog("%@", "🍓 \(#function) peerID = \(peerID), state = \(state)") - Task { await proxy?.session(session, peer: peerID, didChange: state) } - } - - func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) { - NSLog("%@", "🍓 \(#function) data = \(data.count) bytes, peerID = \(peerID)") - } - - func session(_ session: MCSession, didReceive stream: InputStream, withName streamName: String, fromPeer peerID: MCPeerID) { - NSLog("%@", "🍓 \(#function) stream = \(stream), streamName = \(streamName), peerID = \(peerID)") - } - - func session(_ session: MCSession, didStartReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, with progress: Progress) { - NSLog("%@", "🍓 \(#function) resourceName = \(resourceName), peerID = \(peerID), progress = \(progress)") - } - - func session(_ session: MCSession, didFinishReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, at localURL: URL?, withError error: Swift.Error?) { - NSLog("%@", "🍓 \(#function) resourceName = \(resourceName), peerID = \(peerID), localURL = \(String(describing: localURL)), error = \(String(describing: error))") - Task { await proxy?.session(session, didFinishReceivingResourceWithName: resourceName, fromPeer: peerID, at: localURL, withError: error) } - } - } - - private func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState) { - switch state { - case .notConnected: break - case .connecting: break - case .connected: - do { - let payload = try JSONEncoder().encode(Env.shared) // TODO: use parameterized env, or minimal data required for build - try self.session?.send(payload, toPeers: [peerID], with: .reliable) - } catch { - NSLog("%@", "🍓 \(#function) error = \(error)") - } - @unknown default: break - } - } - - private func session(_ session: MCSession, didFinishReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, at localURL: URL?, withError error: Swift.Error?) { - guard let localURL else { return } - let tmpDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - .appendingPathComponent("SwiftHotReload") - .appendingPathComponent("dylibs") - guard let filename = resourceName.components(separatedBy: "/").last else { return } // { throw Error.invalidFilePath(resourceName) } - let tmpDylibPath = tmpDir.appendingPathComponent(filename) - - do { - try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true) - guard !FileManager.default.fileExists(atPath: tmpDylibPath.path) else { return } // { throw Error.fileAlreadyExists(tmpDylibPath.path) } - - try FileManager.default.copyItem(at: localURL, to: tmpDylibPath) - } catch { - NSLog("%@", "🍓 \(#function) line \(#line) error = \(error)") - } - Task { - do { - try await loader.load(dylibPath: tmpDylibPath) - receivedDylibFiles.append(tmpDylibPath) - } catch { - NSLog("%@", "🍓 \(#function) line \(#line) error = \(error)") - } - } - } - - // MARK: - -} - -#endif diff --git a/Sources/ProxyReloader/MultipeerConnectivityConstants.swift b/Sources/ProxyReloader/MultipeerConnectivityConstants.swift new file mode 100644 index 0000000..5146beb --- /dev/null +++ b/Sources/ProxyReloader/MultipeerConnectivityConstants.swift @@ -0,0 +1,13 @@ +import Foundation + +enum MultipeerConnectivityConstants { + /// MultipeerConnectivity service type + /// The type of service to advertise. This should be a short text string that describes the app's networking protocol, in the same format as a Bonjour service type (without the transport protocol) and meeting the restrictions of RFC 6335 (section 5.1) governing Service Name Syntax. In particular, the string: + /// * Must be 1–15 characters long + /// * Can contain only ASCII lowercase letters, numbers, and hyphens + /// * Must contain at least one ASCII letter + /// * Must not begin or end with a hyphen + /// * Must not contain hyphens adjacent to other hyphens. + static let serviceType = "swifthotreload" + static let serverDiscoveryInfo: [String: String] = ["SwiftHotReloadServer": "1"] +} diff --git a/Sources/ProxyReloader/Proxy.swift b/Sources/ProxyReloader/Proxy.swift new file mode 100644 index 0000000..be6d92a --- /dev/null +++ b/Sources/ProxyReloader/Proxy.swift @@ -0,0 +1,158 @@ +#if DEBUG +import Foundation +import MultipeerConnectivity + +final actor Proxy { + private let loader: Loader = .init() + @Published private(set) var receivedDylibFiles: [URL] = [] + + private let builderParams: Builder.InputParameters + + private let peerID: MCPeerID + private let advertiser: MCNearbyServiceAdvertiser + private var session: MCSession? { + didSet { + session?.delegate = sessionDelegate + } + } + private let advertiserDelegate: AdvertiserDelegate + private let sessionDelegate: SessionDelegate + + enum Error: Swift.Error { + case invalidFilePath(String) + case fileAlreadyExists(String) + } + + init(hostName: String = ProcessInfo().hostName, bundleID: String = Env.shared.CFBundleIdentifier!, processID: Int32 = ProcessInfo().processIdentifier, builderParams: Builder.InputParameters) { + self.builderParams = builderParams + // the doc: The display name is intended for use in UI elements, and should be short and descriptive of the local peer. The maximum allowable length is 63 bytes in UTF-8 encoding. The displayName parameter may not be nil or an empty string. + let displayName = String("Server[\(hostName)] \(bundleID)(\(processID))".utf8.prefix(63))! + self.peerID = MCPeerID(displayName: displayName) + self.advertiser = MCNearbyServiceAdvertiser(peer: peerID, discoveryInfo: MultipeerConnectivityConstants.serverDiscoveryInfo, serviceType: MultipeerConnectivityConstants.serviceType) + self.advertiserDelegate = AdvertiserDelegate() + self.sessionDelegate = SessionDelegate() + + self.advertiser.delegate = self.advertiserDelegate + + Task { + advertiserDelegate.proxy = self + sessionDelegate.proxy = self + await start() + } + } + + func start() { + advertiser.startAdvertisingPeer() + } + + func stop() { + advertiser.stopAdvertisingPeer() + session?.disconnect() + } + + // MARK: - MCNearbyServiceAdvertiserDelegate + + private final class AdvertiserDelegate: NSObject, MCNearbyServiceAdvertiserDelegate { + unowned var proxy: Proxy? + override init() { super.init() } + func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didReceiveInvitationFromPeer peerID: MCPeerID, withContext context: Data?, invitationHandler: @escaping (Bool, MCSession?) -> Void) { + NSLog("%@", "🍓 \(#function) advertiser = \(advertiser), peerID = \(peerID), context = \(context?.count ?? 0) bytes") + Task { await proxy?.advertiser(advertiser, didReceiveInvitationFromPeer: peerID, withContext: context, invitationHandler: invitationHandler) } + } + func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didNotStartAdvertisingPeer error: Swift.Error) { + NSLog("%@", "🍓 \(#function) advertiser = \(advertiser), error = \(error)") + Task { await proxy?.advertiser(advertiser, didNotStartAdvertisingPeer: error) } + } + } + + private func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didReceiveInvitationFromPeer peerID: MCPeerID, withContext context: Data?, invitationHandler: @escaping (Bool, MCSession?) -> Void) { + NSLog("%@", "🍓 \(#function) advertiser = \(advertiser), peerID = \(peerID), context = \(context?.count ?? 0) bytes") + guard session == nil else { return } + + let session = MCSession(peer: self.peerID, securityIdentity: nil, encryptionPreference: .required) + self.session = session + + NSLog("%@", "🍓 \(#function) ⚠️ TODO: some auth to refrain from loading dylibs sent from the unidentified build helper") + invitationHandler(true, session) // TODO: some auth + } + + private func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didNotStartAdvertisingPeer error: Swift.Error) { + NSLog("%@", "🍓 \(#function) advertiser = \(advertiser), error = \(error)") + } + + // MARK: - MCSessionDelegate + + private final class SessionDelegate: NSObject, MCSessionDelegate { + unowned var proxy: Proxy? + override init() { super.init() } + + func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState) { + NSLog("%@", "🍓 \(#function) peerID = \(peerID), state = \(state)") + Task { await proxy?.session(session, peer: peerID, didChange: state) } + } + + func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) { + NSLog("%@", "🍓 \(#function) data = \(data.count) bytes, peerID = \(peerID)") + } + + func session(_ session: MCSession, didReceive stream: InputStream, withName streamName: String, fromPeer peerID: MCPeerID) { + NSLog("%@", "🍓 \(#function) stream = \(stream), streamName = \(streamName), peerID = \(peerID)") + } + + func session(_ session: MCSession, didStartReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, with progress: Progress) { + NSLog("%@", "🍓 \(#function) resourceName = \(resourceName), peerID = \(peerID), progress = \(progress)") + } + + func session(_ session: MCSession, didFinishReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, at localURL: URL?, withError error: Swift.Error?) { + NSLog("%@", "🍓 \(#function) resourceName = \(resourceName), peerID = \(peerID), localURL = \(String(describing: localURL)), error = \(String(describing: error))") + Task { await proxy?.session(session, didFinishReceivingResourceWithName: resourceName, fromPeer: peerID, at: localURL, withError: error) } + } + } + + private func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState) { + switch state { + case .notConnected: break + case .connecting: break + case .connected: + do { + let payload = try JSONEncoder().encode(builderParams) + try self.session?.send(payload, toPeers: [peerID], with: .reliable) + } catch { + NSLog("%@", "🍓 \(#function) error = \(error)") + } + @unknown default: break + } + } + + private func session(_ session: MCSession, didFinishReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, at localURL: URL?, withError error: Swift.Error?) { + guard let localURL else { return } + let tmpDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("SwiftHotReload") + .appendingPathComponent("dylibs") + guard let filename = resourceName.components(separatedBy: "/").last else { return } // { throw Error.invalidFilePath(resourceName) } + let tmpDylibPath = tmpDir.appendingPathComponent(filename) + + do { + try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true) + // guard !FileManager.default.fileExists(atPath: tmpDylibPath.path) else { return } + + if FileManager.default.fileExists(atPath: tmpDylibPath.path) { + try FileManager.default.removeItem(atPath: tmpDylibPath.path) + } + + try FileManager.default.copyItem(at: localURL, to: tmpDylibPath) + try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: tmpDylibPath.path) + } catch { + NSLog("%@", "🍓 \(#function) line \(#line) error = \(error)") + } + Task { + do { + try await loader.load(dylibPath: tmpDylibPath) + receivedDylibFiles.append(tmpDylibPath) + } catch { + NSLog("%@", "🍓 \(#function) line \(#line) error = \(error)") + } + } + } +} +#endif diff --git a/Sources/ProxyReloader/ProxyReloader.swift b/Sources/ProxyReloader/ProxyReloader.swift new file mode 100644 index 0000000..aceab85 --- /dev/null +++ b/Sources/ProxyReloader/ProxyReloader.swift @@ -0,0 +1,19 @@ +#if DEBUG +import Foundation +import MultipeerConnectivity + +public final class ProxyReloader: ObservableObject { + private let proxy: Proxy + + @Published public private(set) var dateReloaded: Date? + + public init(_ builderParams: Builder.InputParameters) { + self.proxy = Proxy(builderParams: builderParams) + + Task { + await proxy.$receivedDylibFiles.map {_ in Date() }.receive(on: DispatchQueue.main).assign(to: &$dateReloaded) + await proxy.start() + } + } +} +#endif diff --git a/Sources/ProxyReloader/RuntimePeer.swift b/Sources/ProxyReloader/RuntimePeer.swift new file mode 100644 index 0000000..b2d0cf5 --- /dev/null +++ b/Sources/ProxyReloader/RuntimePeer.swift @@ -0,0 +1,13 @@ +#if DEBUG +import Foundation +import MultipeerConnectivity + +struct RuntimePeer { + /// route for sending dylib + var session: MCSession + /// the destination peerID that will load the dylib on runtime + var peerID: MCPeerID + /// build environments for the destination + var builderParams: Builder.InputParameters? +} +#endif diff --git a/Sources/StandaloneReloader.swift b/Sources/StandaloneReloader/StandaloneReloader.swift similarity index 78% rename from Sources/StandaloneReloader.swift rename to Sources/StandaloneReloader/StandaloneReloader.swift index 5809650..7206012 100644 --- a/Sources/StandaloneReloader.swift +++ b/Sources/StandaloneReloader/StandaloneReloader.swift @@ -14,8 +14,8 @@ public final class StandaloneReloader: ObservableObject { NSLog("%@", "🍓 ⚠️ To do hot reloads standalone, the process host should be able to execute swiftc. ⚠️") } - fileMonitor = .init(file: monitoredSwiftFile, platformName: platformName ?? env.DTPlatformName!) - core = .init(builder: .init(targetSwiftFile: monitoredSwiftFile, env: env, derivedData: derivedData, confBuildDirAppRandomString: confBuildDirAppRandomString, mainModule: mainModule, modules: modules, configurationPlatform: configurationPlatform, arch: arch, targetTriple: targetTriple, sdk: sdk, platformName: platformName), loader: .init()) + fileMonitor = .init(file: monitoredSwiftFile) + core = .init(builder: .init(.init(targetSwiftFile: monitoredSwiftFile, env: env, derivedData: derivedData, confBuildDirAppRandomString: confBuildDirAppRandomString, mainModule: mainModule, modules: modules, configurationPlatform: configurationPlatform, arch: arch, targetTriple: targetTriple, sdk: sdk, platformName: platformName)), loader: .init()) Task { await fileMonitor.$fileChanges.compactMap {$0}.sink { [weak self] _ in diff --git a/SwiftHotReload.xcworkspace/contents.xcworkspacedata b/SwiftHotReload.xcworkspace/contents.xcworkspacedata index 84efcd4..878d5bd 100644 --- a/SwiftHotReload.xcworkspace/contents.xcworkspacedata +++ b/SwiftHotReload.xcworkspace/contents.xcworkspacedata @@ -19,4 +19,7 @@ + + From 7707b9eb8868668c7a77038fa9f9f589eb1179b0 Mon Sep 17 00:00:00 2001 From: banjun Date: Tue, 14 Nov 2023 14:30:59 +0900 Subject: [PATCH 08/17] estimate codesign identity from main bundle --- BuildHelper/BuildHelperApp.swift | 2 +- BuildHelper/ContentView.swift | 1 + Sources/BuildHelper/BuildHelper.swift | 11 ++++++++++- Sources/Core/Builder.swift | 15 +++++++++++---- Sources/Core/Env.swift | 7 +++++++ Sources/Core/Loader.swift | 6 +++++- Sources/Core/NSTaskCommand.swift | 18 +++++++++++++++--- 7 files changed, 50 insertions(+), 10 deletions(-) diff --git a/BuildHelper/BuildHelperApp.swift b/BuildHelper/BuildHelperApp.swift index 76a8eef..8857166 100644 --- a/BuildHelper/BuildHelperApp.swift +++ b/BuildHelper/BuildHelperApp.swift @@ -12,7 +12,7 @@ struct BuildHelperApp: App { @ObservedObject private(set) var buildHelper = BuildHelper() var body: some Scene { - WindowGroup { + Window("BuildHelper (\(String(ProcessInfo().processIdentifier)))", id: "Main") { ContentView() .environmentObject(buildHelper) } diff --git a/BuildHelper/ContentView.swift b/BuildHelper/ContentView.swift index e7fe71b..d6a6d52 100644 --- a/BuildHelper/ContentView.swift +++ b/BuildHelper/ContentView.swift @@ -18,4 +18,5 @@ struct ContentView: View { #Preview { ContentView() + .environmentObject(BuildHelper()) } diff --git a/Sources/BuildHelper/BuildHelper.swift b/Sources/BuildHelper/BuildHelper.swift index d58a4b0..71debf8 100644 --- a/Sources/BuildHelper/BuildHelper.swift +++ b/Sources/BuildHelper/BuildHelper.swift @@ -55,6 +55,15 @@ public final class BuildHelper: ObservableObject { init() {} func setRuntimePeer(_ runtimePeer: RuntimePeer?) { + var runtimePeer = runtimePeer + if let p = runtimePeer?.builderParams { + let identity = p.env.estimatedProductBundlePath.filter { FileManager.default.fileExists(atPath: $0.path) }.lazy.compactMap { + let stderr = try? NSTaskCommand(launchPath: "/usr/bin/codesign", args: ["-dvvvvv", $0.path]).run().stderr + // extract `Apple Development: xxxxxx@xxxxxx (XXXXXXXXXX)` + return stderr?.components(separatedBy: "\n").first { $0.hasPrefix("Authority=") }?.split(separator: "=", maxSplits: 2).last + }.map(String.init).first + runtimePeer?.builderParams?.codesignIdentity = identity + } self.runtimePeer = runtimePeer } @@ -62,7 +71,7 @@ public final class BuildHelper: ObservableObject { guard let builder else { throw Error.builderUninitialized } counter += 1 - let dylibPath = try await builder.build(dylibFilename: "HotReload\(counter).dylib", codesignIdentity: "Apple Development: ..... ..... (..........)") // FIXME: hardcoded identity + let dylibPath = try await builder.build(dylibFilename: "HotReload\(counter).dylib") guard let session = runtimePeer?.session, let server = runtimePeer?.peerID else { return } try await withCheckedThrowingContinuation { (c: CheckedContinuation) in session.sendResource(at: dylibPath, withName: dylibPath.lastPathComponent, toPeer: server) { error in diff --git a/Sources/Core/Builder.swift b/Sources/Core/Builder.swift index 31ef485..62d7bfd 100644 --- a/Sources/Core/Builder.swift +++ b/Sources/Core/Builder.swift @@ -13,6 +13,7 @@ public final actor Builder { private let sdk: URL? private let arch: String private let platformName: String + private let codesignIdentity: String? public struct InputParameters: Codable { public var targetSwiftFile: URL @@ -26,8 +27,9 @@ public final actor Builder { public var targetTriple: String? public var sdk: URL? public var platformName: String? + public var codesignIdentity: String? - public init(targetSwiftFile: URL, env: Env = .shared, derivedData: URL? = nil, confBuildDirAppRandomString: String? = nil, mainModule: String? = nil, modules: [String] = [], configurationPlatform: String? = nil, arch: String? = nil, targetTriple: String? = nil, sdk: URL? = nil, platformName: String? = nil) { + public init(targetSwiftFile: URL, env: Env = .shared, derivedData: URL? = nil, confBuildDirAppRandomString: String? = nil, mainModule: String? = nil, modules: [String] = [], configurationPlatform: String? = nil, arch: String? = nil, targetTriple: String? = nil, sdk: URL? = nil, platformName: String? = nil, codesignIdentity: String? = nil) { self.targetSwiftFile = targetSwiftFile self.env = env self.derivedData = derivedData @@ -39,6 +41,7 @@ public final actor Builder { self.targetTriple = targetTriple self.sdk = sdk self.platformName = platformName + self.codesignIdentity = codesignIdentity } } @@ -84,9 +87,10 @@ public final actor Builder { self.targetTriple = p.targetTriple ?? p.env.estimatedTargetTriple! self.sdk = p.sdk ?? p.env.estimatedSDK ?? Env.shared.estimatedSDK self.platformName = p.platformName ?? p.env.DTPlatformName! + self.codesignIdentity = p.codesignIdentity } - func build(dylibFilename: String, codesignIdentity: String? = nil) throws -> URL { + func build(dylibFilename: String) throws -> URL { let dylibPath = buildDir.appendingPathComponent(dylibFilename) try build(dylibPath: dylibPath) if let codesignIdentity { @@ -127,7 +131,7 @@ public final actor Builder { headerMaps.flatMap { ["-Xcc", "-I", "-Xcc", $0.path] } ].flatMap { $0 }) - NSLog("%@", "🍓 exec and args = ") + NSLog("%@", "🍓 build: exec and args = ") print("\(command.launchPath) \(command.args.joined(separator: " "))") try command.run() @@ -143,7 +147,10 @@ public final actor Builder { let command = NSTaskCommand(launchPath: "/usr/bin/codesign", args: [ "-f", "-s", codesignIdentity, dylibPath.path ]) - try command.run() + NSLog("%@", "🍓 codesign: exec and args = ") + print("\(command.launchPath) \(command.args.joined(separator: " "))") + let outputs = try command.run() + print(outputs) } } diff --git a/Sources/Core/Env.swift b/Sources/Core/Env.swift index 036eb88..6178fda 100644 --- a/Sources/Core/Env.swift +++ b/Sources/Core/Env.swift @@ -95,6 +95,11 @@ public struct Env: Codable, Equatable { "x86_64" #endif } + /// Product app bundle on host + public var estimatedProductBundlePath: [URL] { + guard let CFBundleName else { return [] } + return estimatedBuilProductsDir.map { $0.appendingPathComponent(CFBundleName).appendingPathExtension("app") } + } // Environment Variables var SIMULATOR_HOST_HOME: String? @@ -118,6 +123,7 @@ public struct Env: Codable, Equatable { var LSMinimumSystemVersion: String? var CFBundleExecutable: String? var CFBundleIdentifier: String? + var CFBundleName: String? private init() { let env = ProcessInfo().environment @@ -142,6 +148,7 @@ public struct Env: Codable, Equatable { LSMinimumSystemVersion = info["LSMinimumSystemVersion"] as? String CFBundleExecutable = info["CFBundleExecutable"] as? String CFBundleIdentifier = info["CFBundleIdentifier"] as? String + CFBundleName = info["CFBundleName"] as? String } } #endif diff --git a/Sources/Core/Loader.swift b/Sources/Core/Loader.swift index fc1cdbc..f63d619 100644 --- a/Sources/Core/Loader.swift +++ b/Sources/Core/Loader.swift @@ -4,6 +4,7 @@ import Foundation final actor Loader { enum Error: Swift.Error { case symbol_not_found_in_flat_namespace(String) + case code_signature_invalid(String) case unknown(String) } @@ -17,7 +18,10 @@ final actor Loader { NSLog("%@", "🍓 possible workarounds: remove `private` from the func, or add `-Xfrontend -enable-private-imports` to OTHER_SWIFT_FLAGS of the module to be overridden") throw Error.symbol_not_found_in_flat_namespace(error) } - // TODO: code signature invalid: on device dylibs needs to be signed by Individual or Company identity (it cannot be verified by Personal nor Enterprise identity. see `amfid` process message on the device console) + if error.contains("code signature invalid") { + NSLog("%@", "🍓 code signature invalid: on device dylibs needs to be signed by Individual, Company or Enterprise identity (it cannot be verified by Personal identity. see `amfid` process message on the device console)") + throw Error.code_signature_invalid(error) + } throw Error.unknown(error) } } diff --git a/Sources/Core/NSTaskCommand.swift b/Sources/Core/NSTaskCommand.swift index 1d33411..c83ab51 100644 --- a/Sources/Core/NSTaskCommand.swift +++ b/Sources/Core/NSTaskCommand.swift @@ -7,10 +7,11 @@ struct NSTaskCommand { enum Error: Swift.Error { case nsTaskUnavailable - case failureStatus(Int?) + case failureStatus(status: Int?, stdout: String?, stderr: String?) } - func run(clearEnvironments: Bool = true) throws { + @discardableResult + func run(clearEnvironments: Bool = true) throws -> (stdout: String?, stderr: String?) { let NSTask: AnyClass? = NSClassFromString("NSTask") let task = NSTask?.value(forKey: "new") as? NSObject guard let task else { throw Error.nsTaskUnavailable } @@ -22,11 +23,22 @@ struct NSTaskCommand { task.setValue(launchPath, forKey: "launchPath") task.setValue(args, forKey: "arguments") + let stdout = Pipe() + let stderr = Pipe() + task.setValue(stdout, forKey: "standardOutput") + task.setValue(stderr, forKey: "standardError") + task.value(forKey: "launch") task.value(forKey: "waitUntilExit") + let outputs = ( + stdout: (try? stdout.fileHandleForReading.readToEnd()).flatMap { String(data: $0, encoding: .utf8) }, + stderr: (try? stderr.fileHandleForReading.readToEnd()).flatMap { String(data: $0, encoding: .utf8) }) + let terminationStatus = task.value(forKey: "terminationStatus") as? Int - guard terminationStatus == 0 else { throw Error.failureStatus(terminationStatus) } + guard terminationStatus == 0 else { throw Error.failureStatus(status: terminationStatus, stdout: outputs.stdout, stderr: outputs.stderr) } + + return outputs } } #endif From 431cd9d6fb69fb5ec019f578b6a3b972cf00050c Mon Sep 17 00:00:00 2001 From: banjun Date: Tue, 14 Nov 2023 21:40:52 +0900 Subject: [PATCH 09/17] swift run BuildHelper to run build helper on host for proxy on device --- .github/workflows/main.yml | 11 ++++- .../BuildHelper.xcodeproj/project.pbxproj | 28 ++++++++---- BuildHelper/BuildHelperApp.swift | 20 --------- BuildHelper/ContentView.swift | 22 --------- .../Sources}/BuildHelper.swift | 1 + BuildHelper/Sources/BuildHelperApp.swift | 45 +++++++++++++++++++ .../Sources}/ProxyBlowser.swift | 7 ++- .../SwiftHotReload.xcodeproj/project.pbxproj | 16 ------- Package.swift | 13 +++++- Sources/Core/Builder.swift | 2 +- Sources/Core/Env.swift | 2 +- Sources/Core/FileMonitor.swift | 3 +- Sources/Core/Loader.swift | 2 +- Sources/Core/NSTaskCommand.swift | 2 +- Sources/Core/TargetSwiftFile.swift | 2 +- .../MultipeerConnectivityConstants.swift | 2 + Sources/ProxyReloader/Proxy.swift | 2 +- Sources/ProxyReloader/ProxyReloader.swift | 2 +- Sources/ProxyReloader/RuntimePeer.swift | 2 +- .../StandaloneReloader.swift | 2 +- SwiftHotReload.podspec | 8 +++- .../contents.xcworkspacedata | 7 +++ 22 files changed, 119 insertions(+), 82 deletions(-) delete mode 100644 BuildHelper/BuildHelperApp.swift delete mode 100644 BuildHelper/ContentView.swift rename {Sources/BuildHelper => BuildHelper/Sources}/BuildHelper.swift (96%) create mode 100644 BuildHelper/Sources/BuildHelperApp.swift rename {Sources/BuildHelper => BuildHelper/Sources}/ProxyBlowser.swift (93%) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 01529bb..51d5795 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -8,14 +8,21 @@ on: jobs: build: + strategy: + matrix: + configuration: ['debug', 'release'] runs-on: macOS-13 steps: - uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: '^15.0.1' - uses: actions/checkout@v2 - - run: swift build + - run: swift build -c ${{ matrix.configuration }} podspec: + strategy: + matrix: + configuration: ['Debug', 'Release'] + platform: ['ios', 'macos'] runs-on: macOS-13 steps: - uses: maxim-lobanov/setup-xcode@v1 @@ -23,4 +30,4 @@ jobs: xcode-version: '^15.0.1' - uses: actions/checkout@v2 - run: bundle install - - run: bundle exec pod lib lint + - run: bundle exec pod lib lint --platforms=${{ matrix.platform }} --configuration=${{ matrix.configuration }} diff --git a/BuildHelper/BuildHelper.xcodeproj/project.pbxproj b/BuildHelper/BuildHelper.xcodeproj/project.pbxproj index 4ec7f9c..9284f97 100644 --- a/BuildHelper/BuildHelper.xcodeproj/project.pbxproj +++ b/BuildHelper/BuildHelper.xcodeproj/project.pbxproj @@ -7,8 +7,9 @@ objects = { /* Begin PBXBuildFile section */ - 63A7469A2AFDD742003FA3AC /* BuildHelperApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63A746992AFDD742003FA3AC /* BuildHelperApp.swift */; }; - 63A7469C2AFDD742003FA3AC /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63A7469B2AFDD742003FA3AC /* ContentView.swift */; }; + 63599A3E2B03A121009186F4 /* BuildHelperApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63599A3B2B03A121009186F4 /* BuildHelperApp.swift */; }; + 63599A3F2B03A121009186F4 /* ProxyBlowser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63599A3C2B03A121009186F4 /* ProxyBlowser.swift */; }; + 63599A402B03A121009186F4 /* BuildHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63599A3D2B03A121009186F4 /* BuildHelper.swift */; }; 63A7469E2AFDD748003FA3AC /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 63A7469D2AFDD748003FA3AC /* Assets.xcassets */; }; 63A746A12AFDD748003FA3AC /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 63A746A02AFDD748003FA3AC /* Preview Assets.xcassets */; }; 63A746B12AFDD8F4003FA3AC /* SwiftHotReload.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 63A746B02AFDD8F4003FA3AC /* SwiftHotReload.framework */; }; @@ -30,9 +31,10 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 63599A3B2B03A121009186F4 /* BuildHelperApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BuildHelperApp.swift; sourceTree = ""; }; + 63599A3C2B03A121009186F4 /* ProxyBlowser.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProxyBlowser.swift; sourceTree = ""; }; + 63599A3D2B03A121009186F4 /* BuildHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BuildHelper.swift; sourceTree = ""; }; 63A746962AFDD742003FA3AC /* BuildHelper.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BuildHelper.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 63A746992AFDD742003FA3AC /* BuildHelperApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildHelperApp.swift; sourceTree = ""; }; - 63A7469B2AFDD742003FA3AC /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 63A7469D2AFDD748003FA3AC /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 63A746A02AFDD748003FA3AC /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 63A746A22AFDD748003FA3AC /* BuildHelper.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = BuildHelper.entitlements; sourceTree = ""; }; @@ -52,12 +54,21 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 63599A3A2B03A121009186F4 /* Sources */ = { + isa = PBXGroup; + children = ( + 63599A3B2B03A121009186F4 /* BuildHelperApp.swift */, + 63599A3C2B03A121009186F4 /* ProxyBlowser.swift */, + 63599A3D2B03A121009186F4 /* BuildHelper.swift */, + ); + path = Sources; + sourceTree = ""; + }; 63A7468D2AFDD742003FA3AC = { isa = PBXGroup; children = ( 63A746AE2AFDD7DC003FA3AC /* Info.plist */, - 63A746992AFDD742003FA3AC /* BuildHelperApp.swift */, - 63A7469B2AFDD742003FA3AC /* ContentView.swift */, + 63599A3A2B03A121009186F4 /* Sources */, 63A7469D2AFDD748003FA3AC /* Assets.xcassets */, 63A746A22AFDD748003FA3AC /* BuildHelper.entitlements */, 63A7469F2AFDD748003FA3AC /* Preview Content */, @@ -161,8 +172,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 63A7469C2AFDD742003FA3AC /* ContentView.swift in Sources */, - 63A7469A2AFDD742003FA3AC /* BuildHelperApp.swift in Sources */, + 63599A3E2B03A121009186F4 /* BuildHelperApp.swift in Sources */, + 63599A402B03A121009186F4 /* BuildHelper.swift in Sources */, + 63599A3F2B03A121009186F4 /* ProxyBlowser.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/BuildHelper/BuildHelperApp.swift b/BuildHelper/BuildHelperApp.swift deleted file mode 100644 index 8857166..0000000 --- a/BuildHelper/BuildHelperApp.swift +++ /dev/null @@ -1,20 +0,0 @@ -import SwiftUI -import SwiftHotReload - -// NOTE: app sandbox is disabled to: -// - monitor any file changes (to trigger build a swift file) -// - run swiftc - -// TODO: user consent on each connection to peers - -@main -struct BuildHelperApp: App { - @ObservedObject private(set) var buildHelper = BuildHelper() - - var body: some Scene { - Window("BuildHelper (\(String(ProcessInfo().processIdentifier)))", id: "Main") { - ContentView() - .environmentObject(buildHelper) - } - } -} diff --git a/BuildHelper/ContentView.swift b/BuildHelper/ContentView.swift deleted file mode 100644 index d6a6d52..0000000 --- a/BuildHelper/ContentView.swift +++ /dev/null @@ -1,22 +0,0 @@ -import SwiftUI -import SwiftHotReload - -struct ContentView: View { - @EnvironmentObject var buildHelper: BuildHelper - - var body: some View { - VStack(spacing: 20) { - Text("Monitored File" + "\n" + (buildHelper.monitoredFile?.path ?? "Nothing")) - .multilineTextAlignment(.center) - - Text("Date Reloaded" + "\n" + (buildHelper.dateReloaded?.formatted(date: .numeric, time: .complete) ?? "Never")) - .multilineTextAlignment(.center) - } - .padding() - } -} - -#Preview { - ContentView() - .environmentObject(BuildHelper()) -} diff --git a/Sources/BuildHelper/BuildHelper.swift b/BuildHelper/Sources/BuildHelper.swift similarity index 96% rename from Sources/BuildHelper/BuildHelper.swift rename to BuildHelper/Sources/BuildHelper.swift index 71debf8..7be1512 100644 --- a/Sources/BuildHelper/BuildHelper.swift +++ b/BuildHelper/Sources/BuildHelper.swift @@ -1,4 +1,5 @@ #if os(macOS) +@testable import SwiftHotReload // NOTE: use internal methods. SPM does not allow overlapping sources for a single Package.swift // NOTE: should not be submitted for App Store Review // Release build is not disabled as BuildHelper.app is to be buildable for generating a mac helper app. // TODO: BuildHelper may be separated into sub- spec/package diff --git a/BuildHelper/Sources/BuildHelperApp.swift b/BuildHelper/Sources/BuildHelperApp.swift new file mode 100644 index 0000000..5276d81 --- /dev/null +++ b/BuildHelper/Sources/BuildHelperApp.swift @@ -0,0 +1,45 @@ +#if os(macOS) +import SwiftUI + +/// to use: `swift run BuildHelper` +/// to debug: Build & Run BuildHelper target on SwiftHotReload.xcworkspace +/// NOTE: when run as an app, the app sandbox should be disabled to: +/// - monitor any file changes (to trigger build a swift file) +/// - run swiftc +@main +struct BuildHelperApp: SwiftUI.App { + @ObservedObject private(set) var buildHelper = BuildHelper() + + var body: some Scene { + Window("BuildHelper (\(String(ProcessInfo().processIdentifier)))", id: "Main") { + ContentView() + .environmentObject(buildHelper) + } + MenuBarExtra("BuildHelper", systemImage: "hammer.circle.fill") { + Button("Show All") { + // needs workaround: not works nicely when launched via `swift run BuildHelper` + NSApp.unhide(nil) + } + Divider() + Button("Quit") { + NSApp.terminate(nil) + } + } + } + + struct ContentView: View { + @EnvironmentObject var buildHelper: BuildHelper + + var body: some View { + VStack(spacing: 20) { + Text("Monitored File" + "\n" + (buildHelper.monitoredFile?.path ?? "Nothing")) + .multilineTextAlignment(.center) + + Text("Date Reloaded" + "\n" + (buildHelper.dateReloaded?.formatted(date: .numeric, time: .complete) ?? "Never")) + .multilineTextAlignment(.center) + } + .padding() + } + } +} +#endif diff --git a/Sources/BuildHelper/ProxyBlowser.swift b/BuildHelper/Sources/ProxyBlowser.swift similarity index 93% rename from Sources/BuildHelper/ProxyBlowser.swift rename to BuildHelper/Sources/ProxyBlowser.swift index 75b3583..831c39f 100644 --- a/Sources/BuildHelper/ProxyBlowser.swift +++ b/BuildHelper/Sources/ProxyBlowser.swift @@ -4,6 +4,7 @@ // TODO: BuildHelper may be separated into sub- spec/package import Foundation import MultipeerConnectivity +@testable import SwiftHotReload // NOTE: use internal methods. SPM does not allow overlapping sources for a single Package.swift final actor ProxyBrowser { @Published private(set) var runtimePeer: RuntimePeer? { @@ -16,8 +17,8 @@ final actor ProxyBrowser { private let browserDelegate: BrowserDelegate private let sessionDelegate: SessionDelegate = .init() - init(hostName: String = ProcessInfo().hostName, bundleID: String = Env.shared.CFBundleIdentifier!, processID: Int32 = ProcessInfo().processIdentifier) { - let displayName = String("Client[\(hostName)] \(bundleID)(\(processID))".utf8.prefix(63))! + init(hostName: String = ProcessInfo().hostName, bundleID: String? = Env.shared.CFBundleIdentifier, processID: Int32 = ProcessInfo().processIdentifier) { + let displayName = String("Client[\(hostName)] \(bundleID ?? "cli")(\(processID))".utf8.prefix(63))! self.peerID = MCPeerID(displayName: displayName) self.browser = MCNearbyServiceBrowser(peer: peerID, serviceType: MultipeerConnectivityConstants.serviceType) self.browserDelegate = BrowserDelegate() @@ -112,10 +113,12 @@ final actor ProxyBrowser { // MARK: - func start() { + NSLog("%@", "🍓 ProxyBrowser.\(#function)") browser.startBrowsingForPeers() } func stop() { + NSLog("%@", "🍓 ProxyBrowser.\(#function)") browser.stopBrowsingForPeers() } } diff --git a/FrameworkTarget/SwiftHotReload.xcodeproj/project.pbxproj b/FrameworkTarget/SwiftHotReload.xcodeproj/project.pbxproj index 92df435..0a84f96 100644 --- a/FrameworkTarget/SwiftHotReload.xcodeproj/project.pbxproj +++ b/FrameworkTarget/SwiftHotReload.xcodeproj/project.pbxproj @@ -15,8 +15,6 @@ 63458AC32AFA6247001A5630 /* Builder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63458AC22AFA6247001A5630 /* Builder.swift */; }; 63458AC42AFA6247001A5630 /* FileMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63458AC12AFA6247001A5630 /* FileMonitor.swift */; }; 6355B0F52AFB6899008C4C50 /* ProxyReloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6355B0F42AFB6899008C4C50 /* ProxyReloader.swift */; }; - 63A746B52AFE0122003FA3AC /* BuildHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63A746B42AFE0122003FA3AC /* BuildHelper.swift */; }; - 63A746B82AFE015C003FA3AC /* ProxyBlowser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63A746B72AFE015C003FA3AC /* ProxyBlowser.swift */; }; 63A746BA2AFE0286003FA3AC /* RuntimePeer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63A746B92AFE0286003FA3AC /* RuntimePeer.swift */; }; 63A746BC2AFE02EA003FA3AC /* Proxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63A746BB2AFE02EA003FA3AC /* Proxy.swift */; }; 63A746C12AFE0700003FA3AC /* MultipeerConnectivityConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63A746C02AFE0700003FA3AC /* MultipeerConnectivityConstants.swift */; }; @@ -34,8 +32,6 @@ 63458AC22AFA6247001A5630 /* Builder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Builder.swift; sourceTree = ""; }; 6355B0F42AFB6899008C4C50 /* ProxyReloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyReloader.swift; sourceTree = ""; }; 63980F712AEA93310099B122 /* SwiftHotReload.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SwiftHotReload.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 63A746B42AFE0122003FA3AC /* BuildHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildHelper.swift; sourceTree = ""; }; - 63A746B72AFE015C003FA3AC /* ProxyBlowser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyBlowser.swift; sourceTree = ""; }; 63A746B92AFE0286003FA3AC /* RuntimePeer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuntimePeer.swift; sourceTree = ""; }; 63A746BB2AFE02EA003FA3AC /* Proxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Proxy.swift; sourceTree = ""; }; 63A746C02AFE0700003FA3AC /* MultipeerConnectivityConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultipeerConnectivityConstants.swift; sourceTree = ""; }; @@ -58,7 +54,6 @@ 63A746BD2AFE031C003FA3AC /* Core */, 63A746BF2AFE034F003FA3AC /* StandaloneReloader */, 63A746BE2AFE033F003FA3AC /* ProxyReloader */, - 63A746B62AFE0141003FA3AC /* BuildHelper */, ); name = Sources; path = ../Sources; @@ -91,15 +86,6 @@ name = Products; sourceTree = ""; }; - 63A746B62AFE0141003FA3AC /* BuildHelper */ = { - isa = PBXGroup; - children = ( - 63A746B42AFE0122003FA3AC /* BuildHelper.swift */, - 63A746B72AFE015C003FA3AC /* ProxyBlowser.swift */, - ); - path = BuildHelper; - sourceTree = ""; - }; 63A746BD2AFE031C003FA3AC /* Core */ = { isa = PBXGroup; children = ( @@ -219,10 +205,8 @@ 63A746BC2AFE02EA003FA3AC /* Proxy.swift in Sources */, 63A746BA2AFE0286003FA3AC /* RuntimePeer.swift in Sources */, 6355B0F52AFB6899008C4C50 /* ProxyReloader.swift in Sources */, - 63A746B52AFE0122003FA3AC /* BuildHelper.swift in Sources */, 63A746C12AFE0700003FA3AC /* MultipeerConnectivityConstants.swift in Sources */, 630C245F2AEBD4E10012C490 /* Env.swift in Sources */, - 63A746B82AFE015C003FA3AC /* ProxyBlowser.swift in Sources */, 63458ABF2AFA6232001A5630 /* Loader.swift in Sources */, 63458AC32AFA6247001A5630 /* Builder.swift in Sources */, ); diff --git a/Package.swift b/Package.swift index 74e81e0..745b1da 100644 --- a/Package.swift +++ b/Package.swift @@ -10,7 +10,18 @@ let package = Package( .library(name: "SwiftHotReload", targets: ["SwiftHotReload"]), ], targets: [ - .target(name: "SwiftHotReload"), + .target( + name: "SwiftHotReload", + path: "Sources", + sources: ["Core", "StandaloneReloader", "ProxyReloader"], + swiftSettings: [.define("DEBUG", .when(configuration: .debug))] + ), + .executableTarget( + name: "BuildHelper", + dependencies: ["SwiftHotReload"], + path: "Sources", + sources: ["BuildHelper"] + ), .testTarget(name: "SwiftHotReloadTests", dependencies: ["SwiftHotReload"]), ] ) diff --git a/Sources/Core/Builder.swift b/Sources/Core/Builder.swift index 62d7bfd..9df7052 100644 --- a/Sources/Core/Builder.swift +++ b/Sources/Core/Builder.swift @@ -1,4 +1,4 @@ -#if DEBUG +#if DEBUG || os(macOS) import Foundation public final actor Builder { diff --git a/Sources/Core/Env.swift b/Sources/Core/Env.swift index 6178fda..953ab00 100644 --- a/Sources/Core/Env.swift +++ b/Sources/Core/Env.swift @@ -1,4 +1,4 @@ -#if DEBUG +#if DEBUG || os(macOS) import Foundation public struct Env: Codable, Equatable { diff --git a/Sources/Core/FileMonitor.swift b/Sources/Core/FileMonitor.swift index aacd26c..ea366fa 100644 --- a/Sources/Core/FileMonitor.swift +++ b/Sources/Core/FileMonitor.swift @@ -1,4 +1,4 @@ -#if DEBUG +#if DEBUG || os(macOS) import Foundation final actor FileMonitor { @@ -30,6 +30,7 @@ final actor FileMonitor { } private func install() { + NSLog("%@", "🍓 \(#function) starting file monitor for file at \(file.path)") let handle = FileHandle(forReadingAtPath: file.path) monitor = handle.map { DispatchSource.makeFileSystemObjectSource(fileDescriptor: $0.fileDescriptor, eventMask: .all) } monitor?.setEventHandler { [unowned self] in diff --git a/Sources/Core/Loader.swift b/Sources/Core/Loader.swift index f63d619..e535717 100644 --- a/Sources/Core/Loader.swift +++ b/Sources/Core/Loader.swift @@ -1,4 +1,4 @@ -#if DEBUG +#if DEBUG || os(macOS) import Foundation final actor Loader { diff --git a/Sources/Core/NSTaskCommand.swift b/Sources/Core/NSTaskCommand.swift index c83ab51..a07d4fc 100644 --- a/Sources/Core/NSTaskCommand.swift +++ b/Sources/Core/NSTaskCommand.swift @@ -1,4 +1,4 @@ -#if DEBUG +#if DEBUG || os(macOS) import Foundation struct NSTaskCommand { diff --git a/Sources/Core/TargetSwiftFile.swift b/Sources/Core/TargetSwiftFile.swift index cb6ee7f..4db44e7 100644 --- a/Sources/Core/TargetSwiftFile.swift +++ b/Sources/Core/TargetSwiftFile.swift @@ -1,4 +1,4 @@ -#if DEBUG +#if DEBUG || os(macOS) import Foundation struct TargetSwiftFile { diff --git a/Sources/ProxyReloader/MultipeerConnectivityConstants.swift b/Sources/ProxyReloader/MultipeerConnectivityConstants.swift index 5146beb..2fcdf77 100644 --- a/Sources/ProxyReloader/MultipeerConnectivityConstants.swift +++ b/Sources/ProxyReloader/MultipeerConnectivityConstants.swift @@ -1,3 +1,4 @@ +#if DEBUG || os(macOS) import Foundation enum MultipeerConnectivityConstants { @@ -11,3 +12,4 @@ enum MultipeerConnectivityConstants { static let serviceType = "swifthotreload" static let serverDiscoveryInfo: [String: String] = ["SwiftHotReloadServer": "1"] } +#endif diff --git a/Sources/ProxyReloader/Proxy.swift b/Sources/ProxyReloader/Proxy.swift index be6d92a..b73e686 100644 --- a/Sources/ProxyReloader/Proxy.swift +++ b/Sources/ProxyReloader/Proxy.swift @@ -1,4 +1,4 @@ -#if DEBUG +#if DEBUG || os(macOS) import Foundation import MultipeerConnectivity diff --git a/Sources/ProxyReloader/ProxyReloader.swift b/Sources/ProxyReloader/ProxyReloader.swift index aceab85..c5139ea 100644 --- a/Sources/ProxyReloader/ProxyReloader.swift +++ b/Sources/ProxyReloader/ProxyReloader.swift @@ -1,4 +1,4 @@ -#if DEBUG +#if DEBUG || os(macOS) import Foundation import MultipeerConnectivity diff --git a/Sources/ProxyReloader/RuntimePeer.swift b/Sources/ProxyReloader/RuntimePeer.swift index b2d0cf5..297f650 100644 --- a/Sources/ProxyReloader/RuntimePeer.swift +++ b/Sources/ProxyReloader/RuntimePeer.swift @@ -1,4 +1,4 @@ -#if DEBUG +#if DEBUG || os(macOS) import Foundation import MultipeerConnectivity diff --git a/Sources/StandaloneReloader/StandaloneReloader.swift b/Sources/StandaloneReloader/StandaloneReloader.swift index 7206012..d1ee789 100644 --- a/Sources/StandaloneReloader/StandaloneReloader.swift +++ b/Sources/StandaloneReloader/StandaloneReloader.swift @@ -1,4 +1,4 @@ -#if DEBUG +#if DEBUG || os(macOS) import Foundation import Combine diff --git a/SwiftHotReload.podspec b/SwiftHotReload.podspec index fc86ee4..760d98d 100644 --- a/SwiftHotReload.podspec +++ b/SwiftHotReload.podspec @@ -14,6 +14,12 @@ Pod::Spec.new do |spec| # spec.tvos.deployment_target = "9.0" # spec.visionos.deployment_target = "1.0" spec.source = { :git => "https://github.com/banjun/SwiftHotReload.git", :tag => "#{spec.version}" } - spec.source_files = "Sources/**/*.swift" + spec.source_files = "Sources/{Core,StandaloneReloader,ProxyReloader}/**/*.swift" spec.swift_version = "5.1" + + spec.ios.deployment_target = "14.0" + spec.osx.deployment_target = "11.0" + # spec.watchos.deployment_target = "2.0" + # spec.tvos.deployment_target = "9.0" + # spec.visionos.deployment_target = "1.0" end diff --git a/SwiftHotReload.xcworkspace/contents.xcworkspacedata b/SwiftHotReload.xcworkspace/contents.xcworkspacedata index 878d5bd..10ee0b9 100644 --- a/SwiftHotReload.xcworkspace/contents.xcworkspacedata +++ b/SwiftHotReload.xcworkspace/contents.xcworkspacedata @@ -4,6 +4,13 @@ + + + + From d1d6f6f0b26deca327a8b1c09d7d3a7f4a523a36 Mon Sep 17 00:00:00 2001 From: banjun Date: Tue, 14 Nov 2023 21:52:02 +0900 Subject: [PATCH 10/17] simplify podspec & Package.swift --- Package.swift | 4 +--- SwiftHotReload.podspec | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/Package.swift b/Package.swift index 745b1da..8a4a702 100644 --- a/Package.swift +++ b/Package.swift @@ -13,14 +13,12 @@ let package = Package( .target( name: "SwiftHotReload", path: "Sources", - sources: ["Core", "StandaloneReloader", "ProxyReloader"], swiftSettings: [.define("DEBUG", .when(configuration: .debug))] ), .executableTarget( name: "BuildHelper", dependencies: ["SwiftHotReload"], - path: "Sources", - sources: ["BuildHelper"] + path: "BuildHelper/Sources" ), .testTarget(name: "SwiftHotReloadTests", dependencies: ["SwiftHotReload"]), ] diff --git a/SwiftHotReload.podspec b/SwiftHotReload.podspec index 760d98d..c3e2aef 100644 --- a/SwiftHotReload.podspec +++ b/SwiftHotReload.podspec @@ -14,7 +14,7 @@ Pod::Spec.new do |spec| # spec.tvos.deployment_target = "9.0" # spec.visionos.deployment_target = "1.0" spec.source = { :git => "https://github.com/banjun/SwiftHotReload.git", :tag => "#{spec.version}" } - spec.source_files = "Sources/{Core,StandaloneReloader,ProxyReloader}/**/*.swift" + spec.source_files = "Sources/**/*.swift" spec.swift_version = "5.1" spec.ios.deployment_target = "14.0" From 6480b645d955e115bffa51aaed924fd8dc2b6fe3 Mon Sep 17 00:00:00 2001 From: banjun Date: Tue, 14 Nov 2023 21:54:23 +0900 Subject: [PATCH 11/17] use latest github actions/checkout --- .github/workflows/main.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 51d5795..ee04882 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -16,7 +16,7 @@ jobs: - uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: '^15.0.1' - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - run: swift build -c ${{ matrix.configuration }} podspec: strategy: @@ -28,6 +28,6 @@ jobs: - uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: '^15.0.1' - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - run: bundle install - run: bundle exec pod lib lint --platforms=${{ matrix.platform }} --configuration=${{ matrix.configuration }} From fd324d881e3a5a06292a266dd4c5fbb6849e902c Mon Sep 17 00:00:00 2001 From: banjun Date: Wed, 15 Nov 2023 16:10:52 +0900 Subject: [PATCH 12/17] update README to describe abount Standalone Reloader, Proxy Reloader and BuildHelper --- README.md | 55 ++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index abc6645..fa3176f 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,17 @@ SwiftHotReload is an experimental project. We investigate a real world applicati * Xcode 15.x * Host macOS 13.x, 14.x -* Runtime macOS app -* Runtime simulators for iOS, iPadOS, and possibly visionOS + +We can use either Standalone Reloader or Proxy Reloader. Standalone Reloader runs all required tasks on the runtime target process. Proxy Reloader runs on the runtime target process and receives dylibs from BuildHelper via network. BuildHelper runs on the host Mac and monitors file changes to build the file and send dylibs to Proxy on the target. + +| Runtime Target App | Standalone | Proxy & BuildHelper | +|-------------------------------|------------|---------------------| +| iOS app on Simulator | ✅ | ✅ | +| iOS app on Device | ❌ | ✅ (codesign with Individual, Company or Enterprise ADP) | +| macOS app (App Sandbox = NO) | ✅ | ✅ | +| macOS app (App Sandbox = YES) | ❌ | ❌ | +| macOS app (Designed for iPad) | ❌ | ❌ | + ## Features @@ -24,11 +33,10 @@ SwiftHotReload is an experimental project. We investigate a real world applicati * SPM project structures * CocoaPods project structures * Update trigger for SwiftUI views +* Helper app on host & Reload on devices ### TODOs (not yet implemented, nice to have) -* Helper app on host -* Reload on devices * Less invasive: be easy to adopt & compatible for App Store submission * Build settings (-Xfrontend ...) * Sandbox restrictions for macOS app @@ -38,7 +46,6 @@ SwiftHotReload is an experimental project. We investigate a real world applicati ## How to use the Example app * Open `SwiftHotReload.xcworkspace` -* Modify `targetSwiftFile:` file path to along with your path in `App.swift` * Run `SwiftHotReloadExample` on Mac or any simulators * Edit `ReplaceView.swift` and save @@ -66,17 +73,28 @@ Set up app as described below and build & run on a supported platform. ```swift extension App { - static let reloader = StandaloneReloader( + static let reloader = StandaloneReloader(monitoredSwiftFile: URL(fileURLWithPath: #filePath).deletingLastPathComponent() // file path to be monitored - monitoredSwiftFile: Env.shared.estimatedHomeDir! - .appendingPathComponent("path_to_project/RuntimeOverrides.swift") - ) + .appendingPathComponent("RuntimeOverrides.swift") : _ = App.reloader // use to load the lazy static property above and start a file monitor } ``` -### Disable sandbox (only required for macOS app target): +### (on iOS Device) Use ProxyReloader & BuildHelper + +If the app is for iOS Device, use `ProxyReloader` instead of `StandaloneReloader`. Run BuildHelper separately on the host Mac: + +``` +git clone https://github.com/banjun/SwiftHotReload.git +cd SwiftHotReload + +swift run BuildHelper -c debug +``` + +Alternatively to `swift run`, we can run BuildHelper as an app (not CLI) using BuildHelper target on SwiftHotReload.xcworkspace. + +### (only required for macOS app target) Disable App Sandbox: Modify the app entitlements file: @@ -84,7 +102,7 @@ Modify the app entitlements file: App Sandbox = NO ``` -### (Optionally but recommended) set build settings: +### (optional but recommended) Set build settings: * Add to `OTHER_SWIFT_FLAGS` of the app target * `-Xfrontend` `-enable-implicit-dynamic` @@ -92,6 +110,13 @@ App Sandbox = NO * `-Xfrontend` `-enable-private-imports` * use the flag instead of making related `func`s or `var`s visible by removing `private` + +### (optional) Insert hooks to update SwiftUI view after reloadings: + +```swift +@ObservedObject private var reloader = App.reloader +``` + ### Create `path_to_project/RuntimeOverrides.swift`: Any funcs/vars can be replaced (not only for SwiftUI). @@ -106,11 +131,3 @@ extension ContentView { // <- typically use extension for a type containing func } } ``` - -### (Optionally) to update SwiftUI view after reloadings: - -```swift -@ObservedObject private var reloader = App.reloader -``` - - From 27a1a9c1362ca1c794374c88edd325f31b0964de Mon Sep 17 00:00:00 2001 From: banjun Date: Wed, 15 Nov 2023 16:14:42 +0900 Subject: [PATCH 13/17] update README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fa3176f..fb69fe6 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,8 @@ We can use either Standalone Reloader or Proxy Reloader. Standalone Reloader run | iOS app on Simulator | ✅ | ✅ | | iOS app on Device | ❌ | ✅ (codesign with Individual, Company or Enterprise ADP) | | macOS app (App Sandbox = NO) | ✅ | ✅ | -| macOS app (App Sandbox = YES) | ❌ | ❌ | -| macOS app (Designed for iPad) | ❌ | ❌ | +| macOS app (App Sandbox = YES) | ❌ | ❌ (codesign cannot be trusted to load) | +| macOS app (Designed for iPad) | ❌ | ❌ (codesign cannot be trusted to load) | ## Features From 8cc89566db48d15abe16187bf7713aa359b2bdb8 Mon Sep 17 00:00:00 2001 From: banjun Date: Wed, 15 Nov 2023 17:34:47 +0900 Subject: [PATCH 14/17] add dlopen error type --- Sources/Core/Loader.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Sources/Core/Loader.swift b/Sources/Core/Loader.swift index e535717..dee27dd 100644 --- a/Sources/Core/Loader.swift +++ b/Sources/Core/Loader.swift @@ -5,6 +5,7 @@ final actor Loader { enum Error: Swift.Error { case symbol_not_found_in_flat_namespace(String) case code_signature_invalid(String) + case system_policy(String) case unknown(String) } @@ -22,6 +23,10 @@ final actor Loader { NSLog("%@", "🍓 code signature invalid: on device dylibs needs to be signed by Individual, Company or Enterprise identity (it cannot be verified by Personal identity. see `amfid` process message on the device console)") throw Error.code_signature_invalid(error) } + if error.contains("library load disallowed by system policy") { + NSLog("%@", "🍓 possible workarounds: turn off App Sandbox") + throw Error.system_policy(error) + } throw Error.unknown(error) } } From a04532991f2edaacc5b059907c8af530b60a2059 Mon Sep 17 00:00:00 2001 From: banjun Date: Wed, 15 Nov 2023 17:36:21 +0900 Subject: [PATCH 15/17] add -enable-testing for CI as currently depends on the feature integrating BuildHelper in release configuration --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ee04882..6e42c59 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -17,7 +17,7 @@ jobs: with: xcode-version: '^15.0.1' - uses: actions/checkout@v4 - - run: swift build -c ${{ matrix.configuration }} + - run: swift build -c ${{ matrix.configuration }} -Xswiftc -enable-testing podspec: strategy: matrix: From 3e54dbd04d300fb09670d639e132a21ad237af94 Mon Sep 17 00:00:00 2001 From: banjun Date: Wed, 15 Nov 2023 20:15:18 +0900 Subject: [PATCH 16/17] use MCBrowserViewController to require developer click to opt-in to connect to Proxy Reloader from BuildHelper --- .../BuildHelper.xcodeproj/project.pbxproj | 12 +- BuildHelper/Sources/BuildHelper.swift | 7 +- BuildHelper/Sources/BuildHelperApp.swift | 2 + .../Sources/MCBrowserViewControllerView.swift | 44 +++++++ ...{ProxyBlowser.swift => ProxyBrowser.swift} | 112 +++++++----------- Sources/ProxyReloader/Proxy.swift | 14 ++- 6 files changed, 111 insertions(+), 80 deletions(-) create mode 100644 BuildHelper/Sources/MCBrowserViewControllerView.swift rename BuildHelper/Sources/{ProxyBlowser.swift => ProxyBrowser.swift} (55%) diff --git a/BuildHelper/BuildHelper.xcodeproj/project.pbxproj b/BuildHelper/BuildHelper.xcodeproj/project.pbxproj index 9284f97..2fab050 100644 --- a/BuildHelper/BuildHelper.xcodeproj/project.pbxproj +++ b/BuildHelper/BuildHelper.xcodeproj/project.pbxproj @@ -8,8 +8,9 @@ /* Begin PBXBuildFile section */ 63599A3E2B03A121009186F4 /* BuildHelperApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63599A3B2B03A121009186F4 /* BuildHelperApp.swift */; }; - 63599A3F2B03A121009186F4 /* ProxyBlowser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63599A3C2B03A121009186F4 /* ProxyBlowser.swift */; }; + 63599A3F2B03A121009186F4 /* ProxyBrowser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63599A3C2B03A121009186F4 /* ProxyBrowser.swift */; }; 63599A402B03A121009186F4 /* BuildHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63599A3D2B03A121009186F4 /* BuildHelper.swift */; }; + 638083822B04D5FC00A39A64 /* MCBrowserViewControllerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638083812B04D5FC00A39A64 /* MCBrowserViewControllerView.swift */; }; 63A7469E2AFDD748003FA3AC /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 63A7469D2AFDD748003FA3AC /* Assets.xcassets */; }; 63A746A12AFDD748003FA3AC /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 63A746A02AFDD748003FA3AC /* Preview Assets.xcassets */; }; 63A746B12AFDD8F4003FA3AC /* SwiftHotReload.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 63A746B02AFDD8F4003FA3AC /* SwiftHotReload.framework */; }; @@ -32,8 +33,9 @@ /* Begin PBXFileReference section */ 63599A3B2B03A121009186F4 /* BuildHelperApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BuildHelperApp.swift; sourceTree = ""; }; - 63599A3C2B03A121009186F4 /* ProxyBlowser.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProxyBlowser.swift; sourceTree = ""; }; + 63599A3C2B03A121009186F4 /* ProxyBrowser.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProxyBrowser.swift; sourceTree = ""; }; 63599A3D2B03A121009186F4 /* BuildHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BuildHelper.swift; sourceTree = ""; }; + 638083812B04D5FC00A39A64 /* MCBrowserViewControllerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MCBrowserViewControllerView.swift; sourceTree = ""; }; 63A746962AFDD742003FA3AC /* BuildHelper.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BuildHelper.app; sourceTree = BUILT_PRODUCTS_DIR; }; 63A7469D2AFDD748003FA3AC /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 63A746A02AFDD748003FA3AC /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; @@ -58,7 +60,8 @@ isa = PBXGroup; children = ( 63599A3B2B03A121009186F4 /* BuildHelperApp.swift */, - 63599A3C2B03A121009186F4 /* ProxyBlowser.swift */, + 63599A3C2B03A121009186F4 /* ProxyBrowser.swift */, + 638083812B04D5FC00A39A64 /* MCBrowserViewControllerView.swift */, 63599A3D2B03A121009186F4 /* BuildHelper.swift */, ); path = Sources; @@ -174,7 +177,8 @@ files = ( 63599A3E2B03A121009186F4 /* BuildHelperApp.swift in Sources */, 63599A402B03A121009186F4 /* BuildHelper.swift in Sources */, - 63599A3F2B03A121009186F4 /* ProxyBlowser.swift in Sources */, + 638083822B04D5FC00A39A64 /* MCBrowserViewControllerView.swift in Sources */, + 63599A3F2B03A121009186F4 /* ProxyBrowser.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/BuildHelper/Sources/BuildHelper.swift b/BuildHelper/Sources/BuildHelper.swift index 7be1512..1e6a0b8 100644 --- a/BuildHelper/Sources/BuildHelper.swift +++ b/BuildHelper/Sources/BuildHelper.swift @@ -7,7 +7,7 @@ import Foundation import Combine public final class BuildHelper: ObservableObject { - private let proxyBrowser = ProxyBrowser() + let proxyBrowser = ProxyBrowser() @Published public private(set) var monitoredFile: URL? { didSet { @@ -31,8 +31,11 @@ public final class BuildHelper: ObservableObject { public init() { Task { @MainActor in - await proxyBrowser.$runtimePeer.receive(on: DispatchQueue.main).sink { [weak self] runtimePeer in + await proxyBrowser.$runtimePeers.receive(on: DispatchQueue.main).sink { [weak self] runtimePeers in guard let self else { return } + // TODO: support multiple peers + let runtimePeer = runtimePeers.first + NSLog("%@", "🍓 TODO: support multiple peers: \(runtimePeers.count) peers connected. currently using only first peer \(String(describing: runtimePeer))") monitoredFile = runtimePeer?.builderParams?.targetSwiftFile Task { await self.core.setRuntimePeer(runtimePeer) } }.store(in: &cancellables) diff --git a/BuildHelper/Sources/BuildHelperApp.swift b/BuildHelper/Sources/BuildHelperApp.swift index 5276d81..6ca346a 100644 --- a/BuildHelper/Sources/BuildHelperApp.swift +++ b/BuildHelper/Sources/BuildHelperApp.swift @@ -37,6 +37,8 @@ struct BuildHelperApp: SwiftUI.App { Text("Date Reloaded" + "\n" + (buildHelper.dateReloaded?.formatted(date: .numeric, time: .complete) ?? "Never")) .multilineTextAlignment(.center) + + buildHelper.proxyBrowser.browserView } .padding() } diff --git a/BuildHelper/Sources/MCBrowserViewControllerView.swift b/BuildHelper/Sources/MCBrowserViewControllerView.swift new file mode 100644 index 0000000..1662d34 --- /dev/null +++ b/BuildHelper/Sources/MCBrowserViewControllerView.swift @@ -0,0 +1,44 @@ +#if os(macOS) +import Foundation +import MultipeerConnectivity +import SwiftUI +@testable import SwiftHotReload + +struct MCBrowserViewControllerView: NSViewControllerRepresentable { + var browser: MCNearbyServiceBrowser + var session: MCSession + + func makeNSViewController(context: Context) -> MCBrowserViewController { + let vc = MCBrowserViewController(browser: browser, session: session) + vc.delegate = context.coordinator + vc.maximumNumberOfPeers = 1 + return vc + } + + func makeCoordinator() -> Coordinator { + .init() + } + + final class Coordinator: NSObject, MCBrowserViewControllerDelegate { + func browserViewControllerDidFinish(_ browserViewController: MCBrowserViewController) { + NSLog("%@", "🍓 \(#function) Done pressed. ignored. continue searching...") + } + func browserViewControllerWasCancelled(_ browserViewController: MCBrowserViewController) { + NSLog("%@", "🍓 \(#function) Cancel pressed. ignored. continue searching...") + } + + func browserViewController(_ browserViewController: MCBrowserViewController, shouldPresentNearbyPeer peerID: MCPeerID, withDiscoveryInfo info: [String : String]?) -> Bool { + NSLog("%@", "🍓 \(#function) peerID = \(peerID), info = \(String(describing: info))") + guard info == MultipeerConnectivityConstants.serverDiscoveryInfo else { + NSLog("%@", "🍓 \(#function) ignore peer \(peerID) as it's not a server") + return false + } + return true + } + } + + func updateNSViewController(_ vc: MCBrowserViewController, context: Context) { + vc.delegate = context.coordinator + } +} +#endif diff --git a/BuildHelper/Sources/ProxyBlowser.swift b/BuildHelper/Sources/ProxyBrowser.swift similarity index 55% rename from BuildHelper/Sources/ProxyBlowser.swift rename to BuildHelper/Sources/ProxyBrowser.swift index 831c39f..60d1994 100644 --- a/BuildHelper/Sources/ProxyBlowser.swift +++ b/BuildHelper/Sources/ProxyBrowser.swift @@ -7,81 +7,77 @@ import MultipeerConnectivity @testable import SwiftHotReload // NOTE: use internal methods. SPM does not allow overlapping sources for a single Package.swift final actor ProxyBrowser { - @Published private(set) var runtimePeer: RuntimePeer? { - didSet { - runtimePeer?.session.delegate = sessionDelegate - } - } + @Published private(set) var runtimePeers: [RuntimePeer] = [] private let peerID: MCPeerID + private let session: MCSession private let browser: MCNearbyServiceBrowser - private let browserDelegate: BrowserDelegate private let sessionDelegate: SessionDelegate = .init() + let browserView: MCBrowserViewControllerView init(hostName: String = ProcessInfo().hostName, bundleID: String? = Env.shared.CFBundleIdentifier, processID: Int32 = ProcessInfo().processIdentifier) { let displayName = String("Client[\(hostName)] \(bundleID ?? "cli")(\(processID))".utf8.prefix(63))! self.peerID = MCPeerID(displayName: displayName) + self.session = MCSession(peer: peerID, securityIdentity: nil, encryptionPreference: .required) self.browser = MCNearbyServiceBrowser(peer: peerID, serviceType: MultipeerConnectivityConstants.serviceType) - self.browserDelegate = BrowserDelegate() + self.browserView = MCBrowserViewControllerView(browser: browser, session: session) - self.browser.delegate = browserDelegate - Task { - browserDelegate.owner = self +// Task { + session.delegate = sessionDelegate sessionDelegate.owner = self - await start() - } +// } } - // MARK: - MCNearbyServiceBrowserDelegate - - private final class BrowserDelegate: NSObject, MCNearbyServiceBrowserDelegate { - unowned var owner: ProxyBrowser? - override init() { super.init() } - - func browser(_ browser: MCNearbyServiceBrowser, foundPeer peerID: MCPeerID, withDiscoveryInfo info: [String : String]?) { - Task { await owner?.browser(browser, foundPeer: peerID, withDiscoveryInfo: info) } - } + func start() { + NSLog("%@", "🍓 ProxyBrowser.\(#function)") + browser.startBrowsingForPeers() + } - func browser(_ browser: MCNearbyServiceBrowser, lostPeer peerID: MCPeerID) { - Task { await owner?.browser(browser, lostPeer: peerID) } - } + func stop() { + NSLog("%@", "🍓 ProxyBrowser.\(#function)") + browser.stopBrowsingForPeers() } - func browser(_ browser: MCNearbyServiceBrowser, foundPeer peerID: MCPeerID, withDiscoveryInfo info: [String : String]?) { - NSLog("%@", "🍓 \(#function) peerID = \(peerID), info = \(String(describing: info))") - guard info == MultipeerConnectivityConstants.serverDiscoveryInfo else { - NSLog("%@", "🍓 \(#function) ignore peer \(peerID) as it's not a server") - return - } - guard runtimePeer == nil else { - NSLog("%@", "🍓 \(#function) ⚠️ TODO: support mutiple sessions") - return - } + // MARK: - MCSessionDelegate - NSLog("%@", "🍓 \(#function) ⚠️ TODO: some auth to refrain from sending secret dylib to the unidentified server") - let session = MCSession(peer: self.peerID, securityIdentity: nil, encryptionPreference: .required) - self.browser.invitePeer(peerID, to: session, withContext: nil, timeout: 30) - runtimePeer = .init(session: session, peerID: peerID, builderParams: nil) + func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState) { + switch state { + case .notConnected: + NSLog("%@", "🍓 \(#function) .notConnected: peerID = \(peerID)") + runtimePeers = runtimePeers.filter { $0.peerID != peerID } + case .connecting: + NSLog("%@", "🍓 \(#function) .connecting: peerID = \(peerID)") + case .connected: + NSLog("%@", "🍓 \(#function) .connected: peerID = \(peerID)") + // NOTE: it is a good idea doing some auth to refrain from sending secret dylib to the unidentified server + runtimePeers.append(.init(session: session, peerID: peerID, builderParams: nil)) + @unknown default: + NSLog("%@", "🍓 \(#function) @unknown default: peerID = \(peerID)") + } } - func browser(_ browser: MCNearbyServiceBrowser, lostPeer peerID: MCPeerID) { - NSLog("%@", "🍓 \(#function) peerID = \(peerID)") - if runtimePeer?.peerID == peerID { - runtimePeer = nil + func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) { + NSLog("%@", "🍓 \(#function) data = \(data.count) bytes, peerID = \(peerID)") + do { + let builderParams = try JSONDecoder().decode(Builder.InputParameters.self, from: data) + NSLog("%@", "🍓 \(#function) using received build parameters when build for the session: \(builderParams)") + guard let index = (runtimePeers.firstIndex { $0.peerID == peerID }) else { return } + runtimePeers[index].builderParams = builderParams + } catch { + NSLog("%@", "🍓 \(#function) error = \(error)") } } +} - // MARK: - MCSessionDelegate - +private extension ProxyBrowser { private final class SessionDelegate: NSObject, MCSessionDelegate { unowned var owner: ProxyBrowser? override init() { super.init() } func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState) { - NSLog("%@", "🍓 \(#function) peerID = \(peerID), state = \(state)") + Task { await owner?.session(session, peer: peerID, didChange: state) } } func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) { - NSLog("%@", "🍓 \(#function) data = \(data.count) bytes, peerID = \(peerID)") Task { await owner?.session(session, didReceive: data, fromPeer: peerID) } } @@ -97,29 +93,5 @@ final actor ProxyBrowser { NSLog("%@", "🍓 \(#function) resourceName = \(resourceName), peerID = \(peerID), localURL = \(String(describing: localURL)), error = \(String(describing: error))") } } - - func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) { - do { - let builderParams = try JSONDecoder().decode(Builder.InputParameters.self, from: data) - NSLog("%@", "🍓 \(#function) using received build parameters when build for the session: \(builderParams)") - var runtimePeer = runtimePeer - runtimePeer?.builderParams = builderParams - self.runtimePeer = runtimePeer - } catch { - NSLog("%@", "🍓 \(#function) error = \(error)") - } - } - - // MARK: - - - func start() { - NSLog("%@", "🍓 ProxyBrowser.\(#function)") - browser.startBrowsingForPeers() - } - - func stop() { - NSLog("%@", "🍓 ProxyBrowser.\(#function)") - browser.stopBrowsingForPeers() - } } #endif diff --git a/Sources/ProxyReloader/Proxy.swift b/Sources/ProxyReloader/Proxy.swift index b73e686..8f8bf9c 100644 --- a/Sources/ProxyReloader/Proxy.swift +++ b/Sources/ProxyReloader/Proxy.swift @@ -12,6 +12,7 @@ final actor Proxy { private let advertiser: MCNearbyServiceAdvertiser private var session: MCSession? { didSet { + oldValue?.disconnect() session?.delegate = sessionDelegate } } @@ -26,7 +27,7 @@ final actor Proxy { init(hostName: String = ProcessInfo().hostName, bundleID: String = Env.shared.CFBundleIdentifier!, processID: Int32 = ProcessInfo().processIdentifier, builderParams: Builder.InputParameters) { self.builderParams = builderParams // the doc: The display name is intended for use in UI elements, and should be short and descriptive of the local peer. The maximum allowable length is 63 bytes in UTF-8 encoding. The displayName parameter may not be nil or an empty string. - let displayName = String("Server[\(hostName)] \(bundleID)(\(processID))".utf8.prefix(63))! + let displayName = String("\(hostName) \(bundleID)(\(processID))".utf8.prefix(63))! self.peerID = MCPeerID(displayName: displayName) self.advertiser = MCNearbyServiceAdvertiser(peer: peerID, discoveryInfo: MultipeerConnectivityConstants.serverDiscoveryInfo, serviceType: MultipeerConnectivityConstants.serviceType) self.advertiserDelegate = AdvertiserDelegate() @@ -47,7 +48,7 @@ final actor Proxy { func stop() { advertiser.stopAdvertisingPeer() - session?.disconnect() + session = nil } // MARK: - MCNearbyServiceAdvertiserDelegate @@ -67,7 +68,10 @@ final actor Proxy { private func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didReceiveInvitationFromPeer peerID: MCPeerID, withContext context: Data?, invitationHandler: @escaping (Bool, MCSession?) -> Void) { NSLog("%@", "🍓 \(#function) advertiser = \(advertiser), peerID = \(peerID), context = \(context?.count ?? 0) bytes") - guard session == nil else { return } + guard session == nil else { + NSLog("%@", "🍓 \(#function) ignored additional session") + return + } let session = MCSession(peer: self.peerID, securityIdentity: nil, encryptionPreference: .required) self.session = session @@ -111,10 +115,12 @@ final actor Proxy { private func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState) { switch state { - case .notConnected: break + case .notConnected: + self.session = nil case .connecting: break case .connected: do { + NSLog("%@", "🍓 \(#function) connected: sending builderParams = \(builderParams)") let payload = try JSONEncoder().encode(builderParams) try self.session?.send(payload, toPeers: [peerID], with: .reliable) } catch { From c997dae313550649db64399da359fe6d324a4d48 Mon Sep 17 00:00:00 2001 From: banjun Date: Fri, 24 Nov 2023 15:12:28 +0900 Subject: [PATCH 17/17] add user consent alert when connecting to peer found in multipeer network --- .../SwiftHotReload.xcodeproj/project.pbxproj | 4 ++ Sources/ProxyReloader/Proxy.swift | 17 ++++--- Sources/ProxyReloader/ProxyReloader.swift | 6 ++- Sources/ProxyReloader/UserConsent.swift | 47 +++++++++++++++++++ 4 files changed, 67 insertions(+), 7 deletions(-) create mode 100644 Sources/ProxyReloader/UserConsent.swift diff --git a/FrameworkTarget/SwiftHotReload.xcodeproj/project.pbxproj b/FrameworkTarget/SwiftHotReload.xcodeproj/project.pbxproj index 0a84f96..4bc45e9 100644 --- a/FrameworkTarget/SwiftHotReload.xcodeproj/project.pbxproj +++ b/FrameworkTarget/SwiftHotReload.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ 63A746BA2AFE0286003FA3AC /* RuntimePeer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63A746B92AFE0286003FA3AC /* RuntimePeer.swift */; }; 63A746BC2AFE02EA003FA3AC /* Proxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63A746BB2AFE02EA003FA3AC /* Proxy.swift */; }; 63A746C12AFE0700003FA3AC /* MultipeerConnectivityConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63A746C02AFE0700003FA3AC /* MultipeerConnectivityConstants.swift */; }; + 63FE42F92B107425000A950E /* UserConsent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63FE42F82B107425000A950E /* UserConsent.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -35,6 +36,7 @@ 63A746B92AFE0286003FA3AC /* RuntimePeer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuntimePeer.swift; sourceTree = ""; }; 63A746BB2AFE02EA003FA3AC /* Proxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Proxy.swift; sourceTree = ""; }; 63A746C02AFE0700003FA3AC /* MultipeerConnectivityConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultipeerConnectivityConstants.swift; sourceTree = ""; }; + 63FE42F82B107425000A950E /* UserConsent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserConsent.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -103,6 +105,7 @@ isa = PBXGroup; children = ( 6355B0F42AFB6899008C4C50 /* ProxyReloader.swift */, + 63FE42F82B107425000A950E /* UserConsent.swift */, 63A746C02AFE0700003FA3AC /* MultipeerConnectivityConstants.swift */, 63A746BB2AFE02EA003FA3AC /* Proxy.swift */, 63A746B92AFE0286003FA3AC /* RuntimePeer.swift */, @@ -207,6 +210,7 @@ 6355B0F52AFB6899008C4C50 /* ProxyReloader.swift in Sources */, 63A746C12AFE0700003FA3AC /* MultipeerConnectivityConstants.swift in Sources */, 630C245F2AEBD4E10012C490 /* Env.swift in Sources */, + 63FE42F92B107425000A950E /* UserConsent.swift in Sources */, 63458ABF2AFA6232001A5630 /* Loader.swift in Sources */, 63458AC32AFA6247001A5630 /* Builder.swift in Sources */, ); diff --git a/Sources/ProxyReloader/Proxy.swift b/Sources/ProxyReloader/Proxy.swift index 8f8bf9c..d4d27d8 100644 --- a/Sources/ProxyReloader/Proxy.swift +++ b/Sources/ProxyReloader/Proxy.swift @@ -5,6 +5,8 @@ import MultipeerConnectivity final actor Proxy { private let loader: Loader = .init() @Published private(set) var receivedDylibFiles: [URL] = [] + private var shouldConnectToBuilder: (_ title: String, _ message: String) async -> Bool + func setShouldConnectToBuilder(_ shouldConnectToBuilder: @escaping (String, String) async -> Bool) { self.shouldConnectToBuilder = shouldConnectToBuilder } private let builderParams: Builder.InputParameters @@ -24,8 +26,9 @@ final actor Proxy { case fileAlreadyExists(String) } - init(hostName: String = ProcessInfo().hostName, bundleID: String = Env.shared.CFBundleIdentifier!, processID: Int32 = ProcessInfo().processIdentifier, builderParams: Builder.InputParameters) { + init(hostName: String = ProcessInfo().hostName, bundleID: String = Env.shared.CFBundleIdentifier!, processID: Int32 = ProcessInfo().processIdentifier, builderParams: Builder.InputParameters, shouldConnectToBuilder: @escaping (_ title: String, _ message: String) async -> Bool) { self.builderParams = builderParams + self.shouldConnectToBuilder = shouldConnectToBuilder // the doc: The display name is intended for use in UI elements, and should be short and descriptive of the local peer. The maximum allowable length is 63 bytes in UTF-8 encoding. The displayName parameter may not be nil or an empty string. let displayName = String("\(hostName) \(bundleID)(\(processID))".utf8.prefix(63))! self.peerID = MCPeerID(displayName: displayName) @@ -73,11 +76,13 @@ final actor Proxy { return } - let session = MCSession(peer: self.peerID, securityIdentity: nil, encryptionPreference: .required) - self.session = session - - NSLog("%@", "🍓 \(#function) ⚠️ TODO: some auth to refrain from loading dylibs sent from the unidentified build helper") - invitationHandler(true, session) // TODO: some auth + Task { + let trusted = await shouldConnectToBuilder("⚠️ Connect to a Builder \(peerID)?", "SwiftHotReload loads any code from the Builder") + if trusted { + self.session = MCSession(peer: self.peerID, securityIdentity: nil, encryptionPreference: .required) + } + invitationHandler(trusted, self.session) + } } private func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didNotStartAdvertisingPeer error: Swift.Error) { diff --git a/Sources/ProxyReloader/ProxyReloader.swift b/Sources/ProxyReloader/ProxyReloader.swift index c5139ea..a830f98 100644 --- a/Sources/ProxyReloader/ProxyReloader.swift +++ b/Sources/ProxyReloader/ProxyReloader.swift @@ -8,12 +8,16 @@ public final class ProxyReloader: ObservableObject { @Published public private(set) var dateReloaded: Date? public init(_ builderParams: Builder.InputParameters) { - self.proxy = Proxy(builderParams: builderParams) + self.proxy = Proxy(builderParams: builderParams, shouldConnectToBuilder: UserConsent.alert) Task { await proxy.$receivedDylibFiles.map {_ in Date() }.receive(on: DispatchQueue.main).assign(to: &$dateReloaded) await proxy.start() } } + + public func setShouldConnectToBuilder(_ shouldConnectToBuilder: @escaping (String, String) async -> Bool) { + Task { await proxy.setShouldConnectToBuilder(shouldConnectToBuilder) } + } } #endif diff --git a/Sources/ProxyReloader/UserConsent.swift b/Sources/ProxyReloader/UserConsent.swift new file mode 100644 index 0000000..674b907 --- /dev/null +++ b/Sources/ProxyReloader/UserConsent.swift @@ -0,0 +1,47 @@ +import Foundation + +enum UserConsent { +} + +#if canImport(AppKit) +import AppKit +extension UserConsent { + @MainActor + static func alert(_ title: String, _ message: String) async -> Bool { + await withCheckedContinuation { continuation in + let alert = NSAlert() + alert.alertStyle = .critical + alert.messageText = title + alert.informativeText = message + alert.addButton(withTitle: "Trust").hasDestructiveAction = true + alert.addButton(withTitle: "Cancel") + + continuation.resume(returning: alert.runModal() == .alertFirstButtonReturn) + } + } +} + +#elseif canImport(UIKit) +import UIKit +extension UserConsent { + @MainActor + static func alert(_ title: String, _ message: String) async -> Bool { + guard let window = (UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.compactMap { $0.windows.first { $0.isKeyWindow } }.first) else { + NSLog("%@", "⚠️ cannot get keyWindow from scenes = \(UIApplication.shared.connectedScenes)") + return false + } + + var vc = window.rootViewController + while let pvc = vc?.presentedViewController { + vc = pvc + } + + return await withCheckedContinuation { continuation in + let ac = UIAlertController(title: title, message: message, preferredStyle: .alert) + ac.addAction(.init(title: "Trust", style: .destructive) {_ in continuation.resume(returning: true) }) + ac.addAction(.init(title: "Cancel", style: .cancel) {_ in continuation.resume(returning: false) }) + vc?.present(ac, animated: true) + } + } +} +#endif