Use atproto actions with ease in iOS shortcuts

Working move of code

Changed files
+7309
at_toolbox.xcodeproj
project.xcworkspace
xcshareddata
xcschemes
shortcut
shortcut.xcodeproj
project.xcworkspace
shortcutTests
shortcutUITests
+91
.gitignore
···
··· 1 + # Created by https://www.toptal.com/developers/gitignore/api/swift 2 + # Edit at https://www.toptal.com/developers/gitignore?templates=swift 3 + 4 + ### Swift ### 5 + # Xcode 6 + # 7 + # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 8 + 9 + ## User settings 10 + xcuserdata/ 11 + 12 + ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 13 + *.xcscmblueprint 14 + *.xccheckout 15 + 16 + ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 17 + build/ 18 + DerivedData/ 19 + *.moved-aside 20 + *.pbxuser 21 + !default.pbxuser 22 + *.mode1v3 23 + !default.mode1v3 24 + *.mode2v3 25 + !default.mode2v3 26 + *.perspectivev3 27 + !default.perspectivev3 28 + 29 + ## Obj-C/Swift specific 30 + *.hmap 31 + 32 + ## App packaging 33 + *.ipa 34 + *.dSYM.zip 35 + *.dSYM 36 + 37 + ## Playgrounds 38 + timeline.xctimeline 39 + playground.xcworkspace 40 + 41 + # Swift Package Manager 42 + # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 43 + # Packages/ 44 + # Package.pins 45 + # Package.resolved 46 + # *.xcodeproj 47 + # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 48 + # hence it is not needed unless you have added a package configuration file to your project 49 + # .swiftpm 50 + 51 + .build/ 52 + 53 + # CocoaPods 54 + # We recommend against adding the Pods directory to your .gitignore. However 55 + # you should judge for yourself, the pros and cons are mentioned at: 56 + # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 57 + # Pods/ 58 + # Add this line if you want to avoid checking in source code from the Xcode workspace 59 + # *.xcworkspace 60 + 61 + # Carthage 62 + # Add this line if you want to avoid checking in source code from Carthage dependencies. 63 + # Carthage/Checkouts 64 + 65 + Carthage/Build/ 66 + 67 + # Accio dependency management 68 + Dependencies/ 69 + .accio/ 70 + 71 + # fastlane 72 + # It is recommended to not store the screenshots in the git repo. 73 + # Instead, use fastlane to re-generate the screenshots whenever they are needed. 74 + # For more information about the recommended setup visit: 75 + # https://docs.fastlane.tools/best-practices/source-control/#source-control 76 + 77 + fastlane/report.xml 78 + fastlane/Preview.html 79 + fastlane/screenshots/**/*.png 80 + fastlane/test_output 81 + 82 + # Code Injection 83 + # After new code Injection tools there's a generated folder /iOSInjectionProject 84 + # https://github.com/johnno1962/injectionforxcode 85 + 86 + iOSInjectionProject/ 87 + 88 + # End of https://www.toptal.com/developers/gitignore/api/swift 89 + 90 + .DS_Store 91 + /User.xcconfig
+125
AT Toolbox.storekit
···
··· 1 + { 2 + "appPolicies" : { 3 + "eula" : "", 4 + "policies" : [ 5 + { 6 + "locale" : "en_US", 7 + "policyText" : "", 8 + "policyURL" : "" 9 + } 10 + ] 11 + }, 12 + "identifier" : "22E4E9D8", 13 + "nonRenewingSubscriptions" : [ 14 + 15 + ], 16 + "products" : [ 17 + { 18 + "displayPrice" : "1.99", 19 + "familyShareable" : false, 20 + "internalID" : "6748326981", 21 + "localizations" : [ 22 + { 23 + "description" : "A small tip to help with development costs", 24 + "displayName" : "Tip", 25 + "locale" : "en_US" 26 + } 27 + ], 28 + "productID" : "tip1", 29 + "referenceName" : "Tip", 30 + "type" : "Consumable" 31 + }, 32 + { 33 + "displayPrice" : "2.99", 34 + "familyShareable" : false, 35 + "internalID" : "6748613381", 36 + "localizations" : [ 37 + { 38 + "description" : "A tip to help with development costs", 39 + "displayName" : "Tip+", 40 + "locale" : "en_US" 41 + } 42 + ], 43 + "productID" : "tip2", 44 + "referenceName" : "Tip+", 45 + "type" : "Consumable" 46 + }, 47 + { 48 + "displayPrice" : "3.99", 49 + "familyShareable" : false, 50 + "internalID" : "6748622765", 51 + "localizations" : [ 52 + { 53 + "description" : "A small tip to help with development costs", 54 + "displayName" : "Tip++", 55 + "locale" : "en_US" 56 + } 57 + ], 58 + "productID" : "tip3", 59 + "referenceName" : "Tip++", 60 + "type" : "Consumable" 61 + } 62 + ], 63 + "settings" : { 64 + "_applicationInternalID" : "6747999688", 65 + "_developerTeamID" : "HJ9PCD3YLH", 66 + "_failTransactionsEnabled" : false, 67 + "_lastSynchronizedDate" : 774221810.33701897, 68 + "_locale" : "en_US", 69 + "_storefront" : "USA", 70 + "_storeKitErrors" : [ 71 + { 72 + "current" : null, 73 + "enabled" : false, 74 + "name" : "Load Products" 75 + }, 76 + { 77 + "current" : null, 78 + "enabled" : false, 79 + "name" : "Purchase" 80 + }, 81 + { 82 + "current" : null, 83 + "enabled" : false, 84 + "name" : "Verification" 85 + }, 86 + { 87 + "current" : null, 88 + "enabled" : false, 89 + "name" : "App Store Sync" 90 + }, 91 + { 92 + "current" : null, 93 + "enabled" : false, 94 + "name" : "Subscription Status" 95 + }, 96 + { 97 + "current" : null, 98 + "enabled" : false, 99 + "name" : "App Transaction" 100 + }, 101 + { 102 + "current" : null, 103 + "enabled" : false, 104 + "name" : "Manage Subscriptions Sheet" 105 + }, 106 + { 107 + "current" : null, 108 + "enabled" : false, 109 + "name" : "Refund Request Sheet" 110 + }, 111 + { 112 + "current" : null, 113 + "enabled" : false, 114 + "name" : "Offer Code Redeem Sheet" 115 + } 116 + ] 117 + }, 118 + "subscriptionGroups" : [ 119 + 120 + ], 121 + "version" : { 122 + "major" : 4, 123 + "minor" : 0 124 + } 125 + }
+686
at_toolbox.xcodeproj/project.pbxproj
···
··· 1 + // !$*UTF8*$! 2 + { 3 + archiveVersion = 1; 4 + classes = { 5 + }; 6 + objectVersion = 77; 7 + objects = { 8 + 9 + /* Begin PBXBuildFile section */ 10 + 4B9388BE2E20B74C0067CD4B /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4B9388BD2E20B74C0067CD4B /* StoreKit.framework */; }; 11 + 4B9E72CB2E255D7600DC1E77 /* AT Toolbox.storekit in Resources */ = {isa = PBXBuildFile; fileRef = 4B9E72CA2E255D7600DC1E77 /* AT Toolbox.storekit */; }; 12 + 4B9E72D82E25FAC300DC1E77 /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 4B9E72D72E25FAC300DC1E77 /* ZIPFoundation */; }; 13 + 4BC6D6712E0C810F00967DB4 /* ATMacro in Frameworks */ = {isa = PBXBuildFile; productRef = 4BC6D6702E0C810F00967DB4 /* ATMacro */; }; 14 + 4BC6D6732E0C810F00967DB4 /* ATProtoKit in Frameworks */ = {isa = PBXBuildFile; productRef = 4BC6D6722E0C810F00967DB4 /* ATProtoKit */; }; 15 + 4BCBC0A12E0E0EC1003753C8 /* ATIdentityTools in Frameworks */ = {isa = PBXBuildFile; productRef = 4BCBC0A02E0E0EC1003753C8 /* ATIdentityTools */; }; 16 + 4BCBC0A32E0E0EC1003753C8 /* DIDCore in Frameworks */ = {isa = PBXBuildFile; productRef = 4BCBC0A22E0E0EC1003753C8 /* DIDCore */; }; 17 + 4BDB7D1C2E16225900439106 /* Cache in Frameworks */ = {isa = PBXBuildFile; productRef = 4BDB7D1B2E16225900439106 /* Cache */; }; 18 + /* End PBXBuildFile section */ 19 + 20 + /* Begin PBXContainerItemProxy section */ 21 + 4BBE4EFA2E09F977009F9D3E /* PBXContainerItemProxy */ = { 22 + isa = PBXContainerItemProxy; 23 + containerPortal = 4BBE4EE02E09F975009F9D3E /* Project object */; 24 + proxyType = 1; 25 + remoteGlobalIDString = 4BBE4EE72E09F975009F9D3E; 26 + remoteInfo = shortcut; 27 + }; 28 + 4BBE4F042E09F977009F9D3E /* PBXContainerItemProxy */ = { 29 + isa = PBXContainerItemProxy; 30 + containerPortal = 4BBE4EE02E09F975009F9D3E /* Project object */; 31 + proxyType = 1; 32 + remoteGlobalIDString = 4BBE4EE72E09F975009F9D3E; 33 + remoteInfo = shortcut; 34 + }; 35 + /* End PBXContainerItemProxy section */ 36 + 37 + /* Begin PBXFileReference section */ 38 + 4B9388BD2E20B74C0067CD4B /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; }; 39 + 4B9E72CA2E255D7600DC1E77 /* AT Toolbox.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = "AT Toolbox.storekit"; sourceTree = "<group>"; }; 40 + 4BBE4EE82E09F976009F9D3E /* shortcut.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = shortcut.app; sourceTree = BUILT_PRODUCTS_DIR; }; 41 + 4BBE4EF92E09F977009F9D3E /* shortcutTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = shortcutTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 42 + 4BBE4F032E09F977009F9D3E /* shortcutUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = shortcutUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 43 + /* End PBXFileReference section */ 44 + 45 + /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ 46 + 4BBE4F0B2E09F977009F9D3E /* Exceptions for "shortcut" folder in "shortcut" target */ = { 47 + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; 48 + membershipExceptions = ( 49 + Info.plist, 50 + ); 51 + target = 4BBE4EE72E09F975009F9D3E /* shortcut */; 52 + }; 53 + /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ 54 + 55 + /* Begin PBXFileSystemSynchronizedRootGroup section */ 56 + 4BBE4EEA2E09F976009F9D3E /* shortcut */ = { 57 + isa = PBXFileSystemSynchronizedRootGroup; 58 + exceptions = ( 59 + 4BBE4F0B2E09F977009F9D3E /* Exceptions for "shortcut" folder in "shortcut" target */, 60 + ); 61 + path = shortcut; 62 + sourceTree = "<group>"; 63 + }; 64 + 4BBE4EFC2E09F977009F9D3E /* shortcutTests */ = { 65 + isa = PBXFileSystemSynchronizedRootGroup; 66 + path = shortcutTests; 67 + sourceTree = "<group>"; 68 + }; 69 + 4BBE4F062E09F977009F9D3E /* shortcutUITests */ = { 70 + isa = PBXFileSystemSynchronizedRootGroup; 71 + path = shortcutUITests; 72 + sourceTree = "<group>"; 73 + }; 74 + /* End PBXFileSystemSynchronizedRootGroup section */ 75 + 76 + /* Begin PBXFrameworksBuildPhase section */ 77 + 4BBE4EE52E09F975009F9D3E /* Frameworks */ = { 78 + isa = PBXFrameworksBuildPhase; 79 + buildActionMask = 2147483647; 80 + files = ( 81 + 4BC6D6712E0C810F00967DB4 /* ATMacro in Frameworks */, 82 + 4BCBC0A32E0E0EC1003753C8 /* DIDCore in Frameworks */, 83 + 4BCBC0A12E0E0EC1003753C8 /* ATIdentityTools in Frameworks */, 84 + 4B9388BE2E20B74C0067CD4B /* StoreKit.framework in Frameworks */, 85 + 4BC6D6732E0C810F00967DB4 /* ATProtoKit in Frameworks */, 86 + 4BDB7D1C2E16225900439106 /* Cache in Frameworks */, 87 + 4B9E72D82E25FAC300DC1E77 /* ZIPFoundation in Frameworks */, 88 + ); 89 + runOnlyForDeploymentPostprocessing = 0; 90 + }; 91 + 4BBE4EF62E09F977009F9D3E /* Frameworks */ = { 92 + isa = PBXFrameworksBuildPhase; 93 + buildActionMask = 2147483647; 94 + files = ( 95 + ); 96 + runOnlyForDeploymentPostprocessing = 0; 97 + }; 98 + 4BBE4F002E09F977009F9D3E /* Frameworks */ = { 99 + isa = PBXFrameworksBuildPhase; 100 + buildActionMask = 2147483647; 101 + files = ( 102 + ); 103 + runOnlyForDeploymentPostprocessing = 0; 104 + }; 105 + /* End PBXFrameworksBuildPhase section */ 106 + 107 + /* Begin PBXGroup section */ 108 + 4BBE4EDF2E09F975009F9D3E = { 109 + isa = PBXGroup; 110 + children = ( 111 + 4B9E72CA2E255D7600DC1E77 /* AT Toolbox.storekit */, 112 + 4BBE4EEA2E09F976009F9D3E /* shortcut */, 113 + 4BBE4EFC2E09F977009F9D3E /* shortcutTests */, 114 + 4BBE4F062E09F977009F9D3E /* shortcutUITests */, 115 + 4BE1BB942E1C9457002FEAD4 /* Frameworks */, 116 + 4BBE4EE92E09F976009F9D3E /* Products */, 117 + ); 118 + sourceTree = "<group>"; 119 + }; 120 + 4BBE4EE92E09F976009F9D3E /* Products */ = { 121 + isa = PBXGroup; 122 + children = ( 123 + 4BBE4EE82E09F976009F9D3E /* shortcut.app */, 124 + 4BBE4EF92E09F977009F9D3E /* shortcutTests.xctest */, 125 + 4BBE4F032E09F977009F9D3E /* shortcutUITests.xctest */, 126 + ); 127 + name = Products; 128 + sourceTree = "<group>"; 129 + }; 130 + 4BE1BB942E1C9457002FEAD4 /* Frameworks */ = { 131 + isa = PBXGroup; 132 + children = ( 133 + 4B9388BD2E20B74C0067CD4B /* StoreKit.framework */, 134 + ); 135 + name = Frameworks; 136 + sourceTree = "<group>"; 137 + }; 138 + /* End PBXGroup section */ 139 + 140 + /* Begin PBXNativeTarget section */ 141 + 4BBE4EE72E09F975009F9D3E /* shortcut */ = { 142 + isa = PBXNativeTarget; 143 + buildConfigurationList = 4BBE4F0C2E09F977009F9D3E /* Build configuration list for PBXNativeTarget "shortcut" */; 144 + buildPhases = ( 145 + 4BBE4EE42E09F975009F9D3E /* Sources */, 146 + 4BBE4EE52E09F975009F9D3E /* Frameworks */, 147 + 4BBE4EE62E09F975009F9D3E /* Resources */, 148 + ); 149 + buildRules = ( 150 + ); 151 + dependencies = ( 152 + ); 153 + fileSystemSynchronizedGroups = ( 154 + 4BBE4EEA2E09F976009F9D3E /* shortcut */, 155 + ); 156 + name = shortcut; 157 + packageProductDependencies = ( 158 + 4BC6D6702E0C810F00967DB4 /* ATMacro */, 159 + 4BC6D6722E0C810F00967DB4 /* ATProtoKit */, 160 + 4BCBC0A02E0E0EC1003753C8 /* ATIdentityTools */, 161 + 4BCBC0A22E0E0EC1003753C8 /* DIDCore */, 162 + 4BDB7D1B2E16225900439106 /* Cache */, 163 + 4B9E72D72E25FAC300DC1E77 /* ZIPFoundation */, 164 + ); 165 + productName = shortcut; 166 + productReference = 4BBE4EE82E09F976009F9D3E /* shortcut.app */; 167 + productType = "com.apple.product-type.application"; 168 + }; 169 + 4BBE4EF82E09F977009F9D3E /* shortcutTests */ = { 170 + isa = PBXNativeTarget; 171 + buildConfigurationList = 4BBE4F112E09F977009F9D3E /* Build configuration list for PBXNativeTarget "shortcutTests" */; 172 + buildPhases = ( 173 + 4BBE4EF52E09F977009F9D3E /* Sources */, 174 + 4BBE4EF62E09F977009F9D3E /* Frameworks */, 175 + 4BBE4EF72E09F977009F9D3E /* Resources */, 176 + ); 177 + buildRules = ( 178 + ); 179 + dependencies = ( 180 + 4BBE4EFB2E09F977009F9D3E /* PBXTargetDependency */, 181 + ); 182 + fileSystemSynchronizedGroups = ( 183 + 4BBE4EFC2E09F977009F9D3E /* shortcutTests */, 184 + ); 185 + name = shortcutTests; 186 + packageProductDependencies = ( 187 + ); 188 + productName = shortcutTests; 189 + productReference = 4BBE4EF92E09F977009F9D3E /* shortcutTests.xctest */; 190 + productType = "com.apple.product-type.bundle.unit-test"; 191 + }; 192 + 4BBE4F022E09F977009F9D3E /* shortcutUITests */ = { 193 + isa = PBXNativeTarget; 194 + buildConfigurationList = 4BBE4F142E09F977009F9D3E /* Build configuration list for PBXNativeTarget "shortcutUITests" */; 195 + buildPhases = ( 196 + 4BBE4EFF2E09F977009F9D3E /* Sources */, 197 + 4BBE4F002E09F977009F9D3E /* Frameworks */, 198 + 4BBE4F012E09F977009F9D3E /* Resources */, 199 + ); 200 + buildRules = ( 201 + ); 202 + dependencies = ( 203 + 4BBE4F052E09F977009F9D3E /* PBXTargetDependency */, 204 + ); 205 + fileSystemSynchronizedGroups = ( 206 + 4BBE4F062E09F977009F9D3E /* shortcutUITests */, 207 + ); 208 + name = shortcutUITests; 209 + packageProductDependencies = ( 210 + ); 211 + productName = shortcutUITests; 212 + productReference = 4BBE4F032E09F977009F9D3E /* shortcutUITests.xctest */; 213 + productType = "com.apple.product-type.bundle.ui-testing"; 214 + }; 215 + /* End PBXNativeTarget section */ 216 + 217 + /* Begin PBXProject section */ 218 + 4BBE4EE02E09F975009F9D3E /* Project object */ = { 219 + isa = PBXProject; 220 + attributes = { 221 + BuildIndependentTargetsInParallel = 1; 222 + LastSwiftUpdateCheck = 1640; 223 + LastUpgradeCheck = 1640; 224 + TargetAttributes = { 225 + 4BBE4EE72E09F975009F9D3E = { 226 + CreatedOnToolsVersion = 16.4; 227 + }; 228 + 4BBE4EF82E09F977009F9D3E = { 229 + CreatedOnToolsVersion = 16.4; 230 + TestTargetID = 4BBE4EE72E09F975009F9D3E; 231 + }; 232 + 4BBE4F022E09F977009F9D3E = { 233 + CreatedOnToolsVersion = 16.4; 234 + TestTargetID = 4BBE4EE72E09F975009F9D3E; 235 + }; 236 + }; 237 + }; 238 + buildConfigurationList = 4BBE4EE32E09F975009F9D3E /* Build configuration list for PBXProject "at_toolbox" */; 239 + developmentRegion = en; 240 + hasScannedForEncodings = 0; 241 + knownRegions = ( 242 + en, 243 + Base, 244 + ); 245 + mainGroup = 4BBE4EDF2E09F975009F9D3E; 246 + minimizedProjectReferenceProxies = 1; 247 + packageReferences = ( 248 + 4BC6D66F2E0C810F00967DB4 /* XCLocalSwiftPackageReference "../ATProtoKit" */, 249 + 4BCBC09F2E0E0EC1003753C8 /* XCLocalSwiftPackageReference "../ATIdentityTools" */, 250 + 4BDB7D1A2E16225900439106 /* XCRemoteSwiftPackageReference "Cache" */, 251 + 4B9E72D62E25FAC300DC1E77 /* XCRemoteSwiftPackageReference "ZIPFoundation" */, 252 + ); 253 + preferredProjectObjectVersion = 77; 254 + productRefGroup = 4BBE4EE92E09F976009F9D3E /* Products */; 255 + projectDirPath = ""; 256 + projectRoot = ""; 257 + targets = ( 258 + 4BBE4EE72E09F975009F9D3E /* shortcut */, 259 + 4BBE4EF82E09F977009F9D3E /* shortcutTests */, 260 + 4BBE4F022E09F977009F9D3E /* shortcutUITests */, 261 + ); 262 + }; 263 + /* End PBXProject section */ 264 + 265 + /* Begin PBXResourcesBuildPhase section */ 266 + 4BBE4EE62E09F975009F9D3E /* Resources */ = { 267 + isa = PBXResourcesBuildPhase; 268 + buildActionMask = 2147483647; 269 + files = ( 270 + 4B9E72CB2E255D7600DC1E77 /* AT Toolbox.storekit in Resources */, 271 + ); 272 + runOnlyForDeploymentPostprocessing = 0; 273 + }; 274 + 4BBE4EF72E09F977009F9D3E /* Resources */ = { 275 + isa = PBXResourcesBuildPhase; 276 + buildActionMask = 2147483647; 277 + files = ( 278 + ); 279 + runOnlyForDeploymentPostprocessing = 0; 280 + }; 281 + 4BBE4F012E09F977009F9D3E /* Resources */ = { 282 + isa = PBXResourcesBuildPhase; 283 + buildActionMask = 2147483647; 284 + files = ( 285 + ); 286 + runOnlyForDeploymentPostprocessing = 0; 287 + }; 288 + /* End PBXResourcesBuildPhase section */ 289 + 290 + /* Begin PBXSourcesBuildPhase section */ 291 + 4BBE4EE42E09F975009F9D3E /* Sources */ = { 292 + isa = PBXSourcesBuildPhase; 293 + buildActionMask = 2147483647; 294 + files = ( 295 + ); 296 + runOnlyForDeploymentPostprocessing = 0; 297 + }; 298 + 4BBE4EF52E09F977009F9D3E /* Sources */ = { 299 + isa = PBXSourcesBuildPhase; 300 + buildActionMask = 2147483647; 301 + files = ( 302 + ); 303 + runOnlyForDeploymentPostprocessing = 0; 304 + }; 305 + 4BBE4EFF2E09F977009F9D3E /* Sources */ = { 306 + isa = PBXSourcesBuildPhase; 307 + buildActionMask = 2147483647; 308 + files = ( 309 + ); 310 + runOnlyForDeploymentPostprocessing = 0; 311 + }; 312 + /* End PBXSourcesBuildPhase section */ 313 + 314 + /* Begin PBXTargetDependency section */ 315 + 4BBE4EFB2E09F977009F9D3E /* PBXTargetDependency */ = { 316 + isa = PBXTargetDependency; 317 + target = 4BBE4EE72E09F975009F9D3E /* shortcut */; 318 + targetProxy = 4BBE4EFA2E09F977009F9D3E /* PBXContainerItemProxy */; 319 + }; 320 + 4BBE4F052E09F977009F9D3E /* PBXTargetDependency */ = { 321 + isa = PBXTargetDependency; 322 + target = 4BBE4EE72E09F975009F9D3E /* shortcut */; 323 + targetProxy = 4BBE4F042E09F977009F9D3E /* PBXContainerItemProxy */; 324 + }; 325 + /* End PBXTargetDependency section */ 326 + 327 + /* Begin XCBuildConfiguration section */ 328 + 4BBE4F0D2E09F977009F9D3E /* Debug */ = { 329 + isa = XCBuildConfiguration; 330 + buildSettings = { 331 + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 332 + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 333 + CODE_SIGN_ENTITLEMENTS = shortcut/shortcut.entitlements; 334 + CODE_SIGN_STYLE = Automatic; 335 + CURRENT_PROJECT_VERSION = 2; 336 + DEVELOPMENT_TEAM = HJ9PCD3YLH; 337 + ENABLE_PREVIEWS = YES; 338 + GENERATE_INFOPLIST_FILE = YES; 339 + INFOPLIST_FILE = shortcut/Info.plist; 340 + INFOPLIST_KEY_CFBundleDisplayName = "AT Toolbox"; 341 + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 342 + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 343 + INFOPLIST_KEY_UILaunchScreen_Generation = YES; 344 + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 345 + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 346 + LD_RUNPATH_SEARCH_PATHS = ( 347 + "$(inherited)", 348 + "@executable_path/Frameworks", 349 + ); 350 + MARKETING_VERSION = 1.0.4; 351 + PRODUCT_BUNDLE_IDENTIFIER = dev.baileytownsend.attoolbox; 352 + PRODUCT_NAME = "$(TARGET_NAME)"; 353 + SWIFT_EMIT_LOC_STRINGS = YES; 354 + SWIFT_VERSION = 5.0; 355 + TARGETED_DEVICE_FAMILY = "1,2"; 356 + }; 357 + name = Debug; 358 + }; 359 + 4BBE4F0E2E09F977009F9D3E /* Release */ = { 360 + isa = XCBuildConfiguration; 361 + buildSettings = { 362 + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 363 + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 364 + CODE_SIGN_ENTITLEMENTS = shortcut/shortcut.entitlements; 365 + CODE_SIGN_STYLE = Automatic; 366 + CURRENT_PROJECT_VERSION = 2; 367 + DEVELOPMENT_TEAM = HJ9PCD3YLH; 368 + ENABLE_PREVIEWS = YES; 369 + GENERATE_INFOPLIST_FILE = YES; 370 + INFOPLIST_FILE = shortcut/Info.plist; 371 + INFOPLIST_KEY_CFBundleDisplayName = "AT Toolbox"; 372 + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 373 + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 374 + INFOPLIST_KEY_UILaunchScreen_Generation = YES; 375 + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 376 + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 377 + LD_RUNPATH_SEARCH_PATHS = ( 378 + "$(inherited)", 379 + "@executable_path/Frameworks", 380 + ); 381 + MARKETING_VERSION = 1.0.4; 382 + PRODUCT_BUNDLE_IDENTIFIER = dev.baileytownsend.attoolbox; 383 + PRODUCT_NAME = "$(TARGET_NAME)"; 384 + SWIFT_EMIT_LOC_STRINGS = YES; 385 + SWIFT_VERSION = 5.0; 386 + TARGETED_DEVICE_FAMILY = "1,2"; 387 + }; 388 + name = Release; 389 + }; 390 + 4BBE4F0F2E09F977009F9D3E /* Debug */ = { 391 + isa = XCBuildConfiguration; 392 + buildSettings = { 393 + ALWAYS_SEARCH_USER_PATHS = NO; 394 + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 395 + CLANG_ANALYZER_NONNULL = YES; 396 + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 397 + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 398 + CLANG_ENABLE_MODULES = YES; 399 + CLANG_ENABLE_OBJC_ARC = YES; 400 + CLANG_ENABLE_OBJC_WEAK = YES; 401 + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 402 + CLANG_WARN_BOOL_CONVERSION = YES; 403 + CLANG_WARN_COMMA = YES; 404 + CLANG_WARN_CONSTANT_CONVERSION = YES; 405 + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 406 + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 407 + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 408 + CLANG_WARN_EMPTY_BODY = YES; 409 + CLANG_WARN_ENUM_CONVERSION = YES; 410 + CLANG_WARN_INFINITE_RECURSION = YES; 411 + CLANG_WARN_INT_CONVERSION = YES; 412 + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 413 + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 414 + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 415 + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 416 + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 417 + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 418 + CLANG_WARN_STRICT_PROTOTYPES = YES; 419 + CLANG_WARN_SUSPICIOUS_MOVE = YES; 420 + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 421 + CLANG_WARN_UNREACHABLE_CODE = YES; 422 + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 423 + COPY_PHASE_STRIP = NO; 424 + DEBUG_INFORMATION_FORMAT = dwarf; 425 + DEVELOPMENT_TEAM = HJ9PCD3YLH; 426 + ENABLE_STRICT_OBJC_MSGSEND = YES; 427 + ENABLE_TESTABILITY = YES; 428 + ENABLE_USER_SCRIPT_SANDBOXING = YES; 429 + GCC_C_LANGUAGE_STANDARD = gnu17; 430 + GCC_DYNAMIC_NO_PIC = NO; 431 + GCC_NO_COMMON_BLOCKS = YES; 432 + GCC_OPTIMIZATION_LEVEL = 0; 433 + GCC_PREPROCESSOR_DEFINITIONS = ( 434 + "DEBUG=1", 435 + "$(inherited)", 436 + ); 437 + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 438 + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 439 + GCC_WARN_UNDECLARED_SELECTOR = YES; 440 + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 441 + GCC_WARN_UNUSED_FUNCTION = YES; 442 + GCC_WARN_UNUSED_VARIABLE = YES; 443 + IPHONEOS_DEPLOYMENT_TARGET = 18.5; 444 + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 445 + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 446 + MTL_FAST_MATH = YES; 447 + ONLY_ACTIVE_ARCH = YES; 448 + SDKROOT = iphoneos; 449 + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 450 + SWIFT_EMIT_LOC_STRINGS = YES; 451 + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 452 + }; 453 + name = Debug; 454 + }; 455 + 4BBE4F102E09F977009F9D3E /* Release */ = { 456 + isa = XCBuildConfiguration; 457 + buildSettings = { 458 + ALWAYS_SEARCH_USER_PATHS = NO; 459 + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 460 + CLANG_ANALYZER_NONNULL = YES; 461 + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 462 + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 463 + CLANG_ENABLE_MODULES = YES; 464 + CLANG_ENABLE_OBJC_ARC = YES; 465 + CLANG_ENABLE_OBJC_WEAK = YES; 466 + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 467 + CLANG_WARN_BOOL_CONVERSION = YES; 468 + CLANG_WARN_COMMA = YES; 469 + CLANG_WARN_CONSTANT_CONVERSION = YES; 470 + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 471 + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 472 + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 473 + CLANG_WARN_EMPTY_BODY = YES; 474 + CLANG_WARN_ENUM_CONVERSION = YES; 475 + CLANG_WARN_INFINITE_RECURSION = YES; 476 + CLANG_WARN_INT_CONVERSION = YES; 477 + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 478 + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 479 + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 480 + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 481 + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 482 + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 483 + CLANG_WARN_STRICT_PROTOTYPES = YES; 484 + CLANG_WARN_SUSPICIOUS_MOVE = YES; 485 + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 486 + CLANG_WARN_UNREACHABLE_CODE = YES; 487 + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 488 + COPY_PHASE_STRIP = NO; 489 + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 490 + DEVELOPMENT_TEAM = HJ9PCD3YLH; 491 + ENABLE_NS_ASSERTIONS = NO; 492 + ENABLE_STRICT_OBJC_MSGSEND = YES; 493 + ENABLE_USER_SCRIPT_SANDBOXING = YES; 494 + GCC_C_LANGUAGE_STANDARD = gnu17; 495 + GCC_NO_COMMON_BLOCKS = YES; 496 + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 497 + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 498 + GCC_WARN_UNDECLARED_SELECTOR = YES; 499 + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 500 + GCC_WARN_UNUSED_FUNCTION = YES; 501 + GCC_WARN_UNUSED_VARIABLE = YES; 502 + IPHONEOS_DEPLOYMENT_TARGET = 18.5; 503 + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 504 + MTL_ENABLE_DEBUG_INFO = NO; 505 + MTL_FAST_MATH = YES; 506 + SDKROOT = iphoneos; 507 + SWIFT_COMPILATION_MODE = wholemodule; 508 + SWIFT_EMIT_LOC_STRINGS = YES; 509 + VALIDATE_PRODUCT = YES; 510 + }; 511 + name = Release; 512 + }; 513 + 4BBE4F122E09F977009F9D3E /* Debug */ = { 514 + isa = XCBuildConfiguration; 515 + buildSettings = { 516 + BUNDLE_LOADER = "$(TEST_HOST)"; 517 + CODE_SIGN_STYLE = Automatic; 518 + CURRENT_PROJECT_VERSION = 1; 519 + DEVELOPMENT_TEAM = HJ9PCD3YLH; 520 + GENERATE_INFOPLIST_FILE = YES; 521 + IPHONEOS_DEPLOYMENT_TARGET = 18.5; 522 + MARKETING_VERSION = 1.0; 523 + PRODUCT_BUNDLE_IDENTIFIER = dev.baileytownsend.shortcutTests; 524 + PRODUCT_NAME = "$(TARGET_NAME)"; 525 + SWIFT_EMIT_LOC_STRINGS = NO; 526 + SWIFT_VERSION = 5.0; 527 + TARGETED_DEVICE_FAMILY = "1,2"; 528 + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/shortcut.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/shortcut"; 529 + }; 530 + name = Debug; 531 + }; 532 + 4BBE4F132E09F977009F9D3E /* Release */ = { 533 + isa = XCBuildConfiguration; 534 + buildSettings = { 535 + BUNDLE_LOADER = "$(TEST_HOST)"; 536 + CODE_SIGN_STYLE = Automatic; 537 + CURRENT_PROJECT_VERSION = 1; 538 + DEVELOPMENT_TEAM = HJ9PCD3YLH; 539 + GENERATE_INFOPLIST_FILE = YES; 540 + IPHONEOS_DEPLOYMENT_TARGET = 18.5; 541 + MARKETING_VERSION = 1.0; 542 + PRODUCT_BUNDLE_IDENTIFIER = dev.baileytownsend.shortcutTests; 543 + PRODUCT_NAME = "$(TARGET_NAME)"; 544 + SWIFT_EMIT_LOC_STRINGS = NO; 545 + SWIFT_VERSION = 5.0; 546 + TARGETED_DEVICE_FAMILY = "1,2"; 547 + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/shortcut.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/shortcut"; 548 + }; 549 + name = Release; 550 + }; 551 + 4BBE4F152E09F977009F9D3E /* Debug */ = { 552 + isa = XCBuildConfiguration; 553 + buildSettings = { 554 + CODE_SIGN_STYLE = Automatic; 555 + CURRENT_PROJECT_VERSION = 1; 556 + DEVELOPMENT_TEAM = HJ9PCD3YLH; 557 + GENERATE_INFOPLIST_FILE = YES; 558 + MARKETING_VERSION = 1.0; 559 + PRODUCT_BUNDLE_IDENTIFIER = dev.baileytownsend.shortcutUITests; 560 + PRODUCT_NAME = "$(TARGET_NAME)"; 561 + SWIFT_EMIT_LOC_STRINGS = NO; 562 + SWIFT_VERSION = 5.0; 563 + TARGETED_DEVICE_FAMILY = "1,2"; 564 + TEST_TARGET_NAME = shortcut; 565 + }; 566 + name = Debug; 567 + }; 568 + 4BBE4F162E09F977009F9D3E /* Release */ = { 569 + isa = XCBuildConfiguration; 570 + buildSettings = { 571 + CODE_SIGN_STYLE = Automatic; 572 + CURRENT_PROJECT_VERSION = 1; 573 + DEVELOPMENT_TEAM = HJ9PCD3YLH; 574 + GENERATE_INFOPLIST_FILE = YES; 575 + MARKETING_VERSION = 1.0; 576 + PRODUCT_BUNDLE_IDENTIFIER = dev.baileytownsend.shortcutUITests; 577 + PRODUCT_NAME = "$(TARGET_NAME)"; 578 + SWIFT_EMIT_LOC_STRINGS = NO; 579 + SWIFT_VERSION = 5.0; 580 + TARGETED_DEVICE_FAMILY = "1,2"; 581 + TEST_TARGET_NAME = shortcut; 582 + }; 583 + name = Release; 584 + }; 585 + /* End XCBuildConfiguration section */ 586 + 587 + /* Begin XCConfigurationList section */ 588 + 4BBE4EE32E09F975009F9D3E /* Build configuration list for PBXProject "at_toolbox" */ = { 589 + isa = XCConfigurationList; 590 + buildConfigurations = ( 591 + 4BBE4F0F2E09F977009F9D3E /* Debug */, 592 + 4BBE4F102E09F977009F9D3E /* Release */, 593 + ); 594 + defaultConfigurationIsVisible = 0; 595 + defaultConfigurationName = Release; 596 + }; 597 + 4BBE4F0C2E09F977009F9D3E /* Build configuration list for PBXNativeTarget "shortcut" */ = { 598 + isa = XCConfigurationList; 599 + buildConfigurations = ( 600 + 4BBE4F0D2E09F977009F9D3E /* Debug */, 601 + 4BBE4F0E2E09F977009F9D3E /* Release */, 602 + ); 603 + defaultConfigurationIsVisible = 0; 604 + defaultConfigurationName = Release; 605 + }; 606 + 4BBE4F112E09F977009F9D3E /* Build configuration list for PBXNativeTarget "shortcutTests" */ = { 607 + isa = XCConfigurationList; 608 + buildConfigurations = ( 609 + 4BBE4F122E09F977009F9D3E /* Debug */, 610 + 4BBE4F132E09F977009F9D3E /* Release */, 611 + ); 612 + defaultConfigurationIsVisible = 0; 613 + defaultConfigurationName = Release; 614 + }; 615 + 4BBE4F142E09F977009F9D3E /* Build configuration list for PBXNativeTarget "shortcutUITests" */ = { 616 + isa = XCConfigurationList; 617 + buildConfigurations = ( 618 + 4BBE4F152E09F977009F9D3E /* Debug */, 619 + 4BBE4F162E09F977009F9D3E /* Release */, 620 + ); 621 + defaultConfigurationIsVisible = 0; 622 + defaultConfigurationName = Release; 623 + }; 624 + /* End XCConfigurationList section */ 625 + 626 + /* Begin XCLocalSwiftPackageReference section */ 627 + 4BC6D66F2E0C810F00967DB4 /* XCLocalSwiftPackageReference "../ATProtoKit" */ = { 628 + isa = XCLocalSwiftPackageReference; 629 + relativePath = ../ATProtoKit; 630 + }; 631 + 4BCBC09F2E0E0EC1003753C8 /* XCLocalSwiftPackageReference "../ATIdentityTools" */ = { 632 + isa = XCLocalSwiftPackageReference; 633 + relativePath = ../ATIdentityTools; 634 + }; 635 + /* End XCLocalSwiftPackageReference section */ 636 + 637 + /* Begin XCRemoteSwiftPackageReference section */ 638 + 4B9E72D62E25FAC300DC1E77 /* XCRemoteSwiftPackageReference "ZIPFoundation" */ = { 639 + isa = XCRemoteSwiftPackageReference; 640 + repositoryURL = "https://github.com/weichsel/ZIPFoundation.git"; 641 + requirement = { 642 + kind = upToNextMajorVersion; 643 + minimumVersion = 0.9.19; 644 + }; 645 + }; 646 + 4BDB7D1A2E16225900439106 /* XCRemoteSwiftPackageReference "Cache" */ = { 647 + isa = XCRemoteSwiftPackageReference; 648 + repositoryURL = "https://github.com/hyperoslo/Cache.git"; 649 + requirement = { 650 + kind = upToNextMajorVersion; 651 + minimumVersion = 7.4.0; 652 + }; 653 + }; 654 + /* End XCRemoteSwiftPackageReference section */ 655 + 656 + /* Begin XCSwiftPackageProductDependency section */ 657 + 4B9E72D72E25FAC300DC1E77 /* ZIPFoundation */ = { 658 + isa = XCSwiftPackageProductDependency; 659 + package = 4B9E72D62E25FAC300DC1E77 /* XCRemoteSwiftPackageReference "ZIPFoundation" */; 660 + productName = ZIPFoundation; 661 + }; 662 + 4BC6D6702E0C810F00967DB4 /* ATMacro */ = { 663 + isa = XCSwiftPackageProductDependency; 664 + productName = ATMacro; 665 + }; 666 + 4BC6D6722E0C810F00967DB4 /* ATProtoKit */ = { 667 + isa = XCSwiftPackageProductDependency; 668 + productName = ATProtoKit; 669 + }; 670 + 4BCBC0A02E0E0EC1003753C8 /* ATIdentityTools */ = { 671 + isa = XCSwiftPackageProductDependency; 672 + productName = ATIdentityTools; 673 + }; 674 + 4BCBC0A22E0E0EC1003753C8 /* DIDCore */ = { 675 + isa = XCSwiftPackageProductDependency; 676 + productName = DIDCore; 677 + }; 678 + 4BDB7D1B2E16225900439106 /* Cache */ = { 679 + isa = XCSwiftPackageProductDependency; 680 + package = 4BDB7D1A2E16225900439106 /* XCRemoteSwiftPackageReference "Cache" */; 681 + productName = Cache; 682 + }; 683 + /* End XCSwiftPackageProductDependency section */ 684 + }; 685 + rootObject = 4BBE4EE02E09F975009F9D3E /* Project object */; 686 + }
+7
at_toolbox.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>
+123
at_toolbox.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
···
··· 1 + { 2 + "originHash" : "a5fef376f4b110244116021a9a3613b013c5987eb3b56400cfd863e10bdd3095", 3 + "pins" : [ 4 + { 5 + "identity" : "atcommontools", 6 + "kind" : "remoteSourceControl", 7 + "location" : "https://github.com/ATProtoKit/ATCommonTools.git", 8 + "state" : { 9 + "revision" : "ca5eee7f09202699707cb249d411bbb2c7597986", 10 + "version" : "0.0.13" 11 + } 12 + }, 13 + { 14 + "identity" : "atcryptography", 15 + "kind" : "remoteSourceControl", 16 + "location" : "https://github.com/ATProtoKit/ATCryptography.git", 17 + "state" : { 18 + "revision" : "51137acf84f6da1c3ff41b208733e7e3d4208a0d", 19 + "version" : "0.1.10" 20 + } 21 + }, 22 + { 23 + "identity" : "bigint", 24 + "kind" : "remoteSourceControl", 25 + "location" : "https://github.com/attaswift/BigInt.git", 26 + "state" : { 27 + "revision" : "99c4b9fb0f52dc9182aee106b07c3d205583b98c", 28 + "version" : "5.6.0" 29 + } 30 + }, 31 + { 32 + "identity" : "cache", 33 + "kind" : "remoteSourceControl", 34 + "location" : "https://github.com/hyperoslo/Cache.git", 35 + "state" : { 36 + "revision" : "24e47109e31b2031cb26e25cc1b81b607496066c", 37 + "version" : "7.4.0" 38 + } 39 + }, 40 + { 41 + "identity" : "multiformatskit", 42 + "kind" : "remoteSourceControl", 43 + "location" : "https://github.com/ATProtoKit/MultiformatsKit.git", 44 + "state" : { 45 + "revision" : "e03ab44983ae3cf525a3f8df8d1640f819385926", 46 + "version" : "0.3.0" 47 + } 48 + }, 49 + { 50 + "identity" : "swift-asn1", 51 + "kind" : "remoteSourceControl", 52 + "location" : "https://github.com/apple/swift-asn1.git", 53 + "state" : { 54 + "revision" : "a54383ada6cecde007d374f58f864e29370ba5c3", 55 + "version" : "1.3.2" 56 + } 57 + }, 58 + { 59 + "identity" : "swift-async-dns-resolver", 60 + "kind" : "remoteSourceControl", 61 + "location" : "https://github.com/apple/swift-async-dns-resolver", 62 + "state" : { 63 + "revision" : "08c07ff31a745ee5e522ac10132fb4949834d925", 64 + "version" : "0.4.0" 65 + } 66 + }, 67 + { 68 + "identity" : "swift-cbor", 69 + "kind" : "remoteSourceControl", 70 + "location" : "https://github.com/MasterJ93/swift-cbor.git", 71 + "state" : { 72 + "revision" : "640a30c0651d734f21b800b221c60feee6382e69", 73 + "version" : "0.0.5" 74 + } 75 + }, 76 + { 77 + "identity" : "swift-crypto", 78 + "kind" : "remoteSourceControl", 79 + "location" : "https://github.com/apple/swift-crypto.git", 80 + "state" : { 81 + "revision" : "e8d6eba1fef23ae5b359c46b03f7d94be2f41fed", 82 + "version" : "3.12.3" 83 + } 84 + }, 85 + { 86 + "identity" : "swift-log", 87 + "kind" : "remoteSourceControl", 88 + "location" : "https://github.com/apple/swift-log.git", 89 + "state" : { 90 + "revision" : "3d8596ed08bd13520157f0355e35caed215ffbfa", 91 + "version" : "1.6.3" 92 + } 93 + }, 94 + { 95 + "identity" : "swift-secp256k1", 96 + "kind" : "remoteSourceControl", 97 + "location" : "https://github.com/21-DOT-DEV/swift-secp256k1.git", 98 + "state" : { 99 + "revision" : "57ce9af6db14e0114af631ace25231a9d0ccccbd", 100 + "version" : "0.18.0" 101 + } 102 + }, 103 + { 104 + "identity" : "swift-syntax", 105 + "kind" : "remoteSourceControl", 106 + "location" : "https://github.com/swiftlang/swift-syntax.git", 107 + "state" : { 108 + "revision" : "0687f71944021d616d34d922343dcef086855920", 109 + "version" : "600.0.1" 110 + } 111 + }, 112 + { 113 + "identity" : "zipfoundation", 114 + "kind" : "remoteSourceControl", 115 + "location" : "https://github.com/weichsel/ZIPFoundation.git", 116 + "state" : { 117 + "revision" : "02b6abe5f6eef7e3cbd5f247c5cc24e246efcfe0", 118 + "version" : "0.9.19" 119 + } 120 + } 121 + ], 122 + "version" : 3 123 + }
+105
at_toolbox.xcodeproj/xcshareddata/xcschemes/shortcut.xcscheme
···
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <Scheme 3 + LastUpgradeVersion = "1640" 4 + version = "1.7"> 5 + <BuildAction 6 + parallelizeBuildables = "YES" 7 + buildImplicitDependencies = "YES" 8 + buildArchitectures = "Automatic"> 9 + <BuildActionEntries> 10 + <BuildActionEntry 11 + buildForTesting = "YES" 12 + buildForRunning = "YES" 13 + buildForProfiling = "YES" 14 + buildForArchiving = "YES" 15 + buildForAnalyzing = "YES"> 16 + <BuildableReference 17 + BuildableIdentifier = "primary" 18 + BlueprintIdentifier = "4BBE4EE72E09F975009F9D3E" 19 + BuildableName = "shortcut.app" 20 + BlueprintName = "shortcut" 21 + ReferencedContainer = "container:at_toolbox.xcodeproj"> 22 + </BuildableReference> 23 + </BuildActionEntry> 24 + </BuildActionEntries> 25 + </BuildAction> 26 + <TestAction 27 + buildConfiguration = "Debug" 28 + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" 29 + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" 30 + shouldUseLaunchSchemeArgsEnv = "YES" 31 + shouldAutocreateTestPlan = "YES"> 32 + <Testables> 33 + <TestableReference 34 + skipped = "NO" 35 + parallelizable = "YES"> 36 + <BuildableReference 37 + BuildableIdentifier = "primary" 38 + BlueprintIdentifier = "4BBE4EF82E09F977009F9D3E" 39 + BuildableName = "shortcutTests.xctest" 40 + BlueprintName = "shortcutTests" 41 + ReferencedContainer = "container:at_toolbox.xcodeproj"> 42 + </BuildableReference> 43 + </TestableReference> 44 + <TestableReference 45 + skipped = "NO" 46 + parallelizable = "YES"> 47 + <BuildableReference 48 + BuildableIdentifier = "primary" 49 + BlueprintIdentifier = "4BBE4F022E09F977009F9D3E" 50 + BuildableName = "shortcutUITests.xctest" 51 + BlueprintName = "shortcutUITests" 52 + ReferencedContainer = "container:at_toolbox.xcodeproj"> 53 + </BuildableReference> 54 + </TestableReference> 55 + </Testables> 56 + </TestAction> 57 + <LaunchAction 58 + buildConfiguration = "Debug" 59 + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" 60 + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" 61 + launchStyle = "0" 62 + useCustomWorkingDirectory = "NO" 63 + ignoresPersistentStateOnLaunch = "NO" 64 + debugDocumentVersioning = "YES" 65 + debugServiceExtension = "internal" 66 + allowLocationSimulation = "YES"> 67 + <BuildableProductRunnable 68 + runnableDebuggingMode = "0"> 69 + <BuildableReference 70 + BuildableIdentifier = "primary" 71 + BlueprintIdentifier = "4BBE4EE72E09F975009F9D3E" 72 + BuildableName = "shortcut.app" 73 + BlueprintName = "shortcut" 74 + ReferencedContainer = "container:at_toolbox.xcodeproj"> 75 + </BuildableReference> 76 + </BuildableProductRunnable> 77 + <StoreKitConfigurationFileReference 78 + identifier = "../../AT Toolbox.storekit"> 79 + </StoreKitConfigurationFileReference> 80 + </LaunchAction> 81 + <ProfileAction 82 + buildConfiguration = "Release" 83 + shouldUseLaunchSchemeArgsEnv = "YES" 84 + savedToolIdentifier = "" 85 + useCustomWorkingDirectory = "NO" 86 + debugDocumentVersioning = "YES"> 87 + <BuildableProductRunnable 88 + runnableDebuggingMode = "0"> 89 + <BuildableReference 90 + BuildableIdentifier = "primary" 91 + BlueprintIdentifier = "4BBE4EE72E09F975009F9D3E" 92 + BuildableName = "shortcut.app" 93 + BlueprintName = "shortcut" 94 + ReferencedContainer = "container:at_toolbox.xcodeproj"> 95 + </BuildableReference> 96 + </BuildableProductRunnable> 97 + </ProfileAction> 98 + <AnalyzeAction 99 + buildConfiguration = "Debug"> 100 + </AnalyzeAction> 101 + <ArchiveAction 102 + buildConfiguration = "Release" 103 + revealArchiveInOrganizer = "YES"> 104 + </ArchiveAction> 105 + </Scheme>
icon.xcf

