feat: Complete Share Extension implementation with Keychain integration
UI/UX Improvements: - Replace SLComposeServiceViewController with custom UIViewController - Add beautiful green-themed UI with Readeck branding and logo - Implement modern card-based layout with shadows and rounded corners - Add custom cancel button and proper navigation styling - Include loading states and comprehensive user feedback Backend Integration: - Add KeychainHelper integration for secure token/endpoint storage - Implement proper API integration with async/await - Add comprehensive error handling and status messages - Include DTOs for API communication Security & Configuration: - Add keychain access groups to entitlements for both main app and extension - Update TokenProvider to save tokens to keychain - Modify LogoutUseCase to clear keychain data - Update SaveServerSettingsUseCase to persist endpoint in keychain - Configure proper build settings and file sharing between targets Extension Lifecycle: - Implement proper URL extraction from various sources - Add automatic extension dismissal on success - Ensure proper extension context handling
This commit is contained in:
parent
e88693363b
commit
1763dd6fa1
@ -9,58 +9,234 @@ import UIKit
|
|||||||
import Social
|
import Social
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
class ShareViewController: SLComposeServiceViewController {
|
class ShareViewController: UIViewController {
|
||||||
|
|
||||||
private var extractedURL: String?
|
private var extractedURL: String?
|
||||||
private var extractedTitle: 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() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
setupUI()
|
||||||
extractSharedContent()
|
extractSharedContent()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - UI Setup
|
||||||
|
private func setupUI() {
|
||||||
|
view.backgroundColor = UIColor(named: "green") ?? UIColor.systemGroupedBackground
|
||||||
|
|
||||||
// Automatisch die Haupt-App öffnen, sobald URL extrahiert wurde
|
// Add cancel button
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
let cancelButton = UIBarButtonItem(title: "Abbrechen", style: .plain, target: self, action: #selector(cancelButtonTapped))
|
||||||
self.openParentApp()
|
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 {
|
private func setupConstraints() {
|
||||||
return true // Immer true, damit der Button funktioniert
|
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() {
|
// MARK: - Content Extraction
|
||||||
openMainApp()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Methods
|
|
||||||
|
|
||||||
private func extractSharedContent() {
|
private func extractSharedContent() {
|
||||||
guard let extensionContext = extensionContext else { return }
|
guard let extensionContext = extensionContext else { return }
|
||||||
|
|
||||||
print("=== DEBUG: Starting content extraction ===")
|
for item in extensionContext.inputItems {
|
||||||
print("Input items count: \(extensionContext.inputItems.count)")
|
|
||||||
|
|
||||||
for (itemIndex, item) in extensionContext.inputItems.enumerated() {
|
|
||||||
guard let inputItem = item as? NSExtensionItem else { continue }
|
guard let inputItem = item as? NSExtensionItem else { continue }
|
||||||
|
|
||||||
print("Item \(itemIndex) - attachments: \(inputItem.attachments?.count ?? 0)")
|
for attachment in inputItem.attachments ?? [] {
|
||||||
|
if attachment.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
|
||||||
// Versuche alle verfügbaren Type Identifiers
|
attachment.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { [weak self] (url, error) in
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
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) {
|
// MARK: - Actions
|
||||||
print("Processing item of type \(typeIdentifier): \(type(of: item))")
|
@objc private func textFieldDidChange() {
|
||||||
|
updateSaveButtonState()
|
||||||
// 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))")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func extractURLFromText(_ text: String) -> String? {
|
@objc private func saveButtonTapped() {
|
||||||
let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
guard let title = titleTextField?.text, !title.isEmpty else {
|
||||||
let range = NSRange(text.startIndex..<text.endIndex, in: text)
|
showStatus("Bitte geben Sie einen Titel ein.", error: true)
|
||||||
|
|
||||||
if let match = detector?.firstMatch(in: text, options: [], range: range),
|
|
||||||
let url = match.url {
|
|
||||||
return url.absoluteString
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handlePropertyList(_ dictionary: [String: Any]) {
|
|
||||||
// Safari und andere Browser verwenden oft Property Lists
|
|
||||||
if let results = dictionary[NSExtensionJavaScriptPreprocessingResultsKey] as? [String: Any] {
|
|
||||||
if let url = results["URL"] as? String {
|
|
||||||
extractedURL = url
|
|
||||||
extractedTitle = results["title"] as? String
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Direkte URL im Dictionary
|
|
||||||
if let url = dictionary["URL"] as? String {
|
|
||||||
extractedURL = url
|
|
||||||
extractedTitle = dictionary["title"] as? String
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Andere mögliche Keys
|
saveButton?.isEnabled = false
|
||||||
for key in dictionary.keys {
|
activityIndicator?.startAnimating()
|
||||||
if let value = dictionary[key] as? String, URL(string: value) != nil {
|
|
||||||
extractedURL = value
|
Task {
|
||||||
return
|
await addBookmarkViaAPI(title: title)
|
||||||
|
await MainActor.run {
|
||||||
|
self.saveButton?.isEnabled = true
|
||||||
|
self.activityIndicator?.stopAnimating()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func openMainApp() {
|
@objc private func cancelButtonTapped() {
|
||||||
let url = extractedURL ?? "https://example.com"
|
|
||||||
let title = extractedTitle ?? ""
|
|
||||||
|
|
||||||
print("Opening main app with URL: \(url)")
|
|
||||||
|
|
||||||
// Verwende NSUserActivity anstatt URL-Schema
|
|
||||||
let userActivity = NSUserActivity(activityType: "de.ilyas.readeck")
|
|
||||||
userActivity.userInfo = [
|
|
||||||
"url": url,
|
|
||||||
"title": title
|
|
||||||
]
|
|
||||||
userActivity.webpageURL = URL(string: url)
|
|
||||||
|
|
||||||
// Extension schließen und Activity übergeben
|
|
||||||
extensionContext?.completeRequest(returningItems: [userActivity], completionHandler: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func completeRequest() {
|
|
||||||
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func openParentApp() {
|
private func updateSaveButtonState() {
|
||||||
guard let extensionContext = extensionContext else { return }
|
let isValid = !(titleTextField?.text?.isEmpty ?? true) && extractedURL != nil
|
||||||
|
saveButton?.isEnabled = isValid
|
||||||
let url = extractedURL ?? "https://example.com"
|
saveButton?.alpha = isValid ? 1.0 : 0.6
|
||||||
let title = extractedTitle ?? ""
|
}
|
||||||
|
|
||||||
print("Opening parent app with URL: \(url)")
|
// MARK: - API Call
|
||||||
|
private func addBookmarkViaAPI(title: String) async {
|
||||||
// URL für die Haupt-App erstellen mit Parametern
|
guard let url = extractedURL, !url.isEmpty else {
|
||||||
var urlComponents = URLComponents(string: "readeck://add-bookmark")
|
showStatus("Keine URL gefunden.", error: true)
|
||||||
urlComponents?.queryItems = [
|
|
||||||
URLQueryItem(name: "url", value: url)
|
|
||||||
]
|
|
||||||
|
|
||||||
if !title.isEmpty {
|
|
||||||
urlComponents?.queryItems?.append(URLQueryItem(name: "title", value: title))
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let finalURL = urlComponents?.url else {
|
|
||||||
print("Failed to create final URL")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
print("Final URL: \(finalURL)")
|
// Token und Endpoint aus KeychainHelper
|
||||||
|
guard let token = KeychainHelper.shared.loadToken() else {
|
||||||
|
showStatus("Kein Token gefunden. Bitte in der Haupt-App einloggen.", error: true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var responder: UIResponder? = self
|
guard let endpoint = KeychainHelper.shared.loadEndpoint(), !endpoint.isEmpty else {
|
||||||
|
showStatus("Kein Server-Endpunkt gefunden.", error: true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
while responder != nil {
|
let requestDto = CreateBookmarkRequestDto(url: url, title: title, labels: [])
|
||||||
if let application = responder as? UIApplication {
|
guard let requestData = try? JSONEncoder().encode(requestDto) else {
|
||||||
application.open(finalURL)
|
showStatus("Fehler beim Kodieren der Anfrage.", error: true)
|
||||||
break
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let apiUrl = URL(string: endpoint + "/api/bookmarks") else {
|
||||||
|
showStatus("Ungültiger Server-Endpunkt.", error: true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: apiUrl)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
request.httpBody = requestData
|
||||||
|
|
||||||
|
do {
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
showStatus("Ungültige Server-Antwort.", error: true)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
responder = responder?.next
|
|
||||||
|
guard 200...299 ~= httpResponse.statusCode else {
|
||||||
|
let msg = String(data: data, encoding: .utf8) ?? "Unbekannter Fehler"
|
||||||
|
showStatus("Serverfehler: \(httpResponse.statusCode)\n\(msg)", error: true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: Response parsen
|
||||||
|
if let resp = try? JSONDecoder().decode(CreateBookmarkResponseDto.self, from: data) {
|
||||||
|
showStatus("Gespeichert: \(resp.message)", error: false)
|
||||||
|
} else {
|
||||||
|
showStatus("Lesezeichen gespeichert!", error: false)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showStatus("Netzwerkfehler: \(error.localizedDescription)", error: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func showStatus(_ message: String, error: Bool) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.statusLabel?.text = message
|
||||||
|
self.statusLabel?.textColor = error ? UIColor.systemRed : UIColor.systemGreen
|
||||||
|
self.statusLabel?.backgroundColor = error ? UIColor.systemRed.withAlphaComponent(0.1) : UIColor.systemGreen.withAlphaComponent(0.1)
|
||||||
|
self.statusLabel?.isHidden = false
|
||||||
|
|
||||||
|
if !error {
|
||||||
|
// Automatically dismiss after success
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
||||||
|
self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - DTOs (kopiert)
|
||||||
|
private struct CreateBookmarkRequestDto: Codable {
|
||||||
|
let labels: [String]?
|
||||||
|
let title: String?
|
||||||
|
let url: String
|
||||||
|
|
||||||
|
init(url: String, title: String? = nil, labels: [String]? = nil) {
|
||||||
|
self.url = url
|
||||||
|
self.title = title
|
||||||
|
self.labels = labels
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CreateBookmarkResponseDto: Codable {
|
||||||
|
let message: String
|
||||||
|
let status: Int
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
10
URLShare/URLShare.entitlements
Normal file
10
URLShare/URLShare.entitlements
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>keychain-access-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>$(AppIdentifierPrefix)de.ilyashallak.readeck2</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@ -77,7 +77,9 @@
|
|||||||
5DCD48B72DFB44D600AC7FB6 /* Exceptions for "readeck" folder in "URLShare" target */ = {
|
5DCD48B72DFB44D600AC7FB6 /* Exceptions for "readeck" folder in "URLShare" target */ = {
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
membershipExceptions = (
|
membershipExceptions = (
|
||||||
|
Assets.xcassets,
|
||||||
Data/CoreData/CoreDataManager.swift,
|
Data/CoreData/CoreDataManager.swift,
|
||||||
|
Data/KeychainHelper.swift,
|
||||||
Domain/Model/Bookmark.swift,
|
Domain/Model/Bookmark.swift,
|
||||||
readeck.xcdatamodeld,
|
readeck.xcdatamodeld,
|
||||||
);
|
);
|
||||||
@ -409,6 +411,7 @@
|
|||||||
5D2B7FBC2DFA27A400EBDB2B /* Debug */ = {
|
5D2B7FBC2DFA27A400EBDB2B /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
|
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
@ -437,6 +440,7 @@
|
|||||||
5D2B7FBD2DFA27A400EBDB2B /* Release */ = {
|
5D2B7FBD2DFA27A400EBDB2B /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
|
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
|
|||||||
@ -23,9 +23,9 @@
|
|||||||
"color-space" : "srgb",
|
"color-space" : "srgb",
|
||||||
"components" : {
|
"components" : {
|
||||||
"alpha" : "1.000",
|
"alpha" : "1.000",
|
||||||
"blue" : "0x5B",
|
"blue" : "0x39",
|
||||||
"green" : "0x4D",
|
"green" : "0x37",
|
||||||
"red" : "0x00"
|
"red" : "0x2E"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"idiom" : "universal"
|
"idiom" : "universal"
|
||||||
|
|||||||
58
readeck/Data/KeychainHelper.swift
Normal file
58
readeck/Data/KeychainHelper.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -11,6 +11,7 @@ class CoreDataTokenProvider: TokenProvider {
|
|||||||
private let settingsRepository = SettingsRepository()
|
private let settingsRepository = SettingsRepository()
|
||||||
private var cachedSettings: Settings?
|
private var cachedSettings: Settings?
|
||||||
private var isLoaded = false
|
private var isLoaded = false
|
||||||
|
private let keychainHelper = KeychainHelper.shared
|
||||||
|
|
||||||
private func loadSettingsIfNeeded() async {
|
private func loadSettingsIfNeeded() async {
|
||||||
guard !isLoaded else { return }
|
guard !isLoaded else { return }
|
||||||
@ -40,6 +41,7 @@ class CoreDataTokenProvider: TokenProvider {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
try await settingsRepository.saveToken(token)
|
try await settingsRepository.saveToken(token)
|
||||||
|
saveTokenToKeychain(token: token)
|
||||||
if cachedSettings != nil {
|
if cachedSettings != nil {
|
||||||
cachedSettings!.token = token
|
cachedSettings!.token = token
|
||||||
}
|
}
|
||||||
@ -52,8 +54,19 @@ class CoreDataTokenProvider: TokenProvider {
|
|||||||
do {
|
do {
|
||||||
try await settingsRepository.clearSettings()
|
try await settingsRepository.clearSettings()
|
||||||
cachedSettings = nil
|
cachedSettings = nil
|
||||||
|
saveTokenToKeychain(token: "")
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to clear settings: \(error)")
|
print("Failed to clear settings: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Keychain Support
|
||||||
|
|
||||||
|
func saveTokenToKeychain(token: String) {
|
||||||
|
keychainHelper.saveToken(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadTokenFromKeychain() -> String? {
|
||||||
|
keychainHelper.loadToken()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,9 +35,12 @@ class LogoutUseCase: LogoutUseCaseProtocol {
|
|||||||
try await settingsRepository.saveUsername("")
|
try await settingsRepository.saveUsername("")
|
||||||
try await settingsRepository.savePassword("")
|
try await settingsRepository.savePassword("")
|
||||||
|
|
||||||
|
KeychainHelper.shared.saveToken("")
|
||||||
|
KeychainHelper.shared.saveEndpoint("")
|
||||||
|
|
||||||
// Note: We keep the endpoint for potential re-login
|
// Note: We keep the endpoint for potential re-login
|
||||||
// but clear the authentication data
|
// but clear the authentication data
|
||||||
|
|
||||||
print("LogoutUseCase: User logged out successfully")
|
print("LogoutUseCase: User logged out successfully")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,5 +9,8 @@ class SaveServerSettingsUseCase {
|
|||||||
|
|
||||||
func execute(endpoint: String, username: String, password: String, token: String) async throws {
|
func execute(endpoint: String, username: String, password: String, token: String) async throws {
|
||||||
try await repository.saveServerSettings(endpoint: endpoint, username: username, password: password, token: token)
|
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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,9 +2,13 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.app-sandbox</key>
|
<key>com.apple.security.app-sandbox</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.files.user-selected.read-only</key>
|
<key>com.apple.security.files.user-selected.read-only</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>keychain-access-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>$(AppIdentifierPrefix)de.ilyashallak.readeck2</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user