diff --git a/Empusa.xcodeproj/project.pbxproj b/Empusa.xcodeproj/project.pbxproj index 2e518d1..c0fdda5 100644 --- a/Empusa.xcodeproj/project.pbxproj +++ b/Empusa.xcodeproj/project.pbxproj @@ -17,6 +17,8 @@ 3F3B176E2ADDE117000283AE /* DestinationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F3B176D2ADDE117000283AE /* DestinationView.swift */; }; 3F3B17702ADDE16A000283AE /* ResourcesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F3B176F2ADDE16A000283AE /* ResourcesView.swift */; }; 3F3B17722ADDE25D000283AE /* DataProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F3B17712ADDE25D000283AE /* DataProgressView.swift */; }; + 3FCE9D2D2AE0944C00A4E3F5 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 3FCE9D2C2AE0944C00A4E3F5 /* Sparkle */; }; + 3FCE9D312AE17F5F00A4E3F5 /* CheckForUpdatesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FCE9D302AE17F5F00A4E3F5 /* CheckForUpdatesView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -43,6 +45,10 @@ 3F3B176D2ADDE117000283AE /* DestinationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DestinationView.swift; sourceTree = ""; }; 3F3B176F2ADDE16A000283AE /* ResourcesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourcesView.swift; sourceTree = ""; }; 3F3B17712ADDE25D000283AE /* DataProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataProgressView.swift; sourceTree = ""; }; + 3F54EB962AE2553D008A45B0 /* Empusa.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = Empusa.xctestplan; sourceTree = ""; }; + 3FC172272AE1D63A00DCBE28 /* EmpusaMacros */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = EmpusaMacros; sourceTree = ""; }; + 3FCE9D2E2AE0FE2E00A4E3F5 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 3FCE9D302AE17F5F00A4E3F5 /* CheckForUpdatesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckForUpdatesView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -51,6 +57,7 @@ buildActionMask = 2147483647; files = ( 3F21D3412ADD77E700B6A5D9 /* EmpusaKit in Frameworks */, + 3FCE9D2D2AE0944C00A4E3F5 /* Sparkle in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -99,6 +106,7 @@ 3F21D31B2ADADC7400B6A5D9 /* Tests */ = { isa = PBXGroup; children = ( + 3F54EB962AE2553D008A45B0 /* Empusa.xctestplan */, 3F21D31C2ADADC7400B6A5D9 /* EmpusaTests.swift */, ); path = Tests; @@ -107,6 +115,7 @@ 3F21D3352ADADC8D00B6A5D9 /* Resources */ = { isa = PBXGroup; children = ( + 3FCE9D2E2AE0FE2E00A4E3F5 /* Info.plist */, 3F21D30E2ADADC7400B6A5D9 /* Assets.xcassets */, ); path = Resources; @@ -115,6 +124,7 @@ 3F21D3362ADADE8B00B6A5D9 /* Packages */ = { isa = PBXGroup; children = ( + 3FC172272AE1D63A00DCBE28 /* EmpusaMacros */, 3F21D3372ADADEBE00B6A5D9 /* EmpusaKit */, ); path = Packages; @@ -135,6 +145,7 @@ 3F3B176D2ADDE117000283AE /* DestinationView.swift */, 3F3B176F2ADDE16A000283AE /* ResourcesView.swift */, 3F3B17712ADDE25D000283AE /* DataProgressView.swift */, + 3FCE9D302AE17F5F00A4E3F5 /* CheckForUpdatesView.swift */, ); path = "UI Components"; sourceTree = ""; @@ -157,6 +168,7 @@ name = Empusa; packageProductDependencies = ( 3F21D3402ADD77E700B6A5D9 /* EmpusaKit */, + 3FCE9D2C2AE0944C00A4E3F5 /* Sparkle */, ); productName = SwitchSDTool; productReference = 3F21D3072ADADC7300B6A5D9 /* Empusa.app */; @@ -208,6 +220,9 @@ Base, ); mainGroup = 3F21D2FE2ADADC7300B6A5D9; + packageReferences = ( + 3FCE9D2B2AE0944C00A4E3F5 /* XCRemoteSwiftPackageReference "Sparkle" */, + ); productRefGroup = 3F21D3082ADADC7300B6A5D9 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -243,6 +258,7 @@ files = ( 3F3B17702ADDE16A000283AE /* ResourcesView.swift in Sources */, 3F21D30D2ADADC7300B6A5D9 /* MainView.swift in Sources */, + 3FCE9D312AE17F5F00A4E3F5 /* CheckForUpdatesView.swift in Sources */, 3F3B17722ADDE25D000283AE /* DataProgressView.swift in Sources */, 3F21D30B2ADADC7300B6A5D9 /* EmpusaApp.swift in Sources */, 3F3B176E2ADDE117000283AE /* DestinationView.swift in Sources */, @@ -396,11 +412,12 @@ CODE_SIGN_ENTITLEMENTS = Empusa/Empusa.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_TEAM = D97MJ3844Q; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Empusa/Resources/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "εmpusa"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; @@ -409,7 +426,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 1.0.1; + MARKETING_VERSION = 1.0.3; PRODUCT_BUNDLE_IDENTIFIER = nl.trevisa.diego.Empusa; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -425,11 +442,12 @@ CODE_SIGN_ENTITLEMENTS = Empusa/Empusa.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_TEAM = D97MJ3844Q; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Empusa/Resources/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "εmpusa"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; @@ -438,7 +456,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 1.0.1; + MARKETING_VERSION = 1.0.3; PRODUCT_BUNDLE_IDENTIFIER = nl.trevisa.diego.Empusa; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -516,11 +534,27 @@ }; /* End XCConfigurationList section */ +/* Begin XCRemoteSwiftPackageReference section */ + 3FCE9D2B2AE0944C00A4E3F5 /* XCRemoteSwiftPackageReference "Sparkle" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/sparkle-project/Sparkle"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.5.1; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + /* Begin XCSwiftPackageProductDependency section */ 3F21D3402ADD77E700B6A5D9 /* EmpusaKit */ = { isa = XCSwiftPackageProductDependency; productName = EmpusaKit; }; + 3FCE9D2C2AE0944C00A4E3F5 /* Sparkle */ = { + isa = XCSwiftPackageProductDependency; + package = 3FCE9D2B2AE0944C00A4E3F5 /* XCRemoteSwiftPackageReference "Sparkle" */; + productName = Sparkle; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 3F21D2FF2ADADC7300B6A5D9 /* Project object */; diff --git a/Empusa.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Empusa.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 66598a9..deff244 100644 --- a/Empusa.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Empusa.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,23 @@ { "pins" : [ + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle", + "state" : { + "revision" : "1f07f4096e52f19b5e7abaa697b7fc592b7ff57c", + "version" : "2.5.1" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax.git", + "state" : { + "revision" : "74203046135342e4a4a627476dd6caf8b28fe11b", + "version" : "509.0.0" + } + }, { "identity" : "zip", "kind" : "remoteSourceControl", diff --git a/Empusa.xcodeproj/xcshareddata/xcschemes/Empusa.xcscheme b/Empusa.xcodeproj/xcshareddata/xcschemes/Empusa.xcscheme index 08f959f..730b674 100644 --- a/Empusa.xcodeproj/xcshareddata/xcschemes/Empusa.xcscheme +++ b/Empusa.xcodeproj/xcshareddata/xcschemes/Empusa.xcscheme @@ -29,7 +29,7 @@ shouldUseLaunchSchemeArgsEnv = "YES"> diff --git a/Empusa/EmpusaApp.swift b/Empusa/EmpusaApp.swift index 06d283b..cc6b5a9 100644 --- a/Empusa/EmpusaApp.swift +++ b/Empusa/EmpusaApp.swift @@ -1,14 +1,27 @@ import SwiftUI +import Sparkle @main struct EmpusaApp: App { + private let updaterController: SPUStandardUpdaterController = .init( + startingUpdater: true, + updaterDelegate: nil, + userDriverDelegate: nil + ) + var body: some Scene { WindowGroup { MainView( model: EmpusaModel() ) - .frame(width: 680, height: 400) + .frame(width: 680, height: 420) } .windowResizability(.contentSize) + .commands { + CommandGroup(replacing: CommandGroupPlacement.newItem) {} + CommandGroup(after: .appInfo) { + CheckForUpdatesView(updater: updaterController.updater) + } + } } } diff --git a/Empusa/EmpusaModel.swift b/Empusa/EmpusaModel.swift index 3258908..fd55084 100644 --- a/Empusa/EmpusaModel.swift +++ b/Empusa/EmpusaModel.swift @@ -77,7 +77,6 @@ final class EmpusaModel: ObservableObject { // MARK: - Init init() { loadExternalVolumes() - loadResourcesVersions() } // MARK: - Public functions @@ -107,18 +106,27 @@ final class EmpusaModel: ObservableObject { .receive(on: RunLoop.main) .assign(to: &$progress) - do { - try await contentManager.download( - resources: selectedResources, - into: selectedVolume.url, - progressSubject: progressSubject - ) - } catch { - alertData = .init(error: error) - } + let result = await contentManager.download( + resources: selectedResources, + into: selectedVolume, + progressSubject: progressSubject + ) isProcessing = false self.progress = nil + loadResourcesVersions() + + if !result.failedResources.isEmpty { + alertData = .init( + title: "Alert", + message: "Failed to install \(result.failedResourceNames):\n\n\(result.failedResources.last!.error.localizedDescription)" + ) + } else { + alertData = .init( + title: "Success", + message: "Selected resources have been downloaded into the selected destination." + ) + } } } @@ -170,16 +178,24 @@ final class EmpusaModel: ObservableObject { self.alertData = .init( title: "Success", - message: "Backup sucessfully restored to the selected volume" + message: "Backup sucessfully restored to the selected destination." ) } } func loadResourcesVersions() { - Task { [resourceService, weak self] in - self?.isLoadingResources = true - self?.availableResources = await resourceService.fetchResources() - self?.isLoadingResources = false + Task { [weak self] in + guard let self else { return } + self.isLoadingResources = true + + self.availableResources = await resourceService + .fetchResources(for: self.selectedVolume) + + self.selectedResources = availableResources + .filter{ $0.preChecked } + .map { $0.resource } + + self.isLoadingResources = false } } } diff --git a/Empusa/Resources/Info.plist b/Empusa/Resources/Info.plist new file mode 100644 index 0000000..c2bd41e --- /dev/null +++ b/Empusa/Resources/Info.plist @@ -0,0 +1,10 @@ + + + + + SUFeedURL + https://empusa.pacotevicio.app/sparkle.xml + SUPublicEDKey + 62os1l9hx8vk8noKLn706G/htYbpMlhOP2TTJQFa66w= + + diff --git a/Empusa/UI Components/CheckForUpdatesView.swift b/Empusa/UI Components/CheckForUpdatesView.swift new file mode 100644 index 0000000..7516d76 --- /dev/null +++ b/Empusa/UI Components/CheckForUpdatesView.swift @@ -0,0 +1,26 @@ +import SwiftUI +import Sparkle + +final class CheckForUpdatesViewModel: ObservableObject { + @Published var canCheckForUpdates = false + + init(updater: SPUUpdater) { + updater.publisher(for: \.canCheckForUpdates) + .assign(to: &$canCheckForUpdates) + } +} + +struct CheckForUpdatesView: View { + @ObservedObject private var checkForUpdatesViewModel: CheckForUpdatesViewModel + private let updater: SPUUpdater + + init(updater: SPUUpdater) { + self.updater = updater + self.checkForUpdatesViewModel = CheckForUpdatesViewModel(updater: updater) + } + + var body: some View { + Button("Check for Updates...", action: updater.checkForUpdates) + .disabled(!checkForUpdatesViewModel.canCheckForUpdates) + } +} diff --git a/Empusa/UI Components/ResourcesView.swift b/Empusa/UI Components/ResourcesView.swift index dc979ef..565817a 100644 --- a/Empusa/UI Components/ResourcesView.swift +++ b/Empusa/UI Components/ResourcesView.swift @@ -25,8 +25,14 @@ struct ResourcesView: View { model.selectedResources.remove(at: index) } })) { - Text(displayingResource.formattedName) - .frame(maxWidth: .infinity, alignment: .leading) + HStack { + Text(displayingResource.formattedName) + .frame(maxWidth: .infinity, alignment: .leading) + + if let version = displayingResource.version { + Text(version) + } + } } } } @@ -36,6 +42,12 @@ struct ResourcesView: View { } } .disabled(model.isProcessing) + .onAppear(perform: { + model.loadResourcesVersions() + }) + .onChange(of: model.selectedVolume) { _ in + model.loadResourcesVersions() + } } } diff --git a/Packages/EmpusaKit/Package.swift b/Packages/EmpusaKit/Package.swift index e4bbabe..7147369 100644 --- a/Packages/EmpusaKit/Package.swift +++ b/Packages/EmpusaKit/Package.swift @@ -13,13 +13,15 @@ let package = Package( targets: ["EmpusaKit"]), ], dependencies: [ - .package(url: "https://github.com/marmelroy/Zip.git", from: "2.1.2") + .package(url: "https://github.com/marmelroy/Zip.git", from: "2.1.2"), + .package(path: "../EmpusaMacros") ], targets: [ .target( name: "EmpusaKit", dependencies: [ - .product(name: "Zip", package: "Zip") + .product(name: "Zip", package: "Zip"), + .product(name: "EmpusaMacros", package: "EmpusaMacros") ] ), .testTarget( diff --git a/Packages/EmpusaKit/Sources/EmpusaKit/Client/Client.swift b/Packages/EmpusaKit/Sources/EmpusaKit/Client/Client.swift index 38e3a77..df0341a 100644 --- a/Packages/EmpusaKit/Sources/EmpusaKit/Client/Client.swift +++ b/Packages/EmpusaKit/Sources/EmpusaKit/Client/Client.swift @@ -2,17 +2,20 @@ import Foundation import Combine protocol ClientProtocol { - func request(url: URL) async throws -> T + func request( + url: URL + ) async throws -> T + func downloadFile( url: URL, progressSubject: CurrentValueSubject - ) async throws -> Data + ) async throws -> URL } -final class Client: ClientProtocol { +final class Client: NSObject, ClientProtocol { func request(url: URL) async throws -> T { let request = URLRequest(url: url) - let (data, response) = try await URLSession + let (data, _) = try await URLSession .shared .data(for: request) @@ -26,21 +29,16 @@ final class Client: ClientProtocol { func downloadFile( url: URL, progressSubject: CurrentValueSubject - ) async throws -> Data { - let (asyncBytes, urlResponse) = try await URLSession - .shared - .bytes(from: url) - - let length = urlResponse.expectedContentLength - var data = Data() - data.reserveCapacity(Int(length)) - - for try await byte in asyncBytes { - data.append(byte) - let downloadProgress = Double(data.count) / Double(length) - progressSubject.send(downloadProgress) + ) async throws -> URL { + // TODO: implement download progress + defer { + progressSubject.send(1) } - return data + let (localUrl, _) = try await URLSession + .shared + .download(from: url) + + return localUrl } } diff --git a/Packages/EmpusaKit/Sources/EmpusaKit/ContentManager.swift b/Packages/EmpusaKit/Sources/EmpusaKit/ContentManager.swift index 34daad8..a96881e 100644 --- a/Packages/EmpusaKit/Sources/EmpusaKit/ContentManager.swift +++ b/Packages/EmpusaKit/Sources/EmpusaKit/ContentManager.swift @@ -1,8 +1,25 @@ import Combine import Foundation +import OSLog + +public struct FailedResource { + let resource: SwitchResource + public let error: Error +} + +public struct ProcessResult { + public var succeededResources: [SwitchResource] = [] + public var failedResources: [FailedResource] = [] + + public var failedResourceNames: String { + failedResources + .map { $0.resource.displayName } + .joined(separator: ", ") + } +} public protocol ContentManagerProtocol { - func download(resources: [SwitchResource], into destination: URL, progressSubject: CurrentValueSubject) async throws + func download(resources: [SwitchResource], into volume: ExternalVolume, progressSubject: CurrentValueSubject) async -> ProcessResult func backupVolume(at location: URL, progressSubject: CurrentValueSubject) async throws -> ZipFile func restoreBackup(at location: URL, to destination: URL, progressSubject: CurrentValueSubject) async throws } @@ -10,78 +27,110 @@ public protocol ContentManagerProtocol { public final class ContentManager: ContentManagerProtocol { private let storageService: StorageServiceProtocol = StorageService() private let githubService: AssetServiceProtocol = AssetService() + private let logger: Logger = .init(subsystem: "nl.trevisa.diego.Empusa.EmpusaKit", category: "ContentManager") public init() {} public func download( resources: [SwitchResource], - into destination: URL, + into volume: ExternalVolume, progressSubject: CurrentValueSubject - ) async throws { + ) async -> ProcessResult { let totalProgress = Double(resources.count) * 3 + let log: EmpusaLog = storageService.getLog(at: volume) ?? .init() + var result = ProcessResult() for resource in resources { - let accumulatedProgress = 3 * Double(resources.firstIndex(of: resource)!) - let progressTitleSubject = CurrentValueSubject("") - let downloadProgressSubject = CurrentValueSubject(0) - let unzipProgressSubject = CurrentValueSubject(0) - let mergeProgressSubject = CurrentValueSubject(0) - - let cancellable = Publishers.CombineLatest4( - progressTitleSubject, - downloadProgressSubject, - unzipProgressSubject, - mergeProgressSubject - ).map { (title, downloadProgress, unzipProgress, mergeProgress) in - ProgressData( - title: title, - progress: accumulatedProgress + downloadProgress + unzipProgress + mergeProgress, - total: totalProgress + do { + let accumulatedProgress = 3 * Double(resources.firstIndex(of: resource)!) + let progressTitleSubject = CurrentValueSubject("") + let downloadProgressSubject = CurrentValueSubject(0) + let unzipProgressSubject = CurrentValueSubject(0) + let mergeProgressSubject = CurrentValueSubject(0) + + let cancellable = Publishers.CombineLatest4( + progressTitleSubject, + downloadProgressSubject, + unzipProgressSubject, + mergeProgressSubject + ).map { (title, downloadProgress, unzipProgress, mergeProgress) in + ProgressData( + title: title, + progress: accumulatedProgress + downloadProgress + unzipProgress + mergeProgress, + total: totalProgress + ) + }.assign(to: \.value, on: progressSubject) + + // Download asset + progressTitleSubject.send("Downloading \(resource.assetFileName)...") + let asset = try await githubService.downloadAsset( + for: resource, + progressSubject: downloadProgressSubject + ) + + // Rename asset file + let assetFileUrl = asset + .url + .deletingLastPathComponent() + .appending(component: resource.assetFileName) + + try storageService.moveItem( + at: asset.url, + to: assetFileUrl + ) + + let extractedUrl = try { + if resource.isAssetZipped { + // Unzip asset + progressTitleSubject.send("Unzipping \(resource.assetFileName)...") + return try storageService.unzipFile( + at: assetFileUrl, + progressSubject: unzipProgressSubject + ) + } else { + // Do nothing + unzipProgressSubject.send(1) + return assetFileUrl + } + }() + + // Copy asset to SD + progressTitleSubject.send("Copying contents of \(resource.assetFileName) into destination...") + try resource.handleAsset( + at: extractedUrl, + destination: volume.url, + progressSubject: mergeProgressSubject + ) + + // Delete downloaded files on disk + progressTitleSubject.send("Removing temporary files...") + storageService.removeItem(at: assetFileUrl) + storageService.removeItem(at: extractedUrl) + + // Update log + log.add( + resource: resource, + version: asset.version ) - }.assign(to: \.value, on: progressSubject) - - // Download asset - progressTitleSubject.send("Downloading \(resource.assetFileName)...") - let asset = try await githubService.downloadAsset( - for: resource, - progressSubject: downloadProgressSubject - ) - - progressTitleSubject.send("Saving \(resource.assetFileName)...") - let assetFilePath = try await storageService.saveFile( - data: asset, - fileName: resource.assetFileName - ) - let extractedPath = try { - if resource.isAssetZipped { - // Unzip asset - progressTitleSubject.send("Unzipping \(resource.assetFileName)...") - return try storageService.unzipFile( - at: assetFilePath, - progressSubject: unzipProgressSubject - ) - } else { - unzipProgressSubject.send(1) - return assetFilePath - } - }() - - // Copy asset to SD - progressTitleSubject.send("Copying contents of \(resource.assetFileName) into destination...") - try resource.handleAsset( - at: extractedPath, - destination: destination, - progressSubject: mergeProgressSubject - ) + result.succeededResources.append(resource) + cancellable.cancel() + } catch { + result.failedResources.append(.init( + resource: resource, + error: error + )) + logger.error("Fail to execute for resource \(resource.displayName): \(error.localizedDescription)") + } + } - // Delete downloaded files on disk - progressTitleSubject.send("Removing temporary files...") - storageService.removeItem(at: assetFilePath) - storageService.removeItem(at: extractedPath) + // Save log file + storageService.saveLog( + log, + at: volume + ) - cancellable.cancel() - } + return result } public func backupVolume( diff --git a/Packages/EmpusaKit/Sources/EmpusaKit/Extensions/FileManager+FoldersMerger.swift b/Packages/EmpusaKit/Sources/EmpusaKit/Extensions/FileManager+FoldersMerger.swift index 58aefa2..3c08d1b 100644 --- a/Packages/EmpusaKit/Sources/EmpusaKit/Extensions/FileManager+FoldersMerger.swift +++ b/Packages/EmpusaKit/Sources/EmpusaKit/Extensions/FileManager+FoldersMerger.swift @@ -7,12 +7,7 @@ import Combine extension FileManager { private var logger: Logger { - .init(subsystem: "nl.trevisa.diego.Empusa.Services", category: "FileManager") - } - - enum ActionType { - case move - case copy + .init(subsystem: "nl.trevisa.diego.Empusa.EmpusaKit", category: "FileManager") } enum ConflictResolution { @@ -22,7 +17,8 @@ extension FileManager { func moveFile( at location: URL, - to destination: URL + to destination: URL, + progressSubject: CurrentValueSubject ) { let fileName = location.lastPathComponent let filedestination = destination.appending(path: fileName) @@ -50,6 +46,8 @@ extension FileManager { } catch { logger.error("Move file error: \(error.localizedDescription)") } + + progressSubject.send(1) } func merge( diff --git a/Packages/EmpusaKit/Sources/EmpusaKit/Models/Asset.swift b/Packages/EmpusaKit/Sources/EmpusaKit/Models/Asset.swift new file mode 100644 index 0000000..76faee1 --- /dev/null +++ b/Packages/EmpusaKit/Sources/EmpusaKit/Models/Asset.swift @@ -0,0 +1,6 @@ +import Foundation + +public struct DownloadedAsset { + let version: String? + let url: URL +} diff --git a/Packages/EmpusaKit/Sources/EmpusaKit/Models/EmpusaLog.swift b/Packages/EmpusaKit/Sources/EmpusaKit/Models/EmpusaLog.swift new file mode 100644 index 0000000..ef61788 --- /dev/null +++ b/Packages/EmpusaKit/Sources/EmpusaKit/Models/EmpusaLog.swift @@ -0,0 +1,22 @@ +import Foundation + +public struct InstalledResource: Codable { + let resource: SwitchResource + let version: String? + var updatedAt: Date = Date() +} + +public final class EmpusaLog: Codable { + var resources: [InstalledResource] = [] + + func add( + resource: SwitchResource, + version: String? + ) { + resources.removeAll(where: { $0.resource == resource }) + resources.append(.init( + resource: resource, + version: version + )) + } +} diff --git a/Packages/EmpusaKit/Sources/EmpusaKit/Models/SwitchResource.swift b/Packages/EmpusaKit/Sources/EmpusaKit/Models/SwitchResource.swift index 0e04dce..f7f80d7 100644 --- a/Packages/EmpusaKit/Sources/EmpusaKit/Models/SwitchResource.swift +++ b/Packages/EmpusaKit/Sources/EmpusaKit/Models/SwitchResource.swift @@ -1,22 +1,22 @@ import Foundation import Combine +import EmpusaMacros // MARK: - DisplayingSwitchResource public struct DisplayingSwitchResource: Hashable { public let resource: SwitchResource public let version: String? + public let preChecked: Bool public var formattedName: String { - [resource.displayName, version] + [ + resource.displayName, + resource.additionalDescription + ] .compactMap { $0 } .joined(separator: " ") } - - public init(resource: SwitchResource, version: String? = nil) { - self.resource = resource - self.version = version - } } // MARK: - SwitchResource @@ -27,28 +27,40 @@ public enum SwitchResouceSource { case link(URL, version: String?) } -public enum SwitchResource: String, CaseIterable { +public enum SwitchResource: String, Codable, CaseIterable { case hekate + case hekateIPL case atmosphere + case fusee case sigpatches case tinfoil case bootLogos + case emummc case lockpickRCM + case hbAppStore + case jksv + case ftpd + case nxThemesInstaller + case nxShell + case goldleaf public var displayName: String { switch self { - case .hekate: - "Hekate" - case .atmosphere: - "Atmosphère" - case .sigpatches: - "Sigpatches" - case .tinfoil: - "Tinfoil" - case .bootLogos: - "Boot logos" - case .lockpickRCM: - "Lockpick RCM" + case .hekate: "Hekate" + case .hekateIPL: "hekate_ipl.ini" + case .atmosphere: "Atmosphère" + case .fusee: "Fusée" + case .sigpatches: "Sigpatches" + case .tinfoil: "Tinfoil" + case .bootLogos: "Boot logos" + case .emummc: "emummc.txt" + case .lockpickRCM: "Lockpick RCM" + case .hbAppStore: "HB App Store" + case .jksv: "JKSV" + case .ftpd: "ftpd" + case .nxThemesInstaller: "NXThemesInstaller" + case .nxShell: "NX-Shell" + case .goldleaf: "Goldleaf" } } @@ -56,94 +68,128 @@ public enum SwitchResource: String, CaseIterable { switch self { case .hekate: .github( - .init(string: "https://api.github.com/repos/CTCaer/hekate/releases/latest")!, + #URL("https://api.github.com/repos/CTCaer/hekate/releases/latest"), assetPrefix: "hekate_ctcaer_" ) + case .hekateIPL: + .link( + #URL("https://nh-server.github.io/switch-guide/files/emu/hekate_ipl.ini"), + version: nil + ) case .atmosphere: .github( - .init(string: "https://api.github.com/repos/Atmosphere-NX/Atmosphere/releases/latest")!, + #URL("https://api.github.com/repos/Atmosphere-NX/Atmosphere/releases/latest"), assetPrefix: "atmosphere-" ) + case .fusee: + .github( + #URL("https://api.github.com/repos/Atmosphere-NX/Atmosphere/releases/latest"), + assetPrefix: "fusee.bin" + ) case .sigpatches: .link( - .init(string: "https://sigmapatches.coomer.party/sigpatches.zip")!, + #URL("https://sigmapatches.coomer.party/sigpatches.zip"), version: "16.1.0" ) case .tinfoil: .github( - .init(string: "https://api.github.com/repos/kkkkyue/Tinfoil/releases/latest")!, + #URL("https://api.github.com/repos/kkkkyue/Tinfoil/releases/latest"), assetPrefix: "Tinfoil.Self.Installer" ) case .bootLogos: .link( - .init(string: "https://nh-server.github.io/switch-guide/files/bootlogos.zip")!, + #URL("https://nh-server.github.io/switch-guide/files/bootlogos.zip"), + version: nil + ) + case .emummc: + .link( + #URL("https://nh-server.github.io/switch-guide/files/emummc.txt"), version: nil ) case .lockpickRCM: .forgejo( - .init(string: "https://vps.suchmeme.nl/git/api/v1/repos/mudkip/Lockpick_RCM/releases/latest")!, + #URL("https://vps.suchmeme.nl/git/api/v1/repos/mudkip/Lockpick_RCM/releases/latest"), assetPrefix: "Lockpick_RCM.bin" ) + case .hbAppStore: + .github( + #URL("https://api.github.com/repos/fortheusers/hb-appstore/releases/latest"), + assetPrefix: "appstore.nro" + ) + case .jksv: + .github( + #URL("https://api.github.com/repos/J-D-K/JKSV/releases/latest"), + assetPrefix: "JKSV.nro" + ) + case .ftpd: + .github( + #URL("https://api.github.com/repos/mtheall/ftpd/releases/latest"), + assetPrefix: "ftpd.nro" + ) + case .nxThemesInstaller: + .github( + #URL("https://api.github.com/repos/exelix11/SwitchThemeInjector/releases/latest"), + assetPrefix: "NXThemesInstaller.nro" + ) + case .nxShell: + .github( + #URL("https://api.github.com/repos/joel16/NX-Shell/releases/latest"), + assetPrefix: "NX-Shell.nro" + ) + case .goldleaf: + .github( + #URL("https://api.github.com/repos/XorTroll/Goldleaf/releases/latest"), + assetPrefix: "Goldleaf.nro" + ) } } var assetFileName: String { switch self { - case .hekate: - "hekate.zip" - case .atmosphere: - "atmosphere.zip" - case .sigpatches: - "sigpatches.zip" - case .tinfoil: - "tinfoil.zip" - case .bootLogos: - "bootlogos.zip" - case .lockpickRCM: - "Lockpick_RCM.bin" + case .hekate: "hekate.zip" + case .hekateIPL: "hekate_ipl.ini" + case .atmosphere: "atmosphere.zip" + case .fusee: "fusee.bin" + case .sigpatches: "sigpatches.zip" + case .tinfoil: "tinfoil.zip" + case .bootLogos: "bootlogos.zip" + case .emummc: "emummc.txt" + case .lockpickRCM: "Lockpick_RCM.bin" + case .hbAppStore: "appstore.nro" + case .jksv: "JKSV.nro" + case .ftpd: "ftpd.nro" + case .nxThemesInstaller: "NXThemesInstaller.nro" + case .nxShell: "NX-Shell.nro" + case .goldleaf: "Goldleaf.nro" + } + } + + var additionalDescription: String? { + switch self { + case .hekateIPL, .bootLogos, .emummc: + "(from NH Switch Guide)" + default: + nil } } var isAssetZipped: Bool { switch self { - case .lockpickRCM: + case .hekateIPL, .emummc, .lockpickRCM, .hbAppStore, + .fusee, .jksv, .ftpd, .nxThemesInstaller, + .nxShell, .goldleaf: false default: true } } -} - -extension SwitchResource { - private var fileManager: FileManager { - .default - } - func handleAsset( - at location: URL, - destination: URL, - progressSubject: CurrentValueSubject - ) throws { + var uncheckedIfInstalled: Bool { switch self { - case .hekate, .bootLogos: - fileManager.merge( - atPath: location.appending(path: "bootloader").path(), - toPath: destination.appending(path: "bootloader").path(), - progressSubject: progressSubject - ) - - case .atmosphere, .sigpatches, .tinfoil: - fileManager.merge( - atPath: location.path(), - toPath: destination.path(), - progressSubject: progressSubject - ) - - case .lockpickRCM: - fileManager.moveFile( - at: location, - to: destination.appending(path: "bootloader").appending(path: "payloads") - ) + case .hekateIPL, .bootLogos: + true + default: + false } } } diff --git a/Packages/EmpusaKit/Sources/EmpusaKit/Services/AssetService.swift b/Packages/EmpusaKit/Sources/EmpusaKit/Services/AssetService.swift index 7f3587b..3b0b543 100644 --- a/Packages/EmpusaKit/Sources/EmpusaKit/Services/AssetService.swift +++ b/Packages/EmpusaKit/Sources/EmpusaKit/Services/AssetService.swift @@ -8,12 +8,12 @@ enum AssetServiceError: Error { public protocol AssetServiceProtocol { func fetchRepositoryRelease(for resourceUrl: URL) async throws -> RepositoryRelease - func downloadAsset(for resource: SwitchResource, progressSubject: CurrentValueSubject) async throws -> Data + func downloadAsset(for resource: SwitchResource, progressSubject: CurrentValueSubject) async throws -> DownloadedAsset } public final class AssetService: AssetServiceProtocol { private let client: ClientProtocol = Client() - private let logger: Logger = .init(subsystem: "nl.trevisa.diego.Empusa.Services", category: "AssetService") + private let logger: Logger = .init(subsystem: "nl.trevisa.diego.Empusa.EmpusaKit", category: "AssetService") public init() {} @@ -28,7 +28,7 @@ public final class AssetService: AssetServiceProtocol { public func downloadAsset( for resource: SwitchResource, progressSubject: CurrentValueSubject - ) async throws -> Data { + ) async throws -> DownloadedAsset { switch resource.source { case .github(let url, let assetPrefix), .forgejo(let url, let assetPrefix): let release = try await fetchRepositoryRelease(for: url) @@ -41,16 +41,26 @@ public final class AssetService: AssetServiceProtocol { logger.info("Will download asset \(asset.name) for \(release.name) (\(release.tagName))") - return try await client.downloadFile( + let assetUrl = try await client.downloadFile( url: asset.browserDownloadUrl, progressSubject: progressSubject ) - case .link(let url, _): - return try await client.downloadFile( + return .init( + version: release.tagName, + url: assetUrl + ) + + case .link(let url, let version): + let assetUrl = try await client.downloadFile( url: url, progressSubject: progressSubject ) + + return .init( + version: version, + url: assetUrl + ) } } } diff --git a/Packages/EmpusaKit/Sources/EmpusaKit/Services/ResourceService.swift b/Packages/EmpusaKit/Sources/EmpusaKit/Services/ResourceService.swift index 247a170..0e4157a 100644 --- a/Packages/EmpusaKit/Sources/EmpusaKit/Services/ResourceService.swift +++ b/Packages/EmpusaKit/Sources/EmpusaKit/Services/ResourceService.swift @@ -2,27 +2,34 @@ import Foundation import OSLog public protocol ResourceServiceProtocol { - func fetchResources() async -> [DisplayingSwitchResource] + func fetchResources(for volume: ExternalVolume) async -> [DisplayingSwitchResource] } public final class ResourceService: ResourceServiceProtocol { // MARK: - Dependencies private let assetService: AssetServiceProtocol = AssetService() - private let logger: Logger = .init(subsystem: "nl.trevisa.diego.Empusa.Services", category: "ResourceService") + private let storageService: StorageServiceProtocol = StorageService() + private let logger: Logger = .init(subsystem: "nl.trevisa.diego.Empusa.EmpusaKit", category: "ResourceService") // MARK: - Public functions public init() {} - public func fetchResources() async -> [DisplayingSwitchResource] { + public func fetchResources( + for volume: ExternalVolume + ) async -> [DisplayingSwitchResource] { var displayingResources = [DisplayingSwitchResource]() + let log = storageService.getLog(at: volume) for resource in SwitchResource.allCases { + let isInstalled = log?.resources.contains(where: { $0.resource == resource }) ?? false + switch resource.source { case .github(let url, _), .forgejo(let url, _): displayingResources.append( .init( resource: resource, - version: try? await assetService.fetchRepositoryRelease(for: url).tagName + version: try? await assetService.fetchRepositoryRelease(for: url).tagName, + preChecked: !(resource.uncheckedIfInstalled && isInstalled) ) ) @@ -30,7 +37,8 @@ public final class ResourceService: ResourceServiceProtocol { displayingResources.append( .init( resource: resource, - version: version + version: version, + preChecked: !(resource.uncheckedIfInstalled && isInstalled) ) ) } diff --git a/Packages/EmpusaKit/Sources/EmpusaKit/Services/StorageService.swift b/Packages/EmpusaKit/Sources/EmpusaKit/Services/StorageService.swift index eac46dd..fa4d48a 100644 --- a/Packages/EmpusaKit/Sources/EmpusaKit/Services/StorageService.swift +++ b/Packages/EmpusaKit/Sources/EmpusaKit/Services/StorageService.swift @@ -5,17 +5,20 @@ import OSLog public protocol StorageServiceProtocol { func listExternalVolumes() throws -> [ExternalVolume] - func saveFile(data: Data, fileName: String) async throws -> URL func unzipFile(at location: URL, progressSubject: CurrentValueSubject) throws -> URL func unzipFile(at location: URL, to destination: URL, progressSubject: CurrentValueSubject) throws - func removeItem(at path: URL) + func moveItem(at location: URL, to destination: URL) throws + func removeItem(at location: URL) func zipDirectory(at location: URL, progressSubject: CurrentValueSubject) throws -> ZipFile + + func getLog(at volume: ExternalVolume) -> EmpusaLog? + func saveLog(_ log: EmpusaLog, at volume: ExternalVolume) } final public class StorageService: StorageServiceProtocol { private let fileManager = FileManager.default private let tempDirectoryPath = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - private let logger: Logger = .init(subsystem: "nl.trevisa.diego.Empusa.Services", category: "StorageService") + private let logger: Logger = .init(subsystem: "nl.trevisa.diego.Empusa.EmpusaKit", category: "StorageService") public init() {} @@ -58,12 +61,6 @@ final public class StorageService: StorageServiceProtocol { } } - public func saveFile(data: Data, fileName: String) throws -> URL { - let destinationPath = tempDirectoryPath.appending(path: fileName) - try data.write(to: destinationPath) - return destinationPath - } - public func unzipFile( at location: URL, progressSubject: CurrentValueSubject @@ -73,13 +70,11 @@ final public class StorageService: StorageServiceProtocol { let directoryName = fileName.replacingOccurrences(of: ".\(fileExtension)", with: "") let destination = tempDirectoryPath.appending(path: directoryName) - try Zip.unzipFile( - location, - destination: destination, - overwrite: true, - password: nil) { unzipProgress in - progressSubject.send(unzipProgress) - } + try unzipFile( + at: location, + to: destination, + progressSubject: progressSubject + ) return destination } @@ -99,9 +94,20 @@ final public class StorageService: StorageServiceProtocol { } } - public func removeItem(at path: URL) { + public func moveItem( + at location: URL, + to destination: URL + ) throws { + try? fileManager.removeItem(at: destination) + try fileManager.moveItem( + at: location, + to: destination + ) + } + + public func removeItem(at location: URL) { do { - try fileManager.removeItem(at: path) + try fileManager.removeItem(at: location) } catch { logger.error("StorageService: \(error.localizedDescription)") } @@ -134,4 +140,83 @@ final public class StorageService: StorageServiceProtocol { url: destinationPath ) } + + public func getLog(at volume: ExternalVolume) -> EmpusaLog? { + do { + let logUrl = volume.url.appending(component: "empusa.log") + let logData = try Data(contentsOf: logUrl) + return try JSONDecoder().decode(EmpusaLog.self, from: logData) + } catch { + logger.error("Could not load log file in volume: \(error.localizedDescription)") + return nil + } + } + + public func saveLog(_ log: EmpusaLog, at volume: ExternalVolume) { + do { + let logUrl = volume.url.appending(component: "empusa.log") + let logData = try JSONEncoder().encode(log) + try logData.write(to: logUrl) + } catch { + logger.error("Could not save log file in volume: \(error.localizedDescription)") + } + } +} + +// MARK: - SwitchResource extensions + +extension SwitchResource { + private var fileManager: FileManager { + .default + } + + func handleAsset( + at location: URL, + destination: URL, + progressSubject: CurrentValueSubject + ) throws { + switch self { + case .hekate, .bootLogos: + fileManager.merge( + atPath: location.appending(path: "bootloader").path(), + toPath: destination.appending(path: "bootloader").path(), + progressSubject: progressSubject + ) + + case .emummc: + fileManager.moveFile( + at: location, + to: destination.appending(component: "atmosphere").appending(component: "hosts"), + progressSubject: progressSubject + ) + + case .atmosphere, .sigpatches, .tinfoil: + fileManager.merge( + atPath: location.path(), + toPath: destination.path(), + progressSubject: progressSubject + ) + + case .hekateIPL: + fileManager.moveFile( + at: location, + to: destination.appending(path: "bootloader"), + progressSubject: progressSubject + ) + + case .lockpickRCM, .fusee: + fileManager.moveFile( + at: location, + to: destination.appending(path: "bootloader").appending(path: "payloads"), + progressSubject: progressSubject + ) + + case .hbAppStore, .jksv, .ftpd, .nxThemesInstaller, .nxShell, .goldleaf: + fileManager.moveFile( + at: location, + to: destination.appending(path: "switch"), + progressSubject: progressSubject + ) + } + } } diff --git a/Packages/EmpusaMacros/.gitignore b/Packages/EmpusaMacros/.gitignore new file mode 100644 index 0000000..0023a53 --- /dev/null +++ b/Packages/EmpusaMacros/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Packages/EmpusaMacros/Package.swift b/Packages/EmpusaMacros/Package.swift new file mode 100644 index 0000000..106515b --- /dev/null +++ b/Packages/EmpusaMacros/Package.swift @@ -0,0 +1,45 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription +import CompilerPluginSupport + +let package = Package( + name: "EmpusaMacros", + platforms: [.macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6), .macCatalyst(.v13)], + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "EmpusaMacros", + targets: ["EmpusaMacros"] + ), + ], + dependencies: [ + // Depend on the Swift 5.9 release of SwiftSyntax + .package(url: "https://github.com/apple/swift-syntax.git", from: "509.0.0"), + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + // Macro implementation that performs the source transformation of a macro. + .macro( + name: "EmpusaMacrosImpl", + dependencies: [ + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax") + ] + ), + + // Library that exposes a macro as part of its API, which is used in client programs. + .target(name: "EmpusaMacros", dependencies: ["EmpusaMacrosImpl"]), + + // A test target used to develop the macro implementation. + .testTarget( + name: "EmpusaMacrosTests", + dependencies: [ + "EmpusaMacrosImpl", + .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"), + ] + ), + ] +) diff --git a/Packages/EmpusaMacros/Sources/EmpusaMacros/EmpusaMacros.swift b/Packages/EmpusaMacros/Sources/EmpusaMacros/EmpusaMacros.swift new file mode 100644 index 0000000..9c54b90 --- /dev/null +++ b/Packages/EmpusaMacros/Sources/EmpusaMacros/EmpusaMacros.swift @@ -0,0 +1,4 @@ +import Foundation + +@freestanding(expression) +public macro URL(_ stringLiteral: String) -> URL = #externalMacro(module: "EmpusaMacrosImpl", type: "URLMacro") diff --git a/Packages/EmpusaMacros/Sources/EmpusaMacrosImpl/EmpusaMacrosPlugin.swift b/Packages/EmpusaMacros/Sources/EmpusaMacrosImpl/EmpusaMacrosPlugin.swift new file mode 100644 index 0000000..9ccd485 --- /dev/null +++ b/Packages/EmpusaMacros/Sources/EmpusaMacrosImpl/EmpusaMacrosPlugin.swift @@ -0,0 +1,11 @@ +import SwiftCompilerPlugin +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +@main +struct EmpusaMacrosPlugin: CompilerPlugin { + let providingMacros: [Macro.Type] = [ + URLMacro.self + ] +} diff --git a/Packages/EmpusaMacros/Sources/EmpusaMacrosImpl/URLMacro.swift b/Packages/EmpusaMacros/Sources/EmpusaMacrosImpl/URLMacro.swift new file mode 100644 index 0000000..6542ef5 --- /dev/null +++ b/Packages/EmpusaMacros/Sources/EmpusaMacrosImpl/URLMacro.swift @@ -0,0 +1,41 @@ +import Foundation +import SwiftCompilerPlugin +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +enum URLMacroError: Error, CustomStringConvertible { + case requiresStaticStringLiteral + case malformedURL(urlString: String) + + var description: String { + switch self { + case .requiresStaticStringLiteral: + return "#URL requires a static string literal" + case .malformedURL(let urlString): + return "The input URL is malformed: \(urlString)" + } + } +} + +public struct URLMacro: ExpressionMacro { + public static func expansion( + of node: some FreestandingMacroExpansionSyntax, + in context: some MacroExpansionContext + ) throws -> ExprSyntax { + guard + let argument = node.argumentList.first?.expression, + let segments = argument.as(StringLiteralExprSyntax.self)?.segments, + segments.count == 1, + case .stringSegment(let literalSegment)? = segments.first + else { + throw URLMacroError.requiresStaticStringLiteral + } + + guard let _ = URL(string: literalSegment.content.text) else { + throw URLMacroError.malformedURL(urlString: "\(argument)") + } + + return "URL(string: \(argument))!" + } +} diff --git a/Packages/EmpusaMacros/Tests/EmpusaMacrosTests/EmpusaMacrosTests.swift b/Packages/EmpusaMacros/Tests/EmpusaMacrosTests/EmpusaMacrosTests.swift new file mode 100644 index 0000000..eb72fce --- /dev/null +++ b/Packages/EmpusaMacros/Tests/EmpusaMacrosTests/EmpusaMacrosTests.swift @@ -0,0 +1,46 @@ +import SwiftSyntaxMacros +import SwiftSyntaxMacrosTestSupport +import XCTest + +// Macro implementations build for the host, so the corresponding module is not available when cross-compiling. Cross-compiled tests may still make use of the macro itself in end-to-end tests. +#if canImport(EmpusaMacrosMacros) +import EmpusaMacrosMacros + +let testMacros: [String: Macro.Type] = [ + "stringify": StringifyMacro.self, +] +#endif + +final class EmpusaMacrosTests: XCTestCase { + func testMacro() throws { + #if canImport(EmpusaMacrosMacros) + assertMacroExpansion( + """ + #stringify(a + b) + """, + expandedSource: """ + (a + b, "a + b") + """, + macros: testMacros + ) + #else + throw XCTSkip("macros are only supported when running tests for the host platform") + #endif + } + + func testMacroWithStringLiteral() throws { + #if canImport(EmpusaMacrosMacros) + assertMacroExpansion( + #""" + #stringify("Hello, \(name)") + """#, + expandedSource: #""" + ("Hello, \(name)", #""Hello, \(name)""#) + """#, + macros: testMacros + ) + #else + throw XCTSkip("macros are only supported when running tests for the host platform") + #endif + } +} diff --git a/Packages/EmpusaKit/Tests/Empusa.xctestplan b/Tests/Empusa.xctestplan similarity index 100% rename from Packages/EmpusaKit/Tests/Empusa.xctestplan rename to Tests/Empusa.xctestplan