diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..b482f87 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,146 @@ +# Plan: standalone iOS app + invisible iMessage sticker extension + +## Goal + +- **iOS app** on the home screen: paste Telegram URL, download packs, preview stickers, toggle individual stickers on/off +- **iMessage extension** is invisible — no custom UI. It serves stickers via `MSStickerBrowserViewController` so they appear in the system sticker picker (emoji keyboard → stickers tab on iOS 17+) +- Data flows from the iOS app → shared App Group container → extension reads it + +## Why the 404 happens + +The backend always calls `manager.getPack()` (Telegram Bot API) before checking its cache. If the bot token is invalid or Telegram is unreachable, it returns `None` → 404, even when sticker files exist on disk. This is a **backend bug** — fix separately. The iOS URL construction is correct. + +## Architecture + +``` +┌─────────────────────────┐ ┌──────────────────────────┐ +│ iOS App (home screen) │ │ iMessage Extension │ +│ │ │ (no custom UI) │ +│ - Paste URL │ │ │ +│ - Download packs │ ──► │ MSStickerBrowserVC │ +│ - Preview stickers │ │ reads shared container │ +│ - Toggle on/off │ │ → stickers appear in │ +│ - Delete packs │ │ system sticker picker │ +└─────────────────────────┘ └──────────────────────────┘ + │ │ + └──────── App Group ───────────────┘ + UserDefaults(suiteName:) + shared Caches/stickers/ +``` + +## File layout after changes + +``` +StickerCloner/ +├── Shared/ ← NEW synced folder, both targets +│ ├── Models.swift moved + updated +│ └── StickerStore.swift moved + rewritten (no Messages import) +│ +├── StickerCloner/ ← iOS app target (auto-synced) +│ ├── Assets.xcassets/ existing +│ ├── StickerClonerApp.swift NEW — SwiftUI @main entry +│ ├── PackListView.swift NEW — list of packs + URL input +│ ├── PackDetailView.swift NEW — sticker grid with toggles +│ └── StickerClonerAPI.swift moved from extension (only app needs it) +│ +├── StickerCloner MessagesExtension/ ← extension target (auto-synced) +│ ├── Assets.xcassets/ existing +│ ├── Base.lproj/MainInterface.storyboard existing +│ ├── Info.plist existing +│ ├── MessagesViewController.swift rewritten (minimal) +│ └── StickerBrowserViewController.swift rewritten (reads shared data) +│ +└── StickerCloner.xcodeproj/ +``` + +## Project file (pbxproj) changes + +1. **Change main target product type**: `com.apple.product-type.application.messages` → `com.apple.product-type.application` +2. **Add Sources build phase** to main target (empty — synced groups handle inclusion) +3. **Add `Shared/` PBXFileSystemSynchronizedRootGroup** to both targets' `fileSystemSynchronizedGroups` +4. **Add App Group entitlements** via `CODE_SIGN_ENTITLEMENTS` build setting on both targets + +## Data model changes + +### Models.swift (Shared/) + +```swift +// API types (unchanged) +struct StickerSetResponse: Codable { ... } +struct StickerResponse: Codable { ... } + +// Local persistence — now tracks enabled stickers +struct SavedPack: Codable { + let name: String + let title: String + let stickerIds: [String] // all sticker IDs in order + var enabledStickerIds: Set // subset the user wants +} +``` + +### StickerStore.swift (Shared/) + +- Uses `UserDefaults(suiteName: "group.de.felixfoertsch.StickerCloner")` instead of `.standard` +- Uses `FileManager.containerURL(forSecurityApplicationGroupIdentifier:)` instead of caches directory +- `stickerFileURLs(for pack:)` returns `[URL]` (not MSSticker — no Messages dependency) +- Returns only enabled stickers + +### StickerClonerAPI.swift (iOS app only) + +Stays in the app target — the extension never calls the API. Unchanged except move location. + +## iOS App views + +### StickerClonerApp.swift +- `@main` SwiftUI app +- Single `PackListView` as root in NavigationStack + +### PackListView.swift +- Text field + "Add" button to paste Telegram URL or pack name +- List of saved packs with swipe-to-delete +- Navigation link to `PackDetailView` per pack +- Loading/error states during download + +### PackDetailView.swift +- Grid of sticker thumbnails (loaded from local PNG files) +- Tap to toggle enabled/disabled (dimmed + checkmark overlay) +- "Select All" / "Deselect All" toolbar buttons +- Pack title in navigation bar + +## Extension changes + +### MessagesViewController.swift +- Hosts `StickerBrowserViewController` as child, fills entire view +- No expanded mode handling, no SwiftUI views +- Reloads stickers on `willBecomeActive` + +### StickerBrowserViewController.swift +- Loads enabled sticker file URLs from `StickerStore` +- Creates `MSSticker` from each URL +- Standard `MSStickerBrowserViewController` data source + +## Entitlements + +Both targets get identical entitlements: +```xml +com.apple.security.application-groups + + group.de.felixfoertsch.StickerCloner + +``` + +## Commits + +1. `convert to standalone iOS app, add shared folder, app group entitlements` +2. `add iOS app ui: pack list, pack detail with sticker filtering` +3. `simplify iMessage extension to read-only sticker browser` + +## Verification + +1. Build in Xcode → no compile errors for both targets +2. iOS app appears on home screen +3. Paste pack URL → downloads (once backend 404 is fixed) +4. Tap pack → see sticker grid, toggle individual stickers +5. Open Messages → stickers appear in sticker picker +6. Only enabled stickers shown +7. Kill app, reopen → state persists via App Group diff --git a/StickerCloner/Shared/StickerStore.swift b/StickerCloner/Shared/StickerStore.swift index 19ebe99..6db556f 100644 --- a/StickerCloner/Shared/StickerStore.swift +++ b/StickerCloner/Shared/StickerStore.swift @@ -1,4 +1,5 @@ import Foundation +import UIKit final class StickerStore { static let shared = StickerStore() @@ -6,6 +7,7 @@ final class StickerStore { private let packsKey = "savedPacks" private let suiteName = "group.de.felixfoertsch.StickerCloner" private let fileManager = FileManager.default + private let thumbnailCache = NSCache() private lazy var defaults: UserDefaults = { guard let defaults = UserDefaults(suiteName: suiteName) else { @@ -66,6 +68,7 @@ final class StickerStore { var packs = savedPacks packs.removeAll { $0.name == name } savedPacks = packs + thumbnailCache.removeObject(forKey: name as NSString) let packDir = packDirectory(name: name) try? fileManager.removeItem(at: packDir) @@ -89,6 +92,14 @@ final class StickerStore { return urls } + func stickerFileURLs(forPack name: String) -> [URL] { + let packDir = packDirectory(name: name) + guard let files = try? fileManager.contentsOfDirectory(at: packDir, includingPropertiesForKeys: nil) else { return [] } + return files + .filter { $0.pathExtension == "png" } + .sorted { $0.lastPathComponent < $1.lastPathComponent } + } + func firstStickerURL(forPack name: String) -> URL? { let packDir = packDirectory(name: name) guard let files = try? fileManager.contentsOfDirectory(at: packDir, includingPropertiesForKeys: nil) else { return nil } @@ -98,6 +109,16 @@ final class StickerStore { .first } + func thumbnail(forPack name: String) -> UIImage? { + let key = name as NSString + if let cached = thumbnailCache.object(forKey: key) { return cached } + guard let url = firstStickerURL(forPack: name), + let data = try? Data(contentsOf: url), + let image = UIImage(data: data) else { return nil } + thumbnailCache.setObject(image, forKey: key) + return image + } + // MARK: - Migration from old sandbox private func migrateIfNeeded() { diff --git a/StickerCloner/StickerCloner MessagesExtension/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/StickerCloner/StickerCloner MessagesExtension/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 0000000..a0d2f66 Binary files /dev/null and b/StickerCloner/StickerCloner MessagesExtension/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/StickerCloner/StickerCloner MessagesExtension/Assets.xcassets/AppIcon.appiconset/Contents.json b/StickerCloner/StickerCloner MessagesExtension/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..cefcc87 --- /dev/null +++ b/StickerCloner/StickerCloner MessagesExtension/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StickerCloner/StickerCloner MessagesExtension/MessagesViewController.swift b/StickerCloner/StickerCloner MessagesExtension/MessagesViewController.swift index efd0056..bcd5cf1 100644 --- a/StickerCloner/StickerCloner MessagesExtension/MessagesViewController.swift +++ b/StickerCloner/StickerCloner MessagesExtension/MessagesViewController.swift @@ -7,7 +7,6 @@ final class MessagesViewController: MSMessagesAppViewController { override func viewDidLoad() { super.viewDidLoad() addStickerBrowser() - stickerBrowser.reloadStickers() } override func willBecomeActive(with conversation: MSConversation) { diff --git a/StickerCloner/StickerCloner MessagesExtension/StickerBrowserViewController.swift b/StickerCloner/StickerCloner MessagesExtension/StickerBrowserViewController.swift index 9a3612d..115dbe1 100644 --- a/StickerCloner/StickerCloner MessagesExtension/StickerBrowserViewController.swift +++ b/StickerCloner/StickerCloner MessagesExtension/StickerBrowserViewController.swift @@ -2,7 +2,23 @@ import Messages import UIKit final class StickerBrowserViewController: MSStickerBrowserViewController { - private var stickers: [MSSticker] = [] + private struct Pack { + let savedPack: SavedPack + let thumbnail: UIImage? + let stickers: [MSSticker] + } + + private var packs: [Pack] = [] + private var selectedPackIndex: Int? // nil = all packs + private var displayedStickers: [MSSticker] = [] + + private var tabCollectionView: UICollectionView! + + private let tabSize: CGFloat = 36 + private let tabSpacing: CGFloat = 6 + private let tabInset: CGFloat = 8 + + // MARK: - Lifecycle init() { super.init(stickerSize: .small) @@ -13,24 +29,196 @@ final class StickerBrowserViewController: MSStickerBrowserViewController { fatalError("init(coder:) is not supported") } + override func viewDidLoad() { + super.viewDidLoad() + setupTabBar() + } + + // MARK: - Data + func reloadStickers() { - let urls = StickerStore.shared.stickerFileURLs() - stickers = urls.compactMap { url in - try? MSSticker( - contentsOfFileURL: url, - localizedDescription: url.deletingLastPathComponent().lastPathComponent - ) + packs = StickerStore.shared.savedPacks + .sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending } + .compactMap { pack in + let urls = StickerStore.shared.stickerFileURLs(forPack: pack.name) + let stickers = urls.compactMap { url in + try? MSSticker(contentsOfFileURL: url, localizedDescription: pack.title) + } + guard !stickers.isEmpty else { return nil } + let thumbnail = StickerStore.shared.thumbnail(forPack: pack.name) + return Pack(savedPack: pack, thumbnail: thumbnail, stickers: stickers) + } + + if let index = selectedPackIndex, index >= packs.count { + selectedPackIndex = nil + } + + updateDisplayedStickers() + tabCollectionView?.reloadData() + } + + private func updateDisplayedStickers() { + if let index = selectedPackIndex { + displayedStickers = packs[index].stickers + } else { + displayedStickers = packs.flatMap(\.stickers) } stickerBrowserView.reloadData() } + // MARK: - Tab bar + + private func setupTabBar() { + let tabBarHeight = tabSize + tabInset * 2 + + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .horizontal + layout.minimumInteritemSpacing = tabSpacing + layout.minimumLineSpacing = tabSpacing + layout.sectionInset = UIEdgeInsets(top: 0, left: tabInset, bottom: 0, right: tabInset) + layout.itemSize = CGSize(width: tabSize, height: tabSize) + + tabCollectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + tabCollectionView.translatesAutoresizingMaskIntoConstraints = false + tabCollectionView.backgroundColor = .secondarySystemBackground + tabCollectionView.showsHorizontalScrollIndicator = false + tabCollectionView.dataSource = self + tabCollectionView.delegate = self + tabCollectionView.register(TabCell.self, forCellWithReuseIdentifier: TabCell.reuseID) + + let separator = UIView() + separator.translatesAutoresizingMaskIntoConstraints = false + separator.backgroundColor = .separator + + view.addSubview(tabCollectionView) + view.addSubview(separator) + + NSLayoutConstraint.activate([ + tabCollectionView.topAnchor.constraint(equalTo: view.topAnchor), + tabCollectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tabCollectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tabCollectionView.heightAnchor.constraint(equalToConstant: tabBarHeight), + + separator.topAnchor.constraint(equalTo: tabCollectionView.bottomAnchor), + separator.leadingAnchor.constraint(equalTo: view.leadingAnchor), + separator.trailingAnchor.constraint(equalTo: view.trailingAnchor), + separator.heightAnchor.constraint(equalToConstant: 1.0 / UIScreen.main.scale), + ]) + + additionalSafeAreaInsets = UIEdgeInsets(top: tabBarHeight, left: 0, bottom: 0, right: 0) + } + + private func selectPack(at index: Int?) { + guard selectedPackIndex != index else { return } + selectedPackIndex = index + updateDisplayedStickers() + tabCollectionView.reloadData() + } + // MARK: - MSStickerBrowserViewDataSource override func numberOfStickers(in stickerBrowserView: MSStickerBrowserView) -> Int { - stickers.count + displayedStickers.count } override func stickerBrowserView(_ stickerBrowserView: MSStickerBrowserView, stickerAt index: Int) -> MSSticker { - stickers[index] + displayedStickers[index] + } +} + +// MARK: - UICollectionViewDataSource + +extension StickerBrowserViewController: UICollectionViewDataSource { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + packs.count + 1 // +1 for "All" tab + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: TabCell.reuseID, for: indexPath) as! TabCell + if indexPath.item == 0 { + let isSelected = selectedPackIndex == nil + cell.configureAsAll(isSelected: isSelected) + } else { + let pack = packs[indexPath.item - 1] + let isSelected = selectedPackIndex == indexPath.item - 1 + cell.configure(image: pack.thumbnail, isSelected: isSelected) + } + return cell + } +} + +// MARK: - UICollectionViewDelegate + +extension StickerBrowserViewController: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + if indexPath.item == 0 { + selectPack(at: nil) + } else { + selectPack(at: indexPath.item - 1) + } + } +} + +// MARK: - TabCell + +private final class TabCell: UICollectionViewCell { + static let reuseID = "TabCell" + + private let imageView = UIImageView() + private let label = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + contentView.layer.cornerRadius = 8 + contentView.clipsToBounds = true + + imageView.contentMode = .scaleAspectFit + imageView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(imageView) + + label.font = .systemFont(ofSize: 11, weight: .medium) + label.textAlignment = .center + label.translatesAutoresizingMaskIntoConstraints = false + label.isHidden = true + contentView.addSubview(label) + + let inset: CGFloat = 4 + NSLayoutConstraint.activate([ + imageView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: inset), + imageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -inset), + imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: inset), + imageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -inset), + + label.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + label.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } + + func configure(image: UIImage?, isSelected: Bool) { + imageView.image = image + imageView.isHidden = false + label.isHidden = true + contentView.backgroundColor = isSelected ? .systemGray5 : .clear + imageView.alpha = isSelected ? 1.0 : 0.5 + } + + func configureAsAll(isSelected: Bool) { + imageView.isHidden = true + label.isHidden = false + label.text = "All" + label.textColor = isSelected ? .label : .secondaryLabel + contentView.backgroundColor = isSelected ? .systemGray5 : .clear + } + + override func prepareForReuse() { + super.prepareForReuse() + imageView.image = nil + imageView.isHidden = false + label.isHidden = true } } diff --git a/StickerCloner/StickerCloner.xcodeproj/project.pbxproj b/StickerCloner/StickerCloner.xcodeproj/project.pbxproj index 8ee59a7..6e9b07d 100644 --- a/StickerCloner/StickerCloner.xcodeproj/project.pbxproj +++ b/StickerCloner/StickerCloner.xcodeproj/project.pbxproj @@ -73,13 +73,6 @@ /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ - 3FD2D01B2F57098E00B76B50 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; 3FD2CFFD2F57098D00B76B50 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -88,6 +81,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 3FD2D01B2F57098E00B76B50 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -226,14 +226,14 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - 3FD2D01A2F57098E00B76B50 /* Sources */ = { + 3FD2CFFC2F57098D00B76B50 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; - 3FD2CFFC2F57098D00B76B50 /* Sources */ = { + 3FD2D01A2F57098E00B76B50 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( @@ -255,6 +255,7 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = "iMessage App Icon"; + ASSETCATALOG_COMPILER_STICKER_ICON_NAME = "iMessage App Icon"; CODE_SIGN_ENTITLEMENTS = "StickerCloner MessagesExtension/StickerClonerMessagesExtension.entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -285,6 +286,7 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = "iMessage App Icon"; + ASSETCATALOG_COMPILER_STICKER_ICON_NAME = "iMessage App Icon"; CODE_SIGN_ENTITLEMENTS = "StickerCloner MessagesExtension/StickerClonerMessagesExtension.entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; diff --git a/StickerCloner/StickerCloner/AddPackView.swift b/StickerCloner/StickerCloner/AddPackView.swift index 679e022..b221500 100644 --- a/StickerCloner/StickerCloner/AddPackView.swift +++ b/StickerCloner/StickerCloner/AddPackView.swift @@ -41,13 +41,30 @@ struct AddPackView: View { Text("Paste a Telegram sticker link or pack name.") } - if !packs.isEmpty { + if packs.isEmpty { + Section { + Button { + urlText = "https://t.me/addstickers/MrBat" + } label: { + HStack(spacing: 12) { + Image(systemName: "star") + .foregroundStyle(.orange) + VStack(alignment: .leading) { + Text("MrBat") + Text("Tap to try this pack") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } header: { + Text("Suggestions") + } + } else { Section("Saved Packs") { ForEach(packs, id: \.name) { pack in HStack(spacing: 12) { - if let url = StickerStore.shared.firstStickerURL(forPack: pack.name), - let data = try? Data(contentsOf: url), - let uiImage = UIImage(data: data) { + if let uiImage = StickerStore.shared.thumbnail(forPack: pack.name) { Image(uiImage: uiImage) .resizable() .aspectRatio(contentMode: .fit)