Files
Huang Xin 912e97cb82 feat(send): iOS share-extension picker + App Group queue + reliable host launch (#4267)
* feat(send): iOS share-extension picker + App Group queue + reliable host launch

Rework the iOS Share Extension to a Zotero-style sheet: URL preview row +
library group picker + "Save & Open". Queues each save into the shared
App Group container and best-effort launches the host app via the
Chrome-style responder-chain trick (IMP cast against
`openURL:options:completionHandler:`). The host plugin drains the queue
on `applicationDidBecomeActive`, so if the launch ever fails the article
still ingests next time Readest is opened.

A `WKScriptMessageHandler` named `readestShareBridge` lets the JS hook
post `{type:'ready'}` on mount, fixing the cold-start race when the
extension wakes the app before the React side has loaded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(send): cover generator, favicon fetcher, share-extension polish, locale sync

Builds on the previous commit (iOS share-extension picker + App Group queue +
reliable host launch) with three further additions to the send-to-Readest
pipeline:

* **Cover generator** — `services/send/conversion/coverGenerator.ts` renders a
  deterministic cover image into clipped EPUBs (favicon + page title + host).
  Hooked into `buildEpub.ts` so every clip path (desktop, mobile, browser
  extension) produces the same cover for the same source.
* **Favicon fetcher** — `services/send/conversion/faviconFetcher.ts` resolves
  the best-available site icon (Open Graph image → apple-touch-icon →
  /favicon.ico), with size + format normalization. Feeds the cover generator.
* **Unified page conversion** — `convertToEpub({kind:'page', ...})` replaces
  the older `convertPageToEpub(html, url)` so the share extension, /send page,
  and browser extension share one entry point. Test: `send-convert-page-unified`.
* **Share-extension project.yml comment** — clarifies why the ShareExtension
  target carries no `.lproj` files (system bar buttons + JS-supplied "Default"
  label, no per-locale strings to wire).
* **Locale sync** — 33 translation.json files updated with new "Default",
  "Saving article…", and cover-generator strings extracted by i18next-scanner.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:46:13 +02:00

75 lines
2.4 KiB
Swift

// Mirror of `src-tauri/gen/apple/ShareExtension/AppGroupBridge.swift`. The
// two targets cannot share Swift source via the Xcode project layout we
// use (xcodegen `sources:` blocks scope strictly to each target's
// directory), so the schema is intentionally duplicated. Keep both files
// byte-aligned when changing field names, keys, or encodings.
import Foundation
enum AppGroupBridge {
static let suiteName = "group.com.bilingify.readest"
static let groupsKey = "shareExtensionGroups"
static let defaultGroupNameKey = "shareExtensionDefaultGroupName"
static let pendingSavesKey = "shareExtensionPendingSaves"
static var defaults: UserDefaults? {
UserDefaults(suiteName: suiteName)
}
struct LibraryGroup: Codable, Equatable {
let id: String
let name: String
}
struct PendingSave: Codable, Equatable {
let url: String
let groupId: String?
let groupName: String?
let addedAt: String
}
static func readGroups() -> [LibraryGroup] {
guard let data = defaults?.data(forKey: groupsKey) else { return [] }
return (try? JSONDecoder().decode([LibraryGroup].self, from: data)) ?? []
}
static func writeGroups(_ groups: [LibraryGroup]) {
guard let data = try? JSONEncoder().encode(groups) else { return }
defaults?.set(data, forKey: groupsKey)
}
/// JS side passes the user-locale-translated "Default" label here so the
/// Share Extension's no-group row reads in the user's language without
/// the extension needing its own per-locale strings file.
static func readDefaultGroupName() -> String? {
defaults?.string(forKey: defaultGroupNameKey)
}
static func writeDefaultGroupName(_ name: String) {
defaults?.set(name, forKey: defaultGroupNameKey)
}
static func readPendingSaves() -> [PendingSave] {
guard let data = defaults?.data(forKey: pendingSavesKey) else { return [] }
return (try? JSONDecoder().decode([PendingSave].self, from: data)) ?? []
}
static func appendPendingSave(_ save: PendingSave) {
var saves = readPendingSaves()
saves.append(save)
if let data = try? JSONEncoder().encode(saves) {
defaults?.set(data, forKey: pendingSavesKey)
}
}
static func clearPendingSaves() {
defaults?.removeObject(forKey: pendingSavesKey)
}
static func nowIso8601() -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter.string(from: Date())
}
}