diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b70f031..bc740ce 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,4 +1,4 @@ # SPDX-License-Identifier: MIT # Copyright 2024, Jamf -* @HarryStrandJamf @BIG-RAT @KapnKerk @mjKosmic @rydgecrakerjamf @jamf/apple-natives-write \ No newline at end of file +* @jamf/jamf_sync-maintainer diff --git a/CHANGELOG.md b/CHANGELOG.md index eae3f83..f6793b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.3.0] - 2024-05-08 +### Features +- Added buttons below the source and destination distribution point to allow local files to be added or removed directly to/from the distribution point. +- Added the ability to copy selected log messages to the clipboard. +- Added support for mpkg files. +### Bug fixes +- Changed the timeout for uploads to an hour to solve an issue with large uploads. This does not solve the issue with files > 5 GB that are uploaded to a JCDS2 DP. +- Fixed an issue with the "Cloud" DP type where the file progress wasn't quite right. + ## [1.2.0] - 2024-04-16 ### Features - Added the ability to use the v1/packages endpoint on Jamf Pro version 11.5 and above, which includes the ablity to upload files to any cloud instance that Jamf Pro supports. It shows up as a distribution point called "Cloud", but only for the destination since there isn't a way to download those files at this time. diff --git a/Jamf Sync.xcodeproj/project.pbxproj b/Jamf Sync.xcodeproj/project.pbxproj index 03705f9..08831ec 100644 --- a/Jamf Sync.xcodeproj/project.pbxproj +++ b/Jamf Sync.xcodeproj/project.pbxproj @@ -11,6 +11,8 @@ 840A79032ACB75FC00161D85 /* SavableItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 840A79022ACB75FC00161D85 /* SavableItem.swift */; }; 840A79072ACC8EFF00161D85 /* FolderInstance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 840A79062ACC8EFF00161D85 /* FolderInstance.swift */; }; 840A79092ACD9B0A00161D85 /* ConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 840A79082ACD9B0A00161D85 /* ConfirmationView.swift */; }; + 8412279F2BEADBB20097B83E /* XmlErrorParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8412279E2BEADBB20097B83E /* XmlErrorParser.swift */; }; + 841227A12BEADD6E0097B83E /* XmlErrorParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 841227A02BEADD6E0097B83E /* XmlErrorParserTests.swift */; }; 841DE9D92BA395900092DBE7 /* Jamf Sync User Guide.pdf in Resources */ = {isa = PBXBuildFile; fileRef = 841DE9D82BA395900092DBE7 /* Jamf Sync User Guide.pdf */; }; 843BE0F62AEFE6350053431B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 843BE0F52AEFE6350053431B /* Assets.xcassets */; }; 844CF9D12AC4B96600576E1A /* FolderDp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 844CF9D02AC4B96600576E1A /* FolderDp.swift */; }; @@ -113,6 +115,8 @@ 840A79022ACB75FC00161D85 /* SavableItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavableItem.swift; sourceTree = ""; }; 840A79062ACC8EFF00161D85 /* FolderInstance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderInstance.swift; sourceTree = ""; }; 840A79082ACD9B0A00161D85 /* ConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmationView.swift; sourceTree = ""; }; + 8412279E2BEADBB20097B83E /* XmlErrorParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XmlErrorParser.swift; sourceTree = ""; }; + 841227A02BEADD6E0097B83E /* XmlErrorParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XmlErrorParserTests.swift; sourceTree = ""; }; 841DE9D82BA395900092DBE7 /* Jamf Sync User Guide.pdf */ = {isa = PBXFileReference; lastKnownFileType = image.pdf; path = "Jamf Sync User Guide.pdf"; sourceTree = ""; }; 841DE9DA2BA395F00092DBE7 /* User Guide */ = {isa = PBXFileReference; lastKnownFileType = folder; path = "User Guide"; sourceTree = ""; }; 843BE0F52AEFE6350053431B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -292,6 +296,7 @@ 846499DC2B699F5E00A8EA7B /* Mocks */, 846499E32B6B080B00A8EA7B /* SynchronizeTaskTests.swift */, 846499D22B64165E00A8EA7B /* TestErrors.swift */, + 841227A02BEADD6E0097B83E /* XmlErrorParserTests.swift */, ); path = JamfSyncTests; sourceTree = ""; @@ -378,6 +383,7 @@ 84E489982B5AC80600FFFE59 /* UserSettings.swift */, 84FC415F2AD5A78C00DCB033 /* View+NSWindow.swift */, 849FC3402BD06A43008BAC02 /* VersionInfo.swift */, + 8412279E2BEADBB20097B83E /* XmlErrorParser.swift */, ); path = Utility; sourceTree = ""; @@ -585,6 +591,7 @@ 849FC3412BD06A43008BAC02 /* VersionInfo.swift in Sources */, 84E489992B5AC80600FFFE59 /* UserSettings.swift in Sources */, 84BC6E472AC380D200CF6D39 /* JamfProServerView.swift in Sources */, + 8412279F2BEADBB20097B83E /* XmlErrorParser.swift in Sources */, 84E489932B58681D00FFFE59 /* LogMessageView.swift in Sources */, 84DD58372BC58E0D00E8DA23 /* JamfProPackageApi.swift in Sources */, 84DD583B2BC5C2A700E8DA23 /* URL+isDirectory.swift in Sources */, @@ -632,6 +639,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 841227A12BEADD6E0097B83E /* XmlErrorParserTests.swift in Sources */, 846499E02B699FB500A8EA7B /* MockJamfProInstance.swift in Sources */, 846499DE2B699F8E00A8EA7B /* MockDistributionPoint.swift in Sources */, 846499D52B64268A00A8EA7B /* DistributionPointTests.swift in Sources */, @@ -813,7 +821,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.2.0; + MARKETING_VERSION = 1.3.0; PRODUCT_BUNDLE_IDENTIFIER = com.jamf.jamfsync; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -845,7 +853,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.2.0; + MARKETING_VERSION = 1.3.0; PRODUCT_BUNDLE_IDENTIFIER = com.jamf.jamfsync; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/Jamf Sync.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Jamf Sync.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index af41d27..d7071ae 100644 --- a/Jamf Sync.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Jamf Sync.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/jamf/Subprocess", "state" : { - "revision" : "2fdaccadfe68bc80f53cfc2d5d2d2363a8d6a5a6", - "version" : "3.0.2" + "revision" : "9044073a5c3a9e36abcc7df4d833507b093c00e0", + "version" : "3.0.3" } }, { diff --git a/JamfSync/Model/DataModel.swift b/JamfSync/Model/DataModel.swift index c5c10cd..7c53b71 100644 --- a/JamfSync/Model/DataModel.swift +++ b/JamfSync/Model/DataModel.swift @@ -30,7 +30,6 @@ class DataModel: ObservableObject { @Published var dpsForDestination: [DistributionPoint] = [] @Published var selectedSrcDpId = DataModel.noSelection @Published var selectedDstDpId = DataModel.noSelection - @Published var selectedDpFiles: Set = [] @Published var forceSync = false @Published var showSpinner = false @Published var shouldPromptForDpPassword = false @@ -175,30 +174,21 @@ class DataModel: ObservableObject { return synchronizationInProgress || selectedSrcDpId == DataModel.noSelection || selectedDstDpId == DataModel.noSelection || selectedSrcDpId == selectedDstDpId } - func adjustSelectedItems() { - var idsToAdd: [UUID] = [] - var idsToRemove: [UUID] = [] - for id in selectedDpFiles { - if let dstFile = dstPackageListViewModel.dpFiles.findDpFileViewModel(id: id) { - if let srcFile = srcPackageListViewModel.dpFiles.findDpFile(name: dstFile.dpFile.name) { - idsToAdd.append(srcFile.id) - } - idsToRemove.append(dstFile.id) - } - } - for id in idsToAdd { - selectedDpFiles.insert(id) - } - for id in idsToRemove { - selectedDpFiles.remove(id) - } - } - func verifySelectedItemsStillExist() { verifySrcSelectedItemsStillExist() verifyDstSelectedItemsStillExist() } + func selectedDpFilesFromSelectionIds(packageListViewModel: PackageListViewModel) -> [DpFile] { + var selectedFiles: [DpFile] = [] + for id in packageListViewModel.selectedDpFiles { + if let viewModel = packageListViewModel.dpFiles.findDpFileViewModel(id: id) { + selectedFiles.append(viewModel.dpFile) + } + } + return selectedFiles + } + // MARK: Private functions private func updateDpsForSourceAndDestination() { diff --git a/JamfSync/Model/DistributionPoint.swift b/JamfSync/Model/DistributionPoint.swift index 31a737f..c6f1aed 100644 --- a/JamfSync/Model/DistributionPoint.swift +++ b/JamfSync/Model/DistributionPoint.swift @@ -78,6 +78,12 @@ class DistributionPoint: Identifiable { /// If files were zipped, this will be true, indicating that we need to refresh the file list var filesWereZipped = false + /// Inidates whether the distribution point will download files before transferring them. This should be overridden by any distribution points that require downloading first. + var willDownloadFiles = false + + /// Indicates if a file will be deleted by removing it from the corresponding package from the Jamf Pro server + var deleteByRemovingPackage = false + /// Initialize the class. /// /// - Parameters: @@ -135,12 +141,6 @@ class DistributionPoint: Identifiable { } } - /// Inidates whether the distribution point will download files before transferring them. This should be overridden by any distribution points that require downloading first. - /// - Returns: Returns true if downloading of files will need to happen when transferring or false if not. - func willDownloadFiles() -> Bool { - return false - } - /// Downloads a file so it can be used. /// This needs to be overridden by a child distribution point class if downloading is necessary in order for it to be available locally. Otherwise this function can be ignored. /// - Parameters: @@ -203,90 +203,19 @@ class DistributionPoint: Identifiable { /// - forceSync: Set to true if it should copy files even if they are the same on both the source and destination /// - progress: The progress object that should be updated as the synchronization progresses. func copyFiles(selectedItems: [DpFile], dstDp: DistributionPoint, jamfProInstance: JamfProInstance?, forceSync: Bool, progress: SynchronizationProgress) async throws { - isCanceled = false - filesWereZipped = false - var someFileSucceeded = false - var someFilesFailed = false let filesToSync = filesToSynchronize(selectedItems: selectedItems, dstDp: dstDp, forceSync: forceSync) - var downloadMultiple: Int64 = 1 - if willDownloadFiles() { - downloadMultiple = 2 - } - progress.totalSize = calculateTotalTransferSize(filesToSync: filesToSync) * downloadMultiple - var currentTotalSizeTransferred: Int64 = 0 - var lastFile: DpFile? - var lastFileTansferred = false - inProgressDstDp = dstDp - for dpFile in filesToSync { - lastFile = dpFile - progress.initializeFileTransferInfoForFile(operation: "Copying", currentFile: dpFile, currentTotalSizeTransferred: currentTotalSizeTransferred) - - do { - lastFileTansferred = false - var localFileUrl: URL? - if isCanceled { break } - if willDownloadFiles() { - Task { @MainActor in - progress.operation = "Downloading" - } - localFileUrl = try await downloadFile(file: dpFile, progress: progress) - Task { @MainActor in - progress.operation = "Uploading" - } - if isCanceled { break } - } else { - if let url = dpFile.fileUrl, isFluffy(url: url) { - dpFile.fileUrl = try await zipFile(url: url) - if let name = dpFile.fileUrl?.lastPathComponent { - dpFile.name = name - if let zipUrl = dpFile.fileUrl, let zipSize = sizeOfFile(fileUrl: zipUrl) { - dpFile.size = zipSize - } - } - filesWereZipped = true - } - } - - if dstDp.updatePackageInfoBeforeTransfer { - try await addOrUpdatePackageInJamfPro(dpFile: dpFile, jamfProInstance: jamfProInstance) - } - - try await dstDp.transferFile(srcFile: dpFile, moveFrom: localFileUrl, progress: progress) - lastFileTansferred = true - - if let size = dpFile.size { - currentTotalSizeTransferred += size * downloadMultiple - } - someFileSucceeded = true + try await copyFilesToDst(sourceName: selectionName(), willDownloadFiles: willDownloadFiles, filesToSync: filesToSync, dstDp: dstDp, jamfProInstance: jamfProInstance, forceSync: forceSync, progress: progress) + } - addOrUpdateInDstList(dpFile: dpFile, dstDp: dstDp) - if !dstDp.updatePackageInfoBeforeTransfer { - try await addOrUpdatePackageInJamfPro(dpFile: dpFile, jamfProInstance: jamfProInstance) - } - } catch { - if isCanceled { break } - LogManager.shared.logMessage(message: "Failed to copy \(dpFile.name) to \(dstDp.selectionName()): \(error)", level: .error) - someFilesFailed = true - } - if isCanceled { break } - } - if let lastFile, lastFileTansferred { - progress.finalProgressValues(totalBytesTransferred: lastFile.size ?? 0, currentTotalSizeTransferred: currentTotalSizeTransferred) - } - inProgressDstDp = nil - if someFilesFailed { - if someFileSucceeded { - LogManager.shared.logMessage(message: "Not all files were transferred from \(selectionName()) to \(dstDp.selectionName())", level: .warning) - } else { - LogManager.shared.logMessage(message: "No files were transferred from \(selectionName()) to \(dstDp.selectionName())", level: .error) - } - } else { - if isCanceled { - LogManager.shared.logMessage(message: "Canceled synchronizing from \(selectionName()) to \(dstDp.selectionName())", level: .warning) - } else { - LogManager.shared.logMessage(message: "Finished synchronizing from \(selectionName()) to \(dstDp.selectionName())", level: .info) - } - } + /// Transfers a list of local files to this distribution point. + /// - Parameters: + /// - fileUrls: The local file URLs for the files to transfer + /// - jamfProInstance: The Jamf Pro instance of this distribution point, if it is associated with one + /// - progress: The progress object that should be updated as the transfer progresses + /// - Returns: Returns true if all files were copied, otherwise false + func transferLocalFiles(fileUrls: [URL], jamfProInstance: JamfProInstance?, progress: SynchronizationProgress) async throws { + let dpFiles = convertFileUrlsToDpFiles(fileUrls: fileUrls) + try await copyFilesToDst(sourceName: "Selected local files", willDownloadFiles: false, filesToSync: dpFiles, dstDp: self, jamfProInstance: jamfProInstance, forceSync: true, progress: progress) } /// Removes files from this destination distribution point that are not on thie source distribution point. @@ -383,13 +312,108 @@ class DistributionPoint: Identifiable { // MARK: - Private functions + private func copyFilesToDst(sourceName: String, willDownloadFiles: Bool, filesToSync: [DpFile], dstDp: DistributionPoint, jamfProInstance: JamfProInstance?, forceSync: Bool, progress: SynchronizationProgress) async throws { + isCanceled = false + filesWereZipped = false + var someFileSucceeded = false + var someFilesFailed = false + var downloadMultiple: Int64 = 1 + if willDownloadFiles { + downloadMultiple = 2 + } + progress.totalSize = calculateTotalTransferSize(filesToSync: filesToSync) * downloadMultiple + var currentTotalSizeTransferred: Int64 = 0 + var lastFile: DpFile? + var lastFileTansferred = false + inProgressDstDp = dstDp + for dpFile in filesToSync { + lastFile = dpFile + progress.initializeFileTransferInfoForFile(operation: "Copying", currentFile: dpFile, currentTotalSizeTransferred: currentTotalSizeTransferred) + + do { + lastFileTansferred = false + var localFileUrl: URL? + if isCanceled { break } + if willDownloadFiles { + Task { @MainActor in + progress.operation = "Downloading" + } + localFileUrl = try await downloadFile(file: dpFile, progress: progress) + Task { @MainActor in + progress.operation = "Uploading" + } + if isCanceled { break } + } else { + if let url = dpFile.fileUrl, isFluffy(url: url) { + dpFile.fileUrl = try await zipFile(url: url) + if let name = dpFile.fileUrl?.lastPathComponent { + dpFile.name = name + if let zipUrl = dpFile.fileUrl, let zipSize = sizeOfFile(fileUrl: zipUrl) { + dpFile.size = zipSize + } + } + filesWereZipped = true + } + } + + if dstDp.updatePackageInfoBeforeTransfer { + try await addOrUpdatePackageInJamfPro(dpFile: dpFile, jamfProInstance: jamfProInstance) + } + + try await dstDp.transferFile(srcFile: dpFile, moveFrom: localFileUrl, progress: progress) + lastFileTansferred = true + + if let size = dpFile.size { + currentTotalSizeTransferred += size * downloadMultiple + } + someFileSucceeded = true + + addOrUpdateInDstList(dpFile: dpFile, dstDp: dstDp) + if !dstDp.updatePackageInfoBeforeTransfer { + try await addOrUpdatePackageInJamfPro(dpFile: dpFile, jamfProInstance: jamfProInstance) + } + } catch { + if isCanceled { break } + LogManager.shared.logMessage(message: "Failed to copy \(dpFile.name) to \(dstDp.selectionName()): \(error)", level: .error) + someFilesFailed = true + } + if isCanceled { break } + } + if let lastFile, lastFileTansferred { + progress.finalProgressValues(totalBytesTransferred: lastFile.size ?? 0, currentTotalSizeTransferred: currentTotalSizeTransferred) + } + inProgressDstDp = nil + if someFilesFailed { + if someFileSucceeded { + LogManager.shared.logMessage(message: "Not all files were transferred from \(sourceName) to \(dstDp.selectionName())", level: .warning) + } else { + LogManager.shared.logMessage(message: "No files were transferred from \(sourceName) to \(dstDp.selectionName())", level: .error) + } + } else { + if isCanceled { + LogManager.shared.logMessage(message: "Canceled synchronizing from \(sourceName) to \(dstDp.selectionName())", level: .warning) + } else { + LogManager.shared.logMessage(message: "Finished synchronizing from \(sourceName) to \(dstDp.selectionName())", level: .info) + } + } + } + + func convertFileUrlsToDpFiles(fileUrls: [URL]) -> [DpFile] { + var dpFiles: [DpFile] = [] + for fileUrl in fileUrls { + let dpFile = DpFile(name: fileUrl.lastPathComponent, fileUrl: fileUrl, size: sizeOfFile(fileUrl: fileUrl)) + dpFiles.append(dpFile) + } + return dpFiles + } + private func isAcceptableForDp(url: URL) -> Bool { guard url.pathExtension != "dmg" else { return true } // Include .dmg files - guard !url.lastPathComponent.hasSuffix(".pkg.zip") else { return true } // Include zip files that have ".pkg.zip" - guard url.pathExtension == "pkg" else { return false } // Exclude if not ".pkg" + guard !url.lastPathComponent.hasSuffix(".pkg.zip") && !url.lastPathComponent.hasSuffix(".mpkg.zip") else { return true } // Include zip files that have ".pkg.zip" or ".mpkg.zip" + guard url.pathExtension == "pkg" || url.pathExtension == "mpkg" else { return false } // Exclude if not ".pkg" or ".mpkg" guard isFluffy(url: url) else { return true } // Include flat .pkg files (not directories) let urlForZipFile = url.appendingPathExtension("zip") - guard !fileManager.fileExists(atPath: urlForZipFile.path) else { return false } // Exclude fluffy .pkg files that have a corresponding zip file + guard !fileManager.fileExists(atPath: urlForZipFile.path) else { return false } // Exclude fluffy package files that have a corresponding zip file return true // Include when it's a fluffy package without a corresponding zip file } diff --git a/JamfSync/Model/GeneralCloudDp.swift b/JamfSync/Model/GeneralCloudDp.swift index f9a8267..63a49ee 100644 --- a/JamfSync/Model/GeneralCloudDp.swift +++ b/JamfSync/Model/GeneralCloudDp.swift @@ -12,6 +12,7 @@ class GeneralCloudDp: DistributionPoint { var urlSession: URLSession? var downloadTask: URLSessionDownloadTask? var dispatchGroup: DispatchGroup? + static let overheadPerFile = 112 init(jamfProInstanceId: UUID? = nil, jamfProInstanceName: String? = nil) { super.init(name: "Cloud") @@ -19,6 +20,8 @@ class GeneralCloudDp: DistributionPoint { self.jamfProInstanceId = jamfProInstanceId self.jamfProInstanceName = jamfProInstanceName self.updatePackageInfoBeforeTransfer = true + self.willDownloadFiles = true + self.deleteByRemovingPackage = true } override func retrieveFileList() async throws { @@ -33,10 +36,6 @@ class GeneralCloudDp: DistributionPoint { filesLoaded = true } - override func willDownloadFiles() -> Bool { - return true - } - override func downloadFile(file: DpFile, progress: SynchronizationProgress) async throws -> URL? { throw DistributionPointError.downloadingNotSupported } @@ -100,6 +99,7 @@ class GeneralCloudDp: DistributionPoint { guard let url = jamfProInstance.url else { throw ServerCommunicationError.noJamfProUrl } let boundary = createBoundary() + progress.overheadSizePerFile = GeneralCloudDp.overheadPerFile + (boundary.count * 2) + fileUrl.lastPathComponent.count let tempFileName = try prepareFileForMultipartUpload(fileUrl: fileUrl, boundary: boundary) defer { try? FileManager.default.removeItem(at: tempFileName) @@ -113,13 +113,16 @@ class GeneralCloudDp: DistributionPoint { let request = try createUploadRequest(url: packageUrl, fileUrl: tempFileName, boundary: boundary, jamfProInstance: jamfProInstance) - let (_, response) = try await urlSession.upload(for: request, fromFile: tempFileName, delegate: sessionDelegate) + let (responseData, response) = try await urlSession.upload(for: request, fromFile: tempFileName, delegate: sessionDelegate) if let httpResponse = response as? HTTPURLResponse { switch(httpResponse.statusCode) { case 200...299: LogManager.shared.logMessage(message: "Successfully uploaded \(fileUrl.lastPathComponent)", level: .verbose) + case 413: + throw ServerCommunicationError.contentTooLarge default: - throw ServerCommunicationError.dataRequestFailed(statusCode: httpResponse.statusCode) + let responseDataString = String(data: responseData, encoding: .utf8) + throw ServerCommunicationError.dataRequestFailed(statusCode: httpResponse.statusCode, message: responseDataString) } } } @@ -159,7 +162,7 @@ class GeneralCloudDp: DistributionPoint { var request = URLRequest(url: url) request.httpMethod = "POST" - request.timeoutInterval = 60 + request.timeoutInterval = JamfProInstance.uploadTimeoutValue request.allHTTPHeaderFields = [ "Authorization": "Bearer \(token)", "Content-Type": "multipart/form-data; boundary=\(boundary)", diff --git a/JamfSync/Model/JamfProInstance.swift b/JamfSync/Model/JamfProInstance.swift index c8aebcd..241466b 100644 --- a/JamfSync/Model/JamfProInstance.swift +++ b/JamfSync/Model/JamfProInstance.swift @@ -11,9 +11,10 @@ enum ServerCommunicationError: Error { case parsingError case badPackageData case forbidden + case contentTooLarge case invalidCredentials case couldNotAccessServer - case dataRequestFailed(statusCode: Int) + case dataRequestFailed(statusCode: Int, message: String? = nil) case notSupported case prepareForUploadFailed } @@ -35,6 +36,8 @@ class JamfProInstance: SavableItem { var urlSession = URLSession(configuration: URLSessionConfiguration.default) var jamfProVersion: String? static let iconName = "icloud" + static let normalTimeoutValue = 60.0 + static let uploadTimeoutValue = 3600.0 init(name: String = "", url: URL? = nil, useClientApi: Bool = false, usernameOrClientId: String = "", passwordOrClientSecret: String = "") { self.url = url @@ -111,8 +114,7 @@ class JamfProInstance: SavableItem { guard !usernameOrClientId.isEmpty, !passwordOrClientSecret.isEmpty else { return } jamfProVersion = try? await retrieveJamfProVersion() await determinePackageApi() - guard let packageApi else { throw DistributionPointError.programError } - packages = try await packageApi.loadPackages(jamfProInstance: self) + try await loadPackages() try await loadCloudDp() try await loadFileShares() } @@ -192,7 +194,7 @@ class JamfProInstance: SavableItem { /// - httpMethod: The method to use (GET, POST, etc.) /// - httpBody: The body data, if needed, otherwise nil. /// - Returns: Returns a tuple with the data retruned and the URLResponse. - func dataRequest(url: URL, httpMethod: String, httpBody: Data? = nil, contentType: String = "application/json", acceptType: String? = nil, throwHttpError: Bool = true) async throws -> (data: Data?, response: URLResponse?) { + func dataRequest(url: URL, httpMethod: String, httpBody: Data? = nil, contentType: String = "application/json", acceptType: String? = nil, throwHttpError: Bool = true, timeout: Double = JamfProInstance.normalTimeoutValue) async throws -> (data: Data?, response: URLResponse?) { try await retrieveToken(username: usernameOrClientId, password: passwordOrClientSecret) guard let token else { throw ServerCommunicationError.noToken } @@ -204,7 +206,7 @@ class JamfProInstance: SavableItem { var request = URLRequest(url: url) request.httpMethod = httpMethod - request.timeoutInterval = 60 + request.timeoutInterval = timeout request.httpBody = httpBody request.allHTTPHeaderFields = headers @@ -213,7 +215,8 @@ class JamfProInstance: SavableItem { if throwHttpError, let response = response as? HTTPURLResponse { if !(200...299).contains(response.statusCode) { LogManager.shared.logMessage(message: "Failed to transmit data for \(url). Status code: \(response.statusCode)", level: .error) - throw ServerCommunicationError.dataRequestFailed(statusCode: response.statusCode) + let responseDataString = String(data: data, encoding: .utf8) + throw ServerCommunicationError.dataRequestFailed(statusCode: response.statusCode, message: responseDataString) } } return (data: data, response: response) @@ -234,6 +237,13 @@ class JamfProInstance: SavableItem { } } + /// Loads or reloads packages + func loadPackages() async throws { + guard let packageApi else { throw DistributionPointError.programError } + packages.removeAll() + packages = try await packageApi.loadPackages(jamfProInstance: self) + } + /// Checks to see which packages on the Jamf Pro server are not in the source DP /// - Parameters: /// - srcDp: The source distribution point to check for missing files. @@ -311,7 +321,7 @@ class JamfProInstance: SavableItem { request.httpBody = clientString.data(using: .utf8) } request.httpMethod = "POST" - request.timeoutInterval = 60 + request.timeoutInterval = JamfProInstance.normalTimeoutValue request.allHTTPHeaderFields = headers let response: (data: Data?, response: URLResponse?) = try await urlSession.data(for: request) diff --git a/JamfSync/Model/JamfProPackageApi.swift b/JamfSync/Model/JamfProPackageApi.swift index f6114d9..31e0757 100644 --- a/JamfSync/Model/JamfProPackageApi.swift +++ b/JamfSync/Model/JamfProPackageApi.swift @@ -1,8 +1,5 @@ // -// JamfProPackageApi.swift -// Jamf Sync -// -// Created by Harry Strand on 4/9/24. +// Copyright 2024, Jamf // import Foundation diff --git a/JamfSync/Model/JamfProPackageClassicApi.swift b/JamfSync/Model/JamfProPackageClassicApi.swift index a6b7962..9d6cd6a 100644 --- a/JamfSync/Model/JamfProPackageClassicApi.swift +++ b/JamfSync/Model/JamfProPackageClassicApi.swift @@ -1,8 +1,5 @@ // -// JamfProPackageClassicApi.swift -// Jamf Sync -// -// Created by Harry Strand on 4/9/24. +// Copyright 2024, Jamf // import Foundation diff --git a/JamfSync/Model/JamfProPackageUApi.swift b/JamfSync/Model/JamfProPackageUApi.swift index a1587b6..217a9e5 100644 --- a/JamfSync/Model/JamfProPackageUApi.swift +++ b/JamfSync/Model/JamfProPackageUApi.swift @@ -1,8 +1,5 @@ // -// JamfProPackageUApi.swift -// Jamf Sync -// -// Created by Harry Strand on 4/9/24. +// Copyright 2024, Jamf // import Foundation diff --git a/JamfSync/Model/Jcds2Dp.swift b/JamfSync/Model/Jcds2Dp.swift index 09c1e99..0348338 100644 --- a/JamfSync/Model/Jcds2Dp.swift +++ b/JamfSync/Model/Jcds2Dp.swift @@ -18,6 +18,7 @@ class Jcds2Dp: DistributionPoint { super.init(name: "JCDS") self.jamfProInstanceId = jamfProInstanceId self.jamfProInstanceName = jamfProInstanceName + self.willDownloadFiles = true } override func retrieveFileList() async throws { @@ -60,10 +61,6 @@ class Jcds2Dp: DistributionPoint { filesLoaded = true } - override func willDownloadFiles() -> Bool { - return true - } - override func downloadFile(file: DpFile, progress: SynchronizationProgress) async throws -> URL? { guard let cloudUri = try await retrieveCloudDownloadUri(file: file, progress: progress) else { throw DistributionPointError.failedToRetrieveCloudDownloadUri } @@ -226,11 +223,24 @@ class Jcds2Dp: DistributionPoint { case 200...299: LogManager.shared.logMessage(message: "Successfully uploaded \(file.name)", level: .verbose) default: - LogManager.shared.logMessage(message: "Failed to upload \(file.name) with response code \(String(httpResponse.statusCode))", level: .error) + let message = parseErrorData(data: responseData) + throw ServerCommunicationError.dataRequestFailed(statusCode: httpResponse.statusCode, message: message) } } } - + + private func parseErrorData(data: Data) -> String? { + var message: String? + let xmlParser = XMLParser(data: data) + let xmlErrorParser = XmlErrorParser() + xmlParser.delegate = xmlErrorParser + xmlParser.parse() + if xmlParser.parserError == nil { + message = "\(xmlErrorParser.code ?? ""): \(xmlErrorParser.message ?? "") - max allowed size = \(xmlErrorParser.maxAllowedSize ?? "")" + } + return message + } + private func createAwsUploadRequest(uploadData: JsonInitiateUpload, jcdsServerUrl: URL, key: String, contentType: String, currentDate: String, requestTimeStamp: String) throws -> URLRequest { var request = URLRequest(url: jcdsServerUrl, cachePolicy: .reloadIgnoringLocalCacheData) @@ -249,7 +259,9 @@ class Jcds2Dp: DistributionPoint { let (signedHeaders, signatureProvided) = try awsSignatureV4(uploadData: uploadData, httpMethod: "PUT", requestHeaders: request.allHTTPHeaderFields ?? [:], date: requestTimeStamp, key: key, hashedPayload: "", contentType: contentType, currentDate: currentDate) request.addValue("AWS4-HMAC-SHA256 Credential=\(String(describing: accessKeyID))/\(requestTimeStamp.prefix(8))/\(region)/s3/aws4_request,SignedHeaders=\(signedHeaders),Signature=\(signatureProvided)", forHTTPHeaderField: "Authorization") - + + request.timeoutInterval = JamfProInstance.uploadTimeoutValue + return request } @@ -346,7 +358,7 @@ class Jcds2Dp: DistributionPoint { private func contentType(filename: String) -> String? { let ext = URL(fileURLWithPath: filename).pathExtension switch ext { - case "pkg": + case "pkg", "mpkg": return "application/x-newton-compatible-pkg" case "dmg": return "application/octet-stream" diff --git a/JamfSync/Model/SynchronizationProgress.swift b/JamfSync/Model/SynchronizationProgress.swift index 6a723a9..eabce8f 100644 --- a/JamfSync/Model/SynchronizationProgress.swift +++ b/JamfSync/Model/SynchronizationProgress.swift @@ -5,13 +5,12 @@ import Foundation class SynchronizationProgress: ObservableObject { - var srcDp: DistributionPoint? - var dstDp: DistributionPoint? var totalSize: Int64? @Published var operation: String? @Published var currentFile: DpFile? @Published var currentTotalSizeTransferred: Int64 = 0 @Published var currentFileSizeTransferred: Int64? + var overheadSizePerFile: Int = 0 var printToConsole = false var showProgressOnConsole = false var printToConsoleInterval: TimeInterval = 1.0 @@ -63,8 +62,11 @@ class SynchronizationProgress: ObservableObject { } func fileProgress() -> Double? { - if let currentFileSizeTransferred, let currentFile, let size = currentFile.size, size > 0 { - return Double(currentFileSizeTransferred) / Double(size) + if let currentFileSizeTransferred, let currentFile { + let size = (currentFile.size ?? 0) + Int64(overheadSizePerFile) + if size > 0 { + return Double(currentFileSizeTransferred) / Double(size) + } } return nil } diff --git a/JamfSync/Model/SynchronizeTask.swift b/JamfSync/Model/SynchronizeTask.swift index 608876d..cabcd16 100644 --- a/JamfSync/Model/SynchronizeTask.swift +++ b/JamfSync/Model/SynchronizeTask.swift @@ -10,7 +10,7 @@ class SynchronizeTask { /// Loops through the files to synchronize to calculate the total size of files to be transferred. /// - Parameters: /// - srcDp: The destination distribution point to copy the files from - /// - dstDp: The destination distribution point to copy the files to\ + /// - dstDp: The destination distribution point to copy the files to /// - selectedItems: The selected items to synchronize. If the selection list is empty, it will synchronize all files from the source distribution point /// - jamfProInstance: The Jamf Pro instance of the destination distribution point, if it is associated with one /// - forceSync: Set to true if it should copy files even if they are the same on both the source and destination diff --git a/JamfSync/Model/ViewModels/LogViewModel.swift b/JamfSync/Model/ViewModels/LogViewModel.swift index 5d45801..468de7b 100644 --- a/JamfSync/Model/ViewModels/LogViewModel.swift +++ b/JamfSync/Model/ViewModels/LogViewModel.swift @@ -28,6 +28,10 @@ class LogViewModel: ObservableObject { logMessages.removeAll() } + func findLogMessage(id: UUID) -> LogMessage? { + return logMessages.first { $0.id == id } + } + // MARK: - Private functions private func stopNotifications() { diff --git a/JamfSync/Model/ViewModels/PackageListViewModel.swift b/JamfSync/Model/ViewModels/PackageListViewModel.swift index 5c1a9d4..21b4d1f 100644 --- a/JamfSync/Model/ViewModels/PackageListViewModel.swift +++ b/JamfSync/Model/ViewModels/PackageListViewModel.swift @@ -7,6 +7,9 @@ import Foundation class PackageListViewModel: ObservableObject { @Published var dpFiles = DpFilesViewModel() @Published var doChecksumCalculation = false + @Published var selectedDpFiles: Set = [] + @Published var shouldPresentConfirmationSheet = false + @Published var canceled = false var isSrc: Bool private var updateTask: Task? static let needToSortPackagesNotification = "com.jamfsoftware.jamfsync.needToSortPackages" @@ -24,14 +27,17 @@ class PackageListViewModel: ObservableObject { let dataModel = DataModel.shared let selectedSrcDp = dataModel.findDp(id: dataModel.selectedSrcDpId) let selectedDstDp = dataModel.findDp(id: dataModel.selectedDstDpId) - let dp = determineDpForThisPackageList(srcDp: selectedSrcDp, dstDp: selectedDstDp) + let dp = retrieveSelectedDp() if !checksumUpdateInProgress, let dp { + if reload, let jamfProInstanceId = dp.jamfProInstanceId, let jamfProInstance = dp.findJamfProInstance(id: jamfProInstanceId) { + try await jamfProInstance.loadPackages() + } await updateDpFiles(dp: dp, reload: reload) } updateTask = Task { @MainActor in dpFiles.removeAll() - dataModel.selectedDpFiles.removeAll() + selectedDpFiles.removeAll() if let dp { for dpFile in dp.dpFiles.files { @@ -41,7 +47,7 @@ class PackageListViewModel: ObservableObject { } } - let packages = packagesForSelectedDps(srcDp: selectedSrcDp, dstDp: selectedDstDp) + let packages = packagesForSelectedDps(dp: dp) dpFiles.addMissingPackages(packages: packages, isSrc: isSrc, srcDp: selectedSrcDp, dstDp: selectedDstDp) updateTask = nil NotificationCenter.default.post(name: Notification.Name(PackageListViewModel.needToSortPackagesNotification), object: self) @@ -64,6 +70,27 @@ class PackageListViewModel: ObservableObject { } func showCalcChecksumsButton() -> Bool { + let selectedDp = retrieveSelectedDp() + guard let selectedDp else { return false } + + return selectedDp.showCalcChecksumsButton() + } + + func enableFileAddButton() -> Bool { + let selectedDp = retrieveSelectedDp() + guard let selectedDp, selectedDp.id != DataModel.noSelection else { return false } + + return true + } + + func enableFileDeleteButton(selectedDpFiles: Set) -> Bool { + let selectedDp = retrieveSelectedDp() + guard let selectedDp, selectedDp.id != DataModel.noSelection, selectedDpFiles.count > 0 else { return false } + + return true + } + + func retrieveSelectedDp() -> DistributionPoint? { let dataModel = DataModel.shared let selectedDp: DistributionPoint? if isSrc { @@ -72,9 +99,7 @@ class PackageListViewModel: ObservableObject { selectedDp = dataModel.findDp(id: dataModel.selectedDstDpId) } - guard let selectedDp else { return false } - - return selectedDp.showCalcChecksumsButton() + return selectedDp } func cancelUpdate() { @@ -118,6 +143,39 @@ class PackageListViewModel: ObservableObject { return false } + func deleteSelectedFilesFromDp(packagesToo: Bool) { + guard let selectedDp = retrieveSelectedDp() else { + LogManager.shared.logMessage(message: "Failed to retrieve the distribution point", level: .error) + return + } + Task { + var jamfProInstance: JamfProInstance? + if let jamfProInstanceId = selectedDp.jamfProInstanceId, let instance = selectedDp.findJamfProInstance(id: jamfProInstanceId) { + jamfProInstance = instance + } + + let progress = SynchronizationProgress() + let selectedDpFiles = DataModel.shared.selectedDpFilesFromSelectionIds(packageListViewModel: self) + for dpFile in selectedDpFiles { + do { + if let jamfProInstance, let packageApi = jamfProInstance.packageApi, selectedDp.deleteByRemovingPackage { + if let package = jamfProInstance.findPackage(name: dpFile.name), let jamfProId = package.jamfProId { + try await packageApi.deletePackage(packageId: jamfProId, jamfProInstance: jamfProInstance) + } + } else { + try await selectedDp.deleteFile(file: dpFile, progress: progress) + } + LogManager.shared.logMessage(message: "Deleted \(dpFile.name) from \(selectedDp.selectionName())", level: .info) + } catch { + LogManager.shared.logMessage(message: "Failed to deleted \(dpFile.name) from \(selectedDp.selectionName()): \(error)", level: .error) + } + } + DataModel.shared.updateListViewModels(reload: isSrc ? .source : .destination) + } + } + + // MARK: - Private functions + private func determineState(srcDp: DistributionPoint?, dstDp: DistributionPoint?, dpFile: DpFile) -> FileState { guard let srcDp, srcDp.id != DataModel.noSelection, let dstDp, dstDp.id != DataModel.noSelection else { return .undefined } if isSrc { @@ -152,14 +210,10 @@ class PackageListViewModel: ObservableObject { } } - private func packagesForSelectedDps(srcDp: DistributionPoint?, dstDp: DistributionPoint?) -> [Package]? { + private func packagesForSelectedDps(dp: DistributionPoint?) -> [Package]? { + guard let dp else { return nil } let dataModel = DataModel.shared - var savableItem: SavableItem? - if !isSrc, let dstDp, dstDp.id != DataModel.noSelection { - savableItem = dataModel.savableItems.findSavableItemWithDpId(id: dstDp.id) - } else if isSrc, let srcDp, srcDp.id != DataModel.noSelection { - savableItem = dataModel.savableItems.findSavableItemWithDpId(id: srcDp.id) - } + let savableItem = dataModel.savableItems.findSavableItemWithDpId(id: dp.id) return savableItem?.jamfProPackages() } } diff --git a/JamfSync/Resources/Jamf Sync User Guide.pdf b/JamfSync/Resources/Jamf Sync User Guide.pdf index ae644da..ccbe8da 100644 Binary files a/JamfSync/Resources/Jamf Sync User Guide.pdf and b/JamfSync/Resources/Jamf Sync User Guide.pdf differ diff --git a/JamfSync/UI/HeaderView.swift b/JamfSync/UI/HeaderView.swift index ea4fe93..4d4fb44 100644 --- a/JamfSync/UI/HeaderView.swift +++ b/JamfSync/UI/HeaderView.swift @@ -79,12 +79,34 @@ struct HeaderView: View { func startSynchronize(deleteFiles: Bool, deletePackages: Bool) async { if let srcDp = dataModel.findDp(id: dataModel.selectedSrcDpId), let dstDp = dataModel.findDp(id: dataModel.selectedDstDpId) { - SynchronizeProgressView(srcDp: srcDp, dstDp: dstDp, deleteFiles: deleteFiles, deletePackages: deletePackages).openInNewWindow { window in + SynchronizeProgressView(srcDp: srcDp, dstDp: dstDp, deleteFiles: deleteFiles, deletePackages: deletePackages, processToExecute: { (synchronizeTask, deleteFiles, deletePackages, progress, synchronizationProgressView) in + synchronize(srcDp: srcDp, dstDp: dstDp, synchronizeTask: synchronizeTask, deleteFiles: deleteFiles, deletePackages: deletePackages, progress: progress, synchronizeProgressView: synchronizationProgressView) }) + .openInNewWindow { window in window.title = "Synchronization Progress" } } } + func synchronize(srcDp: DistributionPoint?, dstDp: DistributionPoint, synchronizeTask: SynchronizeTask, deleteFiles: Bool, deletePackages: Bool, progress: SynchronizationProgress, synchronizeProgressView: SynchronizeProgressView) { + Task { + var reloadFiles = false + DataModel.shared.cancelUpdateListViewModels() + DataModel.shared.synchronizationInProgress = true + do { + guard let srcDp else { throw DistributionPointError.programError } + reloadFiles = try await synchronizeTask.synchronize(srcDp: srcDp, dstDp: dstDp, selectedItems: DataModel.shared.selectedDpFilesFromSelectionIds(packageListViewModel: DataModel.shared.srcPackageListViewModel), jamfProInstance: DataModel.shared.findJamfProInstance(id: dstDp.jamfProInstanceId), forceSync: DataModel.shared.forceSync, deleteFiles: deleteFiles, deletePackages: deletePackages, progress: progress) + } catch { + LogManager.shared.logMessage(message: "Failed to synchronize \(srcDp?.name ?? "nil") to \(dstDp.name): \(error)", level: .error) + } + DataModel.shared.synchronizationInProgress = false + // Wait a second for the progress bar to catch up and then close + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: { + synchronizeProgressView.dismiss() + DataModel.shared.updateListViewModels(reload: reloadFiles ? .source : .none) + }) + } + } + func showLog() { LogView().openInNewWindow { window in window.title = "Activity and Error Log" @@ -92,7 +114,7 @@ struct HeaderView: View { } func promptForDeletion() -> Bool { - if dataModel.selectedDpFiles.count == 0 { + if dataModel.srcPackageListViewModel.selectedDpFiles.count == 0 { if let srcDp = dataModel.findDp(id: dataModel.selectedSrcDpId), let dstDp = dataModel.findDp(id: dataModel.selectedDstDpId) { // If there are any packages on the destination Jamf Pro server that would be removed, then prompt if let jamfProInstance = DataModel.shared.findJamfProInstance(id: dstDp.jamfProInstanceId) { diff --git a/JamfSync/UI/LogView.swift b/JamfSync/UI/LogView.swift index 13806eb..ef83569 100644 --- a/JamfSync/UI/LogView.swift +++ b/JamfSync/UI/LogView.swift @@ -7,10 +7,11 @@ import SwiftUI struct LogView: View { @StateObject var logViewModel = LogViewModel() @State private var sortOrder = [KeyPathComparator(\LogMessage.date), KeyPathComparator(\LogMessage.logLevel), KeyPathComparator(\LogMessage.message)] + @State private var selectedItems: Set = [] var body: some View { VStack { - Table(logViewModel.logMessages, sortOrder: $sortOrder) { + Table(logViewModel.logMessages, selection: $selectedItems, sortOrder: $sortOrder) { TableColumn("Date & Time", value: \.date) { logMessage in Text(LogManager.shared.dateToLogDateString(logMessage.date)) } @@ -29,6 +30,17 @@ struct LogView: View { } .frame(minWidth: 650) .toolbar { + if selectedItems.count > 0 { + Button { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(stringForSelectedItems(), forType: .string) + } label: { + Image(systemName: "doc.on.doc") + } + .help("Copy selected log messages to the clipboard") + } + Button { logViewModel.clear() } label: { @@ -37,6 +49,16 @@ struct LogView: View { .help("Remove all messages") } } + + func stringForSelectedItems() -> String { + var text = "" + for id in selectedItems { + if let logMessage = logViewModel.findLogMessage(id: id) { + text += "\(LogManager.shared.logMessageToString(logMessage))\n" + } + } + return text + } } struct LogView_Previews: PreviewProvider { diff --git a/JamfSync/UI/PackageListView.swift b/JamfSync/UI/PackageListView.swift index 9220e7b..0a38a06 100644 --- a/JamfSync/UI/PackageListView.swift +++ b/JamfSync/UI/PackageListView.swift @@ -38,7 +38,7 @@ struct PackageListView: View { var body: some View { VStack { - Table(packageListViewModel.dpFiles.files, selection: $dataModel.selectedDpFiles, sortOrder: $sortOrder) { + Table(packageListViewModel.dpFiles.files, selection: $packageListViewModel.selectedDpFiles, sortOrder: $sortOrder) { TableColumn("Sync", value: \.state) { item in if let stateImageData = stateImage(fileItem: item) { if let color = stateImageData.color { @@ -76,29 +76,87 @@ struct PackageListView: View { packageListViewModel.objectWillChange.send() } .alternatingRowBackgrounds(.disabled) - .onChange(of: dataModel.selectedDpFiles) { - dataModel.adjustSelectedItems() - } - if packageListViewModel.showCalcChecksumsButton() { - Button { - if packageListViewModel.doChecksumCalculation { - packageListViewModel.cancelChecksumUpdate() - } else { - Task { - await packageListViewModel.updateChecksums() + ZStack { + if packageListViewModel.showCalcChecksumsButton() { + Button { + if packageListViewModel.doChecksumCalculation { + packageListViewModel.cancelChecksumUpdate() + } else { + Task { + await packageListViewModel.updateChecksums() + } + } + } label: { + if packageListViewModel.doChecksumCalculation { + ProgressView() + .scaleEffect(x: 0.5, y: 0.5, anchor: .center) + .frame(width: 16, height: 16, alignment: .center) + } else { + Text("Calculate Checksums") + } + } + .padding(.bottom) + } + + HStack { + Spacer() + + Button { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = true + panel.allowedFileTypes = ["pkg", "dmg", "mpkg"] + panel.canChooseDirectories = false + panel.canChooseFiles = true + if panel.runModal() == .OK { + Task { + await startFileTransfer(fileUrls: panel.urls) + } } + } label: { + Image(systemName: "plus") + } + .disabled(!packageListViewModel.enableFileAddButton()) + .help("Add file(s)") + .padding(.bottom) + + Button { + packageListViewModel.shouldPresentConfirmationSheet = true + } label: { + Image(systemName: "minus") } - } label: { - if packageListViewModel.doChecksumCalculation { - ProgressView() - .scaleEffect(x: 0.5, y: 0.5, anchor: .center) - .frame(width: 16, height: 16, alignment: .center) - } else { - Text("Calculate Checksums") + .disabled(!packageListViewModel.enableFileDeleteButton(selectedDpFiles: packageListViewModel.selectedDpFiles)) + .help("Remove selected file(s)") + .padding([.bottom, .trailing]) + .alert("Are you sure you want to delete the \(packageListViewModel.selectedDpFiles.count) selected items?", isPresented: $packageListViewModel.shouldPresentConfirmationSheet) { + HStack { + if let dp = packageListViewModel.retrieveSelectedDp() { + if let jamfProInstanceId = dp.jamfProInstanceId, let jamfProInstance = dataModel.findJamfProInstance(id: jamfProInstanceId) { + Button("Files and associated packages", role: .destructive) { + Task { + packageListViewModel.deleteSelectedFilesFromDp(packagesToo: true) + } + } + if !dp.deleteByRemovingPackage { + Button("Files only", role: .destructive) { + Task { + packageListViewModel.deleteSelectedFilesFromDp(packagesToo: false) + } + } + } + } else { + Button("Yes", role: .destructive) { + Task { + packageListViewModel.deleteSelectedFilesFromDp(packagesToo: false) + } + } + } + } + Button("Cancel", role: .cancel) { + } + } } } - .padding([.bottom]) } } .onReceive(publisher) { notification in @@ -111,8 +169,8 @@ struct PackageListView: View { var colorWhenIncluded: Color? = .green var colorWhenDeleted: Color? = .red var colorWhenMismatched: Color? = .yellow - if dataModel.selectedDpFiles.count > 0 { - if !dataModel.selectedDpFiles.contains(fileItem.id) { + if packageListViewModel.selectedDpFiles.count > 0 { + if !packageListViewModel.selectedDpFiles.contains(fileItem.id) { colorWhenIncluded = nil colorWhenDeleted = nil colorWhenMismatched = nil @@ -140,6 +198,33 @@ struct PackageListView: View { return (systemName: "plus.circle.fill", color: colorWhenIncluded) } } + + func startFileTransfer(fileUrls: [URL]) async { + if let dp = packageListViewModel.retrieveSelectedDp() { + SynchronizeProgressView(srcDp: nil, dstDp: dp, deleteFiles: false, deletePackages: false, processToExecute: { (synchronizeTask, deleteFiles, deletePackages, progress, synchronizationProgressView) in + transferFiles(fileUrls: fileUrls, dstDp: dp, synchronizeTask: synchronizeTask, progress: progress, synchronizeProgressView: synchronizationProgressView) }).openInNewWindow { window in + window.title = "Transfer Progress" + } + } + } + + func transferFiles(fileUrls: [URL], dstDp: DistributionPoint, synchronizeTask: SynchronizeTask, progress: SynchronizationProgress, synchronizeProgressView: SynchronizeProgressView) { + Task { + DataModel.shared.cancelUpdateListViewModels() + DataModel.shared.synchronizationInProgress = true + do { + try await dstDp.transferLocalFiles(fileUrls: fileUrls, jamfProInstance: DataModel.shared.findJamfProInstance(id: dstDp.jamfProInstanceId), progress: progress) + } catch { + LogManager.shared.logMessage(message: "Failed to transfer to \(dstDp.name): \(error)", level: .error) + } + DataModel.shared.synchronizationInProgress = false + // Wait a second for the progress bar to catch up and then close + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: { + synchronizeProgressView.dismiss() + DataModel.shared.updateListViewModels(reload: .none) + }) + } + } } struct PackageListView_Previews: PreviewProvider { diff --git a/JamfSync/UI/SourceDestinationView.swift b/JamfSync/UI/SourceDestinationView.swift index 30f0e9e..b831af5 100644 --- a/JamfSync/UI/SourceDestinationView.swift +++ b/JamfSync/UI/SourceDestinationView.swift @@ -42,8 +42,10 @@ struct SourceDestinationView: View { ZStack { HStack { PackageListView(isSrc: true, dataModel: dataModel, packageListViewModel: dataModel.srcPackageListViewModel) + .padding(.trailing, 3) PackageListView(isSrc: false, dataModel: dataModel, packageListViewModel: dataModel.dstPackageListViewModel) + .padding(.leading, 3) } if dataModel.showSpinner { ProgressView() diff --git a/JamfSync/UI/SynchronizeProgressView.swift b/JamfSync/UI/SynchronizeProgressView.swift index dfadc6a..e60be8a 100644 --- a/JamfSync/UI/SynchronizeProgressView.swift +++ b/JamfSync/UI/SynchronizeProgressView.swift @@ -5,14 +5,15 @@ import SwiftUI struct SynchronizeProgressView: View { - @Environment(\.dismiss) private var dismiss - var srcDp: DistributionPoint + @Environment(\.dismiss) var dismiss + var srcDp: DistributionPoint? var dstDp: DistributionPoint var deleteFiles: Bool var deletePackages: Bool @StateObject var progress = SynchronizationProgress() @State var shouldPresentConfirmationSheet = false let synchronizeTask = SynchronizeTask() + var processToExecute: (SynchronizeTask, Bool, Bool, SynchronizationProgress, SynchronizeProgressView)->Void = {_,_,_,_,_ in } // For CrappyButReliableAnimation let timer = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect() @@ -22,7 +23,7 @@ struct SynchronizeProgressView: View { var body: some View { VStack { HStack { - Text("\(srcDp.selectionName())") + Text("\(srcDp?.selectionName() ?? "Local Files")") .padding() BackAndForthAnimation(leftOffset: $leftOffset, rightOffset: $rightOffset) @@ -71,35 +72,8 @@ struct SynchronizeProgressView: View { } .frame(minWidth: 600) .onAppear { - progress.srcDp = srcDp - progress.dstDp = dstDp - Task { - var reloadFiles = false - DataModel.shared.cancelUpdateListViewModels() - DataModel.shared.synchronizationInProgress = true - do { - reloadFiles = try await synchronizeTask.synchronize(srcDp: srcDp, dstDp: dstDp, selectedItems: selectedIdsFromViewModelIds(srcIds: DataModel.shared.selectedDpFiles), jamfProInstance: DataModel.shared.findJamfProInstance(id: dstDp.jamfProInstanceId), forceSync: DataModel.shared.forceSync, deleteFiles: deleteFiles, deletePackages: deletePackages, progress: progress) - } catch { - LogManager.shared.logMessage(message: "Failed to synchronize \(srcDp) to \(dstDp): \(error)", level: .error) - } - DataModel.shared.synchronizationInProgress = false - // Wait a second for the progress bar to catch up and then close - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: { - dismiss() - DataModel.shared.updateListViewModels(reload: reloadFiles ? .source : .none) - }) - } - } - } - - func selectedIdsFromViewModelIds(srcIds: Set) -> [DpFile] { - var selectedFiles: [DpFile] = [] - for id in srcIds { - if let viewModel = DataModel.shared.srcPackageListViewModel.dpFiles.findDpFileViewModel(id: id) { - selectedFiles.append(viewModel.dpFile) - } + processToExecute(synchronizeTask, deleteFiles, deletePackages, progress, self) } - return selectedFiles } } diff --git a/JamfSync/Utility/XmlErrorParser.swift b/JamfSync/Utility/XmlErrorParser.swift new file mode 100644 index 0000000..744a363 --- /dev/null +++ b/JamfSync/Utility/XmlErrorParser.swift @@ -0,0 +1,82 @@ +// +// Copyright 2024, Jamf +// + +import Foundation + +enum XmlErrorField { + case code + case message + case proposedSize + case maxSizeAllowed + case requestId + case hostId +} + +class XmlErrorParser : NSObject, XMLParserDelegate { + var field: XmlErrorField? + var parseError: Error? + var code: String? + var message: String? + var proposedSize: String? + var maxAllowedSize: String? + var requestId: String? + var hostId: String? + + func parser( + _ parser: XMLParser, + didStartElement elementName: String, + namespaceURI: String?, + qualifiedName qName: String?, + attributes attributeDict: [String : String] = [:] + ) { + switch elementName { + case "Code": + field = .code + case "Message": + field = .message + case "ProposedSize": + field = .proposedSize + case "MaxSizeAllowed": + field = .maxSizeAllowed + case "RequestId": + field = .requestId + case "HostId": + field = .hostId + default: + field = nil + } + } + + func parser( + _ parser: XMLParser, + foundCharacters string: String + ) { + let value = string.trimmingCharacters(in: .whitespacesAndNewlines) + if (!value.isEmpty) { + switch field { + case .code: + code = value + case .message: + message = value + case .proposedSize: + proposedSize = value + case .maxSizeAllowed: + maxAllowedSize = value + case .requestId: + requestId = value + case .hostId: + hostId = value + default: + break + } + } + } + + func parser( + _ parser: XMLParser, + parseErrorOccurred parseError: Error + ) { + self.parseError = parseError + } +} diff --git a/JamfSyncTests/DistributionPointTests.swift b/JamfSyncTests/DistributionPointTests.swift index 0d02fc3..5c712c5 100644 --- a/JamfSyncTests/DistributionPointTests.swift +++ b/JamfSyncTests/DistributionPointTests.swift @@ -123,16 +123,6 @@ final class DistributionPointTests: XCTestCase { XCTAssertTrue(destinationDp.isCanceled) } - // MARK: - willDownloadFiles tests - - func test_willDownloadFiles() throws { - // When - let result = dp.willDownloadFiles() - - // Then - XCTAssertFalse(result) - } - // MARK: - downloadFile tests func test_downloadFile() throws { @@ -293,7 +283,7 @@ final class DistributionPointTests: XCTestCase { // Given let selectedItems: [DpFile] = [] let srcDp = MockDistributionPoint(name: "TestSrcDp", fileManager: mockFileManager) - srcDp.willDownloadFilesValue = true + srcDp.willDownloadFiles = true addTestSrcFiles(dp: srcDp) let dstDp = MockDistributionPoint(name: "TestDstDp", fileManager: mockFileManager) let synchronizationProgress = SynchronizationProgress() @@ -609,6 +599,62 @@ final class DistributionPointTests: XCTestCase { XCTAssertEqual(jamfProInstance.packagesUpdated.count, 2) } + // MARK: - transferLocalFiles tests + + func test_transferLocalFiles_withFiles() throws { + // Given + let path1 = "/source/directory/file1.pkg" + let path2 = "/source/directory/file2.pkg" + let path3 = "/source/directory/file3.pkg" + let filesToTransfer = [ URL(fileURLWithPath: path1), URL(fileURLWithPath: path2), URL(fileURLWithPath: path3) ] + mockFileManager.fileAttributes = [path1 : [.size : Int64(12345678)], path2 : [.size : Int64(456)], path3 : [.size : Int64(1111)]] + + let dstDp = MockDistributionPoint(name: "TestDstDp", fileManager: mockFileManager) + let jamfProInstance = MockJamfProInstance() + jamfProInstance.packages = packagesFromDpFiles(dpFiles: dstDp.dpFiles.files) + let synchronizationProgress = SynchronizationProgress() + synchronizationProgress.printToConsole = true // Not as testable otherwise since it does it in the main thread, which may not happen until after the test is completed + + let expectationCompleted = XCTestExpectation() + Task { + // When + try await dstDp.transferLocalFiles(fileUrls: filesToTransfer, jamfProInstance: jamfProInstance, progress: synchronizationProgress) + expectationCompleted.fulfill() + } + wait(for: [expectationCompleted], timeout: 5) + + // Then + XCTAssertNotNil(findLogMessage(messageString: "Finished synchronizing from Selected local files to TestDstDp (local)")) + XCTAssertEqual(synchronizationProgress.totalSize, 12347245) + XCTAssertEqual(synchronizationProgress.currentFileSizeTransferred, 1111) + XCTAssertEqual(synchronizationProgress.currentTotalSizeTransferred, 12347245) + } + + func test_transferLocalFiles_noFiles() throws { + // Given + let filesToTransfer: [URL] = [] + + let dstDp = MockDistributionPoint(name: "TestDstDp", fileManager: mockFileManager) + let jamfProInstance = MockJamfProInstance() + jamfProInstance.packages = packagesFromDpFiles(dpFiles: dstDp.dpFiles.files) + let synchronizationProgress = SynchronizationProgress() + synchronizationProgress.printToConsole = true // Not as testable otherwise since it does it in the main thread, which may not happen until after the test is completed + + let expectationCompleted = XCTestExpectation() + Task { + // When + try await dstDp.transferLocalFiles(fileUrls: filesToTransfer, jamfProInstance: jamfProInstance, progress: synchronizationProgress) + expectationCompleted.fulfill() + } + wait(for: [expectationCompleted], timeout: 5) + + // Then + XCTAssertNotNil(findLogMessage(messageString: "Finished synchronizing from Selected local files to TestDstDp (local)")) + XCTAssertEqual(synchronizationProgress.totalSize, 0) + XCTAssertNil(synchronizationProgress.currentFileSizeTransferred) + XCTAssertEqual(synchronizationProgress.currentTotalSizeTransferred, 0) + } + // MARK: - deleteFilesNotOnSource tests func test_deleteFilesNotOnSource_withFolderDpDestination() throws { diff --git a/JamfSyncTests/Jcds2DpTests.swift b/JamfSyncTests/Jcds2DpTests.swift index 8a0b254..020b0fc 100644 --- a/JamfSyncTests/Jcds2DpTests.swift +++ b/JamfSyncTests/Jcds2DpTests.swift @@ -207,7 +207,7 @@ final class Jcds2DpTests: XCTestCase { // Then XCTFail("Should have returned with ServerCommunicationError.dataRequestFailed") - } catch ServerCommunicationError.dataRequestFailed(let statusCode) { + } catch ServerCommunicationError.dataRequestFailed(let statusCode, let message) { XCTAssertEqual(statusCode, 500) } expectationCompleted.fulfill() @@ -237,13 +237,4 @@ final class Jcds2DpTests: XCTestCase { wait(for: [expectationCompleted], timeout: 5) XCTAssertFalse(jcds2Dp.filesLoaded) } - // MARK: - willDownloadFiles tests - - func test_willDownloadFiles() throws { - // When - let result = jcds2Dp.willDownloadFiles() - - // Then - XCTAssertTrue(result) - } } diff --git a/JamfSyncTests/Mocks/MockDistributionPoint.swift b/JamfSyncTests/Mocks/MockDistributionPoint.swift index af342d4..7fa2271 100644 --- a/JamfSyncTests/Mocks/MockDistributionPoint.swift +++ b/JamfSyncTests/Mocks/MockDistributionPoint.swift @@ -16,17 +16,20 @@ class MockDistributionPoint: DistributionPoint { var errorIdx = 0 var transferItems: [TransferItem] = [] var filesDeleted: [DpFile] = [] - var willDownloadFilesValue = false var cancelSync = false - override func willDownloadFiles() -> Bool { + override func calculateTotalTransferSize(filesToSync: [DpFile]) -> Int64 { if cancelSync { cancel() } - return willDownloadFilesValue + return super.calculateTotalTransferSize(filesToSync: filesToSync) } override func transferFile(srcFile: DpFile, moveFrom: URL? = nil, progress: SynchronizationProgress) async throws { + if cancelSync { + cancel() + return + } if errorIdx < errors.count, let error = errors[errorIdx] { throw error } diff --git a/JamfSyncTests/Mocks/MockJamfProInstance.swift b/JamfSyncTests/Mocks/MockJamfProInstance.swift index ea22093..3204b5c 100644 --- a/JamfSyncTests/Mocks/MockJamfProInstance.swift +++ b/JamfSyncTests/Mocks/MockJamfProInstance.swift @@ -37,7 +37,7 @@ class MockJamfProInstance: JamfProInstance { deletePackagesNotOnSourceCalled = true } - override func dataRequest(url: URL, httpMethod: String, httpBody: Data? = nil, contentType: String = "application/json", acceptType: String? = nil, throwHttpError: Bool = true) async throws -> (data: Data?, response: URLResponse?) { + override func dataRequest(url: URL, httpMethod: String, httpBody: Data? = nil, contentType: String = "application/json", acceptType: String? = nil, throwHttpError: Bool = true, timeout: Double = JamfProInstance.normalTimeoutValue) async throws -> (data: Data?, response: URLResponse?) { if let requestResponse = findRequestResponse(url: url, httpMethod: httpMethod, httpBody: httpBody, contentType: contentType) { if let error = requestResponse.error { throw error diff --git a/JamfSyncTests/XmlErrorParserTests.swift b/JamfSyncTests/XmlErrorParserTests.swift new file mode 100644 index 0000000..08c6116 --- /dev/null +++ b/JamfSyncTests/XmlErrorParserTests.swift @@ -0,0 +1,93 @@ +// +// Copyright 2024, Jamf +// + +@testable import Jamf_Sync +import XCTest + +final class XmlErrorParserTests: XCTestCase { + func testParser_happyPath() throws { + // Given + let xmlContent = + """ + + + EntityTooLarge + Your proposed upload exceeds the maximum allowed size + 13630203999 + 5368709120 + DV1FDVPNGBQ5M1PX + F9zqYNRz3MMZ5nsfIkkIgrjUK7STfn9CUiIB2TlmvbFXWA5M0N/4pIKIRMqpiIZXhlH2Cc0xEiY= + + """; + let xmlData = Data(xmlContent.utf8) + let xmlParser = XMLParser(data: xmlData) + let xmlErrorParser = XmlErrorParser() + xmlParser.delegate = xmlErrorParser + + // When + xmlParser.parse() + + // Then + XCTAssertEqual(xmlErrorParser.code, "EntityTooLarge") + XCTAssertEqual(xmlErrorParser.message, "Your proposed upload exceeds the maximum allowed size") + XCTAssertEqual(xmlErrorParser.proposedSize, "13630203999") + XCTAssertEqual(xmlErrorParser.maxAllowedSize, "5368709120") + XCTAssertEqual(xmlErrorParser.requestId, "DV1FDVPNGBQ5M1PX") + XCTAssertEqual(xmlErrorParser.hostId, "F9zqYNRz3MMZ5nsfIkkIgrjUK7STfn9CUiIB2TlmvbFXWA5M0N/4pIKIRMqpiIZXhlH2Cc0xEiY=") + XCTAssertNil(xmlErrorParser.parseError) + } + + func testParser_someMissingFields() throws { + // Given + let xmlContent = + """ + + + EntityTooLarge + Your proposed upload exceeds the maximum allowed size + + """; + let xmlData = Data(xmlContent.utf8) + let xmlParser = XMLParser(data: xmlData) + let xmlErrorParser = XmlErrorParser() + xmlParser.delegate = xmlErrorParser + + // When + xmlParser.parse() + + // Then + XCTAssertEqual(xmlErrorParser.code, "EntityTooLarge") + XCTAssertEqual(xmlErrorParser.message, "Your proposed upload exceeds the maximum allowed size") + XCTAssertNil(xmlErrorParser.proposedSize) + XCTAssertNil(xmlErrorParser.maxAllowedSize) + XCTAssertNil(xmlErrorParser.requestId) + XCTAssertNil(xmlErrorParser.hostId) + XCTAssertNil(xmlErrorParser.parseError) + } + + func testParser_parsingError() throws { + // Given + let xmlContent = + """ + This + is not very good