diff --git a/services/apple/HotPocket.xcodeproj/project.pbxproj b/services/apple/HotPocket.xcodeproj/project.pbxproj index 2ec7913..570259c 100644 --- a/services/apple/HotPocket.xcodeproj/project.pbxproj +++ b/services/apple/HotPocket.xcodeproj/project.pbxproj @@ -7,11 +7,33 @@ objects = { /* Begin PBXBuildFile section */ + 4C1159202E8B055F003B34AD /* Save to HotPocket.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 4CBCEA4F2E81CB9500722009 /* Save to HotPocket.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 4C2F0C692E851BBD0033F5C2 /* Save to HotPocket.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 4C2F0C5E2E851BBD0033F5C2 /* Save to HotPocket.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 4C70F30D2E8869FB00320048 /* HPShareExtensionHelper.m in Sources */ = {isa = PBXBuildFile; fileRef = 4C70F30C2E8869FB00320048 /* HPShareExtensionHelper.m */; }; + 4C70F30E2E8869FB00320048 /* HPShareExtensionHelper.m in Sources */ = {isa = PBXBuildFile; fileRef = 4C70F30C2E8869FB00320048 /* HPShareExtensionHelper.m */; }; + 4C70F3152E886A8F00320048 /* HPSharedItem.m in Sources */ = {isa = PBXBuildFile; fileRef = 4C70F3142E886A8F00320048 /* HPSharedItem.m */; }; + 4C70F3162E886A8F00320048 /* HPSharedItem.m in Sources */ = {isa = PBXBuildFile; fileRef = 4C70F3142E886A8F00320048 /* HPSharedItem.m */; }; + 4C70F3192E886ADD00320048 /* HPSharedItemsContainer.m in Sources */ = {isa = PBXBuildFile; fileRef = 4C70F3182E886ADD00320048 /* HPSharedItemsContainer.m */; }; + 4C70F31A2E886ADD00320048 /* HPSharedItemsContainer.m in Sources */ = {isa = PBXBuildFile; fileRef = 4C70F3182E886ADD00320048 /* HPSharedItemsContainer.m */; }; 4CABCAD62E56F0C900D8A354 /* HotPocket Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 4CABCAD52E56F0C900D8A354 /* HotPocket Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 4CABCAE02E56F0C900D8A354 /* HotPocket Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 4CABCADF2E56F0C900D8A354 /* HotPocket Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 4C1159212E8B055F003B34AD /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 4CABCA922E56F0C800D8A354 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 4CBCEA4E2E81CB9500722009; + remoteInfo = "macOS (Share Extension)"; + }; + 4C2F0C672E851BBD0033F5C2 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 4CABCA922E56F0C800D8A354 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 4C2F0C5D2E851BBD0033F5C2; + remoteInfo = "iOS (Share Extension)"; + }; 4CABCAD72E56F0C900D8A354 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 4CABCA922E56F0C800D8A354 /* Project object */; @@ -36,6 +58,7 @@ dstSubfolderSpec = 13; files = ( 4CABCAD62E56F0C900D8A354 /* HotPocket Extension.appex in Embed Foundation Extensions */, + 4C2F0C692E851BBD0033F5C2 /* Save to HotPocket.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -46,6 +69,7 @@ dstPath = ""; dstSubfolderSpec = 13; files = ( + 4C1159202E8B055F003B34AD /* Save to HotPocket.appex in Embed Foundation Extensions */, 4CABCAE02E56F0C900D8A354 /* HotPocket Extension.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; @@ -54,22 +78,64 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 4C2F0C5E2E851BBD0033F5C2 /* Save to HotPocket.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Save to HotPocket.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; + 4C70F30B2E8869FB00320048 /* HPShareExtensionHelper.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = HPShareExtensionHelper.h; sourceTree = ""; }; + 4C70F30C2E8869FB00320048 /* HPShareExtensionHelper.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = HPShareExtensionHelper.m; sourceTree = ""; }; + 4C70F3132E886A8F00320048 /* HPSharedItem.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = HPSharedItem.h; sourceTree = ""; }; + 4C70F3142E886A8F00320048 /* HPSharedItem.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = HPSharedItem.m; sourceTree = ""; }; + 4C70F3172E886ADD00320048 /* HPSharedItemsContainer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = HPSharedItemsContainer.h; sourceTree = ""; }; + 4C70F3182E886ADD00320048 /* HPSharedItemsContainer.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = HPSharedItemsContainer.m; sourceTree = ""; }; 4CABCAB02E56F0C900D8A354 /* HotPocket.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = HotPocket.app; sourceTree = BUILT_PRODUCTS_DIR; }; 4CABCAC62E56F0C900D8A354 /* HotPocket.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = HotPocket.app; sourceTree = BUILT_PRODUCTS_DIR; }; 4CABCAD52E56F0C900D8A354 /* HotPocket Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "HotPocket Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 4CABCADF2E56F0C900D8A354 /* HotPocket Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "HotPocket Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; + 4CBCEA4F2E81CB9500722009 /* Save to HotPocket.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Save to HotPocket.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 4C2F0C6D2E851BBD0033F5C2 /* Exceptions for "iOS (Share Extension)" folder in "iOS (Share Extension)" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 4C2F0C5D2E851BBD0033F5C2 /* iOS (Share Extension) */; + }; + 4C2F0C6F2E851BF90033F5C2 /* Exceptions for "Shared (App)" folder in "iOS (Share Extension)" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Assets.xcassets, + HPAPI.m, + HPCredentialsHelper.m, + HPRPCClient.m, + "NSURL+HotPocketExtensions.m", + "Resources/icon-mac-384.png", + ); + target = 4C2F0C5D2E851BBD0033F5C2 /* iOS (Share Extension) */; + }; + 4C3B958C2E83C83A00F4F82C /* Exceptions for "macOS (App)" folder in "HotPocket (macOS)" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 4CABCAC52E56F0C900D8A354 /* HotPocket (macOS) */; + }; + 4C7A01792E867D6200DEA460 /* Exceptions for "iOS (App)" folder in "iOS (Share Extension)" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + MultilineLabel.m, + ); + target = 4C2F0C5D2E851BBD0033F5C2 /* iOS (Share Extension) */; + }; 4CABCB042E56F0C900D8A354 /* Exceptions for "Shared (App)" folder in "HotPocket (iOS)" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( - "/Localized: Resources/Main.html", Assets.xcassets, + HPAPI.m, + HPAuthFlow.m, + HPCredentialsHelper.m, + HPRPCClient.m, + "NSURL+HotPocketExtensions.m", "Resources/icon-mac-384.png", - Resources/Script.js, - Resources/Style.css, - ViewController.m, ); target = 4CABCAAF2E56F0C900D8A354 /* HotPocket (iOS) */; }; @@ -90,12 +156,13 @@ 4CABCB0E2E56F0C900D8A354 /* Exceptions for "Shared (App)" folder in "HotPocket (macOS)" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( - "/Localized: Resources/Main.html", Assets.xcassets, + HPAPI.m, + HPAuthFlow.m, + HPCredentialsHelper.m, + HPRPCClient.m, + "NSURL+HotPocketExtensions.m", "Resources/icon-mac-384.png", - Resources/Script.js, - Resources/Style.css, - ViewController.m, ); target = 4CABCAC52E56F0C900D8A354 /* HotPocket (macOS) */; }; @@ -134,14 +201,43 @@ ); target = 4CABCADE2E56F0C900D8A354 /* HotPocket Extension (macOS) */; }; + 4CBCEA612E81CB9500722009 /* Exceptions for "macOS (Share Extension)" folder in "macOS (Share Extension)" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 4CBCEA4E2E81CB9500722009 /* macOS (Share Extension) */; + }; + 4CBCEA632E81CBC800722009 /* Exceptions for "Shared (App)" folder in "macOS (Share Extension)" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Assets.xcassets, + HPAPI.m, + HPCredentialsHelper.m, + HPRPCClient.m, + "NSURL+HotPocketExtensions.m", + "Resources/icon-mac-384.png", + ); + target = 4CBCEA4E2E81CB9500722009 /* macOS (Share Extension) */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + 4C2F0C5F2E851BBD0033F5C2 /* iOS (Share Extension) */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 4C2F0C6D2E851BBD0033F5C2 /* Exceptions for "iOS (Share Extension)" folder in "iOS (Share Extension)" target */, + ); + path = "iOS (Share Extension)"; + sourceTree = ""; + }; 4CABCA962E56F0C800D8A354 /* Shared (App) */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( 4CABCB042E56F0C900D8A354 /* Exceptions for "Shared (App)" folder in "HotPocket (iOS)" target */, 4CABCB0E2E56F0C900D8A354 /* Exceptions for "Shared (App)" folder in "HotPocket (macOS)" target */, + 4CBCEA632E81CBC800722009 /* Exceptions for "Shared (App)" folder in "macOS (Share Extension)" target */, + 4C2F0C6F2E851BF90033F5C2 /* Exceptions for "Shared (App)" folder in "iOS (Share Extension)" target */, ); path = "Shared (App)"; sourceTree = ""; @@ -163,12 +259,16 @@ isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( 4CABCB0D2E56F0C900D8A354 /* Exceptions for "iOS (App)" folder in "HotPocket (iOS)" target */, + 4C7A01792E867D6200DEA460 /* Exceptions for "iOS (App)" folder in "iOS (Share Extension)" target */, ); path = "iOS (App)"; sourceTree = ""; }; 4CABCAC72E56F0C900D8A354 /* macOS (App) */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 4C3B958C2E83C83A00F4F82C /* Exceptions for "macOS (App)" folder in "HotPocket (macOS)" target */, + ); path = "macOS (App)"; sourceTree = ""; }; @@ -188,9 +288,24 @@ path = "macOS (Extension)"; sourceTree = ""; }; + 4CBCEA502E81CB9500722009 /* macOS (Share Extension) */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 4CBCEA612E81CB9500722009 /* Exceptions for "macOS (Share Extension)" folder in "macOS (Share Extension)" target */, + ); + path = "macOS (Share Extension)"; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + 4C2F0C5B2E851BBD0033F5C2 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 4CABCAAD2E56F0C900D8A354 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -219,18 +334,49 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 4CBCEA4C2E81CB9500722009 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 4C11591F2E8B055F003B34AD /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; + 4C70F30A2E8869D200320048 /* Shared (Share Extension) */ = { + isa = PBXGroup; + children = ( + 4C70F30B2E8869FB00320048 /* HPShareExtensionHelper.h */, + 4C70F30C2E8869FB00320048 /* HPShareExtensionHelper.m */, + 4C70F3132E886A8F00320048 /* HPSharedItem.h */, + 4C70F3142E886A8F00320048 /* HPSharedItem.m */, + 4C70F3172E886ADD00320048 /* HPSharedItemsContainer.h */, + 4C70F3182E886ADD00320048 /* HPSharedItemsContainer.m */, + ); + path = "Shared (Share Extension)"; + sourceTree = ""; + }; 4CABCA912E56F0C800D8A354 = { isa = PBXGroup; children = ( 4CABCA962E56F0C800D8A354 /* Shared (App) */, 4CABCAA02E56F0C900D8A354 /* Shared (Extension) */, + 4C70F30A2E8869D200320048 /* Shared (Share Extension) */, 4CABCAB22E56F0C900D8A354 /* iOS (App) */, 4CABCAC72E56F0C900D8A354 /* macOS (App) */, 4CABCAD92E56F0C900D8A354 /* iOS (Extension) */, 4CABCAE32E56F0C900D8A354 /* macOS (Extension) */, + 4CBCEA502E81CB9500722009 /* macOS (Share Extension) */, + 4C2F0C5F2E851BBD0033F5C2 /* iOS (Share Extension) */, + 4C11591F2E8B055F003B34AD /* Frameworks */, 4CABCAB12E56F0C900D8A354 /* Products */, ); sourceTree = ""; @@ -242,6 +388,8 @@ 4CABCAC62E56F0C900D8A354 /* HotPocket.app */, 4CABCAD52E56F0C900D8A354 /* HotPocket Extension.appex */, 4CABCADF2E56F0C900D8A354 /* HotPocket Extension.appex */, + 4CBCEA4F2E81CB9500722009 /* Save to HotPocket.appex */, + 4C2F0C5E2E851BBD0033F5C2 /* Save to HotPocket.appex */, ); name = Products; sourceTree = ""; @@ -249,6 +397,28 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 4C2F0C5D2E851BBD0033F5C2 /* iOS (Share Extension) */ = { + isa = PBXNativeTarget; + buildConfigurationList = 4C2F0C6A2E851BBD0033F5C2 /* Build configuration list for PBXNativeTarget "iOS (Share Extension)" */; + buildPhases = ( + 4C2F0C5A2E851BBD0033F5C2 /* Sources */, + 4C2F0C5B2E851BBD0033F5C2 /* Frameworks */, + 4C2F0C5C2E851BBD0033F5C2 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 4C2F0C5F2E851BBD0033F5C2 /* iOS (Share Extension) */, + ); + name = "iOS (Share Extension)"; + packageProductDependencies = ( + ); + productName = "iOS (Share Extension)"; + productReference = 4C2F0C5E2E851BBD0033F5C2 /* Save to HotPocket.appex */; + productType = "com.apple.product-type.app-extension"; + }; 4CABCAAF2E56F0C900D8A354 /* HotPocket (iOS) */ = { isa = PBXNativeTarget; buildConfigurationList = 4CABCB0A2E56F0C900D8A354 /* Build configuration list for PBXNativeTarget "HotPocket (iOS)" */; @@ -262,6 +432,7 @@ ); dependencies = ( 4CABCAD82E56F0C900D8A354 /* PBXTargetDependency */, + 4C2F0C682E851BBD0033F5C2 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 4CABCAB22E56F0C900D8A354 /* iOS (App) */, @@ -286,6 +457,7 @@ ); dependencies = ( 4CABCAE22E56F0C900D8A354 /* PBXTargetDependency */, + 4C1159222E8B055F003B34AD /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 4CABCAC72E56F0C900D8A354 /* macOS (App) */, @@ -341,6 +513,28 @@ productReference = 4CABCADF2E56F0C900D8A354 /* HotPocket Extension.appex */; productType = "com.apple.product-type.app-extension"; }; + 4CBCEA4E2E81CB9500722009 /* macOS (Share Extension) */ = { + isa = PBXNativeTarget; + buildConfigurationList = 4CBCEA602E81CB9500722009 /* Build configuration list for PBXNativeTarget "macOS (Share Extension)" */; + buildPhases = ( + 4CBCEA4B2E81CB9500722009 /* Sources */, + 4CBCEA4C2E81CB9500722009 /* Frameworks */, + 4CBCEA4D2E81CB9500722009 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 4CBCEA502E81CB9500722009 /* macOS (Share Extension) */, + ); + name = "macOS (Share Extension)"; + packageProductDependencies = ( + ); + productName = "macOS (Share Extension)"; + productReference = 4CBCEA4F2E81CB9500722009 /* Save to HotPocket.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -350,6 +544,9 @@ BuildIndependentTargetsInParallel = 1; LastUpgradeCheck = 1640; TargetAttributes = { + 4C2F0C5D2E851BBD0033F5C2 = { + CreatedOnToolsVersion = 26.0; + }; 4CABCAAF2E56F0C900D8A354 = { CreatedOnToolsVersion = 16.4; }; @@ -362,6 +559,9 @@ 4CABCADE2E56F0C900D8A354 = { CreatedOnToolsVersion = 16.4; }; + 4CBCEA4E2E81CB9500722009 = { + CreatedOnToolsVersion = 16.4; + }; }; }; buildConfigurationList = 4CABCA952E56F0C800D8A354 /* Build configuration list for PBXProject "HotPocket" */; @@ -382,11 +582,20 @@ 4CABCAC52E56F0C900D8A354 /* HotPocket (macOS) */, 4CABCAD42E56F0C900D8A354 /* HotPocket Extension (iOS) */, 4CABCADE2E56F0C900D8A354 /* HotPocket Extension (macOS) */, + 4CBCEA4E2E81CB9500722009 /* macOS (Share Extension) */, + 4C2F0C5D2E851BBD0033F5C2 /* iOS (Share Extension) */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 4C2F0C5C2E851BBD0033F5C2 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 4CABCAAE2E56F0C900D8A354 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -415,9 +624,26 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 4CBCEA4D2E81CB9500722009 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 4C2F0C5A2E851BBD0033F5C2 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 4C70F30E2E8869FB00320048 /* HPShareExtensionHelper.m in Sources */, + 4C70F3152E886A8F00320048 /* HPSharedItem.m in Sources */, + 4C70F3192E886ADD00320048 /* HPSharedItemsContainer.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 4CABCAAC2E56F0C900D8A354 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -446,9 +672,29 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 4CBCEA4B2E81CB9500722009 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 4C70F3162E886A8F00320048 /* HPSharedItem.m in Sources */, + 4C70F30D2E8869FB00320048 /* HPShareExtensionHelper.m in Sources */, + 4C70F31A2E886ADD00320048 /* HPSharedItemsContainer.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 4C1159222E8B055F003B34AD /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 4CBCEA4E2E81CB9500722009 /* macOS (Share Extension) */; + targetProxy = 4C1159212E8B055F003B34AD /* PBXContainerItemProxy */; + }; + 4C2F0C682E851BBD0033F5C2 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 4C2F0C5D2E851BBD0033F5C2 /* iOS (Share Extension) */; + targetProxy = 4C2F0C672E851BBD0033F5C2 /* PBXContainerItemProxy */; + }; 4CABCAD82E56F0C900D8A354 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 4CABCAD42E56F0C900D8A354 /* HotPocket Extension (iOS) */; @@ -462,6 +708,71 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ + 4C2F0C6B2E851BBD0033F5C2 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = "iOS (Share Extension)/iOS (Share Extension).entitlements"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 2025092901; + DEVELOPMENT_TEAM = 648728X64K; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "iOS (Share Extension)/Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = "Save to HotPocket"; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025-present BTHLabs"; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 25.9.17; + PRODUCT_BUNDLE_IDENTIFIER = pl.bthlabs.HotPocket.ShareExtension; + PRODUCT_NAME = "Save to HotPocket"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 4C2F0C6C2E851BBD0033F5C2 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = "iOS (Share Extension)/iOS (Share Extension).entitlements"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 2025091701; + DEVELOPMENT_TEAM = 648728X64K; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "iOS (Share Extension)/Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = "Save to HotPocket"; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025-present BTHLabs"; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 25.9.17; + PRODUCT_BUNDLE_IDENTIFIER = pl.bthlabs.HotPocket.ShareExtension; + PRODUCT_NAME = "Save to HotPocket"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; 4CABCB062E56F0C900D8A354 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -487,6 +798,10 @@ PRODUCT_NAME = "HotPocket Extension"; SDKROOT = iphoneos; SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -517,6 +832,10 @@ PRODUCT_NAME = "HotPocket Extension"; SDKROOT = iphoneos; SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; @@ -528,6 +847,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "iOS (App)/HotPocket (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 2025091701; DEVELOPMENT_TEAM = 648728X64K; @@ -539,7 +859,8 @@ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIMainStoryboardFile = Main; - INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -569,6 +890,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "iOS (App)/HotPocket (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 2025091701; DEVELOPMENT_TEAM = 648728X64K; @@ -580,7 +902,8 @@ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIMainStoryboardFile = Main; - INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -610,7 +933,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_ENTITLEMENTS = "macOS (Extension)/HotPocket.entitlements"; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 2025091701; DEVELOPMENT_TEAM = 648728X64K; @@ -625,7 +948,7 @@ "@executable_path/../Frameworks", "@executable_path/../../../../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 25.9.17; OTHER_LDFLAGS = ( "-framework", @@ -633,6 +956,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = pl.bthlabs.HotPocket.Extension; PRODUCT_NAME = "HotPocket Extension"; + REGISTER_APP_GROUPS = YES; SDKROOT = macosx; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; @@ -658,7 +982,7 @@ "@executable_path/../Frameworks", "@executable_path/../../../../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 25.9.17; OTHER_LDFLAGS = ( "-framework", @@ -666,6 +990,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = pl.bthlabs.HotPocket.Extension; PRODUCT_NAME = "HotPocket Extension"; + REGISTER_APP_GROUPS = YES; SDKROOT = macosx; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; @@ -678,12 +1003,13 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "macOS (App)/HotPocket.entitlements"; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 2025091701; DEVELOPMENT_TEAM = 648728X64K; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "macOS (App)/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = HotPocket; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025-present BTHLabs"; @@ -721,6 +1047,7 @@ DEVELOPMENT_TEAM = 648728X64K; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "macOS (App)/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = HotPocket; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025-present BTHLabs"; @@ -859,9 +1186,76 @@ }; name = Release; }; + 4CBCEA5E2E81CB9500722009 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = "macOS (Share Extension)/macOS (Share Extension).entitlements"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 2025091701; + DEVELOPMENT_TEAM = 648728X64K; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "macOS (Share Extension)/Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = "Save to HotPocket"; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025-present BTHLabs"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 15.0; + MARKETING_VERSION = 25.9.17; + PRODUCT_BUNDLE_IDENTIFIER = pl.bthlabs.HotPocket.ShareExtension; + PRODUCT_NAME = "Save to HotPocket"; + REGISTER_APP_GROUPS = YES; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + }; + name = Debug; + }; + 4CBCEA5F2E81CB9500722009 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = "macOS (Share Extension)/macOS (Share Extension).entitlements"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 2025091701; + DEVELOPMENT_TEAM = 648728X64K; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "macOS (Share Extension)/Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = "Save to HotPocket"; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025-present BTHLabs"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 15.0; + MARKETING_VERSION = 25.9.17; + PRODUCT_BUNDLE_IDENTIFIER = pl.bthlabs.HotPocket.ShareExtension; + PRODUCT_NAME = "Save to HotPocket"; + REGISTER_APP_GROUPS = YES; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 4C2F0C6A2E851BBD0033F5C2 /* Build configuration list for PBXNativeTarget "iOS (Share Extension)" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4C2F0C6B2E851BBD0033F5C2 /* Debug */, + 4C2F0C6C2E851BBD0033F5C2 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 4CABCA952E56F0C800D8A354 /* Build configuration list for PBXProject "HotPocket" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -907,6 +1301,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 4CBCEA602E81CB9500722009 /* Build configuration list for PBXNativeTarget "macOS (Share Extension)" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4CBCEA5E2E81CB9500722009 /* Debug */, + 4CBCEA5F2E81CB9500722009 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 4CABCA922E56F0C800D8A354 /* Project object */; diff --git a/services/apple/HotPocket.xcodeproj/xcshareddata/xcschemes/HotPocket (iOS).xcscheme b/services/apple/HotPocket.xcodeproj/xcshareddata/xcschemes/HotPocket (iOS).xcscheme new file mode 100644 index 0000000..092d7f2 --- /dev/null +++ b/services/apple/HotPocket.xcodeproj/xcshareddata/xcschemes/HotPocket (iOS).xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/services/apple/HotPocket.xcodeproj/xcshareddata/xcschemes/HotPocket (macOS).xcscheme b/services/apple/HotPocket.xcodeproj/xcshareddata/xcschemes/HotPocket (macOS).xcscheme new file mode 100644 index 0000000..ca3cbf9 --- /dev/null +++ b/services/apple/HotPocket.xcodeproj/xcshareddata/xcschemes/HotPocket (macOS).xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/services/apple/HotPocket.xcodeproj/xcshareddata/xcschemes/HotPocket Extension (iOS).xcscheme b/services/apple/HotPocket.xcodeproj/xcshareddata/xcschemes/HotPocket Extension (iOS).xcscheme new file mode 100644 index 0000000..5a2b3af --- /dev/null +++ b/services/apple/HotPocket.xcodeproj/xcshareddata/xcschemes/HotPocket Extension (iOS).xcscheme @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/services/apple/HotPocket.xcodeproj/xcshareddata/xcschemes/HotPocket Extension (macOS).xcscheme b/services/apple/HotPocket.xcodeproj/xcshareddata/xcschemes/HotPocket Extension (macOS).xcscheme new file mode 100644 index 0000000..372542a --- /dev/null +++ b/services/apple/HotPocket.xcodeproj/xcshareddata/xcschemes/HotPocket Extension (macOS).xcscheme @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/services/apple/HotPocket.xcodeproj/xcshareddata/xcschemes/macOS (Share Extension).xcscheme b/services/apple/HotPocket.xcodeproj/xcshareddata/xcschemes/macOS (Share Extension).xcscheme new file mode 100644 index 0000000..fe29935 --- /dev/null +++ b/services/apple/HotPocket.xcodeproj/xcshareddata/xcschemes/macOS (Share Extension).xcscheme @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/services/apple/Shared (App)/Assets.xcassets/DangerColor.colorset/Contents.json b/services/apple/Shared (App)/Assets.xcassets/DangerColor.colorset/Contents.json new file mode 100644 index 0000000..965c122 --- /dev/null +++ b/services/apple/Shared (App)/Assets.xcassets/DangerColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.463", + "green" : "0.392", + "red" : "0.933" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/services/apple/Shared (App)/Assets.xcassets/SuccessColor.colorset/Contents.json b/services/apple/Shared (App)/Assets.xcassets/SuccessColor.colorset/Contents.json new file mode 100644 index 0000000..8a3a3d0 --- /dev/null +++ b/services/apple/Shared (App)/Assets.xcassets/SuccessColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x67", + "green" : "0xA7", + "red" : "0x0E" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/services/apple/Shared (App)/Assets.xcassets/WarningColor.colorset/Contents.json b/services/apple/Shared (App)/Assets.xcassets/WarningColor.colorset/Contents.json new file mode 100644 index 0000000..e29056f --- /dev/null +++ b/services/apple/Shared (App)/Assets.xcassets/WarningColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x2E", + "green" : "0x96", + "red" : "0xF1" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/services/apple/Shared (App)/HPAPI.h b/services/apple/Shared (App)/HPAPI.h new file mode 100644 index 0000000..5bf2b4a --- /dev/null +++ b/services/apple/Shared (App)/HPAPI.h @@ -0,0 +1,32 @@ +// +// HPAPI.h +// HotPocket +// +// Created by Tomek Wójcik on 23/09/2025. +// + +#import + +#import "HPRPCClient.h" + +NS_ASSUME_NONNULL_BEGIN + +typedef void (^HPAPICheckAuthCompletionHandler)(BOOL authValid, NSError * _Nullable error, NSString * _Nullable callId); + +@interface HPAPI : NSObject + +@property (nullable) HPRPCClient *rpcClient; +@property NSMutableSet *callIds; + +-(id)initWithRPCClientDelegate:(id)delegate; + ++(NSDictionary *)getAccessTokenMeta; + +-(NSString *)checkAuth; +-(void)checkAuth:(HPAPICheckAuthCompletionHandler)completionHandler; +-(NSString *)save:(NSURL *)url; +-(BOOL)save:(NSURL *)url completionHandler:(HPRPCClientCompletionHandler)completionHandler; + +@end + +NS_ASSUME_NONNULL_END diff --git a/services/apple/Shared (App)/HPAPI.m b/services/apple/Shared (App)/HPAPI.m new file mode 100644 index 0000000..03aa98b --- /dev/null +++ b/services/apple/Shared (App)/HPAPI.m @@ -0,0 +1,148 @@ +// +// HPAPI.m +// HotPocket +// +// Created by Tomek Wójcik on 23/09/2025. +// + +#import "HPAPI.h" + +#import "HPCredentialsHelper.h" +#import "HPRPCClient.h" + +@implementation HPAPI (HPAPIPrivate) + +#pragma mark - Private interface + +-(void)updateRPCClientCredentials { + HPCredentials *credentials = [[HPCredentialsHelper sharedHelper] getCredentials]; + self.rpcClient.baseURL = credentials.rpcURL; + self.rpcClient.accessToken = credentials.accessToken; +} + +@end + +@implementation HPAPI + +#pragma mark - Initialization + +-(id)init { + if (self = [super init]) { + self.rpcClient = [[HPRPCClient alloc] initWithBaseURL:nil accessToken:nil]; + + [self updateRPCClientCredentials]; + + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(onCredentialsChanged:) + name:@"HPCredentialsChanged" + object:nil]; + } + + return self; +} + +-(id)initWithRPCClientDelegate:(id)rpcClientDelegate { + if (self = [self init]) { + self.rpcClient.delegate = rpcClientDelegate; + } + + return self; +} + +-(void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +#pragma mark - Public interface + ++(NSDictionary *)getAccessTokenMeta { + NSString *platform = @"macOS"; +#ifdef TARGET_OS_IOS + platform = @"iPhone"; +#endif + + return @{ + @"version": [[[NSBundle mainBundle] infoDictionary] valueForKey:@"CFBundleShortVersionString"], + @"platform": platform, + }; +} + +-(NSString *)checkAuth { + if (self.rpcClient.hasCredentials == NO) { + return nil; + } + + NSString *callId = [[NSUUID UUID] UUIDString]; + + BOOL callResult = [self.rpcClient call:callId + method:@"accounts.auth.check_access_token" + params:@[self.rpcClient.accessToken, [HPAPI getAccessTokenMeta]] + endopoint:@"/accounts/rpc/"]; + if (callResult == NO) { + return nil; + } + + return callId; +} + +-(void)checkAuth:(HPAPICheckAuthCompletionHandler)completionHandler { + if (self.rpcClient.hasCredentials == NO) { + completionHandler(NO, nil, nil); + } else { + NSString *callId = [[NSUUID UUID] UUIDString]; + BOOL callResult = [self.rpcClient call:callId + method:@"accounts.auth.check_access_token" + params:@[self.rpcClient.accessToken, [HPAPI getAccessTokenMeta]] + endopoint:@"/accounts/rpc/" completionHandler:^(NSString *finalCallId, HPRPCCallResult *result) { + BOOL authValid = YES; + if (result.error != nil) { + authValid = NO; + } else if ([(NSNumber *)result.result boolValue] == NO) { + authValid = NO; + } + + completionHandler(authValid, result.error, finalCallId); + }]; + } +} + +-(NSString *)save:(NSURL *)url { + if (self.rpcClient.hasCredentials == NO) { + return nil; + } + + if (url == nil) { + return nil; + } + + NSString *callId = [[NSUUID UUID] UUIDString]; + BOOL callResult = [self.rpcClient call:callId method:@"saves.create" params:@[[url absoluteString]]]; + if (callResult == NO) { + return nil; + } + + return callId; +} + +-(BOOL)save:(NSURL *)url completionHandler:(HPRPCClientCompletionHandler)completionHandler { + if (self.rpcClient.hasCredentials == NO) { + return NO; + } + + if (url == nil) { + return NO; + } + + NSString *callId = [[NSUUID UUID] UUIDString]; + return [self.rpcClient call:callId + method:@"saves.create" + params:@[[url absoluteString]] + completionHandler:completionHandler]; +} + +#pragma mark - Notification handlers + +-(void)onCredentialsChanged:(NSNotification *)notification { + [self updateRPCClientCredentials]; +} +@end diff --git a/services/apple/Shared (App)/HPAuthFlow.h b/services/apple/Shared (App)/HPAuthFlow.h new file mode 100644 index 0000000..4735177 --- /dev/null +++ b/services/apple/Shared (App)/HPAuthFlow.h @@ -0,0 +1,30 @@ +// +// HPAuthFlow.h +// HotPocket (macOS) +// +// Created by Tomek Wójcik on 21/09/2025. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface HPAuthParams : NSObject + +@property (copy) NSString *authKey; +@property (copy) NSString *sessionToken; + +@end + +@interface HPAuthFlow : NSObject + +@property (nullable) NSURL *baseURL; +@property (nullable) NSString *sessionToken; + +-(NSURL *)start; +-(HPAuthParams *)handlePostAuthenticateURL:(NSURL *)url; +-(BOOL)handleAuthParams:(HPAuthParams *)authParams; + +@end + +NS_ASSUME_NONNULL_END diff --git a/services/apple/Shared (App)/HPAuthFlow.m b/services/apple/Shared (App)/HPAuthFlow.m new file mode 100644 index 0000000..220aea2 --- /dev/null +++ b/services/apple/Shared (App)/HPAuthFlow.m @@ -0,0 +1,140 @@ +// +// HPAuthFlow.m +// HotPocket (macOS) +// +// Created by Tomek Wójcik on 21/09/2025. +// + +#import "HPAuthFlow.h" + +#import "HPAPI.h" +#import "HPCredentialsHelper.h" +#import "HPRPCClient.h" + +@implementation HPAuthParams + +#pragma mark - HPAuthParams implementation + +@end + +@implementation HPAuthFlow (HPAuthFlowPrivate) + +#pragma mark - HPAuthFlow private interface + +-(NSURL *)resolveAuthenticateURL { + if (self.baseURL == nil) { + return nil; + } + + NSURL *authURL = [self.baseURL URLByAppendingPathComponent:@"/integrations/extension/authenticate/"]; + + if (authURL.scheme == nil) { + return nil; + } + + NSBundle *mainBundle = [NSBundle mainBundle]; + + NSURLComponents *authURLComponents = [NSURLComponents componentsWithURL:authURL resolvingAgainstBaseURL:NO]; + authURLComponents.queryItems = @[ + [NSURLQueryItem queryItemWithName:@"source" value:[[mainBundle infoDictionary] valueForKey:@"HPAuthFlowSource"]], + [NSURLQueryItem queryItemWithName:@"session_token" value:self.sessionToken], + ]; + + return authURLComponents.URL; +} + +@end + +@implementation HPAuthFlow + +#pragma mark - Initialization + +-(id)init { + if (self = [super init]) { + self.baseURL = nil; + self.sessionToken = nil; + } + + return self; +} + +#pragma mark - Public interface + +-(NSURL *)start { + if (self.baseURL == nil) { + return nil; + } + + if (self.sessionToken == nil) { + self.sessionToken = [[NSUUID UUID] UUIDString]; + } + + return [self resolveAuthenticateURL]; +} + +-(HPAuthParams *)handlePostAuthenticateURL:(NSURL *)url { + if (url == nil) { + return nil; + } + + NSDictionary *postAuthenticateURLParams = [[[NSBundle mainBundle] infoDictionary] valueForKey:@"HPAuthFlowPostAuthenticateURLParts"]; + if (postAuthenticateURLParams == nil) { + return nil; + } + + NSURLComponents *urlComponents = [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:NO]; + + if ([urlComponents.scheme isEqualToString:[postAuthenticateURLParams valueForKey:@"scheme"]] == NO) { + return nil; + } + + if ([urlComponents.host isEqualToString:[postAuthenticateURLParams valueForKey:@"host"]] == NO) { + return nil; + } + + HPAuthParams *result = [[HPAuthParams alloc] init]; + for (NSURLQueryItem *queryItem in urlComponents.queryItems) { + if ([queryItem.name isEqualToString:@"auth_key"] == YES) { + result.authKey = queryItem.value; + } else if ([queryItem.name isEqualToString:@"session_token"] == YES) { + result.sessionToken = queryItem.value; + } + } + + if ([self.sessionToken isEqualToString:result.sessionToken] == NO) { + return nil; + } + + return result; +} + +-(BOOL)handleAuthParams:(HPAuthParams *)authParams { + HPRPCClient *rpcClient = [[HPRPCClient alloc] initWithBaseURL:self.baseURL accessToken:nil]; + + NSArray *callParams = @[ + authParams.authKey, + [HPAPI getAccessTokenMeta], + ]; + + BOOL callResult = [rpcClient call:self.sessionToken + method:@"accounts.access_tokens.create" + params:callParams endopoint:@"/accounts/rpc/" + completionHandler:^(NSString *callId, HPRPCCallResult *result) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (result.error != nil) { + NSLog(@"-[HPAuthFlow handleAuthParams:] error=`%@`", result.error); + } else { + HPCredentialsHelper *credentialsHelper = [HPCredentialsHelper sharedHelper]; + [credentialsHelper saveCredentials:[self.baseURL absoluteString] accessToken:(NSString *)result.result]; + } + + self.sessionToken = nil; + + [[NSNotificationCenter defaultCenter] postNotificationName:@"AuthFlowDidFinish" object:self]; + }); + }]; + + return callResult; +} + +@end diff --git a/services/apple/Shared (App)/HPCredentialsHelper.h b/services/apple/Shared (App)/HPCredentialsHelper.h new file mode 100644 index 0000000..0c401c0 --- /dev/null +++ b/services/apple/Shared (App)/HPCredentialsHelper.h @@ -0,0 +1,32 @@ +// +// HPCredentialsHelper.h +// HotPocket +// +// Created by Tomek Wójcik on 19/09/2025. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface HPCredentials : NSObject + +@property (nullable) NSString *baseURL; +@property (nullable) NSString *accessToken; + +@property (readonly) BOOL usable; +@property (readonly) NSURL *rpcURL; + +@end + +@interface HPCredentialsHelper : NSObject + ++(instancetype)sharedHelper; + +-(HPCredentials *)getCredentials; +-(BOOL)saveCredentials:(NSString *)baseURL accessToken:(NSString *)accessToken; +-(BOOL)clearCredentials; + +@end + +NS_ASSUME_NONNULL_END diff --git a/services/apple/Shared (App)/HPCredentialsHelper.m b/services/apple/Shared (App)/HPCredentialsHelper.m new file mode 100644 index 0000000..2f66d76 --- /dev/null +++ b/services/apple/Shared (App)/HPCredentialsHelper.m @@ -0,0 +1,218 @@ +// +// HPCredentialsHelper.m +// HotPocket +// +// Created by Tomek Wójcik on 19/09/2025. +// + +#import + +#import "HPCredentialsHelper.h" + +@implementation HPCredentials + +#pragma mark - HPCredentials implementation + +-(id)init { + if (self = [super init]) { + self.baseURL = nil; + self.accessToken = nil; + } + + return self; +} + +-(BOOL)usable { + return (self.baseURL != nil && self.accessToken != nil); +} + +-(NSURL *)rpcURL { + return [NSURL URLWithString:self.baseURL]; +} + +-(NSString *)description { + NSMutableDictionary *attributes = [NSMutableDictionary dictionaryWithCapacity:2]; + + if (self.baseURL == nil) { + [attributes setValue:@"(null)" forKey:@"baseURL"]; + } else { + [attributes setValue:self.baseURL forKey:@"baseURL"]; + } + + if (self.accessToken == nil) { + [attributes setValue:@"(null)" forKey:@"accessToken"]; + } else { + [attributes setValue:@"***" forKey:@"accessToken"]; + } + + return [NSString stringWithFormat:@"<%@: %p; %@>", NSStringFromClass([self class]), self, attributes]; +} + +@end + +@implementation HPCredentialsHelper (HPCredentialsHelperPrivate) + +#pragma mark - Private interface + +-(NSString *)getService { +#ifdef DEBUG + return @"pl.bthlabs.HotPocket.Debug"; +#else + return @"pl.bthlabs.HotPocket"; +#endif +} + +-(NSData *)getKeychainItem:(NSString *)service account:(NSString *)account { + if (service == nil || account == nil) { + return nil; + } + + NSDictionary *query = @{ + (__bridge id)kSecClass: (__bridge id)kSecClassGenericPassword, + (__bridge id)kSecAttrService: service, + (__bridge id)kSecAttrAccount: account, + (__bridge id)kSecReturnData: @YES, + (__bridge id)kSecMatchLimit: (__bridge id)kSecMatchLimitOne, + (__bridge id)kSecAttrAccessGroup: @"648728X64K.pl.bthlabs.HotPocketShared", + (__bridge id)kSecUseDataProtectionKeychain: @YES, + }; + + CFTypeRef resultData = NULL; + OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, &resultData); + + if (status == errSecSuccess && resultData != NULL) { + NSData *result = (__bridge_transfer NSData *)resultData; + return result; + } else { + CFStringRef statusStringRef = SecCopyErrorMessageString(status, NULL); + NSString *statusString = (__bridge NSString *)statusStringRef; + NSLog(@"-[HPCredentialsHelper getKeychainItem:account:] service=`%@` account=`%@` status=%@", service, account, statusString); + return nil; + } +} + +-(BOOL)createKeychainItemWithValue:(NSData *)value service:(NSString *)service account:(NSString *)account { + if (value == nil || service == nil || account == nil) { + return NO; + } + + NSDictionary *attributes = @{ + (__bridge id)kSecClass: (__bridge id)kSecClassGenericPassword, + (__bridge id)kSecAttrService: service, + (__bridge id)kSecAttrAccount: account, + (__bridge id)kSecValueData: value, + (__bridge id)kSecAttrAccessGroup: @"648728X64K.pl.bthlabs.HotPocketShared", + (__bridge id)kSecUseDataProtectionKeychain: @YES, + }; + + OSStatus status = SecItemAdd((__bridge CFDictionaryRef)attributes, NULL); + + if (status != errSecSuccess) { + CFStringRef statusStringRef = SecCopyErrorMessageString(status, NULL); + NSString *statusString = (__bridge NSString *)statusStringRef; + NSLog(@"-[HPCredentialsHelper createKeychainItemWithValue:service:account:] service=`%@` account=`%@` status=%@", service, account, statusString); + return NO; + } + + return YES; +} + +-(BOOL)deleteKeychainItem:(NSString *)service account:(NSString *)account { + if (service == nil || account == nil) { + return NO; + } + + NSDictionary *query = @{ + (__bridge id)kSecClass: (__bridge id)kSecClassGenericPassword, + (__bridge id)kSecAttrService: service, + (__bridge id)kSecAttrAccount: account, + (__bridge id)kSecAttrAccessGroup: @"648728X64K.pl.bthlabs.HotPocketShared", + (__bridge id)kSecUseDataProtectionKeychain: @YES, + }; + + OSStatus status = SecItemDelete((__bridge CFDictionaryRef)query); + + if (status != errSecSuccess) { + CFStringRef statusStringRef = SecCopyErrorMessageString(status, NULL); + NSString *statusString = (__bridge NSString *)statusStringRef; + NSLog(@"-[HPCredentialsHelper deleteKeychainItem:account:] service=`%@` account=`%@` status=%@", service, account, statusString); + return NO; + } + + return YES; +} + +@end + +@implementation HPCredentialsHelper + +#pragma mark - Initialization + ++(instancetype)sharedHelper { + static HPCredentialsHelper *sharedInstance = nil; + static dispatch_once_t initToken; + dispatch_once(&initToken, ^{ + sharedInstance = [[self alloc] init]; + }); + + return sharedInstance; +} + +#pragma mark - Public interface + +-(HPCredentials *)getCredentials { + HPCredentials *result = [[HPCredentials alloc] init]; + + NSData *itemData = [self getKeychainItem:[self getService] account:@"RPC"]; + if (itemData != nil) { + NSError *error; + NSDictionary *itemPayload = [NSJSONSerialization JSONObjectWithData:itemData + options:NSJSONReadingTopLevelDictionaryAssumed + error:&error]; + + if (error != nil) { + NSLog(@"-[HPCredentialsHalper getCredentials] error=`%@`", error); + } else if (itemPayload != nil) { + result.baseURL = [itemPayload valueForKey:@"baseURL"]; + result.accessToken = [itemPayload valueForKey:@"accessToken"]; + } + } + + return result; +} + +-(BOOL)saveCredentials:(NSString *)baseURL accessToken:(NSString *)accessToken { + NSMutableDictionary *itemPayload = [NSMutableDictionary dictionaryWithCapacity:2]; + + if (baseURL != nil) { + [itemPayload setValue:baseURL forKey:@"baseURL"]; + } + + if (accessToken != nil) { + [itemPayload setValue:accessToken forKey:@"accessToken"]; + } + + NSError *error; + NSData *itemData = [NSJSONSerialization dataWithJSONObject:itemPayload options:0 error:&error]; + + if (error != nil) { + NSLog(@"-[HPCredentialsHalper saveCredentials:accessToken:] error=`%@`", error); + return NO; + } + + BOOL saveResult = [self createKeychainItemWithValue:itemData service:[self getService] account:@"RPC"]; + + [[NSNotificationCenter defaultCenter] postNotificationName:@"HPCredentialsChanged" object:self]; + + return saveResult; +} + +-(BOOL)clearCredentials { + BOOL deleteResult = [self deleteKeychainItem:[self getService] account:@"RPC"]; + + [[NSNotificationCenter defaultCenter] postNotificationName:@"HPCredentialsChanged" object:self]; + + return deleteResult; +} + +@end diff --git a/services/apple/Shared (App)/HPRPCClient.h b/services/apple/Shared (App)/HPRPCClient.h new file mode 100644 index 0000000..a8a45df --- /dev/null +++ b/services/apple/Shared (App)/HPRPCClient.h @@ -0,0 +1,44 @@ +// +// HPRPCClient.h +// HotPocket +// +// Created by Tomek Wójcik on 19/09/2025. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface HPRPCCallResult : NSObject + +@property (nullable) NSError *error; +@property (nullable) id result; + +@end + +@protocol HPRPCClientDelegate + +-(void)rpcClientDidReceiveResult:(HPRPCCallResult *)result callId:(NSString *)callId; + +@end + +typedef void (^HPRPCClientCompletionHandler)(NSString * _Nullable callId, HPRPCCallResult * _Nullable result); + +@interface HPRPCClient : NSObject + +@property (nonatomic, weak) id delegate; +@property NSURL *baseURL; +@property NSString *accessToken; +@property NSURLSession *session; + +-(id)initWithBaseURL:(nullable NSURL *)baseURL accessToken:(nullable NSString *)accessToken; + +-(BOOL)hasCredentials; +-(BOOL)call:(nullable NSString *)callId method:(NSString *)method params:(NSArray *)params endopoint:(nullable NSString *)endpoint completionHandler:(HPRPCClientCompletionHandler)completionHandler; +-(BOOL)call:(nullable NSString *)callId method:(NSString *)method params:(NSArray *)params endopoint:(nullable NSString *)endpoint; +-(BOOL)call:(nullable NSString *)callId method:(NSString *)method params:(NSArray *)params; +-(BOOL)call:(nullable NSString *)callId method:(NSString *)method params:(NSArray *)params completionHandler:(HPRPCClientCompletionHandler)completionHandler; + +@end + +NS_ASSUME_NONNULL_END diff --git a/services/apple/Shared (App)/HPRPCClient.m b/services/apple/Shared (App)/HPRPCClient.m new file mode 100644 index 0000000..375e604 --- /dev/null +++ b/services/apple/Shared (App)/HPRPCClient.m @@ -0,0 +1,185 @@ +// +// HPRPCClient.m +// HotPocket +// +// Created by Tomek Wójcik on 19/09/2025. +// + +#import "HPRPCClient.h" + +@implementation HPRPCCallResult + +#pragma mark - HPRPCCallResult implementation + +-(id)init { + if (self = [super init]) { + self.error = nil; + self.result = nil; + } + + return self; +} + +-(NSString *)description { + NSMutableDictionary *attributes = [NSMutableDictionary dictionaryWithCapacity:2]; + + if (self.error == nil) { + [attributes setValue:@"(null)" forKey:@"error"]; + } else { + [attributes setValue:self.error forKey:@"error"]; + } + + if (self.result == nil) { + [attributes setValue:@"(null)" forKey:@"result"]; + } else { + [attributes setValue:self.result forKey:@"result"]; + } + + return [NSString stringWithFormat:@"<%@: %p; %@>", NSStringFromClass([self class]), self, attributes]; +} + +@end + +@implementation HPRPCClient + +#pragma mark - Initialization + +-(id)initWithBaseURL:(nullable NSURL *)baseURL accessToken:(nullable NSString *)accessToken { + if (self = [super init]) { + self.baseURL = baseURL; + self.accessToken = accessToken; + self.session = [NSURLSession sessionWithConfiguration:NSURLSessionConfiguration.ephemeralSessionConfiguration]; + } + + return self; +} + +#pragma mark - Public interface + +-(BOOL)hasCredentials { + return (self.baseURL != nil && self.accessToken != nil); +} + +-(BOOL)call:(nullable NSString *)callId method:(NSString *)method params:(NSArray *)params endopoint:(nullable NSString *)endpoint completionHandler:(HPRPCClientCompletionHandler)completionHandler { + if (self.baseURL == nil) { + return NO; + } + + if (callId == nil) { + callId = [[NSUUID UUID] UUIDString]; + } + + if (endpoint == nil) { + endpoint = @"/rpc/"; + } + + NSBundle *mainBundle = [NSBundle mainBundle]; + + NSDictionary *payload = @{ + @"jsonrpc": @"2.0", + @"id": callId, + @"method": method, + @"params": params, + }; + + NSError *error; + NSData *jsonPayload = [NSJSONSerialization dataWithJSONObject:payload options:0 error:&error]; + if (!jsonPayload) { + NSLog(@"-[HPRPCClient call:method:params:endpoint:] Unable to serialize payload: error=`%@`", error); + HPRPCCallResult *result = [[HPRPCCallResult alloc] init]; + result.error = error; + + if (self.delegate != nil) { + [self.delegate rpcClientDidReceiveResult:result callId:callId]; + } + + return NO; + } + + NSURLComponents *urlComponents = [NSURLComponents componentsWithURL:[self.baseURL URLByAppendingPathComponent:endpoint] resolvingAgainstBaseURL:NO]; + urlComponents.queryItems = @[ + [NSURLQueryItem queryItemWithName:@"method" value:method], + ]; + + NSURL *callURL = [urlComponents URL]; +#ifdef DEBUG + NSLog(@"-[HPRPCClient call:method:params:endpoint:] callURL=`%@`", callURL.absoluteString); +#endif + + NSMutableURLRequest *request = [[NSMutableURLRequest alloc] initWithURL:callURL]; + request.HTTPMethod = @"POST"; + request.HTTPBody = jsonPayload; + + [request setValue:@"application/json;charset=utf-8" forHTTPHeaderField:@"Content-Type"]; + [request setValue:[[mainBundle infoDictionary] valueForKey:@"HPRPCClientOrigin"] forHTTPHeaderField:@"Origin"]; + + if (self.accessToken != nil) { + NSString *authorization = [NSString stringWithFormat:@"Bearer %@", self.accessToken]; + [request setValue:authorization forHTTPHeaderField:@"Authorization"]; + } + + NSString *build = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleVersion"]; + NSString *userAgent = [NSString stringWithFormat:@"HotPocket/%@", build]; + [request setValue:userAgent forHTTPHeaderField:@"User-Agent"]; + + NSURLSessionDataTask *task = [self.session dataTaskWithRequest:request + completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + HPRPCCallResult *result = [[HPRPCCallResult alloc] init]; + if (error != nil) { + result.error = error; + } else { + NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response; + if (httpResponse.statusCode != 200) { + result.error = [[NSError alloc] initWithDomain:@"pl.bthlabs.HotPocket.HPRPCClient" code:-32000 userInfo:@{ + @"callId": callId, + @"url": httpResponse.URL, + @"statusCode": [NSNumber numberWithInteger:httpResponse.statusCode], + @"response": response, + }]; + } else { + NSError *jsonDecodeError; + NSDictionary *callResult = [NSJSONSerialization JSONObjectWithData:data options:NSJSONReadingTopLevelDictionaryAssumed error:&error]; + if (jsonDecodeError != nil) { + result.error = jsonDecodeError; + } else { + NSDictionary *rpcError = [callResult valueForKey:@"error"]; + if (rpcError != nil) { + NSNumber *rpcErrorCode = [rpcError valueForKey:@"code"]; + if (rpcErrorCode == nil) { + rpcErrorCode = [NSNumber numberWithInt:-32000]; + } + + result.error = [[NSError alloc] initWithDomain:@"pl.bthlabs.HotPocket.HPRPCClient" code:[rpcErrorCode integerValue] userInfo:rpcError]; + } else { + result.result = [callResult valueForKey:@"result"]; + } + } + } + } + + if (completionHandler) { + completionHandler(callId, result); + } + }]; + [task resume]; + + return YES; +} + +-(BOOL)call:(nullable NSString *)callId method:(NSString *)method params:(NSArray *)params endopoint:(nullable NSString *)endpoint { + return [self call:callId method:method params:params endopoint:endpoint completionHandler:^(NSString *callId, HPRPCCallResult *result) { + if (self.delegate != nil) { + [self.delegate rpcClientDidReceiveResult:result callId:callId]; + } + }]; +} + +-(BOOL)call:(nullable NSString *)callId method:(NSString *)method params:(NSArray *)params { + return [self call:callId method:method params:params endopoint:nil]; +} + +-(BOOL)call:(nullable NSString *)callId method:(NSString *)method params:(NSArray *)params completionHandler:(HPRPCClientCompletionHandler)completionHandler { + return [self call:callId method:method params:params endopoint:nil completionHandler:completionHandler]; +} + +@end diff --git a/services/apple/Shared (App)/NSURL+HotPocketExtensions.h b/services/apple/Shared (App)/NSURL+HotPocketExtensions.h new file mode 100644 index 0000000..72e9c41 --- /dev/null +++ b/services/apple/Shared (App)/NSURL+HotPocketExtensions.h @@ -0,0 +1,18 @@ +// +// NSURL+HotPocketExtensions.h +// HotPocket (macOS) +// +// Created by Tomek Wójcik on 30/09/2025. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface NSURL (HotPocketExtensions) + +-(BOOL)isUsableInHotPocket; + +@end + +NS_ASSUME_NONNULL_END diff --git a/services/apple/Shared (App)/NSURL+HotPocketExtensions.m b/services/apple/Shared (App)/NSURL+HotPocketExtensions.m new file mode 100644 index 0000000..b302460 --- /dev/null +++ b/services/apple/Shared (App)/NSURL+HotPocketExtensions.m @@ -0,0 +1,30 @@ +// +// NSURL+HotPocketExtensions.m +// HotPocket (macOS) +// +// Created by Tomek Wójcik on 30/09/2025. +// + +#import "NSURL+HotPocketExtensions.h" + +@implementation NSURL (HotPocketExtensions) + +-(BOOL)isUsableInHotPocket { + static NSArray *supportedSchemes = @[@"http", @"https"]; + + if (self.baseURL != nil) { + return NO; + } + + if (self.scheme == nil || [supportedSchemes containsObject:self.scheme] == NO) { + return NO; + } + + if (self.host == nil || [@"" isEqualToString:self.host] == YES) { + return NO; + } + + return YES; +} + +@end diff --git a/services/apple/Shared (App)/Resources/Base.lproj/Main.html b/services/apple/Shared (App)/Resources/Base.lproj/Main.html deleted file mode 100644 index 28b6f6f..0000000 --- a/services/apple/Shared (App)/Resources/Base.lproj/Main.html +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - HotPocket Icon -