This is a binary file and will not be displayed.

+4
shortcut.xcodeproj/project.xcworkspace/contents.xcworkspacedata
···
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <Workspace 3 + version = "1.0"> 4 + </Workspace>
+526
shortcut/ATProtoKit/BlobDownloader.swift
···
··· 1 + // 2 + // BlobDownloader.swift 3 + // shortcut 4 + // 5 + // Created by Bailey Townsend on 7/14/25. 6 + // 7 + 8 + import ATProtoKit 9 + import Foundation 10 + import UniformTypeIdentifiers 11 + import ZIPFoundation 12 + 13 + // Progress event types 14 + public enum DownloadProgress { 15 + case started(totalCIDs: Int) 16 + case progressUpdate(currentCount: Int, totalCount: Int) 17 + case completed(totalDownloaded: Int, zipURL: URL) 18 + case failed(error: Error) 19 + } 20 + 21 + public actor BlobDownloader { 22 + private var maxConcurrentDownloads: Int = 1 23 + private var activeTasks = 0 24 + private var continuation: CheckedContinuation<Void, Never>? 25 + // private var atProtocolManger: AtProtocolManager 26 + 27 + // Add these for task management 28 + private var activeDownloadTasks: Set<Task<URL?, Error>> = [] 29 + private var currentDownloadTask: Task<[URL], Error>? 30 + 31 + // Progress tracking 32 + // private var progressContinuation: AsyncStream<DownloadProgress>.Continuation? 33 + 34 + // Actor for thread-safe archive writes 35 + 36 + // init(maxConcurrentDownloads: Int = 2) { 37 + // self.maxConcurrentDownloads = maxConcurrentDownloads 38 + // } 39 + 40 + private func waitForAvailableSlot() async { 41 + while activeTasks >= maxConcurrentDownloads { 42 + await withCheckedContinuation { cont in 43 + continuation = cont 44 + } 45 + } 46 + activeTasks += 1 47 + } 48 + 49 + private func releaseSlot() { 50 + activeTasks -= 1 51 + if let cont = continuation { 52 + continuation = nil 53 + cont.resume() 54 + } 55 + } 56 + 57 + // Modified downloadBlobs method with proper cleanup 58 + func downloadBlobs( 59 + repo: String, 60 + pdsURL: String, 61 + cids: [String], 62 + saveLocationBookmark: Data? = nil, 63 + maxConcurrentDownloads: Int = 1 64 + ) async throws -> [URL] { 65 + self.maxConcurrentDownloads = maxConcurrentDownloads 66 + // Cancel any existing download task 67 + cancelAllActiveTasks() 68 + 69 + // Create new download task and store reference 70 + 71 + // guard let self = self else { throw BlobDownloadError.noData } 72 + 73 + // Check for cancellation at the start 74 + try Task.checkCancellation() 75 + 76 + do { 77 + var totalProcessed = 0 78 + var totalCIDs = 0 79 + var successfulDownloads = 0 80 + 81 + var saveLocation: URL 82 + if let override = saveLocationBookmark { 83 + var isStale = false 84 + guard 85 + let saveUrl = try? URL( 86 + resolvingBookmarkData: override, 87 + options: .withoutUI, 88 + relativeTo: nil, 89 + bookmarkDataIsStale: &isStale 90 + ) 91 + else { 92 + throw GenericIntentError.message("Failed to resolve bookmark data") 93 + } 94 + saveLocation = saveUrl 95 + } else { 96 + let tempDirectory = FileManager.default.temporaryDirectory 97 + saveLocation = tempDirectory.appendingPathComponent(repo) 98 + 99 + do { 100 + try FileManager.default.createDirectory( 101 + at: saveLocation, withIntermediateDirectories: true, attributes: nil) 102 + } catch CocoaError.fileWriteFileExists { 103 + print("Folder already exists at: \(saveLocation.path)") 104 + } catch { 105 + throw error 106 + } 107 + } 108 + 109 + // Check for cancellation before processing 110 + // try Task.checkCancellation() 111 + if Task.isCancelled { 112 + throw CancellationError() 113 + } 114 + 115 + totalCIDs += cids.count 116 + var urls = [URL]() 117 + let chunkSize = maxConcurrentDownloads 118 + 119 + for chunk in cids.chunked(into: chunkSize) { 120 + // Check for cancellation before each chunk 121 + try Task.checkCancellation() 122 + 123 + let newUrls = try await downloadBlobsConcurrently( 124 + repo: repo, 125 + pdsURL: pdsURL, 126 + cids: chunk, 127 + fileManager: FileManager.default, 128 + saveToDirectory: saveLocation 129 + ) 130 + urls.append(contentsOf: newUrls) 131 + totalProcessed += chunk.count 132 + successfulDownloads += chunk.count 133 + 134 + if Task.isCancelled { 135 + print("Download cancelled early") 136 + } 137 + print("Downloaded \(totalProcessed) of \(totalCIDs) blobs") 138 + } 139 + 140 + return urls 141 + 142 + } catch { 143 + // Clean up on any error (including cancellation) 144 + self.cancelAllActiveTasks() 145 + throw error 146 + } 147 + 148 + // Store the task reference 149 + // currentDownloadTask = downloadTask 150 + // 151 + // defer { 152 + // // Clean up task reference when done 153 + // Task { [weak self] in 154 + // await self?.clearCurrentTask() 155 + // } 156 + // } 157 + 158 + // return try await downloadTask.value 159 + } 160 + 161 + public func CancelAll() { 162 + self.cancelAllActiveTasks() 163 + } 164 + 165 + // Method to cancel all active tasks 166 + private func cancelAllActiveTasks() { 167 + print("Cancelling all active download tasks...") 168 + 169 + // Cancel all individual download tasks 170 + for task in activeDownloadTasks { 171 + task.cancel() 172 + } 173 + activeDownloadTasks.removeAll() 174 + 175 + // Cancel current download task 176 + currentDownloadTask?.cancel() 177 + currentDownloadTask = nil 178 + 179 + // Reset active task counter 180 + activeTasks = 0 181 + 182 + // Resume any waiting continuations 183 + if let cont = continuation { 184 + continuation = nil 185 + cont.resume() 186 + } 187 + } 188 + 189 + private func clearCurrentTask() { 190 + currentDownloadTask = nil 191 + } 192 + 193 + // Add task tracking to downloadBlobsConcurrently 194 + private func downloadBlobsConcurrently( 195 + repo: String, 196 + pdsURL: String, 197 + cids: [String], 198 + fileManager: FileManager, 199 + saveToDirectory: URL 200 + ) async throws -> [URL] { 201 + var successCount = 0 202 + var urls: [URL] = [] 203 + 204 + try await withThrowingTaskGroup(of: URL?.self) { group in 205 + for cid in cids { 206 + // Check for cancellation before adding each task 207 + try Task.checkCancellation() 208 + 209 + group.addTask { [weak self] in 210 + // Create a cancellable task and track it 211 + let downloadTask = Task<URL?, Error> { 212 + do { 213 + // Check for cancellation at start of each download 214 + try Task.checkCancellation() 215 + 216 + let checkForUrl = findFile( 217 + withBaseName: cid, inDirectory: saveToDirectory) 218 + if let checkForUrl { 219 + return checkForUrl 220 + } 221 + 222 + let url = try await self?.downloadBlobWithRetry( 223 + repo: repo, 224 + pdsURL: pdsURL, 225 + cid: cid, 226 + maxRetries: 3, 227 + fileManger: fileManager, 228 + saveToDirectory: saveToDirectory 229 + ) 230 + return url 231 + } catch let downloadError as BlobDownloadError { 232 + throw downloadError 233 + 234 + } catch { 235 + if error is CancellationError { 236 + print("Download cancelled for blob \(cid)") 237 + throw error 238 + } else { 239 + print("Failed to download blob \(cid): \(error)") 240 + } 241 + return nil 242 + } 243 + } 244 + 245 + // Track the task 246 + await self?.addActiveTask(downloadTask) 247 + 248 + let result = try await downloadTask.value 249 + 250 + // Remove from tracking when done 251 + await self?.removeActiveTask(downloadTask) 252 + 253 + return result 254 + } 255 + } 256 + 257 + // Wait for all tasks to complete, but check for cancellation 258 + for try await success in group { 259 + try Task.checkCancellation() 260 + if let url = success { 261 + urls.append(url) 262 + successCount += 1 263 + } 264 + } 265 + } 266 + 267 + return urls 268 + } 269 + 270 + // Helper methods for task tracking 271 + private func addActiveTask(_ task: Task<URL?, Error>) { 272 + activeDownloadTasks.insert(task) 273 + } 274 + 275 + private func removeActiveTask(_ task: Task<URL?, Error>) { 276 + activeDownloadTasks.remove(task) 277 + } 278 + 279 + // Public method to cancel downloads from outside 280 + // public func cancelDownloads() async { 281 + // cancelAllActiveTasks() 282 + // } 283 + 284 + // Alternative: Stream large blobs to temporary files 285 + 286 + public func streamBlobToDisk( 287 + resourceURL: URL, 288 + fileManager: FileManager, 289 + saveLocation: URL, 290 + fileName: String 291 + ) async throws -> URL { 292 + try Task.checkCancellation() 293 + 294 + var request = URLRequest(url: resourceURL) 295 + request.httpMethod = "GET" 296 + request.setValue("*/*", forHTTPHeaderField: "Accept") 297 + request.timeoutInterval = 30 298 + 299 + // Download to file instead of memory 300 + let (tempURL, response) = try await URLSession.shared.download(for: request) 301 + 302 + if let httpResponse = response as? HTTPURLResponse { 303 + switch httpResponse.statusCode { 304 + case (200...299): 305 + let mimeType = httpResponse.value(forHTTPHeaderField: "Content-Type") ?? "" 306 + let ending = fileExtension(fromMimeType: mimeType).map { ".\($0)" } ?? "" 307 + let newLocation = saveLocation.appendingPathComponent("\(fileName)\(ending)") 308 + try fileManager.moveItem( 309 + at: tempURL, to: newLocation) 310 + return newLocation 311 + case 400: 312 + let (data, _) = try await URLSession.shared.data(for: request) 313 + 314 + let errorResponse = try JSONDecoder().decode( 315 + ATHTTPResponseError.self, from: data) 316 + throw BlobDownloadError.apiError(error: errorResponse) 317 + 318 + default: 319 + try? FileManager.default.removeItem(at: tempURL) 320 + throw BlobDownloadError.httpError( 321 + statusCode: httpResponse.statusCode) 322 + } 323 + } 324 + throw BlobDownloadError.unknownError 325 + // guard let httpResponse = response as? HTTPURLResponse, 326 + // (200...299).contains(httpResponse.statusCode) 327 + // else { 328 + // try? FileManager.default.removeItem(at: tempURL) 329 + // throw BlobDownloadError.httpError( 330 + // statusCode: (response as? HTTPURLResponse)?.statusCode ?? 0) 331 + // } 332 + 333 + } 334 + 335 + public func getBlob( 336 + from accountDID: String, 337 + cid: String, 338 + fileManager: FileManager, 339 + saveLocation: URL, 340 + pdsURL: String? = nil 341 + ) async throws -> URL { 342 + let baseUrl = pdsURL ?? "https://bsky.network" 343 + 344 + // Construct URL with query parameters 345 + guard var urlComponents = URLComponents(string: "\(baseUrl)/xrpc/com.atproto.sync.getBlob") 346 + else { 347 + throw BlobDownloadError.invalidURL 348 + } 349 + 350 + urlComponents.queryItems = [ 351 + URLQueryItem(name: "did", value: accountDID), 352 + URLQueryItem(name: "cid", value: cid), 353 + ] 354 + 355 + guard let url = urlComponents.url else { 356 + throw BlobDownloadError.invalidURL 357 + } 358 + 359 + return try await streamBlobToDisk( 360 + resourceURL: url, fileManager: fileManager, saveLocation: saveLocation, fileName: cid) 361 + } 362 + 363 + public func getCar( 364 + from accountDID: String, 365 + since: String?, 366 + fileManager: FileManager, 367 + saveLocation: URL, 368 + fileName: String = "repo.car", 369 + pdsURL: String? = nil 370 + ) async throws -> URL { 371 + 372 + let baseUrl = pdsURL ?? "https://bsky.network" 373 + // Only ever one file being downloaded 374 + self.maxConcurrentDownloads = 1 375 + // Construct URL with query parameters 376 + guard var urlComponents = URLComponents(string: "\(baseUrl)/xrpc/com.atproto.sync.getRepo") 377 + else { 378 + throw BlobDownloadError.invalidURL 379 + } 380 + 381 + urlComponents.queryItems = [ 382 + URLQueryItem(name: "did", value: accountDID) 383 + 384 + ] 385 + 386 + if let sinceQuery = since { 387 + urlComponents.queryItems?.append(URLQueryItem(name: "since", value: sinceQuery)) 388 + } 389 + 390 + guard let url = urlComponents.url else { 391 + throw BlobDownloadError.invalidURL 392 + } 393 + 394 + return try await streamBlobToDisk( 395 + resourceURL: url, fileManager: fileManager, saveLocation: saveLocation, 396 + fileName: fileName) 397 + } 398 + 399 + /// Downloads a single blob with retry logic 400 + public func downloadBlobWithRetry( 401 + repo: String, 402 + pdsURL: String, 403 + cid: String, 404 + maxRetries: Int, 405 + fileManger: FileManager, 406 + saveToDirectory: URL 407 + ) async throws -> URL { 408 + var lastError: Error? 409 + 410 + for attempt in 0..<maxRetries { 411 + // Check for cancellation before each retry 412 + try Task.checkCancellation() 413 + 414 + do { 415 + if attempt > 0 { 416 + // Exponential backoff 417 + let delay = UInt64(pow(2.0, Double(attempt)) * 1_000_000_000) 418 + try await Task.sleep(nanoseconds: delay) 419 + } 420 + return try await getBlob( 421 + from: repo, cid: cid, fileManager: fileManger, saveLocation: saveToDirectory, 422 + pdsURL: pdsURL) 423 + } catch let downloadError as BlobDownloadError { 424 + switch downloadError { 425 + case .apiError(_): 426 + throw downloadError 427 + default: 428 + lastError = downloadError 429 + if attempt < maxRetries - 1 { 430 + continue 431 + } 432 + break 433 + } 434 + } catch { 435 + // If it's a cancellation error, don't retry 436 + if error is CancellationError { 437 + throw error 438 + } 439 + 440 + lastError = error 441 + if attempt < maxRetries - 1 { 442 + continue 443 + } 444 + 445 + } 446 + } 447 + 448 + throw lastError 449 + ?? GenericIntentError.message("Failed to download blob after \(maxRetries) attempts") 450 + } 451 + public struct BlobDownloadOutput { 452 + public var data: Data 453 + public var cid: String 454 + public var mimeType: String 455 + } 456 + 457 + // MARK: - Optimized Concurrent Blob Downloader 458 + 459 + public class ConcurrentBlobDownloader { 460 + private let urlSession: URLSession 461 + 462 + public init() { 463 + let config = URLSessionConfiguration.default 464 + config.httpMaximumConnectionsPerHost = 10 465 + config.timeoutIntervalForRequest = 30 466 + config.timeoutIntervalForResource = 300 467 + self.urlSession = URLSession(configuration: config) 468 + } 469 + 470 + } 471 + 472 + } 473 + 474 + public func fileExtension(fromMimeType mimeType: String) -> String? { 475 + guard let utType = UTType(mimeType: mimeType) else { return nil } 476 + return utType.preferredFilenameExtension 477 + } 478 + 479 + // MARK: - Helper Extensions 480 + 481 + extension Array { 482 + /// Splits array into chunks of specified size 483 + func chunked(into size: Int) -> [[Element]] { 484 + return stride(from: 0, to: count, by: size).map { 485 + Array(self[$0..<Swift.min($0 + size, count)]) 486 + } 487 + } 488 + } 489 + 490 + /// An error type related to issues surrounding HTTP responses. 491 + public struct ATHTTPResponseError: Decodable, ATProtoError { 492 + 493 + /// The name of the error. 494 + public let error: String 495 + 496 + /// The message for the error. 497 + public let message: String 498 + } 499 + 500 + func findFile(withBaseName baseName: String, inDirectory directory: URL) -> URL? { 501 + let fileManager = FileManager.default 502 + 503 + do { 504 + let contents = try fileManager.contentsOfDirectory( 505 + at: directory, includingPropertiesForKeys: nil) 506 + 507 + // Find the first file that matches the base name 508 + return contents.first { fileURL in 509 + let fileNameWithoutExtension = fileURL.deletingPathExtension().lastPathComponent 510 + return fileNameWithoutExtension == baseName 511 + } 512 + } catch { 513 + print("Error reading directory: \(error)") 514 + return nil 515 + } 516 + } 517 + 518 + public enum BlobDownloadError: Error { 519 + case invalidURL 520 + case networkError(Error) 521 + case noData 522 + case httpError(statusCode: Int) 523 + case unknownError 524 + case apiError(error: ATHTTPResponseError) 525 + 526 + }
+65
shortcut/ATProtoKit/GetRemoteInfo.swift
···
··· 1 + // 2 + // GetRemoteInfo.swift 3 + // shortcut 4 + // 5 + // Created by Bailey Townsend on 7/2/25. 6 + // 7 + 8 + import Foundation 9 + 10 + struct GetRemoteInfo { 11 + public static func getRemoteRepoInfo(possibleRepo: String, atProtoManager: AtProtocolManager) 12 + async throws 13 + -> RemoteRepoInfo 14 + { 15 + let lowerCaseRepo = possibleRepo.lowercased() 16 + var repo = "" 17 + var pdsUrl = "https://bsky.social" 18 + var handle = "" 19 + 20 + if lowerCaseRepo.hasPrefix("did:") { 21 + do { 22 + let didDoc = try await atProtoManager.resolveDidDocument(did: lowerCaseRepo) 23 + repo = didDoc.id 24 + if let pds = didDoc.getPDSEndpoint() { 25 + pdsUrl = pds.absoluteString 26 + } 27 + if let knownAs = didDoc.alsoKnownAs?.first { 28 + handle = knownAs 29 + } 30 + } catch { 31 + throw GenericIntentError.message("Could not resolve DID document") 32 + } 33 + } else { 34 + let cleanedHandle = lowerCaseRepo.replacingOccurrences(of: "@", with: "") 35 + 36 + do { 37 + let didDoc = try await atProtoManager.resolveDidDocument(handle: cleanedHandle) 38 + repo = didDoc.id 39 + if let pds = didDoc.getPDSEndpoint() { 40 + pdsUrl = pds.absoluteString 41 + } 42 + if let knownAs = didDoc.alsoKnownAs?.first { 43 + handle = knownAs 44 + } 45 + } catch let error as LoginError { 46 + switch error { 47 + case LoginError.handleDoesNotResolve: 48 + throw GenericIntentError.message("Cannot resolve handle to a DID") 49 + case .didDocumentNotFound: 50 + throw GenericIntentError.message("Cannot resolve DID document") 51 + } 52 + } 53 + } 54 + 55 + return RemoteRepoInfo( 56 + repo: repo, pdsURL: pdsUrl, handle: handle.replacingOccurrences(of: "at://", with: "")) 57 + } 58 + 59 + } 60 + 61 + struct RemoteRepoInfo { 62 + let repo: String 63 + let pdsURL: String 64 + let handle: String 65 + }
+146
shortcut/ATProtoKit/IntentFileToImageQuery.swift
···
··· 1 + // 2 + // FileIntentToImageQuery.swift 3 + // shortcut 4 + // 5 + // Created by Bailey Townsend on 6/30/25. 6 + // 7 + 8 + import ATProtoKit 9 + import AppIntents 10 + import CoreGraphics 11 + import Foundation 12 + import ImageIO 13 + import Photos 14 + import UIKit 15 + 16 + public func IntentFileToImageQuery(files: [IntentFile], altText: [String]) async throws 17 + -> [ATProtoTools.ImageQuery] 18 + { 19 + var imageQueries: [ATProtoTools.ImageQuery] = [] 20 + 21 + for (index, image) in files.enumerated() { 22 + 23 + if image.availableContentTypes.contains(.heic) { 24 + let heicImage = try await image.withFile( 25 + contentType: .heic, 26 + fileHandler: { url, openInPlace in 27 + guard 28 + let image = UIImage(contentsOfFile: url.absoluteURL.path()) 29 + else { 30 + throw GenericIntentError.message( 31 + "Failed to load image \(image.filename).") 32 + } 33 + return image 34 + } 35 + ) 36 + let fileName = image.filename 37 + if let jpegData = heicImage.jpegData(compressionQuality: 0.1) { 38 + imageQueries.append( 39 + 40 + ATProtoTools.ImageQuery( 41 + imageData: jpegData, fileName: fileName, 42 + altText: altText[safe: index], 43 + aspectRatio: AppBskyLexicon.Embed.AspectRatioDefinition( 44 + width: Int(heicImage.size.width), 45 + height: Int(heicImage.size.height)) 46 + )) 47 + } 48 + 49 + } else if image.availableContentTypes.contains(.jpeg) { 50 + let jpeg = try await image.withFile( 51 + contentType: .jpeg, 52 + fileHandler: { url, openInPlace in 53 + guard 54 + let image = UIImage(contentsOfFile: url.absoluteURL.path()) 55 + else { 56 + throw GenericIntentError.message( 57 + "Failed to load image \(image.filename).") 58 + } 59 + return image 60 + } 61 + ) 62 + if let jpegData = jpeg.jpegData(compressionQuality: 0.1) { 63 + imageQueries.append( 64 + ATProtoTools.ImageQuery( 65 + imageData: jpegData, fileName: image.filename, 66 + altText: altText[safe: index], 67 + aspectRatio: AppBskyLexicon.Embed.AspectRatioDefinition( 68 + width: Int(jpeg.size.width), height: Int(jpeg.size.height)) 69 + )) 70 + } 71 + 72 + } else if image.availableContentTypes.contains(.png) { 73 + let png = try await image.withFile( 74 + contentType: .png, 75 + fileHandler: { url, openInPlace in 76 + guard 77 + let image = UIImage(contentsOfFile: url.absoluteURL.path()) 78 + else { 79 + throw GenericIntentError.message( 80 + "Failed to load image \(image.filename).") 81 + } 82 + return image 83 + } 84 + ) 85 + 86 + if let jpegData = png.jpegData(compressionQuality: 0.1) { 87 + imageQueries.append( 88 + ATProtoTools.ImageQuery( 89 + imageData: jpegData, fileName: image.filename, 90 + altText: altText[safe: index], 91 + aspectRatio: AppBskyLexicon.Embed.AspectRatioDefinition( 92 + width: Int(png.size.width), height: Int(png.size.height)) 93 + )) 94 + } 95 + 96 + } else { 97 + //Just yolo it 98 + imageQueries.append( 99 + ATProtoTools.ImageQuery( 100 + imageData: image.data, fileName: image.filename, 101 + altText: altText[safe: index], aspectRatio: nil 102 + )) 103 + } 104 + } 105 + return imageQueries 106 + } 107 + 108 + private func extractCaptionFromImageData(_ data: Data) -> String? { 109 + guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil) else { 110 + return nil 111 + } 112 + 113 + guard let metadata = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [String: Any] 114 + else { 115 + return nil 116 + } 117 + 118 + // Check EXIF UserComment 119 + // if let exifDict = metadata[kCGImagePropertyExifDictionary as String] as? [String: Any], 120 + // let userComment = exifDict[kCGImagePropertyExifUserComment as String] as? String 121 + // { 122 + // return userComment.isEmpty ? nil : userComment 123 + // } 124 + 125 + // Check IPTC Caption 126 + // if let iptcDict = metadata[kCGImagePropertyIPTCDictionary as String] as? [String: Any], 127 + // let caption = iptcDict[kCGImagePropertyIPTCCaptionAbstract as String] as? String 128 + // { 129 + // return caption.isEmpty ? nil : caption 130 + // } 131 + 132 + // Check TIFF ImageDescription 133 + if let tiffDict = metadata[kCGImagePropertyTIFFDictionary as String] as? [String: Any], 134 + let description = tiffDict[kCGImagePropertyTIFFImageDescription as String] as? String 135 + { 136 + return description.isEmpty ? nil : description 137 + } 138 + 139 + return nil 140 + } 141 + 142 + extension Array { 143 + subscript(safe index: Int) -> Element? { 144 + return indices.contains(index) ? self[index] : nil 145 + } 146 + }
+137
shortcut/ATProtoKit/PersistentUserSessionRegistry.swift
···
··· 1 + // 2 + // PersistentUserSessionRegistry.swift 3 + // shortcut 4 + // 5 + // Created by Bailey Townsend on 6/25/25. 6 + // 7 + 8 + import ATProtoKit 9 + import Foundation 10 + import SwiftData 11 + 12 + @MainActor 13 + public struct PersistentUserSessionRegistry: UserSessionRegistry { 14 + 15 + private var modelContext: ModelContext 16 + 17 + init(modelContext: ModelContext) { 18 + self.modelContext = modelContext 19 + } 20 + 21 + public func register(_ id: UUID, session: UserSession) async { 22 + let model = UserSessionToModel(id, session: session) 23 + self.modelContext.insert(model) 24 + do { 25 + try self.modelContext.save() 26 + } catch { 27 + print("Error saving user session: \(error)") 28 + } 29 + 30 + } 31 + 32 + public func getSession(for id: UUID) async -> UserSession? { 33 + let descriptor = FetchDescriptor<UserSessionModel>( 34 + predicate: #Predicate { $0.id == id } 35 + ) 36 + 37 + do { 38 + let models = try self.modelContext.fetch(descriptor) 39 + guard let model = models.first else { return nil } 40 + return ModelToUserSession(model) 41 + } catch { 42 + return nil 43 + } 44 + } 45 + 46 + public func containsSession(for id: UUID) async -> Bool { 47 + let descriptor = FetchDescriptor<UserSessionModel>( 48 + predicate: #Predicate { $0.id == id } 49 + ) 50 + 51 + do { 52 + let count = try self.modelContext.fetchCount(descriptor) 53 + return count > 0 54 + } catch { 55 + return false 56 + } 57 + } 58 + 59 + public func removeSession(for id: UUID) async { 60 + let descriptor = FetchDescriptor<UserSessionModel>( 61 + predicate: #Predicate { $0.id == id } 62 + ) 63 + 64 + do { 65 + let models = try self.modelContext.fetch(descriptor) 66 + for model in models { 67 + self.modelContext.delete(model) 68 + } 69 + try self.modelContext.save() 70 + } catch { 71 + // Handle error silently or log as needed 72 + } 73 + } 74 + 75 + public func removeAllSessions() async { 76 + let descriptor = FetchDescriptor<UserSessionModel>() 77 + 78 + do { 79 + let models = try self.modelContext.fetch(descriptor) 80 + for model in models { 81 + self.modelContext.delete(model) 82 + } 83 + try self.modelContext.save() 84 + } catch { 85 + // Handle error silently or log as needed 86 + } 87 + } 88 + 89 + public func getAllSessions() async -> [UUID: UserSession] { 90 + let descriptor = FetchDescriptor<UserSessionModel>() 91 + 92 + do { 93 + let models = try self.modelContext.fetch(descriptor) 94 + var sessions: [UUID: UserSession] = [:] 95 + 96 + for model in models { 97 + let session = ModelToUserSession(model) 98 + sessions[model.id] = session 99 + } 100 + 101 + return sessions 102 + } catch { 103 + return [:] 104 + } 105 + } 106 + 107 + } 108 + 109 + private func ModelToUserSession(_ model: UserSessionModel) -> UserSession { 110 + return UserSession( 111 + handle: model.handle, 112 + sessionDID: model.sessionDID, 113 + email: model.email, 114 + isEmailConfirmed: model.isEmailConfirmed, 115 + isEmailAuthenticationFactorEnabled: model.isEmailAuthenticationFactorEnabled, 116 + // didDocument: model.didDocument, 117 + isActive: model.isActive, 118 + status: model.status, 119 + serviceEndpoint: model.serviceEndpoint, 120 + pdsURL: model.pdsURL 121 + ) 122 + } 123 + 124 + private func UserSessionToModel(_ id: UUID, session: UserSession) -> UserSessionModel { 125 + return UserSessionModel( 126 + sessionId: id, 127 + handle: session.handle, 128 + sessionDID: session.sessionDID, 129 + email: session.email, 130 + isEmailConfirmed: session.isEmailConfirmed, 131 + isEmailAuthenticationFactorEnabled: session.isEmailAuthenticationFactorEnabled, 132 + // didDocument: session.didDocument, 133 + isActive: session.isActive, 134 + status: session.status, 135 + serviceEndpoint: session.serviceEndpoint, 136 + pdsURL: session.pdsURL) 137 + }
+156
shortcut/ATProtoKit/Resolvers.swift
···
··· 1 + // 2 + // Resolvers.swift 3 + // shortcut 4 + // 5 + // Created by Bailey Townsend on 7/2/25. 6 + // 7 + 8 + import ATCommonWeb 9 + import ATIdentityTools 10 + import ATProtoKit 11 + import Cache 12 + import Foundation 13 + 14 + public struct Resolvers { 15 + 16 + let handleResolver: HandleResolver 17 + var didCache: Storage<String, String>? 18 + var didDocCache: Storage<String, CommonDIDDocument>? 19 + 20 + init() { 21 + self.handleResolver = HandleResolver() 22 + let one_hour = Expiry.seconds(TimeInterval(3_600)) 23 + let didCacheDiskConfig = DiskConfig(name: "DidCache", expiry: one_hour) 24 + let didDocCacheDiskConfig = DiskConfig(name: "DidDocCache", expiry: one_hour) 25 + let memoryConfig = MemoryConfig( 26 + expiry: one_hour, countLimit: 10, totalCostLimit: 20) 27 + 28 + do { 29 + self.didCache = try Storage<String, String>( 30 + diskConfig: didCacheDiskConfig, 31 + memoryConfig: memoryConfig, 32 + fileManager: FileManager(), 33 + transformer: TransformerFactory.forCodable( 34 + ofType: String.self 35 + ) 36 + ) 37 + 38 + self.didDocCache = try Storage<String, CommonDIDDocument>( 39 + diskConfig: didDocCacheDiskConfig, 40 + memoryConfig: memoryConfig, 41 + fileManager: FileManager(), 42 + transformer: TransformerFactory.forCodable( 43 + ofType: CommonDIDDocument.self 44 + )) 45 + } catch { 46 + print(error) 47 + 48 + self.didCache = nil 49 + self.didDocCache = nil 50 + } 51 + } 52 + 53 + public func resolveDid(handle: String) async throws -> String? { 54 + 55 + if let cache = self.didCache { 56 + if cache.objectExists(forKey: handle) { 57 + let result = try cache.entry(forKey: handle) 58 + if result.expiry.isExpired { 59 + try cache.removeObject(forKey: handle) 60 + } 61 + return result.object 62 + } 63 + } 64 + 65 + var didToReturn: String 66 + 67 + if handle.hasSuffix(".bsky.social") { 68 + let config = ATProtocolConfiguration() 69 + let atProto = await ATProtoKit( 70 + sessionConfiguration: config) 71 + 72 + do { 73 + let did = try await atProto.resolveHandle(from: handle) 74 + didToReturn = did.did 75 + } catch { 76 + throw LoginError.handleDoesNotResolve 77 + } 78 + } else { 79 + guard let did = try await handleResolver.resolve(handle: handle) else { 80 + throw LoginError.handleDoesNotResolve 81 + } 82 + didToReturn = did 83 + } 84 + 85 + do { 86 + try self.didCache?.setObject(didToReturn, forKey: handle) 87 + } catch { 88 + print(error) 89 + } 90 + return didToReturn 91 + 92 + } 93 + 94 + public func getDidDoc(handle: String) async throws -> CommonDIDDocument { 95 + 96 + do { 97 + 98 + guard let did = try await self.resolveDid(handle: handle) else { 99 + throw LoginError.handleDoesNotResolve 100 + } 101 + 102 + if let cache = self.didDocCache { 103 + if cache.objectExists(forKey: handle) { 104 + let result = try cache.entry(forKey: handle) 105 + if result.expiry.isExpired { 106 + try cache.removeObject(forKey: handle) 107 + } 108 + return result.object 109 + } 110 + } 111 + 112 + var didResolver = DIDResolver() 113 + 114 + let didDoc = try await didResolver.resolve(did: did) 115 + do { 116 + try self.didDocCache?.setObject(didDoc, forKey: handle) 117 + } catch { 118 + print(error) 119 + } 120 + return didDoc 121 + 122 + } catch { 123 + throw error 124 + } 125 + } 126 + 127 + public func getDidDoc(did: String) async throws -> CommonDIDDocument { 128 + 129 + do { 130 + 131 + if let cache = self.didDocCache { 132 + if cache.objectExists(forKey: did) { 133 + let result = try cache.entry(forKey: did) 134 + if result.expiry.isExpired { 135 + try cache.removeObject(forKey: did) 136 + } 137 + return result.object 138 + } 139 + } 140 + 141 + var didResolver = DIDResolver() 142 + 143 + let didDoc = try await didResolver.resolve(did: did) 144 + do { 145 + try self.didDocCache?.setObject(didDoc, forKey: did) 146 + } catch { 147 + print(error) 148 + } 149 + return didDoc 150 + 151 + } catch { 152 + throw error 153 + } 154 + } 155 + 156 + }
+130
shortcut/ATProtoKit/TIDGenerator.swift
···
··· 1 + // 2 + // TIDGenerator.swift 3 + // shortcut 4 + // 5 + // Created by Bailey Townsend on 6/30/25. 6 + // 7 + 8 + import Foundation 9 + 10 + struct TIDGenerator { 11 + // Base32-sortable alphabet as specified 12 + private static let base32Alphabet = "234567abcdefghijklmnopqrstuvwxyz" 13 + private static let alphabetArray = Array(base32Alphabet) 14 + 15 + // Random clock identifier (10 bits max = 1023) 16 + private let clockIdentifier: UInt16 17 + 18 + // Thread-safe counter for monotonic ordering within same microsecond 19 + private var lastTimestamp: UInt64 = 0 20 + private var counter: UInt16 = 0 21 + private let queue = DispatchQueue(label: "tid.generator", qos: .utility) 22 + 23 + init() { 24 + // Generate random 10-bit clock identifier 25 + self.clockIdentifier = UInt16.random(in: 0...1023) 26 + } 27 + 28 + init(clockIdentifier: UInt16) { 29 + // Use provided clock identifier (useful for testing or specific worker IDs) 30 + self.clockIdentifier = clockIdentifier & 0x3FF // Ensure it's only 10 bits 31 + } 32 + 33 + mutating func generateTID(from date: Date = Date()) -> String { 34 + return queue.sync { 35 + // Convert date to microseconds since Unix epoch 36 + let microseconds = UInt64(date.timeIntervalSince1970 * 1_000_000) 37 + 38 + // Ensure monotonic ordering 39 + let timestamp: UInt64 40 + if microseconds <= lastTimestamp { 41 + // If same microsecond or clock went backwards, increment counter 42 + counter += 1 43 + if counter > 1023 { // 10 bits max 44 + counter = 0 45 + timestamp = lastTimestamp + 1 46 + } else { 47 + timestamp = lastTimestamp 48 + } 49 + } else { 50 + // New microsecond, reset counter 51 + counter = 0 52 + timestamp = microseconds 53 + } 54 + 55 + lastTimestamp = timestamp 56 + 57 + // Create 64-bit TID 58 + var tid: UInt64 = 0 59 + 60 + // Top bit is always 0 (already 0 by default) 61 + 62 + // Next 53 bits: timestamp (microseconds since Unix epoch) 63 + // Shift left by 10 bits to make room for clock identifier 64 + tid = (timestamp & 0x1F_FFFF_FFFF_FFFF) << 10 65 + 66 + // Final 10 bits: combine clock identifier and counter for uniqueness 67 + let finalClockId = (UInt64(clockIdentifier) + UInt64(counter)) & 0x3FF 68 + tid |= finalClockId 69 + 70 + return encodeBase32Sortable(tid) 71 + } 72 + } 73 + 74 + private func encodeBase32Sortable(_ value: UInt64) -> String { 75 + var result = "" 76 + var num = value 77 + 78 + // Always encode to 13 characters (64 bits / 5 bits per character = 12.8, rounded up) 79 + for _ in 0..<13 { 80 + let remainder = Int(num & 0x1F) // Get bottom 5 bits 81 + result = String(Self.alphabetArray[remainder]) + result 82 + num >>= 5 // Shift right by 5 bits 83 + } 84 + 85 + return result 86 + } 87 + 88 + // Static convenience method 89 + static func generate(from date: Date = Date()) -> String { 90 + var generator = TIDGenerator() 91 + return generator.generateTID(from: date) 92 + } 93 + } 94 + 95 + // Extension for decoding TIDs back to components (useful for debugging) 96 + extension TIDGenerator { 97 + struct DecodedTID { 98 + let timestamp: Date 99 + let clockIdentifier: UInt16 100 + let originalValue: UInt64 101 + } 102 + 103 + static func decode(_ tid: String) -> DecodedTID? { 104 + guard tid.count == 13 else { return nil } 105 + 106 + var value: UInt64 = 0 107 + 108 + // Decode base32-sortable 109 + for char in tid { 110 + guard let index = base32Alphabet.firstIndex(of: char) else { 111 + return nil 112 + } 113 + value = 114 + (value << 5) 115 + | UInt64(base32Alphabet.distance(from: base32Alphabet.startIndex, to: index)) 116 + } 117 + 118 + // Extract components 119 + let clockId = UInt16(value & 0x3FF) // Bottom 10 bits 120 + let timestamp = (value >> 10) & 0x1F_FFFF_FFFF_FFFF // Next 53 bits 121 + 122 + let date = Date(timeIntervalSince1970: Double(timestamp) / 1_000_000.0) 123 + 124 + return DecodedTID( 125 + timestamp: date, 126 + clockIdentifier: clockId, 127 + originalValue: value 128 + ) 129 + } 130 + }
+84
shortcut/AppEntities/AtIdentifierAppEntity.swift
···
··· 1 + // 2 + // UserAccountParameter.swift 3 + // shortcut 4 + // 5 + // Created by Bailey Townsend on 6/29/25. 6 + // 7 + 8 + import AppIntents 9 + import Foundation 10 + 11 + struct AtIdentifierAppEntity: AppEntity { 12 + //The users handle 13 + var id: UUID 14 + 15 + @Property 16 + var handle: String 17 + 18 + @Property 19 + var did: String 20 + 21 + static var typeDisplayName: LocalizedStringResource = "Saved AT Identifier" 22 + 23 + static var typeDescription: LocalizedStringResource? = 24 + "This is your saved user session that is created inside of the app" 25 + 26 + var displayRepresentation: AppIntents.DisplayRepresentation { 27 + DisplayRepresentation(title: LocalizedStringResource(stringLiteral: handle)) 28 + } 29 + 30 + static var typeDisplayRepresentation: TypeDisplayRepresentation = TypeDisplayRepresentation( 31 + name: LocalizedStringResource("AT Identifier")) 32 + 33 + static var defaultQuery = AtIdentifierAppEntityQuery() 34 + } 35 + 36 + struct AtIdentifierAppEntityQuery: EntityQuery { 37 + 38 + func entities(for identifiers: [UUID]) async throws -> [AtIdentifierAppEntity] { 39 + let atProtocolManager = AtProtocolManager() 40 + do { 41 + let sessions = try await atProtocolManager.getSessions(sessionIDs: identifiers) 42 + if sessions.isEmpty { 43 + return [] 44 + 45 + } 46 + return sessions.map { session in 47 + let userAccount: AtIdentifierAppEntity = AtIdentifierAppEntity(id: session.id) 48 + userAccount.handle = session.handle 49 + userAccount.did = session.sessionDID 50 + return userAccount 51 + } 52 + } // return identifiers.compactMap { SessionManager.session(for: $0) } 53 + 54 + catch { 55 + print(error) 56 + throw GenericIntentError.message( 57 + "Could not retrieve saved AT Identifiers. Please make sure you have logged in via the app" 58 + ) 59 + } 60 + } 61 + 62 + func suggestedEntities() async throws -> [AtIdentifierAppEntity] { 63 + 64 + let atProtocolManager = AtProtocolManager() 65 + do { 66 + let sessions = try await atProtocolManager.getAllSessions() 67 + if sessions.isEmpty { 68 + return [] 69 + } 70 + return sessions.map { session in 71 + let userAccount: AtIdentifierAppEntity = AtIdentifierAppEntity(id: session.id) 72 + userAccount.handle = session.handle 73 + userAccount.did = session.sessionDID 74 + return userAccount 75 + } 76 + } 77 + 78 + catch { 79 + throw GenericIntentError.message( 80 + "Could not retrieve saved AT Identifiers. Please make sure you have logged in via the app" 81 + ) 82 + } 83 + } 84 + }
+66
shortcut/AppEntities/BlobsEntity.swift
···
··· 1 + // 2 + // shortcut 3 + // 4 + // Created by Bailey Townsend on 6/29/25. 5 + // 6 + 7 + import ATProtoKit 8 + import AppIntents 9 + import Foundation 10 + import UniformTypeIdentifiers 11 + 12 + //@AssistantEntity(schema: .files.file) 13 + //struct BlobsEntity: FileEntity { 14 + // 15 + // struct Query: EntityStringQuery { 16 + // func entities(for identifiers: [FilesEntity.ID]) async throws -> [FilesEntity] { [] } 17 + // func entities(matching string: String) async throws -> [FilesEntity] { [] } 18 + // } 19 + // 20 + // static var defaultQuery = Query() 21 + // 22 + // static var supportedContentTypes: [UTType] = [UTType.folder] 23 + // 24 + // var id: FileEntityIdentifier 25 + // 26 + // @Property(title: "Car File") 27 + // public var car: IntentFile? 28 + // 29 + // static var typeDisplayName: LocalizedStringResource = "Repo" 30 + // 31 + // static var typeDescription: LocalizedStringResource? = 32 + // "The CAR file of a user's repository" 33 + // 34 + // var displayRepresentation: AppIntents.DisplayRepresentation { 35 + // return DisplayRepresentation( 36 + // title: "A .CAR file") 37 + // } 38 + // 39 + // static var typeDisplayRepresentation: TypeDisplayRepresentation { 40 + // TypeDisplayRepresentation( 41 + // name: LocalizedStringResource("Repo", table: "AppIntents") 42 + // 43 + // ) 44 + // } 45 + // 46 + //} 47 + 48 + @AssistantEntity(schema: .files.file) 49 + struct FolderEntity: FileEntity { 50 + 51 + struct Query: EntityStringQuery { 52 + func entities(for identifiers: [FolderEntity.ID]) async throws -> [FolderEntity] { [] } 53 + func entities(matching string: String) async throws -> [FolderEntity] { [] } 54 + } 55 + static var defaultQuery = Query() 56 + 57 + static var supportedContentTypes = [UTType.folder] 58 + var displayRepresentation: AppIntents.DisplayRepresentation { 59 + "Folder Location" 60 + } 61 + 62 + var id: FileEntityIdentifier 63 + 64 + var creationDate: Date? 65 + var fileModificationDate: Date? 66 + }
+33
shortcut/AppEntities/BlobsExportEntity.swift
···
··· 1 + // 2 + // shortcut 3 + // 4 + // Created by Bailey Townsend on 6/29/25. 5 + // 6 + 7 + import ATProtoKit 8 + import AppIntents 9 + import Foundation 10 + 11 + struct BlobsExportEntity: TransientAppEntity, Codable { 12 + 13 + @Property(title: "Blobs Export") 14 + public var zipExport: IntentFile? 15 + 16 + static var typeDisplayName: LocalizedStringResource = "Blobs" 17 + 18 + static var typeDescription: LocalizedStringResource? = 19 + "The zip file of a user's blobs" 20 + 21 + var displayRepresentation: AppIntents.DisplayRepresentation { 22 + return DisplayRepresentation( 23 + title: "Zip FIle") 24 + } 25 + 26 + static var typeDisplayRepresentation: TypeDisplayRepresentation { 27 + TypeDisplayRepresentation( 28 + name: LocalizedStringResource("Blobs", table: "AppIntents") 29 + 30 + ) 31 + } 32 + 33 + }
+36
shortcut/AppEntities/ListBlobsEntity.swift
···
··· 1 + // 2 + // StrongReference.swift 3 + // shortcut 4 + // 5 + // Created by Bailey Townsend on 6/29/25. 6 + // 7 + 8 + import ATProtoKit 9 + import AppIntents 10 + import Foundation 11 + 12 + struct ListBlobsEntity: TransientAppEntity { 13 + 14 + @Property(title: "Cursor") 15 + public var cursor: String? 16 + 17 + /// The CID hash for the record. 18 + @Property(title: "CIDs") 19 + public var cids: [String] 20 + 21 + var id: UUID = UUID() 22 + 23 + static var typeDisplayName: LocalizedStringResource = "List Blobs Output" 24 + 25 + static var typeDescription: LocalizedStringResource = 26 + "A list of blobs on the user's repo along with a cursor for pagination" 27 + 28 + var displayRepresentation: AppIntents.DisplayRepresentation { 29 + DisplayRepresentation(title: "Cursor: \(cursor ?? "None"), Blob CIDs:\(cids.count)") 30 + 31 + } 32 + 33 + static var typeDisplayRepresentation: TypeDisplayRepresentation = TypeDisplayRepresentation( 34 + name: LocalizedStringResource("Blobs List")) 35 + 36 + }
+36
shortcut/AppEntities/ListRecordsAppEntity.swift
···
··· 1 + // 2 + // shortcut 3 + // 4 + // Created by Bailey Townsend on 6/29/25. 5 + // 6 + 7 + import ATProtoKit 8 + import AppIntents 9 + import Foundation 10 + 11 + struct ListRecordsAppEntity: TransientAppEntity { 12 + 13 + @Property(title: "Cursor") 14 + public var cursor: String? 15 + 16 + @Property(title: "Records") 17 + var records: [RecordAppEntity] 18 + 19 + var id: UUID = UUID() 20 + 21 + static var typeDisplayName: LocalizedStringResource = "List Records" 22 + 23 + static var typeDescription: LocalizedStringResource? = 24 + "The response from a list records request. Shows the requested records along with a cursor for pagination" 25 + 26 + var displayRepresentation: AppIntents.DisplayRepresentation { 27 + 28 + return DisplayRepresentation( 29 + title: "\(records.count) records (Cursor:\(cursor ?? "No cursor"))") 30 + 31 + } 32 + 33 + static var typeDisplayRepresentation: TypeDisplayRepresentation = TypeDisplayRepresentation( 34 + name: LocalizedStringResource("List Records Result")) 35 + 36 + }
+65
shortcut/AppEntities/RecordAppEntity.swift
···
··· 1 + // 2 + // shortcut 3 + // 4 + // Created by Bailey Townsend on 6/29/25. 5 + // 6 + 7 + import ATProtoKit 8 + import AppIntents 9 + import Foundation 10 + 11 + struct RecordAppEntity: TransientAppEntity, Codable { 12 + 13 + /// The URI for the record. 14 + @Property(title: "Record's URI") 15 + public var uri: String 16 + 17 + /// The CID hash for the record. 18 + @Property(title: "Record's CID") 19 + public var cid: String 20 + 21 + /// Can change output from the variable to dictionary in shortcuts 22 + @Property(title: "Value") 23 + var value: IntentFile? 24 + 25 + var id: String { uri } 26 + 27 + static var typeDisplayName: LocalizedStringResource = "Record" 28 + 29 + static var typeDescription: LocalizedStringResource? = 30 + "Response from a PDS when you get a record" 31 + 32 + var displayRepresentation: AppIntents.DisplayRepresentation { 33 + var anonymousObject: [String: CodableValue] = [ 34 + "uri": CodableValue(stringLiteral: self.uri), 35 + "cid": CodableValue(stringLiteral: self.cid), 36 + ] 37 + 38 + do { 39 + if let valueData = self.value?.data { 40 + let decoder = JSONDecoder() 41 + 42 + anonymousObject["value"] = try decoder.decode( 43 + CodableValue.self, from: valueData) 44 + } 45 + guard let fullJsonData = try anonymousObject.toJsonData() else { 46 + throw GenericIntentError.general 47 + } 48 + let jsonString = 49 + String(data: fullJsonData, encoding: .utf8) ?? "" 50 + 51 + return DisplayRepresentation(title: "\(jsonString)") 52 + } catch { 53 + return DisplayRepresentation( 54 + title: "uri: \(uri)\n recordCID:\(cid)") 55 + } 56 + } 57 + 58 + static var typeDisplayRepresentation: TypeDisplayRepresentation { 59 + TypeDisplayRepresentation( 60 + name: LocalizedStringResource("ATProtocol Record", table: "AppIntents") 61 + 62 + ) 63 + } 64 + 65 + }
+33
shortcut/AppEntities/RepoCarEntity.swift
···
··· 1 + // 2 + // shortcut 3 + // 4 + // Created by Bailey Townsend on 6/29/25. 5 + // 6 + 7 + import ATProtoKit 8 + import AppIntents 9 + import Foundation 10 + 11 + struct RepoCarEntity: TransientAppEntity, Codable { 12 + 13 + @Property(title: "Car File") 14 + public var car: IntentFile? 15 + 16 + static var typeDisplayName: LocalizedStringResource = "Repo" 17 + 18 + static var typeDescription: LocalizedStringResource? = 19 + "The CAR file of a user's repository" 20 + 21 + var displayRepresentation: AppIntents.DisplayRepresentation { 22 + return DisplayRepresentation( 23 + title: "A .CAR file") 24 + } 25 + 26 + static var typeDisplayRepresentation: TypeDisplayRepresentation { 27 + TypeDisplayRepresentation( 28 + name: LocalizedStringResource("Repo", table: "AppIntents") 29 + 30 + ) 31 + } 32 + 33 + }
+37
shortcut/AppEntities/StrongReferenceAppEntity.swift
···
··· 1 + // 2 + // StrongReference.swift 3 + // shortcut 4 + // 5 + // Created by Bailey Townsend on 6/29/25. 6 + // 7 + 8 + import ATProtoKit 9 + import AppIntents 10 + import Foundation 11 + 12 + struct StrongReferenceAppEntity: TransientAppEntity { 13 + 14 + /// The URI for the record. 15 + @Property(title: "Record URI") 16 + public var recordURI: String 17 + 18 + /// The CID hash for the record. 19 + @Property(title: "Record CID") 20 + public var recordCID: String 21 + 22 + var id: String { recordURI } 23 + 24 + static var typeDisplayName: LocalizedStringResource = "Strong Reference" 25 + 26 + static var typeDescription: LocalizedStringResource = 27 + "This is the com.atproto.repo.strongRef type" 28 + 29 + var displayRepresentation: AppIntents.DisplayRepresentation { 30 + DisplayRepresentation(title: "recordUri: \(recordURI), recordCID:\(recordCID)") 31 + 32 + } 33 + 34 + static var typeDisplayRepresentation: TypeDisplayRepresentation = TypeDisplayRepresentation( 35 + name: LocalizedStringResource("Strong Reference")) 36 + 37 + }
+117
shortcut/AppIntents/CreateARecordIntent.swift
···
··· 1 + // 2 + // CreateARecordIntent.swift 3 + // shortcut 4 + // 5 + // Created by Bailey Townsend on 6/30/25. 6 + // 7 + 8 + import ATProtoKit 9 + import AppIntents 10 + import SwiftData 11 + import SwiftUI 12 + 13 + struct CreateARecordIntent: AppIntent { 14 + 15 + @Parameter( 16 + title: "AT Identifier", 17 + description: 18 + "The saved AT Identifier of the account you want to use to authenticate with and write the record to", 19 + requestValueDialog: IntentDialog("Choose a logged in AT Identifier")) 20 + public var atIdentifier: AtIdentifierAppEntity 21 + 22 + @Parameter( 23 + title: "Collection", 24 + description: "The collection you want to write to, like app.bsky.feed.post") 25 + var collection: String 26 + 27 + @Parameter( 28 + title: "Record Key", 29 + description: 30 + "The record key for the new record, optional. A TID will be used if not provided", 31 + default: nil) 32 + var recordKey: String? 33 + 34 + @Parameter( 35 + title: "Should Validate", 36 + description: 37 + "You will probably not use this unless you are writing known atproto records. i.e. the ones found in the atproto repo", 38 + default: false 39 + ) 40 + var shouldValidate: Bool 41 + 42 + @Parameter( 43 + title: "Record", 44 + description: 45 + "This is most likely a Dictionary Variable, or a JSON file. But it is the supplied atproto record. We add the $type from the type parameter to this record", 46 + ) 47 + var record: IntentFile 48 + 49 + static let title: LocalizedStringResource = "Create a Record" 50 + 51 + static let description: IntentDescription = 52 + "Create a new record in a collection with one of your saved AT Identifiers" 53 + 54 + static var parameterSummary: some ParameterSummary { 55 + Summary("Create a \(\.$collection) record for \(\.$atIdentifier) with \(\.$record)") { 56 + \.$recordKey 57 + \.$shouldValidate 58 + } 59 + } 60 + 61 + func perform() async throws -> some ReturnsValue<StrongReferenceAppEntity> { 62 + 63 + do { 64 + let lowercaseType = self.collection.lowercased() 65 + 66 + let decoder = JSONDecoder() 67 + 68 + guard 69 + case .dictionary(var dict) = try decoder.decode( 70 + CodableValue.self, from: record.data) 71 + else { 72 + throw GenericIntentError.message("Could not parse JSON") 73 + } 74 + dict["$type"] = CodableValue(stringLiteral: lowercaseType) 75 + 76 + let unknownRecord = UnknownType.unknown(dict) 77 + 78 + let atProtoManager = AtProtocolManager() 79 + if let recordKey = self.recordKey { 80 + if recordKey.isEmpty { 81 + self.recordKey = nil 82 + } 83 + } 84 + 85 + let recordref = try await atProtoManager.createARecord( 86 + sessionId: self.atIdentifier.id, 87 + repositoryDID: self.atIdentifier.did.lowercased(), 88 + collection: lowercaseType, recordKey: self.recordKey, 89 + record: unknownRecord) 90 + 91 + let strongRef: StrongReferenceAppEntity = StrongReferenceAppEntity() 92 + strongRef.recordCID = recordref.recordCID 93 + strongRef.recordURI = recordref.recordURI 94 + 95 + return .result( 96 + value: strongRef) 97 + } catch let shortCutError as ShortcutErrors { 98 + switch shortCutError { 99 + case .NoSession: 100 + throw GenericIntentError.message("No session found") 101 + case .ErrorCreatingARecord(let errorMessage): 102 + throw GenericIntentError.message(errorMessage) 103 + case .AuthError(let authError): 104 + throw GenericIntentError.message(authError) 105 + } 106 + } catch let shortCutError as GenericIntentError { 107 + throw shortCutError 108 + } catch _ as DecodingError { 109 + throw GenericIntentError.message( 110 + "There was an error decoding the record. Please make sure your record is valid JSON or use a shortcut dictionary." 111 + ) 112 + } catch { 113 + throw GenericIntentError.general 114 + } 115 + // return .result(dialog: "Okay, making a Bluesky Post.") 116 + } 117 + }
+109
shortcut/AppIntents/CreateATIDIntent.swift
···
··· 1 + // 2 + // MakeAPostIntent.swift 3 + // shortcut 4 + // 5 + // Created by Bailey Townsend on 6/29/25. 6 + // 7 + 8 + // StartMeditationIntent creates a meditation session. 9 + 10 + import ATProtoKit 11 + import AppIntents 12 + import SwiftData 13 + 14 + struct CreateATidIntent: AppIntent { 15 + 16 + @Parameter( 17 + title: "Date Source", 18 + description: "Choose whether to use the current datetime or specify a date", 19 + ) 20 + var dateSource: DateSource 21 + 22 + @Parameter( 23 + title: "DateTime", 24 + description: 25 + "Creates a TID from the given datetime, if one is not given uses the current devices datetime. For best results make sure you use \"Date Format: Long\" and \"Time Format: Long\" on the date parameter" 26 + ) 27 + var date: Date? 28 + 29 + @Parameter( 30 + title: "Clock Identifier", 31 + description: 32 + "(Optional) Used to create the same TID from the same DateTime. Helpful if you have a record you want to update and used as a unique ID for that record" 33 + ) 34 + var clockIdentifier: Int? 35 + 36 + static let title: LocalizedStringResource = 37 + "Create a TID" 38 + 39 + static let description: IntentDescription = IntentDescription( 40 + "Creates a TID from the given DateTime, if one is not given uses the current DateTime", 41 + resultValueName: "TID" 42 + ) 43 + 44 + static var parameterSummary: some ParameterSummary { 45 + Switch(\.$dateSource) { 46 + Case(.currentTime) { 47 + Summary("Create a TID from the \(\.$dateSource)") { 48 + \.$clockIdentifier 49 + } 50 + } 51 + Case(.specificDate) { 52 + Summary("Create a TID from a \(\.$dateSource) using \(\.$date)") { 53 + \.$clockIdentifier 54 + } 55 + } 56 + DefaultCase { 57 + Summary("Create a TID using \(\.$dateSource)") { 58 + \.$clockIdentifier 59 + } 60 + } 61 + } 62 + 63 + } 64 + func perform() async throws -> some ReturnsValue<String> { 65 + 66 + var generator: TIDGenerator 67 + if var clockIdentifier = self.clockIdentifier { 68 + if clockIdentifier > 1023 { 69 + clockIdentifier = 23 70 + } 71 + generator = TIDGenerator(clockIdentifier: UInt16(clockIdentifier)) 72 + } else { 73 + generator = TIDGenerator() 74 + } 75 + 76 + if let date = self.date { 77 + return .result(value: generator.generateTID(from: date)) 78 + } 79 + 80 + return .result(value: generator.generateTID()) 81 + } 82 + 83 + } 84 + 85 + enum DateSource: String, Codable, Sendable { 86 + case currentTime 87 + case specificDate 88 + } 89 + 90 + extension DateSource: AppEnum { 91 + static var typeDisplayRepresentation: TypeDisplayRepresentation { 92 + TypeDisplayRepresentation( 93 + name: LocalizedStringResource("Date Source", table: "AppIntents") 94 + ) 95 + } 96 + 97 + static let caseDisplayRepresentations: [DateSource: DisplayRepresentation] = [ 98 + .currentTime: DisplayRepresentation( 99 + title: "Current Date Time", 100 + subtitle: "Use the current time", 101 + image: .init(systemName: "clock") 102 + ), 103 + .specificDate: DisplayRepresentation( 104 + title: "Specific Date Time", 105 + subtitle: "Provide a specific date and time", 106 + image: .init(systemName: "calendar") 107 + ), 108 + ] 109 + }
+72
shortcut/AppIntents/DeleteARecordIntent.swift
···
··· 1 + // 2 + // CreateARecordIntent.swift 3 + // shortcut 4 + // 5 + // Created by Bailey Townsend on 6/30/25. 6 + // 7 + 8 + import ATProtoKit 9 + import AppIntents 10 + import SwiftData 11 + import SwiftUI 12 + 13 + struct DeleteARecordIntent: AppIntent { 14 + 15 + @Parameter( 16 + title: "AT Identifier", 17 + description: 18 + "The saved AT Identifier of the account you want to use to authenticate with and write the record to", 19 + requestValueDialog: IntentDialog("Choose a logged in AT Identifier")) 20 + public var atIdentifier: AtIdentifierAppEntity 21 + 22 + @Parameter( 23 + title: "Collection", 24 + description: "The collection you want to delete a record from, like app.bsky.feed.post") 25 + var collection: String 26 + 27 + @Parameter( 28 + title: "Record Key", 29 + description: 30 + "The record key for the record you want to delete", 31 + default: nil) 32 + var recordKey: String 33 + 34 + static let title: LocalizedStringResource = "Delete a Record" 35 + 36 + static let description: IntentDescription = 37 + "Delete a new record in a collection with one of your saved AT Identifiers" 38 + 39 + static var parameterSummary: some ParameterSummary { 40 + Summary( 41 + "Delete the record \(\.$recordKey) from \(\.$collection) in \(\.$atIdentifier)'s repo") 42 + } 43 + 44 + func perform() async throws -> some IntentResult { 45 + 46 + let lowercaseType = self.collection.lowercased() 47 + 48 + let atProtoManager = AtProtocolManager() 49 + 50 + do { 51 + try await atProtoManager.deleteARecord( 52 + sessionId: self.atIdentifier.id, repository: self.atIdentifier.did, 53 + collection: lowercaseType, recordKey: self.recordKey) 54 + 55 + return .result() 56 + } catch let shortCutError as ShortcutErrors { 57 + switch shortCutError { 58 + case .NoSession: 59 + throw GenericIntentError.message("No session found") 60 + case .ErrorCreatingARecord(let errorMessage): 61 + throw GenericIntentError.message(errorMessage) 62 + case .AuthError(let authError): 63 + throw GenericIntentError.message(authError) 64 + } 65 + } catch let shortCutError as GenericIntentError { 66 + throw shortCutError 67 + } catch { 68 + throw GenericIntentError.general 69 + } 70 + // return .result(dialog: "Okay, making a Bluesky Post.") 71 + } 72 + }
+204
shortcut/AppIntents/DownloadBlobsIntent.swift
···
··· 1 + // 2 + // GetARecordIntent.swift 3 + // shortcut 4 + // 5 + // Created by Bailey Townsend on 6/30/25. 6 + // 7 + 8 + import ATProtoKit 9 + import AppIntents 10 + import SwiftData 11 + import SwiftUI 12 + import UniformTypeIdentifiers 13 + 14 + //@AssistantIntent(schema: .files.createFolder) 15 + struct DownloadBlobsIntent: AppIntent { 16 + 17 + @Parameter( 18 + title: "Repo", 19 + description: "The handle or DID of the repo" 20 + ) 21 + var atIdentifier: String 22 + 23 + @Parameter( 24 + title: "CIDs", 25 + description: "The CIDS of the blobs to download") 26 + var cids: [String] 27 + 28 + @Parameter( 29 + title: "Max Concurrent Downloads", 30 + description: 31 + "Number of blobs to download at once. Defaults to 2. This is more of an advance feature, if your downloads are timing out may try increasing this and downloading fewer blobs at once" 32 + ) 33 + var maxConcurrent: Int? 34 + 35 + @Parameter( 36 + title: 37 + "Save location", 38 + description: 39 + "Optional save location to speed up downloads if you are just wanting to save to a physical location", 40 + supportedContentTypes: [.folder] 41 + ) 42 + 43 + var saveLocation: IntentFile? 44 + 45 + static let title: LocalizedStringResource = "Download Blobs" 46 + 47 + static let description: IntentDescription = IntentDescription( 48 + stringLiteral: 49 + "Downloads a list of blobs from a given repo via their cid. Cids can be found via the List Blobs action. This action caches the downloads in a folder by your accounts did in either the temp folder, or the save location you provide. So reoccurring downloads are faster. The temp folder can be cleared via the info screen in the app" 50 + ) 51 + 52 + static var parameterSummary: some ParameterSummary { 53 + Summary( 54 + "Download the blobs from \(\.$atIdentifier)'s with the given \(\.$cids)" 55 + ) { 56 + \.$maxConcurrent 57 + \.$saveLocation 58 + } 59 + } 60 + 61 + @Dependency 62 + private var blobDownloader: BlobDownloader 63 + 64 + func perform() async throws -> some ReturnsValue<[IntentFile]> { 65 + let atProtoManager = AtProtocolManager() 66 + let lowerCaseAtIdentifier = self.atIdentifier.lowercased() 67 + let info = try await GetRemoteInfo.getRemoteRepoInfo( 68 + possibleRepo: lowerCaseAtIdentifier, atProtoManager: atProtoManager) 69 + var maxConcurrent: Int = 2 70 + if let inputMaxConcurrent = self.maxConcurrent { 71 + maxConcurrent = inputMaxConcurrent 72 + 73 + } 74 + 75 + do { 76 + var blobUrls: [URL] = [] 77 + if let saveLocation = self.saveLocation { 78 + 79 + guard let saveUrl = saveLocation.fileURL else { 80 + throw GenericIntentError.message( 81 + "Was not able to get a valid url for the save location") 82 + } 83 + let didStartAccessing = saveUrl.startAccessingSecurityScopedResource() 84 + defer { 85 + if didStartAccessing { 86 + saveUrl.stopAccessingSecurityScopedResource() 87 + } 88 + } 89 + var bookmarkData: Data? 90 + if didStartAccessing { 91 + do { 92 + bookmarkData = try saveUrl.bookmarkData( 93 + options: .minimalBookmark, 94 + includingResourceValuesForKeys: nil, 95 + relativeTo: nil 96 + ) 97 + } catch { 98 + print("Failed to create bookmark: \(error)") 99 + } 100 + } 101 + 102 + blobUrls = try await withThrowingTaskGroup(of: [URL].self) { group in 103 + group.addTask { 104 + return try await self.blobDownloader.downloadBlobs( 105 + repo: info.repo, pdsURL: info.pdsURL, cids: self.cids, 106 + saveLocationBookmark: bookmarkData, 107 + maxConcurrentDownloads: maxConcurrent) 108 + } 109 + 110 + group.addTask { 111 + try await Task.sleep(for: .milliseconds(31_000)) 112 + await self.blobDownloader.CancelAll() 113 + throw GenericIntentError.message("Timeout downloading blobs.") 114 + } 115 + 116 + // group.addTask { 117 + // try await cancellationTask.value 118 + // return [] 119 + // } 120 + 121 + // Wait for the first task to complete (or throw) 122 + // This automatically cancels remaining tasks 123 + let result = try await group.next() 124 + 125 + // Cancel any remaining tasks 126 + group.cancelAll() 127 + if let urls = result { 128 + return urls 129 + } 130 + 131 + return [] 132 + } 133 + 134 + } else { 135 + 136 + //No location set so downloads to temp 137 + blobUrls = try await withThrowingTaskGroup(of: [URL].self) { group in 138 + group.addTask { 139 + return try await self.blobDownloader.downloadBlobs( 140 + repo: info.repo, pdsURL: info.pdsURL, cids: self.cids, 141 + maxConcurrentDownloads: maxConcurrent 142 + ) 143 + 144 + } 145 + 146 + group.addTask { 147 + try await Task.sleep(for: .milliseconds(31_000)) 148 + await self.blobDownloader.CancelAll() 149 + throw GenericIntentError.message("Timeout downloading blobs.") 150 + } 151 + 152 + // group.addTask { 153 + // try await cancellationTask.value 154 + // return [] 155 + // } 156 + 157 + // Wait for the first task to complete (or throw) 158 + // This automatically cancels remaining tasks 159 + let result = try await group.next() 160 + 161 + // Cancel any remaining tasks 162 + group.cancelAll() 163 + if let urls = result { 164 + return urls 165 + } 166 + 167 + return [] 168 + } 169 + 170 + } 171 + 172 + var files: [IntentFile] = [] 173 + 174 + for blobURL in blobUrls { 175 + files.append(IntentFile(fileURL: blobURL)) 176 + } 177 + 178 + return .result( 179 + value: files) 180 + 181 + } catch let shortCutError as ShortcutErrors { 182 + switch shortCutError { 183 + case .NoSession: 184 + throw GenericIntentError.message("No session found") 185 + case .ErrorCreatingARecord(let errorMessage): 186 + throw GenericIntentError.message(errorMessage) 187 + case .AuthError(let authError): 188 + throw GenericIntentError.message(authError) 189 + } 190 + } catch let shortCutError as GenericIntentError { 191 + throw shortCutError 192 + } catch let blobDownloadError as BlobDownloadError { 193 + switch blobDownloadError { 194 + case .apiError(let error): 195 + throw GenericIntentError.message(error.message) 196 + default: 197 + throw GenericIntentError.general 198 + } 199 + } catch { 200 + throw GenericIntentError.general 201 + } 202 + } 203 + 204 + }
+21
shortcut/AppIntents/ErrorHandling.swift
···
··· 1 + // 2 + // ErrorHandling.swift 3 + // shortcut 4 + // 5 + // Created by Bailey Townsend on 6/29/25. 6 + // 7 + import Foundation 8 + 9 + enum GenericIntentError: Error, CustomLocalizedStringResourceConvertible, LocalizedError { 10 + case general 11 + case message(_ message: String) 12 + case notFound(_ lostItem: String) 13 + 14 + var localizedStringResource: LocalizedStringResource { 15 + switch self { 16 + case let .message(message): return "\(message)" 17 + case .general: return "There was an error making the post." 18 + case let .notFound(lostItem): return "\(lostItem) could not be found" 19 + } 20 + } 21 + }
+61
shortcut/AppIntents/GetALocalAtIdentifiertIntent.swift
···
··· 1 + // 2 + // MakeAPostIntent.swift 3 + // shortcut 4 + // 5 + // Created by Bailey Townsend on 6/29/25. 6 + // 7 + 8 + // StartMeditationIntent creates a meditation session. 9 + 10 + import ATProtoKit 11 + import AppIntents 12 + import SwiftData 13 + 14 + struct GetALocalAtIdentifierIntent: AppIntent { 15 + 16 + @Parameter( 17 + title: "Handle", 18 + description: 19 + "The handle of the saved account you would like to retrieve. Ex @alice.bsky.social") 20 + var handle: String 21 + 22 + static let title: LocalizedStringResource = 23 + "Get AT Identifier" 24 + 25 + static let description: IntentDescription = IntentDescription( 26 + stringLiteral: 27 + "This shortcut allows you to look up locally logged in ATProto accounts to use them for other actions. This is useful if you want to dynamically switch between accounts" 28 + ) 29 + 30 + static var parameterSummary: some ParameterSummary { 31 + Summary("Get the AT Identifier for \(\.$handle)") 32 + } 33 + 34 + func perform() async throws -> some ReturnsValue<AtIdentifierAppEntity> { 35 + 36 + let atProtoManager = AtProtocolManager() 37 + do { 38 + 39 + let lookedUpSession = try await atProtoManager.getSessionByHandle( 40 + handle: self.handle.lowercased()) 41 + guard let session = lookedUpSession else { 42 + throw GenericIntentError.message( 43 + "\(self.handle) could not be found. Please check the app to make sure that is an account you have setup" 44 + ) 45 + } 46 + let userAccount: AtIdentifierAppEntity = AtIdentifierAppEntity(id: session.id) 47 + userAccount.handle = session.handle 48 + userAccount.did = session.sessionDID 49 + 50 + return .result(value: userAccount) 51 + 52 + } catch let error as ShortcutErrors { 53 + throw GenericIntentError.message( 54 + error.errorDescription ?? "There was an error getting the user") 55 + } catch let error as GenericIntentError { 56 + throw error 57 + } catch { 58 + throw GenericIntentError.message(error.localizedDescription) 59 + } 60 + } 61 + }
+98
shortcut/AppIntents/GetARecordIntent.swift
···
··· 1 + // 2 + // GetARecordIntent.swift 3 + // shortcut 4 + // 5 + // Created by Bailey Townsend on 6/30/25. 6 + // 7 + 8 + import ATProtoKit 9 + import AppIntents 10 + import SwiftData 11 + import SwiftUI 12 + 13 + struct GetARecordIntent: AppIntent { 14 + 15 + @Parameter( 16 + title: "Repo", 17 + description: "The handle or DID of the repo" 18 + ) 19 + var atIdentifier: String 20 + 21 + @Parameter( 22 + title: "Collection", 23 + description: "The collection you want to write to, like app.bsky.feed.post") 24 + var collection: String 25 + 26 + @Parameter( 27 + title: "Record Key", 28 + description: 29 + "The record key for record", 30 + ) 31 + var recordKey: String 32 + 33 + @Parameter( 34 + title: "Record CID", 35 + description: 36 + "The CID of the version of the record. If not specified, then return the most recent version", 37 + default: nil 38 + ) 39 + var cid: String? 40 + 41 + static let title: LocalizedStringResource = "Get a Record" 42 + 43 + static let description: IntentDescription = IntentDescription( 44 + stringLiteral: 45 + "Get a single record from a repository. Does not require auth" 46 + ) 47 + 48 + static var parameterSummary: some ParameterSummary { 49 + Summary( 50 + "Get a record from \(\.$collection) in \(\.$atIdentifier)'s repo with the Record key \(\.$recordKey)" 51 + ) { 52 + \.$cid 53 + } 54 + } 55 + 56 + func perform() async throws -> some ReturnsValue<RecordAppEntity> { 57 + let atProtoManager = AtProtocolManager() 58 + let lowerCaseCollection = self.collection.lowercased() 59 + let lowerCaseAtIdentifier = self.atIdentifier.lowercased() 60 + 61 + let info = try await GetRemoteInfo.getRemoteRepoInfo( 62 + possibleRepo: lowerCaseAtIdentifier, atProtoManager: atProtoManager) 63 + 64 + do { 65 + let response = try await atProtoManager.getARecord( 66 + pdsURL: info.pdsURL, 67 + repositoryDID: info.repo, 68 + collection: lowerCaseCollection, 69 + recordKey: self.recordKey, 70 + recordCID: self.cid) 71 + 72 + let record: RecordAppEntity = RecordAppEntity() 73 + record.cid = response.cid 74 + record.uri = response.uri 75 + if let attempt = try response.value?.toJSON() { 76 + record.value = IntentFile( 77 + data: attempt, filename: "\(record.uri).json") 78 + } 79 + 80 + return .result( 81 + value: record) 82 + } catch let shortCutError as ShortcutErrors { 83 + switch shortCutError { 84 + case .NoSession: 85 + throw GenericIntentError.message("No session found") 86 + case .ErrorCreatingARecord(let errorMessage): 87 + throw GenericIntentError.message(errorMessage) 88 + case .AuthError(let authError): 89 + throw GenericIntentError.message(authError) 90 + } 91 + } catch let shortCutError as GenericIntentError { 92 + throw shortCutError 93 + } catch { 94 + throw GenericIntentError.general 95 + } 96 + } 97 + 98 + }
+96
shortcut/AppIntents/GetRepoIntent.swift
···
··· 1 + // 2 + // GetARecordIntent.swift 3 + // shortcut 4 + // 5 + // Created by Bailey Townsend on 6/30/25. 6 + // 7 + 8 + import ATProtoKit 9 + import AppIntents 10 + import SwiftData 11 + import SwiftUI 12 + import UniformTypeIdentifiers 13 + 14 + struct GetRepoIntent: AppIntent { 15 + 16 + @Parameter( 17 + title: "Repo", 18 + description: "The handle or DID of the repo" 19 + ) 20 + var atIdentifier: String 21 + 22 + @Parameter( 23 + title: "Since", 24 + description: "A TID created from a timestamp of since when to get the record") 25 + var since: String? 26 + 27 + static let title: LocalizedStringResource = "Get Repo" 28 + 29 + static let description: IntentDescription = IntentDescription( 30 + stringLiteral: 31 + "Get's the users repo as a .CAR file. This can also be used as a backup of your repo, but will not include the account's blobs (pictures, videos, etc)" 32 + ) 33 + 34 + static var parameterSummary: some ParameterSummary { 35 + Summary( 36 + "Download a CAR export of \(\.$atIdentifier)'s repo" 37 + ) { 38 + \.$since 39 + } 40 + } 41 + 42 + @Dependency 43 + private var blobDownloader: BlobDownloader 44 + 45 + func perform() async throws -> some ReturnsValue<RepoCarEntity> { 46 + let atProtoManager = AtProtocolManager() 47 + let lowerCaseAtIdentifier = self.atIdentifier.lowercased().trim() 48 + let info = try await GetRemoteInfo.getRemoteRepoInfo( 49 + possibleRepo: lowerCaseAtIdentifier, atProtoManager: atProtoManager) 50 + 51 + let fileManager = FileManager.default 52 + let saveLocation = fileManager.temporaryDirectory 53 + let date = Date() 54 + let formatter = ISO8601DateFormatter() 55 + let utcString = formatter.string(from: date) 56 + let fileName = "\(info.handle)-\(utcString).car" 57 + 58 + do { 59 + 60 + ///Just to be on the safe side 61 + await self.blobDownloader.CancelAll() 62 + let response = try await self.blobDownloader.getCar( 63 + from: info.repo, since: self.since, fileManager: fileManager, 64 + saveLocation: saveLocation, fileName: fileName, pdsURL: info.pdsURL) 65 + let record: RepoCarEntity = RepoCarEntity() 66 + 67 + record.car = IntentFile( 68 + fileURL: response 69 + ) 70 + 71 + return .result( 72 + value: record) 73 + } catch let shortCutError as ShortcutErrors { 74 + switch shortCutError { 75 + case .NoSession: 76 + throw GenericIntentError.message("No session found") 77 + case .ErrorCreatingARecord(let errorMessage): 78 + throw GenericIntentError.message(errorMessage) 79 + case .AuthError(let authError): 80 + throw GenericIntentError.message(authError) 81 + } 82 + } catch let shortCutError as GenericIntentError { 83 + throw shortCutError 84 + } catch let blobDownloadError as BlobDownloadError { 85 + switch blobDownloadError { 86 + case .apiError(let error): 87 + throw GenericIntentError.message(error.message) 88 + default: 89 + throw GenericIntentError.general 90 + } 91 + } catch { 92 + throw GenericIntentError.general 93 + } 94 + } 95 + 96 + }
+78
shortcut/AppIntents/GetServiceAuth.swift
···
··· 1 + // 2 + // GetServiceAuthIntent.swift 3 + // shortcut 4 + // 5 + // Created by Bailey Townsend on 7/23/25. 6 + // 7 + 8 + import ATProtoKit 9 + import AppIntents 10 + import SwiftData 11 + import SwiftUI 12 + 13 + struct GetServiceAuthIntent: AppIntent { 14 + 15 + @Parameter( 16 + title: "AT Identifier", 17 + description: 18 + "The saved AT Identifier of the account you want to use to authenticate with and create a JWT for", 19 + requestValueDialog: IntentDialog("Choose a logged in AT Identifier")) 20 + public var atIdentifier: AtIdentifierAppEntity 21 + 22 + //TODO does not look to be working as expected 23 + // @Parameter( 24 + // title: "Expiration", 25 + // description: "Time in seconds for the JWT to be valid. Default is 60") 26 + // var exp: Int? 27 + 28 + @Parameter( 29 + title: "Lexicon XRPC Method", 30 + description: 31 + "This is the XRPC method you want to set in the JWT's claim. like neat.atprotocol.app.privateMessage. Allows for scoping with the external service. Not required", 32 + default: nil) 33 + var lxm: String? 34 + 35 + static let title: LocalizedStringResource = "Create A Service Auth JWT" 36 + 37 + static let description: IntentDescription = 38 + "Creates a JWT that is signed by your accounts private key to allow you to authenticate with an external service proving you are the atprotocol account owner. This JWT is valid for 60 seconds from the time it is created" 39 + 40 + static var parameterSummary: some ParameterSummary { 41 + Summary("Create a JWT for \(\.$atIdentifier) to authenticate with an external service") { 42 + // \.$exp 43 + \.$lxm 44 + } 45 + } 46 + 47 + func perform() async throws -> some ReturnsValue<String> { 48 + 49 + do { 50 + 51 + let atProtoManager = AtProtocolManager() 52 + let tokenResponse = try await atProtoManager.getServiceAuth( 53 + sessionId: self.atIdentifier.id, usersDid: self.atIdentifier.did, exp: nil, 54 + lxm: self.lxm) 55 + 56 + return .result( 57 + value: tokenResponse.token) 58 + 59 + } catch let shortCutError as ShortcutErrors { 60 + switch shortCutError { 61 + case .NoSession: 62 + throw GenericIntentError.message("No session found") 63 + case .ErrorCreatingARecord(let errorMessage): 64 + throw GenericIntentError.message(errorMessage) 65 + case .AuthError(let authError): 66 + throw GenericIntentError.message(authError) 67 + } 68 + } catch let shortCutError as GenericIntentError { 69 + throw shortCutError 70 + } catch _ as DecodingError { 71 + throw GenericIntentError.message( 72 + "There was an error decoding the record. Please make sure your record is valid JSON or use a shortcut dictionary." 73 + ) 74 + } catch { 75 + throw GenericIntentError.general 76 + } 77 + } 78 + }
+90
shortcut/AppIntents/ListBlobsIntent.swift
···
··· 1 + // 2 + // GetARecordIntent.swift 3 + // shortcut 4 + // 5 + // Created by Bailey Townsend on 6/30/25. 6 + // 7 + 8 + import ATProtoKit 9 + import AppIntents 10 + import SwiftData 11 + import SwiftUI 12 + import UniformTypeIdentifiers 13 + 14 + struct ListBlobsIntent: AppIntent { 15 + 16 + @Parameter( 17 + title: "Repo", 18 + description: "The handle or DID of the repo" 19 + ) 20 + var atIdentifier: String 21 + 22 + @Parameter( 23 + title: "Since", 24 + description: "A TID created from a timestamp of since when to get the blob's cids") 25 + var since: String? 26 + 27 + @Parameter( 28 + title: "Limit", 29 + description: "Limit of blobs to return. Max 1000, default 500") 30 + var limit: Int? 31 + 32 + @Parameter( 33 + title: "Cursor", 34 + description: "The cursor to paginate through the blobs") 35 + var cursor: String? 36 + 37 + static let title: LocalizedStringResource = "List Blobs" 38 + 39 + static let description: IntentDescription = IntentDescription( 40 + stringLiteral: 41 + "List the blobs on the users's remote repository" 42 + ) 43 + 44 + static var parameterSummary: some ParameterSummary { 45 + Summary( 46 + "Get a list of \(\.$atIdentifier)'s blobs" 47 + ) { 48 + \.$limit 49 + \.$cursor 50 + \.$since 51 + } 52 + } 53 + 54 + func perform() async throws -> some ReturnsValue<ListBlobsEntity> { 55 + 56 + let atProtoManager = AtProtocolManager() 57 + let lowerCaseAtIdentifier = self.atIdentifier.lowercased() 58 + let info = try await GetRemoteInfo.getRemoteRepoInfo( 59 + possibleRepo: lowerCaseAtIdentifier, atProtoManager: atProtoManager) 60 + 61 + do { 62 + 63 + let result = try await atProtoManager.listBlobs( 64 + repo: info.repo, pdsURL: info.pdsURL, since: self.since, limit: self.limit ?? 500, 65 + cursor: self.cursor 66 + ) 67 + let record: ListBlobsEntity = ListBlobsEntity() 68 + record.cids = result.accountCIDs 69 + record.cursor = result.cursor 70 + 71 + return .result( 72 + value: record) 73 + 74 + } catch let shortCutError as ShortcutErrors { 75 + switch shortCutError { 76 + case .NoSession: 77 + throw GenericIntentError.message("No session found") 78 + case .ErrorCreatingARecord(let errorMessage): 79 + throw GenericIntentError.message(errorMessage) 80 + case .AuthError(let authError): 81 + throw GenericIntentError.message(authError) 82 + } 83 + } catch let shortCutError as GenericIntentError { 84 + throw shortCutError 85 + } catch { 86 + throw GenericIntentError.general 87 + } 88 + } 89 + 90 + }
+121
shortcut/AppIntents/ListRecordsIntent.swift
···
··· 1 + // 2 + // ListRecordsIntent.swift 3 + // shortcut 4 + // 5 + // Created by Bailey Townsend on 7/4/25. 6 + // 7 + 8 + import ATProtoKit 9 + import AppIntents 10 + import SwiftData 11 + import SwiftUI 12 + 13 + struct ListRecordsIntent: AppIntent { 14 + 15 + @Parameter( 16 + title: "Repo", 17 + description: "The handle or DID of the repo" 18 + ) 19 + var atIdentifier: String 20 + 21 + @Parameter( 22 + title: "Collection", 23 + description: "The collection you want to write to, like app.bsky.feed.post") 24 + var collection: String 25 + 26 + @Parameter( 27 + title: "Limit", 28 + description: "The number of records to return. Between 2 and 100, defaults to 50", 29 + default: nil) 30 + var limit: Int? 31 + 32 + @Parameter( 33 + title: "Cursor", 34 + description: 35 + "This can be a Record Key to used as the last seen record top paginate the results", 36 + default: nil 37 + ) 38 + var cursor: String? 39 + 40 + @Parameter( 41 + title: "Reverse", 42 + description: 43 + "Flag to reverse the order of the returned records", 44 + default: nil 45 + ) 46 + var reverse: Bool? 47 + 48 + static let title: LocalizedStringResource = "List Records" 49 + 50 + static let description: IntentDescription = IntentDescription( 51 + stringLiteral: 52 + "List a range of records in a repository, matching a specific collection. Does not require auth" 53 + ) 54 + 55 + static var parameterSummary: some ParameterSummary { 56 + Summary("List \(\.$collection) records from \(\.$atIdentifier)") { 57 + \.$limit 58 + \.$cursor 59 + \.$reverse 60 + } 61 + } 62 + 63 + func perform() async throws -> some IntentResult & ReturnsValue<ListRecordsAppEntity> { 64 + let atProtoManager = AtProtocolManager() 65 + let lowerCaseCollection = self.collection.lowercased() 66 + let lowerCaseAtIdentifier = self.atIdentifier.lowercased() 67 + print(lowerCaseAtIdentifier) 68 + let info = try await GetRemoteInfo.getRemoteRepoInfo( 69 + possibleRepo: lowerCaseAtIdentifier, atProtoManager: atProtoManager) 70 + if let limit = self.limit { 71 + if limit < 2 { 72 + throw GenericIntentError.message("Limit must be greater than or equal to 2") 73 + } 74 + 75 + if limit > 100 { 76 + throw GenericIntentError.message("Limit cannot be greater than 100") 77 + } 78 + } 79 + 80 + do { 81 + let response = try await atProtoManager.listRecords( 82 + pdsURL: info.pdsURL, 83 + repository: info.repo, 84 + collection: lowerCaseCollection, 85 + limit: self.limit, 86 + cursor: self.cursor, 87 + isArrayReverse: self.reverse) 88 + 89 + let result: ListRecordsAppEntity = ListRecordsAppEntity() 90 + result.cursor = response.cursor 91 + result.records = [] 92 + for record in response.records { 93 + let recordAppEntity = RecordAppEntity() 94 + recordAppEntity.uri = record.uri 95 + recordAppEntity.cid = record.cid 96 + if let attempt = try record.value?.toJSON() { 97 + recordAppEntity.value = IntentFile( 98 + data: attempt, filename: "\(record.uri).json") 99 + } 100 + result.records.append(recordAppEntity) 101 + } 102 + 103 + return .result( 104 + value: result) 105 + } catch let shortCutError as ShortcutErrors { 106 + switch shortCutError { 107 + case .NoSession: 108 + throw GenericIntentError.message("No session found") 109 + case .ErrorCreatingARecord(let errorMessage): 110 + throw GenericIntentError.message(errorMessage) 111 + case .AuthError(let authError): 112 + throw MakeAPostIntentError.message(authError) 113 + } 114 + } catch let shortCutError as GenericIntentError { 115 + throw shortCutError 116 + } catch { 117 + throw GenericIntentError.general 118 + } 119 + } 120 + 121 + }
+184
shortcut/AppIntents/MakeAPostIntent.swift
···
··· 1 + // 2 + // MakeAPostIntent.swift 3 + // shortcut 4 + // 5 + // Created by Bailey Townsend on 6/29/25. 6 + // 7 + 8 + // StartMeditationIntent creates a meditation session. 9 + 10 + import ATProtoKit 11 + import AppIntents 12 + import SwiftData 13 + import SwiftUI 14 + 15 + struct MakeAPostIntent: AppIntent { 16 + // 17 + @Parameter(requestValueDialog: "What type of Bluesky post would you like to make?") 18 + var postType: PostType 19 + 20 + @Parameter( 21 + title: "AT Identifier", 22 + description: "The saved AT Identifier you'd like make a Bluesky post to") 23 + var atIdentifierToUse: AtIdentifierAppEntity 24 + 25 + @Parameter(title: "Text", description: "The text of the post. 300 character limit", ) 26 + var postText: String 27 + 28 + @Parameter( 29 + title: "Reply to Record URI", 30 + description: 31 + "The at-uri of the post you want to reply to. Can find it in the result of Bluesky Post if you are making a thread", 32 + default: nil) 33 + var replyTo: String? 34 + 35 + @Parameter( 36 + title: "Images", 37 + description: "Select up to 4 images to upload. Supports .jpeg, .png, and .heic", 38 + supportedContentTypes: [.jpeg, .png, .heic] 39 + ) 40 + var images: [IntentFile]? 41 + 42 + @Parameter( 43 + title: "Alt Text", 44 + description: 45 + "Add up to 4 alt text descriptions for your images. These will be added in the same order as the images", 46 + default: [] 47 + ) 48 + var altText: [String] 49 + 50 + @Parameter( 51 + title: "Post Tags", 52 + description: 53 + "Additional hashtags, in addition to any included in post text and facets. Useful if you want to cross post to Flashes with \"flashes-app-ln3348nvl89\"", 54 + default: nil 55 + ) 56 + var tags: [String]? 57 + 58 + static let title: LocalizedStringResource = "Bluesky Post" 59 + 60 + static let description: IntentDescription = IntentDescription( 61 + stringLiteral: 62 + "This shortcut allows you to make a Bluesky post. You can add up to 4 images as well. The result is a Strong Ref which can be used in another action, like in the \"Reply to Record URI\" parameter to make a thread" 63 + ) 64 + 65 + static var parameterSummary: some ParameterSummary { 66 + 67 + Switch(\.$postType) { 68 + Case(.image) { 69 + 70 + Summary( 71 + "Make a \(\.$postType) from \(\.$atIdentifierToUse) saying \(\.$postText) with the attached \(\.$images) and \(\.$altText) descriptions" 72 + ) { 73 + \.$altText 74 + \.$replyTo 75 + \.$tags 76 + } 77 + } 78 + Case(.text) { 79 + Summary( 80 + "Make a \(\.$postType) from \(\.$atIdentifierToUse) saying \(\.$postText)" 81 + ) { 82 + \.$replyTo 83 + \.$tags 84 + } 85 + 86 + } 87 + DefaultCase { 88 + Summary("Make a \(\.$postType) post") 89 + } 90 + 91 + } 92 + 93 + } 94 + func perform() async throws -> some ReturnsValue<StrongReferenceAppEntity> { 95 + 96 + if self.postText.count > 300 { 97 + throw MakeAPostIntentError.message("Your post text exceeds 300 characters.") 98 + } 99 + var embedIdentifier: ATProtoBluesky.EmbedIdentifier? = nil 100 + if let images = self.images { 101 + // embedIdentifier = ATProtoBluesky.EmbedIdentifier() 102 + if images.count > 4 { 103 + throw MakeAPostIntentError.message("You can only upload up to 4 images.") 104 + } 105 + // if let altTexts = self.altText { 106 + if self.altText.count > 4 { 107 + throw MakeAPostIntentError.message("Your you can only have up to 4 alt texts.") 108 + } 109 + // } 110 + let imageQueries = try await IntentFileToImageQuery( 111 + files: images, altText: self.altText) 112 + embedIdentifier = .images(images: imageQueries) 113 + } 114 + 115 + let atProtoManager = AtProtocolManager() 116 + do { 117 + 118 + let postRef = try await atProtoManager.makeAPost( 119 + sessionId: atIdentifierToUse.id, text: postText, embed: embedIdentifier, 120 + replyTo: self.replyTo, locales: [Locale.current], tags: self.tags) 121 + 122 + let strongRef: StrongReferenceAppEntity = StrongReferenceAppEntity() 123 + strongRef.recordCID = postRef.recordCID 124 + strongRef.recordURI = postRef.recordURI 125 + 126 + return .result( 127 + value: strongRef) 128 + } catch let shortCutError as ShortcutErrors { 129 + switch shortCutError { 130 + case .NoSession: 131 + throw MakeAPostIntentError.noSessionFound 132 + case .ErrorCreatingARecord(let errorMessage): 133 + throw MakeAPostIntentError.message(errorMessage) 134 + case .AuthError(let authError): 135 + throw MakeAPostIntentError.message(authError) 136 + } 137 + } catch { 138 + throw MakeAPostIntentError.general 139 + } 140 + // return .result(dialog: "Okay, making a Bluesky Post.") 141 + } 142 + } 143 + 144 + enum MakeAPostIntentError: Swift.Error, CustomLocalizedStringResourceConvertible { 145 + case general 146 + case message(_ message: String) 147 + case noSessionFound 148 + 149 + var localizedStringResource: LocalizedStringResource { 150 + switch self { 151 + case let .message(message): return "\(message)" 152 + case .general: return "There was an error making the post." 153 + case .noSessionFound: return "No session found. Please open the app and login" 154 + } 155 + } 156 + 157 + } 158 + 159 + enum PostType: String, Codable, Sendable { 160 + case text 161 + case image 162 + } 163 + 164 + extension PostType: AppEnum { 165 + static var typeDisplayRepresentation: TypeDisplayRepresentation { 166 + TypeDisplayRepresentation( 167 + name: LocalizedStringResource("Post Type", table: "AppIntents"), 168 + numericFormat: LocalizedStringResource( 169 + "\(placeholder: .int) posts", table: "AppIntents") 170 + ) 171 + } 172 + 173 + static let caseDisplayRepresentations: [PostType: DisplayRepresentation] = [ 174 + .text: DisplayRepresentation( 175 + title: "Text post", 176 + subtitle: "A post with just text", 177 + image: .init(systemName: "signpost.right.and.left") 178 + ), 179 + .image: DisplayRepresentation( 180 + title: "Text post with images", 181 + subtitle: "A post with text and up to 4 images", 182 + image: .init(systemName: "photo")), 183 + ] 184 + }
+128
shortcut/AppIntents/PutARecordIntent.swift
···
··· 1 + // 2 + // CreateARecordIntent.swift 3 + // shortcut 4 + // 5 + // Created by Bailey Townsend on 6/30/25. 6 + // 7 + 8 + import ATCommonWeb 9 + import ATProtoKit 10 + import AppIntents 11 + import SwiftData 12 + import SwiftUI 13 + 14 + struct PutARecordIntent: AppIntent { 15 + 16 + @Parameter( 17 + title: "AT Identifier", 18 + description: 19 + "The saved AT Identifier of the account you want to use to authenticate with and write the record to" 20 + ) 21 + var atIdentifier: AtIdentifierAppEntity 22 + 23 + @Parameter( 24 + title: "Collection", 25 + description: "The collection you want to write to, like app.bsky.feed.post", ) 26 + var collection: String 27 + 28 + @Parameter( 29 + title: "Record Key", 30 + description: 31 + "The record key for the new post, optional. A tid will be used if not provided", 32 + default: nil) 33 + var recordKey: String? 34 + 35 + @Parameter( 36 + title: "Should Validate", 37 + description: 38 + "You will probably not use this unless you are writing known atproto records. i.e. the ones found in the atproto repo", 39 + default: false 40 + ) 41 + var shouldValidate: Bool 42 + 43 + @Parameter( 44 + title: "Record", 45 + description: 46 + "This is most likely a Dictionary Variable, or a JSON file. But it is the supplied atproto record. We add the $type from the type parameter to this record", 47 + ) 48 + var record: IntentFile 49 + 50 + static let title: LocalizedStringResource = "Put a Record" 51 + 52 + static let description: IntentDescription? = 53 + "Either updates a record or create one if it doesn't exist" 54 + 55 + static var parameterSummary: some ParameterSummary { 56 + Summary( 57 + "Update or create a \(\.$collection) record for \(\.$atIdentifier) with \(\.$record)" 58 + ) { 59 + \.$recordKey 60 + \.$shouldValidate 61 + } 62 + } 63 + 64 + func perform() async throws -> some ReturnsValue<StrongReferenceAppEntity> { 65 + 66 + do { 67 + let lowercaseType = self.collection.lowercased() 68 + 69 + let decoder = JSONDecoder() 70 + 71 + guard 72 + case .dictionary(var dict) = try decoder.decode( 73 + CodableValue.self, from: record.data) 74 + else { 75 + throw GenericIntentError.message("Could not parse JSON") 76 + } 77 + dict["$type"] = CodableValue(stringLiteral: lowercaseType) 78 + 79 + let unknownRecord = UnknownType.unknown(dict) 80 + 81 + let atProtoManager = AtProtocolManager() 82 + var putRecordKey = "" 83 + if let recordKey = self.recordKey { 84 + if recordKey.isEmpty { 85 + var generator = TIDGenerator() 86 + putRecordKey = generator.generateTID() 87 + } else { 88 + putRecordKey = self.recordKey ?? "" 89 + } 90 + } else { 91 + var generator = TIDGenerator() 92 + putRecordKey = generator.generateTID() 93 + } 94 + 95 + let recordref = try await atProtoManager.putARecord( 96 + sessionId: self.atIdentifier.id, 97 + repositoryDID: self.atIdentifier.did.lowercased(), 98 + collection: lowercaseType, recordKey: putRecordKey, 99 + record: unknownRecord) 100 + 101 + let strongRef: StrongReferenceAppEntity = StrongReferenceAppEntity() 102 + strongRef.recordCID = recordref.recordCID 103 + strongRef.recordURI = recordref.recordURI 104 + 105 + return .result( 106 + value: strongRef) 107 + } catch let shortCutError as ShortcutErrors { 108 + switch shortCutError { 109 + case .NoSession: 110 + throw GenericIntentError.message("No session found") 111 + case .ErrorCreatingARecord(let errorMessage): 112 + throw GenericIntentError.message(errorMessage) 113 + case .AuthError(let authError): 114 + throw GenericIntentError.message(authError) 115 + } 116 + } catch let shortCutError as GenericIntentError { 117 + throw shortCutError 118 + 119 + } catch _ as DecodingError { 120 + throw GenericIntentError.message( 121 + "There was an error decoding the record. Please make sure your record is valid JSON or use a shortcut dictionary." 122 + ) 123 + } catch { 124 + throw GenericIntentError.general 125 + } 126 + // return .result(dialog: "Okay, making a Bluesky Post.") 127 + } 128 + }
+61
shortcut/AppIntents/ResolveDidOrHandle.swift
···
··· 1 + // 2 + // GetARecordIntent.swift 3 + // shortcut 4 + // 5 + // Created by Bailey Townsend on 6/30/25. 6 + // 7 + 8 + import ATProtoKit 9 + import AppIntents 10 + import SwiftData 11 + import SwiftUI 12 + 13 + struct ResolveDidOrHandleIntent: AppIntent { 14 + 15 + @Parameter( 16 + title: "DID or Handle", 17 + description: "The handle or DID you want resolved" 18 + ) 19 + var atIdentifier: String 20 + 21 + static let title: LocalizedStringResource = "Resolve a DID or Handle" 22 + 23 + static let description: IntentDescription = IntentDescription( 24 + "Either resolves a DID to a handle, or a handle to DID depending on which you pass in", 25 + resultValueName: "DID or Handle" 26 + ) 27 + 28 + static var parameterSummary: some ParameterSummary { 29 + Summary("Resolve \(\.$atIdentifier)") 30 + } 31 + 32 + func perform() async throws -> some ReturnsValue<String> { 33 + let atProtoManager = AtProtocolManager() 34 + do { 35 + if self.atIdentifier.starts(with: "did") { 36 + let didDoc = try await atProtoManager.resolveDidDocument(did: self.atIdentifier) 37 + guard let handle = didDoc.getHandle() else { 38 + throw GenericIntentError.message("No handle found in the DID Doc") 39 + } 40 + return .result( 41 + value: handle) 42 + } else { 43 + guard 44 + let did = try await atProtoManager.resolvers.resolveDid( 45 + handle: self.atIdentifier.replacingOccurrences(of: "@", with: "")) 46 + else { 47 + throw GenericIntentError.message( 48 + "No DID found for the handle \(self.atIdentifier)") 49 + } 50 + 51 + return .result( 52 + value: did) 53 + } 54 + 55 + } catch { 56 + throw GenericIntentError.message( 57 + "Error resolving the DID or Handle. Check to make sure they are valid") 58 + } 59 + } 60 + 61 + }
+62
shortcut/AppIntents/UTCIntent.swift
···
··· 1 + // 2 + // UTCIntent.swift 3 + // shortcut 4 + // 5 + // Created by Bailey Townsend on 6/29/25. 6 + // 7 + 8 + import ATProtoKit 9 + import AppIntents 10 + import SwiftData 11 + 12 + //struct UTCIntent: AppIntent { 13 + // 14 + // @Parameter( 15 + // title: "Date Source", 16 + // description: "Choose whether to use the current datetime or specify a date", 17 + // ) 18 + // var dateSource: DateSource 19 + // 20 + // @Parameter( 21 + // title: "DateTime", 22 + // description: 23 + // "Creates a UTC Timestamp from the given datetime, if one is not given uses the current devices time to create the timestamp. Make sure you use \"Date Format: Long\" and \"Time Format Long\"" 24 + // ) 25 + // var date: Date? 26 + // 27 + // static let title: LocalizedStringResource = 28 + // "Create a UTC timestamp" 29 + // 30 + // static let description: IntentDescription = IntentDescription( 31 + // "Creates a UTC ISO8601 timestamp from the given DateTime, if one is not given uses the current DateTime", 32 + // resultValueName: "UTC Timestamp" 33 + // ) 34 + // 35 + // static var parameterSummary: some ParameterSummary { 36 + // Switch(\.$dateSource) { 37 + // Case(.currentTime) { 38 + // Summary("Create a UTC timestamp using \(\.$dateSource)") 39 + // 40 + // } 41 + // Case(.specificDate) { 42 + // Summary("Create a UTC timestamp from a \(\.$dateSource) using \(\.$date)") 43 + // } 44 + // DefaultCase { 45 + // Summary("Create a UTC timestamp using \(\.$dateSource)") 46 + // } 47 + // } 48 + // 49 + // } 50 + // func perform() async throws -> some ReturnsValue<String> { 51 + // 52 + // let formatter = ISO8601DateFormatter() 53 + // formatter.timeZone = TimeZone(abbreviation: "UTC") 54 + // if let date = self.date { 55 + // return .result(value: formatter.string(from: date)) 56 + // } 57 + // let currentDate = Date() 58 + // return .result(value: formatter.string(from: currentDate)) 59 + // 60 + // } 61 + // 62 + //}
+116
shortcut/AppIntents/UpdateProfileIntent.swift
···
··· 1 + // 2 + // CreateARecordIntent.swift 3 + // shortcut 4 + // 5 + // Created by Bailey Townsend on 6/30/25. 6 + // 7 + 8 + import ATProtoKit 9 + import AppIntents 10 + import SwiftData 11 + import SwiftUI 12 + 13 + struct UpdateProfileIntent: AppIntent { 14 + 15 + @Parameter( 16 + title: "AT Identifier", 17 + description: 18 + "The saved AT Identifier of the account you want to use to authenticate with and update the profile of", 19 + requestValueDialog: IntentDialog("Choose a logged in AT Identifier")) 20 + public var atIdentifier: AtIdentifierAppEntity 21 + 22 + @Parameter( 23 + title: "Display Name", 24 + description: 25 + "Enter a value if you would like to update your display name. Leave empty to not update your display name" 26 + ) 27 + var displayName: String? 28 + 29 + @Parameter( 30 + title: "Description", 31 + description: 32 + "Enter a value if you would like to update your profile's description. Leave empty to not update your profile's description", 33 + default: nil) 34 + var description: String? 35 + 36 + @Parameter( 37 + title: "Profile Picture", 38 + description: "Updates your profile picture if provided, if not keeps the current one", 39 + supportedContentTypes: [.jpeg, .png, .heic]) 40 + var profilePic: IntentFile? 41 + 42 + @Parameter( 43 + title: "Banner Picture", 44 + description: "Updates your banner picture if provided, if not keeps the current one", 45 + supportedContentTypes: [.jpeg, .png, .heic]) 46 + var bannerPic: IntentFile? 47 + 48 + static let title: LocalizedStringResource = "Update your Bluesky profile" 49 + 50 + static let description: IntentDescription = 51 + "Updates your Bluesky profile with the provided data. If the field does not have a value it keeps the current value" 52 + 53 + static var parameterSummary: some ParameterSummary { 54 + Summary("Update \(\.$atIdentifier)'s profile") { 55 + \.$displayName 56 + \.$description 57 + \.$profilePic 58 + \.$bannerPic 59 + } 60 + } 61 + 62 + func perform() async throws -> some ReturnsValue<StrongReferenceAppEntity> { 63 + 64 + var profileUpdates: [ATProtoBluesky.UpdatedProfileRecordField] = [] 65 + 66 + if let displayName = self.displayName { 67 + profileUpdates.append(.displayName(with: displayName)) 68 + } 69 + 70 + if let description = self.description { 71 + profileUpdates.append(.description(with: description)) 72 + } 73 + 74 + if let profilePic = self.profilePic { 75 + let imageQuery = try await IntentFileToImageQuery(files: [profilePic], altText: []) 76 + profileUpdates.append(.avatarImage(with: imageQuery.first)) 77 + } 78 + 79 + if let bannerPic = self.bannerPic { 80 + let imageQuery = try await IntentFileToImageQuery(files: [bannerPic], altText: []) 81 + profileUpdates.append(.bannerImage(with: imageQuery.first)) 82 + } 83 + 84 + if profileUpdates.isEmpty { 85 + throw GenericIntentError.message("No updates provided") 86 + } 87 + 88 + let atProtoManager = AtProtocolManager() 89 + 90 + do { 91 + let recordref = try await atProtoManager.updateProfile( 92 + sessionId: self.atIdentifier.id, usersDID: self.atIdentifier.did, 93 + updates: profileUpdates) 94 + 95 + let strongRef: StrongReferenceAppEntity = StrongReferenceAppEntity() 96 + strongRef.recordCID = recordref.recordCID 97 + strongRef.recordURI = recordref.recordURI 98 + 99 + return .result( 100 + value: strongRef) 101 + } catch let shortCutError as ShortcutErrors { 102 + switch shortCutError { 103 + case .NoSession: 104 + throw GenericIntentError.message("No session found") 105 + case .ErrorCreatingARecord(let errorMessage): 106 + throw GenericIntentError.message(errorMessage) 107 + case .AuthError(let authError): 108 + throw GenericIntentError.message(authError) 109 + } 110 + } catch let shortCutError as GenericIntentError { 111 + throw shortCutError 112 + } catch { 113 + throw GenericIntentError.general 114 + } 115 + } 116 + }
+11
shortcut/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 + }
shortcut/Assets.xcassets/AppIcon.appiconset/100.png

