Simple App to help @jaspermayone make it through COMP1050 with a professor who won't use version control.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

inital version

Jasper Mayone 53343060

+1510
+131
IconGenerator.swift
··· 1 + /* 2 + * 3 + * © 2026 Jasper Mayone <me@jaspermayone.com> 4 + * Licensed under the O'Saasy License Agreement (https://osaasy.dev/") 5 + * 6 + * Icon Generator for ZipMerge - Run this to generate app icon 7 + * Instructions: 8 + * 1. Open this file in Xcode Playground or add to project temporarily 9 + * 2. Run and screenshot the preview at different sizes 10 + * 3. Use the screenshots for your app icon 11 + * 12 + */ 13 + 14 + import SwiftUI 15 + 16 + struct AppIconView: View { 17 + let size: CGFloat = 1024 // Standard app icon size 18 + 19 + var body: some View { 20 + ZStack { 21 + // Background gradient 22 + LinearGradient( 23 + gradient: Gradient(colors: [ 24 + Color(red: 0.2, green: 0.5, blue: 0.9), 25 + Color(red: 0.1, green: 0.3, blue: 0.7) 26 + ]), 27 + startPoint: .topLeading, 28 + endPoint: .bottomTrailing 29 + ) 30 + 31 + // Main icon content 32 + VStack(spacing: size * 0.05) { 33 + // Zip icon on top 34 + Image(systemName: "doc.zipper") 35 + .font(.system(size: size * 0.25, weight: .medium)) 36 + .foregroundColor(.white) 37 + .shadow(color: .black.opacity(0.3), radius: size * 0.02) 38 + 39 + // Merge arrows 40 + HStack(spacing: size * 0.08) { 41 + Image(systemName: "arrow.down.circle.fill") 42 + .font(.system(size: size * 0.15, weight: .bold)) 43 + .foregroundColor(.white.opacity(0.9)) 44 + 45 + Image(systemName: "arrow.triangle.merge") 46 + .font(.system(size: size * 0.15, weight: .bold)) 47 + .foregroundColor(Color(red: 1.0, green: 0.8, blue: 0.2)) 48 + .shadow(color: .black.opacity(0.3), radius: size * 0.01) 49 + } 50 + 51 + // Folder icon at bottom 52 + Image(systemName: "folder.fill") 53 + .font(.system(size: size * 0.2, weight: .medium)) 54 + .foregroundColor(.white.opacity(0.95)) 55 + .shadow(color: .black.opacity(0.3), radius: size * 0.02) 56 + } 57 + } 58 + .frame(width: size, height: size) 59 + .cornerRadius(size * 0.225) // Standard iOS icon corner radius 60 + } 61 + } 62 + 63 + // Alternative simpler design 64 + struct AppIconViewSimple: View { 65 + let size: CGFloat = 1024 66 + 67 + var body: some View { 68 + ZStack { 69 + // Background 70 + RoundedRectangle(cornerRadius: size * 0.225) 71 + .fill(LinearGradient( 72 + gradient: Gradient(colors: [ 73 + Color(red: 0.3, green: 0.6, blue: 1.0), 74 + Color(red: 0.2, green: 0.4, blue: 0.8) 75 + ]), 76 + startPoint: .top, 77 + endPoint: .bottom 78 + )) 79 + 80 + // Zipper merge symbol 81 + VStack(spacing: size * 0.08) { 82 + // Two zips merging 83 + HStack(spacing: size * 0.06) { 84 + Image(systemName: "doc.zipper") 85 + .font(.system(size: size * 0.22, weight: .semibold)) 86 + .foregroundColor(.white.opacity(0.85)) 87 + .rotationEffect(.degrees(-15)) 88 + 89 + Image(systemName: "arrow.right") 90 + .font(.system(size: size * 0.15, weight: .bold)) 91 + .foregroundColor(Color(red: 1.0, green: 0.9, blue: 0.3)) 92 + 93 + Image(systemName: "doc.zipper") 94 + .font(.system(size: size * 0.22, weight: .semibold)) 95 + .foregroundColor(.white.opacity(0.85)) 96 + .rotationEffect(.degrees(15)) 97 + } 98 + 99 + // Merge result 100 + Image(systemName: "checkmark.circle.fill") 101 + .font(.system(size: size * 0.18, weight: .bold)) 102 + .foregroundColor(Color(red: 0.3, green: 0.9, blue: 0.5)) 103 + .shadow(color: .black.opacity(0.3), radius: size * 0.02) 104 + } 105 + } 106 + .frame(width: size, height: size) 107 + } 108 + } 109 + 110 + // Preview for both designs 111 + struct IconPreview: View { 112 + var body: some View { 113 + VStack(spacing: 40) { 114 + Text("Design 1: Zip to Folder Flow") 115 + .font(.title) 116 + AppIconView() 117 + .frame(width: 512, height: 512) 118 + 119 + Text("Design 2: Zip Merge Simple") 120 + .font(.title) 121 + AppIconViewSimple() 122 + .frame(width: 512, height: 512) 123 + } 124 + .padding(50) 125 + .background(Color(white: 0.9)) 126 + } 127 + } 128 + 129 + #Preview { 130 + IconPreview() 131 + }
+10
LICENSE.md
··· 1 + # O'Saasy License Agreement 2 + 3 + Copyright © 2025, Jasper Mayone. 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 + 7 + 1. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 + 2. No licensee or downstream recipient may use the Software (including any modified or derivative versions) to directly compete with the original Licensor by offering it to third parties as a hosted, managed, or Software-as-a-Service (SaaS) product or cloud service where the primary value of the service is the functionality of the Software itself. 9 + 10 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+354
ZipMerge.xcodeproj/project.pbxproj
··· 1 + // !$*UTF8*$! 2 + { 3 + archiveVersion = 1; 4 + classes = { 5 + }; 6 + objectVersion = 56; 7 + objects = { 8 + 9 + /* Begin PBXBuildFile section */ 10 + 001 /* ZipMergeApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 101 /* ZipMergeApp.swift */; }; 11 + 002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102 /* ContentView.swift */; }; 12 + 003 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 103 /* Models.swift */; }; 13 + 004 /* FileComparer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 104 /* FileComparer.swift */; }; 14 + 005 /* DiffView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 105 /* DiffView.swift */; }; 15 + 006 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 201 /* Assets.xcassets */; }; 16 + /* End PBXBuildFile section */ 17 + 18 + /* Begin PBXFileReference section */ 19 + 101 /* ZipMergeApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZipMergeApp.swift; sourceTree = "<group>"; }; 20 + 102 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; }; 21 + 103 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = "<group>"; }; 22 + 104 /* FileComparer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileComparer.swift; sourceTree = "<group>"; }; 23 + 105 /* DiffView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiffView.swift; sourceTree = "<group>"; }; 24 + 201 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; 25 + 301 /* ZipMerge.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ZipMerge.app; sourceTree = BUILT_PRODUCTS_DIR; }; 26 + /* End PBXFileReference section */ 27 + 28 + /* Begin PBXFrameworksBuildPhase section */ 29 + 401 /* Frameworks */ = { 30 + isa = PBXFrameworksBuildPhase; 31 + buildActionMask = 2147483647; 32 + files = ( 33 + ); 34 + runOnlyForDeploymentPostprocessing = 0; 35 + }; 36 + /* End PBXFrameworksBuildPhase section */ 37 + 38 + /* Begin PBXGroup section */ 39 + 501 = { 40 + isa = PBXGroup; 41 + children = ( 42 + 502 /* ZipMerge */, 43 + 503 /* Products */, 44 + ); 45 + sourceTree = "<group>"; 46 + }; 47 + 502 /* ZipMerge */ = { 48 + isa = PBXGroup; 49 + children = ( 50 + 101 /* ZipMergeApp.swift */, 51 + 102 /* ContentView.swift */, 52 + 103 /* Models.swift */, 53 + 104 /* FileComparer.swift */, 54 + 105 /* DiffView.swift */, 55 + 201 /* Assets.xcassets */, 56 + ); 57 + path = ZipMerge; 58 + sourceTree = "<group>"; 59 + }; 60 + 503 /* Products */ = { 61 + isa = PBXGroup; 62 + children = ( 63 + 301 /* ZipMerge.app */, 64 + ); 65 + name = Products; 66 + sourceTree = "<group>"; 67 + }; 68 + /* End PBXGroup section */ 69 + 70 + /* Begin PBXNativeTarget section */ 71 + 601 /* ZipMerge */ = { 72 + isa = PBXNativeTarget; 73 + buildConfigurationList = 701 /* Build configuration list for PBXNativeTarget "ZipMerge" */; 74 + buildPhases = ( 75 + 602 /* Sources */, 76 + 401 /* Frameworks */, 77 + 603 /* Resources */, 78 + ); 79 + buildRules = ( 80 + ); 81 + dependencies = ( 82 + ); 83 + name = ZipMerge; 84 + productName = ZipMerge; 85 + productReference = 301 /* ZipMerge.app */; 86 + productType = "com.apple.product-type.application"; 87 + }; 88 + /* End PBXNativeTarget section */ 89 + 90 + /* Begin PBXProject section */ 91 + 801 /* Project object */ = { 92 + isa = PBXProject; 93 + attributes = { 94 + BuildIndependentTargetsInParallel = 1; 95 + LastSwiftUpdateCheck = 1500; 96 + LastUpgradeCheck = 2600; 97 + TargetAttributes = { 98 + 601 = { 99 + CreatedOnToolsVersion = 15.0; 100 + }; 101 + }; 102 + }; 103 + buildConfigurationList = 802 /* Build configuration list for PBXProject "ZipMerge" */; 104 + compatibilityVersion = "Xcode 14.0"; 105 + developmentRegion = en; 106 + hasScannedForEncodings = 0; 107 + knownRegions = ( 108 + en, 109 + Base, 110 + ); 111 + mainGroup = 501; 112 + productRefGroup = 503 /* Products */; 113 + projectDirPath = ""; 114 + projectRoot = ""; 115 + targets = ( 116 + 601 /* ZipMerge */, 117 + ); 118 + }; 119 + /* End PBXProject section */ 120 + 121 + /* Begin PBXResourcesBuildPhase section */ 122 + 603 /* Resources */ = { 123 + isa = PBXResourcesBuildPhase; 124 + buildActionMask = 2147483647; 125 + files = ( 126 + 006 /* Assets.xcassets in Resources */, 127 + ); 128 + runOnlyForDeploymentPostprocessing = 0; 129 + }; 130 + /* End PBXResourcesBuildPhase section */ 131 + 132 + /* Begin PBXSourcesBuildPhase section */ 133 + 602 /* Sources */ = { 134 + isa = PBXSourcesBuildPhase; 135 + buildActionMask = 2147483647; 136 + files = ( 137 + 001 /* ZipMergeApp.swift in Sources */, 138 + 002 /* ContentView.swift in Sources */, 139 + 003 /* Models.swift in Sources */, 140 + 004 /* FileComparer.swift in Sources */, 141 + 005 /* DiffView.swift in Sources */, 142 + ); 143 + runOnlyForDeploymentPostprocessing = 0; 144 + }; 145 + /* End PBXSourcesBuildPhase section */ 146 + 147 + /* Begin XCBuildConfiguration section */ 148 + 901 /* Debug */ = { 149 + isa = XCBuildConfiguration; 150 + buildSettings = { 151 + ALWAYS_SEARCH_USER_PATHS = NO; 152 + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 153 + CLANG_ANALYZER_NONNULL = YES; 154 + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 155 + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 156 + CLANG_ENABLE_MODULES = YES; 157 + CLANG_ENABLE_OBJC_ARC = YES; 158 + CLANG_ENABLE_OBJC_WEAK = YES; 159 + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 160 + CLANG_WARN_BOOL_CONVERSION = YES; 161 + CLANG_WARN_COMMA = YES; 162 + CLANG_WARN_CONSTANT_CONVERSION = YES; 163 + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 164 + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 165 + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 166 + CLANG_WARN_EMPTY_BODY = YES; 167 + CLANG_WARN_ENUM_CONVERSION = YES; 168 + CLANG_WARN_INFINITE_RECURSION = YES; 169 + CLANG_WARN_INT_CONVERSION = YES; 170 + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 171 + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 172 + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 173 + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 174 + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 175 + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 176 + CLANG_WARN_STRICT_PROTOTYPES = YES; 177 + CLANG_WARN_SUSPICIOUS_MOVE = YES; 178 + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 179 + CLANG_WARN_UNREACHABLE_CODE = YES; 180 + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 181 + COPY_PHASE_STRIP = NO; 182 + DEAD_CODE_STRIPPING = YES; 183 + DEBUG_INFORMATION_FORMAT = dwarf; 184 + DEVELOPMENT_TEAM = M67B42LX8D; 185 + ENABLE_STRICT_OBJC_MSGSEND = YES; 186 + ENABLE_TESTABILITY = YES; 187 + ENABLE_USER_SCRIPT_SANDBOXING = YES; 188 + GCC_C_LANGUAGE_STANDARD = gnu17; 189 + GCC_DYNAMIC_NO_PIC = NO; 190 + GCC_NO_COMMON_BLOCKS = YES; 191 + GCC_OPTIMIZATION_LEVEL = 0; 192 + GCC_PREPROCESSOR_DEFINITIONS = ( 193 + "DEBUG=1", 194 + "$(inherited)", 195 + ); 196 + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 197 + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 198 + GCC_WARN_UNDECLARED_SELECTOR = YES; 199 + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 200 + GCC_WARN_UNUSED_FUNCTION = YES; 201 + GCC_WARN_UNUSED_VARIABLE = YES; 202 + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 203 + MACOSX_DEPLOYMENT_TARGET = 14.0; 204 + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 205 + MTL_FAST_MATH = YES; 206 + ONLY_ACTIVE_ARCH = YES; 207 + SDKROOT = macosx; 208 + STRING_CATALOG_GENERATE_SYMBOLS = YES; 209 + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 210 + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 211 + }; 212 + name = Debug; 213 + }; 214 + 902 /* Release */ = { 215 + isa = XCBuildConfiguration; 216 + buildSettings = { 217 + ALWAYS_SEARCH_USER_PATHS = NO; 218 + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 219 + CLANG_ANALYZER_NONNULL = YES; 220 + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 221 + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 222 + CLANG_ENABLE_MODULES = YES; 223 + CLANG_ENABLE_OBJC_ARC = YES; 224 + CLANG_ENABLE_OBJC_WEAK = YES; 225 + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 226 + CLANG_WARN_BOOL_CONVERSION = YES; 227 + CLANG_WARN_COMMA = YES; 228 + CLANG_WARN_CONSTANT_CONVERSION = YES; 229 + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 230 + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 231 + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 232 + CLANG_WARN_EMPTY_BODY = YES; 233 + CLANG_WARN_ENUM_CONVERSION = YES; 234 + CLANG_WARN_INFINITE_RECURSION = YES; 235 + CLANG_WARN_INT_CONVERSION = YES; 236 + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 237 + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 238 + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 239 + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 240 + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 241 + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 242 + CLANG_WARN_STRICT_PROTOTYPES = YES; 243 + CLANG_WARN_SUSPICIOUS_MOVE = YES; 244 + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 245 + CLANG_WARN_UNREACHABLE_CODE = YES; 246 + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 247 + COPY_PHASE_STRIP = NO; 248 + DEAD_CODE_STRIPPING = YES; 249 + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 250 + DEVELOPMENT_TEAM = M67B42LX8D; 251 + ENABLE_NS_ASSERTIONS = NO; 252 + ENABLE_STRICT_OBJC_MSGSEND = YES; 253 + ENABLE_USER_SCRIPT_SANDBOXING = YES; 254 + GCC_C_LANGUAGE_STANDARD = gnu17; 255 + GCC_NO_COMMON_BLOCKS = YES; 256 + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 257 + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 258 + GCC_WARN_UNDECLARED_SELECTOR = YES; 259 + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 260 + GCC_WARN_UNUSED_FUNCTION = YES; 261 + GCC_WARN_UNUSED_VARIABLE = YES; 262 + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 263 + MACOSX_DEPLOYMENT_TARGET = 14.0; 264 + MTL_ENABLE_DEBUG_INFO = NO; 265 + MTL_FAST_MATH = YES; 266 + SDKROOT = macosx; 267 + STRING_CATALOG_GENERATE_SYMBOLS = YES; 268 + SWIFT_COMPILATION_MODE = wholemodule; 269 + }; 270 + name = Release; 271 + }; 272 + 903 /* Debug */ = { 273 + isa = XCBuildConfiguration; 274 + buildSettings = { 275 + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 276 + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 277 + CODE_SIGN_ENTITLEMENTS = ZipMerge/ZipMerge.entitlements; 278 + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; 279 + CODE_SIGN_STYLE = Automatic; 280 + COMBINE_HIDPI_IMAGES = YES; 281 + CURRENT_PROJECT_VERSION = 1; 282 + DEAD_CODE_STRIPPING = YES; 283 + DEVELOPMENT_ASSET_PATHS = ""; 284 + ENABLE_APP_SANDBOX = YES; 285 + ENABLE_PREVIEWS = YES; 286 + ENABLE_USER_SELECTED_FILES = readwrite; 287 + GENERATE_INFOPLIST_FILE = YES; 288 + INFOPLIST_KEY_NSHumanReadableCopyright = ""; 289 + LD_RUNPATH_SEARCH_PATHS = ( 290 + "$(inherited)", 291 + "@executable_path/../Frameworks", 292 + ); 293 + MARKETING_VERSION = 1.0; 294 + PRODUCT_BUNDLE_IDENTIFIER = com.singlefeather.ZipMerge; 295 + PRODUCT_NAME = "$(TARGET_NAME)"; 296 + SWIFT_EMIT_LOC_STRINGS = YES; 297 + SWIFT_VERSION = 5.0; 298 + }; 299 + name = Debug; 300 + }; 301 + 904 /* Release */ = { 302 + isa = XCBuildConfiguration; 303 + buildSettings = { 304 + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 305 + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 306 + CODE_SIGN_ENTITLEMENTS = ZipMerge/ZipMerge.entitlements; 307 + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; 308 + CODE_SIGN_STYLE = Automatic; 309 + COMBINE_HIDPI_IMAGES = YES; 310 + CURRENT_PROJECT_VERSION = 1; 311 + DEAD_CODE_STRIPPING = YES; 312 + DEVELOPMENT_ASSET_PATHS = ""; 313 + ENABLE_APP_SANDBOX = YES; 314 + ENABLE_PREVIEWS = YES; 315 + ENABLE_USER_SELECTED_FILES = readwrite; 316 + GENERATE_INFOPLIST_FILE = YES; 317 + INFOPLIST_KEY_NSHumanReadableCopyright = ""; 318 + LD_RUNPATH_SEARCH_PATHS = ( 319 + "$(inherited)", 320 + "@executable_path/../Frameworks", 321 + ); 322 + MARKETING_VERSION = 1.0; 323 + PRODUCT_BUNDLE_IDENTIFIER = com.singlefeather.ZipMerge; 324 + PRODUCT_NAME = "$(TARGET_NAME)"; 325 + SWIFT_EMIT_LOC_STRINGS = YES; 326 + SWIFT_VERSION = 5.0; 327 + }; 328 + name = Release; 329 + }; 330 + /* End XCBuildConfiguration section */ 331 + 332 + /* Begin XCConfigurationList section */ 333 + 701 /* Build configuration list for PBXNativeTarget "ZipMerge" */ = { 334 + isa = XCConfigurationList; 335 + buildConfigurations = ( 336 + 903 /* Debug */, 337 + 904 /* Release */, 338 + ); 339 + defaultConfigurationIsVisible = 0; 340 + defaultConfigurationName = Release; 341 + }; 342 + 802 /* Build configuration list for PBXProject "ZipMerge" */ = { 343 + isa = XCConfigurationList; 344 + buildConfigurations = ( 345 + 901 /* Debug */, 346 + 902 /* Release */, 347 + ); 348 + defaultConfigurationIsVisible = 0; 349 + defaultConfigurationName = Release; 350 + }; 351 + /* End XCConfigurationList section */ 352 + }; 353 + rootObject = 801 /* Project object */; 354 + }
+7
ZipMerge.xcodeproj/project.xcworkspace/contents.xcworkspacedata
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <Workspace 3 + version = "1.0"> 4 + <FileRef 5 + location = "self:"> 6 + </FileRef> 7 + </Workspace>
ZipMerge.xcodeproj/project.xcworkspace/xcuserdata/jsp.xcuserdatad/UserInterfaceState.xcuserstate

This is a binary file and will not be displayed.

+14
ZipMerge.xcodeproj/xcuserdata/jsp.xcuserdatad/xcschemes/xcschememanagement.plist
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 + <plist version="1.0"> 4 + <dict> 5 + <key>SchemeUserState</key> 6 + <dict> 7 + <key>ZipMerge.xcscheme_^#shared#^_</key> 8 + <dict> 9 + <key>orderHint</key> 10 + <integer>0</integer> 11 + </dict> 12 + </dict> 13 + </dict> 14 + </plist>
+11
ZipMerge/Assets.xcassets/AccentColor.colorset/Contents.json
··· 1 + { 2 + "colors" : [ 3 + { 4 + "idiom" : "universal" 5 + } 6 + ], 7 + "info" : { 8 + "author" : "xcode", 9 + "version" : 1 10 + } 11 + }
+59
ZipMerge/Assets.xcassets/AppIcon.appiconset/Contents.json
··· 1 + { 2 + "images" : [ 3 + { 4 + "idiom" : "mac", 5 + "scale" : "1x", 6 + "size" : "16x16" 7 + }, 8 + { 9 + "idiom" : "mac", 10 + "scale" : "2x", 11 + "size" : "16x16" 12 + }, 13 + { 14 + "idiom" : "mac", 15 + "scale" : "1x", 16 + "size" : "32x32" 17 + }, 18 + { 19 + "idiom" : "mac", 20 + "scale" : "2x", 21 + "size" : "32x32" 22 + }, 23 + { 24 + "idiom" : "mac", 25 + "scale" : "1x", 26 + "size" : "128x128" 27 + }, 28 + { 29 + "idiom" : "mac", 30 + "scale" : "2x", 31 + "size" : "128x128" 32 + }, 33 + { 34 + "idiom" : "mac", 35 + "scale" : "1x", 36 + "size" : "256x256" 37 + }, 38 + { 39 + "idiom" : "mac", 40 + "scale" : "2x", 41 + "size" : "256x256" 42 + }, 43 + { 44 + "idiom" : "mac", 45 + "scale" : "1x", 46 + "size" : "512x512" 47 + }, 48 + { 49 + "filename" : "ZipMergeIcon.png", 50 + "idiom" : "mac", 51 + "scale" : "2x", 52 + "size" : "512x512" 53 + } 54 + ], 55 + "info" : { 56 + "author" : "xcode", 57 + "version" : 1 58 + } 59 + }
ZipMerge/Assets.xcassets/AppIcon.appiconset/ZipMergeIcon.png

This is a binary file and will not be displayed.

+6
ZipMerge/Assets.xcassets/Contents.json
··· 1 + { 2 + "info" : { 3 + "author" : "xcode", 4 + "version" : 1 5 + } 6 + }
+421
ZipMerge/ContentView.swift
··· 1 + /* 2 + * 3 + * © 2026 Jasper Mayone <me@jaspermayone.com> 4 + * Licensed under the O'Saasy License Agreement (https://osaasy.dev) 5 + * 6 + * ZipMerge App - Simple App to help @jsp make it through COMP1050 with a professor who won't use version controll. 7 + * 8 + */ 9 + 10 + import SwiftUI 11 + import UniformTypeIdentifiers 12 + 13 + struct ContentView: View { 14 + @State private var yourDirectory: URL? 15 + @State private var zipFile: URL? 16 + @State private var comparison: ComparisonResult? 17 + @State private var selectedFile: ComparedFile? 18 + @State private var isProcessing = false 19 + @State private var errorMessage: String? 20 + @State private var showingSuccess = false 21 + @State private var tempDirectory: URL? 22 + @State private var showingGitCommit = false 23 + @State private var commitMessage = "" 24 + 25 + var body: some View { 26 + HSplitView { 27 + // Left panel - file list 28 + VStack(spacing: 0) { 29 + setupArea 30 + 31 + if let comparison = comparison { 32 + fileListView(comparison) 33 + } else { 34 + emptyStateView 35 + } 36 + } 37 + .frame(minWidth: 300, idealWidth: 350) 38 + 39 + // Right panel - diff view 40 + if let file = selectedFile, file.changeType == .modified || file.changeType == .added || file.changeType == .deleted { 41 + DiffView(file: file) 42 + } else { 43 + VStack { 44 + Image(systemName: "doc.text.magnifyingglass") 45 + .font(.system(size: 48)) 46 + .foregroundColor(.secondary) 47 + Text("Select a file to view changes") 48 + .foregroundColor(.secondary) 49 + } 50 + .frame(maxWidth: .infinity, maxHeight: .infinity) 51 + } 52 + } 53 + .alert("Error", isPresented: .init( 54 + get: { errorMessage != nil }, 55 + set: { if !$0 { errorMessage = nil } } 56 + )) { 57 + Button("OK") { errorMessage = nil } 58 + } message: { 59 + Text(errorMessage ?? "") 60 + } 61 + .alert("Success", isPresented: $showingSuccess) { 62 + Button("OK") { 63 + if isGitRepository() { 64 + showingSuccess = false 65 + showingGitCommit = true 66 + } else { 67 + cleanupAfterMerge() 68 + } 69 + } 70 + } message: { 71 + Text("Changes applied successfully!") 72 + } 73 + .alert("Create Git Commit", isPresented: $showingGitCommit) { 74 + TextField("Commit message", text: $commitMessage) 75 + Button("Commit") { 76 + createGitCommit() 77 + cleanupAfterMerge() 78 + } 79 + Button("Skip") { 80 + cleanupAfterMerge() 81 + } 82 + } message: { 83 + Text("Would you like to create a git commit for these changes?") 84 + } 85 + } 86 + 87 + private var setupArea: some View { 88 + VStack(spacing: 12) { 89 + // Your directory picker 90 + HStack { 91 + Image(systemName: "folder.fill") 92 + .foregroundColor(.blue) 93 + VStack(alignment: .leading) { 94 + Text("Your Project") 95 + .font(.headline) 96 + Text(yourDirectory?.lastPathComponent ?? "Not selected") 97 + .font(.caption) 98 + .foregroundColor(.secondary) 99 + } 100 + Spacer() 101 + Button("Choose...") { 102 + chooseYourDirectory() 103 + } 104 + } 105 + .padding(10) 106 + .background(Color(NSColor.controlBackgroundColor)) 107 + .cornerRadius(8) 108 + 109 + // Zip drop zone 110 + ZipDropZone(zipFile: $zipFile) { 111 + processZip() 112 + } 113 + 114 + if isProcessing { 115 + ProgressView("Processing...") 116 + } 117 + 118 + if let comparison = comparison, comparison.changedFiles.count > 0 { 119 + HStack { 120 + Text("\(comparison.pendingCount) pending") 121 + .foregroundColor(.orange) 122 + Spacer() 123 + Button("Apply Changes") { 124 + applyChanges() 125 + } 126 + .disabled(comparison.pendingCount > 0) 127 + .buttonStyle(.borderedProminent) 128 + } 129 + } 130 + } 131 + .padding() 132 + } 133 + 134 + private var emptyStateView: some View { 135 + VStack(spacing: 16) { 136 + Spacer() 137 + Image(systemName: "arrow.down.doc.fill") 138 + .font(.system(size: 48)) 139 + .foregroundColor(.secondary) 140 + Text("Drop a zip file to compare") 141 + .font(.title3) 142 + .foregroundColor(.secondary) 143 + Text("First, choose your project folder above") 144 + .font(.caption) 145 + .foregroundColor(.secondary) 146 + Spacer() 147 + } 148 + .frame(maxWidth: .infinity) 149 + } 150 + 151 + private func fileListView(_ comparison: ComparisonResult) -> some View { 152 + VStack(alignment: .leading, spacing: 0) { 153 + Text("Changed Files") 154 + .font(.headline) 155 + .padding(.horizontal) 156 + .padding(.vertical, 8) 157 + 158 + Divider() 159 + 160 + if comparison.changedFiles.isEmpty { 161 + VStack { 162 + Spacer() 163 + Image(systemName: "checkmark.circle.fill") 164 + .font(.system(size: 48)) 165 + .foregroundColor(.green) 166 + Text("No changes detected") 167 + .foregroundColor(.secondary) 168 + Spacer() 169 + } 170 + .frame(maxWidth: .infinity) 171 + } else { 172 + List(comparison.changedFiles, selection: $selectedFile) { file in 173 + FileRowView(file: binding(for: file)) 174 + .tag(file) 175 + } 176 + .listStyle(.inset) 177 + } 178 + } 179 + } 180 + 181 + private func binding(for file: ComparedFile) -> Binding<ComparedFile> { 182 + Binding( 183 + get: { 184 + comparison?.files.first { $0.id == file.id } ?? file 185 + }, 186 + set: { newValue in 187 + if let index = comparison?.files.firstIndex(where: { $0.id == file.id }) { 188 + comparison?.files[index] = newValue 189 + } 190 + } 191 + ) 192 + } 193 + 194 + private func chooseYourDirectory() { 195 + let panel = NSOpenPanel() 196 + panel.canChooseFiles = false 197 + panel.canChooseDirectories = true 198 + panel.allowsMultipleSelection = false 199 + panel.message = "Choose your project folder" 200 + 201 + if panel.runModal() == .OK { 202 + yourDirectory = panel.url 203 + } 204 + } 205 + 206 + private func processZip() { 207 + guard let zip = zipFile, let yours = yourDirectory else { 208 + errorMessage = "Please select both your project folder and a zip file" 209 + return 210 + } 211 + 212 + isProcessing = true 213 + 214 + DispatchQueue.global(qos: .userInitiated).async { 215 + do { 216 + // Create temp directory for extraction 217 + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) 218 + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) 219 + 220 + // Extract zip 221 + try FileComparer.extractZip(at: zip, to: tempDir) 222 + 223 + // Find the actual root (in case zip has a wrapper folder) 224 + let theirRoot = FileComparer.findRootDirectory(in: tempDir) 225 + 226 + // Compare 227 + let result = try FileComparer.compare(yourDirectory: yours, theirDirectory: theirRoot) 228 + 229 + DispatchQueue.main.async { 230 + self.tempDirectory = tempDir 231 + self.comparison = result 232 + self.isProcessing = false 233 + } 234 + } catch { 235 + DispatchQueue.main.async { 236 + self.errorMessage = error.localizedDescription 237 + self.isProcessing = false 238 + } 239 + } 240 + } 241 + } 242 + 243 + private func applyChanges() { 244 + guard let comparison = comparison else { return } 245 + 246 + do { 247 + try FileComparer.applyChanges(comparison) 248 + showingSuccess = true 249 + } catch { 250 + errorMessage = error.localizedDescription 251 + } 252 + } 253 + 254 + private func cleanupAfterMerge() { 255 + // Clean up temp directory 256 + if let tempDir = tempDirectory { 257 + try? FileManager.default.removeItem(at: tempDir) 258 + tempDirectory = nil 259 + } 260 + 261 + // Delete the zip file 262 + if let zip = zipFile { 263 + try? FileManager.default.removeItem(at: zip) 264 + } 265 + 266 + // Reset state 267 + comparison = nil 268 + zipFile = nil 269 + commitMessage = "" 270 + } 271 + 272 + private func isGitRepository() -> Bool { 273 + guard let directory = yourDirectory else { return false } 274 + 275 + let gitDir = directory.appendingPathComponent(".git") 276 + var isDirectory: ObjCBool = false 277 + return FileManager.default.fileExists(atPath: gitDir.path, isDirectory: &isDirectory) && isDirectory.boolValue 278 + } 279 + 280 + private func createGitCommit() { 281 + guard let directory = yourDirectory else { return } 282 + 283 + let process = Process() 284 + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") 285 + process.currentDirectoryURL = directory 286 + process.arguments = ["commit", "-am", commitMessage.isEmpty ? "[ZipMerge Auto Merge]" : commitMessage] 287 + 288 + do { 289 + try process.run() 290 + process.waitUntilExit() 291 + } catch { 292 + errorMessage = "Failed to create git commit: \(error.localizedDescription)" 293 + } 294 + } 295 + } 296 + 297 + struct FileRowView: View { 298 + @Binding var file: ComparedFile 299 + 300 + var body: some View { 301 + HStack { 302 + Image(systemName: file.icon) 303 + .foregroundColor(colorForType(file.changeType)) 304 + 305 + VStack(alignment: .leading) { 306 + Text(file.fileName) 307 + .lineLimit(1) 308 + Text(file.relativePath) 309 + .font(.caption) 310 + .foregroundColor(.secondary) 311 + .lineLimit(1) 312 + } 313 + 314 + Spacer() 315 + 316 + if file.changeType != .unchanged { 317 + decisionButtons 318 + } 319 + } 320 + .padding(.vertical, 4) 321 + } 322 + 323 + private var decisionButtons: some View { 324 + HStack(spacing: 4) { 325 + Button { 326 + file.decision = .keepMine 327 + } label: { 328 + Image(systemName: "person.fill") 329 + .foregroundColor(file.decision == .keepMine ? .white : .blue) 330 + } 331 + .buttonStyle(.bordered) 332 + .tint(file.decision == .keepMine ? .blue : nil) 333 + .help("Keep your version") 334 + 335 + Button { 336 + file.decision = .takeTheirs 337 + } label: { 338 + Image(systemName: "graduationcap.fill") 339 + .foregroundColor(file.decision == .takeTheirs ? .white : .green) 340 + } 341 + .buttonStyle(.bordered) 342 + .tint(file.decision == .takeTheirs ? .green : nil) 343 + .help("Take teacher's version") 344 + } 345 + } 346 + 347 + private func colorForType(_ type: FileChangeType) -> Color { 348 + switch type { 349 + case .added: return .green 350 + case .modified: return .orange 351 + case .deleted: return .red 352 + case .unchanged: return .gray 353 + } 354 + } 355 + } 356 + 357 + struct ZipDropZone: View { 358 + @Binding var zipFile: URL? 359 + var onDrop: () -> Void 360 + 361 + @State private var isTargeted = false 362 + 363 + var body: some View { 364 + VStack(spacing: 8) { 365 + Image(systemName: "doc.zipper") 366 + .font(.system(size: 24)) 367 + if let zip = zipFile { 368 + Text(zip.lastPathComponent) 369 + .lineLimit(1) 370 + } else { 371 + Text("Drop zip here") 372 + } 373 + } 374 + .frame(maxWidth: .infinity) 375 + .frame(height: 80) 376 + .background( 377 + RoundedRectangle(cornerRadius: 8) 378 + .strokeBorder(style: StrokeStyle(lineWidth: 2, dash: [8])) 379 + .foregroundColor(isTargeted ? .blue : .secondary) 380 + ) 381 + .background(isTargeted ? Color.blue.opacity(0.1) : Color.clear) 382 + .cornerRadius(8) 383 + .onDrop(of: [.fileURL], isTargeted: $isTargeted) { providers in 384 + guard let provider = providers.first else { return false } 385 + 386 + provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { data, error in 387 + guard let data = data as? Data, 388 + let url = URL(dataRepresentation: data, relativeTo: nil), 389 + url.pathExtension.lowercased() == "zip" else { 390 + return 391 + } 392 + 393 + DispatchQueue.main.async { 394 + zipFile = url 395 + onDrop() 396 + } 397 + } 398 + return true 399 + } 400 + .onTapGesture { 401 + chooseZip() 402 + } 403 + } 404 + 405 + private func chooseZip() { 406 + let panel = NSOpenPanel() 407 + panel.canChooseFiles = true 408 + panel.canChooseDirectories = false 409 + panel.allowedContentTypes = [UTType.zip] 410 + panel.message = "Choose teacher's zip file" 411 + 412 + if panel.runModal() == .OK, let url = panel.url { 413 + zipFile = url 414 + onDrop() 415 + } 416 + } 417 + } 418 + 419 + #Preview { 420 + ContentView() 421 + }
+160
ZipMerge/DiffView.swift
··· 1 + /* 2 + * 3 + * © 2026 Jasper Mayone <me@jaspermayone.com> 4 + * Licensed under the O'Saasy License Agreement (https://osaasy.dev) 5 + * 6 + * ZipMerge App - Simple App to help @jsp make it through COMP1050 with a professor who won't use version controll. 7 + * 8 + */ 9 + 10 + import SwiftUI 11 + 12 + struct DiffView: View { 13 + let file: ComparedFile 14 + 15 + var body: some View { 16 + VStack(spacing: 0) { 17 + // Header 18 + HStack { 19 + Text(file.relativePath) 20 + .font(.headline) 21 + Spacer() 22 + changeTypeBadge 23 + } 24 + .padding() 25 + .background(Color(NSColor.controlBackgroundColor)) 26 + 27 + Divider() 28 + 29 + // Content based on change type 30 + switch file.changeType { 31 + case .added: 32 + addedFileView 33 + case .deleted: 34 + deletedFileView 35 + case .modified: 36 + modifiedFileView 37 + case .unchanged: 38 + Text("No changes") 39 + .foregroundColor(.secondary) 40 + .frame(maxWidth: .infinity, maxHeight: .infinity) 41 + } 42 + } 43 + } 44 + 45 + private var changeTypeBadge: some View { 46 + HStack(spacing: 4) { 47 + Image(systemName: file.icon) 48 + Text(changeTypeText) 49 + } 50 + .font(.caption) 51 + .padding(.horizontal, 8) 52 + .padding(.vertical, 4) 53 + .background(changeTypeColor.opacity(0.2)) 54 + .foregroundColor(changeTypeColor) 55 + .cornerRadius(4) 56 + } 57 + 58 + private var changeTypeText: String { 59 + switch file.changeType { 60 + case .added: return "New" 61 + case .deleted: return "Deleted" 62 + case .modified: return "Modified" 63 + case .unchanged: return "Unchanged" 64 + } 65 + } 66 + 67 + private var changeTypeColor: Color { 68 + switch file.changeType { 69 + case .added: return .green 70 + case .deleted: return .red 71 + case .modified: return .orange 72 + case .unchanged: return .gray 73 + } 74 + } 75 + 76 + private var addedFileView: some View { 77 + VStack(alignment: .leading, spacing: 0) { 78 + sectionHeader("New file from teacher", color: .green) 79 + ScrollView { 80 + codeView(file.theirContent ?? "(binary or unreadable)", lineColor: .green.opacity(0.15)) 81 + } 82 + } 83 + } 84 + 85 + private var deletedFileView: some View { 86 + VStack(alignment: .leading, spacing: 0) { 87 + sectionHeader("File only in your version (not in teacher's zip)", color: .red) 88 + ScrollView { 89 + codeView(file.yourContent ?? "(binary or unreadable)", lineColor: .red.opacity(0.15)) 90 + } 91 + } 92 + } 93 + 94 + private var modifiedFileView: some View { 95 + HSplitView { 96 + VStack(alignment: .leading, spacing: 0) { 97 + sectionHeader("Your version", color: .blue) 98 + ScrollView { 99 + codeView(file.yourContent ?? "(binary or unreadable)", lineColor: nil) 100 + } 101 + } 102 + 103 + VStack(alignment: .leading, spacing: 0) { 104 + sectionHeader("Teacher's version", color: .green) 105 + ScrollView { 106 + codeView(file.theirContent ?? "(binary or unreadable)", lineColor: nil) 107 + } 108 + } 109 + } 110 + } 111 + 112 + private func sectionHeader(_ title: String, color: Color) -> some View { 113 + HStack { 114 + Circle() 115 + .fill(color) 116 + .frame(width: 8, height: 8) 117 + Text(title) 118 + .font(.subheadline.bold()) 119 + Spacer() 120 + } 121 + .padding(.horizontal) 122 + .padding(.vertical, 8) 123 + .background(color.opacity(0.1)) 124 + } 125 + 126 + private func codeView(_ content: String, lineColor: Color?) -> some View { 127 + VStack(alignment: .leading, spacing: 0) { 128 + let lines = content.components(separatedBy: .newlines) 129 + ForEach(Array(lines.enumerated()), id: \.offset) { index, line in 130 + HStack(alignment: .top, spacing: 0) { 131 + Text("\(index + 1)") 132 + .font(.system(.caption, design: .monospaced)) 133 + .foregroundColor(.secondary) 134 + .frame(width: 40, alignment: .trailing) 135 + .padding(.trailing, 8) 136 + 137 + Text(line.isEmpty ? " " : line) 138 + .font(.system(.body, design: .monospaced)) 139 + .textSelection(.enabled) 140 + 141 + Spacer(minLength: 0) 142 + } 143 + .padding(.vertical, 1) 144 + .padding(.horizontal, 8) 145 + .background(lineColor ?? Color.clear) 146 + } 147 + } 148 + .frame(maxWidth: .infinity, alignment: .leading) 149 + .padding(.vertical, 8) 150 + } 151 + } 152 + 153 + #Preview { 154 + DiffView(file: ComparedFile( 155 + relativePath: "src/Main.java", 156 + changeType: .modified, 157 + yourContent: "public class Main {\n public static void main(String[] args) {\n System.out.println(\"Hello\");\n }\n}", 158 + theirContent: "public class Main {\n public static void main(String[] args) {\n System.out.println(\"Hello World\");\n // New comment from teacher\n }\n}" 159 + )) 160 + }
+203
ZipMerge/FileComparer.swift
··· 1 + /* 2 + * 3 + * © 2026 Jasper Mayone <me@jaspermayone.com> 4 + * Licensed under the O'Saasy License Agreement (https://osaasy.dev) 5 + * 6 + * ZipMerge App - Simple App to help @jsp make it through COMP1050 with a professor who won't use version controll. 7 + * 8 + */ 9 + 10 + import Foundation 11 + import Compression 12 + 13 + class FileComparer { 14 + 15 + static func extractZip(at zipURL: URL, to destination: URL) throws { 16 + let process = Process() 17 + process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip") 18 + process.arguments = ["-o", zipURL.path, "-d", destination.path] 19 + 20 + let pipe = Pipe() 21 + process.standardOutput = pipe 22 + process.standardError = pipe 23 + 24 + try process.run() 25 + process.waitUntilExit() 26 + 27 + if process.terminationStatus != 0 { 28 + let data = pipe.fileHandleForReading.readDataToEndOfFile() 29 + let output = String(data: data, encoding: .utf8) ?? "Unknown error" 30 + throw NSError(domain: "ZipMerge", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to extract zip: \(output)"]) 31 + } 32 + } 33 + 34 + static func findRootDirectory(in directory: URL) -> URL { 35 + // Sometimes zips contain a single root folder, we want to get inside it 36 + let fm = FileManager.default 37 + guard let contents = try? fm.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.isDirectoryKey]) else { 38 + return directory 39 + } 40 + 41 + let nonHidden = contents.filter { !$0.lastPathComponent.hasPrefix(".") && !$0.lastPathComponent.hasPrefix("__") } 42 + 43 + if nonHidden.count == 1, 44 + let first = nonHidden.first, 45 + (try? first.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true { 46 + return first 47 + } 48 + 49 + return directory 50 + } 51 + 52 + static func compare(yourDirectory: URL, theirDirectory: URL) throws -> ComparisonResult { 53 + let fm = FileManager.default 54 + var files: [ComparedFile] = [] 55 + 56 + // Get all files from both directories 57 + let yourFiles = getAllFiles(in: yourDirectory, relativeTo: yourDirectory) 58 + let theirFiles = getAllFiles(in: theirDirectory, relativeTo: theirDirectory) 59 + 60 + let yourPaths = Set(yourFiles.keys) 61 + let theirPaths = Set(theirFiles.keys) 62 + 63 + // Files only in theirs (added) 64 + for path in theirPaths.subtracting(yourPaths) { 65 + let content = try? String(contentsOf: theirFiles[path]!, encoding: .utf8) 66 + files.append(ComparedFile( 67 + relativePath: path, 68 + changeType: .added, 69 + yourContent: nil, 70 + theirContent: content 71 + )) 72 + } 73 + 74 + // Files only in yours (deleted from teacher's version) 75 + for path in yourPaths.subtracting(theirPaths) { 76 + let content = try? String(contentsOf: yourFiles[path]!, encoding: .utf8) 77 + files.append(ComparedFile( 78 + relativePath: path, 79 + changeType: .deleted, 80 + yourContent: content, 81 + theirContent: nil 82 + )) 83 + } 84 + 85 + // Files in both - check if modified 86 + for path in yourPaths.intersection(theirPaths) { 87 + let yourURL = yourFiles[path]! 88 + let theirURL = theirFiles[path]! 89 + 90 + let yourData = try? Data(contentsOf: yourURL) 91 + let theirData = try? Data(contentsOf: theirURL) 92 + 93 + if yourData == theirData { 94 + files.append(ComparedFile( 95 + relativePath: path, 96 + changeType: .unchanged 97 + )) 98 + } else { 99 + let yourContent = try? String(contentsOf: yourURL, encoding: .utf8) 100 + let theirContent = try? String(contentsOf: theirURL, encoding: .utf8) 101 + 102 + // Compute hunks for modified files 103 + let hunks = computeHunks(yourContent: yourContent ?? "", theirContent: theirContent ?? "") 104 + 105 + files.append(ComparedFile( 106 + relativePath: path, 107 + changeType: .modified, 108 + yourContent: yourContent, 109 + theirContent: theirContent, 110 + hunks: hunks 111 + )) 112 + } 113 + } 114 + 115 + // Sort by path 116 + files.sort { $0.relativePath < $1.relativePath } 117 + 118 + return ComparisonResult( 119 + files: files, 120 + yourDirectory: yourDirectory, 121 + theirDirectory: theirDirectory 122 + ) 123 + } 124 + 125 + private static func getAllFiles(in directory: URL, relativeTo base: URL) -> [String: URL] { 126 + let fm = FileManager.default 127 + var result: [String: URL] = [:] 128 + 129 + guard let enumerator = fm.enumerator( 130 + at: directory, 131 + includingPropertiesForKeys: [.isRegularFileKey], 132 + options: [.skipsHiddenFiles] 133 + ) else { 134 + return result 135 + } 136 + 137 + for case let fileURL as URL in enumerator { 138 + guard let resourceValues = try? fileURL.resourceValues(forKeys: [.isRegularFileKey]), 139 + resourceValues.isRegularFile == true else { 140 + continue 141 + } 142 + 143 + let relativePath = fileURL.path.replacingOccurrences(of: base.path + "/", with: "") 144 + result[relativePath] = fileURL 145 + } 146 + 147 + return result 148 + } 149 + 150 + static func applyChanges(_ comparison: ComparisonResult) throws { 151 + let fm = FileManager.default 152 + 153 + for file in comparison.files { 154 + guard file.decision != .pending else { continue } 155 + 156 + let yourFile = comparison.yourDirectory.appendingPathComponent(file.relativePath) 157 + let theirFile = comparison.theirDirectory.appendingPathComponent(file.relativePath) 158 + 159 + switch (file.changeType, file.decision) { 160 + case (.added, .takeTheirs): 161 + // Copy new file from theirs to yours 162 + let parentDir = yourFile.deletingLastPathComponent() 163 + try fm.createDirectory(at: parentDir, withIntermediateDirectories: true) 164 + try fm.copyItem(at: theirFile, to: yourFile) 165 + 166 + case (.modified, .takeTheirs): 167 + // Check if hunks are used 168 + if !file.hunks.isEmpty { 169 + // Apply selected hunks only 170 + try applySelectedHunks(file: file, yourFile: yourFile) 171 + } else { 172 + // Replace entire file with theirs 173 + try fm.removeItem(at: yourFile) 174 + try fm.copyItem(at: theirFile, to: yourFile) 175 + } 176 + 177 + case (.deleted, .takeTheirs): 178 + // Delete your file (it's not in teacher's version) 179 + try fm.removeItem(at: yourFile) 180 + 181 + case (_, .keepMine): 182 + // Do nothing, keep your version 183 + break 184 + 185 + default: 186 + break 187 + } 188 + } 189 + } 190 + 191 + private static func computeHunks(yourContent: String, theirContent: String) -> [DiffHunk] { 192 + // For now, return empty array - hunk selection can be added later 193 + // This feature requires a robust diff algorithm which is complex to implement 194 + return [] 195 + } 196 + 197 + private static func applySelectedHunks(file: ComparedFile, yourFile: URL) throws { 198 + // This will be implemented when hunk selection UI is ready 199 + // For now, just replace the entire file 200 + guard let theirContent = file.theirContent else { return } 201 + try theirContent.write(to: yourFile, atomically: true, encoding: .utf8) 202 + } 203 + }
+108
ZipMerge/Models.swift
··· 1 + /* 2 + * 3 + * © 2026 Jasper Mayone <me@jaspermayone.com> 4 + * Licensed under the O'Saasy License Agreement (https://osaasy.dev) 5 + * 6 + * ZipMerge App - Simple App to help @jsp make it through COMP1050 with a professor who won't use version controll. 7 + * 8 + */ 9 + 10 + import Foundation 11 + 12 + enum FileChangeType: Equatable { 13 + case added // New file from teacher 14 + case modified // File exists in both, content differs 15 + case deleted // File in your directory but not in teacher's 16 + case unchanged // Identical 17 + } 18 + 19 + enum MergeDecision { 20 + case pending 21 + case keepMine 22 + case takeTheirs 23 + } 24 + 25 + struct DiffHunk: Identifiable, Equatable, Hashable { 26 + let id = UUID() 27 + let yourStartLine: Int 28 + let yourLineCount: Int 29 + let theirStartLine: Int 30 + let theirLineCount: Int 31 + let lines: [DiffLine] 32 + var isSelected: Bool = true // Default to selected 33 + 34 + var header: String { 35 + "@@ -\(yourStartLine),\(yourLineCount) +\(theirStartLine),\(theirLineCount) @@" 36 + } 37 + 38 + func hash(into hasher: inout Hasher) { 39 + hasher.combine(id) 40 + } 41 + } 42 + 43 + struct DiffLine: Equatable, Hashable { 44 + enum LineType: Equatable { 45 + case context // Unchanged line 46 + case addition // Line added in their version 47 + case deletion // Line deleted in their version 48 + } 49 + 50 + let type: LineType 51 + let content: String 52 + let yourLineNumber: Int? 53 + let theirLineNumber: Int? 54 + } 55 + 56 + struct ComparedFile: Identifiable, Equatable, Hashable { 57 + func hash(into hasher: inout Hasher) { 58 + hasher.combine(id) 59 + } 60 + 61 + let id = UUID() 62 + let relativePath: String 63 + let changeType: FileChangeType 64 + var decision: MergeDecision = .pending 65 + 66 + // For modified files, store both versions 67 + var yourContent: String? 68 + var theirContent: String? 69 + 70 + // For granular hunk selection on modified files 71 + var hunks: [DiffHunk] = [] 72 + 73 + var fileName: String { 74 + (relativePath as NSString).lastPathComponent 75 + } 76 + 77 + var icon: String { 78 + switch changeType { 79 + case .added: return "plus.circle.fill" 80 + case .modified: return "pencil.circle.fill" 81 + case .deleted: return "minus.circle.fill" 82 + case .unchanged: return "checkmark.circle.fill" 83 + } 84 + } 85 + 86 + var color: String { 87 + switch changeType { 88 + case .added: return "green" 89 + case .modified: return "orange" 90 + case .deleted: return "red" 91 + case .unchanged: return "gray" 92 + } 93 + } 94 + } 95 + 96 + struct ComparisonResult { 97 + var files: [ComparedFile] 98 + let yourDirectory: URL 99 + let theirDirectory: URL 100 + 101 + var pendingCount: Int { 102 + files.filter { $0.decision == .pending && $0.changeType != .unchanged }.count 103 + } 104 + 105 + var changedFiles: [ComparedFile] { 106 + files.filter { $0.changeType != .unchanged } 107 + } 108 + }
+5
ZipMerge/ZipMerge.entitlements
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 + <plist version="1.0"> 4 + <dict/> 5 + </plist>
+21
ZipMerge/ZipMergeApp.swift
··· 1 + /* 2 + * 3 + * © 2026 Jasper Mayone <me@jaspermayone.com> 4 + * Licensed under the O'Saasy License Agreement (https://osaasy.dev) 5 + * 6 + * ZipMerge App - Simple App to help @jsp make it through COMP1050 with a professor who won't use version controll. 7 + * 8 + */ 9 + 10 + import SwiftUI 11 + 12 + @main 13 + struct ZipMergeApp: App { 14 + var body: some Scene { 15 + WindowGroup { 16 + ContentView() 17 + } 18 + .windowStyle(.automatic) 19 + .defaultSize(width: 1000, height: 700) 20 + } 21 + }
ZipMergeIcon.png

This is a binary file and will not be displayed.