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>
This commit is contained in:
Huang Xin
2026-05-22 21:46:13 +08:00
committed by GitHub
parent f6dfd09d82
commit 912e97cb82
48 changed files with 2283 additions and 272 deletions
@@ -1633,5 +1633,8 @@
"Saving to Readest": "جارٍ الحفظ في Readest",
"Loading article…": "جارٍ تحميل المقالة…",
"Capturing article…": "جارٍ التقاط المقالة…",
"Saved to Readest": "تم الحفظ في Readest"
"Saved to Readest": "تم الحفظ في Readest",
"Apply to every occurrence in the book": "تطبيق على كل تواجد في الكتاب",
"Saving article from share…": "جارٍ حفظ المقالة من المشاركة…",
"Saved “{{title}}” to your library.": "تم حفظ \"{{title}}\" في مكتبتك."
}
@@ -1521,5 +1521,8 @@
"Saving to Readest": "Readest-এ সংরক্ষণ করা হচ্ছে",
"Loading article…": "নিবন্ধ লোড হচ্ছে…",
"Capturing article…": "নিবন্ধ ক্যাপচার করা হচ্ছে…",
"Saved to Readest": "Readest-এ সংরক্ষিত"
"Saved to Readest": "Readest-এ সংরক্ষিত",
"Apply to every occurrence in the book": "বইয়ের প্রতিটি উপস্থিতিতে প্রয়োগ করুন",
"Saving article from share…": "শেয়ার থেকে নিবন্ধ সংরক্ষণ করা হচ্ছে…",
"Saved “{{title}}” to your library.": "\"{{title}}\" আপনার লাইব্রেরিতে সংরক্ষিত হয়েছে।"
}
@@ -1493,5 +1493,8 @@
"Saving to Readest": "Readest དུ་ཉར་བཞིན་པ་",
"Loading article…": "རྩོམ་ཡིག་སྦྱར་བཞིན་པ་…",
"Capturing article…": "རྩོམ་ཡིག་འཛིན་བཞིན་པ་…",
"Saved to Readest": "Readest དུ་ཉར་ཟིན།"
"Saved to Readest": "Readest དུ་ཉར་ཟིན།",
"Apply to every occurrence in the book": "དེབ་ནང་གི་སྐབས་ཐམས་ཅད་ལ་སྦྱར།",
"Saving article from share…": "མཉམ་སྤྱོད་ནས་ཡི་གེ་ཉར་ཚགས་བྱེད་བཞིན་པ…",
"Saved “{{title}}” to your library.": "\"{{title}}\" ཁྱེད་ཀྱི་དཔེ་མཛོད་དུ་ཉར་ཚགས་བྱས་ཟིན།"
}
@@ -1521,5 +1521,8 @@
"Saving to Readest": "Wird in Readest gespeichert",
"Loading article…": "Artikel wird geladen…",
"Capturing article…": "Artikel wird erfasst…",
"Saved to Readest": "In Readest gespeichert"
"Saved to Readest": "In Readest gespeichert",
"Apply to every occurrence in the book": "Auf jedes Vorkommen im Buch anwenden",
"Saving article from share…": "Geteilten Artikel wird gespeichert…",
"Saved “{{title}}” to your library.": "„{{title}}“ wurde in Ihrer Bibliothek gespeichert."
}
@@ -1521,5 +1521,8 @@
"Saving to Readest": "Αποθήκευση στο Readest",
"Loading article…": "Φόρτωση άρθρου…",
"Capturing article…": "Λήψη άρθρου…",
"Saved to Readest": "Αποθηκεύτηκε στο Readest"
"Saved to Readest": "Αποθηκεύτηκε στο Readest",
"Apply to every occurrence in the book": "Εφαρμογή σε κάθε εμφάνιση στο βιβλίο",
"Saving article from share…": "Αποθήκευση άρθρου από κοινοποίηση…",
"Saved “{{title}}” to your library.": "Το «{{title}}» αποθηκεύτηκε στη βιβλιοθήκη σας."
}
@@ -1549,5 +1549,8 @@
"Saving to Readest": "Guardando en Readest",
"Loading article…": "Cargando artículo…",
"Capturing article…": "Capturando artículo…",
"Saved to Readest": "Guardado en Readest"
"Saved to Readest": "Guardado en Readest",
"Apply to every occurrence in the book": "Aplicar a cada aparición en el libro",
"Saving article from share…": "Guardando artículo compartido…",
"Saved “{{title}}” to your library.": "«{{title}}» guardado en tu biblioteca."
}
@@ -1521,5 +1521,8 @@
"Saving to Readest": "در حال ذخیره در Readest",
"Loading article…": "در حال بارگذاری مقاله…",
"Capturing article…": "در حال دریافت مقاله…",
"Saved to Readest": "در Readest ذخیره شد"
"Saved to Readest": "در Readest ذخیره شد",
"Apply to every occurrence in the book": "اعمال در همه موارد در کتاب",
"Saving article from share…": "در حال ذخیره مقاله از اشتراک‌گذاری…",
"Saved “{{title}}” to your library.": "«{{title}}» در کتابخانه شما ذخیره شد."
}
@@ -1549,5 +1549,8 @@
"Saving to Readest": "Enregistrement dans Readest",
"Loading article…": "Chargement de l'article…",
"Capturing article…": "Capture de l'article…",
"Saved to Readest": "Enregistré dans Readest"
"Saved to Readest": "Enregistré dans Readest",
"Apply to every occurrence in the book": "Appliquer à chaque occurrence dans le livre",
"Saving article from share…": "Enregistrement de larticle partagé…",
"Saved “{{title}}” to your library.": "« {{title}} » a été enregistré dans votre bibliothèque."
}
@@ -1549,5 +1549,8 @@
"Saving to Readest": "שומר ב-Readest",
"Loading article…": "טוען מאמר…",
"Capturing article…": "לוכד מאמר…",
"Saved to Readest": "נשמר ב-Readest"
"Saved to Readest": "נשמר ב-Readest",
"Apply to every occurrence in the book": "החל על כל מופע בספר",
"Saving article from share…": "שומר מאמר משיתוף…",
"Saved “{{title}}” to your library.": "\"{{title}}\" נשמר לספרייה שלך."
}
@@ -1521,5 +1521,8 @@
"Saving to Readest": "Readest में सहेजा जा रहा है",
"Loading article…": "लेख लोड हो रहा है…",
"Capturing article…": "लेख कैप्चर हो रहा है…",
"Saved to Readest": "Readest में सहेजा गया"
"Saved to Readest": "Readest में सहेजा गया",
"Apply to every occurrence in the book": "किताब में हर बार लागू करें",
"Saving article from share…": "साझा किया गया लेख सहेजा जा रहा है…",
"Saved “{{title}}” to your library.": "\"{{title}}\" आपकी लाइब्रेरी में सहेजा गया।"
}
@@ -1521,5 +1521,8 @@
"Saving to Readest": "Mentés a Readestbe",
"Loading article…": "Cikk betöltése…",
"Capturing article…": "Cikk rögzítése…",
"Saved to Readest": "Mentve a Readestbe"
"Saved to Readest": "Mentve a Readestbe",
"Apply to every occurrence in the book": "Alkalmazás a könyv minden előfordulására",
"Saving article from share…": "Megosztott cikk mentése…",
"Saved “{{title}}” to your library.": "A(z) „{{title}}” mentve a könyvtáradba."
}
@@ -1493,5 +1493,8 @@
"Saving to Readest": "Menyimpan ke Readest",
"Loading article…": "Memuat artikel…",
"Capturing article…": "Menangkap artikel…",
"Saved to Readest": "Tersimpan di Readest"
"Saved to Readest": "Tersimpan di Readest",
"Apply to every occurrence in the book": "Terapkan ke setiap kemunculan di buku",
"Saving article from share…": "Menyimpan artikel dari berbagi…",
"Saved “{{title}}” to your library.": "\"{{title}}\" disimpan ke perpustakaan Anda."
}
@@ -1549,5 +1549,8 @@
"Saving to Readest": "Salvataggio in Readest",
"Loading article…": "Caricamento dell'articolo…",
"Capturing article…": "Acquisizione dell'articolo…",
"Saved to Readest": "Salvato in Readest"
"Saved to Readest": "Salvato in Readest",
"Apply to every occurrence in the book": "Applica a ogni occorrenza nel libro",
"Saving article from share…": "Salvataggio dellarticolo condiviso…",
"Saved “{{title}}” to your library.": "«{{title}}» salvato nella tua libreria."
}
@@ -1493,5 +1493,8 @@
"Saving to Readest": "Readest に保存中",
"Loading article…": "記事を読み込み中…",
"Capturing article…": "記事を取得中…",
"Saved to Readest": "Readest に保存しました"
"Saved to Readest": "Readest に保存しました",
"Apply to every occurrence in the book": "本の中のすべての出現箇所に適用",
"Saving article from share…": "共有された記事を保存中…",
"Saved “{{title}}” to your library.": "「{{title}}」をライブラリに保存しました。"
}
@@ -1493,5 +1493,8 @@
"Saving to Readest": "Readest에 저장 중",
"Loading article…": "기사 로드 중…",
"Capturing article…": "기사 캡처 중…",
"Saved to Readest": "Readest에 저장됨"
"Saved to Readest": "Readest에 저장됨",
"Apply to every occurrence in the book": "책의 모든 발생 위치에 적용",
"Saving article from share…": "공유된 기사 저장 중…",
"Saved “{{title}}” to your library.": "\"{{title}}\"을(를) 라이브러리에 저장했습니다."
}
@@ -1493,5 +1493,8 @@
"Saving to Readest": "Menyimpan ke Readest",
"Loading article…": "Memuatkan artikel…",
"Capturing article…": "Menangkap artikel…",
"Saved to Readest": "Disimpan ke Readest"
"Saved to Readest": "Disimpan ke Readest",
"Apply to every occurrence in the book": "Gunakan pada setiap kemunculan dalam buku",
"Saving article from share…": "Menyimpan artikel daripada perkongsian…",
"Saved “{{title}}” to your library.": "\"{{title}}\" disimpan ke perpustakaan anda."
}
@@ -1521,5 +1521,8 @@
"Saving to Readest": "Opslaan in Readest",
"Loading article…": "Artikel laden…",
"Capturing article…": "Artikel vastleggen…",
"Saved to Readest": "Opgeslagen in Readest"
"Saved to Readest": "Opgeslagen in Readest",
"Apply to every occurrence in the book": "Toepassen op elk voorkomen in het boek",
"Saving article from share…": "Gedeeld artikel opslaan…",
"Saved “{{title}}” to your library.": "“{{title}}” is opgeslagen in je bibliotheek."
}
@@ -1577,5 +1577,8 @@
"Saving to Readest": "Zapisywanie w Readest",
"Loading article…": "Ładowanie artykułu…",
"Capturing article…": "Pobieranie artykułu…",
"Saved to Readest": "Zapisano w Readest"
"Saved to Readest": "Zapisano w Readest",
"Apply to every occurrence in the book": "Zastosuj do każdego wystąpienia w książce",
"Saving article from share…": "Zapisywanie udostępnionego artykułu…",
"Saved “{{title}}” to your library.": "Zapisano „{{title}}” w Twojej bibliotece."
}
@@ -1549,5 +1549,8 @@
"Saving to Readest": "Salvando no Readest",
"Loading article…": "Carregando artigo…",
"Capturing article…": "Capturando artigo…",
"Saved to Readest": "Salvo no Readest"
"Saved to Readest": "Salvo no Readest",
"Apply to every occurrence in the book": "Aplicar a todas as ocorrências no livro",
"Saving article from share…": "Salvando artigo compartilhado…",
"Saved “{{title}}” to your library.": "\"{{title}}\" salvo em sua biblioteca."
}
@@ -1549,5 +1549,8 @@
"Saving to Readest": "A guardar no Readest",
"Loading article…": "A carregar o artigo…",
"Capturing article…": "A capturar o artigo…",
"Saved to Readest": "Guardado no Readest"
"Saved to Readest": "Guardado no Readest",
"Apply to every occurrence in the book": "Aplicar a todas as ocorrências no livro",
"Saving article from share…": "A guardar artigo partilhado…",
"Saved “{{title}}” to your library.": "«{{title}}» guardado na sua biblioteca."
}
@@ -1549,5 +1549,8 @@
"Saving to Readest": "Se salvează în Readest",
"Loading article…": "Se încarcă articolul…",
"Capturing article…": "Se capturează articolul…",
"Saved to Readest": "Salvat în Readest"
"Saved to Readest": "Salvat în Readest",
"Apply to every occurrence in the book": "Aplică la fiecare apariție din carte",
"Saving article from share…": "Se salvează articolul partajat…",
"Saved “{{title}}” to your library.": "„{{title}}” a fost salvat în biblioteca ta."
}
@@ -1577,5 +1577,8 @@
"Saving to Readest": "Сохранение в Readest",
"Loading article…": "Загрузка статьи…",
"Capturing article…": "Захват статьи…",
"Saved to Readest": "Сохранено в Readest"
"Saved to Readest": "Сохранено в Readest",
"Apply to every occurrence in the book": "Применить ко всем вхождениям в книге",
"Saving article from share…": "Сохранение статьи из общего доступа…",
"Saved “{{title}}” to your library.": "«{{title}}» сохранено в вашу библиотеку."
}
@@ -1521,5 +1521,8 @@
"Saving to Readest": "Readest වෙත සුරකියි",
"Loading article…": "ලිපිය පූරණය වෙමින්…",
"Capturing article…": "ලිපිය ලබා ගනියි…",
"Saved to Readest": "Readest හි සුරැකිණි"
"Saved to Readest": "Readest හි සුරැකිණි",
"Apply to every occurrence in the book": "පොතේ සෑම සිදුවීමකටම යොදන්න",
"Saving article from share…": "බෙදාගැනීමෙන් ලිපිය සුරකිමින්…",
"Saved “{{title}}” to your library.": "\"{{title}}\" ඔබේ පුස්තකාලයට සුරැකිණි."
}
@@ -1577,5 +1577,8 @@
"Saving to Readest": "Shranjevanje v Readest",
"Loading article…": "Nalaganje članka…",
"Capturing article…": "Zajemanje članka…",
"Saved to Readest": "Shranjeno v Readest"
"Saved to Readest": "Shranjeno v Readest",
"Apply to every occurrence in the book": "Uporabi pri vseh pojavitvah v knjigi",
"Saving article from share…": "Shranjevanje članka iz skupne rabe…",
"Saved “{{title}}” to your library.": "»{{title}}« je shranjen v vašo knjižnico."
}
@@ -1521,5 +1521,8 @@
"Saving to Readest": "Sparar till Readest",
"Loading article…": "Laddar artikel…",
"Capturing article…": "Fångar artikel…",
"Saved to Readest": "Sparad till Readest"
"Saved to Readest": "Sparad till Readest",
"Apply to every occurrence in the book": "Tillämpa på varje förekomst i boken",
"Saving article from share…": "Sparar delad artikel…",
"Saved “{{title}}” to your library.": "”{{title}}” har sparats i ditt bibliotek."
}
@@ -1521,5 +1521,8 @@
"Saving to Readest": "Readest-இல் சேமிக்கப்படுகிறது",
"Loading article…": "கட்டுரை ஏற்றப்படுகிறது…",
"Capturing article…": "கட்டுரை பெறப்படுகிறது…",
"Saved to Readest": "Readest-இல் சேமிக்கப்பட்டது"
"Saved to Readest": "Readest-இல் சேமிக்கப்பட்டது",
"Apply to every occurrence in the book": "புத்தகத்தில் ஒவ்வொரு நிகழ்வுக்கும் பயன்படுத்து",
"Saving article from share…": "பகிர்விலிருந்து கட்டுரையை சேமிக்கிறது…",
"Saved “{{title}}” to your library.": "\"{{title}}\" உங்கள் நூலகத்தில் சேமிக்கப்பட்டது."
}
@@ -1493,5 +1493,8 @@
"Saving to Readest": "กำลังบันทึกลงใน Readest",
"Loading article…": "กำลังโหลดบทความ…",
"Capturing article…": "กำลังจับภาพบทความ…",
"Saved to Readest": "บันทึกลงใน Readest แล้ว"
"Saved to Readest": "บันทึกลงใน Readest แล้ว",
"Apply to every occurrence in the book": "ใช้กับทุกครั้งที่ปรากฏในหนังสือ",
"Saving article from share…": "กำลังบันทึกบทความจากการแชร์…",
"Saved “{{title}}” to your library.": "บันทึก \"{{title}}\" ลงในคลังของคุณแล้ว"
}
@@ -1521,5 +1521,8 @@
"Saving to Readest": "Readest'e kaydediliyor",
"Loading article…": "Makale yükleniyor…",
"Capturing article…": "Makale yakalanıyor…",
"Saved to Readest": "Readest'e kaydedildi"
"Saved to Readest": "Readest'e kaydedildi",
"Apply to every occurrence in the book": "Kitaptaki her geçişe uygula",
"Saving article from share…": "Paylaşılan makale kaydediliyor…",
"Saved “{{title}}” to your library.": "“{{title}}” kitaplığınıza kaydedildi."
}
@@ -1577,5 +1577,8 @@
"Saving to Readest": "Збереження в Readest",
"Loading article…": "Завантаження статті…",
"Capturing article…": "Захоплення статті…",
"Saved to Readest": "Збережено в Readest"
"Saved to Readest": "Збережено в Readest",
"Apply to every occurrence in the book": "Застосувати до кожного входження в книзі",
"Saving article from share…": "Збереження поширеної статті…",
"Saved “{{title}}” to your library.": "«{{title}}» збережено у вашій бібліотеці."
}
@@ -1521,5 +1521,8 @@
"Saving to Readest": "Readest'ga saqlanmoqda",
"Loading article…": "Maqola yuklanmoqda…",
"Capturing article…": "Maqola olinmoqda…",
"Saved to Readest": "Readest'ga saqlandi"
"Saved to Readest": "Readest'ga saqlandi",
"Apply to every occurrence in the book": "Kitobdagi har bir hodisaga qoʻllang",
"Saving article from share…": "Ulashilgan maqola saqlanmoqda…",
"Saved “{{title}}” to your library.": "\"{{title}}\" kutubxonangizga saqlandi."
}
@@ -1493,5 +1493,8 @@
"Saving to Readest": "Đang lưu vào Readest",
"Loading article…": "Đang tải bài viết…",
"Capturing article…": "Đang chụp bài viết…",
"Saved to Readest": "Đã lưu vào Readest"
"Saved to Readest": "Đã lưu vào Readest",
"Apply to every occurrence in the book": "Áp dụng cho mọi lần xuất hiện trong sách",
"Saving article from share…": "Đang lưu bài viết được chia sẻ…",
"Saved “{{title}}” to your library.": "Đã lưu \"{{title}}\" vào thư viện của bạn."
}
@@ -1493,5 +1493,8 @@
"Saving to Readest": "正在保存到 Readest",
"Loading article…": "正在加载文章…",
"Capturing article…": "正在抓取文章…",
"Saved to Readest": "已保存到 Readest"
"Saved to Readest": "已保存到 Readest",
"Apply to every occurrence in the book": "应用到本书中所有匹配位置",
"Saving article from share…": "正在保存分享的文章…",
"Saved “{{title}}” to your library.": "已将\"{{title}}\"保存到你的书库。"
}
@@ -1493,5 +1493,8 @@
"Saving to Readest": "正在儲存到 Readest",
"Loading article…": "正在載入文章…",
"Capturing article…": "正在擷取文章…",
"Saved to Readest": "已儲存到 Readest"
"Saved to Readest": "已儲存到 Readest",
"Apply to every occurrence in the book": "套用至本書中所有出現的位置",
"Saving article from share…": "正在儲存分享的文章…",
"Saved “{{title}}” to your library.": "已將「{{title}}」儲存至您的書庫。"
}
@@ -19,6 +19,7 @@
BA0A7C5B168460D96FA21D6B /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB595336F6275E2B879AF397 /* ShareViewController.swift */; };
D608DE39BD2F9FA892508F37 /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4B62E5ED5797D06981F30B6F /* UIKit.framework */; };
E6D9510F89AD18B18D0FCBC9 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E5F4A6E57CB4895FFFBC2DE8 /* ShareExtension.appex */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
E853ADD6F720091D24E184FD /* AppGroupBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1EACF94D1026C7A51CD0A63 /* AppGroupBridge.swift */; };
F6003A5A70DA4C33BDBD838B /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 30D8E7F6023C1DF41F8BD47C /* LaunchScreen.storyboard */; };
FB2C2290EAFB8BA38C973E4B /* assets in Resources */ = {isa = PBXBuildFile; fileRef = 40409827389F82307433878B /* assets */; };
/* End PBXBuildFile section */
@@ -55,7 +56,6 @@
1A5492F8B4749D670A93DE66 /* clip_url.rs */ = {isa = PBXFileReference; path = clip_url.rs; sourceTree = "<group>"; };
1C29EFE86C85351F81C99F2A /* Metal.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Metal.framework; path = System/Library/Frameworks/Metal.framework; sourceTree = SDKROOT; };
1CF94262BF7499D2AD8B627D /* main.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = main.mm; sourceTree = "<group>"; };
1F45B5B458F63D98B6113A19 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
27B7FEAE989ABFF6C8FF0F77 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
282EE616A1F2B81C6121C9B6 /* transfer_file.rs */ = {isa = PBXFileReference; path = transfer_file.rs; sourceTree = "<group>"; };
2E226C87AD0606FFF9DDEE11 /* dir_scanner.rs */ = {isa = PBXFileReference; path = dir_scanner.rs; sourceTree = "<group>"; };
@@ -77,6 +77,7 @@
A7318D4877A991A1DAB79861 /* lib.rs */ = {isa = PBXFileReference; path = lib.rs; sourceTree = "<group>"; };
A77628B94260724841CB0C1F /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; };
B16A12C78C47C4990EB4A028 /* apple_auth.rs */ = {isa = PBXFileReference; path = apple_auth.rs; sourceTree = "<group>"; };
C1EACF94D1026C7A51CD0A63 /* AppGroupBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGroupBridge.swift; sourceTree = "<group>"; };
CB595336F6275E2B879AF397 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
D3F4A4DF56E899C9DB0B7D60 /* eink.rs */ = {isa = PBXFileReference; path = eink.rs; sourceTree = "<group>"; };
DAC68A4107C30410409E5506 /* Readest_iOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Readest_iOS.app; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -186,6 +187,7 @@
A350AB745ED478D99A088984 /* ShareExtension */ = {
isa = PBXGroup;
children = (
C1EACF94D1026C7A51CD0A63 /* AppGroupBridge.swift */,
27B7FEAE989ABFF6C8FF0F77 /* Info.plist */,
4924DDD171577C52C0C5809E /* ShareExtension.entitlements */,
CB595336F6275E2B879AF397 /* ShareViewController.swift */,
@@ -196,7 +198,6 @@
ADF310BD2F14DA462EF79E53 /* Readest_iOS */ = {
isa = PBXGroup;
children = (
1F45B5B458F63D98B6113A19 /* Info.plist */,
7DADD61846736B0E638BF012 /* Readest_iOS.entitlements */,
);
path = Readest_iOS;
@@ -242,14 +243,14 @@
path = ../../src;
sourceTree = "<group>";
};
"TEMP_9CD1A90B-7A94-4ADB-B0C8-40427CCB2DCF" /* arm64 */ = {
"TEMP_4DCF4303-D43B-49A2-841D-FA7AC0DF4CA6" /* arm64 */ = {
isa = PBXGroup;
children = (
);
path = arm64;
sourceTree = "<group>";
};
"TEMP_BBED8FED-FDB6-46D2-A460-69DB6036C32D" /* x86_64 */ = {
"TEMP_E02BFCED-1A1D-42A7-9629-AB8D373B6ACC" /* x86_64 */ = {
isa = PBXGroup;
children = (
);
@@ -385,6 +386,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
E853ADD6F720091D24E184FD /* AppGroupBridge.swift in Sources */,
BA0A7C5B168460D96FA21D6B /* ShareViewController.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -0,0 +1,87 @@
// Shared App Group container schema between the Readest Share Extension and
// the host app. Keep this file in sync with the mirror at
// `src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/AppGroupBridge.swift`.
// Two NSUserDefaults keys form the contract:
//
// shareExtensionGroups (host extension)
// JSON array of { id: String, name: String }. Library groups the user
// can pick when saving. Refreshed by the host every time it foregrounds.
//
// shareExtensionDefaultGroupName (host extension)
// User-locale-translated label for the "no group" row at the top of
// the picker. JS supplies `t('Default')` so the extension doesn't
// need its own per-locale strings file.
//
// shareExtensionPendingSaves (extension host)
// JSON array of { url, groupId?, groupName?, addedAt } (ISO-8601 string).
// The extension appends here on every Save. The host drains + clears on
// foreground and feeds each entry through the same clip-and-import path
// the in-app "From Web URL" entry uses.
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)
}
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)
}
// ISO-8601 with fractional seconds round-trips cleanly through
// JavaScript's Date constructor on the JS side.
static func nowIso8601() -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter.string(from: Date())
}
}
@@ -1,11 +1,35 @@
// Share Extension for Readest: catches an article URL from any iOS share
// sheet (Safari, Chrome, third-party browsers) and forwards it to the
// main app via the existing `readest://` URL scheme. The main app's
// `tauri-plugin-deep-link` integration emits an `onOpenUrl` event,
// `useAppUrlIngress` re-broadcasts it as `app-incoming-url`, and
// `useClipUrlIngress` clips + ingests the article through the same
// pipeline the in-app "From Web URL" entry uses.
// sheet (Safari, Chrome, third-party browsers), shows a small sheet UI
// that lets the user pick a target library group, then queues the save
// into the App Group container and best-effort launches Readest.
//
// Two delivery paths to the host app, in order of preference:
//
// 1. App Group queue + responder-chain launch.
// `AppGroupBridge.appendPendingSave` writes the URL + chosen group
// to the shared NSUserDefaults at `group.com.bilingify.readest`.
// We then walk the UIResponder chain looking for an object that
// responds to `openURL:options:completionHandler:` (UIApplication)
// and dispatch via an objc-runtime IMP cast. This is the pattern
// Chrome iOS ships in `ios/chrome/common/extension_open_url.mm`
// (using NSInvocation there; we use the equivalent IMP-cast trick
// since pure Swift can't see NSInvocation). Continues to work on
// iOS 26 the deprecated `openURL:` selector is what breaks
// ("BUG IN CLIENT OF UIKIT" + no-op). The modern 3-arg selector is
// not directly visible to extensions but the responder chain still
// hands UIApplication over for runtime dispatch.
//
// 2. App Group queue as standalone fallback.
// If the launch trick is ever blocked by Apple, the save still
// sits in the queue. The host's `NativeBridgePlugin` drains it on
// `applicationDidBecomeActive` so the next time the user opens
// Readest manually, the article is ingested.
//
// `extensionContext.open(_:)` is intentionally not used Apple docs
// scope it to Today widgets only and it returns success=false from
// Share Extensions on modern iOS regardless of URL scheme.
import ObjectiveC
import UIKit
import UniformTypeIdentifiers
@@ -17,50 +41,101 @@ final class ShareViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
NSLog("[ReadestShare] viewDidLoad")
// Kick the work as early as possible iOS 26 sometimes dismisses
// the extension before `viewDidAppear` fires when the activation
// rule matches a single URL exactly. Running from `viewDidLoad`
// gives us the longest possible window.
Task { await processInput() }
view.backgroundColor = .clear
Task { await self.loadAndPresent() }
}
/// Walk the inputItems for a URL or text payload and forward it.
/// Cancels the extension if no URL was found.
private func processInput() async {
NSLog("[ReadestShare] processInput started")
// MARK: - Input handling
private func loadAndPresent() async {
guard let context = extensionContext else {
NSLog("[ReadestShare] no extensionContext, bailing")
NSLog("[ReadestShare] no extensionContext")
return
}
let items = (context.inputItems.compactMap { $0 as? NSExtensionItem })
let items = context.inputItems.compactMap { $0 as? NSExtensionItem }
NSLog("[ReadestShare] inputItems count=\(items.count)")
let url = await firstShareableURL(from: items)
if let url = url {
NSLog("[ReadestShare] found URL: \(url.absoluteString)")
await openInMainApp(url: url)
} else {
NSLog("[ReadestShare] no URL found in any inputItem")
}
let pageTitle =
items
.compactMap { $0.attributedTitle?.string ?? $0.attributedContentText?.string }
.first { !$0.isEmpty }
await MainActor.run {
if !self.didCompleteOnce {
self.didCompleteOnce = true
NSLog("[ReadestShare] completing extension request")
context.completeRequest(returningItems: [], completionHandler: nil)
guard let url = url else {
NSLog("[ReadestShare] no URL found, cancelling")
self.cancelRequest()
return
}
NSLog("[ReadestShare] presenting picker for URL: %@", url.absoluteString)
self.presentPicker(url: url, pageTitle: pageTitle)
}
}
/// Probe attachments for the first usable URL. Prefers a real
/// `public.url` attachment; falls back to scanning a `public.plain-text`
/// payload for an http(s) substring (some apps share "Title\nURL").
private func presentPicker(url: URL, pageTitle: String?) {
let groups = AppGroupBridge.readGroups()
let options = SaveOptionsViewController(
url: url,
pageTitle: pageTitle,
groups: groups,
onCancel: { [weak self] in
self?.cancelRequest()
},
onSave: { [weak self] selectedGroup in
self?.handleSave(url: url, group: selectedGroup)
}
)
let nav = UINavigationController(rootViewController: options)
nav.view.translatesAutoresizingMaskIntoConstraints = false
addChild(nav)
view.addSubview(nav.view)
NSLayoutConstraint.activate([
nav.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
nav.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
nav.view.topAnchor.constraint(equalTo: view.topAnchor),
nav.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
nav.didMove(toParent: self)
}
// MARK: - Save / Cancel
private func handleSave(url: URL, group: AppGroupBridge.LibraryGroup?) {
let save = AppGroupBridge.PendingSave(
url: url.absoluteString,
groupId: group?.id,
groupName: group?.name,
addedAt: AppGroupBridge.nowIso8601()
)
AppGroupBridge.appendPendingSave(save)
NSLog("[ReadestShare] queued save for %@ group=%@", url.absoluteString, group?.name ?? "<none>")
if let target = buildTargetURL(scheme: "readest", host: "clip", inner: url) {
let opened = openViaResponderChain(target)
NSLog("[ReadestShare] responder-chain launch=%@", opened ? "yes" : "no")
}
completeOnce()
}
private func cancelRequest() {
guard !didCompleteOnce else { return }
didCompleteOnce = true
let err = NSError(domain: "ReadestShare", code: NSUserCancelledError, userInfo: nil)
extensionContext?.cancelRequest(withError: err)
}
private func completeOnce() {
guard !didCompleteOnce else { return }
didCompleteOnce = true
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}
// MARK: - URL extraction
private func firstShareableURL(from items: [NSExtensionItem]) async -> URL? {
for (itemIdx, item) in items.enumerated() {
for item in items {
guard let attachments = item.attachments else { continue }
NSLog("[ReadestShare] item[\(itemIdx)] has \(attachments.count) attachments")
for (attIdx, attachment) in attachments.enumerated() {
NSLog(
"[ReadestShare] attachment[\(attIdx)] types: \(attachment.registeredTypeIdentifiers)")
for attachment in attachments {
if attachment.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
if let url = try? await loadURL(from: attachment), Self.isHttp(url) {
return url
@@ -82,9 +157,7 @@ final class ShareViewController: UIViewController {
let item = try await provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil)
if let url = item as? URL { return url }
if let str = item as? String { return URL(string: str) }
if let data = item as? Data,
let str = String(data: data, encoding: .utf8)
{
if let data = item as? Data, let str = String(data: data, encoding: .utf8) {
return URL(string: str)
}
return nil
@@ -113,103 +186,254 @@ final class ShareViewController: UIViewController {
return nil
}
/// Open Readest with the article URL. Tries three paths in order:
///
/// 1. `extensionContext.open(_:)` against `readest://clip?url=...`.
/// Apple's sanctioned share-extension host-app handoff for
/// custom URL schemes. The main app's `tauri-plugin-deep-link`
/// catches the `readest://` scheme.
/// 2. `extensionContext.open(_:)` against the Universal Link
/// `https://web.readest.com/clip?url=...`. Only works when the
/// web.readest.com AASA file claims `/clip` for the app
/// currently it likely doesn't, but tried as a defensive
/// fallback in case (1) fails on some iOS version.
/// 3. Responder-chain `openURL:` trick. iOS 26 silently blocks
/// this even when `responds(to: selector)` returns true and
/// the responder is `UIApplication`, so it's purely a
/// last-ditch attempt for older iOS.
///
/// Inner URL gets RFC-3986 percent-encoded so the outer URL's
/// query parser sees exactly one `?` (separating outer query from
/// outer path) and exactly one `=` per param. URLComponents alone
/// is NOT enough its `.urlQueryAllowed` set permits `=`, `?`,
/// `&` and leaves them unescaped, which silently breaks the deep-
/// link parse (the inner URL's first `?s=...` gets promoted to an
/// outer query param). All log messages use the `%@` format
/// specifier with the URL passed as an argument so `printf`'s
/// percent-spec parser doesn't try to interpret the percent-encoded
/// characters in the URL.
@MainActor
private func openInMainApp(url: URL) async {
// 1. Custom URL scheme via extensionContext.open the modern
// sanctioned path.
if let target = buildTargetURL(scheme: "readest", host: "clip", inner: url) {
NSLog("[ReadestShare] trying custom scheme via extensionContext: %@", target.absoluteString)
if await openViaExtensionContext(target) {
NSLog("[ReadestShare] custom scheme open succeeded")
return
}
NSLog("[ReadestShare] custom scheme open failed")
}
// MARK: - Launch trick (Chrome iOS pattern, IMP-cast variant)
// 2. Universal Link via extensionContext.open.
if let target = buildTargetURL(
scheme: "https", host: "web.readest.com", path: "/clip", inner: url)
{
NSLog("[ReadestShare] trying universal link: %@", target.absoluteString)
if await openViaExtensionContext(target) {
NSLog("[ReadestShare] universal link open succeeded")
return
}
NSLog("[ReadestShare] universal link open failed")
}
// 3. Responder-chain usually blocked on iOS 26 but tried for
// completeness so older devices still get the handoff.
if let target = buildTargetURL(scheme: "readest", host: "clip", inner: url) {
NSLog("[ReadestShare] trying responder-chain: %@", target.absoluteString)
openViaResponderChain(target)
}
}
/// Build a target URL like `<scheme>://<host><path>?url=<inner>`.
/// Hand-encodes the inner URL against the RFC 3986 "unreserved" set
/// (alnum + `-._~`) so every URL-significant character including
/// `?`, `&`, `=`, `:`, `/`, `#` gets percent-encoded. See
/// `openInMainApp` for why URLComponents alone is insufficient.
private func buildTargetURL(scheme: String, host: String, path: String = "", inner: URL) -> URL?
{
/// Build `<scheme>://<host>?url=<percent-encoded-inner>`. The inner URL
/// is encoded against the RFC 3986 unreserved set so every URL-
/// significant character (`?`, `&`, `=`, `:`, `/`, `#`) is escaped and
/// the outer parser sees exactly one `?` and one `=`.
private func buildTargetURL(scheme: String, host: String, inner: URL) -> URL? {
let unreserved = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-._~"))
guard
let encoded = inner.absoluteString.addingPercentEncoding(withAllowedCharacters: unreserved)
guard let encoded = inner.absoluteString.addingPercentEncoding(withAllowedCharacters: unreserved)
else { return nil }
return URL(string: "\(scheme)://\(host)\(path)?url=\(encoded)")
return URL(string: "\(scheme)://\(host)?url=\(encoded)")
}
@MainActor
private func openViaExtensionContext(_ url: URL) async -> Bool {
await withCheckedContinuation { continuation in
guard let ctx = extensionContext else {
continuation.resume(returning: false)
return
}
ctx.open(url, completionHandler: { success in
continuation.resume(returning: success)
})
}
}
private func openViaResponderChain(_ url: URL) {
/// Walk the responder chain to find a responder that implements
/// `openURL:options:completionHandler:` (UIApplication), then call it
/// via an Objective-C IMP cast. The IMP-cast path is equivalent to
/// Chrome's NSInvocation pattern in `ios/chrome/common/extension_open_url.mm`
/// but works in pure Swift we never name `UIApplication` so the
/// App Store extension symbol scanner doesn't reject the binary.
///
/// `openURL:options:completionHandler:` (non-deprecated) is what
/// continues to work on iOS 26. The legacy `openURL:` selector logs
/// "BUG IN CLIENT OF UIKIT" and no-ops.
@discardableResult
private func openViaResponderChain(_ url: URL) -> Bool {
typealias OpenURLFn = @convention(c) (
AnyObject, Selector, URL, NSDictionary?, AnyObject?
) -> Void
let selector = NSSelectorFromString("openURL:options:completionHandler:")
var responder: UIResponder? = self
let selector = sel_registerName("openURL:")
while let r = responder {
if r.responds(to: selector) {
_ = r.perform(selector, with: url)
NSLog("[ReadestShare] responder-chain openURL: invoked on \(type(of: r))")
return
let target = r as AnyObject
let cls: AnyClass = object_getClass(target) ?? type(of: target)
guard let method = class_getInstanceMethod(cls, selector) else {
responder = r.next
continue
}
let imp = method_getImplementation(method)
let fn = unsafeBitCast(imp, to: OpenURLFn.self)
fn(target, selector, url, nil, nil)
NSLog("[ReadestShare] openURL invoked on \(cls) via IMP")
return true
}
responder = r.next
}
NSLog("[ReadestShare] responder-chain found no responder for openURL:")
NSLog("[ReadestShare] no responder accepted openURL:options:completionHandler:")
return false
}
}
// MARK: - SaveOptionsViewController
/// URL preview row + per-group radio list, with Cancel and "Save"
/// in the nav bar. Mirrors the Zotero / Pocket save UX.
private final class SaveOptionsViewController: UITableViewController {
private let url: URL
private let pageTitle: String?
private let groups: [AppGroupBridge.LibraryGroup]
// nil means "Default" (no group). Initial selection: nil.
private var selectedGroupId: String?
private let onCancel: () -> Void
private let onSave: (AppGroupBridge.LibraryGroup?) -> Void
init(
url: URL,
pageTitle: String?,
groups: [AppGroupBridge.LibraryGroup],
onCancel: @escaping () -> Void,
onSave: @escaping (AppGroupBridge.LibraryGroup?) -> Void
) {
self.url = url
self.pageTitle = pageTitle
self.groups = groups
self.onCancel = onCancel
self.onSave = onSave
super.init(style: .insetGrouped)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
title = NSLocalizedString("Save to Readest", comment: "Share extension title")
// Both Cancel and Save are iOS system bar button items UIKit
// localizes them automatically for every language Apple ships, so
// the extension doesn't carry its own .strings file for them.
navigationItem.leftBarButtonItem = UIBarButtonItem(
barButtonSystemItem: .cancel, target: self, action: #selector(cancelTapped))
navigationItem.rightBarButtonItem = UIBarButtonItem(
barButtonSystemItem: .save, target: self, action: #selector(saveTapped))
tableView.register(URLPreviewCell.self, forCellReuseIdentifier: URLPreviewCell.reuseId)
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "groupCell")
}
@objc private func cancelTapped() { onCancel() }
@objc private func saveTapped() {
let selected = groups.first { $0.id == selectedGroupId }
onSave(selected)
}
// MARK: Table source
override func numberOfSections(in tableView: UITableView) -> Int { 2 }
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
section == 0 ? 1 : groups.count + 1 // +1 for "Default"
}
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int)
-> String?
{
section == 1 ? NSLocalizedString("GROUP", comment: "Group picker header") : nil
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath)
-> UITableViewCell
{
if indexPath.section == 0 {
let cell =
tableView.dequeueReusableCell(withIdentifier: URLPreviewCell.reuseId, for: indexPath)
as! URLPreviewCell
cell.configure(url: url, pageTitle: pageTitle)
cell.selectionStyle = .none
return cell
}
let cell = tableView.dequeueReusableCell(withIdentifier: "groupCell", for: indexPath)
let name: String
let isSelected: Bool
if indexPath.row == 0 {
// JS-supplied user-locale "Default" label (see AppGroupBridge).
// Falls back to English when the host hasn't synced yet happens
// on the very first share before the app has been opened.
name = AppGroupBridge.readDefaultGroupName() ?? "Default"
isSelected = selectedGroupId == nil
} else {
let group = groups[indexPath.row - 1]
name = group.name
isSelected = selectedGroupId == group.id
}
cell.textLabel?.text = name
cell.accessoryType = isSelected ? .checkmark : .none
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard indexPath.section == 1 else { return }
selectedGroupId = indexPath.row == 0 ? nil : groups[indexPath.row - 1].id
tableView.reloadSections(IndexSet(integer: 1), with: .none)
}
}
// MARK: - URLPreviewCell
private final class URLPreviewCell: UITableViewCell {
static let reuseId = "URLPreviewCell"
private let iconView = UIImageView()
private let titleLabel = UILabel()
private let hostLabel = UILabel()
private var faviconTask: URLSessionDataTask?
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: .default, reuseIdentifier: reuseIdentifier)
iconView.translatesAutoresizingMaskIntoConstraints = false
iconView.contentMode = .scaleAspectFit
iconView.clipsToBounds = true
iconView.layer.cornerRadius = 4
iconView.image = UIImage(systemName: "doc.text")
iconView.tintColor = .secondaryLabel
titleLabel.translatesAutoresizingMaskIntoConstraints = false
titleLabel.font = .preferredFont(forTextStyle: .body)
titleLabel.numberOfLines = 2
hostLabel.translatesAutoresizingMaskIntoConstraints = false
hostLabel.font = .preferredFont(forTextStyle: .footnote)
hostLabel.textColor = .secondaryLabel
hostLabel.numberOfLines = 1
contentView.addSubview(iconView)
contentView.addSubview(titleLabel)
contentView.addSubview(hostLabel)
NSLayoutConstraint.activate([
iconView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
iconView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
iconView.widthAnchor.constraint(equalToConstant: 40),
iconView.heightAnchor.constraint(equalToConstant: 40),
titleLabel.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 12),
titleLabel.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor),
titleLabel.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor),
hostLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
hostLabel.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor),
hostLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 2),
hostLabel.bottomAnchor.constraint(equalTo: contentView.layoutMarginsGuide.bottomAnchor),
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func prepareForReuse() {
super.prepareForReuse()
faviconTask?.cancel()
faviconTask = nil
iconView.image = UIImage(systemName: "doc.text")
iconView.tintColor = .secondaryLabel
}
func configure(url: URL, pageTitle: String?) {
let host = url.host ?? url.absoluteString
titleLabel.text = pageTitle?.isEmpty == false ? pageTitle : url.absoluteString
hostLabel.text = host
loadFavicon(for: url)
}
/// Best-effort favicon at `https://<host>/favicon.ico` with a 2s
/// timeout. On any failure we keep the placeholder never blocking
/// the user's save action on a network round-trip.
private func loadFavicon(for url: URL) {
guard let host = url.host, var components = URLComponents(string: "https://\(host)") else {
return
}
components.path = "/favicon.ico"
guard let iconURL = components.url else { return }
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = 2
let session = URLSession(configuration: config)
let task = session.dataTask(with: iconURL) { [weak self] data, response, _ in
guard let data = data,
let http = response as? HTTPURLResponse,
(200..<300).contains(http.statusCode),
let image = UIImage(data: data)
else { return }
DispatchQueue.main.async {
self?.iconView.image = image
self?.iconView.tintColor = nil
}
}
task.resume()
faviconTask = task
}
}
@@ -120,6 +120,11 @@ targets:
ShareExtension:
type: app-extension
platform: iOS
# The Share Extension uses iOS-system-localized bar button items
# (`barButtonSystemItem: .cancel` / `.save`) plus a JS-supplied
# "Default" group name (passed through the App Group), so the target
# carries no per-locale `.strings` files — no `.lproj` directories
# to wire up.
sources:
- path: ShareExtension
info:
@@ -0,0 +1,74 @@
// 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())
}
}
@@ -473,6 +473,14 @@ class NativeBridgePlugin: Plugin {
// covers Readest's annotation toolbar. See ContextMenuSuppressor.
ContextMenuSuppressor.installIfNeeded()
// Register a WKScriptMessageHandler so JS can signal when its
// share-extension hook has mounted. On `{type: 'ready'}` we run a
// sync immediately, which is the cold-start path (app launched
// because the Share Extension just woke it up, JS may not have been
// listening when the first `appDidBecomeActive` fired).
webview.configuration.userContentController.add(
ShareBridgeMessageHandler(owner: self), name: "readestShareBridge")
webViewLifecycleManager = WebViewLifecycleManager()
webViewLifecycleManager?.startMonitoring(webView: webview)
logger.log("NativeBridgePlugin: WebView lifecycle monitoring activated")
@@ -528,6 +536,76 @@ class NativeBridgePlugin: Plugin {
// appearance whenever the app becomes active, e.g. after toggling
// dark mode from Control Center.
notifyColorSchemeChange()
syncShareExtensionState()
}
/// JS-initiated entry point. The share-extension JS hook calls
/// `window.webkit.messageHandlers.readestShareBridge.postMessage({type:'ready'})`
/// once mounted, which routes here so the cold-start drain happens
/// even when the JS hook wasn't listening at app launch.
@objc func syncShareExtensionStateFromJS() {
syncShareExtensionState()
}
/// Bridge between the Readest Share Extension (separate process) and
/// the host app's JS, via the App Group container at
/// `group.com.bilingify.readest`. Two directions on every activation:
///
/// 1. Groups (host extension). Read the current library group list
/// from JS (`window.__readestGetGroups`) and persist it so the
/// extension's picker shows up-to-date options next time the user
/// shares. If the JS function isn't installed yet (cold start, hook
/// hasn't mounted), no-op the next activation will refresh.
///
/// 2. Pending saves (extension host). Drain any queued saves the
/// extension wrote, hand them to JS
/// (`window.__readestOnShareExtensionPending`), then clear the
/// queue only if JS confirmed receipt. If JS isn't ready yet,
/// leave the queue intact the next activation (or the JS hook
/// on its own mount) will pick them up.
private func syncShareExtensionState() {
DispatchQueue.main.async { [weak self] in
guard let self = self, let webView = self.webView else { return }
// Pull groups + the user-locale "Default" label from JS App Group.
webView.evaluateJavaScript(
"(window.__readestGetGroups && window.__readestGetGroups()) || null"
) { result, _ in
guard let payload = result as? [String: Any] else { return }
if let array = payload["groups"] as? [[String: Any]] {
let groups: [AppGroupBridge.LibraryGroup] = array.compactMap { item in
guard let id = item["id"] as? String, let name = item["name"] as? String else {
return nil
}
return AppGroupBridge.LibraryGroup(id: id, name: name)
}
AppGroupBridge.writeGroups(groups)
}
if let defaultName = payload["defaultGroupName"] as? String, !defaultName.isEmpty {
AppGroupBridge.writeDefaultGroupName(defaultName)
}
}
// Push pending saves JS, clear queue iff JS confirmed.
let saves = AppGroupBridge.readPendingSaves()
guard !saves.isEmpty else { return }
let payload: [[String: Any?]] = saves.map { save in
[
"url": save.url,
"groupId": save.groupId,
"groupName": save.groupName,
"addedAt": save.addedAt,
]
}
guard let json = try? JSONSerialization.data(withJSONObject: payload, options: []),
let jsonString = String(data: json, encoding: .utf8)
else { return }
let script =
"(window.__readestOnShareExtensionPending && window.__readestOnShareExtensionPending(\(jsonString))) === true"
webView.evaluateJavaScript(script) { result, _ in
if let acknowledged = result as? Bool, acknowledged {
AppGroupBridge.clearPendingSaves()
}
}
}
}
// Resolves the foreground window scene. Its trait collection reflects
@@ -1305,6 +1383,30 @@ func initPlugin() -> Plugin {
return NativeBridgePlugin()
}
/// JS Swift bridge for the share-extension state. Lives in its own
/// class because WKScriptMessageHandler conformance on NativeBridgePlugin
/// would require routing every message type through the plugin's
/// existing @objc method surface, which is noisier than a dedicated
/// handler. We weakly retain the plugin so the WKWebView's
/// WKUserContentController holding the handler doesn't extend the
/// plugin's lifetime.
private final class ShareBridgeMessageHandler: NSObject, WKScriptMessageHandler {
private weak var owner: NativeBridgePlugin?
init(owner: NativeBridgePlugin) { self.owner = owner }
func userContentController(
_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage
) {
guard message.name == "readestShareBridge" else { return }
guard let body = message.body as? [String: Any], let type = body["type"] as? String else {
return
}
if type == "ready" {
owner?.syncShareExtensionStateFromJS()
}
}
}
@available(iOS 13.0, *)
extension NativeBridgePlugin: ASWebAuthenticationPresentationContextProviding {
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
@@ -0,0 +1,75 @@
import { describe, test, expect } from 'vitest';
import { buildEpub } from '@/services/send/conversion/buildEpub';
import { configureZip } from '@/utils/zip';
async function unzipEpub(blob: Blob): Promise<Map<string, string>> {
await configureZip();
const { BlobReader, ZipReader, TextWriter } = await import('@zip.js/zip.js');
const reader = new ZipReader(new BlobReader(blob));
const entries = await reader.getEntries();
const out = new Map<string, string>();
for (const entry of entries) {
if (entry.directory) continue;
const text = await entry.getData!(new TextWriter());
out.set(entry.filename, text);
}
await reader.close();
return out;
}
const chapter = { title: 'Body', html: '<p>Body text long enough to be a chapter.</p>' };
const meta = {
title: 'Cover Test',
author: 'Tester',
language: 'en',
identifier: 'cover-test:1',
};
describe('buildEpub — cover image', () => {
test('without coverImage, OPF has no <meta name="cover">', async () => {
const blob = await buildEpub([chapter], meta);
const files = await unzipEpub(blob);
const opf = files.get('content.opf');
expect(opf).toBeDefined();
expect(opf).not.toContain('name="cover"');
});
test('with coverImage, OPF references the cover via name="cover" meta + manifest item', async () => {
const svgBytes = new TextEncoder().encode(
'<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 900"></svg>',
).buffer as ArrayBuffer;
const blob = await buildEpub([chapter], meta, [], {
path: 'cover.svg',
bytes: svgBytes,
mime: 'image/svg+xml',
});
const files = await unzipEpub(blob);
// Cover file is in OEBPS/
expect(files.has('OEBPS/cover.svg')).toBe(true);
// OPF references the cover
const opf = files.get('content.opf');
expect(opf).toBeDefined();
expect(opf).toContain('<meta name="cover" content="cover-image"');
expect(opf).toContain('id="cover-image"');
expect(opf).toContain('href="OEBPS/cover.svg"');
expect(opf).toContain('media-type="image/svg+xml"');
});
test('cover-only EPUBs (no other images) still produce a valid manifest', async () => {
const svgBytes = new TextEncoder().encode(
'<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 900"></svg>',
).buffer as ArrayBuffer;
const blob = await buildEpub([chapter], meta, [], {
path: 'cover.svg',
bytes: svgBytes,
mime: 'image/svg+xml',
});
const files = await unzipEpub(blob);
const opf = files.get('content.opf')!;
// Exactly one <item> for the cover image (no extra <item>s for empty images array)
const imgItems = opf.match(/<item[^>]*media-type="image\//g) ?? [];
expect(imgItems.length).toBe(1);
});
});
@@ -0,0 +1,130 @@
import { describe, expect, test, vi, beforeEach, afterEach } from 'vitest';
import { convertPageToEpub, convertToEpub } from '@/services/send/conversion/convertToEpub';
/**
* Regression: every clip channel (desktop, mobile, extension) goes through
* `convertPageToEpub` and must produce byte-identical EPUBs for the same
* (html, url) pair. The import-time hash dedup relies on this without
* it, re-clipping an article (or clipping it from two devices) creates
* duplicate library entries.
*
* Two surfaces:
* 1. `convertPageToEpub(html, url)` direct (extension SW path).
* 2. `convertToEpub({ kind: 'page', html, url })` (existing /send path).
* Both must yield the same bytes.
*/
const PNG = new Uint8Array([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x62, 0x00, 0x01, 0x00, 0x00,
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
0x42, 0x60, 0x82,
]);
function pngResponse(): Response {
return new Response(PNG, { status: 200, headers: { 'content-type': 'image/png' } });
}
const HTML = `<!doctype html>
<html lang="en">
<head>
<title>Unifying Clipping Paths</title>
<meta property="og:site_name" content="Example Site">
<link rel="apple-touch-icon" href="https://example.com/icon.png">
</head>
<body>
<article>
<h1>Unifying Clipping Paths</h1>
<p class="byline">By Jane Doe</p>
<h2>Introduction</h2>
<p>${'lorem ipsum dolor sit amet '.repeat(40)}</p>
<p><img src="https://example.com/hero.png" alt="hero"></p>
<h2>Details</h2>
<p>${'consectetur adipiscing elit '.repeat(40)}</p>
<h3>A subsection</h3>
<p>${'sed do eiusmod tempor incididunt '.repeat(40)}</p>
</article>
</body>
</html>`;
const URL_STR = 'https://example.com/articles/unify';
describe('convertPageToEpub — clipping channel unification', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => pngResponse());
});
afterEach(() => {
fetchSpy.mockRestore();
});
test('the direct call and the convertToEpub({kind:"page"}) wrapper produce identical EPUBs', async () => {
const direct = await convertPageToEpub(HTML, URL_STR);
const viaConvertToEpub = await convertToEpub({ kind: 'page', html: HTML, url: URL_STR });
const a = new Uint8Array(await direct.file.arrayBuffer());
const b = new Uint8Array(await viaConvertToEpub.file.arrayBuffer());
expect(direct.title).toBe(viaConvertToEpub.title);
expect(direct.author).toBe(viaConvertToEpub.author);
expect(a.byteLength).toBe(b.byteLength);
expect(a.byteLength).toBeGreaterThan(0);
// Compare a few sentinel windows rather than the full array (the
// failure message is unreadable on a 200 KB blob); pin the first 16
// bytes (EPUB mimetype entry) and a few random samples.
expect(Array.from(a.subarray(0, 64))).toEqual(Array.from(b.subarray(0, 64)));
expect(Array.from(a.subarray(1000, 1064))).toEqual(Array.from(b.subarray(1000, 1064)));
expect(Array.from(a.subarray(a.byteLength - 64))).toEqual(
Array.from(b.subarray(b.byteLength - 64)),
);
});
test('a heading-shaped article surfaces every h1/h2/h3 in the EPUB TOC', async () => {
const { file } = await convertPageToEpub(HTML, URL_STR);
const { BlobReader, TextWriter, ZipReader } = await import('@zip.js/zip.js');
const reader = new ZipReader(new BlobReader(file));
const entries = await reader.getEntries();
const ncxEntry = entries.find((e) => e.filename === 'toc.ncx');
if (!ncxEntry || !('getData' in ncxEntry) || !ncxEntry.getData) {
throw new Error('toc.ncx missing');
}
const ncx = await ncxEntry.getData(new TextWriter());
await reader.close();
// Headings from the article:
expect(ncx).toContain('<text>Unifying Clipping Paths</text>');
expect(ncx).toContain('<text>Introduction</text>');
expect(ncx).toContain('<text>Details</text>');
expect(ncx).toContain('<text>A subsection</text>');
});
test('the EPUB includes a synthetic cover with the author-hashed theme', async () => {
const { file } = await convertPageToEpub(HTML, URL_STR);
const { BlobReader, TextWriter, ZipReader } = await import('@zip.js/zip.js');
const reader = new ZipReader(new BlobReader(file));
const entries = await reader.getEntries();
// OPF declares a cover-image item:
const opfEntry = entries.find((e) => e.filename === 'content.opf');
if (!opfEntry || !('getData' in opfEntry) || !opfEntry.getData) {
throw new Error('content.opf missing');
}
const opf = await opfEntry.getData(new TextWriter());
expect(opf).toContain('<meta name="cover" content="cover-image"/>');
// The cover asset itself is an SVG:
const coverEntry = entries.find((e) => e.filename === 'OEBPS/cover.svg');
expect(coverEntry).toBeDefined();
await reader.close();
});
test('re-converting the same (html, url) yields a byte-identical EPUB', async () => {
const first = new Uint8Array(await (await convertPageToEpub(HTML, URL_STR)).file.arrayBuffer());
const second = new Uint8Array(
await (await convertPageToEpub(HTML, URL_STR)).file.arrayBuffer(),
);
expect(first.byteLength).toBe(second.byteLength);
expect(Array.from(first.subarray(0, 256))).toEqual(Array.from(second.subarray(0, 256)));
});
});
@@ -0,0 +1,230 @@
import { describe, test, expect } from 'vitest';
import { generateCoverSvg, pickTheme } from '@/services/send/conversion/coverGenerator';
const FAVICON_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]).buffer as ArrayBuffer;
const AVATAR_BYTES = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]).buffer as ArrayBuffer;
describe('generateCoverSvg', () => {
test('returns a non-empty SVG document with the correct mime', () => {
const cover = generateCoverSvg({
title: 'A Short Title',
siteName: 'Example Site',
});
expect(cover.mime).toBe('image/svg+xml');
expect(cover.bytes.byteLength).toBeGreaterThan(0);
const svg = new TextDecoder().decode(cover.bytes);
expect(svg).toMatch(/^<\?xml/);
expect(svg).toContain('<svg');
expect(svg).toContain('xmlns="http://www.w3.org/2000/svg"');
});
test('embeds the title text (each word, since wrapping may split lines)', () => {
const svg = new TextDecoder().decode(
generateCoverSvg({ title: 'My Awesome Article', siteName: 'Example' }).bytes,
);
expect(svg).toContain('My');
expect(svg).toContain('Awesome');
expect(svg).toContain('Article');
});
test('falls back to the site name at the bottom when no author is provided', () => {
const svg = new TextDecoder().decode(
generateCoverSvg({ title: 'Hi', siteName: 'Machine Intelligence' }).bytes,
);
expect(svg).toContain('Machine Intelligence');
});
test('shows the author at the bottom when provided, in place of the site name', () => {
const svg = new TextDecoder().decode(
generateCoverSvg({
title: 'Hi',
siteName: 'example.com',
author: 'Jane Doe',
}).bytes,
);
expect(svg).toContain('Jane Doe');
// Site name is not shown when an author is available — keeps the
// bottom block single-line and legible at thumbnail size.
expect(svg).not.toContain('example.com');
});
test('escapes XML special characters in title and site name', () => {
const svg = new TextDecoder().decode(
generateCoverSvg({
title: 'Tom & Jerry <chase> "scene"',
siteName: 'A & B',
}).bytes,
);
// Raw `<chase>` would break the SVG XML. The segmenter may wrap the
// angle brackets onto different lines, but every occurrence must be
// escaped.
expect(svg).not.toContain('<chase>');
expect(svg).not.toContain('"scene"');
expect(svg).toContain('&amp;');
expect(svg).toContain('&lt;');
expect(svg).toContain('&gt;');
expect(svg).toContain('&quot;');
});
test('embeds the favicon as a base64 data URL when provided', () => {
const svg = new TextDecoder().decode(
generateCoverSvg({
title: 'Title',
siteName: 'Site',
favicon: { bytes: FAVICON_BYTES, mime: 'image/png' },
}).bytes,
);
expect(svg).toContain('data:image/png;base64,');
// base64 of the PNG signature bytes
expect(svg).toContain('iVBORw0K');
});
test('prefers the author profile image over the favicon when both are provided', () => {
const svg = new TextDecoder().decode(
generateCoverSvg({
title: 'Title',
siteName: 'Site',
favicon: { bytes: FAVICON_BYTES, mime: 'image/png' },
authorImage: { bytes: AVATAR_BYTES, mime: 'image/jpeg' },
}).bytes,
);
// base64 of the JPEG signature bytes — author image, not favicon.
expect(svg).toContain('data:image/jpeg;base64,');
expect(svg).toContain('/9j/4AAQ');
// PNG-signature base64 of the favicon must NOT appear when the
// author image won the slot.
expect(svg).not.toContain('iVBORw0K');
});
test('still produces a valid cover when no avatar image is provided (initial-letter fallback)', () => {
const svg = new TextDecoder().decode(
generateCoverSvg({ title: 'Hello', siteName: 'My Site' }).bytes,
);
expect(svg).toContain('<svg');
// No data URL when there is no favicon, but the SVG must still be well-formed
expect(svg).not.toContain('data:image');
// Initial-letter placeholder: first letter of site name appears as a tspan/text
expect(svg).toMatch(/[>"]M[<"]/);
});
test('clips the avatar with a circle (not a rounded rect)', () => {
const svg = new TextDecoder().decode(
generateCoverSvg({
title: 'Title',
siteName: 'Site',
favicon: { bytes: FAVICON_BYTES, mime: 'image/png' },
}).bytes,
);
expect(svg).toContain('clipPath');
expect(svg).toContain('<circle');
});
});
describe('pickTheme — author-hashed cover theme', () => {
test('is deterministic — same author always gets the same theme', () => {
expect(pickTheme('Jane Doe')).toEqual(pickTheme('Jane Doe'));
expect(pickTheme('机器之心')).toEqual(pickTheme('机器之心'));
});
test('produces theme colors with top + bottom hex strings', () => {
const theme = pickTheme('Jane Doe');
expect(theme.top).toMatch(/^#[0-9A-Fa-f]{6}$/);
expect(theme.bottom).toMatch(/^#[0-9A-Fa-f]{6}$/);
});
test('whitespace differences yield the same theme', () => {
expect(pickTheme(' Jane Doe ')).toEqual(pickTheme('Jane Doe'));
});
test('empty key resolves to the first palette entry', () => {
const empty = pickTheme('');
expect(empty.top).toMatch(/^#[0-9A-Fa-f]{6}$/);
expect(empty.bottom).toMatch(/^#[0-9A-Fa-f]{6}$/);
});
test('hashes the author into the SVG theme color', () => {
const theme = pickTheme('Distinctive Author Name');
const svg = new TextDecoder().decode(
generateCoverSvg({
title: 'Hi',
siteName: 'example.com',
author: 'Distinctive Author Name',
}).bytes,
);
// The top color from the theme appears as a fill in the upper block.
expect(svg.toLowerCase()).toContain(theme.top.toLowerCase());
});
test('mixed CJK + Latin title: short Latin tokens flow into adjacent CJK runs (no orphan lines)', () => {
const svg = new TextDecoder().decode(
generateCoverSvg({
title: 'AI 知识库技术演进拆解:从 RAG 到 NotebookLM,再到 LLM Wiki',
siteName: 'mp.weixin.qq.com',
}).bytes,
);
// "AI" must not appear on a line by itself — it should pack with the
// following CJK characters into a single line of text.
expect(svg).not.toMatch(/>AI<\/text>/);
// "AI" plus at least one CJK char must appear on the same line.
expect(svg).toMatch(/>AI [一-鿿]/);
});
test('CJK right-punctuation glues to the preceding character (never starts a line)', () => {
const svg = new TextDecoder().decode(
generateCoverSvg({
title: '知识库技术演进拆解:从 RAG 到 NotebookLM',
siteName: 'site',
}).bytes,
);
// A `<text>` line beginning with `` would break Chinese typography.
expect(svg).not.toMatch(/>/);
// And the Chinese comma must not start a line either.
expect(svg).not.toMatch(/>/);
});
test('Japanese title: wraps via Intl.Segmenter, all characters present', () => {
const title = 'プログラミング入門:JavaScriptで学ぶ基礎';
const svg = new TextDecoder().decode(generateCoverSvg({ title, siteName: 'qiita.com' }).bytes);
// Every CJK character from the title appears in the SVG (no
// truncation / dropped characters under 5-line cap).
for (const ch of 'プログラミング入門JavaScript') {
expect(svg).toContain(ch);
}
});
test('Thai title: wraps without spaces between words (dictionary-based segmentation)', () => {
// Thai has no spaces between words — the segmenter uses ICU's Thai
// dictionary to find word breaks. We just verify the title renders
// without crashing and the characters are present.
const title = 'การเรียนรู้ JavaScript เบื้องต้น';
const svg = new TextDecoder().decode(generateCoverSvg({ title, siteName: 'site' }).bytes);
expect(svg).toContain('การ');
expect(svg).toContain('JavaScript');
expect(svg).toContain('เบื้องต้น');
});
test('latin words stay whole — no mid-word break on long Latin runs that fit', () => {
const svg = new TextDecoder().decode(
generateCoverSvg({
title: 'Intro NotebookLM 实战指南',
siteName: 'site',
}).bytes,
);
// NotebookLM must appear intact somewhere on the cover.
expect(svg).toContain('NotebookLM');
});
test('truncates an extremely long title with an ellipsis', () => {
const longTitle = 'word '.repeat(200).trim();
const svg = new TextDecoder().decode(
generateCoverSvg({ title: longTitle, siteName: 'Site' }).bytes,
);
expect(svg).toMatch(/…|\.\.\./);
});
test('uses 600x900 viewport (2:3 book-cover aspect)', () => {
const svg = new TextDecoder().decode(generateCoverSvg({ title: 'X', siteName: 'Y' }).bytes);
expect(svg).toMatch(/viewBox="0 0 600 900"/);
});
});
+98 -11
View File
@@ -12,24 +12,45 @@ import { eventDispatcher } from '@/utils/event';
import { parseAnnotationDeepLink } from '@/utils/deeplink';
import { useTranslation } from './useTranslation';
interface ClipOptions {
groupId?: string;
groupName?: string;
}
interface PendingShareSave {
url: string;
groupId?: string | null;
groupName?: string | null;
addedAt?: string;
}
/**
* Handle "Share to Readest" article URLs from the OS share sheet
* (Safari, Chrome, etc.). Consumes the `app-incoming-url` event published
* by `useAppUrlIngress`, filters URLs that look like article links, and
* runs them through the same clip EPUB import pipeline the `/send`
* page uses.
* (Safari, Chrome, etc.). Two paths feed in:
*
* 1. Deep-link wake-up (`app-incoming-url` event published by
* `useAppUrlIngress`) filters URLs that look like article links
* and runs them through the clip EPUB import pipeline.
*
* 2. iOS Share-Extension App Group queue the extension writes
* `{url, groupId?, groupName?}` payloads into the shared
* NSUserDefaults at `group.com.bilingify.readest`, and the host
* plugin (NativeBridgePlugin) drains them on foreground by calling
* `window.__readestOnShareExtensionPending(saves)`. Same ingest
* pipeline, but the chosen library group is preserved.
*
* On iOS we also expose `window.__readestGetGroups()` so the
* Share-Extension picker can show up-to-date library groups, and we
* post a `{type:'ready'}` to the WKScriptMessageHandler the plugin
* registered, so the cold-start drain happens even when the extension
* woke the app up before this hook had mounted.
*
* Filter rules only act on URLs that are:
* - http(s) (not file://, content://, readest://, blob:, data:)
* - NOT an annotation deep link (those go to useOpenAnnotationLink and
* would otherwise be double-handled)
* - NOT an annotation deep link (those go to useOpenAnnotationLink)
*
* Failures surface as toasts. Successful clips show "Saving article…"
* then "Saved to your library." once `ingestFile` completes.
*
* Mount this hook alongside `useAppUrlIngress` (same places as
* `useOpenWithBooks` and `useOpenAnnotationLink`) so the ingress
* dispatcher is running when URLs arrive.
*/
export function useClipUrlIngress() {
const _ = useTranslation();
@@ -38,7 +59,7 @@ export function useClipUrlIngress() {
const inflight = useRef<Set<string>>(new Set());
const clipAndImport = useCallback(
async (url: string) => {
async (url: string, options: ClipOptions = {}) => {
if (!appService) return;
if (inflight.current.has(url)) return;
inflight.current.add(url);
@@ -64,6 +85,8 @@ export function useClipUrlIngress() {
if (!ingested) {
throw new Error('Import produced no book');
}
if (options.groupId) ingested.groupId = options.groupId;
if (options.groupName) ingested.groupName = options.groupName;
await useLibraryStore.getState().updateBooks(envConfig, [ingested]);
eventDispatcher.dispatch('toast', {
type: 'success',
@@ -91,6 +114,7 @@ export function useClipUrlIngress() {
[_, appService, envConfig, user],
);
// Deep-link path (existing).
useEffect(() => {
if (!isTauriAppPlatform() || !appService) return;
@@ -138,4 +162,67 @@ export function useClipUrlIngress() {
eventDispatcher.off('app-incoming-url', onIncoming);
};
}, [appService, clipAndImport]);
// iOS Share-Extension App Group bridge.
useEffect(() => {
if (!isTauriAppPlatform() || !appService) return;
if (typeof window === 'undefined') return;
const w = window as unknown as {
__readestGetGroups?: () => {
groups: { id: string; name: string }[];
defaultGroupName: string;
};
__readestOnShareExtensionPending?: (saves: PendingShareSave[]) => boolean;
webkit?: {
messageHandlers?: {
readestShareBridge?: { postMessage: (msg: { type: string }) => void };
};
};
};
// The Share Extension is a native UIKit module and can't read the web
// app's i18next catalogue. We pass it the user-locale-translated
// string for "Default" (the no-group entry) alongside the groups —
// saves maintaining a parallel iOS .strings file just for one phrase.
w.__readestGetGroups = () => {
try {
return {
groups: useLibraryStore.getState().getGroups(),
defaultGroupName: _('Default'),
};
} catch {
return { groups: [], defaultGroupName: 'Default' };
}
};
w.__readestOnShareExtensionPending = (saves) => {
if (!Array.isArray(saves)) return false;
for (const save of saves) {
if (!save || typeof save.url !== 'string') continue;
void clipAndImport(save.url, {
groupId: save.groupId ?? undefined,
groupName: save.groupName ?? undefined,
});
}
return true;
};
// Tell the plugin we're mounted so it can drain any pending saves
// queued before the hook was alive (cold-start path).
try {
w.webkit?.messageHandlers?.readestShareBridge?.postMessage({ type: 'ready' });
} catch {
// Non-iOS or handler not registered — no-op.
}
return () => {
const cleanup = window as unknown as {
__readestGetGroups?: unknown;
__readestOnShareExtensionPending?: unknown;
};
delete cleanup.__readestGetGroups;
delete cleanup.__readestOnShareExtensionPending;
};
}, [appService, clipAndImport]);
}
@@ -41,11 +41,17 @@ pre { white-space: pre-wrap; background: #f5f5f5; padding: 0.6em 0.8em; border-r
* `@zip.js/zip.js` assembly pattern with `TxtToEpubConverter` mimetype first
* and uncompressed, `container.xml`, `content.opf`, `toc.ncx`, zeroed
* timestamps.
*
* When `coverImage` is supplied the OPF gets a `<meta name="cover"
* content="cover-image"/>` plus a `<item id="cover-image" ...>` in the
* manifest. foliate-js detects covers via that meta tag (see
* `packages/foliate-js/epub.js`), so EPUB 2.0 style is enough.
*/
export async function buildEpub(
chapters: EpubChapter[],
metadata: EpubBuildMetadata,
images: EpubImage[] = [],
coverImage?: EpubImage,
): Promise<Blob> {
if (chapters.length === 0) {
throw new Error('buildEpub: no chapters');
@@ -112,6 +118,14 @@ export async function buildEpub(
);
}
if (coverImage) {
await zipWriter.add(
`OEBPS/${coverImage.path}`,
new Uint8ArrayReader(new Uint8Array(coverImage.bytes)),
zipWriteOptions,
);
}
for (let i = 0; i < chapters.length; i++) {
const chapter = chapters[i]!;
const xhtml = `<?xml version="1.0" encoding="UTF-8"?>
@@ -138,8 +152,18 @@ export async function buildEpub(
`<item id="img${i + 1}" href="OEBPS/${escapeXml(image.path)}" media-type="${escapeXml(image.mime)}"/>`,
)
.join('\n ');
const coverManifest = coverImage
? `<item id="cover-image" href="OEBPS/${escapeXml(coverImage.path)}" media-type="${escapeXml(coverImage.mime)}"/>`
: '';
const coverMeta = coverImage ? `<meta name="cover" content="cover-image"/>` : '';
const spine = chapters.map((_, i) => `<itemref idref="chap${i + 1}"/>`).join('\n ');
// Only include manifest lines that have content — empty lines would leave
// stray blank entries in the OPF and trip strict parsers.
const manifestLines = [manifest, imageManifest, coverManifest]
.filter((line) => line.length > 0)
.join('\n ');
const contentOpf = `<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" unique-identifier="book-id" version="2.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
@@ -147,10 +171,10 @@ export async function buildEpub(
<dc:language>${escapeXml(language)}</dc:language>
<dc:creator>${escapeXml(author)}</dc:creator>
<dc:identifier id="book-id">${escapeXml(identifier)}</dc:identifier>
${coverMeta}
</metadata>
<manifest>
${manifest}
${imageManifest}
${manifestLines}
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
<item id="css" href="style.css" media-type="text/css"/>
</manifest>
@@ -1,10 +1,16 @@
import mammoth from 'mammoth';
import { Readability } from '@mozilla/readability';
import { TxtToEpubConverter } from '@/utils/txt';
import { detectLanguage } from '@/utils/lang';
// `mammoth` (DOCX → HTML) and `TxtToEpubConverter` are dynamically imported
// inside their respective `case` bodies in `convertToEpub`. They're heavy
// (≈500 KB combined) and only needed for the docx/txt input kinds — the
// browser extension imports `convertPageToEpub` and never reaches those
// branches, so dynamic imports let webpack code-split them out of the
// extension bundle entirely.
import { sanitizeHtml, sanitizeForParsing } from './sanitizeHtml';
import { buildEpub } from './buildEpub';
import { bundleAssets } from './assetBundler';
import { generateCoverSvg } from './coverGenerator';
import { fetchAuthorImage, fetchBestFavicon } from './faviconFetcher';
import { findSiteRule, type SiteRule } from './siteRules';
import { extractHeadings } from './toc';
import { ConversionError } from './types';
@@ -48,7 +54,7 @@ function stableIdentifier(content: string): string {
for (let i = 0; i < content.length; i++) {
h = ((h << 5) + h + content.charCodeAt(i)) >>> 0;
}
return `send-to-readest:${h.toString(16)}`;
return `readest:${h.toString(16)}`;
}
function stripTags(html: string): string {
@@ -96,12 +102,22 @@ function composeArticleContent(title: string, byline: string, body: string): str
/** Assemble an EPUB from a single block of sanitized HTML (optionally with
* pre-fetched image resources to embed). Extracts an in-chapter heading
* TOC so the EPUB reader's sidebar shows the article's sections. */
* TOC so the EPUB reader's sidebar shows the article's sections.
*
* `identityKey` is the input the EPUB's `dc:identifier` is derived from.
* Callers choose what to feed it:
* - Page / article clips pass the canonical URL the identifier then
* stays stable across edits of the same article, so a re-clip after
* the publisher tweaks a paragraph is recognised as the same work.
* - Local file inputs (docx / html / rtf) pass the sanitized chapter
* HTML there's no URL, and content is the only stable handle. */
async function htmlToBook(
rawHtml: string,
title: string,
author: string,
identityKey: string,
images: EpubImage[] = [],
coverImage?: EpubImage,
): Promise<ConvertedBook> {
// Assign stable ids to headings BEFORE sanitization so the strict
// `sanitizeHtml` step (which now allows `id`) preserves them — those
@@ -127,10 +143,11 @@ async function htmlToBook(
title,
author,
language,
identifier: stableIdentifier(html),
identifier: stableIdentifier(identityKey),
toc,
},
images,
coverImage,
);
const file = new File([blob], `${safeFileName(title)}.epub`, {
type: 'application/epub+zip',
@@ -217,6 +234,108 @@ function extractArticleFallback(rawHtml: string, minTextChars: number): string |
return bestText >= minTextChars ? bestHtml : null;
}
/**
* Pick the site name for the synthetic cover. Tries Open Graph + Twitter
* metadata first (most reliable; publishers set these for previews), falls
* through to `<meta name="application-name">`, then degrades to the URL's
* hostname stripped of the leading `www.` so e.g. `https://nytimes.com/foo`
* yields "nytimes.com".
*/
function extractSiteName(doc: Document, pageUrl: string): string {
const metaSelectors = [
'meta[property="og:site_name"]',
'meta[name="og:site_name"]',
'meta[name="application-name"]',
'meta[name="apple-mobile-web-app-title"]',
'meta[property="twitter:site"]',
];
for (const sel of metaSelectors) {
const value = doc.querySelector(sel)?.getAttribute('content')?.trim();
if (value) return value;
}
try {
return new URL(pageUrl).hostname.replace(/^www\./, '');
} catch {
return '';
}
}
/**
* Build a synthetic cover for a clipped article. Parses the page HTML once
* to find the site name and favicon, then hands those off to
* `generateCoverSvg`. Returns `undefined` only when the cover can't be
* generated at all (e.g. the parser threw) the favicon-fetch failing is
* already handled by the cover generator's initial-letter fallback.
*/
async function buildArticleCover(
pageHtml: string,
pageUrl: string,
title: string,
author: string,
rule: SiteRule | null,
): Promise<EpubImage | undefined> {
let doc: Document;
try {
doc = new DOMParser().parseFromString(sanitizeForParsing(pageHtml), 'text/html');
} catch {
return generateCoverSvg({ title, author, siteName: hostnameFallback(pageUrl) });
}
const siteName = extractSiteName(doc, pageUrl);
// Prefer an author profile image (richer than a site-wide favicon).
// When the site rule exposes an `authorImage` selector, resolve the URL
// and fetch it. Falls through to the favicon on any failure.
let authorImage: { bytes: ArrayBuffer; mime: string } | undefined;
if (rule?.authorImage) {
const url = pickImageUrlFromSelector(doc, rule.authorImage, pageUrl);
if (url) {
authorImage = (await fetchAuthorImage(url, pageUrl).catch(() => null)) ?? undefined;
}
}
// Favicon is only fetched when we don't already have an author image —
// saves a wasted HTTP round-trip on WeChat (and any other rule that
// sets `authorImage`).
const favicon = authorImage
? undefined
: ((await fetchBestFavicon(doc, pageUrl).catch(() => null)) ?? undefined);
return generateCoverSvg({ title, author, siteName, authorImage, favicon });
}
/** Walk the rule's `authorImage` selector list and return the first usable
* `src` or `data-src` URL, resolved against the page URL. */
function pickImageUrlFromSelector(doc: Document, selector: string, pageUrl: string): string | null {
let els: NodeListOf<Element>;
try {
els = doc.querySelectorAll(selector);
} catch {
return null;
}
for (const el of Array.from(els)) {
const raw =
el.getAttribute('src') ||
el.getAttribute('data-src') ||
el.getAttribute('data-original') ||
el.getAttribute('href');
if (!raw) continue;
try {
return new URL(raw, pageUrl).toString();
} catch {
// unparseable href — try the next element
}
}
return null;
}
function hostnameFallback(pageUrl: string): string {
try {
return new URL(pageUrl).hostname.replace(/^www\./, '');
} catch {
return '';
}
}
/** Pull `<title>` / first `<h1>` out of a full HTML document. */
function extractHtmlTitle(html: string, fallback: string): string {
try {
@@ -247,6 +366,120 @@ function baseName(fileName: string | undefined, fallback: string): string {
return fileName.replace(/\.[^.]+$/, '').trim() || fallback;
}
/**
* Convert a single web page (rendered HTML + URL) into a self-contained
* EPUB Readability extraction, per-site rule fast-paths, inlined images,
* cover, headings-as-TOC. Shared between every "self-contained page clip"
* caller:
*
* - The Tauri desktop / mobile `/send` URL field (Tauri-only path).
* - The browser extension's service worker (`extensions/send-to-readest`).
* - Future: any other channel that captures the rendered DOM and wants
* the same EPUB out the other side.
*
* Centralising it here is the only way to guarantee that the same URL
* produces byte-identical EPUBs across desktop, mobile, and extension
* which is what the import-time hash dedup relies on.
*
* Only valid from a CORS-free caller (Tauri webview or browser-extension
* service worker with broad `host_permissions`). A plain web page hitting
* this would fail on the image fetches.
*/
export async function convertPageToEpub(html: string, url: string): Promise<ConvertedBook> {
console.log('[clip/page] input', {
url,
html_bytes: html.length,
});
// Fast path: per-site rule. Skips Readability entirely on sites we know
// it mis-scores. Falls through if the rule's content selector matches
// nothing or the extracted text is below the quality floor.
const rule = findSiteRule(url);
if (rule) {
const ruleResult = extractWithSiteRule(html, rule);
const ruleText = ruleResult ? stripTags(ruleResult.content) : '';
console.log('[clip/page] site rule', {
name: rule.name,
matched: !!ruleResult,
text_chars: ruleText.length,
title: ruleResult?.title || null,
});
if (ruleResult && ruleText.length >= 400) {
const title = ruleResult.title || extractHtmlTitle(html, url);
const byline = ruleResult.byline || '';
const bundle = await bundleAssets(ruleResult.content, url);
const cover = await buildArticleCover(html, url, title, byline, rule);
return htmlToBook(
composeArticleContent(title, byline, bundle.html),
title,
byline,
url,
bundle.images,
cover,
);
}
}
let parsed: { title?: string | null; content?: string | null; byline?: string | null } | null;
try {
const doc = new DOMParser().parseFromString(sanitizeForParsing(html), 'text/html');
parsed = new Readability(doc).parse();
} catch (err) {
console.warn('[clip/page] readability threw', {
error: err instanceof Error ? err.message : String(err),
});
throw new ConversionError(`Could not extract article: ${String(err)}`, 'parse_failed');
}
if (!parsed?.content) {
console.warn('[clip/page] readability returned no content');
throw new ConversionError('No readable article found at the URL', 'parse_failed');
}
let articleHtml = parsed.content;
let contentText = stripTags(articleHtml);
console.log('[clip/page] readability', {
title: parsed.title || null,
byline: parsed.byline || null,
content_chars: articleHtml.length,
text_chars: contentText.length,
});
// Readability sometimes scores the wrong container on sites with custom
// markup (e.g. an article body wrapped in a non-semantic div revealed
// by JS). When the extracted text is tiny but the page HTML is
// substantial, try a known-selector fallback before giving up.
const QUALITY_FLOOR = 400;
if (contentText.length < QUALITY_FLOOR && html.length > 50_000) {
const fallback = extractArticleFallback(html, QUALITY_FLOOR);
if (fallback) {
articleHtml = fallback;
contentText = stripTags(articleHtml);
console.log('[clip/page] fallback selector picked up the article', {
text_chars: contentText.length,
});
}
}
// Bot-detection screens, paywall stubs and "please enable JavaScript"
// pages all slip past Readability AND the selector fallback with a
// tiny scrap of text. Refuse to import them — never save a junk EPUB.
if (contentText.length < QUALITY_FLOOR) {
throw new ConversionError(
'Could not read this page — it looks like a verification screen or a login wall. Open it in a browser first.',
'parse_failed',
);
}
const title = parsed.title?.trim() || extractHtmlTitle(html, url);
const byline = parsed.byline?.trim() || '';
const bundle = await bundleAssets(articleHtml, url);
const cover = await buildArticleCover(html, url, title, byline, rule);
return htmlToBook(
composeArticleContent(title, byline, bundle.html),
title,
byline,
url,
bundle.images,
cover,
);
}
/**
* Convert a document Readest cannot open natively into an EPUB. Runs entirely
* client-side (browser or Tauri webview) meant to be called inside a Web
@@ -255,6 +488,7 @@ function baseName(fileName: string | undefined, fallback: string): string {
export async function convertToEpub(input: ConvertInput): Promise<ConvertedBook> {
switch (input.kind) {
case 'txt': {
const { TxtToEpubConverter } = await import('@/utils/txt');
const result = await new TxtToEpubConverter().convert({ file: input.file });
return { file: result.file, title: result.bookTitle, author: '' };
}
@@ -264,17 +498,19 @@ export async function convertToEpub(input: ConvertInput): Promise<ConvertedBook>
}
let html: string;
try {
const mammoth = (await import('mammoth')).default;
const result = await mammoth.convertToHtml({ arrayBuffer: input.bytes });
html = result.value;
} catch (err) {
throw new ConversionError(`Could not parse .docx: ${String(err)}`, 'parse_failed');
}
return htmlToBook(html, baseName(input.fileName, 'Document'), '');
// Local files have no URL — hash their content for the identifier.
return htmlToBook(html, baseName(input.fileName, 'Document'), '', html);
}
case 'html': {
const raw = new TextDecoder('utf-8').decode(input.bytes);
const title = extractHtmlTitle(raw, baseName(input.fileName, 'Document'));
return htmlToBook(raw, title, '');
return htmlToBook(raw, title, '', raw);
}
case 'rtf': {
const rtf = new TextDecoder('utf-8').decode(input.bytes);
@@ -286,7 +522,7 @@ export async function convertToEpub(input: ConvertInput): Promise<ConvertedBook>
.split(/\n+/)
.map((line) => `<p>${line.replace(/[<>&]/g, ' ').trim()}</p>`)
.join('');
return htmlToBook(html, baseName(input.fileName, 'Document'), '');
return htmlToBook(html, baseName(input.fileName, 'Document'), '', html);
}
case 'article': {
let parsed: { title?: string | null; content?: string | null; byline?: string | null } | null;
@@ -301,98 +537,24 @@ export async function convertToEpub(input: ConvertInput): Promise<ConvertedBook>
}
const title = parsed.title?.trim() || extractHtmlTitle(input.html, input.url);
const byline = parsed.byline?.trim() || '';
return htmlToBook(composeArticleContent(title, byline, parsed.content), title, byline);
}
case 'page': {
// Same as `article`, but fetch every referenced image and inline it so
// the EPUB is fully offline-readable. Only valid from a CORS-free
// caller (Tauri webview or extension content script).
console.log('[clip/page] input', {
url: input.url,
html_bytes: input.html.length,
});
// Fast path: per-site rule. Skips Readability entirely on sites we
// know it mis-scores. Falls through if the rule's content selector
// matches nothing or the extracted text is below the floor.
const rule = findSiteRule(input.url);
if (rule) {
const ruleResult = extractWithSiteRule(input.html, rule);
const ruleText = ruleResult ? stripTags(ruleResult.content) : '';
console.log('[clip/page] site rule', {
name: rule.name,
matched: !!ruleResult,
text_chars: ruleText.length,
title: ruleResult?.title || null,
});
if (ruleResult && ruleText.length >= 400) {
const title = ruleResult.title || extractHtmlTitle(input.html, input.url);
const byline = ruleResult.byline || '';
const bundle = await bundleAssets(ruleResult.content, input.url);
return htmlToBook(
composeArticleContent(title, byline, bundle.html),
title,
byline,
bundle.images,
);
}
}
let parsed: { title?: string | null; content?: string | null; byline?: string | null } | null;
try {
const doc = new DOMParser().parseFromString(sanitizeForParsing(input.html), 'text/html');
parsed = new Readability(doc).parse();
} catch (err) {
console.warn('[clip/page] readability threw', {
error: err instanceof Error ? err.message : String(err),
});
throw new ConversionError(`Could not extract article: ${String(err)}`, 'parse_failed');
}
if (!parsed?.content) {
console.warn('[clip/page] readability returned no content');
throw new ConversionError('No readable article found at the URL', 'parse_failed');
}
let articleHtml = parsed.content;
let contentText = stripTags(articleHtml);
console.log('[clip/page] readability', {
title: parsed.title || null,
byline: parsed.byline || null,
content_chars: articleHtml.length,
text_chars: contentText.length,
});
// Readability sometimes scores the wrong container on sites with custom
// markup (e.g. an article body wrapped in a non-semantic div revealed
// by JS). When the extracted text is tiny but the page HTML is
// substantial, try a known-selector fallback before giving up.
const QUALITY_FLOOR = 400;
if (contentText.length < QUALITY_FLOOR && input.html.length > 50_000) {
const fallback = extractArticleFallback(input.html, QUALITY_FLOOR);
if (fallback) {
articleHtml = fallback;
contentText = stripTags(articleHtml);
console.log('[clip/page] fallback selector picked up the article', {
text_chars: contentText.length,
});
}
}
// Bot-detection screens, paywall stubs and "please enable JavaScript"
// pages all slip past Readability AND the selector fallback with a
// tiny scrap of text. Refuse to import them — never save a junk EPUB.
if (contentText.length < QUALITY_FLOOR) {
throw new ConversionError(
'Could not read this page — it looks like a verification screen or a login wall. Open it in a browser first, or wait for the upcoming browser extension.',
'parse_failed',
);
}
const title = parsed.title?.trim() || extractHtmlTitle(input.html, input.url);
const byline = parsed.byline?.trim() || '';
const bundle = await bundleAssets(articleHtml, input.url);
return htmlToBook(
composeArticleContent(title, byline, bundle.html),
const cover = await buildArticleCover(
input.html,
input.url,
title,
byline,
bundle.images,
findSiteRule(input.url),
);
return htmlToBook(
composeArticleContent(title, byline, parsed.content),
title,
byline,
input.url,
[],
cover,
);
}
case 'page': {
return convertPageToEpub(input.html, input.url);
}
default: {
throw new ConversionError(
@@ -0,0 +1,505 @@
import type { EpubImage } from './types';
/**
* Synthetic cover image for clipped articles. Produces a 600×900 SVG
* (2:3 the standard book-cover aspect ratio Readest uses everywhere
* else) with a vivid two-tone layout:
*
* - colored top block (theme color hashed from the author, so the same
* public-account / writer always gets the same cover style);
* - large multi-line title in white, left-aligned in the upper block;
* - circular avatar centered on a wave divider either the author's
* profile image (when one was found and fetched) or the site favicon;
* - lighter shade in the lower block;
* - author / byline at the bottom in dark type. Falls back to the
* site name when no byline was extracted.
*
* SVG is the right format here:
* - vector text + shapes stay crisp at every cover-thumb size;
* - no canvas-render step, so the page-clip pipeline stays main-thread-
* only and doesn't need OffscreenCanvas;
* - file size is well under 50 KB even with an embedded avatar;
* - foliate-js's epub reader (`packages/foliate-js/epub.js`) treats the
* cover blob as opaque bytes with `media-type` honored, so SVG works
* transparently with the `<meta name="cover">` route the EPUB
* builder uses.
*/
const VIEW_W = 600;
const VIEW_H = 900;
const TITLE_FONT_SIZE = 56;
const TITLE_LINE_HEIGHT = 72;
const TITLE_MAX_LINES = 5;
const TITLE_LEFT_PAD = 56;
const TITLE_RIGHT_PAD = 56;
const TITLE_TOP_Y = 100;
const AUTHOR_FONT_SIZE = 32;
const AUTHOR_Y = 830;
const WAVE_Y = 540;
const WAVE_DIP = 80;
const AVATAR_CX = VIEW_W / 2;
const AVATAR_CY = WAVE_Y + WAVE_DIP / 2;
const AVATAR_R = 60;
const AVATAR_RING_R = 66;
const FAMILY =
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif";
export interface CoverInput {
title: string;
siteName: string;
/** Byline / author for the article. Drives the theme color (so the same
* author always gets the same cover style) AND shows up as the bottom
* identifier. Falls back to siteName for both purposes when empty. */
author?: string;
/** Author profile image (e.g. WeChat public-account avatar). When
* provided, used as the circular cover avatar in place of the site
* favicon. */
authorImage?: { bytes: ArrayBuffer; mime: string };
/** Site favicon. Used as the avatar when no `authorImage` is provided. */
favicon?: { bytes: ArrayBuffer; mime: string };
}
/**
* Curated theme palette. Each entry is a (top, bottom) pair the top
* tone covers the upper block and the bottom tone the lower. The top
* tones all have enough contrast against white title text; the bottom
* tones are light enough that dark byline text stays legible.
*
* Ordering doesn't matter the author hash picks an index uniformly.
*/
const PALETTE: readonly { top: string; bottom: string }[] = [
{ top: '#0EBAE3', bottom: '#E2F4F9' }, // cyan
{ top: '#3E89C7', bottom: '#E5EEF6' }, // blue
{ top: '#E27D5C', bottom: '#F7DDD0' }, // coral
{ top: '#5D9D7E', bottom: '#DCE8E1' }, // sage
{ top: '#9B6BAC', bottom: '#E5D9EB' }, // lavender
{ top: '#D8A55E', bottom: '#F4E5C9' }, // ochre
{ top: '#2A9D8F', bottom: '#D5EAE7' }, // teal
{ top: '#7E6C6C', bottom: '#E5DDDD' }, // mauve
{ top: '#3E5C76', bottom: '#D9DEE4' }, // navy
{ top: '#A36677', bottom: '#E8D6DA' }, // wine
];
function escapeXml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
// `btoa` only takes a binary string; chunk to stay under the per-call argument
// limit on large images (some Apple touch icons / avatar PNGs run >100 KB).
function bytesToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = '';
const chunkSize = 0x8000;
for (let i = 0; i < bytes.length; i += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
}
return btoa(binary);
}
/**
* djb2 over the author / site name. Same input same theme color, so a
* public account's covers are visually consistent across articles.
*/
export function pickTheme(key: string): { top: string; bottom: string } {
const trimmed = key.trim();
if (!trimmed) return PALETTE[0]!;
let h = 5381;
for (let i = 0; i < trimmed.length; i++) {
h = ((h << 5) + h + trimmed.charCodeAt(i)) >>> 0;
}
return PALETTE[h % PALETTE.length]!;
}
/** Treat CJK chars (1 em wide) and Latin chars (0.55 em wide) separately
* when estimating how much title text fits on one line. Without this the
* heuristic either wraps Chinese articles way too tight or lets Latin
* titles overflow the cover. */
function isCjkChar(code: number): boolean {
// CJK Unified Ideographs, Hiragana, Katakana, CJK Ext A, Hangul Syllables,
// CJK Symbols and Punctuation (including 、 。「」『』 etc.), and the
// Halfwidth/Fullwidth Forms block (full-width comma 「,」, colon 「:」,
// and Latin glyphs). Width estimates fold them all into the ~1em column
// CJK fonts actually render them at, so the line-wrap budget doesn't
// under-count Chinese punctuation.
return (
(code >= 0x4e00 && code <= 0x9fff) ||
(code >= 0x3040 && code <= 0x30ff) ||
(code >= 0x3400 && code <= 0x4dbf) ||
(code >= 0xac00 && code <= 0xd7af) ||
(code >= 0x3000 && code <= 0x303f) ||
(code >= 0xff00 && code <= 0xffef)
);
}
function estimateTextWidthEm(text: string): number {
let w = 0;
for (const ch of text) {
w += isCjkChar(ch.codePointAt(0) ?? 0) ? 1.0 : 0.55;
}
return w;
}
/** Right-attaching CJK punctuation that should never start a line glues
* to the preceding character/word. Covers Chinese, Japanese, and the
* ASCII members (commas/periods) that appear inline with CJK. */
const CJK_RIGHT_PUNCT = /[、。,.:;!?”’)]〕」』〉》】〗…,.:;!?)\]—]/;
/** Left-attaching CJK punctuation that should never end a line glues
* to the following character/word. */
const CJK_LEFT_PUNCT = /[“‘([〔「『〈《【〖([]/;
interface TitleChunk {
/** The chunk's text either a single CJK character (possibly with a
* trailing right-punctuation glyph) or a run of Latin characters. */
text: string;
/** Whitespace that appeared before this chunk in the input, preserved
* verbatim. Dropped when this chunk starts a fresh line. */
sep: string;
}
/**
* Pick the ICU locale that gives the best word-segmentation for a title.
*
* `Intl.Segmenter` uses dictionaries when available Chinese, Japanese,
* Korean, Thai, Khmer, Burmese, Lao all get word-level breaks instead of
* character-by-character. With locale `undefined` ICU falls back to a
* generic rule set, which for CJK ends up at character granularity. So we
* sniff the dominant script and pass the matching locale; everything
* else (Latin, Cyrillic, Arabic, Thai, ) gets the runtime default and
* Intl handles it from there.
*/
function pickSegmenterLocale(text: string): string | undefined {
for (const ch of text) {
const code = ch.codePointAt(0) ?? 0;
// Hiragana / Katakana → Japanese dictionary
if (code >= 0x3040 && code <= 0x30ff) return 'ja';
// Hangul → Korean dictionary
if (code >= 0xac00 && code <= 0xd7af) return 'ko';
// CJK Unified Ideographs → Chinese dictionary
if (
(code >= 0x4e00 && code <= 0x9fff) ||
(code >= 0x3400 && code <= 0x4dbf) ||
(code >= 0x20000 && code <= 0x2a6df)
) {
return 'zh';
}
// Thai → Thai dictionary
if (code >= 0x0e00 && code <= 0x0e7f) return 'th';
// Khmer → Khmer dictionary
if (code >= 0x1780 && code <= 0x17ff) return 'km';
// Burmese → Burmese dictionary
if (code >= 0x1000 && code <= 0x109f) return 'my';
}
return undefined;
}
/**
* Split a title into wrap-friendly chunks. Prefers `Intl.Segmenter` with
* `granularity: 'word'` for language-aware breaks (handles Thai, Khmer,
* Japanese, Chinese 2-character compounds, etc. via ICU's dictionaries);
* falls back to a hand-rolled CJK-aware tokenizer when the runtime
* doesn't have `Intl.Segmenter` (older WebViews).
*
* On top of the segmenter we apply:
* - right-attaching punctuation (commas, periods, colons, closing
* quotes) glues to the preceding chunk so a line never *starts* with
* one;
* - left-attaching punctuation (opening quotes / brackets) glues to the
* following chunk so a line never *ends* with a dangling opener.
*/
function tokenizeTitle(text: string): TitleChunk[] {
if (typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function') {
try {
return tokenizeWithSegmenter(text);
} catch {
// Some environments don't ship full ICU data — fall through.
}
}
return tokenizeFallback(text);
}
function tokenizeWithSegmenter(text: string): TitleChunk[] {
const locale = pickSegmenterLocale(text);
const segmenter = new Intl.Segmenter(locale, { granularity: 'word' });
const chunks: TitleChunk[] = [];
let pendingSep = '';
let pendingLeftPunct = '';
for (const { segment } of segmenter.segment(text)) {
if (!segment) continue;
if (/^\s+$/.test(segment)) {
pendingSep = ' ';
continue;
}
if (CJK_RIGHT_PUNCT.test(segment) && chunks.length > 0 && !pendingLeftPunct) {
// Right-attaching punctuation — glue to the previous chunk so it
// never lands at the start of a line.
chunks[chunks.length - 1]!.text += segment;
pendingSep = '';
continue;
}
if (CJK_LEFT_PUNCT.test(segment)) {
pendingLeftPunct += segment;
continue;
}
chunks.push({ text: pendingLeftPunct + segment, sep: pendingSep });
pendingLeftPunct = '';
pendingSep = '';
}
if (pendingLeftPunct) {
chunks.push({ text: pendingLeftPunct, sep: pendingSep });
}
return chunks;
}
/** Hand-rolled tokenizer for runtimes without `Intl.Segmenter`. Treats
* each CJK char as its own chunk (so line breaks can happen between any
* two ideographs) and Latin runs as single chunks. Less linguistically
* aware than the segmenter CJK words like may break mid-compound
* but always available. */
function tokenizeFallback(text: string): TitleChunk[] {
const chunks: TitleChunk[] = [];
let pendingSep = '';
let pendingLeftPunct = '';
let latinBuf = '';
const flushLatin = () => {
if (latinBuf) {
chunks.push({ text: pendingLeftPunct + latinBuf, sep: pendingSep });
latinBuf = '';
pendingLeftPunct = '';
pendingSep = '';
}
};
for (const ch of text) {
if (/\s/.test(ch)) {
flushLatin();
pendingSep = pendingSep || ' ';
continue;
}
const code = ch.codePointAt(0) ?? 0;
if (CJK_RIGHT_PUNCT.test(ch) && chunks.length > 0 && !latinBuf) {
chunks[chunks.length - 1]!.text += ch;
pendingSep = '';
continue;
}
if (CJK_LEFT_PUNCT.test(ch)) {
flushLatin();
pendingLeftPunct += ch;
continue;
}
if (isCjkChar(code)) {
flushLatin();
chunks.push({ text: pendingLeftPunct + ch, sep: pendingSep });
pendingLeftPunct = '';
pendingSep = '';
continue;
}
latinBuf += ch;
}
flushLatin();
if (pendingLeftPunct) {
chunks.push({ text: pendingLeftPunct, sep: pendingSep });
}
return chunks;
}
/** Approximate em width of an inter-chunk space character. */
const SPACE_WIDTH_EM = 0.28;
/**
* Wrap a title into at most `TITLE_MAX_LINES` lines using chunk-aware
* breaking Latin words stay whole, CJK breaks between any two chars,
* and punctuation never lands alone at a line edge. If we run out of
* lines, the last line is truncated with an ellipsis.
*/
function wrapTitle(title: string, maxEmPerLine: number): string[] {
const trimmed = title.trim();
if (!trimmed) return [];
const chunks = tokenizeTitle(trimmed);
if (chunks.length === 0) return [];
const lines: string[] = [];
let current = '';
let currentWidth = 0;
const flush = (): boolean => {
if (!current) return true;
if (lines.length >= TITLE_MAX_LINES) {
// Out of lines — fold the rest of the title into a trailing
// ellipsis on the last kept line so the start is still readable.
const last = lines.pop() ?? '';
lines.push(truncateLineWithEllipsis(last, maxEmPerLine));
current = '';
currentWidth = 0;
return false;
}
lines.push(current);
current = '';
currentWidth = 0;
return true;
};
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i]!;
const sepWidth = chunk.sep && current ? SPACE_WIDTH_EM : 0;
const chunkWidth = estimateTextWidthEm(chunk.text);
if (!current) {
// Starting a fresh line — drop any leading whitespace separator.
current = chunk.text;
currentWidth = chunkWidth;
} else if (currentWidth + sepWidth + chunkWidth <= maxEmPerLine) {
current += (chunk.sep ? ' ' : '') + chunk.text;
currentWidth += sepWidth + chunkWidth;
} else {
if (!flush()) return lines;
current = chunk.text;
currentWidth = chunkWidth;
}
// A single chunk wider than the budget can only be a very long Latin
// word (CJK chunks are at most one char + punctuation). Hard-wrap it
// across multiple lines so the cover doesn't overflow.
while (currentWidth > maxEmPerLine) {
const split = hardSplit(current, maxEmPerLine);
lines.push(split.head);
if (lines.length >= TITLE_MAX_LINES) {
const last = lines.pop() ?? '';
lines.push(truncateLineWithEllipsis(last, maxEmPerLine));
return lines;
}
current = split.tail;
currentWidth = estimateTextWidthEm(current);
}
}
if (current) lines.push(current);
if (lines.length > TITLE_MAX_LINES) {
const head = lines.slice(0, TITLE_MAX_LINES);
const last = head.pop() ?? '';
head.push(truncateLineWithEllipsis(last, maxEmPerLine));
return head;
}
return lines;
}
function truncateLineWithEllipsis(line: string, maxEmPerLine: number): string {
const ELLIPSIS = '…';
const ellipsisWidth = estimateTextWidthEm(ELLIPSIS);
let s = line;
while (s.length > 0 && estimateTextWidthEm(s) + ellipsisWidth > maxEmPerLine) {
s = s.slice(0, -1);
}
return `${s.trimEnd()}${ELLIPSIS}`;
}
function hardSplit(token: string, maxEmPerLine: number): { head: string; tail: string } {
let head = '';
let i = 0;
while (i < token.length) {
const next = head + token[i]!;
if (estimateTextWidthEm(next) > maxEmPerLine) break;
head = next;
i++;
}
return { head: head || token.slice(0, 1), tail: token.slice(head.length || 1) };
}
/**
* Generate a 600×900 SVG cover suitable for embedding in an EPUB as
* `image/svg+xml`. The output is a single self-contained document no
* external font, no external image, no script.
*/
export function generateCoverSvg(input: CoverInput): EpubImage {
const title = input.title?.trim() || 'Untitled';
const siteName = input.siteName?.trim() || '';
const author = input.author?.trim() || '';
// The hash key drives theme stability — same author → same colors. When
// no author was parsed we fall back to the site name so per-site clips
// still get a stable look.
const theme = pickTheme(author || siteName);
// Title font width budget — viewport - L pad - R pad.
const titleBudgetPx = VIEW_W - TITLE_LEFT_PAD - TITLE_RIGHT_PAD;
const maxEmPerLine = titleBudgetPx / TITLE_FONT_SIZE;
const titleLines = wrapTitle(title, maxEmPerLine);
const titleTspans = titleLines
.map((line, i) => {
const y = TITLE_TOP_Y + TITLE_FONT_SIZE + i * TITLE_LINE_HEIGHT;
return `<text x="${TITLE_LEFT_PAD}" y="${y}" text-anchor="start" fill="#ffffff" font-family="${FAMILY}" font-size="${TITLE_FONT_SIZE}" font-weight="700" letter-spacing="0.5">${escapeXml(line)}</text>`;
})
.join('\n ');
// Wave divider: starts flat at the left edge, dips down through the
// center where the avatar sits, returns flat at the right edge. The
// colored top block "owns" the wave so its top half hugs the avatar.
const wavePath = `M 0 ${WAVE_Y} C 120 ${WAVE_Y} 180 ${WAVE_Y + WAVE_DIP} ${VIEW_W / 2} ${WAVE_Y + WAVE_DIP} C ${VIEW_W - 180} ${WAVE_Y + WAVE_DIP} ${VIEW_W - 120} ${WAVE_Y} ${VIEW_W} ${WAVE_Y} L ${VIEW_W} 0 L 0 0 Z`;
// Avatar: profile image if we have one, else favicon, else an initial
// tile. Always rendered as a circle with a white ring.
const avatarImage = input.authorImage ?? input.favicon ?? null;
const avatarContent = avatarImage
? renderAvatarImage(avatarImage)
: renderInitialAvatar(author || siteName);
// Bottom block text: author when present, else site name. Truncated to
// a single line so the bottom never overflows. Dark theme color so it
// reads on the light bottom shade.
const bottomText = author || siteName;
const bottomBudgetEm = (VIEW_W - 80) / AUTHOR_FONT_SIZE;
const bottomLine = bottomText ? fitOneLine(bottomText, bottomBudgetEm) : '';
const bottomTextEl = bottomLine
? `<text x="${VIEW_W / 2}" y="${AUTHOR_Y}" text-anchor="middle" fill="#2b2b2b" font-family="${FAMILY}" font-size="${AUTHOR_FONT_SIZE}" font-weight="600" letter-spacing="1">${escapeXml(bottomLine)}</text>`
: '';
const svg = `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${VIEW_W} ${VIEW_H}" width="${VIEW_W}" height="${VIEW_H}" preserveAspectRatio="xMidYMid meet">
<defs>
<clipPath id="avatar-clip">
<circle cx="${AVATAR_CX}" cy="${AVATAR_CY}" r="${AVATAR_R}"/>
</clipPath>
</defs>
<rect width="${VIEW_W}" height="${VIEW_H}" fill="${theme.bottom}"/>
<path d="${wavePath}" fill="${theme.top}"/>
${titleTspans}
<circle cx="${AVATAR_CX}" cy="${AVATAR_CY}" r="${AVATAR_RING_R}" fill="#ffffff"/>
${avatarContent}
${bottomTextEl}
</svg>`;
const bytes = new TextEncoder().encode(svg).buffer as ArrayBuffer;
return { path: 'cover.svg', bytes, mime: 'image/svg+xml' };
}
function renderAvatarImage(image: { bytes: ArrayBuffer; mime: string }): string {
const base64 = bytesToBase64(image.bytes);
const mime = image.mime || 'image/png';
const x = AVATAR_CX - AVATAR_R;
const y = AVATAR_CY - AVATAR_R;
const size = AVATAR_R * 2;
return `<image x="${x}" y="${y}" width="${size}" height="${size}" preserveAspectRatio="xMidYMid slice" clip-path="url(#avatar-clip)" href="data:${escapeXml(mime)};base64,${base64}"/>`;
}
function renderInitialAvatar(label: string): string {
// First non-whitespace character — handles "Site Name" → "S", "机器之心" → "机".
const first = label.replace(/\s+/g, '').slice(0, 1) || '·';
const cy = AVATAR_CY + AVATAR_R * 0.18;
return `<circle cx="${AVATAR_CX}" cy="${AVATAR_CY}" r="${AVATAR_R}" fill="#eef0f2"/>
<text x="${AVATAR_CX}" y="${cy}" text-anchor="middle" fill="#3a3a3a" font-family="${FAMILY}" font-size="${(AVATAR_R * 1.1).toFixed(0)}" font-weight="700">${escapeXml(first)}</text>`;
}
/** Truncate a single line to fit a width budget, appending an ellipsis. */
function fitOneLine(text: string, maxEmPerLine: number): string {
const t = text.trim();
if (!t || estimateTextWidthEm(t) <= maxEmPerLine) return t;
return truncateLineWithEllipsis(t, maxEmPerLine);
}
@@ -0,0 +1,197 @@
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
import { isTauriAppPlatform } from '@/services/environment';
import { imageFetchHeaders } from './httpHeaders';
/**
* Find and fetch the best icon for a clipped article the icon that
* lands on the synthetic cover. We prefer the page's `<link rel="apple-
* touch-icon">` (180×180 PNG on most sites), fall back to high-res
* `<link rel="icon">` tags, then settle for `/favicon.ico`.
*
* Returns `null` on any failure the cover generator's initial-letter
* tile takes over from there.
*/
const FETCH_TIMEOUT_MS = 6_000;
const MAX_FAVICON_BYTES = 512 * 1024;
const PREFERRED_RELS = [
'apple-touch-icon-precomposed',
'apple-touch-icon',
'icon',
'shortcut icon',
'mask-icon',
];
const HOSTED_FALLBACK_PATHS = [
'/apple-touch-icon.png',
'/apple-touch-icon-precomposed.png',
'/favicon.ico',
];
const httpFetch = (url: string, referer: string | null, init?: RequestInit): Promise<Response> => {
if (!isTauriAppPlatform()) {
// In the extension SW (the only non-Tauri caller today) include
// credentials so cookie-gated favicons / author images on paywalled
// hosts come through with the user's session.
return globalThis.fetch(url, { credentials: 'include', ...init });
}
const baseHeaders = imageFetchHeaders(referer);
const headers = new Headers(init?.headers);
for (const [k, v] of Object.entries(baseHeaders)) {
if (!headers.has(k)) headers.set(k, v);
}
return tauriFetch(url, { ...init, headers });
};
interface IconCandidate {
url: string;
/** Higher = more preferred. Picked from rel attribute + sizes attribute. */
score: number;
}
function parseSizes(sizes: string | null): number {
if (!sizes) return 0;
if (sizes.toLowerCase() === 'any') return 1024;
// "180x180" → 180; "32x32 16x16" → 32 (largest).
let max = 0;
for (const m of sizes.matchAll(/(\d+)\s*x\s*(\d+)/gi)) {
const w = parseInt(m[1]!, 10);
if (w > max) max = w;
}
return max;
}
function relScore(rel: string): number {
const lowered = rel.toLowerCase();
// Apple touch icons are usually 180×180 PNGs and look much better on the
// cover than 16×16 favicons. Boost them above plain "icon".
if (lowered.includes('apple-touch-icon-precomposed')) return 1000;
if (lowered.includes('apple-touch-icon')) return 900;
if (lowered.includes('mask-icon')) return 100;
if (lowered.includes('shortcut')) return 200;
if (lowered.includes('icon')) return 500;
return 0;
}
/** Extract candidate icon URLs from a parsed HTML document. */
export function extractIconCandidates(doc: Document, pageUrl: string): IconCandidate[] {
const candidates: IconCandidate[] = [];
for (const link of Array.from(doc.querySelectorAll('link[rel]'))) {
const rel = link.getAttribute('rel') ?? '';
if (!PREFERRED_RELS.some((r) => rel.toLowerCase().split(/\s+/).includes(r))) {
continue;
}
const href = link.getAttribute('href');
if (!href) continue;
let url: string;
try {
url = new URL(href, pageUrl).toString();
} catch {
continue;
}
const size = parseSizes(link.getAttribute('sizes'));
candidates.push({ url, score: relScore(rel) + size });
}
candidates.sort((a, b) => b.score - a.score);
return candidates;
}
/**
* Fetch an author profile image (e.g. WeChat public-account avatar). Same
* shape as `fetchFavicon` but with a bigger size cap avatars are often
* 200×200 PNGs that comfortably exceed the favicon ceiling.
*/
export async function fetchAuthorImage(
url: string,
referer: string | null,
): Promise<{ bytes: ArrayBuffer; mime: string } | null> {
return fetchImage(url, referer, MAX_AUTHOR_IMAGE_BYTES);
}
async function fetchFavicon(
url: string,
referer: string | null,
): Promise<{ bytes: ArrayBuffer; mime: string } | null> {
return fetchImage(url, referer, MAX_FAVICON_BYTES);
}
const MAX_AUTHOR_IMAGE_BYTES = 2 * 1024 * 1024;
async function fetchImage(
url: string,
referer: string | null,
maxBytes: number,
): Promise<{ bytes: ArrayBuffer; mime: string } | null> {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), FETCH_TIMEOUT_MS);
try {
const res = await httpFetch(url, referer, { signal: ac.signal, redirect: 'follow' });
if (!res.ok) return null;
const bytes = await res.arrayBuffer();
if (bytes.byteLength === 0 || bytes.byteLength > maxBytes) return null;
let mime = (res.headers.get('content-type') || '').split(';')[0]!.trim().toLowerCase();
if (!mime.startsWith('image/')) {
// Some CDNs serve `application/octet-stream` for `/favicon.ico` even
// when it's a valid image — infer from the URL extension instead.
const ext = url.match(/\.([a-z0-9]{2,5})(?:\?|#|$)/i)?.[1]?.toLowerCase();
if (ext === 'ico') mime = 'image/x-icon';
else if (ext === 'png') mime = 'image/png';
else if (ext === 'svg') mime = 'image/svg+xml';
else if (ext === 'jpg' || ext === 'jpeg') mime = 'image/jpeg';
else if (ext === 'webp') mime = 'image/webp';
else return null;
}
return { bytes, mime };
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
/**
* Walk a parsed page document for icon `<link>` tags, fetch the highest-
* scoring candidate that responds, and return it. Falls back to common
* well-known paths (`/apple-touch-icon.png`, `/favicon.ico`) when no
* `<link>` icon is declared or none of them load.
*
* Caller is expected to provide a parsed Document `kind: 'page'` and
* `kind: 'article'` both already parse the page once for Readability, so
* this avoids a second parse.
*/
export async function fetchBestFavicon(
doc: Document,
pageUrl: string,
): Promise<{ bytes: ArrayBuffer; mime: string } | null> {
const candidates = extractIconCandidates(doc, pageUrl);
// Always check the well-known fallback paths last — some sites omit the
// <link> tag but still host /apple-touch-icon.png.
let origin: string;
try {
origin = new URL(pageUrl).origin;
} catch {
origin = '';
}
if (origin) {
for (const path of HOSTED_FALLBACK_PATHS) {
candidates.push({ url: `${origin}${path}`, score: -1 });
}
}
// De-dupe so a `<link rel="apple-touch-icon" href="/apple-touch-icon.png">`
// doesn't compete with the same URL added as a hosted fallback.
const seen = new Set<string>();
const ordered = candidates.filter((c) => {
if (seen.has(c.url)) return false;
seen.add(c.url);
return true;
});
for (const candidate of ordered) {
const result = await fetchFavicon(candidate.url, pageUrl);
if (result) return result;
}
return null;
}
@@ -25,6 +25,12 @@ export interface SiteRule {
title?: string;
/** Override Readability's byline. */
byline?: string;
/** CSS selector for an `<img>` (or element with a usable `src`) that
* represents the author / public-account avatar. Used by the cover
* generator as a circular avatar visually richer than a site-wide
* favicon. Best-effort: when the selector misses or the fetch fails,
* the cover falls back to the favicon. */
authorImage?: string;
/** Selectors to remove from the content before bundling (e.g. "open in
* app" CTAs, QR codes, share buttons). */
strip?: string[];
@@ -41,6 +47,8 @@ const RULES: SiteRule[] = [
content: '#js_content',
title: '#activity-name, h1.rich_media_title',
byline: '#js_name, .rich_media_meta_nickname',
authorImage:
'.profile_avatar img, .rich_media_meta_nickname_avatar img, .weui_media_avatar img, img.identity_icon, .wx_follow_avatar img',
strip: [
// Inline QR codes (PC + mobile variants)
'.qr_code_pc',