You can turn on Save to Hotpocket Safari extension in Settings.

-

You can turn on Save to Hotpocket extension in Safari Extensions preferences.

-

Save to Hotpocket extension is currently on. You can turn it off in Safari Extensions preferences.

-

Save to Hotpocket extension is currently off. You can turn it on in Safari Extensions preferences.

- - - diff --git a/services/apple/Shared (App)/Resources/Script.js b/services/apple/Shared (App)/Resources/Script.js deleted file mode 100644 index 5ed12c9..0000000 --- a/services/apple/Shared (App)/Resources/Script.js +++ /dev/null @@ -1,24 +0,0 @@ -function show(platform, enabled, useSettingsInsteadOfPreferences) { - document.body.classList.add(`platform-${platform}`); - - if (useSettingsInsteadOfPreferences) { - document.getElementsByClassName('platform-mac state-on')[0].innerText = "Save to Hotpocket extension is currently on. You can turn it off in the Extensions section of Safari Settings."; - document.getElementsByClassName('platform-mac state-off')[0].innerText = "Save to Hotpocket extension is currently off. You can turn it on in the Extensions section of Safari Settings."; - document.getElementsByClassName('platform-mac state-unknown')[0].innerText = "You can turn on Save to Hotpocket extension in the Extensions section of Safari Settings."; - document.getElementsByClassName('platform-mac open-preferences')[0].innerText = "Quit and Open Safari Settings…"; - } - - if (typeof enabled === "boolean") { - document.body.classList.toggle(`state-on`, enabled); - document.body.classList.toggle(`state-off`, !enabled); - } else { - document.body.classList.remove(`state-on`); - document.body.classList.remove(`state-off`); - } -} - -function openPreferences() { - webkit.messageHandlers.controller.postMessage("open-preferences"); -} - -document.querySelector("button.open-preferences").addEventListener("click", openPreferences); diff --git a/services/apple/Shared (App)/Resources/Style.css b/services/apple/Shared (App)/Resources/Style.css deleted file mode 100644 index fb90ccd..0000000 --- a/services/apple/Shared (App)/Resources/Style.css +++ /dev/null @@ -1,63 +0,0 @@ -* { - -webkit-user-select: none; - -webkit-user-drag: none; - cursor: default; -} - -:root { - color-scheme: light dark; - - --spacing: 20px; -} - -html { - height: 100%; -} - -body { - background: #212529; - color: white; - display: flex; - align-items: center; - justify-content: center; - flex-direction: column; - - gap: var(--spacing); - margin: 0 calc(var(--spacing) * 2); - height: 100%; - - font: -apple-system-short-body; - text-align: center; -} - -body:not(.platform-mac, .platform-ios) :is(.platform-mac, .platform-ios) { - display: none; -} - -body.platform-ios .platform-mac { - display: none; -} - -body.platform-mac .platform-ios { - display: none; -} - -body.platform-ios .platform-mac { - display: none; -} - -body:not(.state-on, .state-off) :is(.state-on, .state-off) { - display: none; -} - -body.state-on :is(.state-off, .state-unknown) { - display: none; -} - -body.state-off :is(.state-on, .state-unknown) { - display: none; -} - -button { - font-size: 1em; -} diff --git a/services/apple/Shared (App)/ViewController.h b/services/apple/Shared (App)/ViewController.h deleted file mode 100644 index 25eea78..0000000 --- a/services/apple/Shared (App)/ViewController.h +++ /dev/null @@ -1,26 +0,0 @@ -// -// ViewController.h -// Shared (App) -// -// Created by Tomek Wójcik on 21/08/2025. -// - -#import - -#if TARGET_OS_IOS - -#import - -typedef UIViewController PlatformViewController; - -#elif TARGET_OS_OSX - -#import - -typedef NSViewController PlatformViewController; - -#endif - -@interface ViewController : PlatformViewController - -@end diff --git a/services/apple/Shared (App)/ViewController.m b/services/apple/Shared (App)/ViewController.m deleted file mode 100644 index 3c76975..0000000 --- a/services/apple/Shared (App)/ViewController.m +++ /dev/null @@ -1,76 +0,0 @@ -// -// ViewController.m -// Shared (App) -// -// Created by Tomek Wójcik on 21/08/2025. -// - -#import "ViewController.h" - -#import - -#if TARGET_OS_OSX -#import -#endif - -static NSString * const extensionBundleIdentifier = @"pl.bthlabs.HotPocket.HotPocket.Extension"; - -@interface ViewController () - -@property (nonatomic) IBOutlet WKWebView *webView; - -@end - -@implementation ViewController - -- (void)viewDidLoad { - [super viewDidLoad]; - - _webView.navigationDelegate = self; - -#if TARGET_OS_IOS - _webView.scrollView.scrollEnabled = NO; -#endif - - [_webView.configuration.userContentController addScriptMessageHandler:self name:@"controller"]; - - [_webView loadFileURL:[NSBundle.mainBundle URLForResource:@"Main" withExtension:@"html"] allowingReadAccessToURL:NSBundle.mainBundle.resourceURL]; -} - -- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation { -#if TARGET_OS_IOS - [webView evaluateJavaScript:@"show('ios')" completionHandler:nil]; -#elif TARGET_OS_OSX - [webView evaluateJavaScript:@"show('mac')" completionHandler:nil]; - - [SFSafariExtensionManager getStateOfSafariExtensionWithIdentifier:extensionBundleIdentifier completionHandler:^(SFSafariExtensionState *state, NSError *error) { - dispatch_async(dispatch_get_main_queue(), ^{ - if (!state) { - // Insert code to inform the user something went wrong. - return; - } - - NSString *isExtensionEnabledAsString = state.isEnabled ? @"true" : @"false"; - if (@available(macOS 13, *)) - [webView evaluateJavaScript:[NSString stringWithFormat:@"show('mac', %@, true)", isExtensionEnabledAsString] completionHandler:nil]; - else - [webView evaluateJavaScript:[NSString stringWithFormat:@"show('mac', %@, false)", isExtensionEnabledAsString] completionHandler:nil]; - }); - }]; -#endif -} - -- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message { -#if TARGET_OS_OSX - if (![message.body isEqualToString:@"open-preferences"]) - return; - - [SFSafariApplication showPreferencesForExtensionWithIdentifier:extensionBundleIdentifier completionHandler:^(NSError *error) { - dispatch_async(dispatch_get_main_queue(), ^{ - [NSApp terminate:self]; - }); - }]; -#endif -} - -@end diff --git a/services/apple/Shared (Share Extension)/HPShareExtensionHelper.h b/services/apple/Shared (Share Extension)/HPShareExtensionHelper.h new file mode 100644 index 0000000..05e6479 --- /dev/null +++ b/services/apple/Shared (Share Extension)/HPShareExtensionHelper.h @@ -0,0 +1,27 @@ +// +// HPShareExtensionHelper.h +// HotPocket +// +// Created by Tomek Wójcik on 27/09/2025. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@class HPSharedItemsContainer; + +typedef void (^HPShareExtensionHelperHandleItemsCompletionHandler)(NSURL * _Nullable url); + +@interface HPShareExtensionHelper : NSObject + +@property NSExtensionContext *context; +@property HPSharedItemsContainer *items; + +-(instancetype)initWithContext:(NSExtensionContext *)context; + +-(void)processItems:(HPShareExtensionHelperHandleItemsCompletionHandler)completionHandler; + +@end + +NS_ASSUME_NONNULL_END diff --git a/services/apple/Shared (Share Extension)/HPShareExtensionHelper.m b/services/apple/Shared (Share Extension)/HPShareExtensionHelper.m new file mode 100644 index 0000000..a033210 --- /dev/null +++ b/services/apple/Shared (Share Extension)/HPShareExtensionHelper.m @@ -0,0 +1,102 @@ +// +// HPShareExtensionHelper.m +// HotPocket +// +// Created by Tomek Wójcik on 27/09/2025. +// + +#import + +#import "HPShareExtensionHelper.h" + +#import "HPSharedItem.h" +#import "HPSharedItemsContainer.h" + +@implementation HPShareExtensionHelper (HPShareExtensionHelperPrivate) + +@end + +@implementation HPShareExtensionHelper + +-(instancetype)initWithContext:(NSExtensionContext *)context { + if (self = [super init]) { + self.context = context; + self.items = [[HPSharedItemsContainer alloc] init]; + } + + return self; +} + +-(void)processItems:(HPShareExtensionHelperHandleItemsCompletionHandler)completionHandler { + // Depending on the app, the URL might be stored in `public.url` attachment or elsewhere. + // For example, the YouTube app passes it in `public.plain-text`. Because of course it does. + // Furthermore, for some bizarre reason the recommended way of extracting the URL when sharing from a browser + // is to run a JS snippet and examine its output. + // This method will iterate through all the shared items and their attachments and attempt to extract + // the URL candidates. + // + // Also note that handler for `public.url` explicitly requests the payload to be corced to `NSURL *`. Leaving it + // at `NSData *` would cause iOS to, wait for it!, fetch the URL and dump the response body in the payload :D. + // + // This is so _so_ *so* dumb. But hey, at least I learned how to to "chords" in CGD ¯\_(ツ)_/¯ + UTType *propertyListType = [UTType typeWithFilenameExtension:@"plist"]; + + dispatch_group_t dispatchGroup = dispatch_group_create(); + dispatch_queue_t queue = dispatch_queue_create("HPShareExtensionHelper.processItems.queue", DISPATCH_QUEUE_SERIAL); + + for (NSExtensionItem *inputItem in self.context.inputItems) { +#ifdef DEBUG + NSLog(@"-[HPShareExtensionHelper processItems:] inputItem.userInfo=`%@`", inputItem); +#endif + [inputItem.attachments enumerateObjectsUsingBlock:^(NSItemProvider *attachment, NSUInteger index, BOOL *stop) { + dispatch_group_enter(dispatchGroup); + + if ([attachment hasItemConformingToTypeIdentifier:propertyListType.identifier] == YES) { + [attachment loadItemForTypeIdentifier:propertyListType.identifier + options:nil + completionHandler:^(NSDictionary *payload, NSError *error) { + dispatch_async(queue, ^{ + self.items.primaryItem = [[HPSharedItem alloc] initWithPayload:payload + typeIdentifier:propertyListType.identifier + error:error]; + + dispatch_group_leave(dispatchGroup); + }); + }]; + } else if ([attachment hasItemConformingToTypeIdentifier:@"public.url"] == YES) { + [attachment loadItemForTypeIdentifier:@"public.url" + options:nil + completionHandler:^(NSURL *payload, NSError *error) { + dispatch_async(queue, ^{ + [self.items.candidateItems addObject:[[HPSharedItem alloc] initWithPayload:payload + typeIdentifier:@"public.url" + error:error]]; + + dispatch_group_leave(dispatchGroup); + }); + }]; + } else if ([attachment hasItemConformingToTypeIdentifier:@"public.plain-text"] == YES) { + [attachment loadItemForTypeIdentifier:@"public.plain-text" + options:nil + completionHandler:^(NSString *payload, NSError *error) { + dispatch_async(queue, ^{ + [self.items.candidateItems addObject:[[HPSharedItem alloc] initWithPayload:payload + typeIdentifier:@"public.plain-text" + error:error]]; + + dispatch_group_leave(dispatchGroup); + }); + }]; + } else { + dispatch_group_leave(dispatchGroup); + } + }]; + + dispatch_group_notify(dispatchGroup, dispatch_get_main_queue(), ^{ + NSURL *result = [self.items resolveURL]; + completionHandler(result); + }); + } +} + +@end diff --git a/services/apple/Shared (Share Extension)/HPSharedItem.h b/services/apple/Shared (Share Extension)/HPSharedItem.h new file mode 100644 index 0000000..15a8f83 --- /dev/null +++ b/services/apple/Shared (Share Extension)/HPSharedItem.h @@ -0,0 +1,24 @@ +// +// HPSharedItem.h +// HotPocket +// +// Created by Tomek Wójcik on 27/09/2025. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface HPSharedItem : NSObject + +@property (nullable) id payload; +@property NSString *typeIdentifier; +@property (nullable) NSError *error; + +-(instancetype)initWithPayload:(nullable id)payload typeIdentifier:(NSString *)typeIdentifier error:(nullable NSError *)error; + +-(NSURL *)maybeURL; + +@end + +NS_ASSUME_NONNULL_END diff --git a/services/apple/Shared (Share Extension)/HPSharedItem.m b/services/apple/Shared (Share Extension)/HPSharedItem.m new file mode 100644 index 0000000..6d9265a --- /dev/null +++ b/services/apple/Shared (Share Extension)/HPSharedItem.m @@ -0,0 +1,47 @@ +// +// HPSharedItem.m +// HotPocket +// +// Created by Tomek Wójcik on 27/09/2025. +// + +#import + +#import "HPSharedItem.h" + +@implementation HPSharedItem + +-(instancetype)initWithPayload:(id)payload typeIdentifier:(NSString *)typeIdentifier error:(NSError *)error { + if (self = [super init]) { + self.payload = payload; + self.typeIdentifier = typeIdentifier; + self.error = error; + } + + return self; +} + +-(NSURL *)maybeURL { + if (self.error != nil) { + return nil; + } + + if ([self.typeIdentifier isEqualToString:[UTType typeWithFilenameExtension:@"plist"].identifier] == YES) { + NSDictionary *propertyList = self.payload; + NSDictionary *jsHelperResult = [propertyList valueForKey:NSExtensionJavaScriptPreprocessingResultsKey]; + + if ([jsHelperResult valueForKey:@"iHateComputers"] == nil) { + return nil; + } + + return [NSURL URLWithString:[jsHelperResult valueForKey:@"url"]]; + } else if ([self.typeIdentifier isEqualToString:@"public.url"] == YES) { + return self.payload; + } if ([self.typeIdentifier isEqualToString:@"public.plain-text"] == YES) { + return [NSURL URLWithString:self.payload]; + } + + return nil; +} + +@end diff --git a/services/apple/Shared (Share Extension)/HPSharedItemsContainer.h b/services/apple/Shared (Share Extension)/HPSharedItemsContainer.h new file mode 100644 index 0000000..05908b0 --- /dev/null +++ b/services/apple/Shared (Share Extension)/HPSharedItemsContainer.h @@ -0,0 +1,23 @@ +// +// HPSharedItemsContainer.h +// HotPocket +// +// Created by Tomek Wójcik on 27/09/2025. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@class HPSharedItem; + +@interface HPSharedItemsContainer : NSObject + +@property (nullable) HPSharedItem *primaryItem; +@property NSMutableArray *candidateItems; + +-(NSURL *)resolveURL; + +@end + +NS_ASSUME_NONNULL_END diff --git a/services/apple/Shared (Share Extension)/HPSharedItemsContainer.m b/services/apple/Shared (Share Extension)/HPSharedItemsContainer.m new file mode 100644 index 0000000..753ad8d --- /dev/null +++ b/services/apple/Shared (Share Extension)/HPSharedItemsContainer.m @@ -0,0 +1,63 @@ +// +// HPSharedItemsContainer.m +// HotPocket +// +// Created by Tomek Wójcik on 27/09/2025. +// + +#import "HPSharedItemsContainer.h" + +#import "HPSharedItem.h" +#import "NSURL+HotPocketExtensions.h" + +@implementation HPSharedItemsContainer (HPSharedItemsContainerPrivate) + +-(NSURL *)validatedURL:(NSURL *)url { + if (url.isUsableInHotPocket == NO) { + return nil; + } + + return url; +} + +@end + +@implementation HPSharedItemsContainer + +-(instancetype)init { + if (self = [super init]) { + self.primaryItem = nil; + self.candidateItems = [[NSMutableArray alloc] initWithCapacity:1]; + } + + return self; +} + +-(NSURL *)resolveURL { + NSURL *result = nil; + + if (self.primaryItem != nil) { + result = [self validatedURL:[self.primaryItem maybeURL]]; + } + + if ([self.candidateItems count] > 0) { + NSUInteger itemCandidateIndex = 0; + while (result == nil) { + HPSharedItem *itemCandidate = [self.candidateItems objectAtIndex:itemCandidateIndex]; + + result = [self validatedURL:itemCandidate.maybeURL]; + if (result != nil) { + break; + } + + itemCandidateIndex += 1; + if (itemCandidateIndex >= [self.candidateItems count]) { + break; + } + } + } + + return result; +} + +@end diff --git a/services/apple/iOS (App)/AppDelegate.h b/services/apple/iOS (App)/AppDelegate.h index aa9c146..db898f4 100644 --- a/services/apple/iOS (App)/AppDelegate.h +++ b/services/apple/iOS (App)/AppDelegate.h @@ -7,6 +7,10 @@ #import +@class HPAuthFlow; + @interface AppDelegate : UIResponder +@property (strong, nonnull) HPAuthFlow *authFlow; + @end diff --git a/services/apple/iOS (App)/AppDelegate.m b/services/apple/iOS (App)/AppDelegate.m index ceb53ae..093d592 100644 --- a/services/apple/iOS (App)/AppDelegate.m +++ b/services/apple/iOS (App)/AppDelegate.m @@ -7,14 +7,17 @@ #import "AppDelegate.h" +#import "HPAuthFlow.h" + @implementation AppDelegate -- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { - // Override point for customization after application launch. +-(BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { + self.authFlow = [[HPAuthFlow alloc] init]; + return YES; } -- (UISceneConfiguration *)application:(UIApplication *)application configurationForConnectingSceneSession:(UISceneSession *)connectingSceneSession options:(UISceneConnectionOptions *)options { +-(UISceneConfiguration *)application:(UIApplication *)application configurationForConnectingSceneSession:(UISceneSession *)connectingSceneSession options:(UISceneConnectionOptions *)options { return [[UISceneConfiguration alloc] initWithName:@"Default Configuration" sessionRole:connectingSceneSession.role]; } diff --git a/services/apple/iOS (App)/AuthorizationProgressViewController.h b/services/apple/iOS (App)/AuthorizationProgressViewController.h new file mode 100644 index 0000000..7ad3eba --- /dev/null +++ b/services/apple/iOS (App)/AuthorizationProgressViewController.h @@ -0,0 +1,18 @@ +// +// AuthorizationProgressViewController.h +// HotPocket (iOS) +// +// Created by Tomek Wójcik on 25/09/2025. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface AuthorizationProgressViewController : UIViewController + +@property IBOutlet UIActivityIndicatorView *progressIndicator; + +@end + +NS_ASSUME_NONNULL_END diff --git a/services/apple/iOS (App)/AuthorizationProgressViewController.m b/services/apple/iOS (App)/AuthorizationProgressViewController.m new file mode 100644 index 0000000..5ab7483 --- /dev/null +++ b/services/apple/iOS (App)/AuthorizationProgressViewController.m @@ -0,0 +1,74 @@ +// +// AuthorizationProgressViewController.m +// HotPocket (iOS) +// +// Created by Tomek Wójcik on 25/09/2025. +// + +#import "AuthorizationProgressViewController.h" + +#import "AppDelegate.h" +#import "HPCredentialsHelper.h" + +@interface AuthorizationProgressViewController (AuthorizationProgressViewControllerPrivate) + +#pragma mark - Private interface + +@end + +@implementation AuthorizationProgressViewController + +#pragma mark - View lifecycle + +-(void)viewDidLoad { + [super viewDidLoad]; +} + +-(void)viewWillAppear:(BOOL)animated { + [super viewWillAppear:animated]; + [self.progressIndicator startAnimating]; + + AppDelegate *appDelegate = [[UIApplication sharedApplication] delegate]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onAuthFlowDidFinish:) name:@"AuthFlowDidFinish" object:appDelegate.authFlow]; +} + +-(void)viewWillDisappear:(BOOL)animated { + [super viewWillDisappear:animated]; + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +-(void)viewDidDisappear:(BOOL)animated { + [super viewDidDisappear:animated]; + [self.progressIndicator stopAnimating]; +} + +#pragma mark - Notification handlers + +-(void)onAuthFlowDidFinish:(NSNotification *)notification { + dispatch_async(dispatch_get_main_queue(), ^{ +#ifdef DEBUG + NSLog(@"-[AuthorizationViewController onAuthFlowDidFinish:] notification=`%@`", notification); +#endif + HPCredentials *credentials = [[HPCredentialsHelper sharedHelper] getCredentials]; + + if (credentials.usable == NO) { + UIAlertController *alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Oops!", @"Oops!") + message:NSLocalizedString(@"HotPocket couldn't complete this operation.", @"HotPocket couldn't complete this operation.") + preferredStyle:UIAlertControllerStyleAlert]; + + [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Oh well", @"Oh well") + style:UIAlertActionStyleDefault + handler:^(UIAlertAction *action) { + [alert dismissViewControllerAnimated:YES completion:^{ + [self.navigationController popViewControllerAnimated:YES]; + }]; + }]]; + + [self presentViewController:alert animated:YES completion:nil]; + } else { + [self.navigationController popToRootViewControllerAnimated:YES]; + } + }); +} + +@end diff --git a/services/apple/iOS (App)/AuthorizationViewController.h b/services/apple/iOS (App)/AuthorizationViewController.h new file mode 100644 index 0000000..fa82f6c --- /dev/null +++ b/services/apple/iOS (App)/AuthorizationViewController.h @@ -0,0 +1,22 @@ +// +// AuthorizationViewController.h +// HotPocket (iOS) +// +// Created by Tomek Wójcik on 25/09/2025. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface AuthorizationViewController : UIViewController + +@property UIImageView *invalidURLWarningView; + +@property IBOutlet UITextField *instanceURLField; + +-(IBAction)doStartAuthorizationFlow:(id)sender; + +@end + +NS_ASSUME_NONNULL_END diff --git a/services/apple/iOS (App)/AuthorizationViewController.m b/services/apple/iOS (App)/AuthorizationViewController.m new file mode 100644 index 0000000..674639d --- /dev/null +++ b/services/apple/iOS (App)/AuthorizationViewController.m @@ -0,0 +1,105 @@ +// +// AuthorizationViewController.m +// HotPocket (iOS) +// +// Created by Tomek Wójcik on 25/09/2025. +// + +#import "AuthorizationViewController.h" + +#import "AppDelegate.h" +#import "AuthorizationProgressViewController.h" +#import "HPAuthFlow.h" +#import "HPCredentialsHelper.h" +#import "MainViewController.h" +#import "NSURL+HotPocketExtensions.h" + +@interface AuthorizationViewController (AuthorizationViewControllerPrivate) + +#pragma mark - Private interface + +@end + +@implementation AuthorizationViewController + +#pragma mark - View lifecycle + +-(void)viewDidLoad { + [super viewDidLoad]; + + self.invalidURLWarningView = [[UIImageView alloc] initWithImage:[UIImage systemImageNamed:@"exclamationmark.circle.fill"]]; + self.invalidURLWarningView.contentMode = UIViewContentModeScaleAspectFit; + self.invalidURLWarningView.frame = CGRectMake(0, 0, 16, 16); + self.invalidURLWarningView.tintColor = [UIColor colorNamed:@"WarningColor"]; + [self.view addSubview:self.invalidURLWarningView]; +} + +-(void)viewWillAppear:(BOOL)animated { + [super viewWillAppear:animated]; + + self.instanceURLField.rightView = self.invalidURLWarningView; + self.instanceURLField.rightViewMode = UITextFieldViewModeNever; + + [self.instanceURLField addTarget:self action:@selector(onInstanceURLFieldChanged:) forControlEvents:UIControlEventEditingChanged]; +} + +#pragma mark - Actions + +-(IBAction)doStartAuthorizationFlow:(id)sender { +#ifdef DEBUG + NSLog(@"-[AuthorizationViewController doStartAuthorizationFlow:] instanceURL=`%@`", self.instanceURLField.text); +#endif + if (!self.instanceURLField.text) { + // ??? + return; + } + + NSURL *instanceURL = [NSURL URLWithString:self.instanceURLField.text]; + if (instanceURL.isUsableInHotPocket == NO) { + // ??? + return; + } + + UIApplication *application = [UIApplication sharedApplication]; + AppDelegate *appDeleate = [application delegate]; + appDeleate.authFlow.baseURL = instanceURL; + + NSURL *authURL = [appDeleate.authFlow start]; + if (authURL == nil) { + // ??? + return; + } + + if ([application canOpenURL:authURL] == YES) { + [application openURL:authURL options:@{} completionHandler:^(BOOL result) { + if (result == YES) { + AuthorizationProgressViewController *authorizationProgressViewController = [self.storyboard instantiateViewControllerWithIdentifier:@"AuthorizationProgressViewController"]; + [self.navigationController pushViewController:authorizationProgressViewController animated:YES]; + } + }]; + } +} + +#pragma mark - Event handlers + +-(void)onInstanceURLFieldChanged:(UITextField *)sender { + sender.rightViewMode = UITextFieldViewModeNever; + + if (!sender.text || [@"" isEqualToString:sender.text]) { + sender.rightViewMode = UITextFieldViewModeAlways; + return; + } + + NSURL *url = [NSURL URLWithString:sender.text]; + if (url == nil) { + sender.rightViewMode = UITextFieldViewModeAlways; + return; + } + + if (url.isUsableInHotPocket == NO) { + sender.rightViewMode = UITextFieldViewModeAlways; + return; + } +} + +@end diff --git a/services/apple/iOS (App)/Base.lproj/LaunchScreen.storyboard b/services/apple/iOS (App)/Base.lproj/LaunchScreen.storyboard index 04af28d..7b543dd 100644 --- a/services/apple/iOS (App)/Base.lproj/LaunchScreen.storyboard +++ b/services/apple/iOS (App)/Base.lproj/LaunchScreen.storyboard @@ -1,8 +1,8 @@ - + - + @@ -17,11 +17,24 @@ - - - - - + + + + + + + + + + + + @@ -29,11 +42,11 @@ - + - + diff --git a/services/apple/iOS (App)/Base.lproj/Main.storyboard b/services/apple/iOS (App)/Base.lproj/Main.storyboard index a01c47b..b665bba 100644 --- a/services/apple/iOS (App)/Base.lproj/Main.storyboard +++ b/services/apple/iOS (App)/Base.lproj/Main.storyboard @@ -1,46 +1,245 @@ - + - + + - + - + - - - - - - - - - + + + + + + + + + + + + + + + + + + - + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/services/apple/iOS (App)/HotPocket (iOS).entitlements b/services/apple/iOS (App)/HotPocket (iOS).entitlements new file mode 100644 index 0000000..eed7aca --- /dev/null +++ b/services/apple/iOS (App)/HotPocket (iOS).entitlements @@ -0,0 +1,15 @@ + + + + + com.apple.security.application-groups + + group.pl.bthlabs.HotPocket + + keychain-access-groups + + $(AppIdentifierPrefix)pl.bthlabs.HotPocketShared + $(AppIdentifierPrefix)pl.bthlabs.HotPocket + + + diff --git a/services/apple/iOS (App)/Info.plist b/services/apple/iOS (App)/Info.plist index 81ed29b..c15acda 100644 --- a/services/apple/iOS (App)/Info.plist +++ b/services/apple/iOS (App)/Info.plist @@ -2,6 +2,32 @@ + CFBundleURLTypes + + + CFBundleTypeRole + Viewer + CFBundleURLIconFile + icon-mac-384 + CFBundleURLName + HotPocketDesktopMac + CFBundleURLSchemes + + hotpocket-mobile + + + + HPAuthFlowPostAuthenticateURLParts + + host + post-authenticate + scheme + hotpocket-mobile + + HPAuthFlowSource + HotPocketMobile + HPRPCClientOrigin + hotpocket-mobile://HPRPCClient UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/services/apple/iOS (App)/InstanceURLField.h b/services/apple/iOS (App)/InstanceURLField.h new file mode 100644 index 0000000..895314e --- /dev/null +++ b/services/apple/iOS (App)/InstanceURLField.h @@ -0,0 +1,16 @@ +// +// InstanceURLField.h +// HotPocket (macOS) +// +// Created by Tomek Wójcik on 30/09/2025. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface InstanceURLField : UITextField + +@end + +NS_ASSUME_NONNULL_END diff --git a/services/apple/iOS (App)/InstanceURLField.m b/services/apple/iOS (App)/InstanceURLField.m new file mode 100644 index 0000000..b8c71ac --- /dev/null +++ b/services/apple/iOS (App)/InstanceURLField.m @@ -0,0 +1,21 @@ +// +// InstanceURLField.m +// HotPocket (macOS) +// +// Created by Tomek Wójcik on 30/09/2025. +// + +#import "InstanceURLField.h" + +@implementation InstanceURLField + +-(CGRect)rightViewRectForBounds:(CGRect)bounds { + if (self.rightViewMode != UITextFieldViewModeNever) { + CGFloat offsetTop = (bounds.size.height - 16.0) / 2.0; + return CGRectMake(bounds.size.width - 16.0 - offsetTop, offsetTop, 16.0, 16.0); + } + + return CGRectNull; +} + +@end diff --git a/services/apple/iOS (App)/MainViewController.h b/services/apple/iOS (App)/MainViewController.h new file mode 100644 index 0000000..6bf8964 --- /dev/null +++ b/services/apple/iOS (App)/MainViewController.h @@ -0,0 +1,22 @@ +// +// MainViewController.h +// HotPocket (iOS) +// +// Created by Tomek Wójcik on 25/09/2025. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface MainViewController : UIViewController + +@property IBOutlet UIButton *instanceURLButton; +@property IBOutlet UIButton *logoutButton; + +-(IBAction)doOpenInstanceURL:(id)sender; +-(IBAction)doLogOut:(id)sender; + +@end + +NS_ASSUME_NONNULL_END diff --git a/services/apple/iOS (App)/MainViewController.m b/services/apple/iOS (App)/MainViewController.m new file mode 100644 index 0000000..d01eae2 --- /dev/null +++ b/services/apple/iOS (App)/MainViewController.m @@ -0,0 +1,77 @@ +// +// MainViewController.m +// HotPocket (iOS) +// +// Created by Tomek Wójcik on 25/09/2025. +// + +#import "MainViewController.h" + +#import "HPCredentialsHelper.h" + +#import "AuthorizationViewController.h" + +@interface MainViewController (MainViewControllerPrivate) + +#pragma mark - Private interface + +@end + +@implementation MainViewController + +#pragma mark - View lifecycle + +-(void)viewDidLoad { + [super viewDidLoad]; + + [self.instanceURLButton setTitle:@"" forState:UIControlStateNormal]; + self.instanceURLButton.enabled = NO; + + self.logoutButton.enabled = NO; +} + +-(void)viewWillAppear:(BOOL)animated { + [super viewWillAppear:animated]; + [self.navigationController setNavigationBarHidden:YES animated:NO]; + + HPCredentials *credentials = [[HPCredentialsHelper sharedHelper] getCredentials]; + if (credentials.usable == NO) { + AuthorizationViewController *authorizationViewController = [self.storyboard instantiateViewControllerWithIdentifier:@"AuthorizationViewController"]; + [self.navigationController pushViewController:authorizationViewController animated:NO]; + } else { + [self.instanceURLButton setTitle:credentials.baseURL forState:UIControlStateNormal]; + self.instanceURLButton.enabled = YES; + + self.logoutButton.enabled = YES; + } + + NSString *instanceURLText = @""; + if (credentials.baseURL != nil) { + instanceURLText = credentials.baseURL; + } + + self.instanceURLButton.titleLabel.text = instanceURLText; +} + +#pragma mark - Actions + +-(IBAction)doOpenInstanceURL:(id)sender { + HPCredentials *credentials = [[HPCredentialsHelper sharedHelper] getCredentials]; + if (credentials.usable == YES) { + NSURL *instanceURL = [NSURL URLWithString:credentials.baseURL]; + + UIApplication *application = [UIApplication sharedApplication]; + if ([application canOpenURL:instanceURL] == YES) { + [application openURL:instanceURL options:@{} completionHandler:nil]; + } + } +} + +-(IBAction)doLogOut:(id)sender { + [[HPCredentialsHelper sharedHelper] clearCredentials]; + + AuthorizationViewController *authorizationViewController = [self.storyboard instantiateViewControllerWithIdentifier:@"AuthorizationViewController"]; + [self.navigationController pushViewController:authorizationViewController animated:NO]; +} + +@end diff --git a/services/apple/iOS (App)/MultilineLabel.h b/services/apple/iOS (App)/MultilineLabel.h new file mode 100644 index 0000000..07a7ada --- /dev/null +++ b/services/apple/iOS (App)/MultilineLabel.h @@ -0,0 +1,16 @@ +// +// MultilineLabel.h +// HotPocket (iOS) +// +// Created by Tomek Wójcik on 25/09/2025. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface MultilineLabel : UILabel + +@end + +NS_ASSUME_NONNULL_END diff --git a/services/apple/iOS (App)/MultilineLabel.m b/services/apple/iOS (App)/MultilineLabel.m new file mode 100644 index 0000000..0cdc731 --- /dev/null +++ b/services/apple/iOS (App)/MultilineLabel.m @@ -0,0 +1,24 @@ +// +// MultilineLabel.m +// HotPocket (iOS) +// +// Created by Tomek Wójcik on 25/09/2025. +// + +#import "MultilineLabel.h" + +@implementation MultilineLabel + +-(void)drawTextInRect:(CGRect)rect { + if (!self.text) { + [super drawTextInRect:rect]; + return; + } + + CGSize textSize = [self sizeThatFits:CGSizeMake(rect.size.width, CGFLOAT_MAX)]; + CGRect textRect = CGRectMake(rect.origin.x, rect.origin.y, rect.size.width, MIN(rect.size.height, textSize.height)); + + [super drawTextInRect:textRect]; +} + +@end diff --git a/services/apple/iOS (App)/SceneDelegate.m b/services/apple/iOS (App)/SceneDelegate.m index 4e08865..d3e14b7 100644 --- a/services/apple/iOS (App)/SceneDelegate.m +++ b/services/apple/iOS (App)/SceneDelegate.m @@ -7,6 +7,24 @@ #import "SceneDelegate.h" +#import "AppDelegate.h" +#import "HPAuthFlow.h" + @implementation SceneDelegate +-(void)scene:(UIScene *)scene openURLContexts:(NSSet *)URLContexts { + AppDelegate *appDelegate = [[UIApplication sharedApplication] delegate]; + + for (UIOpenURLContext *context in URLContexts) { + NSURL *url = context.URL; + HPAuthParams *receivedAuthParams = [appDelegate.authFlow handlePostAuthenticateURL:url]; + + if (receivedAuthParams == nil) { + return; + } + + [appDelegate.authFlow handleAuthParams:receivedAuthParams]; + } +} + @end diff --git a/services/apple/iOS (Share Extension)/Base.lproj/MainInterface.storyboard b/services/apple/iOS (Share Extension)/Base.lproj/MainInterface.storyboard new file mode 100644 index 0000000..fa64a4d --- /dev/null +++ b/services/apple/iOS (Share Extension)/Base.lproj/MainInterface.storyboard @@ -0,0 +1,223 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/services/apple/iOS (Share Extension)/Info.plist b/services/apple/iOS (Share Extension)/Info.plist new file mode 100644 index 0000000..01480a5 --- /dev/null +++ b/services/apple/iOS (Share Extension)/Info.plist @@ -0,0 +1,27 @@ + + + + + NSExtension + + NSExtensionAttributes + + NSExtensionJavaScriptPreprocessingFile + ShareExtensionHelper + NSExtensionActivationRule + + NSExtensionActivationSupportsWebPageWithMaxCount + 1 + NSExtensionActivationSupportsWebURLWithMaxCount + 1 + NSExtensionActivationSupportsText + + + + NSExtensionMainStoryboard + MainInterface + NSExtensionPointIdentifier + com.apple.share-services + + + diff --git a/services/apple/iOS (Share Extension)/ShareExtensionHelper.js b/services/apple/iOS (Share Extension)/ShareExtensionHelper.js new file mode 100644 index 0000000..86b7099 --- /dev/null +++ b/services/apple/iOS (Share Extension)/ShareExtensionHelper.js @@ -0,0 +1,20 @@ +// +// ShareExtensionHelper.js +// HotPocket +// +// Created by Tomek Wójcik on 26/09/2025. +// +var ShareExtensionHelper = function() { + // OMG I CAN'T BELIEVE I HAVE TO EMBED JS IN THE SHARE EXTENSION :D +}; + +ShareExtensionHelper.prototype = { + run: function(arguments) { + arguments.completionFunction({ + 'iHateComputers': true, + 'url': document.location.href, + }); + }, +}; + +var ExtensionPreprocessingJS = new ShareExtensionHelper(); diff --git a/services/apple/iOS (Share Extension)/ShareViewController.h b/services/apple/iOS (Share Extension)/ShareViewController.h new file mode 100644 index 0000000..e751cf4 --- /dev/null +++ b/services/apple/iOS (Share Extension)/ShareViewController.h @@ -0,0 +1,27 @@ +// +// ShareViewController.h +// iOS (Share Extension) +// +// Created by Tomek Wójcik on 25/09/2025. +// + +#import + +@class HPAPI; + +@interface ShareViewController : UIViewController + +@property HPAPI *api; + +@property IBOutlet UIActivityIndicatorView *progressIndicator; +@property IBOutlet UIView *savingView; +@property IBOutlet UIView *needsSetupView; +@property IBOutlet UIView *doneView; +@property IBOutlet UIView *errorView; +@property IBOutlet UIView *unprocessableEntityView; +@property IBOutlet UILabel *unameLabel; + +-(IBAction)doCancel:(id)sender; +-(IBAction)doClose:(id)sender; + +@end diff --git a/services/apple/iOS (Share Extension)/ShareViewController.m b/services/apple/iOS (Share Extension)/ShareViewController.m new file mode 100644 index 0000000..a90db46 --- /dev/null +++ b/services/apple/iOS (Share Extension)/ShareViewController.m @@ -0,0 +1,126 @@ +// +// ShareViewController.m +// iOS (Share Extension) +// +// Created by Tomek Wójcik on 25/09/2025. +// + +#import + +#import "ShareViewController.h" + +#import "HPAPI.h" +#import "HPCredentialsHelper.h" +#import "HPShareExtensionHelper.h" + +@implementation ShareViewController (ShareViewControllerPrivate) + +#pragma mark - Private interface + +-(void)saveURL:(NSURL *)url { +#ifdef DEBUG + NSLog(@"-[ShareViewController save:] url=`%@`", url); +#endif + BOOL callResult = [self.api save:url completionHandler:^(NSString * _Nullable callId, HPRPCCallResult * _Nullable result) { + dispatch_async(dispatch_get_main_queue(), ^{ + self.savingView.hidden = YES; + + if (result.error != nil) { +#ifdef DEBUG + NSLog(@"-[ShareViewController resolveLinkAndSave] saveError=`%@`", result.error); +#endif + self.errorView.hidden = NO; + } else { + self.doneView.hidden = NO; + } + }); + }]; + + if (callResult == NO) { + self.savingView.hidden = YES; + self.errorView.hidden = NO; + } +} + +-(void)resolveLinkAndSave { + HPShareExtensionHelper *helper = [[HPShareExtensionHelper alloc] initWithContext:self.extensionContext]; + [helper processItems:^(NSURL *url) { + if (url == nil) { + self.savingView.hidden = YES; + self.unprocessableEntityView.hidden = NO; + } else { + [self saveURL:url]; + } + }]; +} + +@end + +@implementation ShareViewController + +#pragma mark - View lifecycle + +-(void)viewDidLoad { + [super viewDidLoad]; + self.savingView.hidden = NO; + self.needsSetupView.hidden = YES; + self.doneView.hidden = YES; + self.errorView.hidden = YES; + self.unprocessableEntityView.hidden = YES; + + NSBundle *mainBundle = [NSBundle mainBundle]; + self.unameLabel.text = [NSString stringWithFormat:@"HotPocket v%@ (%@)", [mainBundle.infoDictionary valueForKey:@"CFBundleShortVersionString"], [mainBundle.infoDictionary valueForKey:@"CFBundleVersion"]]; + + self.api = [[HPAPI alloc] init]; + if (self.api.rpcClient.hasCredentials == YES) { + self.savingView.hidden = NO; + self.needsSetupView.hidden = YES; + } else { + self.savingView.hidden = YES; + self.needsSetupView.hidden = NO; + } +} + +-(void)viewWillAppear:(BOOL)animated { + [super viewWillAppear:animated]; + [self.progressIndicator startAnimating]; +} + +-(void)viewWillDisappear:(BOOL)animated { + [super viewWillDisappear:animated]; + [self.progressIndicator stopAnimating]; +} + +-(void)viewDidAppear:(BOOL)animated { + [super viewDidAppear:animated]; + + [self.api checkAuth:^(BOOL authValid, NSError *error, NSString *callId) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (authValid == NO) { +#ifdef DEBUG + NSLog(@"-[ShareViewController viewDidAppear:] checkAuthError=`%@`", error); +#endif + self.savingView.hidden = YES; + self.needsSetupView.hidden = NO; + } else { + [self resolveLinkAndSave]; + } + }); + }]; +} + +#pragma mark - Actions + +-(IBAction)doCancel:(id)sender { + NSError *cancelError = [NSError errorWithDomain:NSCocoaErrorDomain code:NSUserCancelledError userInfo:nil]; + [self.extensionContext cancelRequestWithError:cancelError]; +} + +-(IBAction)doClose:(id)sender { + NSExtensionItem *outputItem = [[NSExtensionItem alloc] init]; + + NSArray *outputItems = @[outputItem]; + [self.extensionContext completeRequestReturningItems:outputItems completionHandler:nil]; +} + +@end diff --git a/services/apple/iOS (Share Extension)/iOS (Share Extension).entitlements b/services/apple/iOS (Share Extension)/iOS (Share Extension).entitlements new file mode 100644 index 0000000..06ad5b3 --- /dev/null +++ b/services/apple/iOS (Share Extension)/iOS (Share Extension).entitlements @@ -0,0 +1,15 @@ + + + + + com.apple.security.application-groups + + group.pl.bthlabs.HotPocket + + keychain-access-groups + + $(AppIdentifierPrefix)pl.bthlabs.HotPocketShared + $(AppIdentifierPrefix)pl.bthlabs.HotPocket.ShareExtension + + + diff --git a/services/apple/invoke.yaml b/services/apple/invoke.yaml index 03b633f..71ee5c9 100644 --- a/services/apple/invoke.yaml +++ b/services/apple/invoke.yaml @@ -3,3 +3,4 @@ run: pty: true files_to_version: - "HotPocket.xcodeproj/project.pbxproj" + - "pyproject.toml" diff --git a/services/apple/macOS (App)/AppDelegate.h b/services/apple/macOS (App)/AppDelegate.h index 9a4217f..ed52cd2 100644 --- a/services/apple/macOS (App)/AppDelegate.h +++ b/services/apple/macOS (App)/AppDelegate.h @@ -7,6 +7,10 @@ #import +@class HPAuthFlow; + @interface AppDelegate : NSObject +@property (strong, nonnull) HPAuthFlow *authFlow; + @end diff --git a/services/apple/macOS (App)/AppDelegate.m b/services/apple/macOS (App)/AppDelegate.m index 6f43d11..8ca6abe 100644 --- a/services/apple/macOS (App)/AppDelegate.m +++ b/services/apple/macOS (App)/AppDelegate.m @@ -7,14 +7,32 @@ #import "AppDelegate.h" +#import "HPAuthFlow.h" +#import "HPCredentialsHelper.h" + @implementation AppDelegate -- (void)applicationDidFinishLaunching:(NSNotification *)notification { - // Override point for customization after application launch. +-(void)applicationDidFinishLaunching:(NSNotification *)notification { + self.authFlow = [[HPAuthFlow alloc] init]; } -- (BOOL)applicationShouldTerminateAfterLastWindowClosed:(NSApplication *)sender { +-(BOOL)applicationShouldTerminateAfterLastWindowClosed:(NSApplication *)sender { return YES; } +-(void)application:(NSApplication *)application openURLs:(NSArray *)urls { + HPAuthParams *receivedAuthParams = nil; + for (NSURL *url in urls) { + receivedAuthParams = [self.authFlow handlePostAuthenticateURL:url]; + + if (receivedAuthParams != nil) { + break; + } + } + + if (receivedAuthParams != nil) { + [self.authFlow handleAuthParams:receivedAuthParams]; + } +} + @end diff --git a/services/apple/macOS (App)/AuthorizationProgressViewController.h b/services/apple/macOS (App)/AuthorizationProgressViewController.h new file mode 100644 index 0000000..e41a363 --- /dev/null +++ b/services/apple/macOS (App)/AuthorizationProgressViewController.h @@ -0,0 +1,18 @@ +// +// AuthorizationProgressViewController.h +// HotPocket (macOS) +// +// Created by Tomek Wójcik on 20/09/2025. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface AuthorizationProgressViewController : NSViewController + +@property IBOutlet NSProgressIndicator *progressIndicator; + +@end + +NS_ASSUME_NONNULL_END diff --git a/services/apple/macOS (App)/AuthorizationProgressViewController.m b/services/apple/macOS (App)/AuthorizationProgressViewController.m new file mode 100644 index 0000000..6d6b74c --- /dev/null +++ b/services/apple/macOS (App)/AuthorizationProgressViewController.m @@ -0,0 +1,68 @@ +// +// AuthorizationProgressViewController.m +// HotPocket (macOS) +// +// Created by Tomek Wójcik on 20/09/2025. +// + +#import "AuthorizationProgressViewController.h" + +#import "AppDelegate.h" +#import "AuthorizationViewController.h" +#import "HPCredentialsHelper.h" +#import "MainViewController.h" +#import "ReplaceAnimator.h" + +@interface AuthorizationProgressViewController (AuthorizationProgressViewControllerPrivate) + +#pragma mark - Private interface + +@end + +@implementation AuthorizationProgressViewController + +#pragma mark - View lifecycle + +-(void)viewDidLoad { + [super viewDidLoad]; +} + +-(void)viewWillAppear { + AppDelegate *appDelegate = [[NSApplication sharedApplication] delegate]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onAuthFlowDidFinish:) name:@"AuthFlowDidFinish" object:appDelegate.authFlow]; + + [self.progressIndicator startAnimation:self]; +} + +-(void)viewDidDisappear { + [self.progressIndicator stopAnimation:self]; + + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +#pragma mark - Notification handlers + +-(void)onAuthFlowDidFinish:(NSNotification *)notification { + dispatch_async(dispatch_get_main_queue(), ^{ + HPCredentials *credentials = [[HPCredentialsHelper sharedHelper] getCredentials]; + + [[NSApplication sharedApplication] requestUserAttention:NSInformationalRequest]; + [[NSApplication sharedApplication] activateIgnoringOtherApps:YES]; + + if (credentials.usable == NO) { + NSAlert *alert = [[NSAlert alloc] init]; + alert.alertStyle = NSAlertStyleCritical; + alert.messageText = NSLocalizedString(@"Oops!", @"Oops!"); + alert.informativeText = NSLocalizedString(@"HotPocket couldn't complete this operation.", @"HotPocket couldn't complete this operation."); + [alert runModal]; + + AuthorizationViewController *authorizationViewController = [self.storyboard instantiateControllerWithIdentifier:@"AuthorizationViewController"]; + [self presentViewController:authorizationViewController animator:[[ReplaceAnimator alloc] init]]; + } else { + MainViewController *mainViewController = [self.storyboard instantiateControllerWithIdentifier:@"MainViewController"]; + [self presentViewController:mainViewController animator:[[ReplaceAnimator alloc] init]]; + } + }); +} + +@end diff --git a/services/apple/macOS (App)/AuthorizationViewController.h b/services/apple/macOS (App)/AuthorizationViewController.h new file mode 100644 index 0000000..e1f8c7a --- /dev/null +++ b/services/apple/macOS (App)/AuthorizationViewController.h @@ -0,0 +1,21 @@ +// +// AuthorizationViewController.h +// HotPocket (macOS) +// +// Created by Tomek Wójcik on 20/09/2025. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface AuthorizationViewController : NSViewController + +@property (nullable) NSString *baseURL; +@property (nullable) NSString *authorizationSessionToken; + +-(IBAction)doStartAuthorizationFlow:(id)sender; + +@end + +NS_ASSUME_NONNULL_END diff --git a/services/apple/macOS (App)/AuthorizationViewController.m b/services/apple/macOS (App)/AuthorizationViewController.m new file mode 100644 index 0000000..213c522 --- /dev/null +++ b/services/apple/macOS (App)/AuthorizationViewController.m @@ -0,0 +1,49 @@ +// +// AuthorizationViewController.m +// HotPocket (macOS) +// +// Created by Tomek Wójcik on 20/09/2025. +// + +#import "AuthorizationViewController.h" + +#import "AppDelegate.h" +#import "HPAuthFlow.h" +#import "AuthorizationProgressViewController.h" +#import "ReplaceAnimator.h" + +@interface AuthorizationViewController (AuthorizationViewControllerPrivate) + +#pragma mark - Private interface + +@end + +@implementation AuthorizationViewController + +#pragma mark - View lifecycle + +-(void)viewDidLoad { + [super viewDidLoad]; + self.baseURL = nil; + self.authorizationSessionToken = nil; +} + +#pragma mark - Actions + +-(IBAction)doStartAuthorizationFlow:(id)sender { + AppDelegate *appDeleate = [[NSApplication sharedApplication] delegate]; + appDeleate.authFlow.baseURL = [NSURL URLWithString:self.baseURL]; + + NSURL *authURL = [appDeleate.authFlow start]; + if (authURL == nil) { + NSBeep(); + return; + } + + AuthorizationProgressViewController *authProgressViewController = [self.storyboard instantiateControllerWithIdentifier:@"AuthorizationProgressViewController"]; + [self presentViewController:authProgressViewController animator:[[ReplaceAnimator alloc] init]]; + + [[NSWorkspace sharedWorkspace] openURL:authURL]; +} + +@end diff --git a/services/apple/macOS (App)/Base.lproj/Main.storyboard b/services/apple/macOS (App)/Base.lproj/Main.storyboard index 802e36f..e8c5925 100644 --- a/services/apple/macOS (App)/Base.lproj/Main.storyboard +++ b/services/apple/macOS (App)/Base.lproj/Main.storyboard @@ -1,8 +1,8 @@ - + - - + + @@ -77,7 +77,7 @@ - + @@ -87,38 +87,216 @@ - + - + - - + + + - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/services/apple/macOS (App)/HotPocket.entitlements b/services/apple/macOS (App)/HotPocket.entitlements index 625af03..ac82a9f 100644 --- a/services/apple/macOS (App)/HotPocket.entitlements +++ b/services/apple/macOS (App)/HotPocket.entitlements @@ -4,9 +4,18 @@ com.apple.security.app-sandbox + com.apple.security.application-groups + + group.pl.bthlabs.HotPocket + com.apple.security.files.user-selected.read-only com.apple.security.network.client + keychain-access-groups + + $(AppIdentifierPrefix)pl.bthlabs.HotPocketShared + $(AppIdentifierPrefix)pl.bthlabs.HotPocket + diff --git a/services/apple/macOS (App)/Info.plist b/services/apple/macOS (App)/Info.plist new file mode 100644 index 0000000..bbf499f --- /dev/null +++ b/services/apple/macOS (App)/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleURLTypes + + + CFBundleTypeRole + Viewer + CFBundleURLIconFile + icon-mac-384 + CFBundleURLName + HotPocketDesktopMac + CFBundleURLSchemes + + hotpocket-desktop + + + + HPAuthFlowPostAuthenticateURLParts + + host + post-authenticate + scheme + hotpocket-desktop + + HPAuthFlowSource + HotPocketDesktop + HPRPCClientOrigin + hotpocket-desktop://HPRPCClient + + diff --git a/services/apple/macOS (App)/LinkLabel.h b/services/apple/macOS (App)/LinkLabel.h new file mode 100644 index 0000000..bb0c365 --- /dev/null +++ b/services/apple/macOS (App)/LinkLabel.h @@ -0,0 +1,16 @@ +// +// LinkLabel.h +// HotPocket (macOS) +// +// Created by Tomek Wójcik on 24/09/2025. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface LinkLabel : NSTextField + +@end + +NS_ASSUME_NONNULL_END diff --git a/services/apple/macOS (App)/LinkLabel.m b/services/apple/macOS (App)/LinkLabel.m new file mode 100644 index 0000000..201b713 --- /dev/null +++ b/services/apple/macOS (App)/LinkLabel.m @@ -0,0 +1,24 @@ +// +// LinkLabel.m +// HotPocket (macOS) +// +// Created by Tomek Wójcik on 24/09/2025. +// + +#import "LinkLabel.h" + +@implementation LinkLabel + +-(void)awakeFromNib { + [super awakeFromNib]; + self.allowsEditingTextAttributes = YES; + self.textColor = [NSColor colorNamed:@"SecondaryColor"]; + self.selectable = YES; +} + +-(void)resetCursorRects { + [super resetCursorRects]; + [self addCursorRect:self.bounds cursor:NSCursor.pointingHandCursor]; +} + +@end diff --git a/services/apple/macOS (App)/MainViewController.h b/services/apple/macOS (App)/MainViewController.h new file mode 100644 index 0000000..f589688 --- /dev/null +++ b/services/apple/macOS (App)/MainViewController.h @@ -0,0 +1,22 @@ +// +// MainViewController.h +// HotPocket (iOS) +// +// Created by Tomek Wójcik on 22/09/2025. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface MainViewController : NSViewController + +@property IBOutlet NSTextField *instanceURLLabel; + +@property BOOL logoutButtonEnabled; + +-(IBAction)doLogOut:(id)sender; + +@end + +NS_ASSUME_NONNULL_END diff --git a/services/apple/macOS (App)/MainViewController.m b/services/apple/macOS (App)/MainViewController.m new file mode 100644 index 0000000..7b29b53 --- /dev/null +++ b/services/apple/macOS (App)/MainViewController.m @@ -0,0 +1,65 @@ +// +// MainViewController.m +// HotPocket (iOS) +// +// Created by Tomek Wójcik on 22/09/2025. +// + +#import "MainViewController.h" + +#import "HPCredentialsHelper.h" +#import "AuthorizationViewController.h" +#import "ReplaceAnimator.h" + +@interface MainViewController (MainViewControllerPrivate) + +#pragma mark - Private interface + +@end + +@implementation MainViewController + +#pragma mark - View lifecycle + +-(void)viewDidLoad { + [super viewDidLoad]; + self.logoutButtonEnabled = NO; +} + +-(void)viewDidAppear { + HPCredentials *credentials = [[HPCredentialsHelper sharedHelper] getCredentials]; + + if (credentials.usable == NO) { + AuthorizationViewController *authorizationViewController = [self.storyboard instantiateControllerWithIdentifier:@"AuthorizationViewController"]; + [self presentViewController:authorizationViewController animator:[[ReplaceAnimator alloc] init]]; + } else { + self.logoutButtonEnabled = YES; + } + + NSString *instanceURLText = @""; + if (credentials.baseURL != nil) { + instanceURLText = credentials.baseURL; + } + + NSMutableAttributedString *instanceURLValue = [[NSMutableAttributedString alloc] initWithString:instanceURLText]; + if (credentials.baseURL != nil) { + [instanceURLValue addAttribute:NSLinkAttributeName + value:credentials.baseURL + range:NSMakeRange(0, instanceURLValue.length)]; + } + [instanceURLValue addAttribute:NSUnderlineStyleAttributeName + value:[NSNumber numberWithInteger:NSUnderlineStyleSingle] + range:NSMakeRange(0, instanceURLValue.length)]; + + self.instanceURLLabel.attributedStringValue = instanceURLValue; +} + +#pragma mark - Actions + +-(IBAction)doLogOut:(id)sender { + [[HPCredentialsHelper sharedHelper] clearCredentials]; + AuthorizationViewController *authorizationViewController = [self.storyboard instantiateControllerWithIdentifier:@"AuthorizationViewController"]; + [self presentViewController:authorizationViewController animator:[[ReplaceAnimator alloc] init]]; +} + +@end diff --git a/services/apple/macOS (App)/ReplaceAnimator.h b/services/apple/macOS (App)/ReplaceAnimator.h new file mode 100644 index 0000000..78678d8 --- /dev/null +++ b/services/apple/macOS (App)/ReplaceAnimator.h @@ -0,0 +1,16 @@ +// +// ReplaceAnimator.h +// HotPocket (macOS) +// +// Created by Tomek Wójcik on 20/09/2025. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface ReplaceAnimator : NSObject + +@end + +NS_ASSUME_NONNULL_END diff --git a/services/apple/macOS (App)/ReplaceAnimator.m b/services/apple/macOS (App)/ReplaceAnimator.m new file mode 100644 index 0000000..2593221 --- /dev/null +++ b/services/apple/macOS (App)/ReplaceAnimator.m @@ -0,0 +1,29 @@ +// +// ReplaceAnimator.m +// HotPocket (macOS) +// +// Created by Tomek Wójcik on 20/09/2025. +// + +#import "ReplaceAnimator.h" + +@implementation ReplaceAnimator + +-(void)animatePresentationOfViewController:(NSViewController *)viewController fromViewController:(NSViewController *)fromViewController { + NSView *container = fromViewController.view.superview; + if (container == nil) { + return; + } + + [fromViewController.view removeFromSuperview]; + + [container addSubview:viewController.view]; + viewController.view.frame = NSMakeRect(0, 0, viewController.view.frame.size.width, viewController.view.frame.size.height); + viewController.view.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; +} + +-(void)animateDismissalOfViewController:(NSViewController *)viewController fromViewController:(NSViewController *)fromViewController { + [viewController.view removeFromSuperview]; +} + +@end diff --git a/services/apple/macOS (App)/WindowContentView.h b/services/apple/macOS (App)/WindowContentView.h new file mode 100644 index 0000000..d5280a4 --- /dev/null +++ b/services/apple/macOS (App)/WindowContentView.h @@ -0,0 +1,19 @@ +// +// WindowContentView.h +// HotPocket (macOS) +// +// Created by Tomek Wójcik on 30/09/2025. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface WindowContentView : NSView + +@property (strong) NSColor *darkBackgroundColor; +@property (strong) NSColor *lightBackgroundColor; + +@end + +NS_ASSUME_NONNULL_END diff --git a/services/apple/macOS (App)/WindowContentView.m b/services/apple/macOS (App)/WindowContentView.m new file mode 100644 index 0000000..fac66dd --- /dev/null +++ b/services/apple/macOS (App)/WindowContentView.m @@ -0,0 +1,42 @@ +// +// WindowContentView.m +// HotPocket (macOS) +// +// Created by Tomek Wójcik on 30/09/2025. +// + +#import "WindowContentView.h" + +@implementation WindowContentView + +-(void)awakeFromNib { + [super awakeFromNib]; + self.darkBackgroundColor = [NSColor colorNamed:@"BackgroundColor"]; + self.lightBackgroundColor = [NSColor windowBackgroundColor]; +} + +-(BOOL)isOpaque { + return YES; +} + +-(void)drawRect:(NSRect)dirtyRect { + [super drawRect:dirtyRect]; + + NSAppearance *appearance = self.effectiveAppearance; + NSAppearanceName bestMatch = [appearance bestMatchFromAppearancesWithNames:@[ + NSAppearanceNameAqua, + NSAppearanceNameDarkAqua + ]]; + + NSColor *backgroundColor = self.lightBackgroundColor; + if ([bestMatch isEqualToString:NSAppearanceNameDarkAqua] == YES) { + backgroundColor = self.darkBackgroundColor; + } + + if (backgroundColor) { + [backgroundColor setFill]; + NSRectFill(dirtyRect); + } +} + +@end diff --git a/services/apple/macOS (Extension)/HotPocket.entitlements b/services/apple/macOS (Extension)/HotPocket.entitlements index f2ef3ae..14e8453 100644 --- a/services/apple/macOS (Extension)/HotPocket.entitlements +++ b/services/apple/macOS (Extension)/HotPocket.entitlements @@ -2,9 +2,18 @@ - com.apple.security.app-sandbox - - com.apple.security.files.user-selected.read-only - + com.apple.security.app-sandbox + + com.apple.security.application-groups + + group.pl.bthlabs.HotPocket + + com.apple.security.files.user-selected.read-only + + keychain-access-groups + + $(AppIdentifierPrefix)pl.bthlabs.HotPocketShared + $(AppIdentifierPrefix)pl.bthlabs.HotPocket.Extension + diff --git a/services/apple/macOS (Share Extension)/Base.lproj/ShareViewController.xib b/services/apple/macOS (Share Extension)/Base.lproj/ShareViewController.xib new file mode 100644 index 0000000..de20151 --- /dev/null +++ b/services/apple/macOS (Share Extension)/Base.lproj/ShareViewController.xib @@ -0,0 +1,245 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/services/apple/macOS (Share Extension)/Info.plist b/services/apple/macOS (Share Extension)/Info.plist new file mode 100644 index 0000000..181376c --- /dev/null +++ b/services/apple/macOS (Share Extension)/Info.plist @@ -0,0 +1,29 @@ + + + + + CFBundleIconFile + icon + NSExtension + + NSExtensionAttributes + + NSExtensionJavaScriptPreprocessingFile + ShareExtensionHelper + NSExtensionActivationRule + + NSExtensionActivationSupportsWebPageWithMaxCount + 1 + NSExtensionActivationSupportsWebURLWithMaxCount + 1 + NSExtensionActivationSupportsText + + + + NSExtensionPointIdentifier + com.apple.share-services + NSExtensionPrincipalClass + ShareViewController + + + diff --git a/services/apple/macOS (Share Extension)/ShareViewController.h b/services/apple/macOS (Share Extension)/ShareViewController.h new file mode 100644 index 0000000..b48f0fe --- /dev/null +++ b/services/apple/macOS (Share Extension)/ShareViewController.h @@ -0,0 +1,24 @@ +// +// ShareViewController.h +// macOS (Share Extension) +// +// Created by Tomek Wójcik on 22/09/2025. +// + +#import + +@class HPAPI; + +@interface ShareViewController : NSViewController + +@property HPAPI *api; +@property BOOL savingViewHidden; +@property BOOL needsSetupViewHidden; +@property BOOL doneViewHidden; +@property BOOL errorViewHidden; +@property BOOL unprocessableEntityViewHidden; +@property NSString *uname; + +@property IBOutlet NSProgressIndicator *progressIndicator; + +@end diff --git a/services/apple/macOS (Share Extension)/ShareViewController.m b/services/apple/macOS (Share Extension)/ShareViewController.m new file mode 100644 index 0000000..3364a20 --- /dev/null +++ b/services/apple/macOS (Share Extension)/ShareViewController.m @@ -0,0 +1,128 @@ +// +// ShareViewController.m +// macOS (Share Extension) +// +// Created by Tomek Wójcik on 22/09/2025. +// + +#import "ShareViewController.h" + +#import "HPAPI.h" +#import "HPShareExtensionHelper.h" + +@implementation ShareViewController (ShareViewControllerPrivate) + +#pragma mark - Private interface + +-(void)saveURL:(NSURL *)url { +#ifdef DEBUG + NSLog(@"-[ShareViewController save:] url=`%@`", url); +#endif + BOOL callResult = [self.api save:url completionHandler:^(NSString * _Nullable callId, HPRPCCallResult * _Nullable result) { + dispatch_async(dispatch_get_main_queue(), ^{ + self.savingViewHidden = YES; + + if (result.error != nil) { +#ifdef DEBUG + NSLog(@"-[ShareViewController resolveLinkAndSave] saveError=`%@`", result.error); +#endif + self.errorViewHidden = NO; + } else { + self.doneViewHidden = NO; + } + }); + }]; + + if (callResult == NO) { + self.savingViewHidden = YES; + self.errorViewHidden = NO; + } +} + +-(void)resolveLinkAndSave { + HPShareExtensionHelper *helper = [[HPShareExtensionHelper alloc] initWithContext:self.extensionContext]; + [helper processItems:^(NSURL *url) { + if (url == nil) { + self.savingViewHidden = YES; + self.unprocessableEntityViewHidden = NO; + } else { + [self saveURL:url]; + } + }]; +} + +@end + +@implementation ShareViewController + +#pragma mark - View lifecycle + +-(NSString *)nibName { + return @"ShareViewController"; +} + +-(void)viewDidLoad { + [super viewDidLoad]; + self.savingViewHidden = NO; + self.needsSetupViewHidden = YES; + self.doneViewHidden = YES; + self.errorViewHidden = YES; + self.unprocessableEntityViewHidden = YES; + + NSBundle *mainBundle = [NSBundle mainBundle]; + self.uname = [NSString stringWithFormat:@"HotPocket v%@ (%@)", [mainBundle.infoDictionary valueForKey:@"CFBundleShortVersionString"], [mainBundle.infoDictionary valueForKey:@"CFBundleVersion"]]; + + self.api = [[HPAPI alloc] init]; + if (self.api.rpcClient.hasCredentials == YES) { + self.savingViewHidden = NO; + self.needsSetupViewHidden = YES; + } else { + self.savingViewHidden = YES; + self.needsSetupViewHidden = NO; + } +} + +-(void)viewWillAppear { + [super viewWillAppear]; + [self.progressIndicator startAnimation:self]; +} + +-(void)viewDidAppear { + [super viewDidAppear]; + + [self.api checkAuth:^(BOOL authValid, NSError *error, NSString *callId) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (authValid == NO) { +#ifdef DEBUG + NSLog(@"-[ShareViewController viewDidAppear:] checkAuthError=`%@`", error); +#endif + self.savingViewHidden = YES; + self.needsSetupViewHidden = NO; + } else { + [self resolveLinkAndSave]; + } + }); + }]; +} + +-(void)viewDidDisappear { + [super viewDidDisappear]; + [self.progressIndicator stopAnimation:self]; +} + +#pragma mark - Actions + +-(IBAction)close:(id)sender { + NSExtensionItem *outputItem = [[NSExtensionItem alloc] init]; + + NSArray *outputItems = @[outputItem]; + [self.extensionContext completeRequestReturningItems:outputItems completionHandler:nil]; +} + +-(IBAction)cancel:(id)sender { + NSError *cancelError = [NSError errorWithDomain:NSCocoaErrorDomain code:NSUserCancelledError userInfo:nil]; + [self.extensionContext cancelRequestWithError:cancelError]; +} + +@end + diff --git a/services/apple/macOS (Share Extension)/icon.icns b/services/apple/macOS (Share Extension)/icon.icns new file mode 100644 index 0000000..84ae85c Binary files /dev/null and b/services/apple/macOS (Share Extension)/icon.icns differ diff --git a/services/apple/macOS (Share Extension)/macOS (Share Extension).entitlements b/services/apple/macOS (Share Extension)/macOS (Share Extension).entitlements new file mode 100644 index 0000000..871e43d --- /dev/null +++ b/services/apple/macOS (Share Extension)/macOS (Share Extension).entitlements @@ -0,0 +1,21 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + group.pl.bthlabs.HotPocket + + com.apple.security.files.user-selected.read-only + + com.apple.security.network.client + + keychain-access-groups + + $(AppIdentifierPrefix)pl.bthlabs.HotPocketShared + $(AppIdentifierPrefix)pl.bthlabs.HotPocket.ShareExtension + + + diff --git a/services/apple/pyproject.toml b/services/apple/pyproject.toml index bf3ebf7..38ead3b 100644 --- a/services/apple/pyproject.toml +++ b/services/apple/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "hotpocket-apple" -version = "25.9.12" +version = "25.9.17" description = "HotPocket Apple Integrations" authors = ["Tomek Wójcik "] license = "Apache-2.0" diff --git a/services/backend/hotpocket_backend/apps/accounts/migrations/0006_authkey.py b/services/backend/hotpocket_backend/apps/accounts/migrations/0006_authkey.py new file mode 100644 index 0000000..2e83e45 --- /dev/null +++ b/services/backend/hotpocket_backend/apps/accounts/migrations/0006_authkey.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.3 on 2025-09-22 07:20 + +import uuid6 +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0005_accesstoken'), + ] + + operations = [ + migrations.CreateModel( + name='AuthKey', + fields=[ + ('id', models.UUIDField(default=uuid6.uuid7, editable=False, primary_key=True, serialize=False)), + ('account_uuid', models.UUIDField(db_index=True, default=None)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('deleted_at', models.DateTimeField(blank=True, db_index=True, default=None, editable=False, null=True)), + ('key', models.CharField(db_index=True, default=None, editable=False, max_length=128, unique=True)), + ], + options={ + 'verbose_name': 'Auth Key', + 'verbose_name_plural': 'Auth Keys', + }, + ), + ] diff --git a/services/backend/hotpocket_backend/apps/accounts/migrations/0007_authkey_consumed_at.py b/services/backend/hotpocket_backend/apps/accounts/migrations/0007_authkey_consumed_at.py new file mode 100644 index 0000000..1d61473 --- /dev/null +++ b/services/backend/hotpocket_backend/apps/accounts/migrations/0007_authkey_consumed_at.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.3 on 2025-10-01 07:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0006_authkey'), + ] + + operations = [ + migrations.AddField( + model_name='authkey', + name='consumed_at', + field=models.DateTimeField(blank=True, db_index=True, default=None, editable=False, null=True), + ), + ] diff --git a/services/backend/hotpocket_backend/apps/accounts/models/__init__.py b/services/backend/hotpocket_backend/apps/accounts/models/__init__.py index 236027c..a4dfd35 100644 --- a/services/backend/hotpocket_backend/apps/accounts/models/__init__.py +++ b/services/backend/hotpocket_backend/apps/accounts/models/__init__.py @@ -1,2 +1,3 @@ from .access_token import AccessToken # noqa: F401 from .account import Account # noqa: F401 +from .auth_key import AuthKey # noqa: F401 diff --git a/services/backend/hotpocket_backend/apps/accounts/models/auth_key.py b/services/backend/hotpocket_backend/apps/accounts/models/auth_key.py new file mode 100644 index 0000000..4c8b687 --- /dev/null +++ b/services/backend/hotpocket_backend/apps/accounts/models/auth_key.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from hotpocket_backend.apps.core.models import Model + + +class ActiveAuthKeysManager(models.Manager): + def get_queryset(self) -> models.QuerySet[AuthKey]: + return super().get_queryset().filter( + deleted_at__isnull=True, + ) + + +class AuthKey(Model): + key = models.CharField( + blank=False, + default=None, + null=False, + max_length=128, + db_index=True, + unique=True, + editable=False, + ) + consumed_at = models.DateTimeField( + blank=True, + null=True, + default=None, + db_index=True, + editable=False, + ) + + objects = models.Manager() + active_objects = ActiveAuthKeysManager() + + class Meta: + verbose_name = _('Auth Key') + verbose_name_plural = _('Auth Keys') + + def __str__(self) -> str: + return f'' diff --git a/services/backend/hotpocket_backend/apps/accounts/services/__init__.py b/services/backend/hotpocket_backend/apps/accounts/services/__init__.py index 951ad1d..3acd033 100644 --- a/services/backend/hotpocket_backend/apps/accounts/services/__init__.py +++ b/services/backend/hotpocket_backend/apps/accounts/services/__init__.py @@ -1 +1,3 @@ from .access_tokens import AccessTokensService # noqa: F401 +from .accounts import AccountsService # noqa: F401 +from .auth_keys import AuthKeysService # noqa: F401 diff --git a/services/backend/hotpocket_backend/apps/accounts/services/accounts.py b/services/backend/hotpocket_backend/apps/accounts/services/accounts.py new file mode 100644 index 0000000..b21ddf1 --- /dev/null +++ b/services/backend/hotpocket_backend/apps/accounts/services/accounts.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +import logging +import uuid + +from hotpocket_backend.apps.accounts.models import Account + +LOGGER = logging.getLogger(__name__) + + +class AccountsService: + class AccountsServiceError(Exception): + pass + + class AccountNotFound(AccountsServiceError): + pass + + def get(self, *, pk: uuid.UUID) -> Account: + try: + query_set = Account.objects.filter(is_active=True) + + return query_set.get(pk=pk) + except Account.DoesNotExist as exception: + raise self.AccountNotFound( + f'Account not found: pk=`{pk}`', + ) from exception diff --git a/services/backend/hotpocket_backend/apps/accounts/services/auth_keys.py b/services/backend/hotpocket_backend/apps/accounts/services/auth_keys.py new file mode 100644 index 0000000..17d79d8 --- /dev/null +++ b/services/backend/hotpocket_backend/apps/accounts/services/auth_keys.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +import datetime +import logging +import uuid + +from django.utils.timezone import now +import uuid6 + +from hotpocket_backend.apps.accounts.models import AuthKey +from hotpocket_backend.apps.core.conf import settings + +LOGGER = logging.getLogger(__name__) + + +class AuthKeysService: + class AuthKeysServiceError(Exception): + pass + + class AuthKeyNotFound(AuthKeysServiceError): + pass + + class AuthKeyExpired(AuthKeysServiceError): + pass + + class AuthKeyAccessDenied(AuthKeysServiceError): + pass + + def create(self, *, account_uuid: uuid.UUID) -> AuthKey: + key = str(uuid6.uuid7()) + + return AuthKey.objects.create( + account_uuid=account_uuid, + key=key, + ) + + def get(self, *, pk: uuid.UUID) -> AuthKey: + try: + query_set = AuthKey.active_objects + + return query_set.get(pk=pk) + except AuthKey.DoesNotExist as exception: + raise self.AuthKeyNotFound( + f'Auth Key not found: pk=`{pk}`', + ) from exception + + def get_by_key(self, *, key: str, ttl: int | None = None) -> AuthKey: + try: + query_set = AuthKey.active_objects + + result = query_set.get(key=key) + + if ttl is None: + ttl = settings.AUTH_KEY_TTL + + if ttl > 0: + if result.created_at < now() - datetime.timedelta(seconds=ttl): + raise self.AuthKeyExpired( + f'Auth Key expired: pk=`{key}`', + ) + + if result.consumed_at is not None: + raise self.AuthKeyExpired( + f'Auth Key already consumed: pk=`{key}`', + ) + + return result + except AuthKey.DoesNotExist as exception: + raise self.AuthKeyNotFound( + f'Auth Key not found: key=`{key}`', + ) from exception diff --git a/services/backend/hotpocket_backend/apps/core/types.py b/services/backend/hotpocket_backend/apps/core/types.py index 853dfed..db0750a 100644 --- a/services/backend/hotpocket_backend/apps/core/types.py +++ b/services/backend/hotpocket_backend/apps/core/types.py @@ -30,3 +30,5 @@ class PSettings(typing.Protocol): SAVES_ASSOCIATION_ADAPTER: str UPLOADS_PATH: pathlib.Path + + AUTH_KEY_TTL: int diff --git a/services/backend/hotpocket_backend/apps/saves/migrations/0008_alter_save_url.py b/services/backend/hotpocket_backend/apps/saves/migrations/0008_alter_save_url.py new file mode 100644 index 0000000..3978676 --- /dev/null +++ b/services/backend/hotpocket_backend/apps/saves/migrations/0008_alter_save_url.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.3 on 2025-10-01 05:35 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('saves', '0007_association_target_description_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='save', + name='url', + field=models.CharField(default=None, validators=[django.core.validators.URLValidator(schemes=['http', 'https'])]), + ), + ] diff --git a/services/backend/hotpocket_backend/apps/saves/models/save.py b/services/backend/hotpocket_backend/apps/saves/models/save.py index bf2f2fb..4b9d8e8 100644 --- a/services/backend/hotpocket_backend/apps/saves/models/save.py +++ b/services/backend/hotpocket_backend/apps/saves/models/save.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- from __future__ import annotations +from django.core import validators from django.db import models from django.utils.translation import gettext_lazy as _ @@ -20,6 +21,9 @@ class Save(Model): ) url = models.CharField( blank=False, null=False, default=None, + validators=[ + validators.URLValidator(schemes=['http', 'https']), + ], ) content = models.BinaryField( blank=True, null=True, default=None, editable=False, diff --git a/services/backend/hotpocket_backend/apps/ui/constants.py b/services/backend/hotpocket_backend/apps/ui/constants.py index b4994bb..be701c8 100644 --- a/services/backend/hotpocket_backend/apps/ui/constants.py +++ b/services/backend/hotpocket_backend/apps/ui/constants.py @@ -23,3 +23,11 @@ class UIAccessTokenOriginApp(enum.Enum): SAFARI_WEB_EXTENSION = _('Safari Web Extension') CHROME_EXTENSION = _('Chrome Extension') FIREFOX_EXTENSION = _('Firefox Extension') + HOTPOCKET_DESKTOP = _('HotPocket Desktop') + HOTPOCKET_MOBILE = _('HotPocket Mobile') + + +class AuthSource(enum.Enum): + BROWSER_EXTENSION = 'HotPocketExtension' + DESKTOP = 'HotPocketDesktop' + MOBILE = 'HotPocketMobile' diff --git a/services/backend/hotpocket_backend/apps/ui/rpc_methods/accounts/access_tokens.py b/services/backend/hotpocket_backend/apps/ui/rpc_methods/accounts/access_tokens.py index a54756c..ca57a8e 100644 --- a/services/backend/hotpocket_backend/apps/ui/rpc_methods/accounts/access_tokens.py +++ b/services/backend/hotpocket_backend/apps/ui/rpc_methods/accounts/access_tokens.py @@ -7,23 +7,37 @@ from bthlabs_jsonrpc_core import register_method from django import db from django.http import HttpRequest -from hotpocket_soa.services import AccessTokensService +from hotpocket_soa.services import ( + AccessTokensService, + AccountsService, + AuthKeysService, +) LOGGER = logging.getLogger(__name__) -@register_method('accounts.access_tokens.create') +@register_method('accounts.access_tokens.create', namespace='accounts') def create(request: HttpRequest, auth_key: str, meta: dict, ) -> str: with db.transaction.atomic(): try: - assert 'extension_auth_key' in request.session, 'Auth key missing' - assert request.session['extension_auth_key'] == auth_key, ( - 'Auth key mismatch' + auth_key_object = AuthKeysService().get_by_key( + account_uuid=None, + key=auth_key, ) - except AssertionError as exception: + except AuthKeysService.AuthKeyNotFound as exception: + LOGGER.error( + 'Unable to issue access token: %s', + exception, + exc_info=exception, + ) + raise + + try: + account = AccountsService().get(pk=auth_key_object.account_uuid) + except AccountsService.AccountNotFound as exception: LOGGER.error( 'Unable to issue access token: %s', exception, @@ -32,12 +46,9 @@ def create(request: HttpRequest, raise access_token = AccessTokensService().create( - account_uuid=request.user.pk, + account_uuid=account.pk, origin=request.META['HTTP_ORIGIN'], meta=meta, ) - request.session.pop('extension_auth_key') - request.session.save() - return access_token.key diff --git a/services/backend/hotpocket_backend/apps/ui/rpc_methods/accounts/auth.py b/services/backend/hotpocket_backend/apps/ui/rpc_methods/accounts/auth.py index 6afa2af..d9ba926 100644 --- a/services/backend/hotpocket_backend/apps/ui/rpc_methods/accounts/auth.py +++ b/services/backend/hotpocket_backend/apps/ui/rpc_methods/accounts/auth.py @@ -13,16 +13,18 @@ from hotpocket_soa.services import AccessTokensService LOGGER = logging.getLogger(__name__) -@register_method('accounts.auth.check') +@register_method('accounts.auth.check', namespace='accounts') def check(request: HttpRequest) -> bool: return request.user.is_anonymous is False -@register_method('accounts.auth.check_access_token') +@register_method('accounts.auth.check_access_token', namespace='accounts') def check_access_token(request: HttpRequest, access_token: str, meta: dict | None = None, ) -> bool: + assert request.user.is_anonymous is False, 'Not authenticated' + result = True try: diff --git a/services/backend/hotpocket_backend/apps/ui/templates/ui/integrations/extension/post_authenticate.html b/services/backend/hotpocket_backend/apps/ui/templates/ui/integrations/extension/post_authenticate.html index c93daa8..4af1273 100644 --- a/services/backend/hotpocket_backend/apps/ui/templates/ui/integrations/extension/post_authenticate.html +++ b/services/backend/hotpocket_backend/apps/ui/templates/ui/integrations/extension/post_authenticate.html @@ -11,8 +11,27 @@ {% endblock %} + +{% block page_scripts %} +{% if app_redirect_url %} + +{% endif %} +{% endblock %} diff --git a/services/backend/hotpocket_backend/apps/ui/templatetags/ui.py b/services/backend/hotpocket_backend/apps/ui/templatetags/ui.py index 6d52976..bf4086a 100644 --- a/services/backend/hotpocket_backend/apps/ui/templatetags/ui.py +++ b/services/backend/hotpocket_backend/apps/ui/templatetags/ui.py @@ -137,6 +137,8 @@ def render_access_token_app(access_token: AccessTokenOut) -> str: AccessTokenOriginApp.SAFARI_WEB_EXTENSION, AccessTokenOriginApp.CHROME_EXTENSION, AccessTokenOriginApp.FIREFOX_EXTENSION, + AccessTokenOriginApp.HOTPOCKET_DESKTOP, + AccessTokenOriginApp.HOTPOCKET_MOBILE, ) if origin_app in extension_origin_apps: app = UIAccessTokenOriginApp[origin_app.value].value @@ -152,7 +154,7 @@ def render_access_token_app(access_token: AccessTokenOut) -> str: @register.filter(name='render_access_token_platform') def render_access_token_platform(access_token: AccessTokenOut) -> str: match access_token.meta.get('platform', None): - case 'MacIntel': + case 'MacIntel' | 'macOS': return 'macOS' case 'iPhone': diff --git a/services/backend/hotpocket_backend/apps/ui/urls.py b/services/backend/hotpocket_backend/apps/ui/urls.py index 49dadc4..ce420f7 100644 --- a/services/backend/hotpocket_backend/apps/ui/urls.py +++ b/services/backend/hotpocket_backend/apps/ui/urls.py @@ -59,6 +59,13 @@ urlpatterns = [ accounts.apps.DeleteView.as_view(), name='ui.accounts.apps.delete', ), + path( + 'accounts/rpc/', + JSONRPCView.as_view( + namespace='accounts', + ), + name='ui.accounts.rpc', + ), path('accounts/', accounts.index.index, name='ui.accounts.index'), path( 'imports/pocket/', diff --git a/services/backend/hotpocket_backend/apps/ui/views/integrations/extension.py b/services/backend/hotpocket_backend/apps/ui/views/integrations/extension.py index 8459410..92bc9a1 100644 --- a/services/backend/hotpocket_backend/apps/ui/views/integrations/extension.py +++ b/services/backend/hotpocket_backend/apps/ui/views/integrations/extension.py @@ -2,27 +2,56 @@ from __future__ import annotations import logging +import urllib.parse import uuid +from django import db from django.core.exceptions import PermissionDenied from django.http import HttpRequest, HttpResponse from django.shortcuts import redirect, render from django.urls import reverse +from hotpocket_backend.apps.ui.constants import AuthSource +from hotpocket_soa.services import AuthKeysService + LOGGER = logging.getLogger(__name__) +SOURCE_TO_REDIRECT_SCHEME = { + AuthSource.DESKTOP.value: 'hotpocket-desktop', + AuthSource.MOBILE.value: 'hotpocket-mobile', +} + def authenticate(request: HttpRequest) -> HttpResponse: - if request.user.is_anonymous is False: - auth_key = str(uuid.uuid4()) + source = request.GET.get( + 'source', + request.session.get('extension_source', AuthSource.BROWSER_EXTENSION.value), + ) + session_token = request.GET.get( + 'session_token', request.session.get('extension_session_token', None), + ) - request.session['extension_auth_key'] = auth_key - request.session.save() + if source == AuthSource.BROWSER_EXTENSION.value: + session_token = str(uuid.uuid4()) + elif source in (AuthSource.DESKTOP.value, AuthSource.MOBILE.value): + assert session_token not in ('', None), 'Session token missing' + else: + raise ValueError(f'Unknown source: `{source}`') + + request.session['extension_source'] = source + request.session['extension_session_token'] = session_token + request.session.save() + + if request.user.is_anonymous is False: + with db.transaction.atomic(): + auth_key = AuthKeysService().create( + account_uuid=request.user.pk, + ) return redirect(reverse( 'ui.integrations.extension.post_authenticate', query=[ - ('auth_key', auth_key), + ('auth_key', auth_key.key), ], )) @@ -36,12 +65,35 @@ def post_authenticate(request: HttpRequest) -> HttpResponse: assert request.user.is_anonymous is False, 'Not authenticated' auth_key = request.GET.get('auth_key', None) - assert request.session.get('extension_auth_key', None) == auth_key, ( - 'Auth key mismatch' - ) + assert auth_key is not None, 'Auth key missing' + source = request.session.get('extension_source', None) + assert source is not None, 'Source is missing' + session_token = request.session.get('extension_session_token', None) + assert session_token is not None, 'Session token is missing' + + app_redirect_url = None + if source in (AuthSource.DESKTOP.value, AuthSource.MOBILE.value): + app_redirect_url = urllib.parse.urlunsplit(( + SOURCE_TO_REDIRECT_SCHEME[source], + 'post-authenticate', + '/', + urllib.parse.urlencode([ + ('session_token', session_token), + ('auth_key', auth_key), + ]), + '', + )) + + request.session.pop('extension_source') + request.session.pop('extension_session_token') + request.session.save() return render( - request, 'ui/integrations/extension/post_authenticate.html', + request, + 'ui/integrations/extension/post_authenticate.html', + { + 'app_redirect_url': app_redirect_url, + }, ) except AssertionError as exception: LOGGER.error( diff --git a/services/backend/hotpocket_backend/settings/webapp.py b/services/backend/hotpocket_backend/settings/webapp.py index 46e48bf..8da0e0f 100644 --- a/services/backend/hotpocket_backend/settings/webapp.py +++ b/services/backend/hotpocket_backend/settings/webapp.py @@ -79,3 +79,5 @@ CORS_ALLOW_HEADERS = ( *default_headers, 'cookie', ) + +AUTH_KEY_TTL = 30 diff --git a/services/backend/testing/hotpocket_backend_testing/factories/accounts/__init__.py b/services/backend/testing/hotpocket_backend_testing/factories/accounts/__init__.py index 5035624..4fe2113 100644 --- a/services/backend/testing/hotpocket_backend_testing/factories/accounts/__init__.py +++ b/services/backend/testing/hotpocket_backend_testing/factories/accounts/__init__.py @@ -1,2 +1,3 @@ from .access_token import AccessTokenFactory # noqa: F401,F403 from .account import AccountFactory # noqa: F401,F403 +from .auth_key import AuthKeyFactory # noqa: F401,F403 diff --git a/services/backend/testing/hotpocket_backend_testing/factories/accounts/auth_key.py b/services/backend/testing/hotpocket_backend_testing/factories/accounts/auth_key.py new file mode 100644 index 0000000..84ecf41 --- /dev/null +++ b/services/backend/testing/hotpocket_backend_testing/factories/accounts/auth_key.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +import uuid + +import factory + +from hotpocket_backend.apps.accounts.models import AuthKey + + +class AuthKeyFactory(factory.django.DjangoModelFactory): + account_uuid = None + key = factory.LazyFunction(lambda: str(uuid.uuid4())) + consumed_at = None + + class Meta: + model = AuthKey diff --git a/services/backend/testing/hotpocket_backend_testing/fixtures/accounts/__init__.py b/services/backend/testing/hotpocket_backend_testing/fixtures/accounts/__init__.py index 0b40649..ffb9872 100644 --- a/services/backend/testing/hotpocket_backend_testing/fixtures/accounts/__init__.py +++ b/services/backend/testing/hotpocket_backend_testing/fixtures/accounts/__init__.py @@ -1,3 +1,4 @@ from .access_token import * # noqa: F401,F403 from .account import * # noqa: F401,F403 from .apps import * # noqa: F401,F403 +from .auth_key import * # noqa: F401,F403 diff --git a/services/backend/testing/hotpocket_backend_testing/fixtures/accounts/auth_key.py b/services/backend/testing/hotpocket_backend_testing/fixtures/accounts/auth_key.py new file mode 100644 index 0000000..29ccc3e --- /dev/null +++ b/services/backend/testing/hotpocket_backend_testing/fixtures/accounts/auth_key.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +# type: ignore +from __future__ import annotations + +import datetime + +from django.utils.timezone import get_current_timezone, now +import pytest + +from hotpocket_soa.dto.accounts import AuthKeyOut + + +@pytest.fixture +def auth_key_factory(request: pytest.FixtureRequest): + default_account = request.getfixturevalue('account') + + def factory(account=None, **kwargs): + from hotpocket_backend_testing.factories.accounts import AuthKeyFactory + + return AuthKeyFactory( + account_uuid=( + account.pk + if account is not None + else default_account.pk + ), + **kwargs, + ) + + return factory + + +@pytest.fixture +def auth_key(auth_key_factory): + return auth_key_factory() + + +@pytest.fixture +def auth_key_out(auth_key): + return AuthKeyOut.model_validate(auth_key, from_attributes=True) + + +@pytest.fixture +def deleted_auth_key(auth_key_factory): + return auth_key_factory(deleted_at=now()) + + +@pytest.fixture +def deleted_auth_key_out(deleted_auth_key): + return AuthKeyOut.model_validate(deleted_auth_key, from_attributes=True) + + +@pytest.fixture +def expired_auth_key(auth_key_factory): + result = auth_key_factory() + result.created_at = datetime.datetime( + 1987, 10, 3, 8, 0, 0, tzinfo=get_current_timezone(), + ) + result.save() + + return result + + +@pytest.fixture +def expired_auth_key_out(expired_auth_key): + return AuthKeyOut.model_validate(expired_auth_key, from_attributes=True) + + +@pytest.fixture +def consumed_auth_key(auth_key_factory): + return auth_key_factory(consumed_at=now()) + + +@pytest.fixture +def consumed_auth_key_out(consumed_auth_key): + return AuthKeyOut.model_validate(consumed_auth_key, from_attributes=True) + + +@pytest.fixture +def other_auth_key(auth_key_factory): + return auth_key_factory() + + +@pytest.fixture +def other_auth_key_out(other_auth_key): + return AuthKeyOut.model_validate(other_auth_key, from_attributes=True) + + +@pytest.fixture +def inactive_account_auth_key(auth_key_factory, inactive_account): + return auth_key_factory(account=inactive_account) + + +@pytest.fixture +def inactive_account_auth_key_out(auth_key): + return AuthKeyOut.model_validate( + inactive_account_auth_key, from_attributes=True, + ) + + +@pytest.fixture +def other_account_auth_key(auth_key_factory, other_account): + return auth_key_factory(account=other_account) + + +@pytest.fixture +def other_account_auth_key_out(other_account_auth_key): + return AuthKeyOut.model_validate( + other_account_auth_key, from_attributes=True, + ) diff --git a/services/backend/testing/hotpocket_backend_testing/fixtures/ui.py b/services/backend/testing/hotpocket_backend_testing/fixtures/ui.py index e8fd847..efacf1d 100644 --- a/services/backend/testing/hotpocket_backend_testing/fixtures/ui.py +++ b/services/backend/testing/hotpocket_backend_testing/fixtures/ui.py @@ -5,6 +5,7 @@ from __future__ import annotations import csv import datetime import io +import uuid import pytest @@ -86,3 +87,23 @@ def pocket_csv_content(pocket_import_created_save_spec, csv_f.seek(0) yield csv_f.getvalue() + + +@pytest.fixture +def extension_auth_source_extension(): + return 'HotPocketExtension' + + +@pytest.fixture +def extension_auth_source_desktop(): + return 'HotPocketDesktop' + + +@pytest.fixture +def extension_auth_source_mobile(): + return 'HotPocketMobile' + + +@pytest.fixture +def extension_auth_session_token(): + return str(uuid.uuid4()) diff --git a/services/backend/testing/hotpocket_backend_testing/services/accounts/__init__.py b/services/backend/testing/hotpocket_backend_testing/services/accounts/__init__.py index c62ec63..f272854 100644 --- a/services/backend/testing/hotpocket_backend_testing/services/accounts/__init__.py +++ b/services/backend/testing/hotpocket_backend_testing/services/accounts/__init__.py @@ -1,2 +1,3 @@ from .access_tokens import AccessTokensTestingService # noqa: F401 from .accounts import AccountsTestingService # noqa: F401 +from .auth_key import AuthKeysTestingService # noqa: F401 diff --git a/services/backend/testing/hotpocket_backend_testing/services/accounts/auth_key.py b/services/backend/testing/hotpocket_backend_testing/services/accounts/auth_key.py new file mode 100644 index 0000000..3f7dc25 --- /dev/null +++ b/services/backend/testing/hotpocket_backend_testing/services/accounts/auth_key.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +import uuid + +from hotpocket_backend.apps.accounts.models import AuthKey + + +class AuthKeysTestingService: + def assert_created(self, + *, + key: str, + account_uuid: uuid.UUID, + ): + auth_key = AuthKey.objects.get(key=key) + assert auth_key.account_uuid == account_uuid + + assert auth_key.created_at is not None + assert auth_key.updated_at is not None diff --git a/services/backend/tests/ui/views/integrations/extension/test_authenticate.py b/services/backend/tests/ui/views/integrations/extension/test_authenticate.py index 2641670..706a09a 100644 --- a/services/backend/tests/ui/views/integrations/extension/test_authenticate.py +++ b/services/backend/tests/ui/views/integrations/extension/test_authenticate.py @@ -9,11 +9,15 @@ from django.urls import reverse import pytest from pytest_django import asserts +from hotpocket_backend_testing.services.accounts import AuthKeysTestingService from hotpocket_common.url import URL @pytest.mark.django_db -def test_ok(authenticated_client: Client): +def test_ok(authenticated_client: Client, + extension_auth_source_extension, + account, + ): # When result = authenticated_client.get( reverse('ui.integrations.extension.authenticate'), @@ -28,8 +32,118 @@ def test_ok(authenticated_client: Client): assert redirect_url.raw_path == reverse('ui.integrations.extension.post_authenticate') assert 'auth_key' in redirect_url.query - assert 'extension_auth_key' in authenticated_client.session - assert authenticated_client.session['extension_auth_key'] == redirect_url.query['auth_key'][0] + assert 'extension_source' in authenticated_client.session + assert authenticated_client.session['extension_source'] == extension_auth_source_extension + + assert 'extension_session_token' in authenticated_client.session + + AuthKeysTestingService().assert_created( + key=redirect_url.query['auth_key'][0], + account_uuid=account.pk, + ) + + +@pytest.mark.parametrize( + 'source_fixture_name', + ['extension_auth_source_desktop', 'extension_auth_source_mobile'], +) +@pytest.mark.django_db +def test_ok_with_source(source_fixture_name, + request: pytest.FixtureRequest, + authenticated_client: Client, + extension_auth_session_token, + ): + # Given + source = request.getfixturevalue(source_fixture_name) + + # When + result = authenticated_client.get( + reverse( + 'ui.integrations.extension.authenticate', + query=[ + ('source', source), + ('session_token', extension_auth_session_token), + ], + ), + follow=False, + ) + + # Then + assert result.status_code == http.HTTPStatus.FOUND + assert 'Location' in result.headers + + redirect_url = URL(result.headers['Location']) + assert redirect_url.raw_path == reverse('ui.integrations.extension.post_authenticate') + assert 'auth_key' in redirect_url.query + + assert 'extension_source' in authenticated_client.session + assert authenticated_client.session['extension_source'] == source + + assert 'extension_session_token' in authenticated_client.session + assert authenticated_client.session['extension_session_token'] == extension_auth_session_token + + +@pytest.mark.django_db +def test_source_without_session_token(authenticated_client: Client, + extension_auth_source_desktop, + ): + # Given + with pytest.raises(AssertionError) as exception_info: + # When + _ = authenticated_client.get( + reverse( + 'ui.integrations.extension.authenticate', + query=[ + ('source', extension_auth_source_desktop), + ], + ), + follow=False, + ) + + # Then + assert exception_info.value.args[0] == 'Session token missing' + + +@pytest.mark.django_db +def test_source_without_empty_session_token(authenticated_client: Client, + extension_auth_source_desktop, + ): + # Given + with pytest.raises(AssertionError) as exception_info: + # When + _ = authenticated_client.get( + reverse( + 'ui.integrations.extension.authenticate', + query=[ + ('source', extension_auth_source_desktop), + ('session_token', ''), + ], + ), + follow=False, + ) + + # Then + assert exception_info.value.args[0] == 'Session token missing' + + +@pytest.mark.django_db +def test_unknown_source(authenticated_client: Client, extension_auth_session_token): + # Given + with pytest.raises(ValueError) as exception_info: + # When + _ = authenticated_client.get( + reverse( + 'ui.integrations.extension.authenticate', + query=[ + ('source', 'thisisntright'), + ('session_token', extension_auth_session_token), + ], + ), + follow=False, + ) + + # Then + assert exception_info.value.args[0] == 'Unknown source: `thisisntright`' @pytest.mark.django_db diff --git a/services/backend/tests/ui/views/integrations/extension/test_post_authenticate.py b/services/backend/tests/ui/views/integrations/extension/test_post_authenticate.py index 093119c..75455fc 100644 --- a/services/backend/tests/ui/views/integrations/extension/test_post_authenticate.py +++ b/services/backend/tests/ui/views/integrations/extension/test_post_authenticate.py @@ -3,6 +3,7 @@ from __future__ import annotations import http +import urllib.parse import uuid from django.test import Client @@ -17,10 +18,15 @@ def auth_key(): @pytest.mark.django_db -def test_ok(authenticated_client: Client, auth_key): +def test_ok(authenticated_client: Client, + auth_key, + extension_auth_source_extension, + extension_auth_session_token, + ): # Given session = authenticated_client.session - session['extension_auth_key'] = auth_key + session['extension_source'] = extension_auth_source_extension + session['extension_session_token'] = extension_auth_session_token session.save() # When @@ -34,13 +40,95 @@ def test_ok(authenticated_client: Client, auth_key): # Then assert result.status_code == http.HTTPStatus.OK + assert 'extension_source' not in authenticated_client.session + assert 'extension_session_token' not in authenticated_client.session + asserts.assertTemplateUsed( result, 'ui/integrations/extension/post_authenticate.html', ) + assert result.context[0]['app_redirect_url'] is None + + +@pytest.mark.parametrize( + 'source_fixture_name,expected_app_redirect_url_scheme', + [ + ('extension_auth_source_desktop', 'hotpocket-desktop'), + ('extension_auth_source_mobile', 'hotpocket-mobile'), + ], +) +@pytest.mark.django_db +def test_ok_with_source(source_fixture_name, + expected_app_redirect_url_scheme, + request: pytest.FixtureRequest, + authenticated_client: Client, + auth_key, + extension_auth_session_token, + ): + # Given + source = request.getfixturevalue(source_fixture_name) + + session = authenticated_client.session + session['extension_source'] = source + session['extension_session_token'] = extension_auth_session_token + session.save() + + # When + result = authenticated_client.get( + reverse('ui.integrations.extension.post_authenticate'), + data={ + 'auth_key': auth_key, + }, + ) + + # Then + assert result.status_code == http.HTTPStatus.OK + assert result.context[0]['app_redirect_url'] is not None + + app_redirect_url = result.context[0]['app_redirect_url'] + + parsed_app_redirect_url = urllib.parse.urlsplit(app_redirect_url) + assert parsed_app_redirect_url.scheme == expected_app_redirect_url_scheme + assert parsed_app_redirect_url.netloc == 'post-authenticate' + assert parsed_app_redirect_url.path == '/' + + parsed_app_redirect_url_query = urllib.parse.parse_qs(parsed_app_redirect_url.query) + assert parsed_app_redirect_url_query['session_token'] == [extension_auth_session_token] + assert parsed_app_redirect_url_query['auth_key'] == [auth_key] + @pytest.mark.django_db -def test_auth_key_not_in_session(authenticated_client: Client, auth_key): +def test_auth_key_not_request(authenticated_client: Client, + extension_auth_source_extension, + extension_auth_session_token, + ): + # Given + session = authenticated_client.session + session['extension_source'] = extension_auth_source_extension + session['extension_session_token'] = extension_auth_session_token + session.save() + + # When + result = authenticated_client.get( + reverse('ui.integrations.extension.post_authenticate'), + data={ + }, + ) + + # Then + assert result.status_code == http.HTTPStatus.FORBIDDEN + + +@pytest.mark.django_db +def test_source_not_in_session(authenticated_client: Client, + extension_auth_session_token, + auth_key, + ): + # Given + session = authenticated_client.session + session['extension_session_token'] = extension_auth_session_token + session.save() + # When result = authenticated_client.get( reverse('ui.integrations.extension.post_authenticate'), @@ -54,16 +142,20 @@ def test_auth_key_not_in_session(authenticated_client: Client, auth_key): @pytest.mark.django_db -def test_auth_key_not_request(authenticated_client: Client, auth_key): +def test_session_token_in_session(authenticated_client: Client, + extension_auth_source_extension, + auth_key, + ): # Given session = authenticated_client.session - session['extension_auth_key'] = auth_key + session['extension_source'] = extension_auth_source_extension session.save() # When result = authenticated_client.get( reverse('ui.integrations.extension.post_authenticate'), data={ + 'auth_key': auth_key, }, ) diff --git a/services/backend/tests/ui/views/rpc/accounts/access_tokens/test_create.py b/services/backend/tests/ui/views/rpc/accounts/access_tokens/test_create.py index 06a4e78..793c9f3 100644 --- a/services/backend/tests/ui/views/rpc/accounts/access_tokens/test_create.py +++ b/services/backend/tests/ui/views/rpc/accounts/access_tokens/test_create.py @@ -3,7 +3,6 @@ from __future__ import annotations import http -import uuid from django.test import Client from django.urls import reverse @@ -15,34 +14,23 @@ from hotpocket_backend_testing.services.accounts import ( @pytest.fixture -def auth_key(): - return str(uuid.uuid4()) - - -@pytest.fixture -def call(rpc_call_factory, auth_key, safari_extension_meta): +def call(rpc_call_factory, auth_key_out, safari_extension_meta): return rpc_call_factory( 'accounts.access_tokens.create', - [auth_key, safari_extension_meta], + [auth_key_out.key, safari_extension_meta], ) @pytest.mark.django_db -def test_ok(authenticated_client: Client, - auth_key, +def test_ok(client: Client, call, safari_extension_origin, account, safari_extension_meta, ): - # Given - session = authenticated_client.session - session['extension_auth_key'] = auth_key - session.save() - # When - result = authenticated_client.post( - reverse('ui.rpc'), + result = client.post( + reverse('ui.accounts.rpc'), data=call, content_type='application/json', headers={ @@ -63,17 +51,20 @@ def test_ok(authenticated_client: Client, meta=safari_extension_meta, ) - assert 'extension_auth_key' not in authenticated_client.session - @pytest.mark.django_db -def test_auth_key_missing(authenticated_client: Client, - call, - safari_extension_origin, - ): +def test_auth_key_not_found(null_uuid, + call, + client: Client, + safari_extension_origin, + ): + # Given + call_auth_key = str(null_uuid) + call['params'][0] = call_auth_key + # When - result = authenticated_client.post( - reverse('ui.rpc'), + result = client.post( + reverse('ui.accounts.rpc'), data=call, content_type='application/json', headers={ @@ -86,22 +77,87 @@ def test_auth_key_missing(authenticated_client: Client, call_result = result.json() assert 'error' in call_result - assert call_result['error']['data'] == 'Auth key missing' + assert call_result['error']['data'].startswith( + 'Auth Key not found', + ) + assert call_auth_key in call_result['error']['data'] @pytest.mark.django_db -def test_auth_key_mismatch(authenticated_client: Client, +def test_deleted_auth_key(deleted_auth_key_out, + call, + client: Client, + safari_extension_origin, + ): + # Given + call_auth_key = deleted_auth_key_out.key + call['params'][0] = call_auth_key + + # When + result = client.post( + reverse('ui.accounts.rpc'), + data=call, + content_type='application/json', + headers={ + 'Origin': safari_extension_origin, + }, + ) + + # Then + assert result.status_code == http.HTTPStatus.OK + + call_result = result.json() + assert 'error' in call_result + assert call_result['error']['data'].startswith( + 'Auth Key not found', + ) + assert call_auth_key in call_result['error']['data'] + + +@pytest.mark.django_db +def test_expired_auth_key(expired_auth_key_out, + call, + client: Client, + safari_extension_origin, + ): + # Given + call_auth_key = expired_auth_key_out.key + call['params'][0] = call_auth_key + + # When + result = client.post( + reverse('ui.accounts.rpc'), + data=call, + content_type='application/json', + headers={ + 'Origin': safari_extension_origin, + }, + ) + + # Then + assert result.status_code == http.HTTPStatus.OK + + call_result = result.json() + assert 'error' in call_result + assert call_result['error']['data'].startswith( + 'Auth Key expired', + ) + assert call_auth_key in call_result['error']['data'] + + +@pytest.mark.django_db +def test_consumed_auth_key(consumed_auth_key, call, + client: Client, safari_extension_origin, ): # Given - session = authenticated_client.session - session['extension_auth_key'] = 'thisisntright' - session.save() + call_auth_key = consumed_auth_key.key + call['params'][0] = call_auth_key # When - result = authenticated_client.post( - reverse('ui.rpc'), + result = client.post( + reverse('ui.accounts.rpc'), data=call, content_type='application/json', headers={ @@ -114,28 +170,35 @@ def test_auth_key_mismatch(authenticated_client: Client, call_result = result.json() assert 'error' in call_result - assert call_result['error']['data'] == 'Auth key mismatch' - - -@pytest.mark.django_db -def test_inactive_account(inactive_account_client: Client, call): - # When - result = inactive_account_client.post( - reverse('ui.rpc'), - data=call, + assert call_result['error']['data'].startswith( + 'Auth Key already consumed', ) - - # Then - assert result.status_code == http.HTTPStatus.FORBIDDEN + assert call_auth_key in call_result['error']['data'] @pytest.mark.django_db -def test_anonymous(client: Client, call): +def test_inactive_account(inactive_account_auth_key, + call, + client: Client, + safari_extension_origin, + inactive_account, + ): + # Given + call['params'][0] = inactive_account_auth_key.key + # When result = client.post( - reverse('ui.rpc'), + reverse('ui.accounts.rpc'), data=call, + content_type='application/json', + headers={ + 'Origin': safari_extension_origin, + }, ) # Then - assert result.status_code == http.HTTPStatus.FORBIDDEN + assert result.status_code == http.HTTPStatus.OK + + call_result = result.json() + assert 'error' in call_result + assert str(inactive_account.pk) in call_result['error']['data'] diff --git a/services/backend/tests/ui/views/rpc/accounts/auth/test_check.py b/services/backend/tests/ui/views/rpc/accounts/auth/test_check.py index 59baca7..f502b21 100644 --- a/services/backend/tests/ui/views/rpc/accounts/auth/test_check.py +++ b/services/backend/tests/ui/views/rpc/accounts/auth/test_check.py @@ -23,7 +23,7 @@ def test_ok_session_auth(authenticated_client: Client, ): # When result = authenticated_client.post( - reverse('ui.rpc'), + reverse('ui.accounts.rpc'), data=call, content_type='application/json', ) @@ -42,12 +42,17 @@ def test_session_auth_inactive_account(inactive_account_client: Client, ): # When result = inactive_account_client.post( - reverse('ui.rpc'), + reverse('ui.accounts.rpc'), data=call, + content_type='application/json', ) # Then - assert result.status_code == http.HTTPStatus.FORBIDDEN + assert result.status_code == http.HTTPStatus.OK + + call_result = result.json() + assert 'error' not in call_result + assert call_result['result'] is False @pytest.mark.django_db @@ -57,7 +62,7 @@ def test_ok_access_token_auth(client: Client, ): # When result = client.post( - reverse('ui.rpc'), + reverse('ui.accounts.rpc'), data=call, content_type='application/json', headers={ @@ -80,15 +85,20 @@ def test_access_token_auth_not_bearer(client: Client, ): # When result = client.post( - reverse('ui.rpc'), + reverse('ui.accounts.rpc'), data=call, + content_type='application/json', headers={ 'Authorization': f'thisisntright {access_token_out.key}', }, ) # Then - assert result.status_code == http.HTTPStatus.FORBIDDEN + assert result.status_code == http.HTTPStatus.OK + + call_result = result.json() + assert 'error' not in call_result + assert call_result['result'] is False @pytest.mark.django_db @@ -98,15 +108,20 @@ def test_access_token_auth_invalid_access_token(client: Client, ): # When result = client.post( - reverse('ui.rpc'), + reverse('ui.accounts.rpc'), data=call, + content_type='application/json', headers={ 'Authorization': f'Bearer {null_uuid}', }, ) # Then - assert result.status_code == http.HTTPStatus.FORBIDDEN + assert result.status_code == http.HTTPStatus.OK + + call_result = result.json() + assert 'error' not in call_result + assert call_result['result'] is False @pytest.mark.django_db @@ -116,15 +131,20 @@ def test_access_token_auth_deleted_access_token(client: Client, ): # When result = client.post( - reverse('ui.rpc'), + reverse('ui.accounts.rpc'), data=call, + content_type='application/json', headers={ 'Authorization': f'Bearer {deleted_access_token.key}', }, ) # Then - assert result.status_code == http.HTTPStatus.FORBIDDEN + assert result.status_code == http.HTTPStatus.OK + + call_result = result.json() + assert 'error' not in call_result + assert call_result['result'] is False @pytest.mark.django_db @@ -134,24 +154,34 @@ def test_access_token_auth_inactive_account(client: Client, ): # When result = client.post( - reverse('ui.rpc'), + reverse('ui.accounts.rpc'), data=call, + content_type='application/json', headers={ 'Authorization': f'Bearer {inactive_account_access_token.key}', }, ) # Then - assert result.status_code == http.HTTPStatus.FORBIDDEN + assert result.status_code == http.HTTPStatus.OK + + call_result = result.json() + assert 'error' not in call_result + assert call_result['result'] is False @pytest.mark.django_db def test_anonymous(client: Client, call): # When result = client.post( - reverse('ui.rpc'), + reverse('ui.accounts.rpc'), data=call, + content_type='application/json', ) # Then - assert result.status_code == http.HTTPStatus.FORBIDDEN + assert result.status_code == http.HTTPStatus.OK + + call_result = result.json() + assert 'error' not in call_result + assert call_result['result'] is False diff --git a/services/backend/tests/ui/views/rpc/accounts/auth/test_check_access_token.py b/services/backend/tests/ui/views/rpc/accounts/auth/test_check_access_token.py index 400e7ca..354d9e1 100644 --- a/services/backend/tests/ui/views/rpc/accounts/auth/test_check_access_token.py +++ b/services/backend/tests/ui/views/rpc/accounts/auth/test_check_access_token.py @@ -51,7 +51,7 @@ def test_ok(authenticated_client: Client, ): # When result = authenticated_client.post( - reverse('ui.rpc'), + reverse('ui.accounts.rpc'), data=call, content_type='application/json', ) @@ -94,7 +94,7 @@ def test_ok_with_partial_meta_update(meta_keys_to_pop, # When result = authenticated_client.post( - reverse('ui.rpc'), + reverse('ui.accounts.rpc'), data=call, content_type='application/json', ) @@ -122,7 +122,7 @@ def test_invalid_access_token(authenticated_client: Client, # When result = authenticated_client.post( - reverse('ui.rpc'), + reverse('ui.accounts.rpc'), data=call, content_type='application/json', ) @@ -145,7 +145,7 @@ def test_deleted_access_token(call_factory, # When result = authenticated_client.post( - reverse('ui.rpc'), + reverse('ui.accounts.rpc'), data=call, content_type='application/json', ) @@ -168,7 +168,7 @@ def test_other_account_access_token(call_factory, # When result = authenticated_client.post( - reverse('ui.rpc'), + reverse('ui.accounts.rpc'), data=call, content_type='application/json', ) @@ -185,21 +185,31 @@ def test_other_account_access_token(call_factory, def test_inactive_account(inactive_account_client: Client, call): # When result = inactive_account_client.post( - reverse('ui.rpc'), + reverse('ui.accounts.rpc'), data=call, + content_type='application/json', ) # Then - assert result.status_code == http.HTTPStatus.FORBIDDEN + assert result.status_code == http.HTTPStatus.OK + + call_result = result.json() + assert 'error' in call_result + assert call_result['error']['data'] == 'Not authenticated' @pytest.mark.django_db def test_anonymous(client: Client, call): # When result = client.post( - reverse('ui.rpc'), + reverse('ui.accounts.rpc'), data=call, + content_type='application/json', ) # Then - assert result.status_code == http.HTTPStatus.FORBIDDEN + assert result.status_code == http.HTTPStatus.OK + + call_result = result.json() + assert 'error' in call_result + assert call_result['error']['data'] == 'Not authenticated' diff --git a/services/backend/tests/ui/views/rpc/saves/test_create.py b/services/backend/tests/ui/views/rpc/saves/test_create.py index 287edc3..f9d293d 100644 --- a/services/backend/tests/ui/views/rpc/saves/test_create.py +++ b/services/backend/tests/ui/views/rpc/saves/test_create.py @@ -110,12 +110,12 @@ def test_ok_netloc_banned(authenticated_client: Client, @pytest.mark.django_db -def test_ok_resuse_save(save_out, - authenticated_client: Client, - call, - account, - mock_saves_process_save_task_apply_async: mock.Mock, - ): +def test_ok_reuse_save(save_out, + authenticated_client: Client, + call, + account, + mock_saves_process_save_task_apply_async: mock.Mock, + ): # Given call['params'][0] = save_out.url @@ -148,13 +148,13 @@ def test_ok_resuse_save(save_out, @pytest.mark.django_db -def test_ok_resuse_association(association_out, - save_out, - authenticated_client: Client, - call, - account, - mock_saves_process_save_task_apply_async: mock.Mock, - ): +def test_ok_reuse_association(association_out, + save_out, + authenticated_client: Client, + call, + account, + mock_saves_process_save_task_apply_async: mock.Mock, + ): # Given call['params'][0] = save_out.url @@ -263,6 +263,31 @@ def test_empty_url(authenticated_client: Client, assert call_result['error']['data']['url'] == ['blank'] +@pytest.mark.django_db +def test_invalid_url(authenticated_client: Client, + call, + account, + mock_saves_process_save_task_apply_async: mock.Mock, + ): + # Given + call['params'][0] = 'thisisntright' + + # When + result = authenticated_client.post( + reverse('ui.rpc'), + data=call, + content_type='application/json', + ) + + # Then + assert result.status_code == http.HTTPStatus.OK + + call_result = result.json() + assert 'error' in call_result + + assert call_result['error']['data']['url'] == ['invalid'] + + @pytest.mark.django_db def test_inactive_account(inactive_account_client: Client, call): # When diff --git a/services/extension/rollup.config.js b/services/extension/rollup.config.js index 628cddd..b0ad095 100644 --- a/services/extension/rollup.config.js +++ b/services/extension/rollup.config.js @@ -82,6 +82,7 @@ const manifestJsonOutputPlugin = () => { if (IS_PRODUCTION === false) { result.name = '__MSG_extension_name_development__'; + result.action.default_title = '__MSG_extension_name_development__'; } return JSON.stringify(result, null, 2); diff --git a/services/extension/src/background/main.js b/services/extension/src/background/main.js index e27ccb6..2820064 100644 --- a/services/extension/src/background/main.js +++ b/services/extension/src/background/main.js @@ -1,21 +1,27 @@ import HotPocketExtension from '../common'; const POST_AUTH_PATH = '/integrations/extension/post-authenticate/'; +const ACCOUNTS_RPC_PATH = '/accounts/rpc/'; const RPC_PATH = '/rpc/'; let authSessionToken = null; +let accountsRPCURL = null; let rpcURL = null; -const updateRpcURL = () => { +const updateRpcURLs = () => { + accountsRPCURL = null; rpcURL = null; + if (HotPocketExtension.base_url !== null) { rpcURL = (new URL(RPC_PATH, HotPocketExtension.base_url)).toString(); + accountsRPCURL = (new URL(ACCOUNTS_RPC_PATH, HotPocketExtension.base_url)).toString(); } HotPocketExtension.LOGGER.debug( - 'HotPocketExtension.background.updateRpcURL()', + 'HotPocketExtension.background.updateRpcURLs()', HotPocketExtension.base_url, rpcURL, + accountsRPCURL, ); }; @@ -127,7 +133,7 @@ const doCreateAndStoreAccessToken = async (authKey) => { ); const [accessToken, error] = await executeJSONRPCCall( - rpcURL, accessTokenCall, {accessToken: null}, + accountsRPCURL, accessTokenCall, {accessToken: null}, ); if (error === null) { @@ -210,7 +216,7 @@ const doCheckAuth = async (accessToken) => { [accessToken, getAccessTokenMeta()], ); - const [result, error] = await executeJSONRPCCall(rpcURL, call, { + const [result, error] = await executeJSONRPCCall(accountsRPCURL, call, { accessToken, }); @@ -239,7 +245,7 @@ const doSetupRPC = async () => { let accessToken = null; if (storageResult.baseURL) { HotPocketExtension.base_url = storageResult.baseURL; - updateRpcURL(); + updateRpcURLs(); accessToken = await doCheckAuth( storageResult.accessToken || null, @@ -280,7 +286,7 @@ const doSendTabMessage = (tab, message) => { const doUpdateBaseURL = (nextBaseURL) => { HotPocketExtension.base_url = nextBaseURL; - updateRpcURL(); + updateRpcURLs(); HotPocketExtension.api.storage.local. set({ @@ -378,7 +384,7 @@ export default ({...configuration}) => { background: true, }); - updateRpcURL(); + updateRpcURLs(); HotPocketExtension.api.tabs.onCreated.addListener(onTabCreated); diff --git a/services/extension/src/content/preauth.html b/services/extension/src/content/preauth.html index 36e27ef..746c9a8 100644 --- a/services/extension/src/content/preauth.html +++ b/services/extension/src/content/preauth.html @@ -45,20 +45,21 @@ body, html {

HotPocket by BTHLabs

-
+
Enter the URL to your HotPocket instance, e.g. https://my.hotpocket.app/. diff --git a/services/packages/common/hotpocket_common/constants/accounts.py b/services/packages/common/hotpocket_common/constants/accounts.py index c919265..58c73fe 100644 --- a/services/packages/common/hotpocket_common/constants/accounts.py +++ b/services/packages/common/hotpocket_common/constants/accounts.py @@ -9,3 +9,5 @@ class AccessTokenOriginApp(enum.Enum): SAFARI_WEB_EXTENSION = 'SAFARI_WEB_EXTENSION' CHROME_EXTENSION = 'CHROME_EXTENSION' FIREFOX_EXTENSION = 'FIREFOX_EXTENSION' + HOTPOCKET_DESKTOP = 'HOTPOCKET_DESKTOP' + HOTPOCKET_MOBILE = 'HOTPOCKET_MOBILE' diff --git a/services/packages/soa/hotpocket_soa/dto/accounts.py b/services/packages/soa/hotpocket_soa/dto/accounts.py index 58f3437..922ae7f 100644 --- a/services/packages/soa/hotpocket_soa/dto/accounts.py +++ b/services/packages/soa/hotpocket_soa/dto/accounts.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- from __future__ import annotations +import datetime import urllib.parse import uuid @@ -8,7 +9,7 @@ import pydantic from hotpocket_common.constants import AccessTokenOriginApp -from .base import ModelOut, Query +from .base import BaseModelOut, ModelOut, Query class AccessTokenOut(ModelOut): @@ -32,6 +33,12 @@ class AccessTokenOut(ModelOut): case 'moz-extension': return AccessTokenOriginApp.FIREFOX_EXTENSION + case 'hotpocket-desktop': + return AccessTokenOriginApp.HOTPOCKET_DESKTOP + + case 'hotpocket-mobile': + return AccessTokenOriginApp.HOTPOCKET_MOBILE + case _: return None @@ -47,3 +54,16 @@ class AccessTokensQuery(Query): class AccessTokenMetaUpdateIn(pydantic.BaseModel): version: str | None = None platform: str | None = None + + +class AuthKeyOut(ModelOut): + account_uuid: uuid.UUID + key: str + consumed_at: datetime.datetime | None = None + + +class AccountOut(BaseModelOut): + first_name: str + last_name: str + username: str + settings: dict diff --git a/services/packages/soa/hotpocket_soa/dto/base.py b/services/packages/soa/hotpocket_soa/dto/base.py index e522d69..83a1614 100644 --- a/services/packages/soa/hotpocket_soa/dto/base.py +++ b/services/packages/soa/hotpocket_soa/dto/base.py @@ -7,13 +7,8 @@ import uuid import pydantic -class ModelOut(pydantic.BaseModel): +class BaseModelOut(pydantic.BaseModel): id: uuid.UUID - account_uuid: uuid.UUID - created_at: datetime.datetime - updated_at: datetime.datetime - deleted_at: datetime.datetime | None - is_active: bool @property def pk(self) -> uuid.UUID: @@ -23,5 +18,13 @@ class ModelOut(pydantic.BaseModel): return self.dict() +class ModelOut(BaseModelOut): + account_uuid: uuid.UUID + created_at: datetime.datetime + updated_at: datetime.datetime + deleted_at: datetime.datetime | None + is_active: bool + + class Query(pydantic.BaseModel): pass diff --git a/services/packages/soa/hotpocket_soa/services/__init__.py b/services/packages/soa/hotpocket_soa/services/__init__.py index 80e678d..ab0d265 100644 --- a/services/packages/soa/hotpocket_soa/services/__init__.py +++ b/services/packages/soa/hotpocket_soa/services/__init__.py @@ -1,5 +1,7 @@ from .access_tokens import AccessTokensService # noqa: F401 +from .accounts import AccountsService # noqa: F401 from .associations import AssociationsService # noqa: F401 +from .auth_keys import AuthKeysService # noqa: F401 from .bot import BotService # noqa: F401 from .save_processor import SaveProcessorService # noqa: F401 from .saves import SavesService # noqa: F401 diff --git a/services/packages/soa/hotpocket_soa/services/access_tokens.py b/services/packages/soa/hotpocket_soa/services/access_tokens.py index 674f4c6..caa9bfc 100644 --- a/services/packages/soa/hotpocket_soa/services/access_tokens.py +++ b/services/packages/soa/hotpocket_soa/services/access_tokens.py @@ -76,13 +76,13 @@ class AccessTokensService(ProxyService): return result except SOAError as exception: if isinstance(exception.__cause__, BackendAccessTokensService.AccessTokenNotFound) is True: - raise self.AccessTokenNotFound(f'account_uuid=`{account_uuid}` pk=`{pk}`') from exception + raise self.AccessTokenNotFound(*exception.args) from exception else: raise def get_by_key(self, *, - account_uuid: uuid.UUID, + account_uuid: uuid.UUID | None, key: str, ) -> AccessTokenOut: try: diff --git a/services/packages/soa/hotpocket_soa/services/accounts.py b/services/packages/soa/hotpocket_soa/services/accounts.py new file mode 100644 index 0000000..af2fdc4 --- /dev/null +++ b/services/packages/soa/hotpocket_soa/services/accounts.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +import uuid + +from hotpocket_backend.apps.accounts.services import ( + AccountsService as BackendAccountsService, +) +from hotpocket_soa.dto.accounts import AccountOut + +from .base import ProxyService, SOAError + + +class AccountsService(ProxyService): + class AccountsServiceError(SOAError): + pass + + class AccountNotFound(AccountsServiceError): + pass + + def __init__(self): + super().__init__() + self.backend_accounts_service = BackendAccountsService() + + def wrap_exception(self, exception: Exception) -> Exception: + new_exception_args = [] + if len(exception.args) > 0: + new_exception_args = [exception.args[0]] + + return self.AccountsServiceError(*new_exception_args) + + def get(self, *, pk: uuid.UUID) -> AccountOut: + try: + result = AccountOut.model_validate( + self.call( + self.backend_accounts_service, + 'get', + pk=pk, + ), + from_attributes=True, + ) + + return result + except SOAError as exception: + if isinstance(exception.__cause__, BackendAccountsService.AccountNotFound) is True: + raise self.AccountNotFound(*exception.args) from exception + else: + raise diff --git a/services/packages/soa/hotpocket_soa/services/associations.py b/services/packages/soa/hotpocket_soa/services/associations.py index 0cb70e4..f6fc651 100644 --- a/services/packages/soa/hotpocket_soa/services/associations.py +++ b/services/packages/soa/hotpocket_soa/services/associations.py @@ -91,7 +91,7 @@ class AssociationsService(ProxyService): return result except SOAError as exception: if isinstance(exception.__cause__, BackendAssociationsService.AssociationNotFound) is True: - raise self.AssociationNotFound(f'pk=`{pk}`') from exception + raise self.AssociationNotFound(*exception.args) from exception else: raise diff --git a/services/packages/soa/hotpocket_soa/services/auth_keys.py b/services/packages/soa/hotpocket_soa/services/auth_keys.py new file mode 100644 index 0000000..d214416 --- /dev/null +++ b/services/packages/soa/hotpocket_soa/services/auth_keys.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +import uuid + +from hotpocket_backend.apps.accounts.services import ( + AuthKeysService as BackendAuthKeysService, +) +from hotpocket_soa.dto.accounts import AuthKeyOut + +from .base import ProxyService, SOAError + + +class AuthKeysService(ProxyService): + class AuthKeysServiceError(SOAError): + pass + + class AuthKeyNotFound(AuthKeysServiceError): + pass + + class AuthKeyAccessDenied(AuthKeysServiceError): + pass + + def __init__(self): + super().__init__() + self.backend_auth_keys_service = BackendAuthKeysService() + + def wrap_exception(self, exception: Exception) -> Exception: + new_exception_args = [] + if len(exception.args) > 0: + new_exception_args = [exception.args[0]] + + return self.AuthKeysServiceError(*new_exception_args) + + def _check_auth_key_access(self, + auth_key: AuthKeyOut, + account_uuid: uuid.UUID | None, + ) -> bool: + if account_uuid is not None: + return auth_key.account_uuid == account_uuid + + return True + + def create(self, + *, + account_uuid: uuid.UUID, + ) -> AuthKeyOut: + return AuthKeyOut.model_validate( + self.call( + self.backend_auth_keys_service, + 'create', + account_uuid=account_uuid, + ), + from_attributes=True, + ) + + def get(self, + *, + account_uuid: uuid.UUID, + pk: uuid.UUID, + ) -> AuthKeyOut: + try: + result = AuthKeyOut.model_validate( + self.call( + self.backend_auth_keys_service, + 'get', + pk=pk, + ), + from_attributes=True, + ) + + if self._check_auth_key_access(result, account_uuid) is False: + raise self.AuthKeyAccessDenied( + f'account_uuid=`{account_uuid}` pk=`{pk}`', + ) + + return result + except SOAError as exception: + if isinstance(exception.__cause__, BackendAuthKeysService.AuthKeyNotFound) is True: + raise self.AuthKeyNotFound(*exception.args) from exception + else: + raise + + def get_by_key(self, + *, + account_uuid: uuid.UUID | None, + key: str, + ) -> AuthKeyOut: + try: + result = AuthKeyOut.model_validate( + self.call( + self.backend_auth_keys_service, + 'get_by_key', + key=key, + ), + from_attributes=True, + ) + + if self._check_auth_key_access(result, account_uuid) is False: + raise self.AuthKeyAccessDenied( + f'account_uuid=`{account_uuid}` key=`{key}`', + ) + + return result + except SOAError as exception: + if isinstance(exception.__cause__, BackendAuthKeysService.AuthKeyNotFound) is True: + raise self.AuthKeyNotFound(*exception.args) from exception + else: + raise diff --git a/services/packages/soa/hotpocket_soa/services/saves.py b/services/packages/soa/hotpocket_soa/services/saves.py index f8eb452..dde698d 100644 --- a/services/packages/soa/hotpocket_soa/services/saves.py +++ b/services/packages/soa/hotpocket_soa/services/saves.py @@ -54,6 +54,6 @@ class SavesService(ProxyService): return result except SOAError as exception: if isinstance(exception.__cause__, BackendSavesService.SaveNotFound) is True: - raise self.SaveNotFound(f'pk=`{pk}`') from exception + raise self.SaveNotFound(*exception.args) from exception else: raise