├── .github └── CODEOWNERS ├── .gitignore ├── CHANGELOG.md ├── CoreData ├── DataManager.swift └── StoredSettings.xcdatamodeld │ └── StoredSettings.xcdatamodel │ └── contents ├── Jamf Sync.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved └── xcshareddata │ └── xcschemes │ └── Jamf Sync.xcscheme ├── JamfSync ├── Info.plist ├── JamfSync.entitlements ├── Model │ ├── Checksum.swift │ ├── Checksums.swift │ ├── DataModel.swift │ ├── DataPersistence.swift │ ├── DistributionPoint.swift │ ├── DpFile.swift │ ├── DpFiles.swift │ ├── FileShareDp.swift │ ├── FolderDp.swift │ ├── FolderInstance.swift │ ├── GeneralCloudDp.swift │ ├── JamfProInstance.swift │ ├── JamfProPackageApi.swift │ ├── JamfProPackageClassicApi.swift │ ├── JamfProPackageUApi.swift │ ├── JamfProServerSelectionItem.swift │ ├── Jcds2Dp.swift │ ├── JsonObjects.swift │ ├── LogManager.swift │ ├── LogMessage.swift │ ├── Package.swift │ ├── SavableItem.swift │ ├── SavableItems.swift │ ├── SynchronizationProgress.swift │ ├── SynchronizeTask.swift │ └── ViewModels │ │ ├── DpFileViewModel.swift │ │ ├── DpFilesViewModel.swift │ │ ├── LogViewModel.swift │ │ ├── PackageListViewModel.swift │ │ ├── SettingsViewModel.swift │ │ └── SetupViewModel.swift ├── Multipart │ ├── CompletedChunk.swift │ ├── KeepAwake.swift │ ├── MultipartUpload.swift │ └── UploadTime.swift ├── Resources │ ├── Assets.xcassets │ │ ├── AppIcon.appiconset │ │ │ ├── Contents.json │ │ │ ├── icon_128x128.png │ │ │ ├── icon_128x128@2x.png │ │ │ ├── icon_16x16.png │ │ │ ├── icon_16x16@2x.png │ │ │ ├── icon_256x256.png │ │ │ ├── icon_256x256@2x.png │ │ │ ├── icon_32x32.png │ │ │ ├── icon_32x32@2x.png │ │ │ ├── icon_512x512.png │ │ │ └── icon_512x512@2x.png │ │ ├── Contents.json │ │ ├── JamfSync_64.imageset │ │ │ ├── Contents.json │ │ │ └── JamfSync_64.png │ │ └── pkgIcon.imageset │ │ │ ├── Contents.json │ │ │ ├── pkgIcon.png │ │ │ └── pkgIcon32.png │ ├── Jamf Sync User Guide.pdf │ └── Preview Content │ │ └── Preview Assets.xcassets │ │ └── Contents.json ├── UI │ ├── AboutView.swift │ ├── ChecksumView.swift │ ├── ConfirmationView.swift │ ├── ContentView.swift │ ├── FileShareCredentialsView.swift │ ├── FolderView.swift │ ├── HeaderView.swift │ ├── HelpMenu.swift │ ├── JamfProPasswordView.swift │ ├── JamfProServerPicker.swift │ ├── JamfProServerView.swift │ ├── JamfSyncApp.swift │ ├── LogMessageView.swift │ ├── LogView.swift │ ├── PackageAnimationView.swift │ ├── PackageListView.swift │ ├── SaveableItemListView.swift │ ├── SettingsView.swift │ ├── SetupView.swift │ ├── SourceDestinationView.swift │ └── SynchronizeProgressView.swift └── Utility │ ├── ArgumentParser.swift │ ├── CharacterSet_rfc3986Unreserved.swift │ ├── CloudSessionDelegate.swift │ ├── CommandLineProcessing.swift │ ├── FileHash.swift │ ├── FileManager+moveRetainingPermissions.swift │ ├── FileShare.swift │ ├── FileShares.swift │ ├── KeychainHelper.swift │ ├── OutputStream_write.swift │ ├── TemporaryFiles.swift │ ├── URL+isDirectory.swift │ ├── UserSettings.swift │ ├── VersionInfo.swift │ ├── View+NSWindow.swift │ └── XmlErrorParser.swift ├── JamfSyncTests ├── DistributionPointTests.swift ├── FileShareDpTests.swift ├── FolderDpTests.swift ├── Jcds2DpTests.swift ├── Mocks │ ├── MockDistributionPoint.swift │ ├── MockDistributionPointSync.swift │ ├── MockFileManager.swift │ └── MockJamfProInstance.swift ├── SynchronizeTaskTests.swift ├── TestErrors.swift ├── UploadTimeTests.swift └── XmlErrorParserTests.swift ├── JamfSyncUITests └── jamfsyncUITests.swift ├── LICENSE ├── README.md └── User Guide ├── Images ├── 01 JamfSync First Run.png ├── 02 JamfSync First Run After Accept Setup.png ├── 03 JamfSync Add Jamf Pro Server.png ├── 04 JamfSync Regular Credentials.png ├── 05 JamfSync Client API Credentials.png ├── 06 JamfSync Add File Folder StageReplica.png ├── 07 Folder Selection.png ├── 08 JamfSync Setup After Items Added.png ├── 09 JamfSync Selection Items.png ├── 10 Fileshare Password Prompt.png ├── 11 JamfSync Sync State.png ├── 12 JamfSync Synchronization Confirmation.png ├── 13 JamfSync Synchronization Confirmation Not Jamf Pro.png ├── 14 JamfSync Replication Progress.png ├── 15 JamfSync Network Volumes permission.png ├── 16 Log Messages.png ├── Gear icon.png ├── Green Plus.png ├── Green equals.png ├── Jamf Pro Password Prompt.png ├── Jamf Pro Server Selection Icon.png ├── Open circle black and white.png ├── Open circle red.png ├── Red X.png ├── Refresh small.png ├── Refresh.png ├── Settings.png ├── Trash can.png ├── White equals.png └── Yellow checkmark.png └── Jamf Sync User Guide.pages /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MIT 2 | # Copyright 2024, Jamf 3 | 4 | * @jamf/jamf_sync-maintainer 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | xcuserdata 2 | build/ 3 | *.xccheckout 4 | *.gcno 5 | *.DS_STORE 6 | *.xcuserstate 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [1.5.1] - 2025-2-10 8 | ### Bug fixes 9 | - No longer delete keychain credentials when mounting a file share fails. 10 | 11 | ## [1.5.0] - 2025-1-29 12 | ### Features 13 | - Added the option in settings to restrict deletions after synchronization and manual deletions either entirely or to allow only deleting files or allowing deletion of both files and packages. (Issue #42) 14 | - Changed the prompt for password for a file share to also include the username so it's possible to change it from what Jamf Pro uses. It will save the last username that was used successfully. (Potential fix for issue #48 and #49) 15 | - Added a --dryRun command line flag so you can see what will be copied and deleted without actually doing anything, and also added a dry run checkbox to the main view. (Issue #51) 16 | - Added a view where Jamf Pro servers can be activated or deactivated, and added a setting to have it display at startup before it reads any data from the servers. By limiting the number of active servers, startup time can be greatly reduced. (Issue #55) 17 | - Made it so you can upload zip files when clicking the + button for a distribution point. (Issue #59) 18 | ### Bug fixes 19 | - Made it unmount file shares after command line processing is completed. (Issue #50) 20 | 21 | ## [1.4.0] - 2024-10-25 22 | ### Features 23 | - Added a settings view and a setting for allowing deletions after synchronization. This defaults to off so it's less likely for someone to delete files unintentionally. 24 | ### Enhancements 25 | - Added some more detail to the synchronization confirmation message about how much will be deleted if one of the deletion options is chosen and added a warning that deletion cannot be undone. 26 | - Prevent macOS prompt for credentials from appearing if authentication fails on a file share and removes the keychain entry with the wrong credentials. 27 | ### Bug fixes 28 | - Prevent the machine running Jamf Sync from going to sleep when copying files to an AFP/SMB share. 29 | - Fix permission issues when syncing to a file share (GitHub issue #18 "Permissions issue with packages when using Jamf Sync") 30 | - Fixed an issue where it could crash if the Jamf Pro instance didn't have any packages. 31 | 32 | ## [1.3.3] - 2024-10-21 33 | ### Bug fixes 34 | - Added multipart uploads to solve an issue with >5GB uploads to JCDS distribution points. (GitHub issue #4 "Not able to upload huge files like 14GB.") 35 | - Changed the wording for the delete prompt to be a little less ambiguous. (GitHub issue #11: "Delete dialog ambiguity") 36 | - Fixed an issue where Cancel didn't stop uploading to a JCDS distribution point. (GitHub issue #15: "The file upload action cannot be canceled") 37 | - Fixed an issue where it would attempt to synchronize files that are not allowed by Jamf Pro. (GitHub issue #25: "UNSUPPORTED_FILE_TYPE") 38 | - Fixed a problem where it would only load 100 packages from Jamf Pro. This fix likely also fixed a DUPLICATE_FIELD error that would sometimes happen. (GitHub issue #28: "DUPLICATE_FIELD packageName") 39 | - Fixed an issue that would cause a synchronization from JCDS to Cloud to fail. 40 | - Fixed an issue where the progress view wouldn't go away if the synchronization completed while the cancel confirmation was shown. 41 | - Changed the wording when it fails to unmount a file share to indicate that it may have been unmounted externally. 42 | 43 | ## [1.3.2] - 2024-07-02 44 | ### Bug fixes 45 | - Fixed an issue where it would fail to copy a file from a JCDS DP to a file share DP. 46 | 47 | ## [1.3.1] - 2024-06-25 48 | ### Bug fixes 49 | - Fixed an issue where some package fields for packages on the server would be overwritten with default values when packages were updated. 50 | - Made it so you can delete a package on the Jamf Pro server that doesn't have a file associated with it, as long as "Files and associated packages" is selected. 51 | - Fixed an issue where the Synchronize button may not activate after synchronization is completed. 52 | - Made a change so when transferring files from JCDS distribution points to local or file share distribution points, it will retain the Posix and ACL permissions that are used when creating new files in the destination directory. 53 | - Updated the command line argument help and the documentation regarding that. 54 | - Made a change so that if connecting to a Jamf Pro server fails due to invalid credentials, it will prompt for credentials. 55 | - Changed the prompt for the file share password to include the server address so it is more obvious what password to specify. 56 | 57 | ## [1.3.0] - 2024-05-08 58 | ### Features 59 | - Added buttons below the source and destination distribution point to allow local files to be added or removed directly to/from the distribution point. 60 | - Added the ability to copy selected log messages to the clipboard. 61 | - Added support for mpkg files. 62 | ### Bug fixes 63 | - 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. 64 | - Fixed an issue with the "Cloud" DP type where the file progress wasn't quite right. 65 | 66 | ## [1.2.0] - 2024-04-16 67 | ### Features 68 | - 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. 69 | ### Enhancements 70 | - Made the column headers resizable. 71 | - Made it so it will show packages ending with ".pkg.zip" and if a package is non-flat (right click menu has "Show Package Contents"), it will only show up in the list if it doesn't have a corresponding ".pkg.zip" file. When a non-flat package is transferred, it will create a corresponding ".pkg.zip" file in the same directory and just transfer that. 72 | - Updated the About view. 73 | ### Bug fixes 74 | - Fixed an issue where files containing a "+" (and possibly other characters) would fail to upload to JCDS2 distribution points. 75 | 76 | ## [1.1.0] - 2024-03-19 77 | ### Features 78 | - Made the package, savable item and the log message list columns sortable by clicking on the column headers. 79 | ### Enhancements 80 | - Update AWS signature/header generation to AWS Signature Version 4 format, which improves JCDS 2 support. 81 | - Replaced CommonCrypto with CrytoKit. 82 | ### Bug fixes 83 | - Fixed an issue where it could indicate that JCDS2 was available when it was not. 84 | - Fixed an issue where the icons in the source list could be incorrect. 85 | 86 | ## [1.0.0] - 2024-02-09 87 | - First public release. 88 | - Synchronization between local folders, and file shares and JCDS2 distribution points from Jamf Pro instances. 89 | - Made checksum calculations on-demand instead of automatic. 90 | -------------------------------------------------------------------------------- /CoreData/DataManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import CoreData 6 | import Foundation 7 | 8 | /// Main data manager for the SavableItems (Jamf Pro or Follder instances) 9 | class DataManager: NSObject, ObservableObject { 10 | @Published var savableItems: [SavableItemData] = [] 11 | 12 | /// Add the Core Data container with the model name 13 | let container: NSPersistentContainer = NSPersistentContainer(name: "StoredSettings") 14 | 15 | 16 | /// Default init method. Load the Core Data container 17 | override init() { 18 | super.init() 19 | container.loadPersistentStores { _, _ in } 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /CoreData/StoredSettings.xcdatamodeld/StoredSettings.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /Jamf Sync.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Jamf Sync.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Jamf Sync.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "3232927a5ad14c3bd5cab85314153c072c69a789164a2f7d8cf0cd762e15d1b8", 3 | "pins" : [ 4 | { 5 | "identity" : "haversack", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/jamf/Haversack", 8 | "state" : { 9 | "revision" : "4d58a33d989d5ed318cc0ccc88f9ad6d064fbc68", 10 | "version" : "1.1.1" 11 | } 12 | }, 13 | { 14 | "identity" : "subprocess", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/jamf/Subprocess.git", 17 | "state" : { 18 | "revision" : "be6506cc6b5240cba12aeeace94130f9e25cb296", 19 | "version" : "3.0.5" 20 | } 21 | }, 22 | { 23 | "identity" : "swift-collections", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/apple/swift-collections", 26 | "state" : { 27 | "revision" : "9bf03ff58ce34478e66aaee630e491823326fd06", 28 | "version" : "1.1.3" 29 | } 30 | } 31 | ], 32 | "version" : 3 33 | } 34 | -------------------------------------------------------------------------------- /Jamf Sync.xcodeproj/xcshareddata/xcschemes/Jamf Sync.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 35 | 41 | 42 | 43 | 46 | 52 | 53 | 54 | 55 | 56 | 66 | 68 | 74 | 75 | 76 | 77 | 80 | 81 | 84 | 85 | 88 | 89 | 92 | 93 | 96 | 97 | 100 | 101 | 104 | 105 | 108 | 109 | 112 | 113 | 114 | 115 | 121 | 123 | 129 | 130 | 131 | 132 | 134 | 135 | 138 | 139 | 140 | -------------------------------------------------------------------------------- /JamfSync/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BuildMachineOSBuild 6 | 23E224 7 | CFBundleDevelopmentRegion 8 | $(DEVELOPMENT_LANGUAGE) 9 | CFBundleDisplayName 10 | Jamf Sync 11 | CFBundleExecutable 12 | $(EXECUTABLE_NAME) 13 | CFBundleIconFile 14 | AppIcon 15 | CFBundleIconName 16 | AppIcon 17 | CFBundleIdentifier 18 | $(PRODUCT_BUNDLE_IDENTIFIER) 19 | CFBundleInfoDictionaryVersion 20 | 6.0 21 | CFBundleName 22 | $(PRODUCT_NAME) 23 | CFBundlePackageType 24 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 25 | CFBundleShortVersionString 26 | $(MARKETING_VERSION) 27 | CFBundleSupportedPlatforms 28 | 29 | MacOSX 30 | 31 | CFBundleVersion 32 | $(CURRENT_PROJECT_VERSION) 33 | DTCompiler 34 | com.apple.compilers.llvm.clang.1_0 35 | DTPlatformBuild 36 | 37 | DTPlatformName 38 | macosx 39 | DTPlatformVersion 40 | 14.4 41 | DTSDKBuild 42 | 23E208 43 | DTSDKName 44 | macosx14.4 45 | DTXcode 46 | 1530 47 | DTXcodeBuild 48 | 15E204a 49 | LSApplicationCategoryType 50 | public.app-category.business 51 | LSMinimumSystemVersion 52 | 14.0 53 | NSHumanReadableCopyright 54 | Copyright 2024, Jamf 55 | 56 | 57 | -------------------------------------------------------------------------------- /JamfSync/JamfSync.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /JamfSync/Model/Checksum.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import Foundation 6 | 7 | enum ChecksumType: String { 8 | case SHA3_512 = "SHA3-512" 9 | case SHA_512 = "SHA-512" 10 | case SHA_256 = "SHA-256" 11 | case MD5 = "MD5" 12 | 13 | static func fromRawValue(_ rawValue: String) -> ChecksumType { 14 | switch rawValue { 15 | case String(describing: ChecksumType.SHA_512): 16 | return .SHA_512 17 | case String(describing: ChecksumType.SHA_256): 18 | return .SHA_256 19 | default: 20 | return .MD5 21 | } 22 | } 23 | } 24 | 25 | struct Checksum: Equatable, Identifiable { 26 | let id = UUID() 27 | var type: ChecksumType 28 | var value: String 29 | } 30 | -------------------------------------------------------------------------------- /JamfSync/Model/Checksums.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import Foundation 6 | 7 | class Checksums: Equatable { 8 | var checksums: [Checksum] = [] 9 | 10 | func updateChecksum(_ checksumToUpdate: Checksum) { 11 | removeChecksum(type: checksumToUpdate.type) 12 | checksums.append(checksumToUpdate) 13 | } 14 | 15 | func hasMatchingChecksumType(checksums: Checksums) -> Bool { 16 | for checksum in self.checksums { 17 | if checksums.findChecksum(type: checksum.type) != nil { 18 | return true 19 | } 20 | } 21 | return false 22 | } 23 | 24 | @discardableResult func removeChecksum(type: ChecksumType) -> Bool { 25 | let startingCount = checksums.count 26 | checksums = checksums.filter { $0.type != type } 27 | return (startingCount != checksums.count) 28 | } 29 | 30 | func findChecksum(type: ChecksumType) -> Checksum? { 31 | return checksums.first { $0.type == type } 32 | } 33 | 34 | func bestChecksum() -> Checksum? { 35 | // NOTE: Technically .SHA3_512 is probably better than .SHA_512, but the binary doesn't support SHA3_512 yet 36 | var checksum = findChecksum(type: .SHA_512) 37 | if checksum == nil { 38 | checksum = findChecksum(type: .SHA_256) 39 | } 40 | if checksum == nil { 41 | checksum = findChecksum(type: .MD5) 42 | } 43 | return checksum 44 | } 45 | 46 | static func == (lhs: Checksums, rhs: Checksums) -> Bool { 47 | if let sha512 = lhs.findChecksum(type: .SHA_512), let sha512ToCompare = rhs.findChecksum(type: .SHA_512) { 48 | return sha512.value == sha512ToCompare.value 49 | } 50 | if let md5 = lhs.findChecksum(type: .MD5), let md5ToCompare = rhs.findChecksum(type: .MD5) { 51 | return md5.value == md5ToCompare.value 52 | } 53 | 54 | return false 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /JamfSync/Model/DataPersistence.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import SwiftUI 6 | 7 | class DataPersistence: ObservableObject { 8 | let dataManager: DataManager 9 | let viewContext: NSManagedObjectContext 10 | 11 | init(dataManager: DataManager) { 12 | self.dataManager = dataManager 13 | self.viewContext = dataManager.container.viewContext 14 | } 15 | 16 | func loadSavableItems() -> SavableItems { 17 | let savableItems = SavableItems() 18 | let fetchRequest = NSFetchRequest(entityName: "SavableItemData") 19 | do { 20 | let items = try dataManager.container.viewContext.fetch(fetchRequest) 21 | for item in items { 22 | if let folderInstanceData = item as? FolderInstanceData { 23 | savableItems.items.append(FolderInstance(item: folderInstanceData)) 24 | } else if let jamfProInstanceData = item as? JamfProInstanceData { 25 | savableItems.items.append(JamfProInstance(item: jamfProInstanceData)) 26 | } else { 27 | // This shouldn't normally be possible unless there is a programming error 28 | LogManager.shared.logMessage(message: "A saved item wasn't one of the allowable types", level: .error) 29 | } 30 | } 31 | } catch { 32 | LogManager.shared.logMessage(message: "Failed to get items from core data: \(error)", level: .error) 33 | } 34 | return savableItems 35 | } 36 | 37 | func saveFolderInCoreData(instance: FolderInstance) { 38 | let newItem = FolderInstanceData(context: viewContext) 39 | newItem.initialize(from: instance) 40 | do { 41 | try viewContext.save() 42 | } catch { 43 | LogManager.shared.logMessage(message: "Failed to save new folder item \(instance.name)", level: .error) 44 | } 45 | } 46 | 47 | func updateInCoreData(instance: SavableItem) { 48 | let fetchRequest = NSFetchRequest(entityName: "SavableItemData") 49 | fetchRequest.predicate = NSPredicate(format: "id = %@", instance.id as CVarArg) 50 | 51 | do { 52 | let fetchResults = try viewContext.fetch(fetchRequest) 53 | if fetchResults.count != 0 { 54 | if let managedObject = fetchResults[0] as? SavableItemData { 55 | instance.copyToCoreStorageObject(managedObject) 56 | try viewContext.save() 57 | } else { 58 | LogManager.shared.logMessage(message: "The fetched results were not of the expected type", level: .error) 59 | } 60 | } 61 | } catch { 62 | LogManager.shared.logMessage(message: "Failed to save \(instance.name): \(error)", level: .error) 63 | } 64 | } 65 | 66 | func deleteCoreDataItem(id: UUID) { 67 | let fetchRequest = NSFetchRequest(entityName: "SavableItemData") 68 | fetchRequest.predicate = NSPredicate(format: "id = %@", id as CVarArg) 69 | 70 | do { 71 | let fetchResults = try viewContext.fetch(fetchRequest) 72 | if fetchResults.count != 0, let savableItemData = fetchResults[0] as? SavableItemData { 73 | viewContext.delete(savableItemData) 74 | try viewContext.save() 75 | } 76 | } catch { 77 | LogManager.shared.logMessage(message:"Failed to delete item with id \(id): \(error)", level: .error) 78 | } 79 | 80 | } 81 | 82 | func saveJamfProInCoreData(instance: JamfProInstance) { 83 | let newItem = JamfProInstanceData(context: viewContext) 84 | newItem.initialize(from: instance) 85 | do { 86 | try viewContext.save() 87 | } catch { 88 | LogManager.shared.logMessage(message: "Failed to save new folder item \(instance.name)", level: .error) 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /JamfSync/Model/DpFile.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import Foundation 6 | 7 | // NOTE: This is a class instead of a struct so it can be passed by reference since they need to be stored in a list and later found and modified 8 | class DpFile: Identifiable { 9 | var id = UUID() 10 | var name: String 11 | var fileUrl: URL? 12 | var sizeString: String { return size == nil ? "--" : String(size!) } 13 | var size: Int64? 14 | var checksums = Checksums() 15 | 16 | init(name: String, fileUrl: URL? = nil, size: Int64?, checksums: Checksums? = nil) { 17 | self.name = name 18 | self.fileUrl = fileUrl 19 | self.size = size 20 | if let checksums { 21 | self.checksums = checksums 22 | } 23 | } 24 | 25 | init(dpFile: DpFile) { 26 | id = dpFile.id 27 | name = dpFile.name 28 | fileUrl = dpFile.fileUrl 29 | size = dpFile.size 30 | checksums = dpFile.checksums 31 | } 32 | 33 | static func == (lhs: DpFile, rhs: DpFile) -> Bool { 34 | if lhs.checksums.hasMatchingChecksumType(checksums: rhs.checksums) { 35 | return lhs.checksums == rhs.checksums 36 | } else { 37 | return lhs.size == rhs.size 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /JamfSync/Model/DpFiles.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import Foundation 6 | 7 | class DpFiles { 8 | var files: [DpFile] = [] 9 | 10 | func findDpFile(id: UUID) -> DpFile? { 11 | return files.first { $0.id == id } 12 | } 13 | 14 | func findDpFile(name: String) -> DpFile? { 15 | return files.first { $0.name == name } 16 | } 17 | 18 | func removeAll() { 19 | files.removeAll() 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /JamfSync/Model/FolderDp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import Foundation 6 | 7 | class FolderDp: DistributionPoint { 8 | var filePath: String = "" 9 | 10 | init(name: String, filePath: String, fileManager: FileManager? = nil) { 11 | super.init(name: name, fileManager: fileManager) 12 | self.filePath = filePath 13 | } 14 | 15 | override func showCalcChecksumsButton() -> Bool { 16 | return true 17 | } 18 | 19 | override func retrieveFileList(limitFileTypes: Bool = true) async throws { 20 | try await retrieveLocalFileList(localPath: filePath, limitFileTypes: limitFileTypes) 21 | } 22 | 23 | override func deleteFile(file: DpFile, progress: SynchronizationProgress) async throws { 24 | let fileUrl = URL(fileURLWithPath: filePath).appendingPathComponent(file.name) 25 | try deleteLocal(localUrl: fileUrl, progress: progress) 26 | } 27 | 28 | override func transferFile(srcFile: DpFile, moveFrom: URL? = nil, progress: SynchronizationProgress) async throws { 29 | try await transferLocal(localPath: filePath, srcFile: srcFile, moveFrom: moveFrom, progress: progress) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /JamfSync/Model/FolderInstance.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import Foundation 6 | 7 | class FolderInstance: SavableItem, ObservableObject { 8 | var folderDp: FolderDp 9 | static let iconName = "folder" 10 | 11 | init(name: String = "", filePath: String = "") { 12 | self.folderDp = FolderDp(name: name, filePath: filePath) 13 | super.init(name: name) 14 | self.iconName = Self.iconName 15 | } 16 | 17 | override init(item: SavableItem, copyId: Bool = true) { 18 | if let srcFolderInstance = item as? FolderInstance { 19 | self.folderDp = FolderDp(name: srcFolderInstance.folderDp.name, filePath: srcFolderInstance.folderDp.filePath) 20 | } else { 21 | self.folderDp = FolderDp(name: item.name, filePath: "") 22 | } 23 | super.init(item: item, copyId: copyId) 24 | self.iconName = Self.iconName 25 | } 26 | 27 | override init(item: SavableItemData) { 28 | if let srcFolderInstance = item as? FolderInstanceData { 29 | self.folderDp = FolderDp(name: srcFolderInstance.name ?? "", filePath: srcFolderInstance.filePath ?? "") 30 | } else { 31 | self.folderDp = FolderDp(name: item.name ?? "", filePath: "") 32 | } 33 | super.init(item: item) 34 | self.iconName = Self.iconName 35 | } 36 | 37 | override func copyToCoreStorageObject(_ item: SavableItemData) { 38 | super.copyToCoreStorageObject(item) 39 | if let srcFolderInstanceData = item as? FolderInstanceData { 40 | srcFolderInstanceData.filePath = folderDp.filePath 41 | } 42 | } 43 | 44 | override func copy(source: SavableItem, copyId: Bool = true) { 45 | super.copy(source: source, copyId: copyId) 46 | if let srcFolderInstance = source as? FolderInstance { 47 | self.folderDp = FolderDp(name: srcFolderInstance.folderDp.name, filePath: srcFolderInstance.folderDp.filePath) 48 | } 49 | } 50 | 51 | // MARK: - SavableItem functions 52 | 53 | override func displayInfo() -> String { 54 | return folderDp.filePath 55 | } 56 | 57 | override func getDps() -> [DistributionPoint] { 58 | return [folderDp] // The wonderful thing about FolderDp...I'm the only one 59 | } 60 | 61 | override func jamfProId() -> UUID? { 62 | return nil 63 | } 64 | 65 | override func jamfProPackages() -> [Package]? { 66 | return nil 67 | } 68 | } 69 | 70 | extension FolderInstanceData { 71 | func initialize(from folderInstance: FolderInstance) { 72 | self.id = folderInstance.id 73 | self.name = folderInstance.name 74 | self.filePath = folderInstance.folderDp.filePath 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /JamfSync/Model/JamfProPackageApi.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import Foundation 6 | 7 | protocol JamfProPackageApi { 8 | /// Loads packages from Jamf Pro 9 | /// - Parameters: 10 | /// - jamfProInstance: The jamf pro instance to communicate with 11 | func loadPackages(jamfProInstance: JamfProInstance) async throws -> [Package] 12 | 13 | /// Adds a package to the Jamf Pro instance using the next available id. 14 | /// - Parameters: 15 | /// - dpFile: The file to add as a package 16 | /// - jamfProInstance: The jamf pro instance to communicate with 17 | func addPackage(dpFile: DpFile, jamfProInstance: JamfProInstance) async throws 18 | 19 | /// Update a package in Jamf Pro 20 | /// - Parameters: 21 | /// - package: The package information to update 22 | /// - jamfProInstance: The jamf pro instance to communicate with 23 | func updatePackage(package: Package, jamfProInstance: JamfProInstance) async throws 24 | 25 | /// Delete a package from Jamf Pro 26 | /// - Parameters: 27 | /// - packageId: The id of the package to update 28 | /// - jamfProInstance: The jamf pro instance to communicate with 29 | func deletePackage(packageId: Int, jamfProInstance: JamfProInstance) async throws 30 | } 31 | -------------------------------------------------------------------------------- /JamfSync/Model/JamfProPackageClassicApi.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import Foundation 6 | 7 | class JamfProPackageClassicApi: JamfProPackageApi { 8 | func loadPackages(jamfProInstance: JamfProInstance) async throws -> [Package] { 9 | guard let url = jamfProInstance.url else { throw ServerCommunicationError.noJamfProUrl } 10 | 11 | var packages: [Package] = [] 12 | let packagesUrl = url.appendingPathComponent("JSSResource/packages") 13 | 14 | let response = try await jamfProInstance.dataRequest(url: packagesUrl, httpMethod: "GET") 15 | if let data = response.data { 16 | let decoder = JSONDecoder() 17 | if let jsonPackages = try? decoder.decode(JsonCapiPackages.self, from: data) { 18 | packages.removeAll() 19 | for package in jsonPackages.packages { 20 | if let package = try await loadPackage(package: package, jamfProInstance: jamfProInstance) { 21 | packages.append(package) 22 | } 23 | } 24 | } 25 | } 26 | 27 | return packages 28 | } 29 | 30 | func addPackage(dpFile: DpFile, jamfProInstance: JamfProInstance) async throws { 31 | guard let url = jamfProInstance.url else { throw ServerCommunicationError.noJamfProUrl } 32 | 33 | var package = Package(jamfProId: 0, displayName: dpFile.name, fileName: dpFile.name, category: "", size: dpFile.size, checksums: dpFile.checksums) 34 | let packageUrl = url.appendingPathComponent("JSSResource/packages/id/0") 35 | let body = packageBody(package: package, jamfProPackageId: 0) 36 | guard let bodyData = body.data(using: .utf8) else { throw ServerCommunicationError.badPackageData } 37 | let response = try await jamfProInstance.dataRequest(url: packageUrl, httpMethod: "POST", httpBody: bodyData, contentType: "text/xml") 38 | if let data = response.data { 39 | if let dataString = String(data: data, encoding: .utf8), let jamfProId = parseAddPackageResult(resultString: dataString) { 40 | package.jamfProId = jamfProId 41 | jamfProInstance.packages.append(package) 42 | } else { 43 | LogManager.shared.logMessage(message: "Failed to add a package for file \(dpFile.name) to Jamf Pro server \(jamfProInstance.displayName())", level: .error) 44 | throw ServerCommunicationError.parsingError 45 | } 46 | } else { 47 | LogManager.shared.logMessage(message: "Failed to add a package for file \(dpFile.name) to Jamf Pro server \(jamfProInstance.displayName())", level: .error) 48 | throw ServerCommunicationError.parsingError 49 | } 50 | } 51 | 52 | /// Updates an existing package in the Jamf Pro instance. 53 | /// - Parameters: 54 | /// - package: A Package object to update 55 | func updatePackage(package: Package, jamfProInstance: JamfProInstance) async throws { 56 | guard let url = jamfProInstance.url else { throw ServerCommunicationError.noJamfProUrl } 57 | 58 | guard let jamfProId = package.jamfProId else { throw ServerCommunicationError.badPackageData } 59 | let packageUrl = url.appendingPathComponent("JSSResource/packages/id/\(jamfProId)") 60 | let body = packageBody(package: package, jamfProPackageId: jamfProId) 61 | guard let bodyData = body.data(using: .utf8) else { throw ServerCommunicationError.badPackageData } 62 | let response = try await jamfProInstance.dataRequest(url: packageUrl, httpMethod: "PUT", httpBody: bodyData, contentType: "text/xml") 63 | if let data = response.data { 64 | if let dataString = String(data: data, encoding: .utf8), let jamfProId = parseAddPackageResult(resultString: dataString) { 65 | if let packageJamfProId = package.jamfProId, packageJamfProId != jamfProId { 66 | LogManager.shared.logMessage(message: "After updating the \(package.fileName) package, the package id of \(jamfProId) is different than the expected \(packageJamfProId)", level: .warning) 67 | } 68 | } else { 69 | LogManager.shared.logMessage(message: "Failed to update a package for file \(package.fileName) on Jamf Pro server \(jamfProInstance.displayName())", level: .error) 70 | throw ServerCommunicationError.parsingError 71 | } 72 | } else { 73 | LogManager.shared.logMessage(message: "Failed to update a package for file \(package.fileName) on Jamf Pro server \(jamfProInstance.displayName())", level: .error) 74 | throw ServerCommunicationError.parsingError 75 | } 76 | } 77 | 78 | func deletePackage(packageId: Int, jamfProInstance: JamfProInstance) async throws { 79 | guard let url = jamfProInstance.url else { throw ServerCommunicationError.noJamfProUrl } 80 | 81 | let packageUrl = url.appendingPathComponent("JSSResource/packages/id/\(packageId)") 82 | let _ = try await jamfProInstance.dataRequest(url: packageUrl, httpMethod: "DELETE") 83 | } 84 | 85 | // MARK: Private functions 86 | 87 | private func loadPackage(package: JsonCapiPackage, jamfProInstance: JamfProInstance) async throws -> Package? { 88 | guard let url = jamfProInstance.url else { throw ServerCommunicationError.noJamfProUrl } 89 | 90 | let packageUrl = url.appendingPathComponent("JSSResource/packages/id/\(package.id)") 91 | let response = try await jamfProInstance.dataRequest(url: packageUrl, httpMethod: "GET") 92 | if let data = response.data { 93 | let decoder = JSONDecoder() 94 | let jsonPackage = try decoder.decode(JsonCapiPackageItem.self, from: data) 95 | return Package(capiPackageDetail: jsonPackage.package) 96 | } 97 | return nil 98 | } 99 | 100 | private func packageBody(package: Package, jamfProPackageId: Int) -> String { 101 | let checksum = package.checksums.bestChecksum() 102 | var checksumTypeString = "MD5" 103 | if let checksumType = checksum?.type { 104 | checksumTypeString = String(describing: checksumType) 105 | } 106 | 107 | var category = package.category 108 | if category == "No category assigned" { 109 | category = "" 110 | } 111 | 112 | return """ 113 | 114 | 115 | \(jamfProPackageId) 116 | \(package.displayName) 117 | \(category ?? "None") 118 | \(package.fileName) 119 | \(package.info ?? "") 120 | \(package.notes ?? "") 121 | \(package.priority ?? 10) 122 | \(package.rebootRequired ?? false) 123 | \(package.fillUserTemplate ?? false) 124 | \(package.fillExistingUsers ?? false) 125 | \(package.uninstall ?? false) 126 | \(package.osRequirements ?? "") 127 | None 128 | \(checksumTypeString) 129 | \(checksum?.value ?? "") 130 | \(package.switch_with_package ?? "Do Not Install") 131 | \(package.install_if_reported_available ?? "false") 132 | \(package.reinstall_option ?? "Do Not Reinstall") 133 | 134 | \(package.send_notification ?? false) 135 | 136 | """ 137 | } 138 | 139 | private func parseAddPackageResult(resultString: String) -> Int? { 140 | if let match = resultString.range(of: "(?<=)[^<\\/id>]+", options: .regularExpression) { 141 | let idValue = resultString[match] 142 | if let id = Int(idValue) { 143 | return id 144 | } else { 145 | LogManager.shared.logMessage(message: "Failed to convert the returned package id to an integer: \(idValue)", level: .warning) 146 | return nil 147 | } 148 | } 149 | LogManager.shared.logMessage(message: "Could not parse the package id from the returned data: \(resultString)", level: .warning) 150 | return nil 151 | } 152 | 153 | } 154 | -------------------------------------------------------------------------------- /JamfSync/Model/JamfProPackageUApi.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import Foundation 6 | 7 | class JamfProPackageUApi: JamfProPackageApi { 8 | 9 | /// Loads all the packages from the server 10 | /// - Parameters: 11 | /// - jamfProInstance: The Jamf Pro server from which to load packages 12 | /// - Returns: Returns an array of packages 13 | func loadPackages(jamfProInstance: JamfProInstance) async throws -> [Package] { 14 | let pageSize = 200 15 | var pageOfPackages = try await loadPageOfPackages(pageNbr: 0, pageSize: pageSize, jamfProInstance: jamfProInstance) 16 | var packages = pageOfPackages.packages 17 | let pages = (pageOfPackages.totalPackages + (pageSize - 1)) / pageSize 18 | if pages > 0 { 19 | for pageNbr in 1.. (totalPackages: Int, packages: [Package]) { 88 | guard let url = jamfProInstance.url else { throw ServerCommunicationError.noJamfProUrl } 89 | 90 | var packages: [Package] = [] 91 | let sortByIdInAscendingOrder = "id%3Aasc" 92 | let pageParameters = [URLQueryItem(name: "page", value: "\(pageNbr)"), URLQueryItem(name: "page-size", value: "\(pageSize)"), URLQueryItem(name: "sort", value: sortByIdInAscendingOrder)] 93 | let packagesUrl = url.appendingPathComponent("/api/v1/packages").appending(queryItems: pageParameters) 94 | 95 | var totalPackages = 0 96 | let response = try await jamfProInstance.dataRequest(url: packagesUrl, httpMethod: "GET") 97 | if let data = response.data { 98 | let decoder = JSONDecoder() 99 | do { 100 | if let jsonPackages = try decoder.decode(JsonUapiPackages?.self, from: data) { 101 | totalPackages = jsonPackages.totalCount 102 | LogManager.shared.logMessage(message: "\(jamfProInstance.url?.absoluteString ?? "unknown server") - page \(pageNbr) retrieved \(jsonPackages.results.count) of \(totalPackages) packages", level: .debug) 103 | for package in jsonPackages.results { 104 | if let package = convertToPackage(jsonPackage: package) { 105 | packages.append(package) 106 | } 107 | } 108 | return (totalPackages: totalPackages, packages: packages) 109 | } 110 | } catch { 111 | LogManager.shared.logMessage(message: "Failed to load package info from page \(pageNbr): \(error)", level: .error) 112 | } 113 | } 114 | return (totalPackages: totalPackages, packages: packages) 115 | } 116 | 117 | private func createBoundaryForMultipartUpload() -> String { 118 | let alphNumChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" 119 | let uniqueStr = String((0..<16).map{ _ in alphNumChars.randomElement()! }) 120 | 121 | return "----WebKitFormBoundary\(uniqueStr)" 122 | } 123 | 124 | private func convertToPackage(jsonPackage: JsonUapiPackageDetail) -> Package? { 125 | guard let jamfProIdString = jsonPackage.id, let _ = Int(jamfProIdString), let _ = jsonPackage.packageName, let _ = jsonPackage.fileName else { return nil } 126 | return Package(uapiPackageDetail: jsonPackage) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /JamfSync/Model/JamfProServerSelectionItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import Foundation 6 | 7 | struct JamfProServerSelectionItem: Identifiable { 8 | var id = UUID() 9 | var selectionName: String 10 | var isActive: Bool 11 | var jamfProInstance: JamfProInstance 12 | 13 | init(jamfProInstance: JamfProInstance) { 14 | self.jamfProInstance = jamfProInstance 15 | self.selectionName = "\(jamfProInstance.displayName()) (\(jamfProInstance.displayInfo()))" 16 | self.isActive = jamfProInstance.isActive 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /JamfSync/Model/JsonObjects.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import Foundation 6 | 7 | struct JsonToken: Decodable { 8 | let token: String 9 | let expires: String 10 | } 11 | 12 | struct JsonOauthToken: Decodable { 13 | let access_token: String 14 | let scope: String 15 | let token_type: String 16 | let expires_in: Int 17 | } 18 | 19 | struct JsonCloudInformation: Decodable { 20 | let cloudInstance: Bool 21 | } 22 | 23 | struct JsonCloudDpUploadCapability: Decodable { 24 | let principalDistributionTechnology: Bool 25 | let directUploadCapable: Bool 26 | } 27 | 28 | struct JsonDps: Decodable { 29 | let distribution_points: [JsonDp] 30 | } 31 | 32 | struct JsonDp: Decodable { 33 | let id: Int 34 | let name: String 35 | } 36 | 37 | struct JsonDpItem: Decodable { 38 | let distribution_point: JsonDpDetail 39 | } 40 | 41 | struct JsonDpDetail: Decodable { 42 | var connection_type: String? 43 | var context: String? 44 | var enable_load_balancing: Bool? 45 | var failover_point: String? 46 | var failover_point_url: String? 47 | var http_downloads_enabled: Bool? 48 | var http_password_sha256: String? 49 | var http_url: String? 50 | var http_username: String? 51 | var id: Int? 52 | var ip_address: String? 53 | var is_master: Bool? 54 | var local_path: String? 55 | var name: String? 56 | var no_authentication_required: Bool? 57 | var port: Int? 58 | var protoco: String? 59 | var read_only_password_sha256: String? 60 | var read_only_username: String? 61 | var read_write_password_sha256: String? 62 | var read_write_username: String? 63 | var share_name: String? 64 | var share_port: Int? 65 | var ssh_password_sha256: String? 66 | var ssh_username: String? 67 | var username_password_required: Bool? 68 | var workgroup_or_domain: String? 69 | } 70 | 71 | struct JsonUapiPackages: Decodable { 72 | let totalCount: Int 73 | let results: [JsonUapiPackageDetail] 74 | } 75 | 76 | struct JsonUapiPackageDetail: Codable { 77 | let id: String? 78 | let packageName: String? 79 | let fileName: String? 80 | var categoryId: String? 81 | var info: String? 82 | var notes: String? 83 | var priority: Int? 84 | var osRequirements: String? 85 | var fillUserTemplate: Bool? 86 | var indexed: Bool? 87 | var uninstall: Bool? 88 | var fillExistingUsers: Bool? 89 | var swu: Bool? 90 | var rebootRequired: Bool? 91 | var selfHealNotify: Bool? 92 | var selfHealingAction: String? 93 | var osInstall: Bool? 94 | var serialNumber: String? 95 | var parentPackageId: String? 96 | var basePath: String? 97 | var suppressUpdates: Bool? 98 | var cloudTransferStatus: String? 99 | var ignoreConflicts: Bool? 100 | var suppressFromDock: Bool? 101 | var suppressEula: Bool? 102 | var suppressRegistration: Bool? 103 | var installLanguage: String? 104 | var md5: String? 105 | var sha256: String? 106 | var hashType: String? 107 | var hashValue: String? 108 | var size: String? 109 | var osInstallerVersion: String? 110 | var manifest: String? 111 | var manifestFileName: String? 112 | var format: String? 113 | 114 | init(package: Package) { 115 | if let srcId = package.jamfProId { 116 | id = String(srcId) 117 | } else { 118 | id = nil 119 | } 120 | packageName = package.displayName 121 | fileName = package.fileName 122 | categoryId = package.category 123 | categoryId = package.categoryId ?? "-1" 124 | priority = package.priority ?? 10 125 | fillUserTemplate = package.fillUserTemplate ?? false 126 | uninstall = package.uninstall ?? false 127 | rebootRequired = package.rebootRequired ?? false 128 | osInstall = package.osInstall ?? false 129 | suppressUpdates = package.suppressUpdates ?? false 130 | suppressFromDock = package.suppressFromDock ?? false 131 | suppressEula = package.suppressEula ?? false 132 | suppressRegistration = package.suppressRegistration ?? false 133 | if let checksum = package.checksums.findChecksum(type: .MD5) { 134 | md5 = checksum.value 135 | } else { 136 | md5 = nil 137 | } 138 | if let checksum = package.checksums.findChecksum(type: .SHA_256) { 139 | sha256 = checksum.value 140 | } else { 141 | sha256 = nil 142 | } 143 | if let checksum = package.checksums.findChecksum(type: .SHA_512) { 144 | hashType = "SHA_512" 145 | hashValue = checksum.value 146 | } else { 147 | hashType = nil 148 | hashValue = nil 149 | } 150 | if let pkgSize = package.size { 151 | size = String(pkgSize) 152 | } else { 153 | size = nil 154 | } 155 | 156 | info = package.info 157 | notes = package.notes 158 | osRequirements = package.osRequirements 159 | indexed = package.indexed 160 | fillExistingUsers = package.fillExistingUsers 161 | swu = package.swu 162 | selfHealNotify = package.selfHealNotify 163 | selfHealingAction = package.selfHealingAction 164 | serialNumber = package.serialNumber 165 | parentPackageId = package.parentPackageId 166 | basePath = package.basePath 167 | cloudTransferStatus = package.cloudTransferStatus 168 | ignoreConflicts = package.ignoreConflicts 169 | installLanguage = package.installLanguage 170 | osInstallerVersion = package.osInstallerVersion 171 | format = package.format 172 | } 173 | } 174 | 175 | struct JsonUapiAddPackageResult: Decodable { 176 | let id: String 177 | let href: String? 178 | } 179 | 180 | struct JsonCapiPackages: Decodable { 181 | let packages: [JsonCapiPackage] 182 | } 183 | 184 | struct JsonCapiPackage: Decodable { 185 | let id: Int 186 | let name: String 187 | } 188 | 189 | struct JsonCapiPackageItem: Decodable { 190 | let package: JsonCapiPackageDetail 191 | } 192 | 193 | struct JsonCapiPackageDetail: Decodable { 194 | var allow_uninstalled: Bool? 195 | var category: String? 196 | var filename: String? 197 | var fill_existing_users: Bool? 198 | var fill_user_template: Bool? 199 | var hash_type: String? 200 | var hash_value: String? 201 | let id: Int? 202 | var info: String? 203 | var install_if_reported_available: String? 204 | let name: String? 205 | var notes: String? 206 | var os_requirements: String? 207 | var priority: Int? 208 | var reboot_required: Bool? 209 | var reinstall_option: String? 210 | var required_processor: String? 211 | var send_notification: Bool? 212 | var switch_with_package: String? 213 | var triggering_files: [String: String]? 214 | } 215 | 216 | struct JsonCloudFile: Decodable { 217 | let fileName: String? 218 | let length: Int64? 219 | let md5: String? 220 | let regoin: String? 221 | let sha3: String? 222 | } 223 | 224 | struct JsonCloudFileDownload: Decodable { 225 | let uri: String? 226 | } 227 | 228 | class JsonInitiateUpload: Decodable { 229 | var accessKeyID: String? 230 | var expiration: Int? 231 | var secretAccessKey: String? 232 | var sessionToken: String? 233 | let region: String? 234 | let bucketName: String? 235 | let path: String? 236 | let uuid: String? 237 | } 238 | 239 | struct JsonJamfProVersion: Decodable { 240 | let version: String 241 | } 242 | -------------------------------------------------------------------------------- /JamfSync/Model/LogManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import Foundation 6 | import OSLog 7 | 8 | class LogManager: ObservableObject { 9 | static let shared = LogManager() 10 | static let logMessageNotification = "com.jamfsoftware.jamfsync.logMessageNotification" 11 | var logMessages: [LogMessage] = [] 12 | let log = OSLog(subsystem: "com.jamf.jamfsync", category: "JamfSync") 13 | let logger = Logger(subsystem: "com.jamf.jamfsync", category: "JamfSync") 14 | 15 | func logMessage(message: String, level: LogLevel, dryRun: Bool = false) { 16 | var message = message 17 | if dryRun { 18 | message = "[Dry Run] \(message)" 19 | } 20 | let logMessage = LogMessage(logLevel: level, message: message) 21 | let logMessageString = logMessageToString(logMessage) 22 | writeSystemLog(message: message, level: level) 23 | 24 | if logMessage.showToUser() { 25 | writeMessageToConsole(logMessageString) 26 | logMessages.append(logMessage) 27 | NotificationCenter.default.post(name: Notification.Name(LogManager.logMessageNotification), object: logMessage) 28 | } 29 | } 30 | 31 | func writeMessageToConsole(_ message: String) { 32 | print("\(message)") 33 | } 34 | 35 | func writeSystemLog(message: String, level: LogLevel) { 36 | switch level { 37 | case .debug: 38 | logger.debug("\(message, privacy: .private)") 39 | case .verbose: 40 | logger.trace("\(message, privacy: .public)") 41 | case .warning: 42 | logger.warning("\(message, privacy: .public)") 43 | case .error: 44 | logger.error("\(message, privacy: .public)") 45 | case .info: 46 | logger.info("\(message, privacy: .public)") 47 | } 48 | } 49 | 50 | func logMessageToString(_ logMessage: LogMessage) -> String { 51 | let dateString = dateToLogDateString(logMessage.date) 52 | return "\(dateString)-\(logMessage.logLevel.rawValue): \(logMessage.message)" 53 | } 54 | 55 | func dateToLogDateString(_ date: Date) -> String { 56 | let dateFormatter = DateFormatter() 57 | dateFormatter.dateFormat = "YY/MM/dd HH:mm:ss" 58 | return dateFormatter.string(from: date) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /JamfSync/Model/LogMessage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import Foundation 6 | 7 | enum LogLevel: String, Comparable { 8 | case debug = "DEBUG" 9 | case verbose = "VERBOSE" 10 | case warning = "WARNING" 11 | case error = "ERROR" 12 | case info = "INFO" 13 | 14 | static func < (lhs: Self, rhs: Self) -> Bool { 15 | return lhs.rawValue < rhs.rawValue 16 | } 17 | } 18 | 19 | struct LogMessage: Identifiable { 20 | let id = UUID() 21 | var date: Date 22 | var logLevel: LogLevel 23 | var message: String 24 | 25 | init(logLevel: LogLevel, message: String) { 26 | self.date = Date() 27 | self.logLevel = logLevel 28 | self.message = message 29 | } 30 | 31 | func showToUser() -> Bool { 32 | return logLevel == .error || logLevel == .warning || logLevel == .info || logLevel == .verbose 33 | } 34 | 35 | func showOnMainScreen() -> Bool { 36 | return logLevel == .error || logLevel == .warning || logLevel == .info 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /JamfSync/Model/Package.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import Foundation 6 | 7 | struct Package: Identifiable { 8 | var id = UUID() 9 | var jamfProId: Int? 10 | var displayName: String 11 | var fileName: String 12 | var size: Int64? 13 | var checksums = Checksums() 14 | var category: String? 15 | var categoryId: String? 16 | var info: String? 17 | var notes: String? 18 | var priority: Int? 19 | var osRequirements: String? 20 | var fillUserTemplate: Bool? 21 | var indexed: Bool? // Not to be updated 22 | var uninstall: Bool? // Not to be updated 23 | var fillExistingUsers: Bool? 24 | var swu: Bool? 25 | var rebootRequired: Bool? 26 | var selfHealNotify: Bool? 27 | var selfHealingAction: String? 28 | var osInstall: Bool? 29 | var serialNumber: String? 30 | var parentPackageId: String? 31 | var basePath: String? 32 | var suppressUpdates: Bool? 33 | var cloudTransferStatus: String? // Not to be updated 34 | var ignoreConflicts: Bool? 35 | var suppressFromDock: Bool? 36 | var suppressEula: Bool? 37 | var suppressRegistration: Bool? 38 | var installLanguage: String? 39 | var osInstallerVersion: String? 40 | var manifest: String? 41 | var manifestFileName: String? 42 | var format: String? 43 | var install_if_reported_available: String? 44 | var reinstall_option: String? 45 | var send_notification: Bool? 46 | var switch_with_package: String? 47 | var triggering_files: [String: String]? 48 | 49 | init(jamfProId: Int?, displayName: String, fileName: String, category: String, size: Int64?, checksums: Checksums) { 50 | self.jamfProId = jamfProId 51 | self.displayName = displayName 52 | self.fileName = fileName 53 | self.category = category 54 | self.size = size 55 | self.checksums = checksums 56 | } 57 | 58 | init(capiPackageDetail: JsonCapiPackageDetail) { 59 | jamfProId = capiPackageDetail.id 60 | displayName = capiPackageDetail.name ?? "" 61 | fileName = capiPackageDetail.filename ?? "" 62 | category = capiPackageDetail.category ?? "None" 63 | let hashType = capiPackageDetail.hash_type ?? "MD5" 64 | let hashValue = capiPackageDetail.hash_value 65 | if let hashValue, !hashValue.isEmpty { 66 | checksums.updateChecksum(Checksum(type: ChecksumType.fromRawValue(hashType), value: hashValue)) 67 | } 68 | info = capiPackageDetail.info 69 | notes = capiPackageDetail.notes 70 | priority = capiPackageDetail.priority 71 | osRequirements = capiPackageDetail.os_requirements 72 | fillUserTemplate = capiPackageDetail.fill_user_template 73 | fillExistingUsers = capiPackageDetail.fill_existing_users 74 | rebootRequired = capiPackageDetail.reboot_required 75 | osInstallerVersion = capiPackageDetail.os_requirements 76 | install_if_reported_available = capiPackageDetail.install_if_reported_available 77 | reinstall_option = capiPackageDetail.reinstall_option 78 | send_notification = capiPackageDetail.send_notification 79 | switch_with_package = capiPackageDetail.switch_with_package 80 | triggering_files = capiPackageDetail.triggering_files 81 | } 82 | 83 | 84 | init(uapiPackageDetail: JsonUapiPackageDetail) { 85 | if let jamfProIdString = uapiPackageDetail.id, let jamfProId = Int(jamfProIdString) { 86 | self.jamfProId = jamfProId 87 | } 88 | self.displayName = uapiPackageDetail.packageName ?? "" 89 | self.fileName = uapiPackageDetail.fileName ?? "" 90 | self.categoryId = uapiPackageDetail.categoryId ?? "-1" 91 | if let md5Value = uapiPackageDetail.md5, !md5Value.isEmpty { 92 | self.checksums.updateChecksum(Checksum(type: .MD5, value: md5Value)) 93 | } 94 | if let sha256Value = uapiPackageDetail.sha256, !sha256Value.isEmpty { 95 | self.checksums.updateChecksum(Checksum(type: .SHA_256, value: sha256Value)) 96 | } 97 | if let hashType = uapiPackageDetail.hashType, !hashType.isEmpty, let hashValue = uapiPackageDetail.hashValue, !hashValue.isEmpty { 98 | self.checksums.updateChecksum(Checksum(type: ChecksumType.fromRawValue(hashType), value: hashValue)) 99 | } 100 | if let sizeString = uapiPackageDetail.size { 101 | self.size = Int64(sizeString) 102 | } 103 | info = uapiPackageDetail.info 104 | notes = uapiPackageDetail.notes 105 | priority = uapiPackageDetail.priority 106 | osRequirements = uapiPackageDetail.osRequirements 107 | fillUserTemplate = uapiPackageDetail.fillUserTemplate 108 | indexed = uapiPackageDetail.indexed 109 | uninstall = uapiPackageDetail.uninstall 110 | fillExistingUsers = uapiPackageDetail.fillExistingUsers 111 | swu = uapiPackageDetail.swu 112 | rebootRequired = uapiPackageDetail.rebootRequired 113 | selfHealNotify = uapiPackageDetail.selfHealNotify 114 | selfHealingAction = uapiPackageDetail.selfHealingAction 115 | osInstall = uapiPackageDetail.osInstall 116 | serialNumber = uapiPackageDetail.serialNumber 117 | parentPackageId = uapiPackageDetail.parentPackageId 118 | basePath = uapiPackageDetail.basePath 119 | suppressUpdates = uapiPackageDetail.suppressUpdates 120 | cloudTransferStatus = uapiPackageDetail.cloudTransferStatus 121 | ignoreConflicts = uapiPackageDetail.ignoreConflicts 122 | suppressFromDock = uapiPackageDetail.suppressFromDock 123 | suppressEula = uapiPackageDetail.suppressEula 124 | suppressRegistration = uapiPackageDetail.suppressRegistration 125 | installLanguage = uapiPackageDetail.installLanguage 126 | osInstallerVersion = uapiPackageDetail.osInstallerVersion 127 | manifest = uapiPackageDetail.manifest 128 | manifestFileName = uapiPackageDetail.manifestFileName 129 | format = uapiPackageDetail.format 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /JamfSync/Model/SavableItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import Foundation 6 | 7 | class SavableItem: Identifiable { 8 | var id = UUID() 9 | var name: String = "" 10 | var iconName: String = "" 11 | var urlOrFolder: String { 12 | get { 13 | return self.displayInfo() 14 | } 15 | } 16 | 17 | init(name: String) { 18 | self.name = name 19 | } 20 | 21 | init(item: SavableItem, copyId: Bool = true) { 22 | if copyId { 23 | id = item.id 24 | } 25 | name = item.name 26 | } 27 | 28 | init(item: SavableItemData) { 29 | if let id = item.id { 30 | self.id = id 31 | } 32 | self.name = item.name ?? "" 33 | } 34 | 35 | /// Copies the data from this object to a core storage object 36 | /// - Parameters: 37 | /// - item: A SavableItemData object to copy the data to 38 | func copyToCoreStorageObject(_ item: SavableItemData) { 39 | item.name = name 40 | } 41 | 42 | /// Copies data from another SavableItem object. This should be overridden by child classes although it should also call super.copy(source:, copyid:) 43 | /// - Parameters: 44 | /// - source: A SavableItem object to copy the data from 45 | /// - copyId: Whether to copy the id from the source 46 | func copy(source: SavableItem, copyId: Bool = true) { 47 | if copyId { 48 | self.id = source.id 49 | } 50 | self.name = source.name 51 | } 52 | 53 | /// Gets a string for use with a selection list. This should be overridden by child classes. 54 | /// - Returns: Returns a string that represents the object in a selection list. 55 | func displayInfo() -> String { 56 | // This should be overridden 57 | return "" 58 | } 59 | 60 | /// Gets a list of distribution points associated with this object. This should be overridden by child classes. 61 | /// - Returns: Returns an array of DistributionPoint objects 62 | func getDps() -> [DistributionPoint] { 63 | // This should be overridden 64 | return [] 65 | } 66 | 67 | /// Loads information for the associated distribution points. This should be overridden by child classes that need to do something in order to know which distribution points are associated with it. 68 | func loadDps() async throws { 69 | // This should be overridden if any loading needs to be done 70 | } 71 | 72 | /// Returns the Jamf Pro Id of an associated Jamf Pro instance, otherwise it returns nil. This should be overridden by child classes that are associated with a Jamf Pro instance. 73 | /// - Returns: A UUID of the Jamf Pro instance, or nil if it's not associated with a Jamf Pro instance 74 | func jamfProId() -> UUID? { 75 | // This should be overridden if applicable 76 | return nil 77 | } 78 | 79 | /// Returns a list of packages that are associated with Jamf Pro instance, otherwise it returns nil. This should be overridden by child classes that are associated with a Jamf Pro instance. 80 | /// - Returns: A list of Package objects that are associated with a Jamf Pro instance, otherwise nil. 81 | func jamfProPackages() -> [Package]? { 82 | // This should be overridden if applicable 83 | return nil 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /JamfSync/Model/SavableItems.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import Foundation 6 | 7 | class SavableItems: ObservableObject { 8 | @Published var items: [SavableItem] = [] 9 | 10 | /// Create an exact duplicate that can be sent to the Setup view so if they cancel, the changes can be discarded 11 | func duplicate(copyId: Bool = true) -> SavableItems { 12 | let newSavableItems = SavableItems() 13 | 14 | for item in items { 15 | if item is JamfProInstance { 16 | newSavableItems.items.append(JamfProInstance(item: item, copyId: copyId)) 17 | } else if item is FolderInstance { 18 | newSavableItems.items.append(FolderInstance(item: item, copyId: copyId)) 19 | } else { 20 | // This shouldn't normally be possible unless there is a programming error 21 | LogManager.shared.logMessage(message: "Unknown type for \(item.name)", level: .error) 22 | } 23 | } 24 | 25 | return newSavableItems 26 | } 27 | 28 | /// Replace the contents with the contents of the object passed in. This is used to accept the changes from the Setup view 29 | func replace(savableItems: SavableItems, copyId: Bool = true) { 30 | items.removeAll() 31 | 32 | for item in savableItems.items { 33 | if item is JamfProInstance { 34 | items.append(JamfProInstance(item: item, copyId: copyId)) 35 | } else if item is FolderInstance { 36 | items.append(FolderInstance(item: item, copyId: copyId)) 37 | } else { 38 | // This shouldn't normally be possible unless there is a programming error 39 | LogManager.shared.logMessage(message: "Unknown type for \(item.name)", level: .error) 40 | } 41 | } 42 | } 43 | 44 | func findSavableItem(id: UUID?) -> SavableItem? { 45 | guard let id else { return nil } 46 | for item in items { 47 | if item.id == id { 48 | return item 49 | } 50 | } 51 | return nil 52 | } 53 | 54 | func findSavableItemWithDpId(id: UUID?) -> SavableItem? { 55 | guard let id else { return nil } 56 | for item in items { 57 | for dp in item.getDps() { 58 | if dp.id == id { 59 | return item 60 | } 61 | } 62 | } 63 | return nil 64 | } 65 | 66 | func addJamfProInstance(jamfProInstance: JamfProInstance) { 67 | items.append(JamfProInstance(item: jamfProInstance, copyId: false)) 68 | } 69 | 70 | func addFolderInstance(folderInstance: FolderInstance) { 71 | items.append(FolderInstance(item: folderInstance, copyId: false)) 72 | } 73 | 74 | @discardableResult func updateSavableItem(item: SavableItem) -> Bool { 75 | guard let itemFound = findSavableItem(id: item.id) else { return false } 76 | // Copy data from the item passed in to the item that's already in the list. 77 | if let jamfProInstanceFound = itemFound as? JamfProInstance, let jamfProInstance = item as? JamfProInstance { 78 | jamfProInstanceFound.copy(source: jamfProInstance) 79 | } else if let folderInstanceFound = itemFound as? FolderInstance, let folderInstance = item as? FolderInstance { 80 | folderInstanceFound.copy(source: folderInstance) 81 | } 82 | objectWillChange.send() // Changing the item in the list won't cause the views to redraw 83 | return true 84 | } 85 | 86 | @discardableResult func deleteSavableItem(id: UUID) -> Bool { 87 | guard let itemFound = findSavableItem(id: id), 88 | let index = items.firstIndex(where: { $0 === itemFound }) else { return false } 89 | items.remove(at: index) 90 | return true 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /JamfSync/Model/SynchronizationProgress.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import Foundation 6 | 7 | class SynchronizationProgress: ObservableObject { 8 | var totalSize: Int64? 9 | @Published var operation: String? 10 | @Published var currentFile: DpFile? 11 | @Published var currentTotalSizeTransferred: Int64 = 0 12 | @Published var currentFileSizeTransferred: Int64? 13 | var overheadSizePerFile: Int = 0 14 | var printToConsole = false 15 | var showProgressOnConsole = false 16 | var printToConsoleInterval: TimeInterval = 1.0 17 | private var lastValuePrinted = "" 18 | private var lastPrintedTime = Date.now 19 | 20 | func initializeFileTransferInfoForFile(operation: String?, currentFile: DpFile?, currentTotalSizeTransferred: Int64) { 21 | if printToConsole, let currentFile { 22 | // NOTE: MainActor isn't called when processing the command line arguments since no UI is shown yet 23 | setInitialVars(operation: operation, currentFile: currentFile, currentTotalSizeTransferred: currentTotalSizeTransferred) 24 | var operationString = "" 25 | if let operation { 26 | operationString = "\(operation) " 27 | } 28 | print("\(operationString)\(currentFile.name)...") 29 | } else { 30 | Task { @MainActor in 31 | setInitialVars(operation: operation, currentFile: currentFile, currentTotalSizeTransferred: currentTotalSizeTransferred) 32 | } 33 | } 34 | } 35 | 36 | func updateFileTransferInfo(totalBytesTransferred: Int64, bytesTransferred: Int64) { 37 | if printToConsole { 38 | // NOTE: MainActor isn't called when processing the command line arguments since no UI is shown yet 39 | setFileTransferVars(totalBytesTransferred: totalBytesTransferred, bytesTransferred: bytesTransferred, progress: self) 40 | if showProgressOnConsole { 41 | printProgressToConsole() 42 | } 43 | } else { 44 | Task { @MainActor in 45 | setFileTransferVars(totalBytesTransferred: totalBytesTransferred, bytesTransferred: bytesTransferred, progress: self) 46 | } 47 | } 48 | } 49 | 50 | func finalProgressValues(totalBytesTransferred: Int64, currentTotalSizeTransferred: Int64) { 51 | if printToConsole { 52 | // NOTE: MainActor isn't called when processing the command line arguments since no UI is shown yet 53 | setFinalProgressValues(totalBytesTransferred: totalBytesTransferred, currentTotalSizeTransferred: currentTotalSizeTransferred) 54 | if showProgressOnConsole { 55 | printProgressToConsole() 56 | } 57 | } else { 58 | Task { @MainActor in 59 | setFinalProgressValues(totalBytesTransferred: totalBytesTransferred, currentTotalSizeTransferred: currentTotalSizeTransferred) 60 | } 61 | } 62 | } 63 | 64 | func fileProgress() -> Double? { 65 | if let currentFileSizeTransferred, let currentFile { 66 | let size = (currentFile.size ?? 0) + Int64(overheadSizePerFile) 67 | if size > 0 { 68 | return Double(currentFileSizeTransferred) / Double(size) 69 | } 70 | } 71 | return nil 72 | } 73 | 74 | func totalProgress() -> Double? { 75 | if let totalSize, totalSize > 0 { 76 | return Double(currentTotalSizeTransferred) / Double(totalSize) 77 | } 78 | return nil 79 | } 80 | 81 | // MARK: - Private functions 82 | 83 | private func printProgressToConsole() { 84 | guard Date.now > lastPrintedTime + printToConsoleInterval || isAt100Percent() else { return } 85 | var fileProgressString = "" 86 | var totalProgressString = "" 87 | if let progress = fileProgress() { 88 | fileProgressString = "File progress: \(Int(progress * 100.0))%\t" 89 | } 90 | if let progress = totalProgress() { 91 | totalProgressString = "Total progress: \(Int(progress * 100.0))%" 92 | } 93 | print("\(fileProgressString)\(totalProgressString)"/*, terminator: "\r"*/) // TODO: It looked like passing "\r" as the terminator would cause it to print the progress on top of itself, but it ended up not showing up at all. See if there is a way to fix this when we get time or inspiration. 94 | lastPrintedTime = Date.now 95 | } 96 | 97 | private func setInitialVars(operation: String?, currentFile: DpFile?, currentTotalSizeTransferred: Int64) { 98 | self.operation = operation 99 | self.currentFile = currentFile 100 | if currentFile != nil { 101 | self.currentFileSizeTransferred = 0 102 | } 103 | self.currentTotalSizeTransferred = currentTotalSizeTransferred 104 | } 105 | 106 | private func setFileTransferVars(totalBytesTransferred: Int64, bytesTransferred: Int64, progress: SynchronizationProgress) { 107 | if progress.operation == "Downloading" { 108 | currentFileSizeTransferred = totalBytesTransferred 109 | if isAt100Percent() { 110 | currentFileSizeTransferred = 0 111 | } 112 | } else { 113 | if currentFileSizeTransferred == nil { 114 | currentFileSizeTransferred = 0 115 | } 116 | currentFileSizeTransferred? += bytesTransferred 117 | } 118 | currentTotalSizeTransferred += bytesTransferred 119 | } 120 | 121 | private func setFinalProgressValues(totalBytesTransferred: Int64, currentTotalSizeTransferred: Int64) { 122 | self.currentFileSizeTransferred = totalBytesTransferred 123 | self.currentTotalSizeTransferred = currentTotalSizeTransferred 124 | } 125 | 126 | private func isAt100Percent() -> Bool { 127 | if let progress = fileProgress(), progress >= 1.0 { 128 | return true 129 | } 130 | if let progress = totalProgress(), progress >= 1.0 { 131 | return true 132 | } 133 | return false 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /JamfSync/Model/SynchronizeTask.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import Foundation 6 | 7 | class SynchronizeTask { 8 | var activeDp: DistributionPoint? 9 | var keepAwake = KeepAwake() 10 | 11 | /// Loops through the files to synchronize to calculate the total size of files to be transferred. 12 | /// - Parameters: 13 | /// - srcDp: The destination distribution point to copy the files from 14 | /// - dstDp: The destination distribution point to copy the files to 15 | /// - selectedItems: The selected items to synchronize. If the selection list is empty, it will synchronize all files from the source distribution point 16 | /// - jamfProInstance: The Jamf Pro instance of the destination distribution point, if it is associated with one 17 | /// - forceSync: Set to true if it should copy files even if they are the same on both the source and destination 18 | /// - deleteFiles: Set to true if it should delete files from the destination that are not on the source 19 | /// - deletePackages: Set to true if it should delete packages from the Jamf Pro instance associated with the destination distribution point 20 | /// - progress: The progress object that should be updated as the synchronization progresses 21 | /// - Returns: Returns true if the file lists need to reload files, otherwise false 22 | func synchronize(srcDp: DistributionPoint, dstDp: DistributionPoint, selectedItems: [DpFile], jamfProInstance: JamfProInstance?, forceSync: Bool, deleteFiles: Bool, deletePackages: Bool, progress: SynchronizationProgress, dryRun: Bool) async throws -> Bool { 23 | 24 | keepAwake.disableSleep(reason: "Starting Sync") 25 | keepAwake.disableDiskIdle(reason: "Starting Sync") 26 | defer { 27 | keepAwake.enableSleep() 28 | keepAwake.enableIdleDisk() 29 | } 30 | 31 | activeDp = srcDp 32 | try await srcDp.prepareDp() 33 | try await srcDp.retrieveFileList() 34 | try await dstDp.prepareDp() 35 | try await dstDp.retrieveFileList() 36 | try await srcDp.copyFiles(selectedItems: selectedItems, dstDp: dstDp, jamfProInstance: jamfProInstance, forceSync: forceSync, progress: progress, dryRun: dryRun) 37 | if !srcDp.isCanceled && selectedItems.count == 0 { 38 | if deleteFiles { 39 | try await dstDp.deleteFilesNotOnSource(srcDp: srcDp, progress: progress, dryRun: dryRun) 40 | } 41 | if let jamfProInstance, deletePackages { 42 | try await jamfProInstance.deletePackagesNotOnSource(srcDp: srcDp, progress: progress, dryRun: dryRun) 43 | } 44 | } 45 | activeDp = nil 46 | return srcDp.filesWereZipped 47 | } 48 | 49 | func cancel() { 50 | activeDp?.cancel() 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /JamfSync/Model/ViewModels/DpFileViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import Foundation 6 | 7 | enum FileState: String, Comparable { 8 | case undefined = "Haven't done any checking of the source or destination yet" 9 | case matched = "Exists on both the source and destination, and they match (checksums and/or size)" 10 | case mismatched = "Exists on both the source and destination, but they do not match (checksums and/or size)" 11 | case packageMissing = "Present as a package on the Jamf Pro Server but missing on both the source and destination" 12 | case packageMissingOnSrc = "Present as a package on the Jamf Pro Server but missing on the source" 13 | case missingOnSrc = "Missing on the source but present on the destination" 14 | case missingOnDst = "Present on the source but missing on the destination" 15 | 16 | static func < (lhs: Self, rhs: Self) -> Bool { 17 | return lhs.rawValue < rhs.rawValue 18 | } 19 | } 20 | 21 | class DpFileViewModel: ObservableObject, Identifiable { 22 | var id = UUID() 23 | @Published var state: FileState = .undefined 24 | @Published var showChecksumSpinner = false 25 | var dpFile: DpFile 26 | 27 | init(dpFile: DpFile, state: FileState = .undefined) { 28 | self.state = state 29 | self.dpFile = dpFile 30 | } 31 | 32 | func compressedSize() -> String { 33 | guard let size = dpFile.size else { return "--" } 34 | let formatter = ByteCountFormatter() 35 | formatter.allowedUnits = .useAll 36 | formatter.countStyle = .file 37 | formatter.includesUnit = true 38 | formatter.isAdaptive = true 39 | return formatter.string(fromByteCount: size) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /JamfSync/Model/ViewModels/DpFilesViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import Foundation 6 | 7 | class DpFilesViewModel: ObservableObject { 8 | @Published var files: [DpFileViewModel] = [] 9 | var cancelChecksumUpdate = false 10 | 11 | func removeAll() { 12 | self.files.removeAll() 13 | } 14 | 15 | func showChecksumSpinner(dpFile: DpFileViewModel, show: Bool) { 16 | Task { @MainActor in 17 | dpFile.showChecksumSpinner = show 18 | } 19 | } 20 | 21 | func addMissingPackages(packages: [Package]?, isSrc: Bool, srcDp: DistributionPoint?, dstDp: DistributionPoint?) { 22 | guard let packages else { return } 23 | for package in packages { 24 | let fileState = determineStateForPackage(package: package, isSrc: isSrc, srcDp: srcDp, dstDp: dstDp) 25 | if fileState != .undefined { 26 | files.append(DpFileViewModel(dpFile: DpFile(name: package.fileName, size: package.size, checksums: package.checksums), state: fileState)) 27 | } 28 | } 29 | } 30 | 31 | func determineStateForPackage(package: Package, isSrc: Bool, srcDp: DistributionPoint?, dstDp: DistributionPoint?) -> FileState { 32 | var fileState: FileState = .undefined 33 | // If this is the source, then just set as missing if it's not found on the source files 34 | if isSrc { 35 | if srcDp?.dpFiles.findDpFile(name: package.fileName) == nil { 36 | fileState = .packageMissing 37 | } 38 | } else { 39 | if let dstDp { 40 | if let srcDp, srcDp.id != DataModel.noSelection { 41 | if srcDp.dpFiles.findDpFile(name: package.fileName) == nil && dstDp.dpFiles.findDpFile(name: package.fileName) == nil { 42 | fileState = .packageMissingOnSrc 43 | } 44 | } else { 45 | if dstDp.dpFiles.findDpFile(name: package.fileName) == nil { 46 | fileState = .packageMissing 47 | } 48 | } 49 | } 50 | } 51 | return fileState 52 | } 53 | 54 | func updateChecksums() async { 55 | cancelChecksumUpdate = false 56 | for file in files { 57 | guard !cancelChecksumUpdate else { return } 58 | guard file.dpFile.checksums.findChecksum(type: .SHA_512) == nil else { continue } 59 | if let fileUrl = file.dpFile.fileUrl, !fileUrl.isDirectory, let filePath = fileUrl.path().removingPercentEncoding { 60 | showChecksumSpinner(dpFile: file, show: true) 61 | do { 62 | let hashValue = try await FileHash.shared.createSHA512Hash(filePath: filePath) 63 | if let hashValue { 64 | let checksum = Checksum(type: .SHA_512, value: hashValue) 65 | file.dpFile.checksums.updateChecksum(checksum) 66 | } 67 | } catch { 68 | LogManager.shared.logMessage(message: "Failed to calculated the checksum for file \(file.dpFile.fileUrl?.path ?? ""): \(error)", level: .error) 69 | } 70 | DataModel.shared.updateListViewModels(checksumUpdateInProgress: true) 71 | showChecksumSpinner(dpFile: file, show: false) 72 | } 73 | } 74 | } 75 | 76 | func findDpFileViewModel(id: UUID) -> DpFileViewModel? { 77 | return files.first { $0.id == id } 78 | } 79 | 80 | func findDpFile(id: UUID) -> DpFileViewModel? { 81 | return files.first { $0.dpFile.id == id } 82 | } 83 | 84 | func findDpFile(name: String) -> DpFileViewModel? { 85 | return files.first { $0.dpFile.name == name } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /JamfSync/Model/ViewModels/LogViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import Foundation 6 | 7 | class LogViewModel: ObservableObject { 8 | static let messageDuration = 5.0 9 | @Published var logMessages: [LogMessage] = [] 10 | @Published var messageToShow: LogMessage? 11 | var observer: NSObjectProtocol? 12 | var timer: Timer? 13 | 14 | init() { 15 | copyMessagesFromLogManager() 16 | observer = NotificationCenter.default.addObserver(forName: NSNotification.Name(LogManager.logMessageNotification), object: nil, queue: .main, using: { [weak self] notification in 17 | if let logMessage = notification.object as? LogMessage { 18 | self?.messageAdded(logMessage: logMessage) 19 | } 20 | }) 21 | } 22 | 23 | deinit { 24 | stopNotifications() 25 | } 26 | 27 | func clear() { 28 | logMessages.removeAll() 29 | } 30 | 31 | func findLogMessage(id: UUID) -> LogMessage? { 32 | return logMessages.first { $0.id == id } 33 | } 34 | 35 | // MARK: - Private functions 36 | 37 | private func stopNotifications() { 38 | if let observer { 39 | NotificationCenter.default.removeObserver(observer) 40 | self.observer = nil 41 | } 42 | } 43 | 44 | private func copyMessagesFromLogManager() { 45 | for logMessage in LogManager.shared.logMessages { 46 | logMessages.append(logMessage) 47 | } 48 | } 49 | 50 | private func messageAdded(logMessage: LogMessage) { 51 | Task { @MainActor in 52 | logMessages.append(logMessage) 53 | if logMessage.showOnMainScreen() { 54 | messageToShow = logMessage 55 | if let timer { 56 | timer.invalidate() 57 | self.timer = nil 58 | } 59 | timer = Timer.scheduledTimer(withTimeInterval: Self.messageDuration, repeats: false, block: { timer in 60 | self.messageToShow = nil 61 | }) 62 | } 63 | } 64 | } 65 | } 66 | 67 | -------------------------------------------------------------------------------- /JamfSync/Model/ViewModels/SettingsViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import Foundation 6 | 7 | class SettingsViewModel: ObservableObject { 8 | let userSettings = UserSettings() 9 | 10 | @Published var allowDeletionsAfterSynchronization: DeletionOptions = .none 11 | @Published var allowManualDeletions: DeletionOptions = .filesAndAssociatedPackages 12 | @Published var promptForJamfProInstances = false 13 | 14 | init() { 15 | loadSettings() 16 | } 17 | 18 | func loadSettings() { 19 | allowDeletionsAfterSynchronization = userSettings.allowDeletionsAfterSynchronization 20 | allowManualDeletions = userSettings.allowManualDeletions 21 | promptForJamfProInstances = userSettings.promptForJamfProInstances 22 | } 23 | 24 | func saveSettings() { 25 | userSettings.allowDeletionsAfterSynchronization = allowDeletionsAfterSynchronization 26 | userSettings.allowManualDeletions = allowManualDeletions 27 | userSettings.promptForJamfProInstances = promptForJamfProInstances 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /JamfSync/Model/ViewModels/SetupViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import Foundation 6 | 7 | class SetupViewModel: ObservableObject { 8 | @Published var selectedSavableItemId: SavableItem.ID? 9 | @Published var title: String = "" 10 | @Published var shouldPresentJamfProServerSheet = false 11 | @Published var shouldPresentFileFolderSheet = false 12 | @Published var shouldPresentEditSheet = false 13 | @Published var shouldPresentConfirmationSheet = false 14 | @Published var folderInstance = FolderInstance() 15 | @Published var jamfProInstance = JamfProInstance() 16 | @Published var canceled = false 17 | } 18 | -------------------------------------------------------------------------------- /JamfSync/Multipart/CompletedChunk.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | class CompletedChunk { 6 | var partNumber: Int 7 | var eTag: String 8 | 9 | init(partNumber: Int, eTag: String) { 10 | self.partNumber = partNumber 11 | self.eTag = eTag 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /JamfSync/Multipart/KeepAwake.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import Foundation 6 | import IOKit.pwr_mgt 7 | 8 | class KeepAwake { 9 | var noSleepAssertionID: IOPMAssertionID = 0 10 | var noDiskIdleAssertionID: IOPMAssertionID = 1 11 | var sleepDisabled = false 12 | var idleDiskDisabled = false 13 | 14 | deinit { 15 | enableSleep() 16 | } 17 | 18 | public func disableSleep(reason: String) { 19 | guard !sleepDisabled else { return } 20 | let status = IOPMAssertionCreateWithName(kIOPMAssertPreventUserIdleSystemSleep as CFString,IOPMAssertionLevel(kIOPMAssertionLevelOn), reason as CFString, &noSleepAssertionID) 21 | if status == kIOReturnSuccess { 22 | sleepDisabled = true 23 | } else { 24 | LogManager.shared.logMessage(message: "Failed to disable sleep...this could cause the upload to fail.", level: .warning) 25 | } 26 | } 27 | 28 | public func disableDiskIdle(reason: String) { 29 | guard !idleDiskDisabled else { return } 30 | let status = IOPMAssertionCreateWithName(kIOPMAssertPreventDiskIdle as CFString,IOPMAssertionLevel(kIOPMAssertionLevelOn), reason as CFString, &noDiskIdleAssertionID) 31 | if status == kIOReturnSuccess { 32 | idleDiskDisabled = true 33 | } else { 34 | LogManager.shared.logMessage(message: "Failed to disable disk idle...this could cause the upload to fail.", level: .warning) 35 | } 36 | } 37 | 38 | public func enableSleep() { 39 | enableIdleDisk() 40 | guard sleepDisabled else { return } 41 | if IOPMAssertionRelease(noSleepAssertionID) == kIOReturnSuccess { 42 | sleepDisabled = false 43 | } else { 44 | LogManager.shared.logMessage(message: "Failed to re-enable sleep...this could affect power consumption.", level: .warning) 45 | } 46 | } 47 | 48 | public func enableIdleDisk() { 49 | guard idleDiskDisabled else { return } 50 | if IOPMAssertionRelease(noDiskIdleAssertionID) == kIOReturnSuccess { 51 | idleDiskDisabled = false 52 | } else { 53 | LogManager.shared.logMessage(message: "Failed to re-enable disk idle...this could affect power consumption.", level: .warning) 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /JamfSync/Multipart/UploadTime.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import Foundation 6 | 7 | class UploadTime { 8 | var start: TimeInterval = 0.0 9 | var end: TimeInterval = 0.0 10 | 11 | func total() -> String { 12 | let formatter = DateComponentsFormatter() 13 | formatter.unitsStyle = .full 14 | formatter.allowedUnits = [.hour, .minute, .second] 15 | return formatter.string(from: end - start) ?? "" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /JamfSync/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_16x16.png", 5 | "idiom" : "mac", 6 | "scale" : "1x", 7 | "size" : "16x16" 8 | }, 9 | { 10 | "filename" : "icon_16x16@2x.png", 11 | "idiom" : "mac", 12 | "scale" : "2x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "icon_32x32.png", 17 | "idiom" : "mac", 18 | "scale" : "1x", 19 | "size" : "32x32" 20 | }, 21 | { 22 | "filename" : "icon_32x32@2x.png", 23 | "idiom" : "mac", 24 | "scale" : "2x", 25 | "size" : "32x32" 26 | }, 27 | { 28 | "filename" : "icon_128x128.png", 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "filename" : "icon_128x128@2x.png", 35 | "idiom" : "mac", 36 | "scale" : "2x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "icon_256x256.png", 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "256x256" 44 | }, 45 | { 46 | "filename" : "icon_256x256@2x.png", 47 | "idiom" : "mac", 48 | "scale" : "2x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "icon_512x512.png", 53 | "idiom" : "mac", 54 | "scale" : "1x", 55 | "size" : "512x512" 56 | }, 57 | { 58 | "filename" : "icon_512x512@2x.png", 59 | "idiom" : "mac", 60 | "scale" : "2x", 61 | "size" : "512x512" 62 | } 63 | ], 64 | "info" : { 65 | "author" : "xcode", 66 | "version" : 1 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /JamfSync/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamf/JamfSync/4f494f96528118c2dd3b120ab9e287fae784be40/JamfSync/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png -------------------------------------------------------------------------------- /JamfSync/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamf/JamfSync/4f494f96528118c2dd3b120ab9e287fae784be40/JamfSync/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png -------------------------------------------------------------------------------- /JamfSync/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamf/JamfSync/4f494f96528118c2dd3b120ab9e287fae784be40/JamfSync/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png -------------------------------------------------------------------------------- /JamfSync/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamf/JamfSync/4f494f96528118c2dd3b120ab9e287fae784be40/JamfSync/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png -------------------------------------------------------------------------------- /JamfSync/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamf/JamfSync/4f494f96528118c2dd3b120ab9e287fae784be40/JamfSync/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png -------------------------------------------------------------------------------- /JamfSync/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamf/JamfSync/4f494f96528118c2dd3b120ab9e287fae784be40/JamfSync/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png -------------------------------------------------------------------------------- /JamfSync/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamf/JamfSync/4f494f96528118c2dd3b120ab9e287fae784be40/JamfSync/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png -------------------------------------------------------------------------------- /JamfSync/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamf/JamfSync/4f494f96528118c2dd3b120ab9e287fae784be40/JamfSync/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png -------------------------------------------------------------------------------- /JamfSync/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamf/JamfSync/4f494f96528118c2dd3b120ab9e287fae784be40/JamfSync/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png -------------------------------------------------------------------------------- /JamfSync/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamf/JamfSync/4f494f96528118c2dd3b120ab9e287fae784be40/JamfSync/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png -------------------------------------------------------------------------------- /JamfSync/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /JamfSync/Resources/Assets.xcassets/JamfSync_64.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "JamfSync_64.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /JamfSync/Resources/Assets.xcassets/JamfSync_64.imageset/JamfSync_64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamf/JamfSync/4f494f96528118c2dd3b120ab9e287fae784be40/JamfSync/Resources/Assets.xcassets/JamfSync_64.imageset/JamfSync_64.png -------------------------------------------------------------------------------- /JamfSync/Resources/Assets.xcassets/pkgIcon.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "pkgIcon.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "pkgIcon32.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /JamfSync/Resources/Assets.xcassets/pkgIcon.imageset/pkgIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamf/JamfSync/4f494f96528118c2dd3b120ab9e287fae784be40/JamfSync/Resources/Assets.xcassets/pkgIcon.imageset/pkgIcon.png -------------------------------------------------------------------------------- /JamfSync/Resources/Assets.xcassets/pkgIcon.imageset/pkgIcon32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamf/JamfSync/4f494f96528118c2dd3b120ab9e287fae784be40/JamfSync/Resources/Assets.xcassets/pkgIcon.imageset/pkgIcon32.png -------------------------------------------------------------------------------- /JamfSync/Resources/Jamf Sync User Guide.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamf/JamfSync/4f494f96528118c2dd3b120ab9e287fae784be40/JamfSync/Resources/Jamf Sync User Guide.pdf -------------------------------------------------------------------------------- /JamfSync/Resources/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /JamfSync/UI/AboutView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct AboutView: View { 8 | var body: some View { 9 | HStack(alignment: .top) { 10 | Image("JamfSync_64") 11 | VStack(alignment: .leading) { 12 | Text("Jamf Sync") 13 | .font(.title) 14 | Text("\(VersionInfo().getDisplayVersion())") 15 | 16 | Text("Jamf Sync helps synchronize content to Jamf Pro distribution points.\n\nCopyright 2024, Jamf") 17 | .padding([.top]) 18 | } 19 | } 20 | .padding() 21 | .frame(minWidth: 530, minHeight: 150) 22 | } 23 | } 24 | 25 | struct AboutView_Previews: PreviewProvider { 26 | static var previews: some View { 27 | AboutView() 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /JamfSync/UI/ChecksumView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct ChecksumView: View { 8 | @ObservedObject var packageListViewModel: PackageListViewModel 9 | @ObservedObject var file: DpFileViewModel 10 | 11 | var body: some View { 12 | ZStack { 13 | if file.showChecksumSpinner { 14 | ProgressView() 15 | .scaleEffect(x: 0.5, y: 0.5, anchor: .center) 16 | .frame(width: 16, height: 16, alignment: .leading) 17 | } else { 18 | Text(checksumString(fileItem: file.dpFile)) 19 | .help(checksumHelpString(fileItem: file.dpFile)) 20 | } 21 | } 22 | } 23 | 24 | func checksumString(fileItem: DpFile) -> String { 25 | if fileItem.checksums.checksums.count == 0 { 26 | return "--" 27 | } else { 28 | var checksumString = "" 29 | for (idx, checksum) in fileItem.checksums.checksums.enumerated() { 30 | checksumString += checksum.type.rawValue 31 | if idx < fileItem.checksums.checksums.count - 1 { 32 | checksumString += ", " 33 | } 34 | } 35 | return checksumString 36 | } 37 | } 38 | 39 | func checksumHelpString(fileItem: DpFile) -> String { 40 | var helpString = "" 41 | var firstLine = true 42 | for checksum in fileItem.checksums.checksums { 43 | if !firstLine { 44 | helpString += " " // There doesn't seem to be a way to do a newline in help text 45 | } 46 | helpString += "\(checksum.type.rawValue): \(checksum.value)" 47 | firstLine = false 48 | } 49 | return helpString 50 | } 51 | } 52 | 53 | struct ChecksumView_Previews: PreviewProvider { 54 | static var previews: some View { 55 | @StateObject var packageListViewModel = PackageListViewModel(isSrc: true) 56 | ChecksumView(packageListViewModel: packageListViewModel, file: DpFileViewModel(dpFile: DpFile(name: "Test.pkg", size: 123456))) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /JamfSync/UI/ConfirmationView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct ConfirmationView: View { 8 | @Environment(\.dismiss) private var dismiss 9 | 10 | var promptMessage = "Are you sure?" 11 | var includeCancelButton = true 12 | @Binding var canceled: Bool 13 | 14 | var body: some View { 15 | VStack(spacing: 10) { 16 | Text(promptMessage) 17 | .font(.title) 18 | 19 | HStack { 20 | Spacer() 21 | if includeCancelButton { 22 | Button("Cancel") { 23 | canceled = true 24 | dismiss() 25 | } 26 | .keyboardShortcut(.cancelAction) 27 | .padding([.top, .trailing]) 28 | } 29 | Button("OK") { 30 | canceled = false 31 | dismiss() 32 | } 33 | .keyboardShortcut(.defaultAction) 34 | .padding(.top) 35 | Spacer() 36 | } 37 | } 38 | .onAppear() { 39 | canceled = false 40 | } 41 | .padding() 42 | } 43 | } 44 | 45 | struct ConfirmationView_Previews: PreviewProvider { 46 | static var previews: some View { 47 | @State var canceled = false 48 | ConfirmationView(canceled: $canceled) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /JamfSync/UI/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import SwiftUI 6 | struct Position: Identifiable { 7 | let id: Int 8 | let name: String 9 | } 10 | 11 | struct ContentView: View { 12 | @ObservedObject var dataPersistence: DataPersistence 13 | @FetchRequest(sortDescriptors: []) private var savableItems: FetchedResults 14 | @State private var favoriteColor = 0 15 | @StateObject var dataModel = DataModel.shared 16 | @State var changesMade = false 17 | @State var canceled = false 18 | 19 | var body: some View { 20 | VStack { 21 | HeaderView(dataPersistence: dataPersistence, dataModel: dataModel) 22 | .padding([.leading, .trailing, .top]) 23 | 24 | SourceDestinationView(dataModel: dataModel) 25 | .padding([.leading, .trailing]) 26 | 27 | LogMessageView() 28 | } 29 | .toolbar { 30 | Button { 31 | dataModel.shouldPresentSetupSheet = true 32 | } label: { 33 | Image(systemName: "gearshape") 34 | } 35 | .help("Setup") 36 | .sheet(isPresented: $dataModel.shouldPresentSetupSheet) { 37 | if changesMade { 38 | dataModel.loadDps() 39 | } 40 | } content: { 41 | SetupView(dataPersistence: dataPersistence, savableItems: dataModel.savableItems, changesMade: $changesMade) 42 | } 43 | 44 | Button { 45 | dataModel.shouldPresentServerSelectionSheet = true 46 | } label: { 47 | Image(systemName: "server.rack") 48 | } 49 | .help("Choose active Jamf Pro instances") 50 | .sheet(isPresented: $dataModel.shouldPresentServerSelectionSheet) { 51 | if changesMade || dataModel.firstLoad { 52 | dataModel.load(dataPersistence: dataPersistence) 53 | } 54 | } content: { 55 | JamfProServerPicker(dataPersistence: dataPersistence, savableItems: dataModel.savableItems, changesMade: $changesMade) 56 | } 57 | 58 | Button { 59 | dataModel.updateListViewModels(reload: .sourceAndDestination) 60 | } label: { 61 | Image(systemName: "arrow.clockwise") 62 | } 63 | .help("Refresh") 64 | 65 | Button("Show Log") { 66 | showLog() 67 | } 68 | .padding([.trailing]) 69 | } 70 | .frame(maxWidth: .infinity, maxHeight: .infinity) 71 | .onAppear() { 72 | dataModel.load(dataPersistence: dataPersistence) 73 | } 74 | // Prompt for file share distribution point password 75 | .sheet(isPresented: $dataModel.shouldPromptForDpPassword) { 76 | if !canceled { 77 | dataModel.updateListViewModels() 78 | } 79 | } content: { 80 | FileShareCredentialsView(fileShareDp: $dataModel.dpToPromptForPassword, canceled: $canceled) 81 | } 82 | // Prompt for Jamf Pro password 83 | .sheet(isPresented: $dataModel.shouldPromptForJamfProPassword) { 84 | if !canceled { 85 | Task { 86 | dataModel.loadDps() 87 | } 88 | } 89 | } content: { 90 | JamfProPasswordView(jamfProInstances: $dataModel.jamfProServersToPromptForPassword, canceled: $canceled) 91 | } 92 | } 93 | 94 | func showLog() { 95 | LogView().openInNewWindow { window in 96 | window.title = "Activity and Error Log" 97 | } 98 | } 99 | } 100 | 101 | struct ContentView_Previews: PreviewProvider { 102 | static var previews: some View { 103 | @StateObject var dataPersistence = DataPersistence(dataManager: DataManager()) 104 | 105 | ContentView(dataPersistence: dataPersistence) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /JamfSync/UI/FileShareCredentialsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct FileShareCredentialsView: View { 8 | @Environment(\.dismiss) private var dismiss 9 | 10 | @Binding var fileShareDp: FileShareDp? 11 | @Binding var canceled: Bool 12 | @State var saveInKeychain = true 13 | @State var username = "" 14 | @State var password = "" 15 | let keychainHelper = KeychainHelper() 16 | 17 | var body: some View { 18 | VStack { 19 | Text("File Share Credentials") 20 | .font(.title) 21 | Text("Enter the credentials for \(fileShareDp?.selectionName() ?? ""): \(fileShareDp?.address ?? "")") 22 | .padding(.bottom) 23 | 24 | HStack { 25 | Text("Username:") 26 | TextField("", text: $username, prompt: Text("Username:")) 27 | } 28 | .padding(.bottom) 29 | 30 | HStack { 31 | Text("Password:") 32 | SecureField(text: $password, prompt: Text("Password")) { 33 | Text("Title") 34 | } 35 | Toggle(isOn: $saveInKeychain) { 36 | Text("Save in Keychain") 37 | } 38 | } 39 | .padding(.bottom) 40 | 41 | HStack { 42 | Button("Cancel") { 43 | canceled = true 44 | dismiss() 45 | } 46 | Button("OK") { 47 | canceled = false 48 | fileShareDp?.readWriteUsername = username 49 | fileShareDp?.readWritePassword = password 50 | storeCredentialsInKeychain() 51 | UserSettings.shared.saveDistributionPointPwInKeychain = saveInKeychain 52 | dismiss() 53 | } 54 | .keyboardShortcut(.defaultAction) 55 | } 56 | } 57 | .onAppear() { 58 | saveInKeychain = UserSettings.shared.saveDistributionPointPwInKeychain 59 | username = fileShareDp?.readWriteUsername ?? "" 60 | password = fileShareDp?.readWritePassword ?? "" 61 | } 62 | .padding() 63 | .frame(width: 600) 64 | } 65 | 66 | func storeCredentialsInKeychain() { 67 | guard saveInKeychain, let fileShareDp else { return } 68 | 69 | if let address = fileShareDp.address, let username = fileShareDp.readWriteUsername, let password = fileShareDp.readWritePassword, !password.isEmpty, let data = password.data(using: String.Encoding.utf8) { 70 | Task { 71 | do { 72 | let serviceName = keychainHelper.fileShareServiceName(username: username, urlString: address) 73 | try await keychainHelper.storeInformationToKeychain(serviceName: serviceName, key: username, data: data) 74 | } catch { 75 | LogManager.shared.logMessage(message: "Failed to save the credentials for \(address) to the keychain: \(error)", level: .error) 76 | } 77 | } 78 | } 79 | else { 80 | LogManager.shared.logMessage(message: "Not enough or the correct kind of data was available to save the credentials for \(fileShareDp.selectionName()) in the keychain.", level: .error) 81 | } 82 | } 83 | } 84 | 85 | struct FileShareCredentialsView_Previews: PreviewProvider { 86 | static var previews: some View { 87 | @State var fileShareDp: FileShareDp? = FileShareDp(jamfProId: 1, name: "My Fileshare description that's kind of long", address: "https://myfileshareurl.com", isMaster: true, connectionType: .smb, shareName: "CasperShare", workgroupOrDomain: "", sharePort: 0, readOnlyUsername: nil, readOnlyPassword: nil, readWriteUsername: "admin", readWritePassword: "password") 88 | @State var canceled: Bool = false 89 | FileShareCredentialsView(fileShareDp: $fileShareDp, canceled: $canceled) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /JamfSync/UI/FolderView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct FolderView: View { 8 | @Environment(\.dismiss) private var dismiss 9 | 10 | @Binding var folderInstance: FolderInstance 11 | @Binding var canceled: Bool 12 | @State var name: String = "" 13 | @State var filePath: String = "" 14 | 15 | var body: some View { 16 | VStack(spacing: 10) { 17 | Text("File Folder") 18 | .font(.title) 19 | .padding(.bottom) 20 | 21 | HStack { 22 | Text("Name:") 23 | 24 | TextField(text: $name, prompt: Text("Name:")) { 25 | Text("Name") 26 | } 27 | } 28 | 29 | HStack { 30 | Text("Path:") 31 | 32 | TextField(text: $filePath, prompt: Text("Path:")) { 33 | Text("Path") 34 | } 35 | .disabled(true) 36 | 37 | Button("Choose...") 38 | { 39 | let panel = NSOpenPanel() 40 | panel.allowsMultipleSelection = false 41 | panel.canChooseDirectories = true 42 | panel.canChooseFiles = false 43 | if panel.runModal() == .OK, let path = panel.url?.path().removingPercentEncoding { 44 | filePath = path 45 | } 46 | } 47 | } 48 | 49 | HStack { 50 | Spacer() 51 | Button("Cancel") { 52 | canceled = true 53 | dismiss() 54 | } 55 | .keyboardShortcut(.cancelAction) 56 | .padding([.top, .trailing]) 57 | Button("OK") { 58 | canceled = false 59 | saveStateVariables() 60 | dismiss() 61 | } 62 | .disabled(!requiredFieldsFilled()) 63 | .keyboardShortcut(.defaultAction) 64 | .padding(.top) 65 | Spacer() 66 | } 67 | } 68 | .onAppear() { 69 | loadStateVariables() 70 | } 71 | .padding() 72 | .frame(width: 600, height: 200) 73 | } 74 | 75 | func requiredFieldsFilled() -> Bool { 76 | return !name.isEmpty && !filePath.isEmpty 77 | } 78 | 79 | func loadStateVariables() { 80 | name = folderInstance.name 81 | filePath = folderInstance.folderDp.filePath 82 | } 83 | 84 | func saveStateVariables() { 85 | folderInstance.name = name 86 | folderInstance.folderDp.name = folderInstance.name 87 | folderInstance.folderDp.filePath = filePath 88 | } 89 | } 90 | 91 | struct FolderView_Previews: PreviewProvider { 92 | static var previews: some View { 93 | @State var folderInstance = FolderInstance() 94 | @State var canceled: Bool = false 95 | FolderView(folderInstance: $folderInstance, canceled: $canceled) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /JamfSync/UI/HelpMenu.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import SwiftUI 6 | import QuickLook 7 | 8 | struct HelpMenu: View { 9 | @State var userGuideUrl: URL? 10 | 11 | var body: some View { 12 | Group { 13 | Button("Jamf Sync User Guide") { 14 | userGuideUrl = Bundle.main.url(forResource: "Jamf Sync User Guide", withExtension: "pdf") 15 | } 16 | .quickLookPreview($userGuideUrl) 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /JamfSync/UI/JamfProPasswordView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct JamfProPasswordView: View { 8 | @Environment(\.dismiss) private var dismiss 9 | 10 | @Binding var jamfProInstances: [JamfProInstance] 11 | @Binding var canceled: Bool 12 | @State var saveInKeychain = true 13 | @State var password = "" 14 | let keychainHelper = KeychainHelper() 15 | 16 | var body: some View { 17 | VStack(spacing: 10) { 18 | if jamfProInstances.count > 0 { 19 | Text("Jamf Pro Password") 20 | .font(.title) 21 | Text("Enter the password for \(jamfProInstances[0].name) (\(jamfProInstances[0].url?.absoluteString ?? ""))") 22 | .padding(.bottom) 23 | 24 | HStack { 25 | VStack(alignment: .trailing) { 26 | Text("\(passwordClientSecretPrompt(jamfProInstance: jamfProInstances[0])):") 27 | .frame(height: 16) 28 | } 29 | VStack { 30 | HStack { 31 | SecureField(text: $password, prompt: Text("Password")) { 32 | Text("Title") 33 | } 34 | Toggle(isOn: $saveInKeychain) { 35 | Text("Save in Keychain") 36 | } 37 | } 38 | .frame(height: 16) 39 | } 40 | } 41 | } 42 | 43 | HStack { 44 | Spacer() 45 | Button("Cancel") { 46 | canceled = true 47 | dismiss() 48 | } 49 | .keyboardShortcut(.cancelAction) 50 | .padding([.top, .trailing]) 51 | Button("OK") { 52 | canceled = false 53 | jamfProInstances[0].passwordOrClientSecret = password 54 | storePasswordInKeychain(jamfProInstance: jamfProInstances[0]) 55 | UserSettings.shared.saveServerPwInKeychain = saveInKeychain 56 | dismiss() 57 | } 58 | .keyboardShortcut(.defaultAction) 59 | .padding(.top) 60 | Spacer() 61 | } 62 | } 63 | .onAppear() { 64 | saveInKeychain = UserSettings.shared.saveServerPwInKeychain 65 | password = jamfProInstances[0].passwordOrClientSecret 66 | } 67 | .padding() 68 | .frame(width: 600) 69 | } 70 | 71 | func storePasswordInKeychain(jamfProInstance: JamfProInstance) { 72 | guard saveInKeychain else { return } 73 | 74 | if let address = jamfProInstance.url?.host { 75 | let username = jamfProInstance.usernameOrClientId 76 | let password = jamfProInstance.passwordOrClientSecret 77 | if !password.isEmpty, let data = password.data(using: String.Encoding.utf8) { 78 | Task { 79 | do { 80 | let serviceName = keychainHelper.jamfProServiceName(urlString: address) 81 | try await keychainHelper.storeInformationToKeychain(serviceName: serviceName, key: username, data: data) 82 | } catch { 83 | LogManager.shared.logMessage(message: "Failed to save the password for \(address) to the keychain: \(error)", level: .error) 84 | } 85 | } 86 | } 87 | } 88 | else { 89 | LogManager.shared.logMessage(message: "Not enough or the correct kind of data was available to save the password for \(jamfProInstance.name) in the keychain.", level: .error) 90 | } 91 | } 92 | 93 | func passwordClientSecretPrompt(jamfProInstance: JamfProInstance) -> String { 94 | return jamfProInstance.useClientApi ? "Client Secret" : "Password" 95 | } 96 | } 97 | 98 | struct JamfProPasswordView_Previews: PreviewProvider { 99 | static var previews: some View { 100 | @State var jamfProInstances: [JamfProInstance] = [JamfProInstance(name: "My Test Server", url: URL(string: "https://mytest01.jamfcloud.com"))] 101 | @State var canceled: Bool = false 102 | JamfProPasswordView(jamfProInstances: $jamfProInstances, canceled: $canceled) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /JamfSync/UI/JamfProServerPicker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2025, Jamf 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct JamfProServerPicker: View { 8 | @Environment(\.dismiss) private var dismiss 9 | @ObservedObject var dataPersistence: DataPersistence 10 | @ObservedObject var savableItems: SavableItems 11 | @State var items: [JamfProServerSelectionItem] = [] 12 | @Binding var changesMade: Bool 13 | 14 | var body: some View { 15 | VStack { 16 | Text("Active Jamf Pro Servers") 17 | .font(.title) 18 | 19 | List($items) { $item in 20 | Toggle("\(item.selectionName)", isOn: $item.isActive) 21 | .toggleStyle(.checkbox) 22 | } 23 | 24 | HStack { 25 | Spacer() 26 | 27 | Button("Cancel") { 28 | dismiss() 29 | } 30 | .keyboardShortcut(.cancelAction) 31 | .padding([.top, .trailing]) 32 | 33 | Button("OK") { 34 | saveChanges() 35 | dismiss() 36 | } 37 | .keyboardShortcut(.defaultAction) 38 | .padding(.top) 39 | 40 | Spacer() 41 | } 42 | } 43 | .padding() 44 | .frame(width: 600, height: 400) 45 | .onAppear() { 46 | changesMade = false 47 | var jamfProInstances: [JamfProInstance] = [] 48 | for saveableItem in savableItems.items { 49 | if let jamfProInstance = saveableItem as? JamfProInstance { 50 | jamfProInstances.append(jamfProInstance) 51 | } 52 | } 53 | createSelectionItemsFromSavableItems(jamfProInstances: jamfProInstances) 54 | } 55 | } 56 | 57 | func createSelectionItemsFromSavableItems(jamfProInstances: [JamfProInstance]) { 58 | for jamfProInstance in jamfProInstances { 59 | items.append(JamfProServerSelectionItem(jamfProInstance: jamfProInstance)) 60 | } 61 | } 62 | 63 | func saveChanges() { 64 | for item in items { 65 | if item.jamfProInstance.isActive != item.isActive { 66 | item.jamfProInstance.isActive = item.isActive 67 | dataPersistence.updateInCoreData(instance: item.jamfProInstance) 68 | changesMade = true 69 | } 70 | } 71 | } 72 | } 73 | 74 | struct JamfProServerPicker_Previews: PreviewProvider { 75 | static var previews: some View { 76 | @StateObject var dataPersistence = DataPersistence(dataManager: DataManager()) 77 | let savableItems = DataModel().savableItems 78 | @State var changesMade: Bool = false 79 | 80 | SetupView(dataPersistence: dataPersistence, savableItems: savableItems, changesMade: $changesMade) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /JamfSync/UI/JamfSyncApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import SwiftUI 6 | 7 | final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { 8 | let timeout = 10.0 9 | 10 | func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { 11 | if DataModel.shared.synchronizationInProgress { 12 | let alert = NSAlert() 13 | alert.messageText = "Are you sure you want to quit JamfSync?" 14 | alert.informativeText = "Closing while a synchronization is in progress will abort the synchroniztion process." 15 | alert.addButton(withTitle: "OK") 16 | alert.addButton(withTitle: "Cancel") 17 | alert.alertStyle = .warning 18 | let result = alert.runModal().rawValue // For some reason, comparing against .OK or .cancel didn't work 19 | if result == 1001 { 20 | return .terminateCancel 21 | } 22 | } 23 | cleanup() 24 | return .terminateNow 25 | } 26 | 27 | func cleanup() { 28 | DataModel.shared.cancelUpdateListViewModels() 29 | let group = DispatchGroup() 30 | group.enter() 31 | Task { 32 | do { 33 | try await DataModel.shared.cleanup() 34 | } catch { 35 | LogManager.shared.logMessage(message: "Failed to cleanup the distribution points: \(error)", level: .error) 36 | } 37 | group.leave() 38 | } 39 | if group.wait(timeout: DispatchTime.now() + timeout) != .success { 40 | let alert = NSAlert() 41 | alert.messageText = "Unmounting distribution points may have failed." 42 | alert.informativeText = "This may be because a distribution point was unmounted externally. Otherwise you may need to unmount these manually using Finder or locate the volume name in /Volumes and run \"diskutil unmount /Volumes/name\" from terminal." 43 | alert.addButton(withTitle: "OK") 44 | alert.alertStyle = .warning 45 | alert.runModal() 46 | } 47 | } 48 | } 49 | 50 | @main 51 | struct JamfSyncApp: App { 52 | @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate 53 | @StateObject var dataPersistence = DataPersistence(dataManager: DataManager()) 54 | 55 | var body: some Scene { 56 | WindowGroup { 57 | startingView() 58 | } 59 | .commands { 60 | CommandGroup(replacing: .help) { 61 | HelpMenu() 62 | } 63 | CommandGroup(replacing: CommandGroupPlacement.appInfo) { 64 | Button(action: { 65 | AboutView().openInNewWindow { window in 66 | window.title = "About Jamf Sync" 67 | } 68 | }) { 69 | Text("About Jamf Sync") 70 | } 71 | } 72 | } 73 | Settings { 74 | SettingsView(settingsViewModel: DataModel.shared.settingsViewModel) 75 | } 76 | } 77 | 78 | func startingView() -> some View { 79 | let argumentParser = ArgumentParser(arguments: CommandLine.arguments) 80 | if !argumentParser.processArgs() { 81 | exit(1) 82 | } 83 | 84 | if argumentParser.someArgumentsPassed { 85 | let commandLineProcessing = CommandLineProcessing(dataModel: DataModel.shared, dataPersistence: dataPersistence) 86 | let succeeded = commandLineProcessing.process(argumentParser: argumentParser) 87 | appDelegate.cleanup() 88 | if succeeded { 89 | exit(0) 90 | } else { 91 | exit(1) 92 | } 93 | } else { 94 | return ContentView(dataPersistence: dataPersistence) 95 | .environmentObject(dataPersistence.dataManager) 96 | .environment(\.managedObjectContext, dataPersistence.dataManager.container.viewContext) 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /JamfSync/UI/LogMessageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct LogMessageView: View { 8 | @StateObject var logViewModel = LogViewModel() 9 | 10 | var body: some View { 11 | if let messageToShow = logViewModel.messageToShow { 12 | ZStack { 13 | logLevelColor(logLevel: messageToShow.logLevel) 14 | Text(messageToShow.message).background(logLevelColor(logLevel: messageToShow.logLevel)) 15 | } 16 | .frame(height: 24) 17 | .padding([.top, .bottom, .leading]) 18 | } 19 | } 20 | 21 | func logLevelColor(logLevel: LogLevel) -> Color { 22 | switch logLevel { 23 | case .error: 24 | return .red 25 | case .warning: 26 | return .yellow 27 | case .info: 28 | return .green 29 | default: 30 | return .gray 31 | } 32 | } 33 | } 34 | 35 | struct LogMessageView_Previews: PreviewProvider { 36 | static var previews: some View { 37 | @StateObject var logViewModel = LogViewModel() 38 | LogMessageView(logViewModel: logViewModel) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /JamfSync/UI/LogView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct LogView: View { 8 | @StateObject var logViewModel = LogViewModel() 9 | @State private var sortOrder = [KeyPathComparator(\LogMessage.date), KeyPathComparator(\LogMessage.logLevel), KeyPathComparator(\LogMessage.message)] 10 | @State private var selectedItems: Set = [] 11 | 12 | var body: some View { 13 | VStack { 14 | Table(logViewModel.logMessages, selection: $selectedItems, sortOrder: $sortOrder) { 15 | TableColumn("Date & Time", value: \.date) { logMessage in 16 | Text(LogManager.shared.dateToLogDateString(logMessage.date)) 17 | } 18 | .width(ideal: 120) 19 | TableColumn("Type", value: \.logLevel) { logMessage in 20 | Text(logMessage.logLevel.rawValue) 21 | } 22 | .width(ideal: 50) 23 | TableColumn("Message", value: \.message) 24 | .width(ideal: 2000) 25 | } 26 | .onChange(of: sortOrder) { 27 | logViewModel.logMessages.sort(using: sortOrder) 28 | } 29 | .padding() 30 | } 31 | .frame(minWidth: 650) 32 | .toolbar { 33 | if selectedItems.count > 0 { 34 | Button { 35 | let pasteboard = NSPasteboard.general 36 | pasteboard.clearContents() 37 | pasteboard.setString(stringForSelectedItems(), forType: .string) 38 | } label: { 39 | Image(systemName: "doc.on.doc") 40 | } 41 | .help("Copy selected log messages to the clipboard") 42 | } 43 | 44 | Button { 45 | logViewModel.clear() 46 | } label: { 47 | Image(systemName: "trash") 48 | } 49 | .help("Remove all messages") 50 | } 51 | } 52 | 53 | func stringForSelectedItems() -> String { 54 | var text = "" 55 | for id in selectedItems { 56 | if let logMessage = logViewModel.findLogMessage(id: id) { 57 | text += "\(LogManager.shared.logMessageToString(logMessage))\n" 58 | } 59 | } 60 | return text 61 | } 62 | } 63 | 64 | struct LogView_Previews: PreviewProvider { 65 | static var previews: some View { 66 | LogView() 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /JamfSync/UI/PackageAnimationView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import SwiftUI 6 | 7 | enum Phase: CaseIterable { 8 | case initial 9 | case moveToMiddle 10 | case moveToEnd 11 | case moveToBackToStart 12 | 13 | var horizontalOffset: Double { 14 | switch self { 15 | case .initial: -100 16 | case .moveToMiddle: 0 17 | case .moveToEnd: 100 18 | case .moveToBackToStart: -100 19 | } 20 | } 21 | 22 | var verticalOffset: Double { 23 | switch self { 24 | case .initial: 0 25 | case .moveToMiddle: -10 26 | case .moveToEnd: 0 27 | case .moveToBackToStart: 0 28 | } 29 | } 30 | 31 | var scale: Double { 32 | switch self { 33 | case .initial: 1.0 34 | case .moveToMiddle: 1.1 35 | case .moveToEnd: 1.0 36 | case .moveToBackToStart: 1.0 37 | } 38 | } 39 | 40 | var opacity: Double { 41 | switch self { 42 | case .initial: 0.0 43 | case .moveToMiddle: 1.0 44 | case .moveToEnd: 0.0 45 | case .moveToBackToStart: 0.0 46 | } 47 | } 48 | } 49 | 50 | struct PackageAnimationView: View { 51 | @State var trigger = 0 52 | 53 | var body: some View { 54 | HStack { 55 | Image("pkgIcon") 56 | .phaseAnimator( 57 | Phase.allCases, 58 | trigger: trigger 59 | ) { content, phase in 60 | content 61 | .scaleEffect(phase.scale) 62 | .offset(x: phase.horizontalOffset) 63 | .offset(y: phase.verticalOffset) 64 | .opacity(phase.opacity) 65 | } animation: { phase in 66 | animationForPhase(phase: phase) 67 | } 68 | } 69 | .frame(width: 200, height: 50) 70 | .onAppear() { 71 | trigger += 1 72 | } 73 | } 74 | 75 | func animationForPhase(phase: Phase) -> Animation { 76 | switch phase { 77 | case .initial: return .smooth(duration: 0.0) 78 | case .moveToMiddle: return .easeIn(duration: 1.0) 79 | case .moveToEnd: return .easeOut(duration: 1.0) 80 | case .moveToBackToStart: 81 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { 82 | trigger += 1 // Once the animation is complete, trigger it again 83 | } 84 | return .smooth(duration: 0.0) 85 | } 86 | } 87 | } 88 | 89 | struct PackageAnimationView_Previews: PreviewProvider { 90 | static var previews: some View { 91 | PackageAnimationView() 92 | } 93 | } 94 | 95 | struct BackAndForthAnimation: View { 96 | @Binding var leftOffset: CGFloat 97 | @Binding var rightOffset: CGFloat 98 | 99 | var body: some View { 100 | HStack { 101 | ZStack { 102 | Image("pkgIcon") 103 | .offset(x: leftOffset) 104 | .opacity(0.7) 105 | .animation(Animation.easeInOut(duration: 1), value: leftOffset) 106 | } 107 | .frame(width: 200) 108 | } 109 | .frame(width: 210, height: 50) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /JamfSync/UI/SaveableItemListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct SavableItemListView: View { 8 | @ObservedObject var savableItems: SavableItems 9 | @Binding var selectedSavableItemId: SavableItem.ID? 10 | @State private var sortOrder = [KeyPathComparator(\SavableItem.name), KeyPathComparator(\SavableItem.iconName), KeyPathComparator(\SavableItem.urlOrFolder)] 11 | let typeColumnSize = 35.0 12 | 13 | var body: some View { 14 | Table(savableItems.items, selection: $selectedSavableItemId, sortOrder: $sortOrder) { 15 | TableColumn("Type", value: \.iconName) { item in 16 | typeImage(savableItem: item) 17 | .frame(width: typeColumnSize, alignment: .center) 18 | } 19 | .width(ideal: typeColumnSize) 20 | TableColumn("Name", value: \.name) 21 | .width(ideal: 150) 22 | TableColumn("URL or Folder", value: \.urlOrFolder) { item in 23 | Text(String(item.urlOrFolder)) 24 | } 25 | .width(ideal: 300) 26 | } 27 | .onChange(of: sortOrder) { 28 | savableItems.items.sort(using: sortOrder) 29 | } 30 | } 31 | 32 | func typeImage(savableItem: SavableItem) -> Image { 33 | return Image(systemName: savableItem.iconName) 34 | } 35 | } 36 | 37 | struct SavableItemListView_Previews: PreviewProvider { 38 | static var previews: some View { 39 | @State var selectedItemId: SavableItem.ID? 40 | let savableItems = DataModel().savableItems 41 | SavableItemListView(savableItems: savableItems, selectedSavableItemId: $selectedItemId) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /JamfSync/UI/SettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct SettingsView: View { 8 | @ObservedObject var settingsViewModel: SettingsViewModel 9 | var body: some View { 10 | VStack { 11 | Picker("Allow deletions after synchronization:", selection: $settingsViewModel.allowDeletionsAfterSynchronization) { 12 | ForEach(DeletionOptions.allCases, id: \.self) { 13 | Text($0.rawValue) 14 | } 15 | } 16 | .onChange(of: settingsViewModel.allowDeletionsAfterSynchronization, initial: false) { 17 | settingsViewModel.saveSettings() 18 | DataModel.shared.updateListViewModels() 19 | } 20 | .padding() 21 | 22 | Picker("Allow manual deletions:", selection: $settingsViewModel.allowManualDeletions) { 23 | ForEach(DeletionOptions.allCases, id: \.self) { 24 | Text($0.rawValue) 25 | } 26 | } 27 | .onChange(of: settingsViewModel.allowManualDeletions, initial: false) { 28 | settingsViewModel.saveSettings() 29 | DataModel.shared.updateListViewModels() 30 | } 31 | .padding([.leading, .trailing, .bottom]) 32 | 33 | Toggle("Prompt for Jamf Pro instances on startup", isOn: $settingsViewModel.promptForJamfProInstances) 34 | .onChange(of: settingsViewModel.promptForJamfProInstances, initial: false) { 35 | settingsViewModel.saveSettings() 36 | DataModel.shared.updateListViewModels() 37 | } 38 | .toggleStyle(SwitchToggleStyle()) 39 | .padding([.leading, .trailing, .bottom]) 40 | } 41 | .padding() 42 | .frame(width: 500) 43 | } 44 | } 45 | 46 | struct SettingsView_Previews: PreviewProvider { 47 | static var previews: some View { 48 | @StateObject var settingsViewModel = SettingsViewModel() 49 | SettingsView(settingsViewModel: settingsViewModel) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /JamfSync/UI/SetupView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct SetupView: View { 8 | @Environment(\.dismiss) private var dismiss 9 | @ObservedObject var dataPersistence: DataPersistence 10 | @ObservedObject var savableItems: SavableItems 11 | @StateObject var setupViewModel = SetupViewModel() 12 | @Binding var changesMade: Bool 13 | 14 | var body: some View { 15 | VStack(spacing: 10) { 16 | Text("Setup") 17 | .font(.title) 18 | 19 | HStack { 20 | SavableItemListView(savableItems: savableItems, selectedSavableItemId: $setupViewModel.selectedSavableItemId) 21 | .padding([.top, .bottom, .trailing]) 22 | 23 | VStack { 24 | Spacer() 25 | 26 | Button("Add Jamf Pro Server") { 27 | setupViewModel.shouldPresentJamfProServerSheet = true 28 | } 29 | .sheet(isPresented: $setupViewModel.shouldPresentJamfProServerSheet) { 30 | if !setupViewModel.canceled { 31 | savableItems.addJamfProInstance(jamfProInstance: setupViewModel.jamfProInstance) 32 | 33 | dataPersistence.saveJamfProInCoreData(instance: setupViewModel.jamfProInstance) 34 | changesMade = true 35 | } 36 | } content: { 37 | addJamfProView() 38 | } 39 | .padding(.bottom) 40 | 41 | Button("Add File Folder") { 42 | setupViewModel.shouldPresentFileFolderSheet = true 43 | } 44 | .sheet(isPresented: $setupViewModel.shouldPresentFileFolderSheet) { 45 | if !setupViewModel.canceled { 46 | savableItems.addFolderInstance(folderInstance: setupViewModel.folderInstance) 47 | dataPersistence.saveFolderInCoreData(instance: setupViewModel.folderInstance) 48 | changesMade = true 49 | } 50 | } content: { 51 | addFolderView() 52 | } 53 | .padding(.bottom) 54 | 55 | Button("Edit") { 56 | setupViewModel.shouldPresentEditSheet = true 57 | } 58 | .disabled(setupViewModel.selectedSavableItemId == nil) 59 | .sheet(isPresented: $setupViewModel.shouldPresentEditSheet) { 60 | if !setupViewModel.canceled { 61 | if savableItems.findSavableItem(id: setupViewModel.selectedSavableItemId) as? JamfProInstance != nil { 62 | savableItems.updateSavableItem(item: setupViewModel.jamfProInstance) 63 | dataPersistence.updateInCoreData(instance: setupViewModel.jamfProInstance) 64 | } else { 65 | savableItems.updateSavableItem(item: setupViewModel.folderInstance) 66 | dataPersistence.updateInCoreData(instance: setupViewModel.folderInstance) 67 | } 68 | changesMade = true 69 | } 70 | } content: { 71 | editView() 72 | } 73 | .padding(.bottom) 74 | 75 | Button("Delete") { 76 | setupViewModel.shouldPresentConfirmationSheet = true 77 | } 78 | .disabled(setupViewModel.selectedSavableItemId == nil) 79 | .sheet(isPresented: $setupViewModel.shouldPresentConfirmationSheet) { 80 | if !setupViewModel.canceled, let selectedSavableItemId = setupViewModel.selectedSavableItemId { 81 | savableItems.deleteSavableItem(id: selectedSavableItemId) 82 | dataPersistence.deleteCoreDataItem(id: selectedSavableItemId) 83 | changesMade = true 84 | setupViewModel.selectedSavableItemId = nil 85 | } 86 | } content: { 87 | deleteView() 88 | } 89 | 90 | Spacer() 91 | } 92 | } 93 | 94 | HStack { 95 | Spacer() 96 | Button("Close") { 97 | dismiss() 98 | } 99 | .keyboardShortcut(.defaultAction) 100 | Spacer() 101 | } 102 | } 103 | .onAppear() { 104 | changesMade = false 105 | } 106 | .padding() 107 | .frame(width: 725, height: 400 ) 108 | } 109 | 110 | func addJamfProView() -> some View { 111 | setupViewModel.jamfProInstance.copy(source: JamfProInstance()) 112 | return JamfProServerView(jamfProInstance: $setupViewModel.jamfProInstance, canceled: $setupViewModel.canceled) 113 | } 114 | 115 | func addFolderView() -> some View { 116 | setupViewModel.folderInstance.copy(source: FolderInstance()) 117 | return FolderView(folderInstance: $setupViewModel.folderInstance, canceled: $setupViewModel.canceled) 118 | } 119 | 120 | func editView() -> some View { 121 | if let srcJamfProInstance = savableItems.findSavableItem(id: setupViewModel.selectedSavableItemId) as? JamfProInstance { 122 | setupViewModel.jamfProInstance.copy(source: srcJamfProInstance) 123 | return AnyView(JamfProServerView(jamfProInstance: $setupViewModel.jamfProInstance, canceled: $setupViewModel.canceled)) 124 | } else if let srcFolderInstance = savableItems.findSavableItem(id: setupViewModel.selectedSavableItemId) as? FolderInstance { 125 | setupViewModel.folderInstance.copy(source: srcFolderInstance) 126 | return AnyView(FolderView(folderInstance: $setupViewModel.folderInstance, canceled: $setupViewModel.canceled)) 127 | } 128 | return AnyView(ConfirmationView(promptMessage: "The item was of an unknown type", includeCancelButton: false, canceled: $setupViewModel.canceled)) 129 | } 130 | 131 | func deleteView() -> some View { 132 | if let item = savableItems.findSavableItem(id: setupViewModel.selectedSavableItemId) { 133 | return ConfirmationView(promptMessage: "Are you sure you want to delete \(item.name)?", includeCancelButton: true, canceled: $setupViewModel.canceled) 134 | } else { 135 | return ConfirmationView(promptMessage: "Couldn't find the item", includeCancelButton: false, canceled: $setupViewModel.canceled) 136 | } 137 | } 138 | } 139 | 140 | struct SetupView_Previews: PreviewProvider { 141 | static var previews: some View { 142 | @StateObject var dataPersistence = DataPersistence(dataManager: DataManager()) 143 | let savableItems = DataModel().savableItems 144 | @State var changesMade: Bool = false 145 | 146 | SetupView(dataPersistence: dataPersistence, savableItems: savableItems, changesMade: $changesMade) 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /JamfSync/UI/SourceDestinationView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct SourceDestinationView: View { 8 | @StateObject var dataModel = DataModel.shared 9 | 10 | var body: some View { 11 | VStack { 12 | HStack { // Source and destination fields 13 | Picker("Source:", selection: $dataModel.selectedSrcDpId) { 14 | ForEach($dataModel.dpsForSource) { 15 | Text("\($0.wrappedValue.selectionName())").tag($0.id.wrappedValue) 16 | } 17 | } 18 | .onChange(of: dataModel.selectedSrcDpId) { 19 | dataModel.updateListViewModels() 20 | } 21 | .padding([.top]) 22 | 23 | Button { 24 | swap(&dataModel.selectedSrcDpId, &dataModel.selectedDstDpId) 25 | dataModel.verifySelectedItemsStillExist() 26 | } label: { 27 | Image(systemName: "arrow.left.arrow.right") 28 | } 29 | .padding([.top, .leading, .trailing]) 30 | 31 | Picker("Destination:", selection: $dataModel.selectedDstDpId) { 32 | ForEach($dataModel.dpsForDestination) { 33 | Text("\($0.wrappedValue.selectionName())").tag($0.id.wrappedValue) 34 | } 35 | } 36 | .onChange(of: dataModel.selectedDstDpId) { 37 | dataModel.updateListViewModels() 38 | } 39 | .padding([.top]) 40 | } 41 | 42 | ZStack { 43 | HStack { 44 | PackageListView(isSrc: true, dataModel: dataModel, packageListViewModel: dataModel.srcPackageListViewModel) 45 | .padding(.trailing, 3) 46 | 47 | PackageListView(isSrc: false, dataModel: dataModel, packageListViewModel: dataModel.dstPackageListViewModel) 48 | .padding(.leading, 3) 49 | } 50 | if dataModel.showSpinner { 51 | ProgressView() 52 | .frame(width: 100, height: 100) 53 | } 54 | } 55 | .padding([.top]) 56 | } 57 | } 58 | } 59 | 60 | struct SourceDestinationView_Previews: PreviewProvider { 61 | static var previews: some View { 62 | @StateObject var dataModel = DataModel() 63 | SourceDestinationView(dataModel: dataModel) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /JamfSync/UI/SynchronizeProgressView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct SynchronizeProgressView: View { 8 | @Environment(\.dismiss) var dismiss 9 | var srcDp: DistributionPoint? 10 | var dstDp: DistributionPoint 11 | var deleteFiles: Bool 12 | var deletePackages: Bool 13 | @StateObject var progress = SynchronizationProgress() 14 | @State var shouldPresentConfirmationSheet = false 15 | let synchronizeTask = SynchronizeTask() 16 | var processToExecute: (SynchronizeTask, Bool, Bool, SynchronizationProgress, SynchronizeProgressView)->Void = {_,_,_,_,_ in } 17 | 18 | // For CrappyButReliableAnimation 19 | let timer = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect() 20 | @State var leftOffset: CGFloat = -100 21 | @State var rightOffset: CGFloat = 100 22 | 23 | var body: some View { 24 | VStack { 25 | HStack { 26 | Text("\(srcDp?.selectionName() ?? "Local Files")") 27 | .padding() 28 | 29 | BackAndForthAnimation(leftOffset: $leftOffset, rightOffset: $rightOffset) 30 | 31 | Text("\(dstDp.selectionName())") 32 | .padding() 33 | } 34 | .onReceive(timer) { (_) in 35 | swap(&self.leftOffset, 36 | &self.rightOffset) 37 | } 38 | 39 | if let currentFile = progress.currentFile, let fileProgress = progress.fileProgress() { 40 | HStack { 41 | if let operation = progress.operation { 42 | Text("\(operation)") 43 | } 44 | Text("\(currentFile.name)") 45 | ProgressView(value: fileProgress) 46 | } 47 | .padding([.leading, .trailing]) 48 | } 49 | 50 | if let totalProgress = progress.totalProgress() { 51 | HStack { 52 | Text("Overall Progress: ") 53 | ProgressView(value: totalProgress) 54 | } 55 | .padding() 56 | } 57 | 58 | Button("Cancel") { 59 | shouldPresentConfirmationSheet = true 60 | } 61 | .padding(.bottom) 62 | .alert("Are you sure you want to cancel the syncrhonization?", isPresented: $shouldPresentConfirmationSheet) { 63 | HStack { 64 | Button("Yes", role: .destructive) { 65 | synchronizeTask.cancel() 66 | shouldPresentConfirmationSheet = false 67 | // If the task finished while this was open, close it immediately, otherwise let SynchronizeTask cloase it. It needs to wait briefly for the confirmation sheet to be dismissed first. 68 | if synchronizeTask.activeDp == nil { 69 | DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: { 70 | dismiss() 71 | }) 72 | } 73 | } 74 | Button("No", role: .cancel) { 75 | shouldPresentConfirmationSheet = false 76 | } 77 | } 78 | } 79 | } 80 | .frame(minWidth: 600) 81 | .onAppear { 82 | processToExecute(synchronizeTask, deleteFiles, deletePackages, progress, self) 83 | } 84 | } 85 | } 86 | 87 | struct SynchronizeProgressView_Previews: PreviewProvider { 88 | static var previews: some View { 89 | let srcDp = DistributionPoint(name: "CasperShare") 90 | let dstDp = DistributionPoint(name: "MyTest") 91 | @StateObject var progress = SynchronizationProgress() 92 | 93 | SynchronizeProgressView(srcDp: srcDp, dstDp: dstDp, deleteFiles: false, deletePackages: false, progress: progress) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /JamfSync/Utility/ArgumentParser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import Foundation 6 | 7 | class ArgumentParser: NSObject { 8 | var srcDp: String? 9 | var dstDp: String? 10 | var forceSync = false 11 | var removeFilesNotOnSrc = false 12 | var removePackagesNotOnSrc = false 13 | var showProgress = false 14 | var dryRun = false 15 | var someArgumentsPassed = false 16 | private var arguments: [String] 17 | 18 | init(arguments: [String] = CommandLine.arguments) { 19 | self.arguments = arguments 20 | } 21 | 22 | fileprivate func processStringArg(_ i: inout Int) -> String? { 23 | var stringArg: String? 24 | if i < CommandLine.arguments.count - 1 { 25 | stringArg = arguments[i + 1] 26 | i = i + 1 27 | } 28 | return stringArg 29 | } 30 | 31 | func processArgs() -> Bool { 32 | var i = 1 33 | while i < arguments.count { 34 | let arg = arguments[i] 35 | switch arg { 36 | case "-h", "--help", "-help": 37 | displayHelp() 38 | someArgumentsPassed = true 39 | return false 40 | case "-v", "--version", "-version": 41 | displayVersion() 42 | someArgumentsPassed = true 43 | return false 44 | case "-s", "--srcDp", "-srcDp": 45 | if let arg = processStringArg(&i) { 46 | srcDp = arg 47 | } 48 | someArgumentsPassed = true 49 | case "-d", "--dstDp", "-dstDp": 50 | if let arg = processStringArg(&i) { 51 | dstDp = arg 52 | } 53 | someArgumentsPassed = true 54 | case "-f", "--forceSync", "-forceSync": 55 | forceSync = true 56 | someArgumentsPassed = true 57 | case "-r", "--removeFilesNotOnSource", "-removeFilesNotOnSource": 58 | removeFilesNotOnSrc = true 59 | someArgumentsPassed = true 60 | case "-rp", "--removePackagesNotOnSource", "-removePackagesNotOnSource": 61 | removePackagesNotOnSrc = true 62 | someArgumentsPassed = true 63 | case "-p", "--progress", "-progress": 64 | showProgress = true 65 | someArgumentsPassed = true 66 | case "-dr", "--dryRun", "-dryRun": 67 | dryRun = true 68 | someArgumentsPassed = true 69 | case "-NSDocumentRevisionsDebugMode": 70 | _ = processStringArg(&i) 71 | default: 72 | print("Unknown argument: " + arg) 73 | print("Use -h or --help to get the valid parameters. NOTE: The UI will start when invalid parameters are specified.") 74 | // Have to return true so it won't exit since otherwise it stops any previews from working. 75 | // There doens't seem to be a way to determine what command line arguments are passed during 76 | // a preview, otherwise we could just ignore them like with -NSDocumentRevisionsDebugMode. 77 | return true 78 | } 79 | i = i + 1 80 | } 81 | 82 | return validateArgs() 83 | } 84 | 85 | func displayHelp() { 86 | displayVersion() 87 | 88 | print("NOTE: Run Jamf Sync with no parameters first to add Jamf Pro servers and/or folders.") 89 | print(" Passwords for Jamf Pro servers and distribution points must be stored in the") 90 | print(" keychain in order to synchronize via command line arguments.") 91 | print("") 92 | print("Usage:") 93 | print("\t\"/Applications/Jamf Sync.app/Contents/MacOS/Jamf Sync\" [(-s | --srcDp) ] [(-d | --dstDp) ] [(-f | --forceSync)] [(-r | --removeFilesNotOnSource)] [(-rp | --removePackagesNotOnSource)] [-p | --progress]") 94 | print("\t\"/Applications/Jamf Sync.app/Contents/MacOS/Jamf Sync\" [-h | --help]") 95 | print("\t\"/Applications/Jamf Sync.app/Contents/MacOS/Jamf Sync\" [-v | --version]") 96 | print("") 97 | print("\t-s --srcDp:\t\tThe name of the source distribution point or folder.") 98 | print("\t-d --dstDp:\t\tThe name of the destination distribution point or folder.") 99 | print("\t-f --forceSync:\t\tForce synchronization of all files even if they appear to match on both the source and destination.") 100 | print("\t-r --removeFilesNotOnSource:\t\tDelete files on the destination that are not on the source. No delete is done if ommitted.") 101 | print("\t-rp --removePackagesNotOnSource:\t\tDelete packages on the destination's Jamf Pro instance that are not on the source. No delete is done if ommitted.") 102 | print("\t-p --progress:\t\tShow the progress of files being copied.") 103 | print("\t-dr --dryRun:\t\tGo through the motions and show what would be done, but don't actually do anything.") 104 | print("\t-v --version:\t\tDisplay the version number and build number.") 105 | print("\t-h --help:\t\tShows this help text.") 106 | print("NOTE: If a distribution point name is the same on multiple Jamf Pro instances, use \"dpName:jamfProName\" for the name.") 107 | print("") 108 | print("Examples:") 109 | print("\t\"/Applications/Jamf Sync.app/Contents/MacOS/Jamf Sync\" -srcDp localSourceName -dstDp destinationSourceName --removeFilesNotOnSource --progress") 110 | print("\t\"/Applications/Jamf Sync.app/Contents/MacOS/Jamf Sync\" -s \"JCDS:Stage\" -d \"JCDS:Prod\" -r -rp -p") 111 | print("\t\"/Applications/Jamf Sync.app/Contents/MacOS/Jamf Sync\" -s localSourceName -d destinationSourceName") 112 | print("") 113 | } 114 | 115 | func displayVersion() { 116 | let versionInfo = VersionInfo() 117 | print(versionInfo.getDisplayVersion()) 118 | } 119 | 120 | func validateArgs() -> Bool { 121 | // Either none or both, but not one or the other 122 | if (srcDp == nil && dstDp == nil) || (srcDp != nil && dstDp != nil) { 123 | return true 124 | } else { 125 | print("Both the source and the destination arguments are required.") 126 | print("") 127 | displayHelp() 128 | return false 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /JamfSync/Utility/CharacterSet_rfc3986Unreserved.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | // From https://stackoverflow.com/questions/41561853/couldnt-encode-plus-character-in-url-swift 5 | 6 | import Foundation 7 | 8 | extension CharacterSet { 9 | static let rfc3986Unreserved = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~") 10 | } 11 | -------------------------------------------------------------------------------- /JamfSync/Utility/CloudSessionDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import Foundation 6 | 7 | class CloudSessionDelegate: NSObject, URLSessionTaskDelegate, URLSessionDownloadDelegate { 8 | let progress: SynchronizationProgress 9 | var dispatchGroup: DispatchGroup? 10 | var downloadLocation: URL? 11 | 12 | init(progress: SynchronizationProgress, dispatchGroup: DispatchGroup? = nil) { 13 | self.progress = progress 14 | self.dispatchGroup = dispatchGroup 15 | } 16 | 17 | // MARK: URLSessionTaskDelegate functions 18 | 19 | func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping( URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { 20 | completionHandler(.useCredential, URLCredential(trust: challenge.protectionSpace.serverTrust!)) 21 | } 22 | 23 | func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) { 24 | LogManager.shared.logMessage(message: "[urlSession.CloudSessionDelegate] totalBytesTransferred: \(totalBytesSent), bytesTransferred: \(bytesSent)", level: .debug) 25 | progress.updateFileTransferInfo(totalBytesTransferred: totalBytesSent, bytesTransferred: bytesSent) 26 | } 27 | 28 | // MARK: URLSessionDownloadDelegate functions 29 | 30 | func urlSession(_ session: URLSession, 31 | downloadTask: URLSessionDownloadTask, 32 | didWriteData bytesWritten: Int64, 33 | totalBytesWritten: Int64, 34 | totalBytesExpectedToWrite: Int64) { 35 | progress.updateFileTransferInfo(totalBytesTransferred: totalBytesWritten, bytesTransferred: bytesWritten) 36 | } 37 | 38 | func urlSession(_ session: URLSession, 39 | downloadTask: URLSessionDownloadTask, 40 | didFinishDownloadingTo location: URL) { 41 | self.downloadLocation = location 42 | dispatchGroup?.leave() 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /JamfSync/Utility/CommandLineProcessing.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import Foundation 6 | 7 | class CommandLineProcessing { 8 | let dataModel: DataModel 9 | let dataPersistence: DataPersistence 10 | 11 | init(dataModel: DataModel, dataPersistence: DataPersistence) { 12 | self.dataModel = dataModel 13 | self.dataPersistence = dataPersistence 14 | } 15 | 16 | func process(argumentParser: ArgumentParser) -> Bool { 17 | guard let srcDpName = argumentParser.srcDp, let dstDpName = argumentParser.dstDp else { 18 | print("Both a source and a destination must be specified") 19 | return false 20 | } 21 | 22 | print("Loading distribution points") 23 | dataModel.loadingInProgressGroup = DispatchGroup() 24 | dataModel.load(dataPersistence: dataPersistence, isProcessingCommandLine: true) 25 | dataModel.loadingInProgressGroup?.wait() 26 | 27 | guard let srcDp = dataModel.findDpByCombinedName(name: srcDpName) else { 28 | print("Couldn't find the source distribution point or folder: \(srcDpName)") 29 | return false 30 | } 31 | guard let dstDp = dataModel.findDpByCombinedName(name: dstDpName) else { 32 | print("Couldn't find the destination distribution point or folder: \(dstDpName)") 33 | return false 34 | } 35 | 36 | dataModel.synchronizationInProgress = true 37 | let synchronizeTask = SynchronizeTask() 38 | let synchronizationInProgressGroup = DispatchGroup() 39 | synchronizationInProgressGroup.enter() 40 | let progress = SynchronizationProgress() 41 | progress.printToConsole = true 42 | progress.showProgressOnConsole = argumentParser.showProgress 43 | Task { 44 | do { 45 | _ = try await synchronizeTask.synchronize(srcDp: srcDp, dstDp: dstDp, selectedItems: [], jamfProInstance: self.dataModel.findJamfProInstance(id: dstDp.jamfProInstanceId), forceSync: argumentParser.forceSync, deleteFiles: argumentParser.removeFilesNotOnSrc, deletePackages: argumentParser.removePackagesNotOnSrc, progress: progress, dryRun: argumentParser.dryRun) 46 | } catch { 47 | LogManager.shared.logMessage(message: "Failed to synchronize \(srcDp) to \(dstDp): \(error)", level: .error) 48 | return false 49 | } 50 | self.dataModel.synchronizationInProgress = false 51 | synchronizationInProgressGroup.leave() 52 | return true 53 | } 54 | synchronizationInProgressGroup.wait() 55 | return true 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /JamfSync/Utility/FileHash.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import Foundation 6 | import CryptoKit 7 | 8 | actor FileHash { 9 | static var shared: FileHash = FileHash() 10 | 11 | func createSHA512Hash(filePath: String) throws -> String? { 12 | let bufferSize = 1024 13 | let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) 14 | defer { 15 | buffer.deallocate() 16 | } 17 | 18 | if let input = InputStream(fileAtPath: filePath) { 19 | defer { 20 | input.close() 21 | } 22 | input.open() 23 | var hasher = SHA512() 24 | while input.hasBytesAvailable { 25 | let read = input.read(buffer, maxLength: bufferSize) 26 | if read < 0 { 27 | //Stream error occured 28 | throw input.streamError! 29 | } else if read == 0 { 30 | //EOF 31 | break 32 | } 33 | var data = Data() 34 | data.append(buffer, count: read) 35 | hasher.update(data: data) 36 | } 37 | let hash = hasher.finalize() 38 | return Data(hash).hexEncodedString() 39 | } 40 | return nil 41 | } 42 | } 43 | 44 | extension Data { 45 | func hexEncodedString() -> String { 46 | return self.map { String(format: "%02hhx", $0) }.joined() 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /JamfSync/Utility/FileManager+moveRetainingPermissions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import Foundation 6 | 7 | extension FileManager { 8 | func moveRetainingDestinationPermisssions(at srcURL: URL, to dstURL: URL) throws { 9 | LogManager.shared.logMessage(message: "Moving file from \(srcURL) to \(dstURL) while retaining permissions", level: .debug) 10 | try "Placeholder file with permissions of containing directory".write(to: dstURL, atomically: false, encoding: .utf8) 11 | do { 12 | if let result = try self.replaceItemAt(dstURL, withItemAt: srcURL), result.path() != dstURL.path() { 13 | // This probably can't happen, but if it does, we should know about it. 14 | LogManager.shared.logMessage(message: "When moving \(srcURL.path) to \(dstURL.path), the file name was changed to \(result.path)", level: .warning) 15 | } 16 | } catch { 17 | // The above only works when it is a local folder and not a file share. So just copy the file in that case. 18 | LogManager.shared.logMessage(message: "Failed to replace the temporary file when moving \(srcURL) to \(dstURL) while retaining permissions. Just copying it without permissions now.", level: .debug) 19 | try? self.removeItem(at: dstURL) 20 | try self.copyItem(at: srcURL, to: dstURL) 21 | } 22 | 23 | let fileAttributes: [FileAttributeKey: Any] = [ 24 | .posixPermissions: 0o644 25 | ] 26 | try self.setAttributes(fileAttributes, ofItemAtPath: dstURL.path(percentEncoded: false)) 27 | 28 | // The replaceItemAt function seems to move the original file, but the name and documentation for the function 29 | // doesn't imply that so we'll attempt to remove the file but ignore any errors. Also, the copyItem function does 30 | // not delete it. 31 | try? self.removeItem(at: srcURL) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /JamfSync/Utility/FileShare.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import Foundation 6 | import NetFS 7 | 8 | enum ConnectionType: String { 9 | case smb = "SMB" 10 | case afp = "AFP" 11 | } 12 | 13 | actor FileShare { 14 | let type: ConnectionType 15 | let address: String 16 | let shareName: String 17 | let username: String 18 | let password: String 19 | var mountPoint: String? 20 | let fileManager: FileManager 21 | 22 | init(type: ConnectionType, address: String, shareName: String, username: String, password: String, mountPoint: String? = nil, fileManager: FileManager = FileManager.default) { 23 | self.type = type 24 | self.address = address 25 | self.shareName = shareName 26 | self.username = username 27 | self.password = password 28 | self.mountPoint = mountPoint 29 | self.fileManager = fileManager 30 | } 31 | 32 | func mount() throws { 33 | if mountPoint != nil { 34 | return // If already mounted, just return 35 | } 36 | guard let url = URL(string: "\(type)://\(address)/\(shareName)"), let mountDirectoryUrl = URL(string: "/Volumes") else { throw FileShareMountFailure.mountingFailed } 37 | 38 | var mountPoints: Unmanaged? 39 | let uiOptions: NSDictionary = [ 40 | kNAUIOptionKey: kNAUIOptionNoUI 41 | ] 42 | let result = NetFSMountURLSync(url as CFURL, 43 | mountDirectoryUrl as CFURL, 44 | username as CFString?, 45 | password as CFString?, 46 | uiOptions as! CFMutableDictionary?, 47 | nil, 48 | &mountPoints) 49 | guard result == 0 else { 50 | throw FileShareMountFailure.mountingFailed 51 | } 52 | 53 | if let mountPathStringsArray = mountPoints?.takeRetainedValue() as? [String] { 54 | if mountPathStringsArray.count > 0 { 55 | mountPoint = mountPathStringsArray[0] 56 | return 57 | } 58 | } 59 | 60 | throw FileShareMountFailure.mountingFailed 61 | } 62 | 63 | func unmount() async throws { 64 | guard let mountPoint else { return } 65 | let url = URL(filePath: mountPoint) 66 | 67 | try await fileManager.unmountVolume(at: url, options: [FileManager.UnmountOptions.withoutUI]) 68 | self.mountPoint = nil 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /JamfSync/Utility/FileShares.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import Foundation 6 | 7 | enum FileShareMountFailure: Error { 8 | case addressMissing 9 | case shareNameMissing 10 | case mountingFailed 11 | case unmountingFailed 12 | case noUsername 13 | case noPassword 14 | } 15 | 16 | actor FileShares { 17 | static let shared = FileShares() 18 | var fileShares: [FileShare] 19 | 20 | private init() { 21 | fileShares = [] 22 | } 23 | 24 | /// Mounts a file share and returns a mountpoint, or just returns the mountpoint if it's already been mounted 25 | /// - Parameters: 26 | /// - type: The connection type for mounting 27 | /// - address: The address of the fileshare 28 | /// - shareName: The name of the directory being shared 29 | /// - username: The username to use when mounting the fileshare 30 | /// - password: The password to use when mounting the fileshare 31 | func mountFileShare(type: ConnectionType, address: String, shareName: String, username: String, password: String) async throws -> FileShare { 32 | var fileShare = findFileShare(type: type, address: address, shareName: shareName) 33 | if fileShare == nil { 34 | fileShare = FileShare(type: type, address: address, shareName: shareName, username: username, password: password) 35 | if let fileShare { // This will always succeed but this is so we don't need to use ! 36 | try await fileShare.mount() 37 | fileShares.append(fileShare) 38 | } 39 | } 40 | 41 | guard let fileShare else { throw DistributionPointError.programError } 42 | return fileShare 43 | } 44 | 45 | /// Stores the information including mount point for a fileshare that's already mounted. This is used for unit tests. 46 | /// - Parameters: 47 | /// - type: The connection type for mounting 48 | /// - address: The address of the fileshare 49 | /// - shareName: The name of the directory being shared 50 | /// - username: The username to use when mounting the fileshare 51 | /// - password: The password to use when mounting the fileshare 52 | /// - mountPoint: The mountPoint of the fileshare 53 | func alreadyMounted(type: ConnectionType, address: String, shareName: String, username: String, password: String, mountPoint: String?) { 54 | let fileShare = FileShare(type: type, address: address, shareName: shareName, username: username, password: password, mountPoint: mountPoint) 55 | fileShares.append(fileShare) 56 | } 57 | 58 | private func findFileShare(type: ConnectionType, address: String, shareName: String) -> FileShare? { 59 | return fileShares.first { $0.type == type && $0.address == address && $0.shareName == shareName } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /JamfSync/Utility/KeychainHelper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import Haversack 6 | import Foundation 7 | 8 | enum KeychainHelperError: Error { 9 | case missingData 10 | } 11 | 12 | class KeychainHelper { 13 | let haversack = Haversack() 14 | 15 | func getInformationFromKeychain(serviceName: String, key: String) async throws -> Data { 16 | let query = GenericPasswordQuery(service: serviceName) 17 | .matching(account: key) 18 | let token = try await haversack.first(where: query) 19 | guard let data = token.passwordData else { 20 | throw KeychainHelperError.missingData 21 | } 22 | return data 23 | } 24 | 25 | func storeInformationToKeychain(serviceName: String, key: String, data: Data) async throws { 26 | let entity = GenericPasswordEntity() 27 | entity.service = serviceName 28 | entity.passwordData = data 29 | entity.account = key 30 | try await haversack.save(entity, itemSecurity: .standard, updateExisting: true) 31 | } 32 | 33 | func deleteKeychainItem(serviceName: String, key: String) async throws { 34 | let query = GenericPasswordQuery(service: serviceName) 35 | .matching(account: key) 36 | try await haversack.delete(where: query) 37 | } 38 | 39 | func jamfProServiceName(urlString: String) -> String { 40 | return "com.jamfsoftware.JamfSync.jps (\(urlString))" 41 | } 42 | 43 | func fileShareServiceName(username: String, urlString: String) -> String { 44 | return "com.jamfsoftware.JamfSync.dp (\(urlString)-\(username))" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /JamfSync/Utility/OutputStream_write.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | // Derived from https://stackoverflow.com/questions/74510888/set-boundary-on-urlsession-uploadtask-with-fromfile 5 | 6 | import Foundation 7 | 8 | enum OutputStreamError: Error { 9 | case stringConversionFailure 10 | case bufferFailure 11 | case writeFailure 12 | case readFailure(URL) 13 | } 14 | 15 | extension OutputStream { 16 | /// Write `String` to `OutputStream` 17 | /// 18 | /// - parameter string: The `String` to write. 19 | /// - parameter encoding: The `String.Encoding` to use when writing the string. This will default to `.utf8`. 20 | /// - parameter allowLossyConversion: Whether to permit lossy conversion when writing the string. Defaults to `false`. 21 | func write(_ string: String, encoding: String.Encoding = .utf8, allowLossyConversion: Bool = false) throws { 22 | guard let data = string.data(using: encoding, allowLossyConversion: allowLossyConversion) else { 23 | throw OutputStreamError.stringConversionFailure 24 | } 25 | try write(data) 26 | } 27 | 28 | /// Write `Data` to `OutputStream` 29 | /// 30 | /// - parameter data: The `Data` to write. 31 | func write(_ data: Data) throws { 32 | try data.withUnsafeBytes { (buffer: UnsafeRawBufferPointer) throws in 33 | guard let pointer = buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else { 34 | throw OutputStreamError.bufferFailure 35 | } 36 | 37 | try write(buffer: pointer, length: buffer.count) 38 | } 39 | } 40 | 41 | /// Write contents of local `URL` to `OutputStream` 42 | /// 43 | /// - parameter fileURL: The `URL` of the file to written to this output stream. 44 | func write(contentsOf fileURL: URL) throws { 45 | guard let inputStream = InputStream(url: fileURL) else { 46 | throw OutputStreamError.readFailure(fileURL) 47 | } 48 | 49 | inputStream.open() 50 | defer { inputStream.close() } 51 | 52 | let bufferSize = 65_536 53 | let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) 54 | defer { buffer.deallocate() } 55 | 56 | while inputStream.hasBytesAvailable { 57 | let length = inputStream.read(buffer, maxLength: bufferSize) 58 | if length < 0 { 59 | throw OutputStreamError.readFailure(fileURL) 60 | } else if length > 0 { 61 | try write(buffer: buffer, length: length) 62 | } 63 | } 64 | } 65 | } 66 | 67 | private extension OutputStream { 68 | /// Writer buffer to output stream. 69 | /// 70 | /// This will loop until all bytes are written. On failure, this throws an error 71 | /// 72 | /// - Parameters: 73 | /// - buffer: Unsafe pointer to the buffer. 74 | /// - length: Number of bytes to be written. 75 | func write(buffer: UnsafePointer, length: Int) throws { 76 | var bytesRemaining = length 77 | var pointer = buffer 78 | 79 | while bytesRemaining > 0 { 80 | let bytesWritten = write(pointer, maxLength: bytesRemaining) 81 | if bytesWritten < 0 { 82 | throw OutputStreamError.writeFailure 83 | } 84 | 85 | bytesRemaining -= bytesWritten 86 | pointer += bytesWritten 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /JamfSync/Utility/TemporaryFiles.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import Foundation 6 | 7 | enum TemporaryFileManagerError: Error { 8 | case failedToCreateTempDirectory 9 | } 10 | 11 | class TemporaryFileManager { 12 | let jamfSyncDirectoryName = "JamfSync" 13 | var tempDirectory: URL? 14 | let fileManager = FileManager.default 15 | 16 | deinit { 17 | guard let tempDirectory else { return } 18 | 19 | // Attempt to clean up the directory, but don't sweat it if it fails. 20 | try? fileManager.removeItem(at: tempDirectory) 21 | } 22 | 23 | func jamfSyncTempDirectory() throws -> URL { 24 | try createTemporaryDirectory() 25 | guard let tempDirectory else { throw TemporaryFileManagerError.failedToCreateTempDirectory } 26 | return tempDirectory 27 | } 28 | 29 | func moveToTemporaryDirectory(src: URL, dstName: String) throws -> URL { 30 | try createTemporaryDirectory() 31 | guard let tempDirectory else { throw TemporaryFileManagerError.failedToCreateTempDirectory } 32 | 33 | let dstFileUrl = tempDirectory.appending(component: dstName) 34 | try fileManager.moveItem(at: src, to: dstFileUrl) 35 | return dstFileUrl 36 | } 37 | 38 | func createTemporaryDirectory(directoryName: String) throws -> URL { 39 | var baseUrl: URL 40 | if let tempDirectory { 41 | baseUrl = tempDirectory 42 | } else { 43 | baseUrl = URL.temporaryDirectory 44 | } 45 | let newTempDirectory = baseUrl.appending(component: directoryName) 46 | var isDirectory : ObjCBool = true 47 | let exists = FileManager.default.fileExists(atPath: newTempDirectory.path(), isDirectory: &isDirectory) 48 | if exists { 49 | if isDirectory.boolValue { 50 | return newTempDirectory 51 | } else { 52 | try fileManager.removeItem(at: newTempDirectory) 53 | } 54 | } 55 | try fileManager.createDirectory(at: newTempDirectory, withIntermediateDirectories: true) 56 | return newTempDirectory 57 | } 58 | 59 | // MARK - Private functions 60 | 61 | private func createTemporaryDirectory() throws { 62 | guard tempDirectory == nil else { return } 63 | tempDirectory = try createTemporaryDirectory(directoryName: jamfSyncDirectoryName) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /JamfSync/Utility/URL+isDirectory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import Foundation 6 | 7 | extension URL { 8 | var isDirectory: Bool { 9 | (try? resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /JamfSync/Utility/UserSettings.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import Foundation 6 | 7 | enum DeletionOptions: String, CaseIterable { 8 | case none = "None" 9 | case filesOnly = "Files Only" 10 | case filesAndAssociatedPackages = "Files and Associated Packages" 11 | 12 | static func optionFromString(_ string: String) -> DeletionOptions? { 13 | return DeletionOptions.allCases.first { $0.rawValue == string } 14 | } 15 | } 16 | 17 | class UserSettings { 18 | static let shared = UserSettings() 19 | 20 | private let saveServerPwInKeychainKey = "saveServerPwInKeychain" 21 | private let saveDistributionPointPwInKeychainKey = "saveDistributionPointPwInKeychain" 22 | private let firstRunKey = "firstRun" 23 | private let allowDeletionsAfterSynchronizationKey = "allowDeletionsAfterSynchronization" 24 | private let allowManualDeletionsKey = "allowManualDeletions" 25 | private let promptForJamfProInstancesKey = "promptForJamfProInstances" 26 | private let distributionPointUsernamesKey = "distributionPointUsernames" 27 | 28 | init() { 29 | UserDefaults.standard.register(defaults: [ 30 | saveServerPwInKeychainKey: true, 31 | saveDistributionPointPwInKeychainKey: true, 32 | firstRunKey: true, 33 | allowDeletionsAfterSynchronizationKey: DeletionOptions.none.rawValue, 34 | allowManualDeletionsKey: DeletionOptions.filesAndAssociatedPackages.rawValue, 35 | promptForJamfProInstancesKey: false, 36 | distributionPointUsernamesKey: [:] 37 | ]) 38 | } 39 | 40 | var saveServerPwInKeychain: Bool { 41 | get { return UserDefaults.standard.bool(forKey: saveServerPwInKeychainKey) } 42 | set(value) { UserDefaults.standard.set(value, forKey: saveServerPwInKeychainKey) } 43 | } 44 | 45 | var saveDistributionPointPwInKeychain: Bool { 46 | get { return UserDefaults.standard.bool(forKey: saveDistributionPointPwInKeychainKey) } 47 | set(value) { UserDefaults.standard.set(value, forKey: saveDistributionPointPwInKeychainKey) } 48 | } 49 | 50 | var firstRun: Bool { 51 | get { return UserDefaults.standard.bool(forKey: firstRunKey) } 52 | set(value) { UserDefaults.standard.set(value, forKey: firstRunKey) } 53 | } 54 | 55 | var allowDeletionsAfterSynchronization: DeletionOptions { 56 | get { 57 | if let stringValue = UserDefaults.standard.string(forKey: allowDeletionsAfterSynchronizationKey), let deleteOption = DeletionOptions.optionFromString(stringValue) { 58 | return deleteOption 59 | } else { 60 | return DeletionOptions.none 61 | } 62 | } 63 | 64 | set(value) { 65 | UserDefaults.standard.set(value.rawValue, forKey: allowDeletionsAfterSynchronizationKey) 66 | } 67 | } 68 | 69 | var allowManualDeletions: DeletionOptions { 70 | get { 71 | if let stringValue = UserDefaults.standard.string(forKey: allowManualDeletionsKey), let deleteOption = DeletionOptions.optionFromString(stringValue) { 72 | return deleteOption 73 | } else { 74 | return DeletionOptions.filesAndAssociatedPackages 75 | } 76 | } 77 | 78 | set(value) { 79 | UserDefaults.standard.set(value.rawValue, forKey: allowManualDeletionsKey) 80 | } 81 | } 82 | 83 | var promptForJamfProInstances: Bool { 84 | get { return UserDefaults.standard.bool(forKey: promptForJamfProInstancesKey) } 85 | set(value) { UserDefaults.standard.set(value, forKey: promptForJamfProInstancesKey) } 86 | } 87 | 88 | var distributionPointUsernames: [String: String] { 89 | get { return UserDefaults.standard.object(forKey: distributionPointUsernamesKey) as? [String: String] ?? [:] } 90 | set(value) { UserDefaults.standard.set(value, forKey: distributionPointUsernamesKey) } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /JamfSync/Utility/VersionInfo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import Foundation 6 | 7 | class VersionInfo { 8 | func getDisplayVersion() -> String { 9 | let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") ?? "" 10 | let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") ?? "" 11 | return "Version: \(version) (\(build))" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /JamfSync/Utility/View+NSWindow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import SwiftUI 6 | 7 | /** 8 | hack to avoid crashes on window close, and remove the window from the 9 | NSApplication stack, ie: avoid leaking window objects 10 | */ 11 | fileprivate final class WindowDelegate: NSObject, NSWindowDelegate { 12 | func windowShouldClose(_ sender: NSWindow) -> Bool { 13 | NSApp.removeWindowsItem(sender) 14 | return true 15 | } 16 | } 17 | 18 | public extension View { 19 | func openInNewWindow(_ introspect: @escaping (_ window: NSWindow) -> Void) { 20 | let windowDelegate = WindowDelegate() 21 | let rv = NSWindow( 22 | contentRect: NSRect(x: 0, y: 0, width: 320, height: 320), 23 | styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], 24 | backing: .buffered, 25 | defer: false) 26 | rv.isReleasedWhenClosed = false 27 | rv.title = "New Window" 28 | // who owns who :-) 29 | rv.delegate = windowDelegate 30 | rv.contentView = NSHostingView(rootView: self) 31 | introspect(rv) 32 | rv.center() 33 | rv.makeKeyAndOrderFront(nil) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /JamfSync/Utility/XmlErrorParser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import Foundation 6 | 7 | enum XmlErrorField { 8 | case code 9 | case message 10 | case proposedSize 11 | case maxSizeAllowed 12 | case requestId 13 | case hostId 14 | } 15 | 16 | class XmlErrorParser : NSObject, XMLParserDelegate { 17 | var field: XmlErrorField? 18 | var parseError: Error? 19 | var code: String? 20 | var message: String? 21 | var proposedSize: String? 22 | var maxAllowedSize: String? 23 | var requestId: String? 24 | var hostId: String? 25 | 26 | func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String : String] = [:]) { 27 | switch elementName { 28 | case "Code": 29 | field = .code 30 | case "Message": 31 | field = .message 32 | case "ProposedSize": 33 | field = .proposedSize 34 | case "MaxSizeAllowed": 35 | field = .maxSizeAllowed 36 | case "RequestId": 37 | field = .requestId 38 | case "HostId": 39 | field = .hostId 40 | default: 41 | field = nil 42 | } 43 | } 44 | 45 | func parser(_ parser: XMLParser, foundCharacters string: String) { 46 | let value = string.trimmingCharacters(in: .whitespacesAndNewlines) 47 | if !value.isEmpty { 48 | switch field { 49 | case .code: 50 | code = value 51 | case .message: 52 | message = value 53 | case .proposedSize: 54 | proposedSize = value 55 | case .maxSizeAllowed: 56 | maxAllowedSize = value 57 | case .requestId: 58 | requestId = value 59 | case .hostId: 60 | hostId = value 61 | default: 62 | break 63 | } 64 | } 65 | } 66 | 67 | func parser(_ parser: XMLParser, parseErrorOccurred parseError: Error) { 68 | self.parseError = parseError 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /JamfSyncTests/FolderDpTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | @testable import Jamf_Sync 6 | import XCTest 7 | 8 | final class FolderDpTests: XCTestCase { 9 | let mockFileManager = MockFileManager() 10 | var folderDp: FolderDp! 11 | let filePath = "/Test/Path" 12 | 13 | override func setUpWithError() throws { 14 | folderDp = FolderDp(name: "TestFolderDp", filePath: filePath, fileManager: mockFileManager) 15 | } 16 | 17 | // MARK: - retrieveFileList tests 18 | 19 | func test_retrieveFileList_happyPath() throws { 20 | // Given 21 | let path = "/Path/" 22 | let happyFunPackage = "HappyFunPackage.pkg" 23 | let notSoGoodToUploadFile = "NotSoGoodToUpload.file" 24 | let superSadDmg = "SuperSad.dmg" 25 | mockFileManager.directoryContents = [URL(fileURLWithPath: path + happyFunPackage), URL(fileURLWithPath: path + notSoGoodToUploadFile), URL(fileURLWithPath: path + superSadDmg)] 26 | mockFileManager.fileAttributes = [path + happyFunPackage : [.size : Int64(12345678)], path + superSadDmg : [.size : Int64(456)]] 27 | 28 | let expectationCompleted = XCTestExpectation() 29 | Task { 30 | // When 31 | try await folderDp.retrieveFileList() 32 | 33 | // Then 34 | XCTAssertTrue(folderDp.filesLoaded) 35 | XCTAssertEqual(folderDp.dpFiles.files.count, 2) 36 | if folderDp.dpFiles.files.count == 2 { 37 | XCTAssertEqual(folderDp.dpFiles.files[0].name, happyFunPackage) 38 | XCTAssertEqual(folderDp.dpFiles.files[0].size, 12345678) 39 | XCTAssertEqual(folderDp.dpFiles.files[1].name, superSadDmg) 40 | XCTAssertEqual(folderDp.dpFiles.files[1].size, 456) 41 | } 42 | expectationCompleted.fulfill() 43 | } 44 | wait(for: [expectationCompleted], timeout: 5) 45 | } 46 | 47 | func test_retrieveFileList_noFiles() throws { 48 | // Given 49 | mockFileManager.directoryContents = [] 50 | 51 | let expectationCompleted = XCTestExpectation() 52 | Task { 53 | // When 54 | try await folderDp.retrieveFileList() 55 | 56 | // Then 57 | XCTAssertTrue(folderDp.filesLoaded) 58 | XCTAssertEqual(folderDp.dpFiles.files.count, 0) 59 | expectationCompleted.fulfill() 60 | } 61 | wait(for: [expectationCompleted], timeout: 5) 62 | } 63 | 64 | func test_retrieveFileList_failed() throws { 65 | // Given 66 | mockFileManager.contentsOfDirectoryError = TestErrors.SomethingWentHaywire 67 | 68 | let expectationCompleted = XCTestExpectation() 69 | Task { 70 | do { 71 | // When 72 | try await folderDp.retrieveFileList() 73 | 74 | // Then 75 | XCTFail("This should have thrown an exception, but didn't") 76 | } catch TestErrors.SomethingWentHaywire { 77 | // It failed perfectly! 78 | } 79 | expectationCompleted.fulfill() 80 | } 81 | wait(for: [expectationCompleted], timeout: 5) 82 | } 83 | 84 | // MARK: - deleteFile tests 85 | 86 | func test_deleteFile() throws { 87 | // Given 88 | let fileName = "fileName" 89 | let fileUrl = URL(fileURLWithPath: "/Source/Path/\(fileName)") 90 | let dpFile = DpFile(name: fileName, fileUrl: fileUrl, size: Int64(123456), checksums: nil) 91 | let fileToDelete = URL(fileURLWithPath: "\(folderDp.filePath)/\(fileName)") 92 | let synchronizationProgress = SynchronizationProgress() 93 | let expectationCompleted = XCTestExpectation() 94 | Task { 95 | // Given 96 | try await folderDp.deleteFile(file: dpFile, progress: synchronizationProgress) 97 | 98 | expectationCompleted.fulfill() 99 | } 100 | 101 | // Then 102 | wait(for: [expectationCompleted], timeout: 5) 103 | XCTAssertEqual(mockFileManager.itemRemoved, fileToDelete) 104 | } 105 | 106 | // MARK: - selectionName tests 107 | 108 | func test_selectionName() throws { 109 | // When 110 | let result = folderDp.selectionName() 111 | 112 | // Then 113 | XCTAssertEqual(result, "TestFolderDp (local)") 114 | } 115 | 116 | // MARK: - transferFile tests 117 | 118 | func test_transferFile() throws { 119 | // Given 120 | let localPath = "/dst/path" 121 | let fileName = "fileName.pkg" 122 | let srcFile = DpFile(name: fileName, fileUrl: URL(fileURLWithPath: "/src/path/fileName.pkg"), size: 123456789) 123 | let synchronizationProgress = SynchronizationProgress() 124 | let dstDp = FolderDp(name: "TestDstDp", filePath: localPath, fileManager: mockFileManager) 125 | synchronizationProgress.printToConsole = true // So it will update before the test is over 126 | synchronizationProgress.currentTotalSizeTransferred = 1234 127 | 128 | let expectationCompleted = XCTestExpectation() 129 | Task { 130 | // When 131 | try await dstDp.transferFile(srcFile: srcFile, moveFrom: nil, progress: synchronizationProgress) 132 | 133 | expectationCompleted.fulfill() 134 | } 135 | wait(for: [expectationCompleted], timeout: 5) 136 | 137 | // Then 138 | XCTAssertEqual(mockFileManager.itemRemoved, URL(fileURLWithPath: "/dst/path/fileName.pkg")) 139 | XCTAssertEqual(mockFileManager.srcItemCopied, URL(fileURLWithPath: "/src/path/fileName.pkg")) 140 | XCTAssertEqual(mockFileManager.dstItemCopied, URL(fileURLWithPath: "/dst/path/fileName.pkg")) 141 | XCTAssertEqual(synchronizationProgress.currentFileSizeTransferred, 123456789) 142 | XCTAssertEqual(synchronizationProgress.currentTotalSizeTransferred, 123458023) 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /JamfSyncTests/Mocks/MockDistributionPoint.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | @testable import Jamf_Sync 6 | import Foundation 7 | 8 | struct TransferItem { 9 | var srcFile: DpFile 10 | var moveFrom: URL? 11 | } 12 | 13 | /// Mock for a DistributionPoint object that requires most functions to be tested 14 | class MockDistributionPoint: DistributionPoint { 15 | var errors: [Error?] = [] 16 | var errorIdx = 0 17 | var transferItems: [TransferItem] = [] 18 | var filesDeleted: [DpFile] = [] 19 | var cancelSync = false 20 | 21 | override func calculateTotalTransferSize(filesToSync: [DpFile]) -> Int64 { 22 | if cancelSync { 23 | cancel() 24 | } 25 | return super.calculateTotalTransferSize(filesToSync: filesToSync) 26 | } 27 | 28 | override func transferFile(srcFile: DpFile, moveFrom: URL? = nil, progress: SynchronizationProgress) async throws { 29 | if cancelSync { 30 | cancel() 31 | return 32 | } 33 | if errorIdx < errors.count, let error = errors[errorIdx] { 34 | throw error 35 | } 36 | errorIdx += 1 37 | 38 | transferItems.append(TransferItem(srcFile: srcFile, moveFrom: moveFrom)) 39 | } 40 | 41 | override func deleteFile(file: DpFile, progress: SynchronizationProgress) async throws { 42 | filesDeleted.append(file) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /JamfSyncTests/Mocks/MockDistributionPointSync.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | @testable import Jamf_Sync 6 | import Foundation 7 | 8 | /// Mock for a DistributionPoint object that mocks out calls made by SyncrhonizationTask 9 | class MockDistributionPointSync: DistributionPoint { 10 | var prepareDpCalled = false 11 | var retrieveFileListCalled = false 12 | var copyFilesCalled = false 13 | var deleteFilesNotOnSourceCalled = false 14 | var prepareDpError: Error? 15 | var retrieveFileListError: Error? 16 | var copyFilesError: Error? 17 | var deleteFilesNotOnSourceError: Error? 18 | 19 | override func prepareDp() async throws { 20 | if let prepareDpError { 21 | throw prepareDpError 22 | } 23 | prepareDpCalled = true 24 | } 25 | 26 | override func retrieveFileList(limitFileTypes: Bool = true) async throws { 27 | if let retrieveFileListError { 28 | throw retrieveFileListError 29 | } 30 | retrieveFileListCalled = true 31 | } 32 | 33 | override func copyFiles(selectedItems: [DpFile], dstDp: DistributionPoint, jamfProInstance: JamfProInstance?, forceSync: Bool, progress: SynchronizationProgress, dryRun: Bool) async throws { 34 | if let copyFilesError { 35 | throw copyFilesError 36 | } 37 | copyFilesCalled = true 38 | } 39 | 40 | override func deleteFilesNotOnSource(srcDp: DistributionPoint, progress: SynchronizationProgress, dryRun: Bool) async throws { 41 | if let deleteFilesNotOnSourceError { 42 | throw deleteFilesNotOnSourceError 43 | } 44 | deleteFilesNotOnSourceCalled = true 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /JamfSyncTests/Mocks/MockFileManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import Foundation 6 | 7 | class MockFileManager: FileManager { 8 | var directoryContents: [URL] = [] 9 | var fileAttributes: [ String: [FileAttributeKey : Any] ] = [:] 10 | var contentsOfDirectoryError: Error? 11 | var removeItemError: Error? 12 | var copyError: Error? 13 | var moveError: Error? 14 | var itemRemoved: URL? 15 | var srcItemMoved: URL? 16 | var dstItemMoved: URL? 17 | var srcItemCopied: URL? 18 | var dstItemCopied: URL? 19 | var unmountedMountPoint: URL? 20 | var fileExistsResponse = true 21 | var directoryCreated: URL? 22 | var createDirectoryError: Error? 23 | 24 | override func contentsOfDirectory(at url: URL, includingPropertiesForKeys keys: [URLResourceKey]?, options mask: FileManager.DirectoryEnumerationOptions = []) throws -> [URL] { 25 | if let contentsOfDirectoryError { 26 | throw contentsOfDirectoryError 27 | } 28 | return directoryContents 29 | } 30 | 31 | override func attributesOfItem(atPath path: String) throws -> [FileAttributeKey : Any] { 32 | if let attributes = fileAttributes[path] { 33 | return attributes 34 | } 35 | return [:] 36 | } 37 | 38 | override func removeItem(at URL: URL) throws { 39 | if let removeItemError { 40 | throw removeItemError 41 | } 42 | itemRemoved = URL 43 | } 44 | 45 | override func moveItem(at srcURL: URL, to dstURL: URL) throws { 46 | if let moveError { 47 | throw moveError 48 | } 49 | srcItemMoved = srcURL 50 | dstItemMoved = dstURL 51 | } 52 | 53 | override func copyItem(at srcURL: URL, to dstURL: URL) throws { 54 | if let copyError { 55 | throw copyError 56 | } 57 | srcItemCopied = srcURL 58 | dstItemCopied = dstURL 59 | } 60 | 61 | override func unmountVolume(at url: URL, options mask: FileManager.UnmountOptions = []) async throws { 62 | unmountedMountPoint = url 63 | } 64 | 65 | override func fileExists(atPath path: String) -> Bool { 66 | return fileExistsResponse 67 | } 68 | 69 | override func createDirectory(at url: URL, withIntermediateDirectories createIntermediates: Bool, attributes: [FileAttributeKey : Any]? = nil) throws { 70 | if let createDirectoryError { 71 | throw createDirectoryError 72 | } 73 | directoryCreated = url 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /JamfSyncTests/Mocks/MockJamfProInstance.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | @testable import Jamf_Sync 6 | import Foundation 7 | 8 | struct MockDataRequestResponse { 9 | var url: URL 10 | var httpMethod: String 11 | var httpBodyData: Data? 12 | var contentType: String 13 | var returnData: Data? 14 | var returnResponse: URLResponse? 15 | var error: Error? 16 | } 17 | 18 | class MockJamfProInstance: JamfProInstance { 19 | var filesAdded: [DpFile] = [] 20 | var packagesUpdated: [Package] = [] 21 | var deletePackagesNotOnSourceCalled = false 22 | var deletePackagesNotOnSourceError: Error? 23 | var mockRequestsAndResponses: [MockDataRequestResponse] = [] 24 | 25 | override func addPackage(dpFile: DpFile) async throws { 26 | filesAdded.append(dpFile) 27 | } 28 | 29 | override func updatePackage(package: Package) async throws { 30 | packagesUpdated.append(package) 31 | } 32 | 33 | override func deletePackagesNotOnSource(srcDp: DistributionPoint, progress: SynchronizationProgress, dryRun: Bool) async throws { 34 | if let deletePackagesNotOnSourceError { 35 | throw deletePackagesNotOnSourceError 36 | } 37 | deletePackagesNotOnSourceCalled = true 38 | } 39 | 40 | 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?) { 41 | if let requestResponse = findRequestResponse(url: url, httpMethod: httpMethod, httpBody: httpBody, contentType: contentType) { 42 | if let error = requestResponse.error { 43 | throw error 44 | } 45 | return (data: requestResponse.returnData, response: requestResponse.returnResponse) 46 | } 47 | return (data: nil, response: nil) 48 | } 49 | 50 | // MARK: - Private helpers 51 | 52 | private func findRequestResponse(url: URL, httpMethod: String, httpBody: Data?, contentType: String) -> MockDataRequestResponse? { 53 | mockRequestsAndResponses.first { return $0.url == url && $0.httpMethod == httpMethod && $0.httpBodyData == httpBody && $0.contentType == contentType } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /JamfSyncTests/TestErrors.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | import Foundation 6 | 7 | enum TestErrors: Error { 8 | case SomethingWentHaywire 9 | } 10 | -------------------------------------------------------------------------------- /JamfSyncTests/UploadTimeTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UploadTimeTests.swift 3 | // Jamf SyncTests 4 | // 5 | // Created by Harry Strand on 10/10/24. 6 | // 7 | 8 | @testable import Jamf_Sync 9 | import XCTest 10 | 11 | final class UploadTimeTests: XCTestCase { 12 | let uploadTime = UploadTime() 13 | 14 | func testUploadTime_zero() throws { 15 | // Given 16 | 17 | // When 18 | let result = uploadTime.total() 19 | 20 | // Then 21 | XCTAssertEqual(result, "0 seconds") 22 | } 23 | 24 | func testUploadTime_partialMinute() throws { 25 | // Given 26 | uploadTime.start = Date().timeIntervalSinceNow 27 | uploadTime.end = uploadTime.start + 53 28 | 29 | // When 30 | let result = uploadTime.total() 31 | 32 | // Then 33 | XCTAssertEqual(result, "53 seconds") 34 | } 35 | 36 | func testUploadTime_multipleMinutesEven() throws { 37 | // Given 38 | uploadTime.start = Date().timeIntervalSinceNow 39 | uploadTime.end = uploadTime.start + 180 40 | 41 | // When 42 | let result = uploadTime.total() 43 | 44 | // Then 45 | XCTAssertEqual(result, "3 minutes") 46 | } 47 | 48 | func testUploadTime_multipleMinutesPlusSeconds() throws { 49 | // Given 50 | uploadTime.start = Date().timeIntervalSinceNow 51 | uploadTime.end = uploadTime.start + 187 52 | 53 | // When 54 | let result = uploadTime.total() 55 | 56 | // Then 57 | XCTAssertEqual(result, "3 minutes, 7 seconds") 58 | } 59 | 60 | func testUploadTime_multipleHoursEven() throws { 61 | // Given 62 | uploadTime.start = Date().timeIntervalSinceNow 63 | uploadTime.end = uploadTime.start + 10800 64 | 65 | // When 66 | let result = uploadTime.total() 67 | 68 | // Then 69 | XCTAssertEqual(result, "3 hours") 70 | } 71 | 72 | func testUploadTime_multipleMinutesPlusMinutesEven() throws { 73 | // Given 74 | uploadTime.start = Date().timeIntervalSinceNow 75 | uploadTime.end = uploadTime.start + 10920 76 | 77 | // When 78 | let result = uploadTime.total() 79 | 80 | // Then 81 | XCTAssertEqual(result, "3 hours, 2 minutes") 82 | } 83 | 84 | func testUploadTime_multipleMinutesPlusMinutesPlusSeconds() throws { 85 | // Given 86 | uploadTime.start = Date().timeIntervalSinceNow 87 | uploadTime.end = uploadTime.start + 10957 88 | 89 | // When 90 | let result = uploadTime.total() 91 | 92 | // Then 93 | XCTAssertEqual(result, "3 hours, 2 minutes, 37 seconds") 94 | } 95 | 96 | } 97 | -------------------------------------------------------------------------------- /JamfSyncTests/XmlErrorParserTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024, Jamf 3 | // 4 | 5 | @testable import Jamf_Sync 6 | import XCTest 7 | 8 | final class XmlErrorParserTests: XCTestCase { 9 | func testParser_happyPath() throws { 10 | // Given 11 | let xmlContent = 12 | """ 13 | 14 | 15 | EntityTooLarge 16 | Your proposed upload exceeds the maximum allowed size 17 | 13630203999 18 | 5368709120 19 | DV1FDVPNGBQ5M1PX 20 | F9zqYNRz3MMZ5nsfIkkIgrjUK7STfn9CUiIB2TlmvbFXWA5M0N/4pIKIRMqpiIZXhlH2Cc0xEiY= 21 | 22 | """; 23 | let xmlData = Data(xmlContent.utf8) 24 | let xmlParser = XMLParser(data: xmlData) 25 | let xmlErrorParser = XmlErrorParser() 26 | xmlParser.delegate = xmlErrorParser 27 | 28 | // When 29 | xmlParser.parse() 30 | 31 | // Then 32 | XCTAssertEqual(xmlErrorParser.code, "EntityTooLarge") 33 | XCTAssertEqual(xmlErrorParser.message, "Your proposed upload exceeds the maximum allowed size") 34 | XCTAssertEqual(xmlErrorParser.proposedSize, "13630203999") 35 | XCTAssertEqual(xmlErrorParser.maxAllowedSize, "5368709120") 36 | XCTAssertEqual(xmlErrorParser.requestId, "DV1FDVPNGBQ5M1PX") 37 | XCTAssertEqual(xmlErrorParser.hostId, "F9zqYNRz3MMZ5nsfIkkIgrjUK7STfn9CUiIB2TlmvbFXWA5M0N/4pIKIRMqpiIZXhlH2Cc0xEiY=") 38 | XCTAssertNil(xmlErrorParser.parseError) 39 | } 40 | 41 | func testParser_someMissingFields() throws { 42 | // Given 43 | let xmlContent = 44 | """ 45 | 46 | 47 | EntityTooLarge 48 | Your proposed upload exceeds the maximum allowed size 49 | 50 | """; 51 | let xmlData = Data(xmlContent.utf8) 52 | let xmlParser = XMLParser(data: xmlData) 53 | let xmlErrorParser = XmlErrorParser() 54 | xmlParser.delegate = xmlErrorParser 55 | 56 | // When 57 | xmlParser.parse() 58 | 59 | // Then 60 | XCTAssertEqual(xmlErrorParser.code, "EntityTooLarge") 61 | XCTAssertEqual(xmlErrorParser.message, "Your proposed upload exceeds the maximum allowed size") 62 | XCTAssertNil(xmlErrorParser.proposedSize) 63 | XCTAssertNil(xmlErrorParser.maxAllowedSize) 64 | XCTAssertNil(xmlErrorParser.requestId) 65 | XCTAssertNil(xmlErrorParser.hostId) 66 | XCTAssertNil(xmlErrorParser.parseError) 67 | } 68 | 69 | func testParser_parsingError() throws { 70 | // Given 71 | let xmlContent = 72 | """ 73 | This 74 | is not very good