BTHLABS-58: Share Extension in Apple Apps

This commit is contained in:
2025-10-04 08:02:13 +02:00
parent 0c12f52569
commit 99e9226338
122 changed files with 5488 additions and 411 deletions

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,32 @@
//
// HPAPI.h
// HotPocket
//
// Created by Tomek Wójcik on 23/09/2025.
//
#import <Foundation/Foundation.h>
#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<HPRPCClientDelegate>)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

View File

@@ -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<HPRPCClientDelegate>)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

View File

@@ -0,0 +1,30 @@
//
// HPAuthFlow.h
// HotPocket (macOS)
//
// Created by Tomek Wójcik on 21/09/2025.
//
#import <Foundation/Foundation.h>
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

View File

@@ -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

View File

@@ -0,0 +1,32 @@
//
// HPCredentialsHelper.h
// HotPocket
//
// Created by Tomek Wójcik on 19/09/2025.
//
#import <Foundation/Foundation.h>
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

View File

@@ -0,0 +1,218 @@
//
// HPCredentialsHelper.m
// HotPocket
//
// Created by Tomek Wójcik on 19/09/2025.
//
#import <Security/Security.h>
#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

View File

@@ -0,0 +1,44 @@
//
// HPRPCClient.h
// HotPocket
//
// Created by Tomek Wójcik on 19/09/2025.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@interface HPRPCCallResult : NSObject
@property (nullable) NSError *error;
@property (nullable) id result;
@end
@protocol HPRPCClientDelegate <NSObject>
-(void)rpcClientDidReceiveResult:(HPRPCCallResult *)result callId:(NSString *)callId;
@end
typedef void (^HPRPCClientCompletionHandler)(NSString * _Nullable callId, HPRPCCallResult * _Nullable result);
@interface HPRPCClient : NSObject
@property (nonatomic, weak) id<HPRPCClientDelegate> 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

View File

@@ -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

View File

@@ -0,0 +1,18 @@
//
// NSURL+HotPocketExtensions.h
// HotPocket (macOS)
//
// Created by Tomek Wójcik on 30/09/2025.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@interface NSURL (HotPocketExtensions)
-(BOOL)isUsableInHotPocket;
@end
NS_ASSUME_NONNULL_END

View File

@@ -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

View File

@@ -1,20 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<link rel="stylesheet" href="../Style.css">
<script src="../Script.js" defer></script>
</head>
<body>
<img src="../icon-mac-384.png" width="128" height="128" alt="HotPocket Icon">
<p class="platform-ios">You can turn on Save to Hotpocket Safari extension in Settings.</p>
<p class="platform-mac state-unknown">You can turn on Save to Hotpocket extension in Safari Extensions preferences.</p>
<p class="platform-mac state-on">Save to Hotpocket extension is currently on. You can turn it off in Safari Extensions preferences.</p>
<p class="platform-mac state-off">Save to Hotpocket extension is currently off. You can turn it on in Safari Extensions preferences.</p>
<button class="platform-mac open-preferences">Quit and Open Safari Extensions Preferences…</button>
</body>
</html>

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -1,26 +0,0 @@
//
// ViewController.h
// Shared (App)
//
// Created by Tomek Wójcik on 21/08/2025.
//
#import <TargetConditionals.h>
#if TARGET_OS_IOS
#import <UIKit/UIKit.h>
typedef UIViewController PlatformViewController;
#elif TARGET_OS_OSX
#import <Cocoa/Cocoa.h>
typedef NSViewController PlatformViewController;
#endif
@interface ViewController : PlatformViewController
@end

View File

@@ -1,76 +0,0 @@
//
// ViewController.m
// Shared (App)
//
// Created by Tomek Wójcik on 21/08/2025.
//
#import "ViewController.h"
#import <WebKit/WebKit.h>
#if TARGET_OS_OSX
#import <SafariServices/SafariServices.h>
#endif
static NSString * const extensionBundleIdentifier = @"pl.bthlabs.HotPocket.HotPocket.Extension";
@interface ViewController () <WKNavigationDelegate, WKScriptMessageHandler>
@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