This is a binary file and will not be displayed.

shortcut/Assets.xcassets/AppIcon.appiconset/102.png

This is a binary file and will not be displayed.

shortcut/Assets.xcassets/AppIcon.appiconset/1024.png

This is a binary file and will not be displayed.

shortcut/Assets.xcassets/AppIcon.appiconset/108.png

This is a binary file and will not be displayed.

shortcut/Assets.xcassets/AppIcon.appiconset/114.png

This is a binary file and will not be displayed.

shortcut/Assets.xcassets/AppIcon.appiconset/120.png

This is a binary file and will not be displayed.

shortcut/Assets.xcassets/AppIcon.appiconset/128.png

This is a binary file and will not be displayed.

shortcut/Assets.xcassets/AppIcon.appiconset/144.png

This is a binary file and will not be displayed.

shortcut/Assets.xcassets/AppIcon.appiconset/152.png

This is a binary file and will not be displayed.

shortcut/Assets.xcassets/AppIcon.appiconset/16.png

This is a binary file and will not be displayed.

shortcut/Assets.xcassets/AppIcon.appiconset/167.png

This is a binary file and will not be displayed.

shortcut/Assets.xcassets/AppIcon.appiconset/172.png

This is a binary file and will not be displayed.

shortcut/Assets.xcassets/AppIcon.appiconset/180.png

