From a75215a9378b7e87965f762f15f8c12f195c1456 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Tue, 3 Mar 2026 13:47:14 +0100 Subject: [PATCH] add data models, api client, sticker store for iMessage extension Co-Authored-By: Claude Opus 4.6 --- .../Models.swift | 27 ++++++ .../StickerClonerAPI.swift | 59 +++++++++++++ .../StickerStore.swift | 87 +++++++++++++++++++ 3 files changed, 173 insertions(+) create mode 100644 StickerCloner/StickerCloner MessagesExtension/Models.swift create mode 100644 StickerCloner/StickerCloner MessagesExtension/StickerClonerAPI.swift create mode 100644 StickerCloner/StickerCloner MessagesExtension/StickerStore.swift diff --git a/StickerCloner/StickerCloner MessagesExtension/Models.swift b/StickerCloner/StickerCloner MessagesExtension/Models.swift new file mode 100644 index 0000000..b179c62 --- /dev/null +++ b/StickerCloner/StickerCloner MessagesExtension/Models.swift @@ -0,0 +1,27 @@ +import Foundation + +// MARK: - API response types (matches backend /api/stickersets/{name}) + +struct StickerSetResponse: Codable { + let name: String + let title: String + let stickerCount: Int + let stickers: [StickerResponse] +} + +struct StickerResponse: Codable { + let id: String + let emoji: String + let emojiName: String + let isAnimated: Bool + let pngUrl: String + let gifUrl: String? +} + +// MARK: - Local persistence + +struct SavedPack: Codable { + let name: String + let title: String + let stickerCount: Int +} diff --git a/StickerCloner/StickerCloner MessagesExtension/StickerClonerAPI.swift b/StickerCloner/StickerCloner MessagesExtension/StickerClonerAPI.swift new file mode 100644 index 0000000..fb97a9d --- /dev/null +++ b/StickerCloner/StickerCloner MessagesExtension/StickerClonerAPI.swift @@ -0,0 +1,59 @@ +import Foundation + +enum StickerClonerAPIError: LocalizedError { + case invalidResponse(Int) + case decodingFailed(Error) + + var errorDescription: String? { + switch self { + case .invalidResponse(let code): + return "Server returned status \(code)" + case .decodingFailed(let error): + return "Failed to decode response: \(error.localizedDescription)" + } + } +} + +enum StickerClonerAPI { + private static let baseURL = URL(string: "https://serve.uber.space/sticker-cloner")! + + private static let decoder: JSONDecoder = { + let d = JSONDecoder() + d.keyDecodingStrategy = .convertFromSnakeCase + return d + }() + + static func fetchStickerSet(name: String) async throws -> StickerSetResponse { + let url = baseURL.appendingPathComponent("api/stickersets/\(name)") + let (data, response) = try await URLSession.shared.data(from: url) + + guard let http = response as? HTTPURLResponse, 200..<300 ~= http.statusCode else { + let code = (response as? HTTPURLResponse)?.statusCode ?? -1 + throw StickerClonerAPIError.invalidResponse(code) + } + + do { + return try decoder.decode(StickerSetResponse.self, from: data) + } catch { + throw StickerClonerAPIError.decodingFailed(error) + } + } + + static func downloadSticker(from relativePath: String, to localURL: URL) async throws { + let url = baseURL.appendingPathComponent(relativePath) + let (tempURL, response) = try await URLSession.shared.download(from: url) + + guard let http = response as? HTTPURLResponse, 200..<300 ~= http.statusCode else { + let code = (response as? HTTPURLResponse)?.statusCode ?? -1 + throw StickerClonerAPIError.invalidResponse(code) + } + + let directory = localURL.deletingLastPathComponent() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + if FileManager.default.fileExists(atPath: localURL.path) { + try FileManager.default.removeItem(at: localURL) + } + try FileManager.default.moveItem(at: tempURL, to: localURL) + } +} diff --git a/StickerCloner/StickerCloner MessagesExtension/StickerStore.swift b/StickerCloner/StickerCloner MessagesExtension/StickerStore.swift new file mode 100644 index 0000000..f91465e --- /dev/null +++ b/StickerCloner/StickerCloner MessagesExtension/StickerStore.swift @@ -0,0 +1,87 @@ +import Foundation +import Messages + +final class StickerStore { + static let shared = StickerStore() + + private let packsKey = "savedPacks" + private let defaults = UserDefaults.standard + private let fileManager = FileManager.default + + private init() {} + + // MARK: - Pack persistence + + var savedPacks: [SavedPack] { + get { + guard let data = defaults.data(forKey: packsKey) else { return [] } + return (try? JSONDecoder().decode([SavedPack].self, from: data)) ?? [] + } + set { + let data = try? JSONEncoder().encode(newValue) + defaults.set(data, forKey: packsKey) + } + } + + // MARK: - File management + + private var stickersDirectory: URL { + let caches = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first! + return caches.appendingPathComponent("stickers", isDirectory: true) + } + + private func packDirectory(name: String) -> URL { + stickersDirectory.appendingPathComponent(name, isDirectory: true) + } + + // MARK: - Add / remove packs + + func addPack(response: StickerSetResponse) async throws { + let pack = SavedPack(name: response.name, title: response.title, stickerCount: response.stickerCount) + + // Download all sticker PNGs + let packDir = packDirectory(name: response.name) + try fileManager.createDirectory(at: packDir, withIntermediateDirectories: true) + + for sticker in response.stickers { + let localURL = packDir.appendingPathComponent("\(sticker.id).png") + if fileManager.fileExists(atPath: localURL.path) { continue } + try await StickerClonerAPI.downloadSticker(from: sticker.pngUrl, to: localURL) + } + + // Save metadata after successful download + var packs = savedPacks + packs.removeAll { $0.name == pack.name } + packs.append(pack) + savedPacks = packs + } + + func removePack(name: String) { + var packs = savedPacks + packs.removeAll { $0.name == name } + savedPacks = packs + + let packDir = packDirectory(name: name) + try? fileManager.removeItem(at: packDir) + } + + // MARK: - Load stickers for display + + func loadStickers() -> [MSSticker] { + var stickers: [MSSticker] = [] + + for pack in savedPacks { + let packDir = packDirectory(name: pack.name) + guard let files = try? fileManager.contentsOfDirectory(at: packDir, includingPropertiesForKeys: nil) else { continue } + + for file in files.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) { + guard file.pathExtension == "png" else { continue } + if let sticker = try? MSSticker(contentsOfFileURL: file, localizedDescription: pack.title) { + stickers.append(sticker) + } + } + } + + return stickers + } +}