├── .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