This is a binary file and will not be displayed.

shortcut/Assets.xcassets/AppIcon.appiconset/196.png

This is a binary file and will not be displayed.

shortcut/Assets.xcassets/AppIcon.appiconset/20.png

This is a binary file and will not be displayed.

shortcut/Assets.xcassets/AppIcon.appiconset/216.png

This is a binary file and will not be displayed.

shortcut/Assets.xcassets/AppIcon.appiconset/234.png

This is a binary file and will not be displayed.

shortcut/Assets.xcassets/AppIcon.appiconset/256.png

This is a binary file and will not be displayed.

shortcut/Assets.xcassets/AppIcon.appiconset/258.png

This is a binary file and will not be displayed.

shortcut/Assets.xcassets/AppIcon.appiconset/29.png

This is a binary file and will not be displayed.

shortcut/Assets.xcassets/AppIcon.appiconset/32.png

This is a binary file and will not be displayed.

shortcut/Assets.xcassets/AppIcon.appiconset/40.png

This is a binary file and will not be displayed.

shortcut/Assets.xcassets/AppIcon.appiconset/48.png

This is a binary file and will not be displayed.

shortcut/Assets.xcassets/AppIcon.appiconset/50.png

This is a binary file and will not be displayed.

shortcut/Assets.xcassets/AppIcon.appiconset/512.png

