add data models, api client, sticker store for iMessage extension
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
27
StickerCloner/StickerCloner MessagesExtension/Models.swift
Normal file
27
StickerCloner/StickerCloner MessagesExtension/Models.swift
Normal file
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user