diff --git a/URLShare/ShareViewController.swift b/URLShare/ShareViewController.swift index cd418e8..22fac6a 100644 --- a/URLShare/ShareViewController.swift +++ b/URLShare/ShareViewController.swift @@ -9,58 +9,234 @@ import UIKit import Social import UniformTypeIdentifiers -class ShareViewController: SLComposeServiceViewController { +class ShareViewController: UIViewController { private var extractedURL: String? private var extractedTitle: String? + // UI Elements + private var titleTextField: UITextField? + private var urlLabel: UILabel? + private var statusLabel: UILabel? + private var saveButton: UIButton? + private var activityIndicator: UIActivityIndicatorView? + override func viewDidLoad() { super.viewDidLoad() + setupUI() extractSharedContent() + } + + + + // MARK: - UI Setup + private func setupUI() { + view.backgroundColor = UIColor(named: "green") ?? UIColor.systemGroupedBackground - // Automatisch die Haupt-App öffnen, sobald URL extrahiert wurde - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - self.openParentApp() - } + // Add cancel button + let cancelButton = UIBarButtonItem(title: "Abbrechen", style: .plain, target: self, action: #selector(cancelButtonTapped)) + cancelButton.tintColor = UIColor.white + navigationItem.leftBarButtonItem = cancelButton + + // Ensure navigation bar is visible + navigationController?.navigationBar.isTranslucent = false + navigationController?.navigationBar.backgroundColor = UIColor(named: "green") ?? UIColor.systemGreen + navigationController?.navigationBar.tintColor = UIColor.white + navigationController?.navigationBar.titleTextAttributes = [NSAttributedString.Key.foregroundColor: UIColor.white] + + // Add logo + let logoImageView = UIImageView(image: UIImage(named: "readeck")) + logoImageView.translatesAutoresizingMaskIntoConstraints = false + logoImageView.contentMode = .scaleAspectFit + logoImageView.alpha = 0.9 + view.addSubview(logoImageView) + + // Add custom cancel button + let customCancelButton = UIButton(type: .system) + customCancelButton.translatesAutoresizingMaskIntoConstraints = false + customCancelButton.setTitle("Abbrechen", for: .normal) + customCancelButton.setTitleColor(UIColor.white, for: .normal) + customCancelButton.titleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .medium) + customCancelButton.addTarget(self, action: #selector(cancelButtonTapped), for: .touchUpInside) + view.addSubview(customCancelButton) + + + + // URL Container View + let urlContainerView = UIView() + urlContainerView.translatesAutoresizingMaskIntoConstraints = false + urlContainerView.backgroundColor = UIColor.white + urlContainerView.layer.cornerRadius = 12 + urlContainerView.layer.shadowColor = UIColor.black.cgColor + urlContainerView.layer.shadowOffset = CGSize(width: 0, height: 2) + urlContainerView.layer.shadowRadius = 4 + urlContainerView.layer.shadowOpacity = 0.1 + view.addSubview(urlContainerView) + + // URL Label + urlLabel = UILabel() + urlLabel?.translatesAutoresizingMaskIntoConstraints = false + urlLabel?.font = UIFont.systemFont(ofSize: 15, weight: .medium) + urlLabel?.textColor = UIColor.label + urlLabel?.numberOfLines = 0 + urlLabel?.text = "URL wird geladen..." + urlLabel?.textAlignment = .left + urlContainerView.addSubview(urlLabel!) + + // Title Container View + let titleContainerView = UIView() + titleContainerView.translatesAutoresizingMaskIntoConstraints = false + titleContainerView.backgroundColor = UIColor.white + titleContainerView.layer.cornerRadius = 12 + titleContainerView.layer.shadowColor = UIColor.black.cgColor + titleContainerView.layer.shadowOffset = CGSize(width: 0, height: 2) + titleContainerView.layer.shadowRadius = 4 + titleContainerView.layer.shadowOpacity = 0.1 + view.addSubview(titleContainerView) + + // Title TextField + titleTextField = UITextField() + titleTextField?.translatesAutoresizingMaskIntoConstraints = false + titleTextField?.placeholder = "Titel eingeben..." + titleTextField?.borderStyle = .none + titleTextField?.font = UIFont.systemFont(ofSize: 16) + titleTextField?.backgroundColor = UIColor.clear + titleTextField?.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged) + titleContainerView.addSubview(titleTextField!) + + // Status Label + statusLabel = UILabel() + statusLabel?.translatesAutoresizingMaskIntoConstraints = false + statusLabel?.font = UIFont.systemFont(ofSize: 14, weight: .medium) + statusLabel?.numberOfLines = 0 + statusLabel?.textAlignment = .center + statusLabel?.isHidden = true + statusLabel?.layer.cornerRadius = 10 + statusLabel?.layer.masksToBounds = true + view.addSubview(statusLabel!) + + // Save Button + saveButton = UIButton(type: .system) + saveButton?.translatesAutoresizingMaskIntoConstraints = false + saveButton?.setTitle("Bookmark speichern", for: .normal) + saveButton?.titleLabel?.font = UIFont.systemFont(ofSize: 17, weight: .semibold) + saveButton?.backgroundColor = UIColor.white + saveButton?.setTitleColor(UIColor(named: "green") ?? UIColor.systemGreen, for: .normal) + saveButton?.layer.cornerRadius = 16 + saveButton?.layer.shadowColor = UIColor.black.cgColor + saveButton?.layer.shadowOffset = CGSize(width: 0, height: 4) + saveButton?.layer.shadowRadius = 8 + saveButton?.layer.shadowOpacity = 0.2 + saveButton?.addTarget(self, action: #selector(saveButtonTapped), for: .touchUpInside) + saveButton?.isEnabled = false + saveButton?.alpha = 0.6 + view.addSubview(saveButton!) + + + + // Activity Indicator + activityIndicator = UIActivityIndicatorView(style: .medium) + activityIndicator?.translatesAutoresizingMaskIntoConstraints = false + activityIndicator?.hidesWhenStopped = true + view.addSubview(activityIndicator!) + + setupConstraints() } - override func isContentValid() -> Bool { - return true // Immer true, damit der Button funktioniert + private func setupConstraints() { + guard let urlLabel = urlLabel, + let titleTextField = titleTextField, + let statusLabel = statusLabel, + let saveButton = saveButton, + let activityIndicator = activityIndicator else { return } + + // Find container views and logo + let urlContainerView = urlLabel.superview! + let titleContainerView = titleTextField.superview! + let logoImageView = view.subviews.first { $0 is UIImageView }! + let customCancelButton = view.subviews.first { $0 is UIButton && $0 != saveButton }! + + NSLayoutConstraint.activate([ + // Custom Cancel Button + customCancelButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16), + customCancelButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + customCancelButton.heightAnchor.constraint(equalToConstant: 36), + customCancelButton.widthAnchor.constraint(equalToConstant: 100), + + // Logo + logoImageView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20), + logoImageView.centerXAnchor.constraint(equalTo: view.centerXAnchor), + logoImageView.heightAnchor.constraint(equalToConstant: 40), + logoImageView.widthAnchor.constraint(equalToConstant: 120), + + // URL Container + urlContainerView.topAnchor.constraint(equalTo: logoImageView.bottomAnchor, constant: 24), + urlContainerView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + urlContainerView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + + // URL Label inside container + urlLabel.topAnchor.constraint(equalTo: urlContainerView.topAnchor, constant: 16), + urlLabel.leadingAnchor.constraint(equalTo: urlContainerView.leadingAnchor, constant: 16), + urlLabel.trailingAnchor.constraint(equalTo: urlContainerView.trailingAnchor, constant: -16), + urlLabel.bottomAnchor.constraint(equalTo: urlContainerView.bottomAnchor, constant: -16), + + // Title Container + titleContainerView.topAnchor.constraint(equalTo: urlContainerView.bottomAnchor, constant: 20), + titleContainerView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + titleContainerView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + titleContainerView.heightAnchor.constraint(equalToConstant: 60), + + // Title TextField inside container + titleTextField.topAnchor.constraint(equalTo: titleContainerView.topAnchor, constant: 16), + titleTextField.leadingAnchor.constraint(equalTo: titleContainerView.leadingAnchor, constant: 16), + titleTextField.trailingAnchor.constraint(equalTo: titleContainerView.trailingAnchor, constant: -16), + titleTextField.bottomAnchor.constraint(equalTo: titleContainerView.bottomAnchor, constant: -16), + + // Status Label + statusLabel.topAnchor.constraint(equalTo: titleContainerView.bottomAnchor, constant: 20), + statusLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + statusLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + + // Save Button + saveButton.topAnchor.constraint(equalTo: statusLabel.bottomAnchor, constant: 32), + saveButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + saveButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + saveButton.heightAnchor.constraint(equalToConstant: 56), + + // Activity Indicator + activityIndicator.centerXAnchor.constraint(equalTo: saveButton.centerXAnchor), + activityIndicator.centerYAnchor.constraint(equalTo: saveButton.centerYAnchor) + ]) } - override func didSelectPost() { - openMainApp() - } - - // MARK: - Private Methods - + // MARK: - Content Extraction private func extractSharedContent() { guard let extensionContext = extensionContext else { return } - print("=== DEBUG: Starting content extraction ===") - print("Input items count: \(extensionContext.inputItems.count)") - - for (itemIndex, item) in extensionContext.inputItems.enumerated() { + for item in extensionContext.inputItems { guard let inputItem = item as? NSExtensionItem else { continue } - print("Item \(itemIndex) - attachments: \(inputItem.attachments?.count ?? 0)") - - // Versuche alle verfügbaren Type Identifiers - for (attachmentIndex, provider) in (inputItem.attachments ?? []).enumerated() { - print("Attachment \(attachmentIndex) - registered types: \(provider.registeredTypeIdentifiers)") - - // Iteriere durch alle registrierten Type Identifiers - for typeIdentifier in provider.registeredTypeIdentifiers { - print("Trying type identifier: \(typeIdentifier)") - - provider.loadItem(forTypeIdentifier: typeIdentifier, options: nil) { [weak self] item, error in - if let error = error { - print("Error loading \(typeIdentifier): \(error)") - return - } - + for attachment in inputItem.attachments ?? [] { + if attachment.hasItemConformingToTypeIdentifier(UTType.url.identifier) { + attachment.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { [weak self] (url, error) in DispatchQueue.main.async { - self?.processLoadedItem(item, typeIdentifier: typeIdentifier, inputItem: inputItem) + if let url = url as? URL { + self?.extractedURL = url.absoluteString + self?.urlLabel?.text = url.absoluteString + self?.updateSaveButtonState() + } + } + } + } + + if attachment.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) { + attachment.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { [weak self] (text, error) in + DispatchQueue.main.async { + if let text = text as? String, let url = URL(string: text) { + self?.extractedURL = url.absoluteString + self?.urlLabel?.text = url.absoluteString + self?.updateSaveButtonState() + } } } } @@ -68,156 +244,131 @@ class ShareViewController: SLComposeServiceViewController { } } - private func processLoadedItem(_ item: Any?, typeIdentifier: String, inputItem: NSExtensionItem) { - print("Processing item of type \(typeIdentifier): \(type(of: item))") - - // URL direkt - if let url = item as? URL { - print("Found URL: \(url.absoluteString)") - extractedURL = url.absoluteString - extractedTitle = inputItem.attributedTitle?.string ?? inputItem.attributedContentText?.string - return - } - - // NSURL - if let nsurl = item as? NSURL { - print("Found NSURL: \(nsurl.absoluteString ?? "nil")") - extractedURL = nsurl.absoluteString - extractedTitle = inputItem.attributedTitle?.string ?? inputItem.attributedContentText?.string - return - } - - // String (könnte URL sein) - if let text = item as? String { - print("Found String: \(text)") - if URL(string: text) != nil { - extractedURL = text - extractedTitle = inputItem.attributedTitle?.string ?? inputItem.attributedContentText?.string - return - } - - // Versuche URL aus Text zu extrahieren - if let extractedURL = extractURLFromText(text) { - self.extractedURL = extractedURL - self.extractedTitle = text != extractedURL ? text : nil - return - } - } - - // Dictionary (Property List) - if let dictionary = item as? [String: Any] { - print("Found Dictionary: \(dictionary)") - handlePropertyList(dictionary) - return - } - - // NSData - versuche als String zu interpretieren - if let data = item as? Data { - if let text = String(data: data, encoding: .utf8) { - print("Found Data as String: \(text)") - if URL(string: text) != nil { - extractedURL = text - return - } - } - } - - print("Could not process item of type: \(type(of: item))") + // MARK: - Actions + @objc private func textFieldDidChange() { + updateSaveButtonState() } - private func extractURLFromText(_ text: String) -> String? { - let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) - let range = NSRange(text.startIndex.. + + + + keychain-access-groups + + $(AppIdentifierPrefix)de.ilyashallak.readeck2 + + + diff --git a/readeck.xcodeproj/project.pbxproj b/readeck.xcodeproj/project.pbxproj index 0878df2..96f23c8 100644 --- a/readeck.xcodeproj/project.pbxproj +++ b/readeck.xcodeproj/project.pbxproj @@ -77,7 +77,9 @@ 5DCD48B72DFB44D600AC7FB6 /* Exceptions for "readeck" folder in "URLShare" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( + Assets.xcassets, Data/CoreData/CoreDataManager.swift, + Data/KeychainHelper.swift, Domain/Model/Bookmark.swift, readeck.xcdatamodeld, ); @@ -409,6 +411,7 @@ 5D2B7FBC2DFA27A400EBDB2B /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 8J69P655GN; @@ -437,6 +440,7 @@ 5D2B7FBD2DFA27A400EBDB2B /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 8J69P655GN; diff --git a/readeck/Assets.xcassets/green.colorset/Contents.json b/readeck/Assets.xcassets/green.colorset/Contents.json index f5a0250..a7ad9c4 100644 --- a/readeck/Assets.xcassets/green.colorset/Contents.json +++ b/readeck/Assets.xcassets/green.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x5B", - "green" : "0x4D", - "red" : "0x00" + "blue" : "0x39", + "green" : "0x37", + "red" : "0x2E" } }, "idiom" : "universal" diff --git a/readeck/Data/KeychainHelper.swift b/readeck/Data/KeychainHelper.swift new file mode 100644 index 0000000..57cc0ef --- /dev/null +++ b/readeck/Data/KeychainHelper.swift @@ -0,0 +1,58 @@ +import Foundation +import Security + +class KeychainHelper { + static let shared = KeychainHelper() + private init() {} + + private static let accessGroup = "8J69P655GN.de.ilyashallak.readeck2" + + @discardableResult + func saveToken(_ token: String) -> Bool { + saveString(token, forKey: "readeck_token") + } + + func loadToken() -> String? { + loadString(forKey: "readeck_token") + } + + @discardableResult + func saveEndpoint(_ endpoint: String) -> Bool { + saveString(endpoint, forKey: "readeck_endpoint") + } + + func loadEndpoint() -> String? { + loadString(forKey: "readeck_endpoint") + } + + // MARK: - Private generic helpers + @discardableResult + private func saveString(_ value: String, forKey key: String) -> Bool { + guard let data = value.data(using: .utf8) else { return false } + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecValueData as String: data, + kSecAttrAccessGroup as String: KeychainHelper.accessGroup + ] + SecItemDelete(query as CFDictionary) + let status = SecItemAdd(query as CFDictionary, nil) + return status == errSecSuccess + } + + private func loadString(forKey key: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecAttrAccessGroup as String: KeychainHelper.accessGroup, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecSuccess, let data = result as? Data { + return String(data: data, encoding: .utf8) + } + return nil + } +} diff --git a/readeck/Data/TokenProvider.swift b/readeck/Data/TokenProvider.swift index 960217b..33b8acb 100644 --- a/readeck/Data/TokenProvider.swift +++ b/readeck/Data/TokenProvider.swift @@ -11,6 +11,7 @@ class CoreDataTokenProvider: TokenProvider { private let settingsRepository = SettingsRepository() private var cachedSettings: Settings? private var isLoaded = false + private let keychainHelper = KeychainHelper.shared private func loadSettingsIfNeeded() async { guard !isLoaded else { return } @@ -40,6 +41,7 @@ class CoreDataTokenProvider: TokenProvider { do { try await settingsRepository.saveToken(token) + saveTokenToKeychain(token: token) if cachedSettings != nil { cachedSettings!.token = token } @@ -52,8 +54,19 @@ class CoreDataTokenProvider: TokenProvider { do { try await settingsRepository.clearSettings() cachedSettings = nil + saveTokenToKeychain(token: "") } catch { print("Failed to clear settings: \(error)") } } + + // MARK: - Keychain Support + + func saveTokenToKeychain(token: String) { + keychainHelper.saveToken(token) + } + + func loadTokenFromKeychain() -> String? { + keychainHelper.loadToken() + } } diff --git a/readeck/Domain/UseCase/LogoutUseCase.swift b/readeck/Domain/UseCase/LogoutUseCase.swift index 55f3425..c871c39 100644 --- a/readeck/Domain/UseCase/LogoutUseCase.swift +++ b/readeck/Domain/UseCase/LogoutUseCase.swift @@ -35,9 +35,12 @@ class LogoutUseCase: LogoutUseCaseProtocol { try await settingsRepository.saveUsername("") try await settingsRepository.savePassword("") + KeychainHelper.shared.saveToken("") + KeychainHelper.shared.saveEndpoint("") + // Note: We keep the endpoint for potential re-login // but clear the authentication data print("LogoutUseCase: User logged out successfully") } -} \ No newline at end of file +} diff --git a/readeck/Domain/UseCase/SaveServerSettingsUseCase.swift b/readeck/Domain/UseCase/SaveServerSettingsUseCase.swift index 7790f6b..24bcd08 100644 --- a/readeck/Domain/UseCase/SaveServerSettingsUseCase.swift +++ b/readeck/Domain/UseCase/SaveServerSettingsUseCase.swift @@ -9,5 +9,8 @@ class SaveServerSettingsUseCase { func execute(endpoint: String, username: String, password: String, token: String) async throws { try await repository.saveServerSettings(endpoint: endpoint, username: username, password: password, token: token) + KeychainHelper.shared.saveToken(token) + KeychainHelper.shared.saveEndpoint(endpoint) + print("token saved", KeychainHelper.shared.loadToken()) } -} \ No newline at end of file +} diff --git a/readeck/readeck.entitlements b/readeck/readeck.entitlements index f2ef3ae..92613a3 100644 --- a/readeck/readeck.entitlements +++ b/readeck/readeck.entitlements @@ -2,9 +2,13 @@ - com.apple.security.app-sandbox - - com.apple.security.files.user-selected.read-only - + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-only + + keychain-access-groups + + $(AppIdentifierPrefix)de.ilyashallak.readeck2 +