This is a binary file and will not be displayed.

shortcut/Assets.xcassets/AppIcon.appiconset/55.png

This is a binary file and will not be displayed.

shortcut/Assets.xcassets/AppIcon.appiconset/57.png

This is a binary file and will not be displayed.

shortcut/Assets.xcassets/AppIcon.appiconset/58.png

This is a binary file and will not be displayed.

shortcut/Assets.xcassets/AppIcon.appiconset/60.png

This is a binary file and will not be displayed.

shortcut/Assets.xcassets/AppIcon.appiconset/64.png

This is a binary file and will not be displayed.

shortcut/Assets.xcassets/AppIcon.appiconset/66.png

This is a binary file and will not be displayed.

shortcut/Assets.xcassets/AppIcon.appiconset/72.png

This is a binary file and will not be displayed.

shortcut/Assets.xcassets/AppIcon.appiconset/76.png

This is a binary file and will not be displayed.

shortcut/Assets.xcassets/AppIcon.appiconset/80.png

This is a binary file and will not be displayed.

shortcut/Assets.xcassets/AppIcon.appiconset/87.png

This is a binary file and will not be displayed.

shortcut/Assets.xcassets/AppIcon.appiconset/88.png

This is a binary file and will not be displayed.

shortcut/Assets.xcassets/AppIcon.appiconset/92.png

This is a binary file and will not be displayed.

+1
shortcut/Assets.xcassets/AppIcon.appiconset/Contents.json
···
··· 1 + {"images":[{"size":"60x60","expected-size":"180","filename":"180.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"40x40","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"60x60","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"57x57","expected-size":"57","filename":"57.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"57x57","expected-size":"114","filename":"114.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"60","filename":"60.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"1024x1024","filename":"1024.png","expected-size":"1024","idiom":"ios-marketing","folder":"Assets.xcassets/AppIcon.appiconset/","scale":"1x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"72x72","expected-size":"72","filename":"72.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"76x76","expected-size":"152","filename":"152.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"50x50","expected-size":"100","filename":"100.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"76x76","expected-size":"76","filename":"76.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"50x50","expected-size":"50","filename":"50.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"72x72","expected-size":"144","filename":"144.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"40x40","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"83.5x83.5","expected-size":"167","filename":"167.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"20x20","expected-size":"20","filename":"20.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"idiom":"watch","filename":"172.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"86x86","expected-size":"172","role":"quickLook"},{"idiom":"watch","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"40x40","expected-size":"80","role":"appLauncher"},{"idiom":"watch","filename":"88.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"40mm","scale":"2x","size":"44x44","expected-size":"88","role":"appLauncher"},{"idiom":"watch","filename":"102.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"51x51","expected-size":"102","role":"appLauncher"},{"idiom":"watch","filename":"108.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"49mm","scale":"2x","size":"54x54","expected-size":"108","role":"appLauncher"},{"idiom":"watch","filename":"92.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"41mm","scale":"2x","size":"46x46","expected-size":"92","role":"appLauncher"},{"idiom":"watch","filename":"100.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"44mm","scale":"2x","size":"50x50","expected-size":"100","role":"appLauncher"},{"idiom":"watch","filename":"196.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"42mm","scale":"2x","size":"98x98","expected-size":"196","role":"quickLook"},{"idiom":"watch","filename":"216.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"44mm","scale":"2x","size":"108x108","expected-size":"216","role":"quickLook"},{"idiom":"watch","filename":"234.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"117x117","expected-size":"234","role":"quickLook"},{"idiom":"watch","filename":"258.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"49mm","scale":"2x","size":"129x129","expected-size":"258","role":"quickLook"},{"idiom":"watch","filename":"48.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"24x24","expected-size":"48","role":"notificationCenter"},{"idiom":"watch","filename":"55.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"42mm","scale":"2x","size":"27.5x27.5","expected-size":"55","role":"notificationCenter"},{"idiom":"watch","filename":"66.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"33x33","expected-size":"66","role":"notificationCenter"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch","role":"companionSettings","scale":"3x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch","role":"companionSettings","scale":"2x"},{"size":"1024x1024","expected-size":"1024","filename":"1024.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch-marketing","scale":"1x"},{"size":"128x128","expected-size":"128","filename":"128.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"256x256","expected-size":"256","filename":"256.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"128x128","expected-size":"256","filename":"256.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"256x256","expected-size":"512","filename":"512.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"32x32","expected-size":"32","filename":"32.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"512x512","expected-size":"512","filename":"512.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"16x16","expected-size":"16","filename":"16.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"16x16","expected-size":"32","filename":"32.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"32x32","expected-size":"64","filename":"64.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"512x512","expected-size":"1024","filename":"1024.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"}]}
+6
shortcut/Assets.xcassets/Contents.json
···
··· 1 + { 2 + "info" : { 3 + "author" : "xcode", 4 + "version" : 1 5 + } 6 + }
+49
shortcut/ContentView.swift
···
··· 1 + // 2 + // ContentView.swift 3 + // shortcut 4 + // 5 + // Created by Bailey Townsend on 6/23/25. 6 + // 7 + 8 + import ATIdentityTools 9 + import ATProtoKit 10 + import AppIntents 11 + import SwiftData 12 + import SwiftUI 13 + 14 + struct ContentView: View { 15 + 16 + @StateObject var atProtocolManager: AtProtocolManager 17 + 18 + init(modelContext: ModelContext) { 19 + let manager = AtProtocolManager(modelContext: modelContext) 20 + _atProtocolManager = StateObject(wrappedValue: manager) 21 + 22 + } 23 + 24 + var body: some View { 25 + 26 + TabView { 27 + 28 + Tab("ATProto Accounts", systemImage: "at") { 29 + AccountsView().environmentObject(atProtocolManager) 30 + } 31 + Tab("Documentation", systemImage: "document.on.document") { 32 + DocumentationView().navigationTitle(Text("Documentation")) 33 + } 34 + Tab("Info", systemImage: "info.circle") { 35 + SettingsView() 36 + } 37 + } 38 + 39 + } 40 + } 41 + 42 + #Preview { 43 + let configuration = ModelConfiguration(isStoredInMemoryOnly: true) 44 + let container = try! ModelContainer( 45 + for: UserSessionModel.self, 46 + configurations: configuration 47 + ) 48 + ContentView(modelContext: container.mainContext) 49 + }
+14
shortcut/Extensions.swift
···
··· 1 + // 2 + // Utils.swift 3 + // shortcut 4 + // 5 + // Created by Bailey Townsend on 7/10/25. 6 + // 7 + 8 + import Foundation 9 + 10 + extension String { 11 + func trim() -> String { 12 + return self.trimmingCharacters(in: .whitespaces) 13 + } 14 + }
+8
shortcut/Info.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>UIBackgroundModes</key> 6 + <array/> 7 + </dict> 8 + </plist>
+754
shortcut/IntentsAndDescriptions.swift
···
··· 1 + // 2 + // IntentsAndDescriptions.swift 3 + // shortcut 4 + // 5 + // Created by Bailey Townsend on 6/30/25. 6 + // 7 + 8 + import Foundation 9 + import SwiftUI 10 + 11 + struct IntentParameter: Identifiable { 12 + let id: UUID = UUID() 13 + let name: LocalizedStringResource 14 + let description: LocalizedStringResource? 15 + let type: LocalizedStringResource 16 + } 17 + 18 + struct AppEntityResult: Identifiable { 19 + let id: UUID = UUID() 20 + let name: LocalizedStringResource 21 + let description: LocalizedStringResource 22 + let parameters: [IntentParameter] 23 + } 24 + 25 + struct DocUrlLink { 26 + let text: String 27 + let url: URL 28 + } 29 + 30 + struct IntentDoc: Identifiable { 31 + let id: UUID = UUID() 32 + let icon: String 33 + let name: LocalizedStringResource 34 + let description: LocalizedStringResource 35 + let parameters: [IntentParameter] 36 + let result: AppEntityResult? 37 + let docUrl: DocUrlLink? 38 + } 39 + 40 + struct Example { 41 + let id: UUID = UUID() 42 + let title: LocalizedStringResource 43 + let icon: String 44 + let description: LocalizedStringResource 45 + let shortcutActionsUsed: [LocalizedStringResource] 46 + let url: URL? 47 + } 48 + 49 + struct IntentsDocumentation { 50 + func createARecordDoc() -> IntentDoc { 51 + let createARecordIntent = CreateARecordIntent() 52 + return IntentDoc( 53 + icon: "document.badge.plus", 54 + name: CreateARecordIntent.title, 55 + description: CreateARecordIntent.description.descriptionText, 56 + parameters: [ 57 + IntentParameter( 58 + name: createARecordIntent.$atIdentifier.title, 59 + description: AtIdentifierAppEntity.typeDescription, 60 + type: AtIdentifierAppEntity.typeDisplayName, 61 + ), 62 + IntentParameter( 63 + name: createARecordIntent.$collection.title, 64 + description: "The collection you want to write to, like app.bsky.feed.post", 65 + type: "NSID", 66 + ), 67 + IntentParameter( 68 + name: createARecordIntent.$recordKey.title, 69 + description: 70 + "The record key for the new record, optional. A TID will be used if not provided", 71 + type: "Record Key", 72 + ), 73 + IntentParameter( 74 + name: createARecordIntent.$shouldValidate.title, 75 + description: 76 + "You will probably not use this unless you are writing known atproto records. i.e. the ones found in the atproto repo.", 77 + type: "Boolean", 78 + ), 79 + IntentParameter( 80 + name: createARecordIntent.$record.title, 81 + description: 82 + "This is most likely a Dictionary Variable, or a JSON file. But it is the supplied atproto record to be created. We add the $type from the type parameter to this record", 83 + type: "JSON serializable object", 84 + ), 85 + ], 86 + result: AppEntityResult( 87 + name: StrongReferenceAppEntity.typeDisplayName, 88 + description: StrongReferenceAppEntity.typeDescription, 89 + parameters: [ 90 + IntentParameter( 91 + name: StrongReferenceAppEntity.init().$recordURI.title, 92 + description: "The URI of the record that was just created", 93 + type: "at-uri"), 94 + 95 + IntentParameter( 96 + name: StrongReferenceAppEntity.init().$recordCID.title, 97 + description: "The CID of the version of the record", 98 + type: "CID"), 99 + ], 100 + 101 + ), 102 + docUrl: DocUrlLink( 103 + text: "Bluesky's API documentation", 104 + url: URL(string: "https://docs.bsky.app/docs/api/com-atproto-repo-create-record")!) 105 + ) 106 + } 107 + 108 + func createATIDDoc() -> IntentDoc { 109 + let createATIDIntent = CreateATidIntent() 110 + 111 + return IntentDoc( 112 + icon: "clock", name: CreateATidIntent.title, 113 + description: CreateATidIntent.description.descriptionText, 114 + parameters: [ 115 + IntentParameter( 116 + name: createATIDIntent.$date.title, 117 + description: 118 + "Creates a TID from the given datetime, if one is not given uses the current devices datetime. For best results make sure you use \"Date Format: Long\" and \"Time Format: Long\" on the date parameter", 119 + type: LocalizedStringResource(stringLiteral: "DateTime")), 120 + IntentParameter( 121 + name: createATIDIntent.$clockIdentifier.title, 122 + description: 123 + "(Optional) Used to create the same TID from the same DateTime. Helpful if you have a record you want to update and used as a unique ID for that record", 124 + type: LocalizedStringResource(stringLiteral: "Int")), 125 + 126 + ], 127 + result: 128 + AppEntityResult( 129 + name: "TID", 130 + description: 131 + "A TID (\"timestamp identifier\") is a compact string identifier based on an integer timestamp. They are sortable, appropriate for use in web URLs, and useful as a \"logical clock\" in networked systems. TIDs are currently used in atproto as record keys and for repository commit \"revision\" numbers.", 132 + parameters: [] 133 + ), 134 + docUrl: DocUrlLink( 135 + text: "TID Documentation", url: URL(string: "https://atproto.com/specs/tid")!) 136 + ) 137 + } 138 + 139 + func getALocalAtIdentifierDoc() -> IntentDoc { 140 + let getALocalAtIdentifierIntent = GetALocalAtIdentifierIntent() 141 + 142 + return IntentDoc( 143 + icon: "person", name: GetALocalAtIdentifierIntent.title, 144 + description: GetALocalAtIdentifierIntent.description.descriptionText, 145 + parameters: [ 146 + IntentParameter( 147 + name: getALocalAtIdentifierIntent.$handle.title, 148 + description: 149 + "The handle of the saved account you would like to retrieve. Ex @alice.bsky.social", 150 + type: "String") 151 + ], 152 + result: AppEntityResult( 153 + name: AtIdentifierAppEntity.typeDisplayName, 154 + description: AtIdentifierAppEntity.typeDescription?.localizedStringResource ?? "", 155 + parameters: []), 156 + docUrl: nil) 157 + } 158 + 159 + func getARecordDoc() -> IntentDoc { 160 + let getARecordIntent = GetARecordIntent() 161 + 162 + return IntentDoc( 163 + icon: "arrow.down.document", 164 + name: GetARecordIntent.title, 165 + description: GetARecordIntent.description.descriptionText, 166 + parameters: [ 167 + IntentParameter( 168 + name: getARecordIntent.$atIdentifier.title, 169 + description: "The handle or DID of the repo", 170 + type: "at-identifier"), 171 + IntentParameter( 172 + name: getARecordIntent.$collection.title, 173 + description: "The collection you want to write to, like app.bsky.feed.post", 174 + type: "NSID"), 175 + IntentParameter( 176 + name: getARecordIntent.$recordKey.title, 177 + description: "The record key for record", 178 + type: "Record Key"), 179 + IntentParameter( 180 + name: getARecordIntent.$cid.title, 181 + description: 182 + "The CID of the version of the record. If not specified, then return the most recent version", 183 + type: "CID"), 184 + ], 185 + result: AppEntityResult( 186 + name: RecordAppEntity.typeDisplayName, 187 + description: RecordAppEntity.typeDescription?.localizedStringResource ?? "", 188 + parameters: [ 189 + IntentParameter( 190 + name: "Record's URI", description: "The at-uri of the record", 191 + type: "at-uri"), 192 + IntentParameter( 193 + name: "Record's CID", description: "The CID of the version of the record", 194 + type: "CID"), 195 + IntentParameter( 196 + name: "Record's value", 197 + description: 198 + "The record it self in JSON format. Can use Get Dictionary Value to get a specific value", 199 + type: "JSON"), 200 + 201 + ]), 202 + docUrl: DocUrlLink( 203 + text: "Bluesky's API documentation", 204 + url: URL(string: "https://docs.bsky.app/docs/api/com-atproto-repo-get-record")!) 205 + ) 206 + } 207 + 208 + func makeAPostDoc() -> IntentDoc { 209 + let makeAPostIntent = MakeAPostIntent() 210 + return IntentDoc( 211 + icon: "signpost.right.and.left", name: MakeAPostIntent.title, 212 + description: MakeAPostIntent.description.descriptionText, 213 + parameters: [ 214 + IntentParameter( 215 + name: makeAPostIntent.$atIdentifierToUse.title, 216 + description: "The saved AT Identifier you'd like make a Bluesky post to", 217 + type: AtIdentifierAppEntity.typeDisplayName), 218 + IntentParameter( 219 + name: makeAPostIntent.$replyTo.title, 220 + description: 221 + "The at-uri of the post you want to reply to. Can find it in the result of Bluesky Post if you are making a thread", 222 + type: "at-uri"), 223 + IntentParameter( 224 + name: makeAPostIntent.$images.title, 225 + description: "Select up to 4 images to upload. Supports .jpeg, .png, and .heic", 226 + type: "jpeg, .png, and .heic"), 227 + IntentParameter( 228 + name: makeAPostIntent.$altText.title, 229 + description: 230 + "Add up to 4 alt text descriptions for your images. These will be added in the same order as the images", 231 + type: "Array of Strings"), 232 + IntentParameter( 233 + name: makeAPostIntent.$tags.title, 234 + description: 235 + "Additional hashtags, in addition to any included in post text and facets. Useful if you want to cross post to Flashes with \"flashes-app-ln3348nvl89\"", 236 + type: "Array of Strings"), 237 + ], 238 + result: AppEntityResult( 239 + name: StrongReferenceAppEntity.typeDisplayName, 240 + description: StrongReferenceAppEntity.typeDescription, 241 + parameters: [ 242 + IntentParameter( 243 + name: StrongReferenceAppEntity.init().$recordURI.title, 244 + description: 245 + "The URI of the post that was just created. Can use this in \"Reply to Record URI\" to make a thread", 246 + type: "at-uri"), 247 + 248 + IntentParameter( 249 + name: StrongReferenceAppEntity.init().$recordURI.title, 250 + description: "The URI the record is strongly referencing", 251 + type: "at-uri"), 252 + ], 253 + 254 + ), 255 + docUrl: nil) 256 + } 257 + 258 + func putARecordDoc() -> IntentDoc { 259 + let putARecordDoc = PutARecordIntent() 260 + return IntentDoc( 261 + icon: "arrow.up.document", name: PutARecordIntent.title, 262 + description: PutARecordIntent.description?.descriptionText ?? "", 263 + parameters: [ 264 + IntentParameter( 265 + name: putARecordDoc.$atIdentifier.title, 266 + description: AtIdentifierAppEntity.typeDescription, 267 + type: AtIdentifierAppEntity.typeDisplayName, 268 + ), 269 + IntentParameter( 270 + name: putARecordDoc.$collection.title, 271 + description: "The collection you want to write to, like app.bsky.feed.post", 272 + type: "NSID"), 273 + IntentParameter( 274 + name: putARecordDoc.$recordKey.title, 275 + description: 276 + "The record key for the new post, optional. A tid will be used if not provided", 277 + type: "Record Key"), 278 + IntentParameter( 279 + name: putARecordDoc.$shouldValidate.title, 280 + description: 281 + "You will probably not use this unless you are writing known atproto records. i.e. the ones found in the atproto repo", 282 + type: "Boolean"), 283 + IntentParameter( 284 + name: putARecordDoc.$record.title, 285 + description: 286 + "This is most likely a Dictionary Variable, or a JSON file. But it is the supplied atproto record. We add the $type from the type parameter to this record", 287 + type: "JSON serializable object"), 288 + ], 289 + result: 290 + AppEntityResult( 291 + name: StrongReferenceAppEntity.typeDisplayName, 292 + description: StrongReferenceAppEntity.typeDescription, 293 + parameters: [ 294 + IntentParameter( 295 + name: StrongReferenceAppEntity.init().$recordURI.title, 296 + description: "The URI of the record that was just created or updated", 297 + type: "at-uri"), 298 + 299 + IntentParameter( 300 + name: StrongReferenceAppEntity.init().$recordCID.title, 301 + description: "The CID of the version of the record", 302 + type: "CID"), 303 + ], 304 + 305 + ), 306 + 307 + docUrl: DocUrlLink( 308 + text: "Bluesky's API documentation", 309 + url: URL(string: "https://docs.bsky.app/docs/api/com-atproto-repo-put-record")!) 310 + ) 311 + } 312 + 313 + func listRecordsDoc() -> IntentDoc { 314 + let listRecordsIntent = ListRecordsIntent() 315 + return IntentDoc( 316 + icon: "list.bullet", 317 + name: ListRecordsIntent.title, 318 + description: ListRecordsIntent.description.descriptionText, 319 + parameters: [ 320 + IntentParameter( 321 + name: listRecordsIntent.$atIdentifier.title, 322 + description: "The handle or DID of the repo", type: "at-identifier"), 323 + IntentParameter( 324 + name: listRecordsIntent.$collection.title, 325 + description: "The collection you want to write to, like app.bsky.feed.post", 326 + type: "NSID"), 327 + IntentParameter( 328 + name: listRecordsIntent.$limit.title, 329 + description: 330 + "The number of records to return. Between 2 and 100, defaults to 50", 331 + type: "Int"), 332 + IntentParameter( 333 + name: listRecordsIntent.$cursor.title, 334 + description: 335 + "This can be a Record Key to used as the last seen record top paginate the results", 336 + type: "String"), 337 + IntentParameter( 338 + name: listRecordsIntent.$reverse.title, 339 + description: "Flag to reverse the order of the returned records", 340 + type: "Boolean"), 341 + 342 + ], 343 + result: AppEntityResult( 344 + name: ListRecordsIntent.title, 345 + description: ListRecordsIntent.description.descriptionText, 346 + parameters: [ 347 + IntentParameter( 348 + name: ListRecordsAppEntity().$cursor.title, 349 + description: "The last record's key to use in pagination", 350 + type: "Record Key"), 351 + IntentParameter( 352 + name: ListRecordsAppEntity().$cursor.title, 353 + description: 354 + "An array of records. Same type as the Get A Record action. The uri, cid, and the value of the record", 355 + type: "JSON serializable object"), 356 + 357 + ]), 358 + docUrl: DocUrlLink( 359 + text: "Bluesky's API documentation", 360 + url: URL( 361 + string: "https://docs.bsky.app/docs/api/com-atproto-repo-list-records")!)) 362 + } 363 + 364 + func resolveADidOrHandleDoc() -> IntentDoc { 365 + 366 + return IntentDoc( 367 + icon: "person.crop.badge.magnifyingglass", name: ResolveDidOrHandleIntent.title, 368 + description: ResolveDidOrHandleIntent.description.descriptionText, 369 + parameters: [ 370 + IntentParameter( 371 + name: "DID or Handle", 372 + description: 373 + "Either the handle if you want to resolve the DID. Or a DID if you want to resolve the handle. If the handle is wrong there is a bit of a delay for a timeout", 374 + type: "at-identifier") 375 + ], 376 + result: AppEntityResult( 377 + name: "DID or Handle", 378 + description: 379 + "Will either return the user's DID if you pass in a handle, or the handle if you pass in a DID", 380 + parameters: []), docUrl: nil 381 + 382 + ) 383 + 384 + } 385 + 386 + func deleteARecordDoc() -> IntentDoc { 387 + let deleteARecordIntent = DeleteARecordIntent() 388 + return IntentDoc( 389 + icon: "trash", 390 + name: DeleteARecordIntent.title, 391 + description: DeleteARecordIntent.description.descriptionText, 392 + parameters: [ 393 + IntentParameter( 394 + name: deleteARecordIntent.$atIdentifier.title, 395 + description: AtIdentifierAppEntity.typeDescription, 396 + type: AtIdentifierAppEntity.typeDisplayName, 397 + ), 398 + IntentParameter( 399 + name: deleteARecordIntent.$collection.title, 400 + description: 401 + "The collection you want to delete a record from, like app.bsky.feed.post", 402 + type: "NSID", 403 + ), 404 + IntentParameter( 405 + name: deleteARecordIntent.$recordKey.title, 406 + description: "The record key for the record you want to delete", 407 + type: "Record Key", 408 + ), 409 + ], 410 + result: AppEntityResult( 411 + name: "No Result", 412 + description: "This action does not return a result", 413 + parameters: [], 414 + ), 415 + docUrl: DocUrlLink( 416 + text: "Bluesky's API documentation", 417 + url: URL(string: "https://docs.bsky.app/docs/api/com-atproto-repo-delete-record")!) 418 + ) 419 + } 420 + 421 + func createUpdateProfileDoc() -> IntentDoc { 422 + let updateProfileIntent = UpdateProfileIntent() 423 + return IntentDoc( 424 + icon: "person.crop.circle.badge.plus", 425 + name: UpdateProfileIntent.title, 426 + description: UpdateProfileIntent.description.descriptionText, 427 + parameters: [ 428 + IntentParameter( 429 + name: updateProfileIntent.$atIdentifier.title, 430 + description: AtIdentifierAppEntity.typeDescription, 431 + type: AtIdentifierAppEntity.typeDisplayName, 432 + ), 433 + IntentParameter( 434 + name: updateProfileIntent.$displayName.title, 435 + description: 436 + "Enter a value if you would like to update your display name. Leave empty to not update your display name", 437 + type: "String", 438 + ), 439 + IntentParameter( 440 + name: updateProfileIntent.$description.title, 441 + description: 442 + "Enter a value if you would like to update your profile's description. Leave empty to not update your profile's description", 443 + type: "String", 444 + ), 445 + IntentParameter( 446 + name: updateProfileIntent.$profilePic.title, 447 + description: 448 + "Updates your profile picture if provided, if not keeps the current one", 449 + type: "jpeg, .png, and .heic", 450 + ), 451 + IntentParameter( 452 + name: updateProfileIntent.$bannerPic.title, 453 + description: 454 + "Updates your banner picture if provided, if not keeps the current one", 455 + type: "jpeg, .png, and .heic", 456 + ), 457 + ], 458 + result: AppEntityResult( 459 + name: StrongReferenceAppEntity.typeDisplayName, 460 + description: StrongReferenceAppEntity.typeDescription, 461 + parameters: [ 462 + IntentParameter( 463 + name: StrongReferenceAppEntity.init().$recordURI.title, 464 + description: "The URI of the profile record that was just updated", 465 + type: "at-uri"), 466 + 467 + IntentParameter( 468 + name: StrongReferenceAppEntity.init().$recordCID.title, 469 + description: "The CID of the version of the updated profile record", 470 + type: "CID"), 471 + ], 472 + 473 + ), 474 + docUrl: nil) 475 + } 476 + 477 + func getRepoDoc() -> IntentDoc { 478 + let getRepoIntent = GetRepoIntent() 479 + return IntentDoc( 480 + icon: "arrow.down.circle", 481 + name: GetRepoIntent.title, 482 + description: GetRepoIntent.description.descriptionText, 483 + parameters: [ 484 + IntentParameter( 485 + name: getRepoIntent.$atIdentifier.title, 486 + description: "The handle or DID of the repo", 487 + type: "String" 488 + ), 489 + IntentParameter( 490 + name: getRepoIntent.$since.title, 491 + description: "A TID created from a timestamp of since when to get the record", 492 + type: "String" 493 + ), 494 + ], 495 + result: AppEntityResult( 496 + name: "Repo CAR File", 497 + description: "Returns a CAR file containing the user's repo data", 498 + parameters: [ 499 + IntentParameter( 500 + name: "CAR File", 501 + description: "The downloaded repository as a CAR file.", 502 + type: ".CAR File" 503 + ) 504 + ] 505 + ), 506 + docUrl: DocUrlLink( 507 + text: "Bluesky's API documentation", 508 + url: URL(string: "https://docs.bsky.app/docs/api/com-atproto-sync-get-repo")!) 509 + ) 510 + } 511 + 512 + func getListBlobsDoc() -> IntentDoc { 513 + let listBlobsIntent = ListBlobsIntent() 514 + return IntentDoc( 515 + icon: "list.bullet.circle", 516 + name: ListBlobsIntent.title, 517 + description: ListBlobsIntent.description.descriptionText, 518 + parameters: [ 519 + IntentParameter( 520 + name: listBlobsIntent.$atIdentifier.title, 521 + description: "The handle or DID of the repo", 522 + type: "String" 523 + ), 524 + IntentParameter( 525 + name: listBlobsIntent.$since.title, 526 + description: 527 + "A TID created from a timestamp of since when to get the blob's cids", 528 + type: "String" 529 + ), 530 + IntentParameter( 531 + name: listBlobsIntent.$limit.title, 532 + description: "Limit of blobs to return. Max 1000, default 500", 533 + type: "Int" 534 + ), 535 + IntentParameter( 536 + name: listBlobsIntent.$cursor.title, 537 + description: "The cursor to paginate through the blobs", 538 + type: "String" 539 + ), 540 + ], 541 + result: AppEntityResult( 542 + name: "List of Blobs", 543 + description: "Returns a list of blob CIDs and cursor for pagination", 544 + parameters: [ 545 + IntentParameter( 546 + name: "CIDs", 547 + description: "Array of blob content identifiers", 548 + type: "[String]" 549 + ), 550 + IntentParameter( 551 + name: "Cursor", 552 + description: "Pagination cursor for next batch of results", 553 + type: "String" 554 + ), 555 + ] 556 + ), 557 + docUrl: DocUrlLink( 558 + text: "Bluesky's API documentation", 559 + url: URL(string: "https://docs.bsky.app/docs/api/com-atproto-sync-list-blobs")!) 560 + ) 561 + } 562 + 563 + func getDownloadBlobsDoc() -> IntentDoc { 564 + let downloadBlobsIntent = DownloadBlobsIntent() 565 + return IntentDoc( 566 + icon: "arrow.down.to.line.circle", 567 + name: DownloadBlobsIntent.title, 568 + description: DownloadBlobsIntent.description.descriptionText, 569 + parameters: [ 570 + IntentParameter( 571 + name: downloadBlobsIntent.$atIdentifier.title, 572 + description: "The handle or DID of the repo", 573 + type: "String" 574 + ), 575 + IntentParameter( 576 + name: downloadBlobsIntent.$cids.title, 577 + description: "The CIDS of the blobs to download", 578 + type: "[String]" 579 + ), 580 + IntentParameter( 581 + name: downloadBlobsIntent.$maxConcurrent.title, 582 + description: 583 + "Number of blobs to download at once. Defaults to 2. This is more of an advance feature, if your downloads are timing out may try increasing this and downloading fewer blobs at once", 584 + type: "Int" 585 + ), 586 + IntentParameter( 587 + name: downloadBlobsIntent.$saveLocation.title, 588 + description: 589 + "Optional save location to speed up downloads if you are just wanting to save to a physical location", 590 + type: "IntentFile" 591 + ), 592 + ], 593 + result: AppEntityResult( 594 + name: "Downloaded Files", 595 + description: "Returns an array of downloaded blob files", 596 + parameters: [ 597 + IntentParameter( 598 + name: "Files", 599 + description: "Array of downloaded blob files", 600 + type: "[IntentFile]" 601 + ) 602 + ] 603 + ), 604 + docUrl: DocUrlLink( 605 + text: "Bluesky's API documentation", 606 + url: URL(string: "https://docs.bsky.app/docs/api/com-atproto-sync-get-blob")!) 607 + ) 608 + } 609 + 610 + func getServiceAuthDoc() -> IntentDoc { 611 + let getServiceAuthIntent = GetServiceAuthIntent() 612 + return IntentDoc( 613 + icon: "key.fill", 614 + name: GetServiceAuthIntent.title, 615 + description: GetServiceAuthIntent.description.descriptionText, 616 + parameters: [ 617 + IntentParameter( 618 + name: getServiceAuthIntent.$atIdentifier.title, 619 + description: 620 + "The saved AT Identifier of the account you want to use to authenticate with and create a JWT for", 621 + type: "AtIdentifierAppEntity" 622 + ), 623 + IntentParameter( 624 + name: getServiceAuthIntent.$lxm.title, 625 + description: 626 + "This is the XRPC method you want to set in the JWT's claim. like neat.atprotocol.app.privateMessage. Allows for scoping with the external service. Not required", 627 + type: "String" 628 + ), 629 + ], 630 + result: AppEntityResult( 631 + name: "JWT Token", 632 + description: "Returns a signed JWT token for service authentication", 633 + parameters: [ 634 + IntentParameter( 635 + name: "Token", 636 + description: "The signed JWT token valid for 60 seconds", 637 + type: "String" 638 + ) 639 + ] 640 + ), 641 + docUrl: DocUrlLink( 642 + text: "Bluesky's API documentation", 643 + url: URL( 644 + string: "https://docs.bsky.app/docs/api/com-atproto-server-get-service-auth")!) 645 + ) 646 + } 647 + 648 + // func createUTCDoc() -> IntentDoc { 649 + // let utcIntent = UTCIntent() 650 + // 651 + // return IntentDoc( 652 + // icon: "deskclock", 653 + // name: UTCIntent.title, 654 + // description: UTCIntent.description.descriptionText, 655 + // parameters: [ 656 + // IntentParameter( 657 + // name: utcIntent.$dateSource.title, 658 + // description: 659 + // "Choose whether to use the current datetime or specify a date. Select 'Current Time' to use the device's current time, or 'Specific Date' to provide a custom datetime.", 660 + // type: LocalizedStringResource(stringLiteral: "DateSource")), 661 + // IntentParameter( 662 + // name: utcIntent.$date.title, 663 + // description: 664 + // "Creates a UTC Timestamp from the given datetime, if one is not given uses the current devices time to create the timestamp. Make sure you use \"Date Format: Long\" and \"Time Format Long\"", 665 + // type: LocalizedStringResource(stringLiteral: "DateTime")), 666 + // ], 667 + // result: 668 + // AppEntityResult( 669 + // name: "UTC Timestamp", 670 + // description: 671 + // "A UTC ISO8601 timestamp string in the format yyyy-MM-dd'T'HH:mm:ss'Z'. This is the recommend format in most lexicons", 672 + // parameters: [] 673 + // ), 674 + // docUrl: DocUrlLink( 675 + // text: "Lexicon Documentation On Datetimes", 676 + // url: URL(string: "https://atproto.com/specs/lexicon#datetime")!) 677 + // ) 678 + // } 679 + 680 + } 681 + 682 + struct Examples { 683 + static func getExamples() -> [Example] { 684 + [ 685 + Example( 686 + title: "Create A Bluesky Post", icon: "pencil.and.scribble", 687 + description: 688 + "Create either a Text or Image post on Bluesky through input prompts.", 689 + shortcutActionsUsed: [MakeAPostIntent.title], 690 + url: URL( 691 + string: "https://www.icloud.com/shortcuts/653b69880e4d443caf02b2d719b4a26e")), 692 + 693 + Example( 694 + title: "Statusphere", icon: "globe", 695 + description: 696 + "[Statusphere](https://atproto.com/guides/applications) is an example of a simple AT Protocol app that the Bluesky team made to help users to get started on building applications on AT Protocol. This is a shortcut version. Choose from a list of emojis to update to your AT Protocol repo on how you are feeling. This example will create a xyz.statusphere.status record in your AT Protocol repo.", 697 + shortcutActionsUsed: [CreateARecordIntent.title], 698 + url: URL( 699 + string: "https://www.icloud.com/shortcuts/fb9dbf682aa24298b6a3cbb967d4040e")), 700 + 701 + Example( 702 + title: "Check Someone's Statusphere", icon: "person.crop.badge.magnifyingglass", 703 + description: 704 + "Check a handle's latest status on Statusphere. This example lists the records found in the xyz.statusphere.status and selects the newest one.", 705 + shortcutActionsUsed: [ListRecordsIntent.title], 706 + url: URL( 707 + string: "https://www.icloud.com/shortcuts/e56a5529215b4416a219923c10cce450")), 708 + 709 + Example( 710 + title: "Like A Bluesky Post", icon: "hand.thumbsup", 711 + description: 712 + "Click share on a Bluesky post and select the \"Like A Bluesky Post\" shortcut to manually create a record and like the post.", 713 + shortcutActionsUsed: [GetARecordIntent.title, CreateARecordIntent.title], 714 + url: URL( 715 + string: "https://www.icloud.com/shortcuts/f33e2b5d87724c239a3b60895818b211")), 716 + 717 + Example( 718 + title: "Add User To A List", icon: "list.clipboard", 719 + description: 720 + "Click share on a Bluesky post and select the \"Add User To A List\" to quickly add a user to a list. This shortcut also shows an example on how to find a saved user's account by their handle.", 721 + shortcutActionsUsed: [ 722 + GetALocalAtIdentifierIntent.title, ListRecordsIntent.title, 723 + CreateARecordIntent.title, 724 + ], 725 + url: URL( 726 + string: "https://www.icloud.com/shortcuts/1fd6b0fe4d464315b3a3f5166795ef68")), 727 + 728 + Example( 729 + title: "Update Your Bluesky Profile", icon: "person.crop.circle", 730 + description: 731 + "Update your Bluesky Profile description to the current song playing on Apple Music. This also updates the banner image to the current album art.", 732 + shortcutActionsUsed: [ 733 + UpdateProfileIntent.title 734 + ], 735 + url: URL( 736 + string: "https://www.icloud.com/shortcuts/12976af4e00b437593a67838f276cdec")), 737 + 738 + Example( 739 + title: "Backup Your Account", icon: "folder.badge.person.crop", 740 + description: 741 + "Backup your AT Protocol/Bluesky account to your phone. This shortcut downloads a copy of your records as a car file and downloads all your blobs(photos and videos) to a local folder you choose on your phone. May need to play around or run this short multiple times for first download. After each run it's faster since it's only downloading new blobs. The shortcut lists 250 of your blobs 10 times and downloads them 2 at a time, so may need to find settings that suit your network and phone a bit better.", 742 + shortcutActionsUsed: [ 743 + ResolveDidOrHandleIntent.title, 744 + GetRepoIntent.title, 745 + ListBlobsIntent.title, 746 + DownloadBlobsIntent.title, 747 + 748 + ], 749 + url: URL( 750 + string: "https://www.icloud.com/shortcuts/7c00fabe8b3e4b4492b20922277d9f0a")), 751 + ] 752 + } 753 + 754 + }
+705
shortcut/Managers/AtProtocolManager.swift
···
··· 1 + // 2 + // AtProtocolManager.swift 3 + // shortcut 4 + // 5 + // Created by Bailey Townsend on 6/24/25. 6 + // 7 + 8 + import ATCommonWeb 9 + import ATIdentityTools 10 + import ATProtoKit 11 + import Foundation 12 + import SwiftData 13 + import SwiftUI 14 + 15 + class AtProtocolManager: ObservableObject { 16 + var modelContext: ModelContext 17 + var resolvers: Resolvers = Resolvers() 18 + 19 + init(modelContext: ModelContext) { 20 + self.modelContext = modelContext 21 + 22 + } 23 + 24 + init() { 25 + let sharedModelContainer: ModelContainer = { 26 + let schema = Schema([ 27 + UserSessionModel.self 28 + ]) 29 + let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) 30 + 31 + do { 32 + return try ModelContainer(for: schema, configurations: [modelConfiguration]) 33 + } catch { 34 + fatalError("Could not create ModelContainer: \(error)") 35 + } 36 + }() 37 + 38 + self.modelContext = ModelContext(sharedModelContainer) 39 + 40 + } 41 + 42 + public func resolveDidDocument(handle: String) async throws -> CommonDIDDocument { 43 + 44 + do { 45 + guard let did = try await self.resolvers.resolveDid(handle: handle) 46 + else { 47 + throw LoginError.handleDoesNotResolve 48 + } 49 + return try await self.resolvers.getDidDoc(did: did) 50 + 51 + } catch { 52 + throw error 53 + } 54 + } 55 + 56 + public func resolveDidDocument(did: String) async throws -> CommonDIDDocument { 57 + do { 58 + return try await self.resolvers.getDidDoc(did: did) 59 + 60 + } catch { 61 + throw error 62 + } 63 + } 64 + 65 + /// If throws login is not successful 66 + public func handleLogin(didDocument: CommonDIDDocument?, handle: String, password: String) 67 + async throws 68 + { 69 + do { 70 + let pdsURL = didDocument?.getPDSEndpoint()?.absoluteString ?? "https://bsky.social" 71 + let keyChainInstance = AppleSecureKeychain() 72 + let instanceID = await keyChainInstance.identifier 73 + print("Instance \(instanceID)") 74 + let userSessionRegistry = await PersistentUserSessionRegistry( 75 + modelContext: modelContext) 76 + let config = ATProtocolConfiguration( 77 + pdsURL: pdsURL, 78 + keychainProtocol: keyChainInstance, 79 + userSessionRegistry: userSessionRegistry, 80 + ) 81 + 82 + try await config.authenticate(with: handle, password: password) 83 + 84 + let atProto = await ATProtoKit( 85 + sessionConfiguration: config, pdsURL: pdsURL, 86 + userSessionRegistry: userSessionRegistry) 87 + 88 + let fetchDescriptor = FetchDescriptor<UserSessionModel>( 89 + predicate: #Predicate { $0.id == instanceID } 90 + ) 91 + let userSessions = try modelContext.fetch(fetchDescriptor) 92 + if let userSession = userSessions.first { 93 + // Use userSession as needed 94 + print("User session found for id: \(userSession.id)") 95 + 96 + let profile = try await atProto.getProfile(for: userSession.sessionDID) 97 + userSession.profilePicture = profile.avatarImageURL 98 + try self.modelContext.save() 99 + } else { 100 + //Probably will not be fatal but will wait and see 101 + print("No user session found for id: \(instanceID)") 102 + } 103 + 104 + } catch { 105 + print("Error: \(error)") 106 + throw error 107 + } 108 + } 109 + 110 + public func deleteSession(sessionID: UUID) async throws { 111 + do { 112 + //Clean up keychain items 113 + let keyChainInstance = AppleSecureKeychain(identifier: sessionID) 114 + try await keyChainInstance.deleteRefreshToken() 115 + try await keyChainInstance.deleteAccessToken() 116 + try await keyChainInstance.deletePassword() 117 + 118 + try modelContext.delete( 119 + model: UserSessionModel.self, where: #Predicate { $0.id == sessionID }) 120 + } catch { 121 + 122 + print("Error deleting session: \(error)") 123 + 124 + throw error 125 + } 126 + } 127 + 128 + public func checkIfAccountExists(handle: String) async -> Bool { 129 + let fetchDescriptor = FetchDescriptor<UserSessionModel>( 130 + predicate: #Predicate { $0.handle == handle } 131 + ) 132 + do { 133 + let userSessions = try modelContext.fetch(fetchDescriptor) 134 + return !userSessions.isEmpty 135 + } catch { 136 + print("Error checking account existence: \(error)") 137 + return false 138 + } 139 + 140 + } 141 + 142 + public func getSession(sessionID: UUID) async throws -> UserSessionModel? { 143 + let fetchDescriptor = FetchDescriptor<UserSessionModel>( 144 + predicate: #Predicate { $0.id == sessionID } 145 + ) 146 + do { 147 + return try modelContext.fetch(fetchDescriptor).first 148 + } catch { 149 + print("Error checking account existence: \(error)") 150 + throw error 151 + } 152 + } 153 + 154 + public func getSessions(sessionIDs: [UUID]) async throws -> [UserSessionModel] { 155 + let fetchDescriptor = FetchDescriptor<UserSessionModel>( 156 + predicate: #Predicate { sessionIDs.contains($0.id) } 157 + ) 158 + do { 159 + return try modelContext.fetch(fetchDescriptor) 160 + } catch { 161 + print("Error checking account existence: \(error)") 162 + throw error 163 + } 164 + } 165 + 166 + public func getAllSessions() async throws -> [UserSessionModel] { 167 + let fetchDescriptor = FetchDescriptor<UserSessionModel>(sortBy: [SortDescriptor(\.handle)]) 168 + do { 169 + return try modelContext.fetch(fetchDescriptor) 170 + } catch { 171 + print("Error checking account existence: \(error)") 172 + throw error 173 + } 174 + } 175 + 176 + public func getSessionByHandle(handle: String) async throws -> UserSessionModel? { 177 + let cleanedHandle = handle.replacingOccurrences(of: "@", with: "").localizedLowercase 178 + let fetchDescriptor = FetchDescriptor<UserSessionModel>( 179 + predicate: #Predicate { $0.handle == cleanedHandle } 180 + ) 181 + do { 182 + return try modelContext.fetch(fetchDescriptor).first 183 + } catch { 184 + print("Error checking account existence: \(error)") 185 + throw error 186 + } 187 + } 188 + 189 + public func getSessionByDid(did: String) async throws -> UserSessionModel? { 190 + let cleanDid = did.localizedLowercase 191 + let fetchDescriptor = FetchDescriptor<UserSessionModel>( 192 + predicate: #Predicate { $0.sessionDID == cleanDid } 193 + ) 194 + do { 195 + return try modelContext.fetch(fetchDescriptor).first 196 + } catch { 197 + print("Error checking account existence: \(error)") 198 + throw error 199 + } 200 + } 201 + 202 + public func refreshUserSession(_ atProto: ATProtoKit) async throws { 203 + guard let config = atProto.sessionConfiguration else { 204 + throw ShortcutErrors.AuthError("No session configuration found.") 205 + } 206 + let sessionId = config.instanceUUID 207 + do { 208 + try await config.refreshSession() 209 + 210 + } catch { 211 + throw ShortcutErrors.AuthError( 212 + "There was an error refreshing your session. Please remove and login to your AT Protocol account via the app." 213 + ) 214 + } 215 + 216 + let fetchDescriptor = FetchDescriptor<UserSessionModel>( 217 + predicate: #Predicate { $0.id == sessionId } 218 + ) 219 + let userSessions = try modelContext.fetch(fetchDescriptor) 220 + if let userSession = userSessions.first { 221 + // Use userSession as needed 222 + print("User session found for id: \(userSession.id)") 223 + 224 + let profile = try await atProto.getProfile(for: userSession.sessionDID) 225 + userSession.profilePicture = profile.avatarImageURL 226 + try self.modelContext.save() 227 + } else { 228 + //Probably will not be fatal but will wait and see 229 + print("No user session found for id: \(sessionId)") 230 + } 231 + 232 + } 233 + 234 + private func getAtProtoKit(sessionId: UUID) async throws -> ATProtoKit { 235 + let keyChainInstance = AppleSecureKeychain(identifier: sessionId) 236 + let userSessionRegistry = await PersistentUserSessionRegistry( 237 + modelContext: modelContext) 238 + let userSession = await userSessionRegistry.getSession(for: sessionId) 239 + guard let userSession else { 240 + throw ShortcutErrors.NoSession 241 + } 242 + 243 + let config = ATProtocolConfiguration( 244 + pdsURL: userSession.pdsURL ?? "https://bsky.social", 245 + keychainProtocol: keyChainInstance, 246 + userSessionRegistry: userSessionRegistry, 247 + ) 248 + let atProto = await ATProtoKit( 249 + sessionConfiguration: config, 250 + userSessionRegistry: userSessionRegistry) 251 + do { 252 + let accessToken = try await keyChainInstance.retrieveAccessToken() 253 + let decodedJWT = try SessionToken(sessionToken: accessToken) 254 + //20 second time skew to account for network latency 255 + if decodedJWT.payload.expiresAt <= Date().addingTimeInterval(-20) { 256 + do { 257 + try await refreshUserSession(atProto) 258 + } catch { 259 + throw ShortcutErrors.AuthError( 260 + "There was an error refreshing your session. Please reloggin via the app.") 261 + } 262 + 263 + } 264 + return atProto 265 + 266 + } catch let keyChainError as ApplSecureKeychainError { 267 + switch keyChainError { 268 + 269 + case .itemNotFound(let key): 270 + throw ShortcutErrors.AuthError( 271 + "Could not find the \(key) in the keychain. Please try re adding your account in the app" 272 + ) 273 + case .accessTokenNotFound: 274 + throw ShortcutErrors.AuthError( 275 + "Could not find the account locally. Please try re adding your account in the app" 276 + ) 277 + case .invalidData: 278 + throw ShortcutErrors.AuthError("Invalid data found in the keychain") 279 + case .unhandledStatus(let status): 280 + throw ShortcutErrors.AuthError( 281 + "Unhandled status \(status) when retrieving secure data from keychain") 282 + } 283 + } catch { 284 + throw ShortcutErrors.ErrorCreatingARecord(error.localizedDescription) 285 + } 286 + } 287 + 288 + public func getAtProtoKit(sessionModel: UserSessionModel) async throws -> ATProtoKit { 289 + let keyChainInstance = AppleSecureKeychain(identifier: sessionModel.id) 290 + let userSessionRegistry = await PersistentUserSessionRegistry( 291 + modelContext: modelContext) 292 + 293 + let config = ATProtocolConfiguration( 294 + pdsURL: sessionModel.pdsURL ?? "https://bsky.social", 295 + keychainProtocol: keyChainInstance, 296 + userSessionRegistry: userSessionRegistry, 297 + ) 298 + return await ATProtoKit( 299 + sessionConfiguration: config, 300 + userSessionRegistry: userSessionRegistry) 301 + } 302 + 303 + private func handleAtErrors(_ error: ATAPIError) -> Error { 304 + switch error { 305 + case .badRequest(let error): 306 + return ShortcutErrors.AuthError(error.message) 307 + case .internalServerError(let error): 308 + return ShortcutErrors.ErrorCreatingARecord(error.message) 309 + default: 310 + return ShortcutErrors.ErrorCreatingARecord(error.localizedDescription) 311 + } 312 + } 313 + 314 + public func makeAPost( 315 + sessionId: UUID, text: String, embed: ATProtoBluesky.EmbedIdentifier? = nil, 316 + replyTo: String? = nil, locales: [Locale] = [], tags: [String]? = nil, 317 + 318 + ) async throws 319 + -> ComAtprotoLexicon.Repository.StrongReference 320 + { 321 + do { 322 + let atProtoKit = try await self.getAtProtoKit(sessionId: sessionId) 323 + var replyRef: AppBskyLexicon.Feed.PostRecord.ReplyReference? = nil 324 + if let replyTo = replyTo { 325 + let tools = ATProtoTools() 326 + do { 327 + //TODO need to do a bit more error handling prob 328 + let userSession = await atProtoKit.userSessionRegistry.getSession( 329 + for: sessionId)! 330 + let strongRef = 331 + try await tools.fetchRecordForURI(replyTo, session: userSession) 332 + let reference = ComAtprotoLexicon.Repository.StrongReference( 333 + recordURI: strongRef.uri, cidHash: strongRef.cid) 334 + replyRef = try await tools.createReplyReference( 335 + from: reference, 336 + session: userSession) 337 + } catch { 338 + throw ShortcutErrors.ErrorCreatingARecord( 339 + "Error replying to the uri: \(replyTo )") 340 + } 341 + } 342 + let atProtoBluesky = ATProtoBluesky(atProtoKitInstance: atProtoKit) 343 + return try await atProtoBluesky.createPostRecord( 344 + text: text, locales: locales, replyTo: replyRef, embed: embed, tags: tags) 345 + } catch let error as ATAPIError { 346 + switch error { 347 + case .badRequest(let error): 348 + throw ShortcutErrors.AuthError(error.message) 349 + default: 350 + throw ShortcutErrors.ErrorCreatingARecord(error.localizedDescription) 351 + } 352 + } catch let shortCutError as ShortcutErrors { 353 + throw shortCutError 354 + } catch { 355 + throw ShortcutErrors.ErrorCreatingARecord(error.localizedDescription) 356 + } 357 + } 358 + 359 + public func createARecord( 360 + sessionId: UUID, 361 + repositoryDID: String, 362 + collection: String, 363 + recordKey: String? = nil, 364 + shouldValidate: Bool? = false, 365 + record: UnknownType 366 + ) async throws -> ComAtprotoLexicon.Repository.StrongReference { 367 + 368 + do { 369 + let atProtoKit = try await self.getAtProtoKit(sessionId: sessionId) 370 + 371 + return try await atProtoKit.createRecord( 372 + repositoryDID: repositoryDID, collection: collection, 373 + recordKey: recordKey, shouldValidate: shouldValidate, record: record) 374 + 375 + } catch let error as ATAPIError { 376 + throw handleAtErrors(error) 377 + } catch let genericIntentError as GenericIntentError { 378 + throw genericIntentError 379 + } catch { 380 + throw GenericIntentError.message(error.localizedDescription) 381 + } 382 + } 383 + 384 + public func putARecord( 385 + sessionId: UUID, 386 + repositoryDID: String, 387 + collection: String, 388 + recordKey: String, 389 + shouldValidate: Bool? = false, 390 + record: UnknownType 391 + ) async throws -> ComAtprotoLexicon.Repository.StrongReference { 392 + 393 + do { 394 + let atProtoKit = try await self.getAtProtoKit(sessionId: sessionId) 395 + return try await atProtoKit.putRecord( 396 + repository: repositoryDID, collection: collection, 397 + recordKey: recordKey, shouldValidate: shouldValidate, record: record) 398 + 399 + } catch let error as ATAPIError { 400 + throw handleAtErrors(error) 401 + } catch let genericIntentError as GenericIntentError { 402 + throw genericIntentError 403 + } catch { 404 + throw GenericIntentError.message(error.localizedDescription) 405 + } 406 + } 407 + 408 + public func getARecord( 409 + pdsURL: String, 410 + repositoryDID: String, 411 + collection: String, 412 + recordKey: String, 413 + recordCID: String? 414 + ) async throws -> ComAtprotoLexicon.Repository.GetRecordOutput { 415 + 416 + do { 417 + let config = ATProtocolConfiguration(pdsURL: pdsURL) 418 + 419 + let atProtoKit = await ATProtoKit(sessionConfiguration: config, pdsURL: pdsURL) 420 + 421 + return try await atProtoKit.getRepositoryRecord( 422 + from: repositoryDID, collection: collection, recordKey: recordKey, 423 + recordCID: recordCID) 424 + 425 + } catch let error as ATAPIError { 426 + switch error { 427 + case .badRequest(let error): 428 + throw ShortcutErrors.AuthError(error.message) 429 + default: 430 + throw ShortcutErrors.ErrorCreatingARecord(error.localizedDescription) 431 + } 432 + } catch let genericIntentError as GenericIntentError { 433 + throw genericIntentError 434 + } catch { 435 + throw GenericIntentError.message(error.localizedDescription) 436 + } 437 + } 438 + 439 + public func listRecords( 440 + pdsURL: String, 441 + repository: String, 442 + collection: String, 443 + limit: Int?, 444 + cursor: String?, 445 + isArrayReverse: Bool? 446 + ) async throws -> ComAtprotoLexicon.Repository.ListRecordsOutput { 447 + do { 448 + let config = ATProtocolConfiguration(pdsURL: pdsURL) 449 + 450 + let atProtoKit = await ATProtoKit(sessionConfiguration: config, pdsURL: pdsURL) 451 + 452 + return try await atProtoKit.listRecords( 453 + from: repository, collection: collection, limit: limit, cursor: cursor, 454 + isArrayReverse: isArrayReverse) 455 + 456 + } catch let error as ATAPIError { 457 + switch error { 458 + case .badRequest(let error): 459 + throw ShortcutErrors.AuthError(error.message) 460 + default: 461 + throw ShortcutErrors.ErrorCreatingARecord(error.localizedDescription) 462 + } 463 + } catch let genericIntentError as GenericIntentError { 464 + throw genericIntentError 465 + } catch { 466 + throw GenericIntentError.message(error.localizedDescription) 467 + } 468 + } 469 + 470 + public func deleteARecord( 471 + sessionId: UUID, 472 + repository: String, 473 + collection: String, 474 + recordKey: String 475 + ) async throws { 476 + do { 477 + 478 + let atProtoKit = try await self.getAtProtoKit(sessionId: sessionId) 479 + 480 + return try await atProtoKit.deleteRecord( 481 + repositoryDID: repository, collection: collection, recordKey: recordKey) 482 + 483 + } catch let error as ATAPIError { 484 + switch error { 485 + case .badRequest(let error): 486 + throw ShortcutErrors.AuthError(error.message) 487 + default: 488 + throw ShortcutErrors.ErrorCreatingARecord(error.localizedDescription) 489 + } 490 + } catch let genericIntentError as GenericIntentError { 491 + throw genericIntentError 492 + } catch { 493 + throw GenericIntentError.message(error.localizedDescription) 494 + } 495 + } 496 + 497 + public func updateProfile( 498 + sessionId: UUID, usersDID: String, updates: [ATProtoBluesky.UpdatedProfileRecordField] 499 + ) async throws -> ComAtprotoLexicon.Repository.StrongReference { 500 + do { 501 + 502 + let atProtoKit = try await self.getAtProtoKit(sessionId: sessionId) 503 + let profileAtUri = "at://\(usersDID)/app.bsky.actor.profile/self" 504 + let atProtoBluesky = ATProtoBluesky(atProtoKitInstance: atProtoKit) 505 + 506 + return try await atProtoBluesky.updateProfileRecord( 507 + profileURI: profileAtUri, 508 + replace: updates) 509 + 510 + } catch let error as ATAPIError { 511 + switch error { 512 + case .badRequest(let error): 513 + throw ShortcutErrors.AuthError(error.message) 514 + default: 515 + throw ShortcutErrors.ErrorCreatingARecord(error.localizedDescription) 516 + } 517 + } catch let shortcutError as ShortcutErrors { 518 + throw shortcutError 519 + } catch let genericIntentError as GenericIntentError { 520 + throw genericIntentError 521 + } catch { 522 + throw GenericIntentError.message(error.localizedDescription) 523 + } 524 + } 525 + 526 + public func downloadUsersRepo( 527 + did: String, 528 + pdsURL: String, 529 + revision: String? = nil 530 + ) async throws -> Data { 531 + do { 532 + let config = ATProtocolConfiguration(pdsURL: pdsURL) 533 + 534 + let atProtoKit = await ATProtoKit(sessionConfiguration: config, pdsURL: pdsURL) 535 + 536 + return try await atProtoKit.getRepository( 537 + by: did, sinceRevision: revision) 538 + 539 + } catch let error as ATAPIError { 540 + switch error { 541 + case .badRequest(let error): 542 + throw ShortcutErrors.AuthError(error.message) 543 + default: 544 + throw ShortcutErrors.ErrorCreatingARecord(error.localizedDescription) 545 + } 546 + } catch let shortcutError as ShortcutErrors { 547 + throw shortcutError 548 + } catch let genericIntentError as GenericIntentError { 549 + throw genericIntentError 550 + } catch { 551 + throw GenericIntentError.message(error.localizedDescription) 552 + } 553 + } 554 + 555 + public func listBlobs( 556 + repo: String, 557 + pdsURL: String, 558 + since: String? = nil, 559 + limit: Int = 500, 560 + cursor: String? = nil 561 + 562 + ) async throws -> ComAtprotoLexicon.Sync.ListBlobsOutput { 563 + do { 564 + let config = ATProtocolConfiguration(pdsURL: pdsURL) 565 + 566 + let atProtoKit = await ATProtoKit(sessionConfiguration: config, pdsURL: pdsURL) 567 + 568 + return try await atProtoKit.listBlobs( 569 + from: repo, sinceRevision: since, 570 + limit: limit, cursor: cursor) 571 + 572 + } catch let error as ATAPIError { 573 + switch error { 574 + case .badRequest(let error): 575 + throw ShortcutErrors.AuthError(error.message) 576 + default: 577 + throw ShortcutErrors.ErrorCreatingARecord(error.localizedDescription) 578 + } 579 + } catch let shortcutError as ShortcutErrors { 580 + throw shortcutError 581 + } catch let genericIntentError as GenericIntentError { 582 + throw genericIntentError 583 + } catch { 584 + throw GenericIntentError.message(error.localizedDescription) 585 + } 586 + } 587 + 588 + public func getBlobs( 589 + repo: String, 590 + pdsURL: String, 591 + cids: [String], 592 + ) async throws -> [Data] { 593 + do { 594 + let config = ATProtocolConfiguration(pdsURL: pdsURL) 595 + let atProtoKit = await ATProtoKit(sessionConfiguration: config, pdsURL: pdsURL) 596 + var blobData: [Data] = [] 597 + for cid in cids { 598 + blobData.append(try await atProtoKit.getBlob(from: repo, cid: cid)) 599 + } 600 + return blobData 601 + 602 + } catch let error as ATAPIError { 603 + switch error { 604 + case .badRequest(let error): 605 + throw ShortcutErrors.AuthError(error.message) 606 + default: 607 + throw ShortcutErrors.ErrorCreatingARecord(error.localizedDescription) 608 + } 609 + } catch let shortcutError as ShortcutErrors { 610 + throw shortcutError 611 + } catch let genericIntentError as GenericIntentError { 612 + throw genericIntentError 613 + } catch { 614 + throw GenericIntentError.message(error.localizedDescription) 615 + } 616 + } 617 + 618 + public func getServiceAuth( 619 + sessionId: UUID, 620 + usersDid: String, 621 + exp: Int?, 622 + lxm: String? = nil, 623 + ) async throws -> ComAtprotoLexicon.Server.GetServiceAuthOutput { 624 + 625 + do { 626 + let atProtoKit = try await self.getAtProtoKit(sessionId: sessionId) 627 + 628 + return try await atProtoKit.getServiceAuthentication( 629 + from: usersDid, expirationTime: exp, lexiconMethod: lxm) 630 + 631 + } catch let error as ATAPIError { 632 + throw handleAtErrors(error) 633 + } catch let genericIntentError as GenericIntentError { 634 + throw genericIntentError 635 + } catch { 636 + throw GenericIntentError.message(error.localizedDescription) 637 + } 638 + } 639 + 640 + /// Downloads blobs with optimized concurrent processing 641 + // public func getBlobsConcurrently( 642 + // repo: String, 643 + // pdsURL: String, 644 + // cids: [String], 645 + // maxConcurrent: Int = 10 646 + // ) async throws -> [Data] { 647 + // // Use TaskGroup for concurrent downloads with controlled parallelism 648 + // try await withThrowingTaskGroup(of: (Int, Data?).self) { group in 649 + // var results = [Data?](repeating: nil, count: cids.count) 650 + // 651 + // // Limit concurrent downloads 652 + // for (index, cid) in cids.enumerated() { 653 + // if index >= maxConcurrent { 654 + // // Wait for one to complete before adding more 655 + // if let (completedIndex, data) = try await group.next() { 656 + // results[completedIndex] = data 657 + // } 658 + // } 659 + // 660 + // group.addTask { 661 + // do { 662 + // let config = ATProtocolConfiguration(pdsURL: pdsURL) 663 + // let atProtoKit = await ATProtoKit( 664 + // sessionConfiguration: config, pdsURL: pdsURL) 665 + // let data = try await atProtoKit.getBlob(from: repo, cid: cid) 666 + // return (index, data) 667 + // } catch { 668 + // print("Failed to download blob at index \(index): \(error)") 669 + // return (index, nil) 670 + // } 671 + // } 672 + // } 673 + // 674 + // // Collect remaining results 675 + // for try await (index, data) in group { 676 + // results[index] = data 677 + // } 678 + // 679 + // // Filter out failed downloads 680 + // return results.compactMap { $0 } 681 + // } 682 + // } 683 + 684 + } 685 + 686 + public enum LoginError: Error { 687 + case handleDoesNotResolve 688 + case didDocumentNotFound 689 + } 690 + 691 + public enum ShortcutErrors: Error, LocalizedError { 692 + case NoSession 693 + case ErrorCreatingARecord(String) 694 + case AuthError(String) 695 + 696 + public var errorDescription: String? { 697 + switch self { 698 + case .NoSession: 699 + return "No session was found for that user. Please try logging in again in the app" 700 + case let .ErrorCreatingARecord(message): return "\(message)" 701 + case let .AuthError(message): return "\(message)" 702 + 703 + } 704 + } 705 + }
+74
shortcut/Models/UserSessionModel.swift
···
··· 1 + // 2 + // UserSessionModel.swift 3 + // shortcut 4 + // 5 + // Created by Bailey Townsend on 6/25/25. 6 + // 7 + 8 + import ATProtoKit 9 + import Foundation 10 + import SwiftData 11 + 12 + @Model 13 + class UserSessionModel { 14 + 15 + @Attribute(.unique) public var id: UUID 16 + 17 + /// The user's handle within the AT Protocol. 18 + public var handle: String 19 + 20 + /// The decentralized identifier (DID), serving as a persistent and long-term account 21 + /// identifier according to the W3C standard. 22 + public var sessionDID: String 23 + 24 + /// The user's email address. Optional. 25 + public var email: String? 26 + 27 + /// Indicates whether the user's email address has been confirmed. Optional. 28 + public var isEmailConfirmed: Bool? 29 + 30 + /// Indicates whether Two-Factor Authentication (via email) is enabled. Optional. 31 + public var isEmailAuthenticationFactorEnabled: Bool? 32 + 33 + /// The DID document associated with the user, which contains AT Protocol-specific 34 + /// information. Optional. 35 + // public var didDocument: DIDDocument? 36 + 37 + /// Indicates whether the user account is active. Optional. 38 + public var isActive: Bool? 39 + 40 + /// Indicates the possible reason for why the user account is inactive. Optional. 41 + public var status: UserAccountStatus? 42 + 43 + /// The user account's endpoint used for sending authentication requests. 44 + public var serviceEndpoint: URL 45 + 46 + /// The URL of the Personal Data Server (PDS) associated with the user. Optional. 47 + /// 48 + /// - Note: This is not included when initalizing `UserSession`. Instead, it's added 49 + /// after the successful initalizing. 50 + public var pdsURL: String? 51 + 52 + /// The URL of the user's profile picture. Optional since it is not part of the user session response. 53 + public var profilePicture: URL? 54 + 55 + public init( 56 + sessionId: UUID, handle: String, sessionDID: String, email: String? = nil, 57 + isEmailConfirmed: Bool? = nil, 58 + isEmailAuthenticationFactorEnabled: Bool? = nil, 59 + isActive: Bool? = nil, status: UserAccountStatus? = nil, 60 + serviceEndpoint: URL, pdsURL: String? = nil 61 + ) { 62 + self.id = sessionId 63 + self.handle = handle 64 + self.sessionDID = sessionDID 65 + self.email = email 66 + self.isEmailConfirmed = isEmailConfirmed 67 + self.isEmailAuthenticationFactorEnabled = isEmailAuthenticationFactorEnabled 68 + // self.didDocument = didDocument 69 + self.isActive = isActive 70 + self.status = status 71 + self.serviceEndpoint = serviceEndpoint 72 + self.pdsURL = pdsURL 73 + } 74 + }
+83
shortcut/Utils/DirectoryCleaner.swift
···
··· 1 + // 2 + // DirectoryCleaner.swift 3 + // shortcut 4 + // 5 + // Created by Bailey Townsend on 7/17/25. 6 + // 7 + 8 + import Foundation 9 + 10 + class DirectoryCleaner { 11 + 12 + // Get the total size of all files in a directory 13 + static func getTotalSize(of directory: URL) -> Int64 { 14 + let fileManager = FileManager.default 15 + var totalSize: Int64 = 0 16 + 17 + do { 18 + let contents = try fileManager.contentsOfDirectory( 19 + at: directory, includingPropertiesForKeys: [.fileSizeKey, .isDirectoryKey]) 20 + 21 + for fileURL in contents { 22 + let resourceValues = try fileURL.resourceValues(forKeys: [ 23 + .fileSizeKey, .isDirectoryKey, 24 + ]) 25 + 26 + // Skip directories 27 + if resourceValues.isDirectory == true { 28 + continue 29 + } 30 + 31 + let fileSize = Int64(resourceValues.fileSize ?? 0) 32 + totalSize += fileSize 33 + } 34 + } catch { 35 + print("Error reading directory: \(error)") 36 + } 37 + 38 + return totalSize 39 + } 40 + 41 + // Clear all files in a directory 42 + static func clearDirectory(_ directory: URL) throws { 43 + let fileManager = FileManager.default 44 + 45 + do { 46 + let contents = try fileManager.contentsOfDirectory( 47 + at: directory, includingPropertiesForKeys: [.isDirectoryKey]) 48 + 49 + for fileURL in contents { 50 + let resourceValues = try fileURL.resourceValues(forKeys: [.isDirectoryKey]) 51 + 52 + // Only remove files, not subdirectories 53 + if resourceValues.isDirectory != true { 54 + try fileManager.removeItem(at: fileURL) 55 + } 56 + } 57 + 58 + print("Directory cleared successfully") 59 + } catch { 60 + print("Error clearing directory: \(error)") 61 + throw error 62 + } 63 + } 64 + 65 + // Clear all files and subdirectories in a directory 66 + static func clearDirectoryCompletely(_ directory: URL) throws { 67 + let fileManager = FileManager.default 68 + 69 + do { 70 + let contents = try fileManager.contentsOfDirectory( 71 + at: directory, includingPropertiesForKeys: nil) 72 + 73 + for fileURL in contents { 74 + try fileManager.removeItem(at: fileURL) 75 + } 76 + 77 + print("Directory cleared completely") 78 + } catch { 79 + print("Error clearing directory: \(error)") 80 + throw error 81 + } 82 + } 83 + }
+207
shortcut/Views/AccountsView.swift
···
··· 1 + // 2 + // AccountsView.swift 3 + // shortcut 4 + // 5 + // Created by Bailey Townsend on 6/24/25. 6 + // 7 + 8 + import ATProtoKit 9 + import SwiftData 10 + import SwiftUI 11 + 12 + struct AccountsView: View { 13 + 14 + @EnvironmentObject var atProtocolManager: AtProtocolManager 15 + @Query(sort: [SortDescriptor(\UserSessionModel.handle, comparator: .localizedStandard)]) 16 + var userSessions: [UserSessionModel] 17 + 18 + @State var itemToDelete: UserSessionModel? = nil 19 + @State private var showDeleteConfirmation: Bool = false 20 + @State private var errorMessage: String? 21 + @State private var showAlert = false 22 + 23 + var body: some View { 24 + NavigationStack { 25 + Group { 26 + if self.userSessions.isEmpty { 27 + VStack(spacing: 16) { 28 + Image(systemName: "person.3") 29 + .font(.system(size: 50)) 30 + .foregroundColor(.gray) 31 + 32 + Text("No ATProto Accounts Yet") 33 + .font(.title2) 34 + .fontWeight(.medium) 35 + .foregroundColor(.primary) 36 + 37 + Text("Add your first account to get started") 38 + .font(.body) 39 + .foregroundColor(.secondary) 40 + .multilineTextAlignment(.center) 41 + } 42 + .padding() 43 + } else { 44 + List { 45 + ForEach(self.userSessions) { session in 46 + AccountRow( 47 + handle: session.handle, profilePicture: session.profilePicture 48 + ) 49 + .swipeActions { 50 + Button { 51 + Task { 52 + await self.refreshSession(session: session) 53 + } 54 + } label: { 55 + Label("Refresh", systemImage: "arrow.clockwise.circle") 56 + } 57 + Button(role: .destructive) { 58 + showDeleteConfirmation = true 59 + itemToDelete = session 60 + } label: { 61 + Label("Delete", systemImage: "trash.fill") 62 + } 63 + 64 + } 65 + } 66 + 67 + } 68 + } 69 + } 70 + .navigationTitle("Accounts") 71 + .toolbar { 72 + ToolbarItem(placement: .navigationBarTrailing) { 73 + NavigationLink { 74 + LoginView() 75 + .environmentObject(atProtocolManager) 76 + } label: { 77 + Image(systemName: "plus") 78 + } 79 + } 80 + } 81 + .alert(isPresented: $showAlert) { 82 + Alert( 83 + title: Text("Error"), message: Text(errorMessage ?? "Unknown error"), 84 + dismissButton: .default(Text("OK"))) 85 + } 86 + 87 + } 88 + .confirmationDialog( 89 + Text( 90 + "Are you sure you want to remove the handle \(self.itemToDelete?.handle ?? "")? You will no longer be able to access this account in your shortcuts." 91 + ), 92 + isPresented: $showDeleteConfirmation, 93 + titleVisibility: .visible 94 + ) { 95 + Button("Delete", role: .destructive) { 96 + withAnimation { 97 + if let session = itemToDelete { 98 + 99 + deleteAccount(id: session.id) 100 + itemToDelete = nil 101 + } 102 + } 103 + } 104 + } 105 + } 106 + 107 + private func deleteAccount(id: UUID) { 108 + Task { 109 + do { 110 + try await self.atProtocolManager.deleteSession(sessionID: id) 111 + } catch { 112 + self.errorMessage = error.localizedDescription 113 + self.showAlert = true 114 + } 115 + } 116 + } 117 + 118 + private func refreshSession(session: UserSessionModel) async { 119 + do { 120 + let atProtoKit = try await self.atProtocolManager.getAtProtoKit(sessionModel: session) 121 + try await self.atProtocolManager.refreshUserSession(atProtoKit) 122 + } catch { 123 + self.errorMessage = error.localizedDescription 124 + self.showAlert = true 125 + } 126 + 127 + } 128 + 129 + } 130 + 131 + struct AccountRow: View { 132 + let handle: String 133 + let profilePicture: URL? 134 + 135 + var body: some View { 136 + HStack(spacing: 12) { 137 + // Profile picture circle 138 + Group { 139 + if let imageURL = self.profilePicture { 140 + AsyncImage(url: imageURL) { image in 141 + image 142 + .resizable() 143 + .aspectRatio(contentMode: .fill) 144 + } placeholder: { 145 + Circle() 146 + .fill(.blue.gradient) 147 + .overlay { 148 + Text(String(self.handle.prefix(1).uppercased())) 149 + .font(.headline) 150 + .fontWeight(.medium) 151 + .foregroundColor(.white) 152 + } 153 + } 154 + } else { 155 + Circle() 156 + .fill(.blue.gradient) 157 + .overlay { 158 + Text(String(self.handle.prefix(1).uppercased())) 159 + .font(.headline) 160 + .fontWeight(.medium) 161 + .foregroundColor(.white) 162 + } 163 + } 164 + } 165 + .frame(width: 44, height: 44) 166 + .clipShape(Circle()) 167 + 168 + VStack(alignment: .leading, spacing: 2) { 169 + Text(self.handle) 170 + .font(.headline) 171 + .foregroundColor(.primary) 172 + 173 + } 174 + 175 + Spacer() 176 + } 177 + .padding(.vertical, 4) 178 + } 179 + } 180 + 181 + #Preview { 182 + 183 + let configuration = ModelConfiguration(isStoredInMemoryOnly: true) 184 + let container = try! ModelContainer( 185 + for: UserSessionModel.self, 186 + configurations: configuration 187 + ) 188 + // var userSessions: [UserSessionModel] = [ 189 + // UserSessionModel( 190 + // sessionId: UUID(), handle: "alice.bsky.social", sessionDID: "", 191 + // serviceEndpoint: URL(string: "https://localhost")!, 192 + // profilePicture: URL( 193 + // string: "https://www.placeholderimage.online/images/avatar/avatar-image-22.png")!), 194 + // UserSessionModel( 195 + // sessionId: UUID(), handle: "jonah.bsky.social", sessionDID: "", 196 + // serviceEndpoint: URL(string: "https://localhost")!, 197 + // profilePicture: URL( 198 + // string: "https://www.placeholderimage.online/images/avatar/avatar-image-05.png")!), 199 + // UserSessionModel( 200 + // sessionId: UUID(), handle: "earl.bsky.social", sessionDID: "", 201 + // serviceEndpoint: URL(string: "https://localhost")!, 202 + // ), 203 + // ] 204 + 205 + AccountsView() 206 + .environmentObject(AtProtocolManager(modelContext: container.mainContext)) 207 + }
+32
shortcut/Views/Components/LoadingButtonStyle.swift
···
··· 1 + // 2 + // LoadingButtonStyle.swift 3 + // shortcut 4 + // 5 + // Created by Bailey Townsend on 7/21/25. 6 + // 7 + 8 + import Foundation 9 + import SwiftUI 10 + 11 + struct LoadingButtonStyle: ButtonStyle { 12 + @Environment(\.isEnabled) private var isEnabled 13 + private var isLoading: Bool 14 + 15 + init(isLoading: Bool) { 16 + self.isLoading = isLoading 17 + } 18 + 19 + func makeBody(configuration: Configuration) -> some View { 20 + HStack(spacing: 8) { 21 + if isLoading { 22 + ProgressView() 23 + } 24 + 25 + configuration.label 26 + } 27 + .foregroundStyle(Color.accentColor) 28 + .opacity(configuration.isPressed ? 0.2 : 1) 29 + .animation(.default, value: isEnabled) 30 + .animation(.default, value: isLoading) 31 + } 32 + }
+57
shortcut/Views/Components/LoadingOverlay.swift
···
··· 1 + // 2 + // Untitled.swift 3 + // shortcut 4 + // 5 + // Created by Bailey Townsend on 6/27/25. 6 + // 7 + 8 + import SwiftUI 9 + 10 + // MARK: - Loading Overlay Component 11 + struct LoadingOverlay: View { 12 + let message: String? 13 + 14 + init(message: String? = nil) { 15 + self.message = message 16 + } 17 + 18 + var body: some View { 19 + ZStack { 20 + // Semi-transparent background 21 + Color.black.opacity(0.4) 22 + .ignoresSafeArea() 23 + 24 + // Loading content 25 + VStack(spacing: 16) { 26 + ProgressView() 27 + .scaleEffect(1.2) 28 + .progressViewStyle(CircularProgressViewStyle(tint: .white)) 29 + 30 + if let message = message { 31 + Text(message) 32 + .foregroundColor(.white) 33 + .font(.system(size: 16, weight: .medium)) 34 + .multilineTextAlignment(.center) 35 + } 36 + } 37 + .padding(24) 38 + .background( 39 + RoundedRectangle(cornerRadius: 12) 40 + .fill(Color.black.opacity(0.8)) 41 + ) 42 + } 43 + } 44 + } 45 + 46 + // MARK: - View Extension for Easy Usage 47 + extension View { 48 + func loadingOverlay(isShowing: Bool, message: String? = nil) -> some View { 49 + ZStack { 50 + self 51 + 52 + if isShowing { 53 + LoadingOverlay(message: message) 54 + } 55 + } 56 + } 57 + }
+293
shortcut/Views/DocumentationView.swift
···
··· 1 + // 2 + // ShortcutsView.swift 3 + // shortcut 4 + // 5 + // Created by Bailey Townsend on 6/24/25. 6 + // 7 + 8 + import SwiftUI 9 + 10 + struct DocumentationView: View { 11 + 12 + @State private var searchText = "" 13 + var intentDocs: [IntentDoc] = [] 14 + var examples: [Example] = [] 15 + 16 + init() { 17 + let docs = IntentsDocumentation() 18 + intentDocs.append(docs.makeAPostDoc()) 19 + intentDocs.append(docs.createARecordDoc()) 20 + intentDocs.append(docs.getServiceAuthDoc()) 21 + intentDocs.append(docs.createATIDDoc()) 22 + intentDocs.append(docs.getDownloadBlobsDoc()) 23 + intentDocs.append(docs.deleteARecordDoc()) 24 + intentDocs.append(docs.getALocalAtIdentifierDoc()) 25 + intentDocs.append(docs.getARecordDoc()) 26 + intentDocs.append(docs.getRepoDoc()) 27 + intentDocs.append(docs.getListBlobsDoc()) 28 + intentDocs.append(docs.listRecordsDoc()) 29 + intentDocs.append(docs.putARecordDoc()) 30 + intentDocs.append(docs.resolveADidOrHandleDoc()) 31 + intentDocs.append(docs.createUpdateProfileDoc()) 32 + 33 + // intentDocs.append(docs.createUTCDoc()) 34 + examples = Examples.getExamples() 35 + } 36 + 37 + var filteredItems: [IntentDoc] { 38 + if searchText.isEmpty { 39 + return intentDocs 40 + } else { 41 + return intentDocs.filter { item in 42 + item.name.key.localizedCaseInsensitiveContains(searchText) 43 + || item.description.key.localizedCaseInsensitiveContains(searchText) 44 + } 45 + } 46 + } 47 + 48 + var filteredExamples: [Example] { 49 + if searchText.isEmpty { 50 + return examples 51 + } else { 52 + return examples.filter { item in 53 + item.title.key.localizedCaseInsensitiveContains(searchText) 54 + } 55 + } 56 + } 57 + 58 + var body: some View { 59 + NavigationStack { 60 + List { 61 + Section(header: Text("Actions")) { 62 + ForEach(filteredItems, id: \.id) { item in 63 + NavigationLink { 64 + IntentDocumentationView(intentDoc: item) 65 + } label: { 66 + Label("\(item.name)", systemImage: item.icon) 67 + } 68 + } 69 + } 70 + Section(header: Text("Examples")) { 71 + ForEach(filteredExamples, id: \.id) { item in 72 + NavigationLink { 73 + ExampleView(example: item) 74 + // IntentDocumentationView(intentDoc: item) 75 + } label: { 76 + Label("\(item.title)", systemImage: item.icon) 77 + } 78 + } 79 + } 80 + } 81 + .navigationTitle("Shortcut actions") 82 + .searchable(text: $searchText, prompt: "Search items...") 83 + .toolbar { 84 + ToolbarItem(placement: .topBarLeading) { 85 + Link("Shortcuts", destination: URL(string: "shortcuts://")!) 86 + } 87 + } 88 + } 89 + } 90 + } 91 + 92 + struct IntentDocumentationView: View { 93 + let intentDoc: IntentDoc 94 + 95 + var body: some View { 96 + ScrollView { 97 + VStack(alignment: .leading, spacing: 24) { 98 + // Header Section 99 + headerSection 100 + 101 + // Description Section 102 + descriptionSection 103 + 104 + // Parameters Section 105 + if !intentDoc.parameters.isEmpty { 106 + parametersSection 107 + } 108 + 109 + // Result Section 110 + if let result = intentDoc.result { 111 + resultSection(result) 112 + } 113 + } 114 + .padding() 115 + } 116 + // .navigationTitle(String(localized: intentDoc.name)) 117 + // .navigationBarTitleDisplayMode 118 + } 119 + 120 + // MARK: - Header Section 121 + private var headerSection: some View { 122 + HStack(spacing: 16) { 123 + Image(systemName: intentDoc.icon) 124 + .font(.system(size: 40)) 125 + .foregroundColor(.accentColor) 126 + .frame(width: 60, height: 60) 127 + .background( 128 + Circle() 129 + .fill(Color.accentColor.opacity(0.1)) 130 + ) 131 + 132 + VStack(alignment: .leading, spacing: 4) { 133 + Text(intentDoc.name) 134 + .font(.title2) 135 + .fontWeight(.bold) 136 + if let docUrl = intentDoc.docUrl { 137 + Link(docUrl.text, destination: docUrl.url) 138 + } 139 + // Text("Intent Documentation") 140 + // .font(.caption) 141 + // .foregroundColor(.secondary) 142 + } 143 + 144 + Spacer() 145 + } 146 + .padding(.vertical, 8) 147 + } 148 + 149 + // MARK: - Description Section 150 + private var descriptionSection: some View { 151 + DocumentationSection(title: "Description", icon: "doc.text") { 152 + Text(intentDoc.description) 153 + .font(.body) 154 + .foregroundColor(.primary) 155 + } 156 + } 157 + 158 + // MARK: - Parameters Section 159 + private var parametersSection: some View { 160 + DocumentationSection(title: "Parameters", icon: "list.bullet") { 161 + LazyVStack(spacing: 12) { 162 + ForEach(Array(intentDoc.parameters.enumerated()), id: \.offset) { 163 + index, parameter in 164 + ParameterCard(parameter: parameter) 165 + } 166 + } 167 + } 168 + } 169 + 170 + // MARK: - Result Section 171 + private func resultSection(_ result: AppEntityResult) -> some View { 172 + DocumentationSection(title: "Result", icon: "return") { 173 + VStack(alignment: .leading, spacing: 16) { 174 + // Result Overview 175 + VStack(alignment: .leading, spacing: 8) { 176 + Text(result.name) 177 + .font(.headline) 178 + .foregroundColor(.primary) 179 + 180 + Text(result.description) 181 + .font(.body) 182 + .foregroundColor(.secondary) 183 + } 184 + .padding() 185 + .background( 186 + RoundedRectangle(cornerRadius: 12) 187 + .fill(Color(.systemGray6)) 188 + ) 189 + 190 + // Result Parameters 191 + if !result.parameters.isEmpty { 192 + VStack(alignment: .leading, spacing: 12) { 193 + Text("Result Properties") 194 + .font(.subheadline) 195 + .fontWeight(.semibold) 196 + .foregroundColor(.primary) 197 + 198 + ForEach(Array(result.parameters), id: \.id) { parameter in 199 + ParameterCard(parameter: parameter, isCompact: true) 200 + } 201 + } 202 + } 203 + } 204 + } 205 + } 206 + } 207 + 208 + // MARK: - Supporting Views 209 + 210 + struct DocumentationSection<Content: View>: View { 211 + let title: String 212 + let icon: String 213 + let content: Content 214 + 215 + init(title: String, icon: String, @ViewBuilder content: () -> Content) { 216 + self.title = title 217 + self.icon = icon 218 + self.content = content() 219 + } 220 + 221 + var body: some View { 222 + VStack(alignment: .leading, spacing: 16) { 223 + HStack(spacing: 8) { 224 + Image(systemName: icon) 225 + .foregroundColor(.accentColor) 226 + .font(.system(size: 16, weight: .medium)) 227 + 228 + Text(title) 229 + .font(.title3) 230 + .fontWeight(.semibold) 231 + 232 + Spacer() 233 + } 234 + 235 + content 236 + } 237 + .padding() 238 + .background( 239 + RoundedRectangle(cornerRadius: 16) 240 + .fill(Color(.systemBackground)) 241 + .shadow(color: .black.opacity(0.05), radius: 8, x: 0, y: 2) 242 + ) 243 + } 244 + } 245 + 246 + struct ParameterCard: View { 247 + let parameter: IntentParameter 248 + let isCompact: Bool 249 + 250 + init(parameter: IntentParameter, isCompact: Bool = false) { 251 + self.parameter = parameter 252 + self.isCompact = isCompact 253 + } 254 + 255 + var body: some View { 256 + VStack(alignment: .leading, spacing: isCompact ? 6 : 8) { 257 + HStack { 258 + Text(parameter.name) 259 + .font(isCompact ? .subheadline : .headline) 260 + .fontWeight(.medium) 261 + .foregroundColor(.primary) 262 + 263 + Spacer() 264 + 265 + Text(parameter.type) 266 + .font(.caption) 267 + .fontWeight(.medium) 268 + .foregroundColor(.white) 269 + .padding(.horizontal, 8) 270 + .padding(.vertical, 4) 271 + .background( 272 + Capsule() 273 + .fill(Color.accentColor) 274 + ) 275 + } 276 + if let description = parameter.description { 277 + Text(description) 278 + .font(isCompact ? .caption : .body) 279 + .foregroundColor(.secondary) 280 + .fixedSize(horizontal: false, vertical: true) 281 + } 282 + 283 + } 284 + .padding(isCompact ? 12 : 16) 285 + .background( 286 + RoundedRectangle(cornerRadius: 12) 287 + .fill(Color(.systemGray6)) 288 + ) 289 + } 290 + } 291 + #Preview { 292 + DocumentationView() 293 + }
+70
shortcut/Views/ExampleView.swift
···
··· 1 + // 2 + // ExampleView.swift 3 + // shortcut 4 + // 5 + // Created by Bailey Townsend on 7/8/25. 6 + // 7 + 8 + import SwiftUI 9 + 10 + struct ExampleView: View { 11 + let example: Example 12 + 13 + var body: some View { 14 + ScrollView { 15 + VStack(alignment: .leading, spacing: 16) { 16 + HStack(spacing: 12) { 17 + Image(systemName: example.icon) 18 + .font(.system(size: 40)) 19 + .foregroundColor(.accentColor) 20 + Text(example.title) 21 + .font(.title) 22 + .fontWeight(.bold) 23 + } 24 + 25 + Text(example.description) 26 + .font(.body) 27 + 28 + if !example.shortcutActionsUsed.isEmpty { 29 + VStack(alignment: .leading, spacing: 8) { 30 + Text("Used Shortcut Actions") 31 + .font(.headline) 32 + 33 + ForEach(example.shortcutActionsUsed.indices, id: \.self) { index in 34 + Text("• \(example.shortcutActionsUsed[index])") 35 + .font(.subheadline) 36 + } 37 + } 38 + } 39 + 40 + if let url = example.url { 41 + Link(destination: url) { 42 + Label("Download Example", systemImage: "link") 43 + .font(.body) 44 + .foregroundColor(.blue) 45 + } 46 + } 47 + 48 + Spacer() 49 + } 50 + .padding() 51 + } 52 + // .navigationTitle(Text(example.title)) 53 + // .navigationBarTitleDisplayMode(.inline) 54 + } 55 + } 56 + // Preview 57 + 58 + #Preview { 59 + NavigationView { 60 + ExampleView( 61 + example: Example( 62 + title: "Sample Shortcut", 63 + icon: "star.fill", 64 + description: 65 + "This is a sample shortcut that demonstrates how to use various actions together.", 66 + shortcutActionsUsed: ["Get Text", "Show Result", "Ask for Input"], 67 + url: URL(string: "https://example.com") 68 + )) 69 + } 70 + }
+209
shortcut/Views/LoginView.swift
···
··· 1 + // 2 + // Login.swift 3 + // shortcut 4 + // 5 + // Created by Bailey Townsend on 6/23/25. 6 + // 7 + 8 + import ATCommonWeb 9 + import ATIdentityTools 10 + import ATProtoKit 11 + import SwiftData 12 + import SwiftUI 13 + 14 + struct LoginView: View { 15 + @EnvironmentObject var atProtocolManager: AtProtocolManager 16 + @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode> 17 + 18 + @State private var handle = "" 19 + @State private var password = "" 20 + @State private var didDocument: CommonDIDDocument? 21 + 22 + @State private var showPasswordField = false 23 + @State private var isLoading = false 24 + @State private var loadingMessage = "Loading..." 25 + @State private var errorMessage: String? 26 + @State private var showAlert = false 27 + 28 + var body: some View { 29 + VStack(spacing: 30) { 30 + // AT Protocol logo/text 31 + Text("AT Toolbox") 32 + .font(.largeTitle) 33 + .fontWeight(.bold) 34 + .foregroundColor(.blue) 35 + .padding(.top, 50) 36 + 37 + // Spacer() 38 + 39 + VStack(spacing: 20) { 40 + // Handle input field 41 + VStack(alignment: .leading, spacing: 8) { 42 + Text("Handle") 43 + .font(.headline) 44 + .foregroundColor(.primary) 45 + 46 + TextField( 47 + "@alice.bsky.social", text: $handle, 48 + onEditingChanged: { _ in 49 + 50 + if showPasswordField && !self.password.isEmpty { 51 + self.showPasswordField = false 52 + self.password = "" 53 + } 54 + 55 + } 56 + ) 57 + .textFieldStyle(.roundedBorder) 58 + .autocapitalization(.none) 59 + .disableAutocorrection(true) 60 + } 61 + 62 + // Password field (only shown after submit is pressed) 63 + if showPasswordField { 64 + VStack(alignment: .leading, spacing: 8) { 65 + Text("App Password") 66 + .font(.headline) 67 + .foregroundColor(.primary) 68 + 69 + SecureField("Enter a App Password", text: $password) 70 + .textFieldStyle(.roundedBorder) 71 + .padding(.bottom, 12) 72 + Link( 73 + "What's an App Password?", 74 + destination: URL( 75 + string: 76 + "https://bsky.app/profile/safety.bsky.app/post/3k7waehomo52m" 77 + )!) 78 + } 79 + .transition(.opacity.combined(with: .move(edge: .top))) 80 + } 81 + 82 + // Submit button 83 + Button( 84 + action: { 85 + Task { 86 + if !showPasswordField { 87 + await resolveHandle() 88 + } else { 89 + self.loadingMessage = "Logging in..." 90 + self.isLoading = true 91 + do { 92 + let cleanedHandle = self.handle.replacingOccurrences( 93 + of: "@", with: "" 94 + ).trim() 95 + try await self.atProtocolManager.handleLogin( 96 + didDocument: didDocument, handle: cleanedHandle, 97 + password: self.password) 98 + self.presentationMode.wrappedValue.dismiss() 99 + } catch let error as ATAPIError { 100 + switch error { 101 + case .unauthorized( 102 + error: let responseError, 103 + wwwAuthenticate: _): 104 + if responseError.error == "AuthFactorTokenRequired" { 105 + self.errorMessage = 106 + "Please only use a App Password. This app does not support using your real password." 107 + self.showAlert = true 108 + break 109 + 110 + } 111 + self.errorMessage = responseError.message 112 + self.showAlert = true 113 + case .tooManyRequests(let error, retryAfter: _): 114 + self.errorMessage = error.message 115 + self.showAlert = true 116 + default: 117 + self.errorMessage = 118 + "Login failed. Please check your credentials." 119 + self.showAlert = true 120 + } 121 + } catch { 122 + self.errorMessage = 123 + "Login failed. Please check your credentials." 124 + self.showAlert = true 125 + 126 + } 127 + self.isLoading = false 128 + } 129 + } 130 + } 131 + ) { 132 + Text(showPasswordField ? "Login" : "Continue") 133 + .font(.headline) 134 + .foregroundColor(.white) 135 + .frame(maxWidth: .infinity) 136 + .padding() 137 + .background(handle.isEmpty ? Color.gray : Color.blue) 138 + .cornerRadius(10) 139 + } 140 + .disabled(handle.isEmpty) 141 + } 142 + .padding(.horizontal, 30) 143 + 144 + Spacer() 145 + } 146 + .background(Color(.systemBackground)) 147 + .alert(isPresented: $showAlert) { 148 + Alert( 149 + title: Text("Error"), message: Text(errorMessage ?? "Unknown error"), 150 + dismissButton: .default(Text("OK"))) 151 + } 152 + .loadingOverlay(isShowing: isLoading, message: self.loadingMessage) 153 + } 154 + 155 + func resolveHandle() async { 156 + self.loadingMessage = "Resolving handle..." 157 + self.isLoading = true 158 + 159 + let cleanedHandle = self.handle.replacingOccurrences( 160 + of: "@", with: "" 161 + ).trim() 162 + let doesHandleExist = await self.atProtocolManager 163 + .checkIfAccountExists(handle: cleanedHandle) 164 + if doesHandleExist { 165 + self.errorMessage = "\(handle) is already a saved handle." 166 + self.isLoading = false 167 + self.showAlert = true 168 + return 169 + } 170 + if cleanedHandle.hasSuffix(".bsky.social") { 171 + // No need to resolve handle if it's a bsky.social handle 172 + showPasswordField = true 173 + self.isLoading = false 174 + return 175 + } 176 + do { 177 + 178 + self.didDocument = 179 + try await self.atProtocolManager.resolveDidDocument( 180 + handle: cleanedHandle) 181 + 182 + guard let _ = self.didDocument else { 183 + self.errorMessage = "Could not resolve the DID document." 184 + self.showAlert = true 185 + return 186 + } 187 + // First tap - show password field 188 + withAnimation(.easeInOut(duration: 0.3)) { 189 + showPasswordField = true 190 + } 191 + } catch { 192 + print(error) 193 + self.errorMessage = "Could not resolve the handle." 194 + self.showAlert = true 195 + } 196 + self.isLoading = false 197 + 198 + } 199 + } 200 + 201 + #Preview { 202 + let configuration = ModelConfiguration(isStoredInMemoryOnly: true) 203 + let container = try! ModelContainer( 204 + for: UserSessionModel.self, 205 + configurations: configuration 206 + ) 207 + LoginView() 208 + .environmentObject(AtProtocolManager(modelContext: container.mainContext)) 209 + }
+276
shortcut/Views/SettingsView.swift
···
··· 1 + // 2 + // SettingsView.swift 3 + // shortcut 4 + // 5 + // Created by Bailey Townsend on 7/7/25. 6 + // 7 + 8 + import Foundation 9 + import StoreKit 10 + import SwiftUI 11 + 12 + struct SettingsView: View { 13 + @State private var showTipJar = false 14 + @State private var tmpDirectorySize: String? 15 + @State private var deleteTmpLoading = false 16 + 17 + let openSourceProjects = [ 18 + ("MasterJ93/ATProtoKit", "https://github.com/MasterJ93/ATProtoKit"), 19 + ("ATProtoKit/ATIdentityTools", "https://github.com/ATProtoKit/ATIdentityTools"), 20 + ("hyperoslo/Cache", "https://github.com/hyperoslo/Cache"), 21 + ] 22 + 23 + var body: some View { 24 + NavigationStack { 25 + VStack { 26 + List { 27 + // Tip Jar Section 28 + Section { 29 + Button(action: { 30 + showTipJar = true 31 + }) { 32 + HStack { 33 + Image(systemName: "heart.fill") 34 + .foregroundColor(.pink) 35 + .frame(width: 28) 36 + Text("Tip Jar") 37 + .foregroundColor(.primary) 38 + Spacer() 39 + Image(systemName: "chevron.right") 40 + .foregroundColor(.secondary) 41 + .font(.caption) 42 + } 43 + } 44 + } 45 + 46 + // About Section 47 + Section { 48 + 49 + Link( 50 + destination: URL( 51 + string: "https://attoolbox.baileytownsend.dev/#privacy")! 52 + ) { 53 + Label( 54 + "Privacy Policy", 55 + systemImage: "lock.fill" 56 + ) 57 + .font(.body) 58 + .foregroundColor(.blue) 59 + } 60 + Link( 61 + destination: URL( 62 + string: 63 + "https://apps.apple.com/app/at-toolbox/id6747999688?action=write-review" 64 + )! 65 + ) { 66 + Label( 67 + "Leave A Review", 68 + systemImage: "star.fill" 69 + ) 70 + .font(.body) 71 + .foregroundColor(.blue) 72 + } 73 + 74 + Link( 75 + destination: URL( 76 + string: "https://attoolbox.baileytownsend.dev/")! 77 + ) { 78 + Label( 79 + "Website", 80 + systemImage: "globe" 81 + ) 82 + .font(.body) 83 + .foregroundColor(.blue) 84 + } 85 + 86 + Link( 87 + destination: URL(string: "https://bsky.app/profile/baileytownsend.dev")! 88 + ) { 89 + Label( 90 + "Created by @baileytownsend.dev", 91 + systemImage: "person.text.rectangle" 92 + ) 93 + .font(.body) 94 + .foregroundColor(.blue) 95 + } 96 + 97 + } 98 + 99 + // Special Thanks Section 100 + Section(header: Text("Made possible Thanks to these projects")) { 101 + 102 + ForEach(openSourceProjects, id: \.0) { project in 103 + 104 + HStack { 105 + Link(destination: URL(string: project.1)!) { 106 + Label( 107 + project.0, 108 + systemImage: "chevron.left.forwardslash.chevron.right" 109 + ) 110 + .font(.body) 111 + .foregroundColor(.blue) 112 + } 113 + 114 + } 115 + 116 + } 117 + 118 + } 119 + 120 + Section { 121 + if let tmpSize = self.tmpDirectorySize { 122 + Button { 123 + withAnimation { 124 + self.deleteTmpLoading = true 125 + } 126 + 127 + Task { 128 + defer { 129 + Task { @MainActor in 130 + withAnimation { 131 + self.deleteTmpLoading = false 132 + } 133 + } 134 + } 135 + 136 + let tmpDirectory = FileManager.default.temporaryDirectory 137 + do { 138 + try DirectoryCleaner.clearDirectoryCompletely(tmpDirectory) 139 + 140 + let totalSize = DirectoryCleaner.getTotalSize( 141 + of: tmpDirectory) 142 + await MainActor.run { 143 + self.tmpDirectorySize = ByteCountFormatter.string( 144 + fromByteCount: totalSize, countStyle: .file) 145 + } 146 + } catch { 147 + print("Error: \(error)") 148 + } 149 + } 150 + } label: { 151 + 152 + Text("Clear temp folder: \(tmpSize)") 153 + .frame(maxWidth: .infinity) 154 + } 155 + .buttonStyle(LoadingButtonStyle(isLoading: self.deleteTmpLoading)) 156 + .disabled(deleteTmpLoading) 157 + } 158 + 159 + } 160 + 161 + } 162 + .listStyle(InsetGroupedListStyle()) 163 + 164 + } 165 + .onAppear { 166 + let tmpDirectory = FileManager.default.temporaryDirectory 167 + let totalSize = DirectoryCleaner.getTotalSize( 168 + of: tmpDirectory) 169 + self.tmpDirectorySize = ByteCountFormatter.string( 170 + fromByteCount: totalSize, countStyle: .file) 171 + } 172 + .navigationTitle("Info") 173 + .sheet(isPresented: $showTipJar) { 174 + TipJarView() 175 + } 176 + } 177 + } 178 + } 179 + 180 + struct TipJarView: View { 181 + @Environment(\.dismiss) var dismiss 182 + @State var showThankYouAlert: Bool = false 183 + @State private var transactionListener: Task<Void, Error>? = nil 184 + 185 + var body: some View { 186 + NavigationView { 187 + VStack(spacing: 20) { 188 + // Header 189 + VStack(spacing: 8) { 190 + Text("❤️") 191 + .font(.system(size: 60)) 192 + Text("Support the Developer") 193 + .font(.title2) 194 + .fontWeight(.semibold) 195 + Text("Your support helps keep the app updated and new features added!") 196 + .font(.subheadline) 197 + .foregroundColor(.secondary) 198 + .multilineTextAlignment(.center) 199 + .padding(.horizontal) 200 + } 201 + .padding(.top, 20) 202 + 203 + // Tip Options 204 + List { 205 + ProductView(id: "tip1") 206 + .listRowSeparator(.hidden) 207 + 208 + ProductView(id: "tip2") 209 + .listRowSeparator(.hidden) 210 + 211 + ProductView(id: "tip3") 212 + .listRowSeparator(.hidden) 213 + 214 + } 215 + .listStyle(.plain) 216 + .padding(.horizontal) 217 + .alert("Thank you!", isPresented: $showThankYouAlert) { 218 + // 219 + // Link( 220 + // "Post about it to Bluesky", 221 + // destination: URL( 222 + // string: 223 + // "https://bsky.app/intent/compose?text=I just left AT Toolbox a tip!\nhttps://attoolbox.baileytownsend.dev" 224 + // )!) 225 + 226 + Button("Close") { 227 + self.showThankYouAlert = false 228 + } 229 + } message: { 230 + Text( 231 + "Thank you so much for your support! Your tips help fund new features and to keep the app updated!" 232 + ) 233 + } 234 + Spacer() 235 + 236 + } 237 + .navigationBarTitle("Tip Jar", displayMode: .inline) 238 + .navigationBarItems(trailing: Button("Done") { dismiss() }) 239 + .onAppear { 240 + transactionListener = createTransactionTask() 241 + } 242 + .onDisappear { 243 + transactionListener?.cancel() 244 + } 245 + 246 + } 247 + } 248 + 249 + private func createTransactionTask() -> Task<Void, Error> { 250 + return Task { 251 + for await update in Transaction.updates { 252 + 253 + switch update { 254 + case .verified(let transaction): 255 + //TODO show alert 256 + //Consume tip right away so another can be made 257 + await transaction.finish() 258 + self.showThankYouAlert = true 259 + 260 + break 261 + 262 + case .unverified: 263 + //guess we will not do anything here? 264 + break 265 + 266 + } 267 + 268 + } 269 + } 270 + } 271 + 272 + } 273 + 274 + #Preview { 275 + SettingsView() 276 + }
+16
shortcut/shortcut.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 + <key>aps-environment</key> 6 + <string>development</string> 7 + <key>com.apple.developer.icloud-container-identifiers</key> 8 + <array/> 9 + <key>com.apple.developer.icloud-services</key> 10 + <array/> 11 + <key>com.apple.security.app-sandbox</key> 12 + <true/> 13 + <key>com.apple.security.files.user-selected.read-only</key> 14 + <true/> 15 + </dict> 16 + </plist>
+43
shortcut/shortcutApp.swift
···
··· 1 + // 2 + // shortcutApp.swift 3 + // shortcut 4 + // 5 + // Created by Bailey Townsend on 6/23/25. 6 + // 7 + 8 + import ATCommonWeb 9 + import AppIntents 10 + import SwiftData 11 + import SwiftUI 12 + 13 + public typealias CommonDIDDocument = ATCommonWeb.DIDDocument 14 + 15 + @main 16 + struct shortcutApp: App { 17 + 18 + init() { 19 + 20 + let blobDownloader = BlobDownloader() 21 + AppDependencyManager.shared.add(dependency: blobDownloader) 22 + } 23 + 24 + var sharedModelContainer: ModelContainer = { 25 + let schema = Schema([ 26 + UserSessionModel.self 27 + ]) 28 + let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) 29 + 30 + do { 31 + return try ModelContainer(for: schema, configurations: [modelConfiguration]) 32 + } catch { 33 + fatalError("Could not create ModelContainer: \(error)") 34 + } 35 + }() 36 + 37 + var body: some Scene { 38 + WindowGroup { 39 + ContentView(modelContext: sharedModelContainer.mainContext) 40 + } 41 + .modelContainer(sharedModelContainer) 42 + } 43 + }
+18
shortcutTests/shortcutTests.swift
···
··· 1 + // 2 + // shortcutTests.swift 3 + // shortcutTests 4 + // 5 + // Created by Bailey Townsend on 6/23/25. 6 + // 7 + 8 + import Testing 9 + 10 + @testable import shortcut 11 + 12 + struct shortcutTests { 13 + 14 + @Test func example() async throws { 15 + // Write your test here and use APIs like `#expect(...)` to check expected conditions. 16 + } 17 + 18 + }
+41
shortcutUITests/shortcutUITests.swift
···
··· 1 + // 2 + // shortcutUITests.swift 3 + // shortcutUITests 4 + // 5 + // Created by Bailey Townsend on 6/23/25. 6 + // 7 + 8 + import XCTest 9 + 10 + final class shortcutUITests: XCTestCase { 11 + 12 + override func setUpWithError() throws { 13 + // Put setup code here. This method is called before the invocation of each test method in the class. 14 + 15 + // In UI tests it is usually best to stop immediately when a failure occurs. 16 + continueAfterFailure = false 17 + 18 + // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. 19 + } 20 + 21 + override func tearDownWithError() throws { 22 + // Put teardown code here. This method is called after the invocation of each test method in the class. 23 + } 24 + 25 + @MainActor 26 + func testExample() throws { 27 + // UI tests must launch the application that they test. 28 + let app = XCUIApplication() 29 + app.launch() 30 + 31 + // Use XCTAssert and related functions to verify your tests produce the correct results. 32 + } 33 + 34 + @MainActor 35 + func testLaunchPerformance() throws { 36 + // This measures how long it takes to launch your application. 37 + measure(metrics: [XCTApplicationLaunchMetric()]) { 38 + XCUIApplication().launch() 39 + } 40 + } 41 + }
+33
shortcutUITests/shortcutUITestsLaunchTests.swift
···
··· 1 + // 2 + // shortcutUITestsLaunchTests.swift 3 + // shortcutUITests 4 + // 5 + // Created by Bailey Townsend on 6/23/25. 6 + // 7 + 8 + import XCTest 9 + 10 + final class shortcutUITestsLaunchTests: XCTestCase { 11 + 12 + override class var runsForEachTargetApplicationUIConfiguration: Bool { 13 + true 14 + } 15 + 16 + override func setUpWithError() throws { 17 + continueAfterFailure = false 18 + } 19 + 20 + @MainActor 21 + func testLaunch() throws { 22 + let app = XCUIApplication() 23 + app.launch() 24 + 25 + // Insert steps here to perform after app launch but before taking a screenshot, 26 + // such as logging into a test account or navigating somewhere in the app 27 + 28 + let attachment = XCTAttachment(screenshot: app.screenshot()) 29 + attachment.name = "Launch Screen" 30 + attachment.lifetime = .keepAlways 31 + add(attachment) 32 + } 33 + }