From 0d613edb5092a51c23fb133d688f93f3f88c1677 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Thu, 12 Nov 2026 15:00:29 -0600 Subject: [PATCH 01/17] Specify the page icon that travels in a URL push's envelope Every client drew a link's icon from Google's favicon service, which handed Google the host of every pushed link, in plaintext and with the device's address, each time a list was drawn. That is exactly what the envelope exists to keep from Google. The `icon` field is a small PNG that the device which scrapes the title captures from the page in the same pass and seals with the title, so no device asks anyone for an icon to draw a row. The section records the bounds, the candidate order, when a push is rewritten, how a row falls back, and why fetching from the site at display time and a drawn monogram were rejected. File-type icons ship inside each app so that pushthat.link's logs stop seeing encrypted files' types. Co-Authored-By: Claude Opus 5.5 --- docs/e2ee.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/e2ee.md b/docs/e2ee.md index f68dbc1..3257481 100644 --- a/docs/e2ee.md +++ b/docs/e2ee.md @@ -1,6 +1,6 @@ # End-to-end encryption -A push's content (its URL, its title, a file's name and bytes) is readable only on the account's own devices. Firestore, Cloud Storage, Cloud Functions, Google and we hold ciphertext and the metadata needed to count, deliver and expire it. The key is derived from the account password on the device, so the password is the encryption and the user can see that it is: there is no second passphrase and nothing to trust in the background. +A push's content (its URL, its page's title and icon, a file's name and bytes) is readable only on the account's own devices. Firestore, Cloud Storage, Cloud Functions, Google and we hold ciphertext and the metadata needed to count, deliver and expire it. The key is derived from the account password on the device, so the password is the encryption and the user can see that it is: there is no second passphrase and nothing to trust in the background. Decided 2026-10-26, after weighing a separate encryption passphrase (Pushbullet's model) and per-device key pairs with pairing. The password-derived design was chosen because every device already signs in with the password, so there is no new ceremony, and because a user who knows the password is the key understands what is encrypted and what a forgotten password costs. The costs that come with it are stated plainly below: a password reset without the recovery key loses the history, and a server-side title lookup for web pushes is no longer possible. @@ -103,7 +103,7 @@ The plaintext is a JSON object: | Type | Fields | | ------ | ------------------------------------------------------------------------------------------------------- | -| `url` | `url`, `title` (may be empty until scraped), `ext` | +| `url` | `url`, `title` (may be empty until scraped), `ext`, `icon` (optional, see Icons) | | `file` | `name`, `mime`, `size`, `ext`, `fk` (the per-file key, base64), `np` (the file's 8-byte nonce prefix, base64) | The plaintext is the object's JSON in UTF-8 and nothing more is fixed: there is no canonical form, since a client encrypts what its own serializer wrote and parses what it finds, so the vector gives the exact bytes it encrypted rather than an object to re-serialize. `size` is a JSON number. No `url`, `title`, `deviceName` or `ext` field may exist in plaintext on a document that has `enc`; the rules refuse them, so a downgraded client cannot leak by accident. @@ -187,6 +187,18 @@ The web app asks a Cloud Function, `getPageTitle`, to fetch a page's title, beca Until a title arrives the list shows the URL, as it does today for a push with no title. +## Icons + +A row shows the linked site's icon, and the icon travels in the envelope with the title, so no device asks anyone for it in order to draw the list. Until this change every client drew it from Google's favicon service (`www.google.com/s2/favicons?domain=`), which handed Google, in plaintext and with the device's address, the host of every link each time a list was drawn: exactly what the envelope keeps from Google. Two other ways were weighed and rejected (decided 2026-11-12). Loading the icon from the site when the row is drawn tells the site about every device that views the list, and makes the web app contact sites it otherwise never touches. Drawing a letter on a colored tile leaks nothing but loses the logos people recognize a link by. + +`icon` is the standard base64 (with padding) of a PNG no larger than 64 by 64 pixels and no more than 4096 bytes. It is optional: a push made before this change, a push whose page has no icon a platform can decode, and a file push have none, and an empty string means none. + +**Capturing it.** The device that scrapes the title (Titles, above: Android, iOS and macOS) fetches the icon in the same pass, from the page it already downloaded. The candidates, in order: every `` naming `apple-touch-icon` or `apple-touch-icon-precomposed`, then every one naming `icon` (including `shortcut icon`) whose `type` is not `image/svg+xml` and whose path does not end in `.svg`, then `/favicon.ico` at the page's origin. Each `href` is resolved against the page's final URL, after redirects (a `` is not followed); only `http` and `https` are fetched. At most eight `` candidates are tried before `/favicon.ico`, so a page full of broken links cannot hold the scraper for hundreds of requests, and a candidate over 256 KiB is skipped rather than decoded cut short. The first candidate that the platform's own image decoder opens decides: it is scaled down to fit 64 by 64 with its aspect kept (never up) and encoded as PNG; over 4096 bytes, it is scaled to fit 32 by 32 and tried once more, and over that there is no icon, without trying the next candidate. Only an encrypted URL push is given one; a plaintext push left from before encryption is not. The decoders differ (an ICO with several sizes may yield a different frame on each platform), which is why the vectors do not pin an icon: what is fixed is the field and its bounds, not the pixels. + +**Writing it.** The icon goes into the rewrite that fills in the title, so the rule (`enc` and `searchTokens`, `isEncryptedRetitle`) is unchanged. A rewrite happens when the title changed or an icon was found for a push that had none. A push that has an icon is not asked for one again and keeps it, and `icon` is written only when there is one, so a push without it encrypts the same bytes as before. 4096 bytes is 5464 characters of base64, which leaves the URL and title room inside `enc.ct`'s 16384 for every ordinary link; when the envelope with the icon would not fit, the icon is dropped and the title written alone. Which pushes are scraped is as before: a device's own, and a received one with no title. Old pushes are not scraped again for an icon. + +**Showing it.** A URL push draws its decoded icon; without one, or when it does not decode, the icon for its `ext` (a link to a PDF has no page to take an icon from), and failing that the placeholder a locked push draws. A file push draws the icon for its `ext` or the placeholder. Nothing is fetched to draw a row. The web app builds a `data:image/png;base64,` URL only from a string of base64 characters. A file push draws the icon for its `ext` from images shipped inside each app, never from pushthat.link, whose logs would otherwise see the type of every encrypted file a list showed. + ## Rules `firestore.rules`: From 0e54ff56afe82049f331a67d5ec905fc40808f38 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Thu, 12 Nov 2026 15:00:29 -0600 Subject: [PATCH 02/17] Draw web list icons from the push instead of Google's favicon service The list built `https://www.google.com/s2/favicons?domain=` for every URL push, so Google learned the host of every pushed link each time the list was drawn, and file icons came from `/files/.svg`, which put the type of each encrypted file into the hosting logs. A URL push now draws the `icon` from its decrypted envelope (docs/e2ee.md, Icons) as a `data:` URL, accepted only when it is plain base64 within the spec's bound. Without one it falls back to the file-type icon for its `ext`, then the placeholder. The file-type SVGs moved into `src/assets` and are inlined into the bundle, so drawing a row makes no request at all. The PNGs stay in `public/files` for app versions from before this change. Co-Authored-By: Claude Opus 5.5 --- .../files => src/assets/file-icons}/aac.svg | 0 .../files => src/assets/file-icons}/ai.svg | 0 .../files => src/assets/file-icons}/apk.svg | 0 .../files => src/assets/file-icons}/avi.svg | 0 .../files => src/assets/file-icons}/css.svg | 0 .../files => src/assets/file-icons}/csv.svg | 0 .../files => src/assets/file-icons}/doc.svg | 0 .../files => src/assets/file-icons}/flac.svg | 0 .../files => src/assets/file-icons}/gif.svg | 0 .../files => src/assets/file-icons}/html.svg | 0 .../files => src/assets/file-icons}/indd.svg | 0 .../files => src/assets/file-icons}/jpeg.svg | 0 .../files => src/assets/file-icons}/jpg.svg | 0 .../files => src/assets/file-icons}/js.svg | 0 .../files => src/assets/file-icons}/mov.svg | 0 .../files => src/assets/file-icons}/mp3.svg | 0 .../files => src/assets/file-icons}/mp4.svg | 0 .../files => src/assets/file-icons}/pdf.svg | 0 .../files => src/assets/file-icons}/png.svg | 0 .../files => src/assets/file-icons}/pproj.svg | 0 .../files => src/assets/file-icons}/psd.svg | 0 .../files => src/assets/file-icons}/tiff.svg | 0 .../files => src/assets/file-icons}/txt.svg | 0 .../files => src/assets/file-icons}/xls.svg | 0 .../files => src/assets/file-icons}/xml.svg | 0 .../files => src/assets/file-icons}/zip.svg | 0 apps/web/src/components/PushesList.tsx | 100 ++++++------------ apps/web/src/services/e2ee.ts | 7 +- apps/web/src/services/pushes.ts | 24 ++++- 29 files changed, 59 insertions(+), 72 deletions(-) rename apps/web/{public/files => src/assets/file-icons}/aac.svg (100%) rename apps/web/{public/files => src/assets/file-icons}/ai.svg (100%) rename apps/web/{public/files => src/assets/file-icons}/apk.svg (100%) rename apps/web/{public/files => src/assets/file-icons}/avi.svg (100%) rename apps/web/{public/files => src/assets/file-icons}/css.svg (100%) rename apps/web/{public/files => src/assets/file-icons}/csv.svg (100%) rename apps/web/{public/files => src/assets/file-icons}/doc.svg (100%) rename apps/web/{public/files => src/assets/file-icons}/flac.svg (100%) rename apps/web/{public/files => src/assets/file-icons}/gif.svg (100%) rename apps/web/{public/files => src/assets/file-icons}/html.svg (100%) rename apps/web/{public/files => src/assets/file-icons}/indd.svg (100%) rename apps/web/{public/files => src/assets/file-icons}/jpeg.svg (100%) rename apps/web/{public/files => src/assets/file-icons}/jpg.svg (100%) rename apps/web/{public/files => src/assets/file-icons}/js.svg (100%) rename apps/web/{public/files => src/assets/file-icons}/mov.svg (100%) rename apps/web/{public/files => src/assets/file-icons}/mp3.svg (100%) rename apps/web/{public/files => src/assets/file-icons}/mp4.svg (100%) rename apps/web/{public/files => src/assets/file-icons}/pdf.svg (100%) rename apps/web/{public/files => src/assets/file-icons}/png.svg (100%) rename apps/web/{public/files => src/assets/file-icons}/pproj.svg (100%) rename apps/web/{public/files => src/assets/file-icons}/psd.svg (100%) rename apps/web/{public/files => src/assets/file-icons}/tiff.svg (100%) rename apps/web/{public/files => src/assets/file-icons}/txt.svg (100%) rename apps/web/{public/files => src/assets/file-icons}/xls.svg (100%) rename apps/web/{public/files => src/assets/file-icons}/xml.svg (100%) rename apps/web/{public/files => src/assets/file-icons}/zip.svg (100%) diff --git a/apps/web/public/files/aac.svg b/apps/web/src/assets/file-icons/aac.svg similarity index 100% rename from apps/web/public/files/aac.svg rename to apps/web/src/assets/file-icons/aac.svg diff --git a/apps/web/public/files/ai.svg b/apps/web/src/assets/file-icons/ai.svg similarity index 100% rename from apps/web/public/files/ai.svg rename to apps/web/src/assets/file-icons/ai.svg diff --git a/apps/web/public/files/apk.svg b/apps/web/src/assets/file-icons/apk.svg similarity index 100% rename from apps/web/public/files/apk.svg rename to apps/web/src/assets/file-icons/apk.svg diff --git a/apps/web/public/files/avi.svg b/apps/web/src/assets/file-icons/avi.svg similarity index 100% rename from apps/web/public/files/avi.svg rename to apps/web/src/assets/file-icons/avi.svg diff --git a/apps/web/public/files/css.svg b/apps/web/src/assets/file-icons/css.svg similarity index 100% rename from apps/web/public/files/css.svg rename to apps/web/src/assets/file-icons/css.svg diff --git a/apps/web/public/files/csv.svg b/apps/web/src/assets/file-icons/csv.svg similarity index 100% rename from apps/web/public/files/csv.svg rename to apps/web/src/assets/file-icons/csv.svg diff --git a/apps/web/public/files/doc.svg b/apps/web/src/assets/file-icons/doc.svg similarity index 100% rename from apps/web/public/files/doc.svg rename to apps/web/src/assets/file-icons/doc.svg diff --git a/apps/web/public/files/flac.svg b/apps/web/src/assets/file-icons/flac.svg similarity index 100% rename from apps/web/public/files/flac.svg rename to apps/web/src/assets/file-icons/flac.svg diff --git a/apps/web/public/files/gif.svg b/apps/web/src/assets/file-icons/gif.svg similarity index 100% rename from apps/web/public/files/gif.svg rename to apps/web/src/assets/file-icons/gif.svg diff --git a/apps/web/public/files/html.svg b/apps/web/src/assets/file-icons/html.svg similarity index 100% rename from apps/web/public/files/html.svg rename to apps/web/src/assets/file-icons/html.svg diff --git a/apps/web/public/files/indd.svg b/apps/web/src/assets/file-icons/indd.svg similarity index 100% rename from apps/web/public/files/indd.svg rename to apps/web/src/assets/file-icons/indd.svg diff --git a/apps/web/public/files/jpeg.svg b/apps/web/src/assets/file-icons/jpeg.svg similarity index 100% rename from apps/web/public/files/jpeg.svg rename to apps/web/src/assets/file-icons/jpeg.svg diff --git a/apps/web/public/files/jpg.svg b/apps/web/src/assets/file-icons/jpg.svg similarity index 100% rename from apps/web/public/files/jpg.svg rename to apps/web/src/assets/file-icons/jpg.svg diff --git a/apps/web/public/files/js.svg b/apps/web/src/assets/file-icons/js.svg similarity index 100% rename from apps/web/public/files/js.svg rename to apps/web/src/assets/file-icons/js.svg diff --git a/apps/web/public/files/mov.svg b/apps/web/src/assets/file-icons/mov.svg similarity index 100% rename from apps/web/public/files/mov.svg rename to apps/web/src/assets/file-icons/mov.svg diff --git a/apps/web/public/files/mp3.svg b/apps/web/src/assets/file-icons/mp3.svg similarity index 100% rename from apps/web/public/files/mp3.svg rename to apps/web/src/assets/file-icons/mp3.svg diff --git a/apps/web/public/files/mp4.svg b/apps/web/src/assets/file-icons/mp4.svg similarity index 100% rename from apps/web/public/files/mp4.svg rename to apps/web/src/assets/file-icons/mp4.svg diff --git a/apps/web/public/files/pdf.svg b/apps/web/src/assets/file-icons/pdf.svg similarity index 100% rename from apps/web/public/files/pdf.svg rename to apps/web/src/assets/file-icons/pdf.svg diff --git a/apps/web/public/files/png.svg b/apps/web/src/assets/file-icons/png.svg similarity index 100% rename from apps/web/public/files/png.svg rename to apps/web/src/assets/file-icons/png.svg diff --git a/apps/web/public/files/pproj.svg b/apps/web/src/assets/file-icons/pproj.svg similarity index 100% rename from apps/web/public/files/pproj.svg rename to apps/web/src/assets/file-icons/pproj.svg diff --git a/apps/web/public/files/psd.svg b/apps/web/src/assets/file-icons/psd.svg similarity index 100% rename from apps/web/public/files/psd.svg rename to apps/web/src/assets/file-icons/psd.svg diff --git a/apps/web/public/files/tiff.svg b/apps/web/src/assets/file-icons/tiff.svg similarity index 100% rename from apps/web/public/files/tiff.svg rename to apps/web/src/assets/file-icons/tiff.svg diff --git a/apps/web/public/files/txt.svg b/apps/web/src/assets/file-icons/txt.svg similarity index 100% rename from apps/web/public/files/txt.svg rename to apps/web/src/assets/file-icons/txt.svg diff --git a/apps/web/public/files/xls.svg b/apps/web/src/assets/file-icons/xls.svg similarity index 100% rename from apps/web/public/files/xls.svg rename to apps/web/src/assets/file-icons/xls.svg diff --git a/apps/web/public/files/xml.svg b/apps/web/src/assets/file-icons/xml.svg similarity index 100% rename from apps/web/public/files/xml.svg rename to apps/web/src/assets/file-icons/xml.svg diff --git a/apps/web/public/files/zip.svg b/apps/web/src/assets/file-icons/zip.svg similarity index 100% rename from apps/web/public/files/zip.svg rename to apps/web/src/assets/file-icons/zip.svg diff --git a/apps/web/src/components/PushesList.tsx b/apps/web/src/components/PushesList.tsx index c6f1ff7..ddaed25 100644 --- a/apps/web/src/components/PushesList.tsx +++ b/apps/web/src/components/PushesList.tsx @@ -1,4 +1,3 @@ -// cspell:ignore pproj indd flac import clsx from 'clsx'; import { getStorage } from 'firebase/storage'; import { Dispatch, SetStateAction, useState } from 'react'; @@ -16,43 +15,32 @@ interface PushesListProps { onSelectedPushesChange: Dispatch>>; } -const ICONS = [ - 'pproj.svg', - 'zip.svg', - 'indd.svg', - 'csv.svg', - 'css.svg', - 'pdf.svg', - 'aac.svg', - 'png.svg', - 'xls.svg', - 'avi.svg', - 'txt.svg', - 'mp3.svg', - 'tiff.svg', - 'psd.svg', - 'jpg.svg', - 'jpeg.svg', - 'mov.svg', - 'html.svg', - 'ai.svg', - 'js.svg', - 'flac.svg', - 'mp4.svg', - 'gif.svg', - 'doc.svg', - 'xml.svg', - 'apk.svg', -]; - -const faviconOf = (url: string | undefined): string => { - let host = ''; - try { - host = url ? new URL(url).hostname : ''; - } catch (error) { - console.error('Error parsing URL:', error); - } - return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(host)}&sz=256`; +/** + * A file push's icon by its extension. Bundled, and small enough that Vite inlines each one + * as a data URL (`?inline` makes sure), so drawing the list asks no server anything: served + * from pushthat.link/files they put the type of every encrypted file a list showed in the + * hosting logs (docs/e2ee.md, Icons). The PNGs in `public/files` stay, for app versions + * from before this change, which still load them from there. + */ +const FILE_ICONS: Record = Object.fromEntries( + Object.entries( + import.meta.glob('../assets/file-icons/*.svg', { eager: true, query: '?inline', import: 'default' }) + ).map(([path, src]) => [path.slice(path.lastIndexOf('/') + 1, -'.svg'.length), src]) +); + +/** The placeholder for a push with no icon of its own, and for a locked one. */ +const PLACEHOLDER = '/pushthat-mono.svg'; + +/** + * What a row's icon shows: a URL push's page icon, else the icon for its type (a link to a + * PDF has no page to take an icon from), else the placeholder. Never the network: the page + * icon came with the push. Google's favicon service drew these until it was seen to learn + * every pushed host from every list (docs/e2ee.md, Icons). + */ +const iconSrc = (push: Push, broken: boolean): string => { + if (push.locked) return PLACEHOLDER; + if (push.icon && !broken) return `data:image/png;base64,${push.icon}`; + return (push.ext && FILE_ICONS[push.ext.toLowerCase()]) || PLACEHOLDER; }; /** @@ -86,7 +74,8 @@ export default function PushesList({ selectedPushes, onSelectedPushesChange, }: PushesListProps) { - const [lowResImages, setLowResImages] = useState>(new Set()); + // Page icons that would not decode, which then draw the type's icon or the placeholder. + const [brokenIcons, setBrokenIcons] = useState>(new Set()); const [opening, setOpening] = useState(undefined); // What opening a row has to say (a locked push, a file that did not decrypt), shown above // the list as the app shows its other errors, not in a browser dialog. @@ -130,21 +119,6 @@ export default function PushesList({ }); }; - const handleImageLoad = (event: React.SyntheticEvent, pushId: string) => { - const image = event.currentTarget; - if (image.naturalHeight === 16) { - setLowResImages((prev) => new Set(prev).add(pushId)); - } - }; - - const getFallbackImageSrc = (push: Push, icon: string | undefined) => { - // If this push has a low-res image, use the PushThat logo as fallback - if (lowResImages.has(push.id)) { - return '/pushthat-mono.svg'; - } - return null; - }; - if (loading) { return
Loading...
; } @@ -166,12 +140,7 @@ export default function PushesList({ )}
    {pushes.map((push) => { - const icon = ICONS.find((icon) => push.ext === icon.split('.')[0]); const isSelected = selectedPushes.has(push.id); - // A locked push, or a file with no icon of its own, has no host to ask for a - // favicon (and an empty one would still ask Google). - const fallbackSrc = - push.locked || (!icon && !push.url) ? '/pushthat-mono.svg' : getFallbackImageSrc(push, icon); const content = ( <>
    {isSelected ? ( - ) : fallbackSrc ? ( - - ) : icon ? ( - handleImageLoad(e, push.id)} - /> ) : ( handleImageLoad(e, push.id)} + onError={() => setBrokenIcons((prev) => new Set(prev).add(push.id))} /> )}
    diff --git a/apps/web/src/services/e2ee.ts b/apps/web/src/services/e2ee.ts index 392be05..bf3d436 100644 --- a/apps/web/src/services/e2ee.ts +++ b/apps/web/src/services/e2ee.ts @@ -322,11 +322,16 @@ export interface Enc { ct: string; } -/** A URL push's plaintext (docs/e2ee.md, The push envelope). `title` is "" until known. */ +/** + * A URL push's plaintext (docs/e2ee.md, The push envelope). `title` is "" until known. + * `icon` is the page's icon as base64 PNG, added by the native device that scrapes the + * title (docs/e2ee.md, Icons); the web app reads it and never writes it. + */ export interface UrlEnvelope { url: string; title: string; ext: string; + icon?: string; } /** diff --git a/apps/web/src/services/pushes.ts b/apps/web/src/services/pushes.ts index 5882b87..3824681 100644 --- a/apps/web/src/services/pushes.ts +++ b/apps/web/src/services/pushes.ts @@ -125,6 +125,8 @@ export interface Push { /** A URL push's URL, or a plaintext file push's download URL. */ url?: string; ext?: string; + /** A URL push's page icon, base64 PNG checked by `iconOf`; absent when it has none. */ + icon?: string; /** Set on a file push stored since encryption, which is opened through `downloadFile`. */ file?: PushFile; /** @@ -147,6 +149,20 @@ const isEnc = (value: unknown): value is Enc => const stringOr = (value: unknown, fallback = ''): string => (typeof value === 'string' ? value : fallback); +// 4096 bytes of PNG in padded base64 (docs/e2ee.md, Icons). +const MAX_ICON_LENGTH = 5464; +const BASE64 = /^[A-Za-z0-9+/]+={0,2}$/; + +/** + * The envelope's icon, if it is one the list can show. It ends up in a `data:` URL, so it + * must be base64 and nothing else: a string that is not cannot smuggle anything into the + * `src`, and a PNG that will not decode is the `img`'s `onError`, not this check's. + */ +const iconOf = (value: unknown): string | undefined => + typeof value === 'string' && value.length <= MAX_ICON_LENGTH && value.length % 4 === 0 && BASE64.test(value) + ? value + : undefined; + export async function readPush(snapshot: DocumentSnapshot, keys: PushKeys | undefined): Promise { const data = snapshot.data() ?? {}; const base = { @@ -189,7 +205,13 @@ export async function readPush(snapshot: DocumentSnapshot, keys: P }; } const url = stringOr(opened.url); - return { ...base, url, title: stringOr(opened.title) || url, ext: stringOr(opened.ext) || undefined }; + return { + ...base, + url, + title: stringOr(opened.title) || url, + ext: stringOr(opened.ext) || undefined, + icon: iconOf(opened.icon), + }; } /** From 5162c9531547e2b3943a9aea485c17816d502f8a Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Thu, 12 Nov 2026 15:00:29 -0600 Subject: [PATCH 03/17] Say on the privacy and security pages that a page's icon is encrypted too URL pushes now carry their page's icon inside the encrypted envelope, so it belongs in what the policy calls Push Content and in the security page's list of what is encrypted. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/pages/Privacy.tsx | 16 ++++++++-------- apps/web/src/pages/Security.tsx | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/web/src/pages/Privacy.tsx b/apps/web/src/pages/Privacy.tsx index 1d8379d..bccea27 100644 --- a/apps/web/src/pages/Privacy.tsx +++ b/apps/web/src/pages/Privacy.tsx @@ -15,7 +15,7 @@ export default function Privacy() {

    PushThat Privacy Policy

    -

    Last updated: October 27, 2026

    +

    Last updated: November 12, 2026

    This Privacy Policy describes how your personal information is collected, used, and shared when you visit or @@ -25,13 +25,13 @@ export default function Privacy() {

    YOUR PUSHES ARE END-TO-END ENCRYPTED

    - The links you push, their page titles, and the names, types and contents of the files you push (together, “Push - Content”) are encrypted on your device before they leave it, with a key that only your password, or your - recovery key, can unlock. They can be decrypted only on the devices you sign in to. We, and Google, which runs - the servers the App uses, hold Push Content only in that encrypted form and cannot read it, apart from pushes - made before end-to-end encryption that are still stored as they were sent (see Data Retention below). Your - recovery key is never sent to us or to Google, and your password only in the cases described below. The{' '} - Security page explains how this works and what it does not cover. + The links you push, the titles and icons of their pages, and the names, types and contents of the files you push + (together, “Push Content”) are encrypted on your device before they leave it, with a key that only your + password, or your recovery key, can unlock. They can be decrypted only on the devices you sign in to. We, and + Google, which runs the servers the App uses, hold Push Content only in that encrypted form and cannot read it, + apart from pushes made before end-to-end encryption that are still stored as they were sent (see Data Retention + below). Your recovery key is never sent to us or to Google, and your password only in the cases described below. + The Security page explains how this works and what it does not cover.

    PERSONAL INFORMATION WE COLLECT
    diff --git a/apps/web/src/pages/Security.tsx b/apps/web/src/pages/Security.tsx index 5ae91dd..6f01f1e 100644 --- a/apps/web/src/pages/Security.tsx +++ b/apps/web/src/pages/Security.tsx @@ -53,7 +53,7 @@ export function Security() {
    WHAT IS ENCRYPTED
      -
    • The link you push, and the title of its page.
    • +
    • The link you push, and the title and icon of its page.
    • A file’s name, its type and its contents. Files are stored under a random name.
    • The words search looks for. Search uses scrambled tokens made with your key, so the server can match a search From d0aad8fc0a3ae27853383062b92e9380b836c0c3 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Thu, 12 Nov 2026 15:00:36 -0600 Subject: [PATCH 04/17] Carry a link's icon in the Android push instead of asking Google for it The list loaded every link's icon from Google's favicon service, which told Google the host of every pushed link each time the list was drawn, and loaded file-type icons from pushthat.link, which put encrypted files' types in its logs. `TitleScrapingWorker` now also takes the page's icon from the page it already reads, shrinks it to the spec's bounds and seals it with the title (docs/e2ee.md, Icons). A push that has an icon keeps it, and when the icon would push the envelope past `enc.ct`'s bound the title is written alone. The row draws only what the push carries: its icon, the bundled icon for its type, or the placeholder, so Glide and every image request are gone. The candidate order and sizing live in `PageIcons` so the JVM tests cover them, and `PushDocument.retitle` holds the rewrite rules. Co-Authored-By: Claude Opus 5.5 --- apps/android/app/build.gradle.kts | 1 - .../com/makerocketgonow/pushthat/FileIcons.kt | 50 +++++++ .../makerocketgonow/pushthat/MainActivity.kt | 15 +- .../com/makerocketgonow/pushthat/PageIcons.kt | 136 ++++++++++++++++++ .../makerocketgonow/pushthat/ShareActivity.kt | 75 ++++++++-- .../pushthat/SwivelCheckView.kt | 94 ++++++------ .../pushthat/e2ee/PushDocument.kt | 55 ++++++- .../main/res/drawable-nodpi/file_icon_aac.png | Bin 0 -> 506 bytes .../main/res/drawable-nodpi/file_icon_ai.png | Bin 0 -> 437 bytes .../main/res/drawable-nodpi/file_icon_apk.png | Bin 0 -> 248 bytes .../main/res/drawable-nodpi/file_icon_avi.png | Bin 0 -> 457 bytes .../main/res/drawable-nodpi/file_icon_css.png | Bin 0 -> 290 bytes .../main/res/drawable-nodpi/file_icon_csv.png | Bin 0 -> 3362 bytes .../main/res/drawable-nodpi/file_icon_doc.png | Bin 0 -> 515 bytes .../res/drawable-nodpi/file_icon_flac.png | Bin 0 -> 472 bytes .../main/res/drawable-nodpi/file_icon_gif.png | Bin 0 -> 451 bytes .../res/drawable-nodpi/file_icon_html.png | Bin 0 -> 420 bytes .../res/drawable-nodpi/file_icon_indd.png | Bin 0 -> 232 bytes .../res/drawable-nodpi/file_icon_jpeg.png | Bin 0 -> 494 bytes .../main/res/drawable-nodpi/file_icon_jpg.png | Bin 0 -> 494 bytes .../main/res/drawable-nodpi/file_icon_js.png | Bin 0 -> 444 bytes .../main/res/drawable-nodpi/file_icon_mov.png | Bin 0 -> 475 bytes .../main/res/drawable-nodpi/file_icon_mp3.png | Bin 0 -> 565 bytes .../main/res/drawable-nodpi/file_icon_mp4.png | Bin 0 -> 500 bytes .../main/res/drawable-nodpi/file_icon_pdf.png | Bin 0 -> 514 bytes .../main/res/drawable-nodpi/file_icon_png.png | Bin 0 -> 491 bytes .../res/drawable-nodpi/file_icon_pproj.png | Bin 0 -> 266 bytes .../main/res/drawable-nodpi/file_icon_psd.png | Bin 0 -> 537 bytes .../res/drawable-nodpi/file_icon_tiff.png | Bin 0 -> 460 bytes .../main/res/drawable-nodpi/file_icon_txt.png | Bin 0 -> 430 bytes .../main/res/drawable-nodpi/file_icon_xls.png | Bin 0 -> 488 bytes .../main/res/drawable-nodpi/file_icon_xml.png | Bin 0 -> 292 bytes .../main/res/drawable-nodpi/file_icon_zip.png | Bin 0 -> 300 bytes .../makerocketgonow/pushthat/PageIconsTest.kt | 121 ++++++++++++++++ .../pushthat/e2ee/PushDocumentTest.kt | 67 +++++++++ apps/android/store/whatsnew/whatsnew-en-US | 4 +- 36 files changed, 539 insertions(+), 79 deletions(-) create mode 100644 apps/android/app/src/main/java/com/makerocketgonow/pushthat/FileIcons.kt create mode 100644 apps/android/app/src/main/java/com/makerocketgonow/pushthat/PageIcons.kt create mode 100644 apps/android/app/src/main/res/drawable-nodpi/file_icon_aac.png create mode 100644 apps/android/app/src/main/res/drawable-nodpi/file_icon_ai.png create mode 100644 apps/android/app/src/main/res/drawable-nodpi/file_icon_apk.png create mode 100644 apps/android/app/src/main/res/drawable-nodpi/file_icon_avi.png create mode 100644 apps/android/app/src/main/res/drawable-nodpi/file_icon_css.png create mode 100644 apps/android/app/src/main/res/drawable-nodpi/file_icon_csv.png create mode 100644 apps/android/app/src/main/res/drawable-nodpi/file_icon_doc.png create mode 100644 apps/android/app/src/main/res/drawable-nodpi/file_icon_flac.png create mode 100644 apps/android/app/src/main/res/drawable-nodpi/file_icon_gif.png create mode 100644 apps/android/app/src/main/res/drawable-nodpi/file_icon_html.png create mode 100644 apps/android/app/src/main/res/drawable-nodpi/file_icon_indd.png create mode 100644 apps/android/app/src/main/res/drawable-nodpi/file_icon_jpeg.png create mode 100644 apps/android/app/src/main/res/drawable-nodpi/file_icon_jpg.png create mode 100644 apps/android/app/src/main/res/drawable-nodpi/file_icon_js.png create mode 100644 apps/android/app/src/main/res/drawable-nodpi/file_icon_mov.png create mode 100644 apps/android/app/src/main/res/drawable-nodpi/file_icon_mp3.png create mode 100644 apps/android/app/src/main/res/drawable-nodpi/file_icon_mp4.png create mode 100644 apps/android/app/src/main/res/drawable-nodpi/file_icon_pdf.png create mode 100644 apps/android/app/src/main/res/drawable-nodpi/file_icon_png.png create mode 100644 apps/android/app/src/main/res/drawable-nodpi/file_icon_pproj.png create mode 100644 apps/android/app/src/main/res/drawable-nodpi/file_icon_psd.png create mode 100644 apps/android/app/src/main/res/drawable-nodpi/file_icon_tiff.png create mode 100644 apps/android/app/src/main/res/drawable-nodpi/file_icon_txt.png create mode 100644 apps/android/app/src/main/res/drawable-nodpi/file_icon_xls.png create mode 100644 apps/android/app/src/main/res/drawable-nodpi/file_icon_xml.png create mode 100644 apps/android/app/src/main/res/drawable-nodpi/file_icon_zip.png create mode 100644 apps/android/app/src/test/java/com/makerocketgonow/pushthat/PageIconsTest.kt diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index b33696a..5e5f278 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -112,7 +112,6 @@ dependencies { implementation("com.google.firebase:firebase-firestore") implementation("com.google.firebase:firebase-storage") implementation("com.google.firebase:firebase-messaging") - implementation("com.github.bumptech.glide:glide:5.0.7") implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.2.0") // Used directly by our own code and layouts. These arrive transitively via diff --git a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/FileIcons.kt b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/FileIcons.kt new file mode 100644 index 0000000..e78962c --- /dev/null +++ b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/FileIcons.kt @@ -0,0 +1,50 @@ +package com.makerocketgonow.pushthat + +import androidx.annotation.DrawableRes + +/** + * The icon a row shows for a file type, shipped inside the app. These were loaded from + * `https://pushthat.link/files/.png`, which put the type of every encrypted file a list + * showed into the site's logs (docs/e2ee.md, Icons). The PNGs are copies of + * `apps/web/public/files`, with a `file_icon_` prefix so they cannot clash with the app's + * other drawables (and so a type like `7z` would still make a valid resource name). + * + * The one list of the types that have an icon: adding one is a `file_icon_.png` in + * `res/drawable-nodpi` (one size, never scaled per density, as the web serves it) and a line + * here. A map of ids rather than a lookup by name, which would hide the icons from the + * compiler and from resource shrinking. + */ +object FileIcons { + private val BY_EXT = mapOf( + "aac" to R.drawable.file_icon_aac, + "ai" to R.drawable.file_icon_ai, + "apk" to R.drawable.file_icon_apk, + "avi" to R.drawable.file_icon_avi, + "css" to R.drawable.file_icon_css, + "csv" to R.drawable.file_icon_csv, + "doc" to R.drawable.file_icon_doc, + "flac" to R.drawable.file_icon_flac, + "gif" to R.drawable.file_icon_gif, + "html" to R.drawable.file_icon_html, + "indd" to R.drawable.file_icon_indd, + "jpeg" to R.drawable.file_icon_jpeg, + "jpg" to R.drawable.file_icon_jpg, + "js" to R.drawable.file_icon_js, + "mov" to R.drawable.file_icon_mov, + "mp3" to R.drawable.file_icon_mp3, + "mp4" to R.drawable.file_icon_mp4, + "pdf" to R.drawable.file_icon_pdf, + "png" to R.drawable.file_icon_png, + "pproj" to R.drawable.file_icon_pproj, + "psd" to R.drawable.file_icon_psd, + "tiff" to R.drawable.file_icon_tiff, + "txt" to R.drawable.file_icon_txt, + "xls" to R.drawable.file_icon_xls, + "xml" to R.drawable.file_icon_xml, + "zip" to R.drawable.file_icon_zip, + ) + + /** The icon for files ending in [ext], any case, or null for a type with none. */ + @DrawableRes + fun forExt(ext: String?): Int? = ext?.lowercase()?.let { BY_EXT[it] } +} diff --git a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/MainActivity.kt b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/MainActivity.kt index 95e3ad8..d24ee80 100644 --- a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/MainActivity.kt +++ b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/MainActivity.kt @@ -384,12 +384,12 @@ class MainActivity : AppCompatActivity() { return when (val read = PushDocument.read(keys, doc.id, data)) { null -> null is PushDocument.Read.Plain -> - PushItem(doc.id, read.title.ifBlank { read.url }, createdAt, read.url, read.ext, PushTarget.Link(read.url)) + PushItem(doc.id, read.title.ifBlank { read.url }, createdAt, null, read.ext, PushTarget.Link(read.url)) PushDocument.Read.Locked -> PushItem(doc.id, getString(R.string.locked_push), createdAt, null, null, PushTarget.Locked) is PushDocument.Read.Opened -> when (val content = read.content) { is PushContent.Url -> - PushItem(doc.id, content.title.ifBlank { content.url }, createdAt, content.url, content.ext, PushTarget.Link(content.url)) + PushItem(doc.id, content.title.ifBlank { content.url }, createdAt, content.icon, content.ext, PushTarget.Link(content.url)) is PushContent.File -> { val target = doc.getString("objectPath")?.let { PushTarget.File(it, content) } ?: PushTarget.Locked PushItem(doc.id, content.name, createdAt, null, content.ext, target) @@ -857,8 +857,11 @@ data class PushItem( val id: String, val title: String, val createdAt: Date?, - /** The URL the favicon comes from; none for an encrypted file or a locked push. */ - val iconUrl: String?, + /** + * The page's icon as the envelope carries it (base64 PNG), or null or empty for none: a + * file push, a locked push, a plaintext push and a link whose page gave none. + */ + val icon: String?, val ext: String?, val target: PushTarget, ) @@ -913,8 +916,8 @@ class PushAdapter( private val dateFormat = SimpleDateFormat("h:mm a", Locale.getDefault()) fun bind(push: PushItem) { - // Load favicon or file icon - avatarImage.loadFavicon(push.iconUrl, push.ext) + // From the push itself: drawing a row fetches nothing. + avatarImage.showIcon(push.icon, push.ext) // Set title titleText.text = push.title diff --git a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/PageIcons.kt b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/PageIcons.kt new file mode 100644 index 0000000..50e025a --- /dev/null +++ b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/PageIcons.kt @@ -0,0 +1,136 @@ +package com.makerocketgonow.pushthat + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import com.makerocketgonow.pushthat.e2ee.E2ee +import okhttp3.HttpUrl +import org.jsoup.nodes.Document +import java.io.ByteArrayOutputStream +import kotlin.math.max +import kotlin.math.roundToInt + +/** + * Finding and shrinking a page's icon, for the envelope's `icon` (docs/e2ee.md, Icons). + * `TitleScrapingWorker` does the fetching; what is here is the choice of candidates and the + * sizes, kept out of the worker so the JVM tests cover them, and the bitmap work, which + * they cannot run. + * + * The icon travels in the envelope so that nothing is fetched to draw a row. The list used + * to ask Google's favicon service for every link's host each time it was drawn, which told + * Google, in plaintext, the host of every push the envelope keeps from it. + */ +object PageIcons { + /** The longest side of an icon in the envelope, in pixels. */ + const val MAX_SIDE = 64 + + /** Tried once when the icon at [MAX_SIDE] encodes past [MAX_ICON_BYTES]. */ + const val FALLBACK_SIDE = 32 + + /** The most PNG bytes an icon may have (5464 characters of base64). */ + const val MAX_ICON_BYTES = 4096 + + /** How much of each candidate is read: an icon larger than this is skipped. */ + const val MAX_FETCH_BYTES = 256L * 1024 + + /** + * How many of the page's `` candidates are tried. The spec says every one; a page + * listing hundreds of broken links would otherwise hold the worker for as many requests. + * Sites list several sizes of the same icon, all of which decode, so the first few + * almost always settle it, and `/favicon.ico` is tried after these whatever the page has. + */ + const val MAX_LINK_CANDIDATES = 8 + + private val APPLE_TOUCH_ICON = setOf("apple-touch-icon", "apple-touch-icon-precomposed") + + /** + * Where to look for [doc]'s icon, in the order to try: every `` whose `rel` names + * `apple-touch-icon` or `apple-touch-icon-precomposed`, then every one naming `icon` + * (`shortcut icon` included) that is not SVG by its `type` or its path, then + * `/favicon.ico` at the page's origin. Each `href` is resolved against [pageUrl], the + * page's final URL after redirects: not against the address the push names, which a + * redirect may have left, nor a `` element, which the spec does not follow. Only + * http and https come back (`HttpUrl` cannot hold anything else), each once. + * + * SVG is left out because `BitmapFactory` cannot open it, so fetching one only costs a + * request. The spec filters the `icon` group only: an apple-touch-icon is a PNG by + * Apple's definition, and one that is not simply fails to decode. + */ + fun candidates(doc: Document, pageUrl: HttpUrl): List { + // An empty href resolves to the page itself, which is HTML, not an icon. + val links = doc.select("link[rel][href]").filter { it.attr("href").isNotBlank() }.map { link -> + val rel = link.attr("rel").lowercase().split(Regex("\\s+")).toSet() + Triple(rel, link.attr("type").trim().lowercase(), pageUrl.resolve(link.attr("href").trim())) + } + val touch = links.filter { (rel, _, _) -> rel.any { it in APPLE_TOUCH_ICON } }.mapNotNull { it.third } + val icons = links + .filter { (rel, type, url) -> "icon" in rel && type != "image/svg+xml" && url != null && !isSvgPath(url) } + .mapNotNull { it.third } + val fromLinks = (touch + icons).distinct().take(MAX_LINK_CANDIDATES) + return (fromLinks + listOfNotNull(pageUrl.resolve("/favicon.ico"))).distinct() + } + + private fun isSvgPath(url: HttpUrl) = url.encodedPath.lowercase().endsWith(".svg") + + /** + * [width] by [height] scaled down to fit [side] by [side] with its aspect kept, never + * up, and never below one pixel a side (a 1000 by 1 strip would round to nothing). + */ + fun fit(width: Int, height: Int, side: Int): Pair { + val longest = max(width, height) + if (longest <= side) return width to height + val scale = side.toDouble() / longest + return max(1, (width * scale).roundToInt()) to max(1, (height * scale).roundToInt()) + } + + /** + * The `inSampleSize` that decodes [width] by [height] as small as it can while its + * longest side stays at least [side]. A 256 KiB PNG of one color can be tens of + * thousands of pixels a side, which decoded whole would be gigabytes; sampled, it is + * never more than four times the icon's area, and the scale after it does the rest. + */ + fun sampleSize(width: Int, height: Int, side: Int): Int { + val longest = max(width, height) + var sample = 1 + while (longest / (sample * 2) >= side) sample *= 2 + return sample + } + + /** [bytes] as an image, or null when the platform's decoder cannot open them. */ + fun decode(bytes: ByteArray): Bitmap? { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + val options = BitmapFactory.Options().apply { + inSampleSize = sampleSize(bounds.outWidth, bounds.outHeight, MAX_SIDE) + } + return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options) + } + + /** + * [bitmap] as the envelope's `icon`: fit to [MAX_SIDE], PNG, base64; at [FALLBACK_SIDE] + * when that is over [MAX_ICON_BYTES]; null when that is too. Null is no icon, not a + * reason to try the next candidate: the decoder opened this one, and the spec takes the + * first it opens. + */ + fun encode(bitmap: Bitmap): String? { + for (side in listOf(MAX_SIDE, FALLBACK_SIDE)) { + val png = png(bitmap, side) + if (png.size <= MAX_ICON_BYTES) return E2ee.base64(png) + } + return null + } + + private fun png(bitmap: Bitmap, side: Int): ByteArray { + val (width, height) = fit(bitmap.width, bitmap.height, side) + val scaled = if (width == bitmap.width && height == bitmap.height) { + bitmap + } else { + Bitmap.createScaledBitmap(bitmap, width, height, true) + } + try { + return ByteArrayOutputStream().also { scaled.compress(Bitmap.CompressFormat.PNG, 100, it) }.toByteArray() + } finally { + if (scaled !== bitmap) scaled.recycle() + } + } +} diff --git a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/ShareActivity.kt b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/ShareActivity.kt index ef479bf..6e3befc 100644 --- a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/ShareActivity.kt +++ b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/ShareActivity.kt @@ -36,6 +36,7 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.tasks.await import androidx.lifecycle.lifecycleScope import androidx.work.* +import okhttp3.HttpUrl import okhttp3.OkHttpClient import okhttp3.Request import org.jsoup.Jsoup @@ -533,6 +534,10 @@ class ShareActivity : AppCompatActivity() { * this install holds; another device scraping the same push writes the same title, and * the last write wins. A plaintext push (a job queued before this version) gets its title * alone, as before; the migration encrypts it. + * + * An encrypted push that has no icon also gets the page's (docs/e2ee.md, Icons), looked for + * in the page this pass already read, so the list never has to ask anyone for it. A push + * that has one keeps it and is not looked up again. */ class TitleScrapingWorker( context: Context, @@ -556,14 +561,16 @@ class TitleScrapingWorker( val updates: Map = when (val push = PushDocument.read(keys, documentId, data)) { is PushDocument.Read.Opened -> { val content = push.content as? PushContent.Url ?: return Result.success() - val title = scrapeTitle(content.url) - if (title.isNullOrBlank() || title == content.title) return Result.success() - val prepared = PreparedPush.prepare(keys, documentId, content.copy(title = title)) + val page = scrape(content.url, withIcon = content.icon.isEmpty()) ?: return Result.success() + val prepared = PushDocument.retitle(keys, documentId, content, page.title, page.icon) + ?: return Result.success() mapOf("enc" to prepared.enc.toMap(), "searchTokens" to prepared.searchTokens) } is PushDocument.Read.Plain -> { if (push.type != PushContent.TYPE_URL) return Result.success() - val title = scrapeTitle(push.url) + // No icon: a plaintext push has no envelope to carry one, and the + // migration that gives it one does not scrape. + val title = scrape(push.url, withIcon = false)?.title if (title.isNullOrBlank() || title == push.title) return Result.success() mapOf("title" to PreparedPush.cutTitle(title)) } @@ -587,16 +594,23 @@ class TitleScrapingWorker( } } - private suspend fun scrapeTitle(url: String): String? { + /** What a scrape found: either may be null. */ + private class Scraped(val title: String?, val icon: String?) + + /** + * [url]'s title and, when [withIcon], its icon as the envelope's `icon`. Null when there + * is no HTML page to read (an error, a file, a URL OkHttp refuses). + */ + private suspend fun scrape(url: String, withIcon: Boolean): Scraped? { return withContext(Dispatchers.IO) { + val client = OkHttpClient() try { - val client = OkHttpClient() val request = Request.Builder() .url(url) - .header("User-Agent", "Mozilla/5.0") + .header("User-Agent", USER_AGENT) .build() - client.newCall(request).execute().use { response -> + val (doc, finalUrl) = client.newCall(request).execute().use { response -> if (!response.isSuccessful) { Log.e(TAG, "HTTP request failed: ${response.code}") return@withContext null @@ -606,7 +620,8 @@ class TitleScrapingWorker( // archives), which have no title to find, and the worker runs for received // ones too. Only an HTML page is read, and only its first 256 KiB, where // the is: `string()` pulled a whole file into memory. iOS and - // macOS read the same. + // macOS read the same. No page means no icon either: a link to a file + // has no <link rel=icon> to look in, and its row shows the file's type. val body = response.body val type = body.contentType() if (type == null || !type.subtype.lowercase().contains("html")) return@withContext null @@ -615,10 +630,13 @@ class TitleScrapingWorker( val bytes = source.buffer.readByteArray(minOf(source.buffer.size, MAX_PAGE_BYTES)) // The header's charset, or, when it names none, whatever the page's // <meta> says. The cap can cut the last character in two, well past the - // <title>, so that costs nothing. - val doc = Jsoup.parse(ByteArrayInputStream(bytes), type.charset()?.name(), url) - doc.title().takeIf { it.isNotBlank() } + // <title>, so that costs nothing. The base is the final URL, after + // redirects, which is what the icon's links are relative to. + val finalUrl = response.request.url + Jsoup.parse(ByteArrayInputStream(bytes), type.charset()?.name(), finalUrl.toString()) to finalUrl } + val icon = if (withIcon) fetchIcon(client, PageIcons.candidates(doc, finalUrl)) else null + Scraped(doc.title().takeIf { it.isNotBlank() }, icon) } catch (e: IOException) { // The exception's type only: its message can name the page's host. Log.e(TAG, "Error scraping title: ${e.javaClass.simpleName}") @@ -630,12 +648,45 @@ class TitleScrapingWorker( } } + /** + * The first of [candidates] that `BitmapFactory` opens, as the envelope's `icon`, or + * null. A candidate that fails in any way is passed over: an icon is a nicety, and + * nothing about it may cost the push its title. Logged by the exception's type only, + * since its message can name the host. + */ + private fun fetchIcon(client: OkHttpClient, candidates: List<HttpUrl>): String? { + for (candidate in candidates) { + try { + val request = Request.Builder().url(candidate).header("User-Agent", USER_AGENT).build() + val bytes = client.newCall(request).execute().use { response -> + if (!response.isSuccessful) return@use null + val source = response.body.source() + // One byte past the cap tells a file that fits from one that does not. + // A cut image would decode partly or not at all, so it is skipped. + source.request(PageIcons.MAX_FETCH_BYTES + 1) + if (source.buffer.size > PageIcons.MAX_FETCH_BYTES) null else source.buffer.readByteArray() + } ?: continue + val bitmap = PageIcons.decode(bytes) ?: continue + try { + return PageIcons.encode(bitmap) + } finally { + bitmap.recycle() + } + } catch (e: Exception) { + Log.w(TAG, "Could not fetch an icon: ${e.javaClass.simpleName}") + } + } + return null + } + companion object { private const val KEY_DOCUMENT_ID = "documentId" /** How much of a page the title is looked for in. */ private const val MAX_PAGE_BYTES = 256L * 1024 + private const val USER_AGENT = "Mozilla/5.0" + fun enqueue(context: Context, documentId: String) { val constraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) diff --git a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/SwivelCheckView.kt b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/SwivelCheckView.kt index 1fc908f..97b1049 100644 --- a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/SwivelCheckView.kt +++ b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/SwivelCheckView.kt @@ -1,10 +1,10 @@ package com.makerocketgonow.pushthat import android.content.Context +import android.graphics.Bitmap import android.graphics.Camera -import android.net.Uri import android.util.AttributeSet -import android.util.Log +import android.util.LruCache import android.view.View import android.view.animation.Animation import android.view.animation.Transformation @@ -12,8 +12,7 @@ import android.widget.Checkable import android.widget.ImageView import android.widget.ViewFlipper import androidx.interpolator.view.animation.FastOutSlowInInterpolator -import com.bumptech.glide.Glide -import com.bumptech.glide.request.target.Target +import com.makerocketgonow.pushthat.e2ee.E2ee class SwivelCheckView @JvmOverloads constructor( context: Context, @@ -35,6 +34,8 @@ class SwivelCheckView @JvmOverloads constructor( View.inflate(context, R.layout.merge_swivel_check, this) frontImageView = getChildAt(0) as ImageView backImageView = getChildAt(1) as ImageView + backImageView.setImageResource(R.drawable.ic_check_circle) + backImageView.setColorFilter(context.getColor(R.color.checkmark_color)) } fun setChecked(targetState: Boolean, shouldAnimate: Boolean = true) { @@ -71,51 +72,22 @@ class SwivelCheckView @JvmOverloads constructor( override fun toggle() = setChecked(!isChecked) /** - * The icon for [fileExt] when there is one, else [url]'s favicon. A push with neither - * (an encrypted file of an unknown type, a locked push) gets the placeholder: there is - * no host to ask for, and a locked push's URL is not known at all. + * The row's icon, from what the push carries and nothing else: nothing is fetched to + * draw a row (docs/e2ee.md, Icons). [pageIcon] is a URL push's `icon`, drawn when it + * decodes; else the app's own icon for [fileExt], when it has one (a file push, or a + * link to a file, which has no page to take an icon from); else the placeholder, which + * is also what a locked push draws. + * + * The page's icon wins over the type: a link whose path ends in `.html` is a page, and + * its own icon says more than the type's. */ - fun loadFavicon(url: String?, fileExt: String? = null) { - // Check if there's a file extension and it matches our supported icons - if (!fileExt.isNullOrBlank()) { - Log.d("SwivelCheckView", "File extension is not null or blank") - val iconName = SUPPORTED_FILE_ICONS.find { it == "${fileExt.lowercase()}.png" } - if (iconName != null) { - // Load file type icon from PushThat CDN - Glide.with(context) - .load("https://pushthat.link/files/${iconName}") - .fitCenter() - .override(Target.SIZE_ORIGINAL) - .error(R.drawable.default_favicon) - .into(frontImageView) - - // Load checkmark for back side - backImageView.setImageResource(R.drawable.ic_check_circle) - backImageView.setColorFilter(context.getColor(R.color.checkmark_color)) - return - } - } - - // If no file extension or unsupported, fall back to favicon - val host = url?.let { Uri.parse(it).host } - if (host.isNullOrEmpty()) { - Glide.with(context).clear(frontImageView) - frontImageView.setImageResource(R.drawable.default_favicon) - backImageView.setImageResource(R.drawable.ic_check_circle) - backImageView.setColorFilter(context.getColor(R.color.checkmark_color)) - return + fun showIcon(pageIcon: String?, fileExt: String? = null) { + val bitmap = pageIcon?.takeIf { it.isNotEmpty() }?.let { decodedIcon(it) } + if (bitmap != null) { + frontImageView.setImageBitmap(bitmap) + } else { + frontImageView.setImageResource(FileIcons.forExt(fileExt) ?: R.drawable.default_favicon) } - val faviconUrl = "https://www.google.com/s2/favicons?domain=${Uri.encode(host)}&sz=256" - - Glide.with(context) - .load(faviconUrl) - .fitCenter() - .override(Target.SIZE_ORIGINAL) - .error(R.drawable.default_favicon) - .into(frontImageView) - - backImageView.setImageResource(R.drawable.ic_check_circle) - backImageView.setColorFilter(context.getColor(R.color.checkmark_color)) } fun setOnCheckedChangeListener(listener: (SwivelCheckView, Boolean) -> Unit) { @@ -123,13 +95,27 @@ class SwivelCheckView @JvmOverloads constructor( } companion object { - private val SUPPORTED_FILE_ICONS = listOf( - "pproj.png", "zip.png", "indd.png", "csv.png", "css.png", "pdf.png", - "aac.png", "png.png", "xls.png", "avi.png", "txt.png", "mp3.png", - "tiff.png", "psd.png", "jpg.png", "jpeg.png", "mov.png", "html.png", - "ai.png", "js.png", "flac.png", "mp4.png", "gif.png", "doc.png", - "xml.png", "apk.png" - ) + /** + * Page icons already decoded, by their base64. Decoding one is cheap (at most 64 by + * 64 and 4 KB), but a bind happens on every scroll, so each is decoded once on the + * main thread rather than per bind or through a loader thread. Keyed by the string + * rather than the push, so a push whose icon changes is never drawn with the old one. + * 256 icons of at most 16 KB each is about 4 MB. + */ + private val decodedIcons = LruCache<String, Bitmap>(256) + + private fun decodedIcon(icon: String): Bitmap? { + decodedIcons.get(icon)?.let { return it } + val bitmap = try { + // Sampled like a scraped icon: the envelope says 64 by 64, but a PNG of a few + // KB can claim to be far larger, and decoded whole it would not fit in memory. + PageIcons.decode(E2ee.fromBase64(icon)) + } catch (e: IllegalArgumentException) { + null + } ?: return null + decodedIcons.put(icon, bitmap) + return bitmap + } private fun toChecked(calculateCenterX: () -> Float, updateDisplayedSide: (DisplaySide) -> Unit) = createAnimation(0f..180f, calculateCenterX, updateDisplayedSide) diff --git a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/e2ee/PushDocument.kt b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/e2ee/PushDocument.kt index 1b25908..9d6dbfe 100644 --- a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/e2ee/PushDocument.kt +++ b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/e2ee/PushDocument.kt @@ -18,11 +18,21 @@ sealed class PushContent { abstract fun toJson(): ByteArray - data class Url(val url: String, val title: String, val ext: String) : PushContent() { + /** + * A link. [icon] is the page's icon as base64 PNG (docs/e2ee.md, Icons), or empty for + * none: a push from before icons, a page with no icon this device could decode, or one + * whose icon would not fit beside its URL and title. + */ + data class Url(val url: String, val title: String, val ext: String, val icon: String = "") : PushContent() { override val type get() = TYPE_URL override val searchText get() = "$title $url" - override fun toJson(): ByteArray = - E2ee.utf8(JSONObject().put("url", url).put("title", title).put("ext", ext).toString()) + override fun toJson(): ByteArray { + val json = JSONObject().put("url", url).put("title", title).put("ext", ext) + // Left out rather than written empty: the spec reads the two alike, and an icon-less + // push then encrypts the same bytes it did before icons, which vector 3 pins. + if (icon.isNotEmpty()) json.put("icon", icon) + return E2ee.utf8(json.toString()) + } } /** @@ -65,7 +75,14 @@ sealed class PushContent { try { val obj = JSONObject(String(json, Charsets.UTF_8)) return when (type) { - TYPE_URL -> Url(obj.getString("url"), obj.optString("title", ""), obj.optString("ext", "")) + // `icon` is optional: a push from before icons, or from a client that found + // none, has no such key. + TYPE_URL -> Url( + obj.getString("url"), + obj.optString("title", ""), + obj.optString("ext", ""), + obj.optString("icon", ""), + ) TYPE_FILE -> { val legacyPlain = obj.optBoolean("legacyPlain", false) val fk = if (legacyPlain) null else E2ee.fromBase64(obj.getString("fk")) @@ -257,6 +274,36 @@ object PushDocument { } } + /** + * The rewrite of URL push [id] after its page was scraped (docs/e2ee.md, Titles and + * Icons), or null when there is nothing to write. [title] and [icon] are what the scrape + * found, either of them null for nothing. + * + * A rewrite happens when the title changed or an icon was found for a push that had + * none. A push that has an icon keeps it: the worker does not look for one then, and a + * scrape that finds none must not take it away. When the envelope with an icon would + * not fit `enc.ct`'s bound, the title is written alone, since the title is what the row + * is read by; with no new title, that leaves nothing to write. Throws + * [PushTooLargeException] when even the title alone does not fit, as before icons. + */ + fun retitle(keys: PushKeys, id: String, content: PushContent.Url, title: String?, icon: String?): PreparedPush? { + val newTitle = title?.takeIf { it.isNotBlank() && it != content.title } + val newIcon = icon?.takeIf { it.isNotEmpty() && content.icon.isEmpty() } + if (newTitle == null && newIcon == null) return null + val updated = content.copy(title = newTitle ?: content.title, icon = newIcon ?: content.icon) + return try { + PreparedPush.prepare(keys, id, updated) + } catch (e: PushTooLargeException) { + when { + newTitle == null -> null + updated.icon.isEmpty() -> throw e + // The icon goes even when it was the push's own: the URL and new title did + // not fit beside it, and the title is worth more than the picture. + else -> PreparedPush.prepare(keys, id, updated.copy(icon = "")) + } + } + } + /** The fields an encrypted push keeps; a migration deletes every other one. */ private val ENCRYPTED_FIELDS = setOf( "userId", "type", "createdAt", "expiresAt", "deviceId", "keyVersion", "enc", "searchTokens", "objectPath", diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_aac.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_aac.png new file mode 100644 index 0000000000000000000000000000000000000000..229acde2be58dfaa68d92f1827325f92fb6571ab GIT binary patch literal 506 zcmV<W0R{evP)<h;3K|Lk000e1NJLTq001!n002M;0{{R3ggkFb0000>P)t-sOlfgX z_We)x{Rhs~2F}$6&eaCa)p!5@Px$`_&eTx&|NsC06w%mM-s9Z)`X|-f#`N|&+TVuf z>+1XbY~tpx@A8`K?tJCxKJN8NEv(7_000VfQchC<nx6l;MtuRD@%WSX!~g&Q32;bR za{vGf6951U69E94oEQKA0Y6DZK~#9!?bge3gD?yQP+CD!d}UjHLH_?sJ0{a6!4R8H zmuSIgj1NMFg}SaA`jK`+sjq9diVM99Rg!^fVomzGSdp<oyb@{=FQiR8lSc7GTE&Aj zi(k?%e#il#KCefG`nVnyOO=YasiI0nIJG7<Q4~>C!MCHLh*O;66s^KK%S*!6*~i+h z&;|e!6Hhq_F|P#x#%&8WFiC)kG$6?UE@v=+?9|}=&?(<`j)(&y2@K!YclvO87KDs- z^Z}lMm)%7l`JI74oR$RM0{V@xfJEmJ$ODshrYa)9`5H)&h{(Vb;NJ)hXd43pC5Uw? z?@qw1qtV-fm(O7kmNU(nCu{C0Z+C7dN!t3ENGF-B_W6uw5t(_r#-IMHQ=H<T#CQ;l wwF$Y1zBYvyG1MmgbzGKNzi;hj?1!%V20!F8tDQ=d?EnA(07*qoM6N<$g327~$N&HU literal 0 HcmV?d00001 diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_ai.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_ai.png new file mode 100644 index 0000000000000000000000000000000000000000..bc85e0f72727db2ba29df4b23f5ea87453f7ed6a GIT binary patch literal 437 zcmeAS@N?(olHy`uVBq!ia0vp^hCuAV!3-o@Z1TN;RA7Klh^tR#;p<KhAhOot2O=v? zAQJdrU;nPl6UesK5_s3`_5c6>7+(tqeNk^~<s<udES)#;{oB|5t!4R1{&#O(OANNV z)974ZlDsR=a!Qo0c2@}#&~UDjAirRSS@V8;bgN~WyK_mM*#@8}XMsm#F#`j)FbFd; z%$g$s6#U}p;uunK>+QAEdCZDDt%*T~p2y}Kyz^|*zW>c(QY&+rJU5C@-87^Af1+Z< z4GH6(X>rf)d`(()?o0SORpzfQzQ^{h=n(C2cM|&Okn(6A$5GjdUGYj{_AYw+I-Pd( zcZcnm?679t%u9mi*L_}g_+OvU_aWQ2s_9Q|XxB1lnMZ$F+YeX;HY^ghbJG{u<MZd! z9Q8$K7zAc4o1)hF!8nM+VF9NA=ck*(sVX}aqPEE}=!>Z)`s-LEo>SF!VH8{Bt0<=M zd78+f_va0>^UfKzoG}zSzT!{kdFvyJ|6C(fH|*uznfWI7p|5lP<@MI@cmLDa+bzqU bypQ3EVZ}fG`t|)l|1)^H`njxgN@xNA;={2Y literal 0 HcmV?d00001 diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_apk.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_apk.png new file mode 100644 index 0000000000000000000000000000000000000000..e8092db4872e3a8639969a045c3a614af8328446 GIT binary patch literal 248 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE3?yBabR7dy5&=FTu0ENCKh`_GoNx7Zf#a=K z$NxJV-!F80Jke2Yo!Vod8kUkEzhH*n2HKTBOeKJP&H|6fVg?3oVGw3ym^B9^Smx>C z7*cWT?&X7g4GKKW0jpzw|F6$E(tUuX?^vA0heJ1*Zf_HD5tcFS(7Pdhz$(LqJ?bK} zvH?rVk@XBpoN@}6LKLj`F{|ZHS7jF{JsQN7a);rBH^YV}+)Gt;rq!NuscX*^cQ%l` oQgS7}g8%*89=rUPDQ_RomtV>wG3#WRBG3g4p00i_>zopr09smA(*OVf literal 0 HcmV?d00001 diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_avi.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_avi.png new file mode 100644 index 0000000000000000000000000000000000000000..08b8557281eb15824f499980ea6b05b5c7f0debf GIT binary patch literal 457 zcmV;)0XF`LP)<h;3K|Lk000e1NJLTq001!n002M;0{{R3ggkFb0000&P)t-sOlfid z|NohpnS+Caf`WpAf`WpAg1fuBe}8|2gM+-hy#N3I_4W0*xVWjQsm8{}iHV7mlarmD zo#^Q3+}zyL)6=gpK;{4d019+cPE!B@t^e^xeVqciBF*Vr00009a7bBm000XU000XU z0RWnu7ytkP7fD1xRCwC$*1K}VAPfaiXJqfjy5bH0|En3idF;Z(f+9s`d^?JRkZ80l z%eJrdYnbovOOqR&g?qA*CR(!Vq9N;mcqKF<UdWhuCWGRMjEWyJEWXLO_#zYHlgtRd zzv;qoS49z)yDEx^+!9TA(-KXPw`Njwxx^*@iHO-v3I|MzP|UgTG5VQ4LI!|j$%xdW zNHmhpgoETF5bps?#7JZyr?0YsC<2i(3Q+>9f`T=!-ftm306`OvKC18nTOXx~3}G9O z9tui=Q3b$6kFIU4<y9dlA?8Dn0GU+b>ct|5a1W@>%H-rcyHKa#T>bs%_<Xv0E^&!J z5$hyawGFw;u5IC6Hf_^yb31nZeYBT#-<IVA_na{v1((3g00000NkvXXu0mjfw_ecJ literal 0 HcmV?d00001 diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_css.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_css.png new file mode 100644 index 0000000000000000000000000000000000000000..56bca644d806acf9780163fade1e438e47fad684 GIT binary patch literal 290 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE3?yBabR7dyDgizru0ENCg0=re>wbyU{uTj} zb-#fO(b`|ab^k@`!R)%<&F2*pf$F(Sg8YIRWFH^d=25v-+{ZhmWVbF*n6tnmvKS~U z48n{Iv*t(u1$#YR978H@?Vaq+ci4c#g|Yro+PnJur+i}_eVZ=1%srY@(Im*RsD1^9 z-J(|vtN0Ejvn4PTuyC+51aUJwVQvWizKj2f<s#N&OYS$^I&po)dZW@wiyG9<=>#$` zato(F-oT))V)l=9Le#ZqdKYHv=a)Cu>C4p_IoGLP?=!Amp?V{wqMQ49ncMsNX<cQr Z=e>{=xvJUrfD!0022WQ%mvv4FO#lbWY0Urt literal 0 HcmV?d00001 diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_csv.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_csv.png new file mode 100644 index 0000000000000000000000000000000000000000..551320d993d3daa5e9308b74de1cd2f2a080ac51 GIT binary patch literal 3362 zcmeHKc~nzZ8o%#_5E9lFg$PyLC{~0}1yMnWf(2>{Q89$gh>oCwvIU5U<)M|dP{EN3 zvPfth5G=9_B3m?sDEm?&$QFsLvI!9aBxZunAD!tL&-^j{W6qg-&i%f7zwi6~-tXOW z?|JY3Kz6iK#p&Px0ICl5wub?L<wF=Kq2!K}wfU*s8IeiO2S{fE-*vZ0JKD4D&8m-+ zny&`6Mbm4%9ArJe$z-yp!N+Dek48%G@mbf!Qxlz0V+nE~hJM)b2r$)=m@cuRrfdS` zA#8xXYcK%HnkyXwDQS2BRK9nx-RI1BG1>PzJk>;t*haeh8(*<TCTvbR7+e@Hu-}4e z>R<Nv?B3V#J)fAK-uS+Si+pR%q}><q=zXyfNr0{P!(_G{3E&v&I3g5zM?Q0>%4gS< zi4Y+Q+9JTJ5g|IjWuVAlEdq~%8F&Va!U{lpJ$MKpHd+Z76F~q3Bgz`U0|A2fDI`9+ z%D@IJ!$jQgq8|g?Kxh}8u7OZA@sst)3GMIxE%ge?W(7(A#YBYyLqyN$j}Kp|a8?qx z^cU+Ro{+@;_u9xk>YD!|{Pg%pFNN}lSNMq7|2Q?;TQiTrpg+D=|I8*n1~FyD#jv<v zO~zz?1d4#Xq`iB_eJ&G$MM!JSj?IY71x5@2<NxM=W(~@AAOo3sl;_qs!NRjg^iO~V z#MT6sf4*T4sD7!!k@N<PnRHsVbXe|<@AP`;{|&g?ajH75Z0_!Pp{aSCSz{N$rs~Jg z+g+<YaBmqUAko|J5v!oAt~R$XKE&w95UYVKuSJ>Gy8(QQ+sn2Dur)Py$9(UD81^i+ zMKLkDLhGH2#Q7?w0+a3QmRzI*`*z5d)Qr?!QaS>7hgySt`HIJZjdy)>`IRWI#ud7H z&U4Gim)$xPV1m5f-F1tPZG2Xk7;RXY?u$;m$x(qK7Rx)1gu3_TxgLz=tkyVC@pglZ z4Al;W%ASQ$o=db{ZnppRQUUMQpj~M$AcK&(nj-b=+p-(Tmr}u#P3{!%Ia0r9&GJ{~ zL2u`1x9&aB(WuN9CJmIp>miS`BQ>H6jX6_df3&lgD!y3V&kec`W_gjX-51(TC|Kv0 zA*Y(m?gLxUQJ(o^_?#<fzRE!+YU^6MOJM-1jAYfNU*UEf<)>Owe{d<7L7^ba&LWUz zXq~W^mESUeZt0+!c}PZ=ldE1~R<TchgE^NOMjcZU2o1j*^r`I<`Bn_Ls^r*VGg=4z zL97h_S-H09rS3D@YP+{~A{7R47(Wjv0MuI`WyL#VMliO#b6t%nL#xs?Jv`r)%-6*h z-}<o+dU@$3-08E<3&_Ar*J;$0Pk)QP#`Nu7X9_a!%;zVBHk}vtHr9>);-ocbcAO+p zx^D<niL;~_M*X_^qNHBVpn<BjTMSFwU^93i_w8q2;gR-3%hEJ`^Po?OXL;44R>G#b zG`A5w#f$(Y@xw>V>00I&u?uy<Q?53Evt+|b>a2v>U=;=7Nk@KqQJ<_|QS_WsFW#N% z0`Q1vA)E8UavP~Gx_ishWeF_ob6D2=1+4mfA|>M0rk@y<bp&r(Oc&y$haQ^$23Bf_ z2w0$$<TDh=OKD+#QfgK~LW)az5XTCG&X~#ZqlbQ_MqF$V6oxo<_P-os5kWlHpFiXK z(}nPfKu7HwSDmgrMP=PY+KDNFEeL$`r694XEHtQl*HM}mUTx9J-u`^UAp;!I(m^*a z<FJo*-*#<GzRF@X0jqu6$R#5I(Dx<>1wIA)!gu4fXeUb|`J%^Hz|Kjcp`!^&mzpA+ zvEz$S*MV~cAvJ$krPzHu=Sh{Mr{*dXq)zSIh+G5X#@nB2DA9(=b%kfp8f*%9kd&ND zJFAli`4fV+$r6AYR1$f1Axx8krCN{v*gx!FbI(?Iu0nY=mdG`D9CGy7*`iQhl+yTp zF`<V7Rw2`_u;XfTrA3QWL--dn!QmUFLuFKm4qvZ4K&G%Ch_-I>(Ve#0f>;5^yXD@5 z0?F9P5=vEN8|sv<%L-h)el(b~<jK3?q$6P#u4f2w>ygRSf@kd6n;vaCWd07nDorCS z+dN{;s&j3b2lppbAg8mKtp{G~N0ETEM$OnPcOx~uoJuko9`4h|5xLk6o*LVXF%Qhs zQ}%1QTXRo9*}L>}os!U~2fl3lTGV-7EKGiLNI<`oJLZ_nb;4?^?=^1ozz`yrp=~(! zbyiHT!_PSeMiUB&cFL4rNAC?a83JV@S9N}S{K0q6qT9Q!#^#$c$}lYRuw4;ekUdQ& z@sY{q6u#s(EfG>62;}ElzOlGJ2M>FV&CLyZvS$l@#;*)b8UhvK{Ok;4ZlI>3gNJ?d z7h}}Z-o|@8Lcun0_f;P)NSn^QGUPAq@D6eK8=#~fNu9k>VPh}WJyx=f$+=&3Nz<3e zT#YDZL?mQ8S-jeCfnOc2KRqbyw2C@LLp_T4&H+^rp8D|Mn~PH38uv_*+mvF4`i^2; zMnf6Z6d0Pvcr11CXj;l)=hkhHce7UY3Q-*`l>a`N4s6}I>$G7XeQM5($PMOFXk+6! zS$l_Bn2bg5wj)i{vcQz#3vS}Wj7zerQbp13_$W-qTeaz=xRXQGmpyiuXSd||S%Pbk zCMTwLQD!GAwlk6z4dBym3UzTO_48C1XD-Ov$4EOjBGJ}5N7D+T3!BGKVs4?u?M9M9 zYXMz-Ew4T@w(OyzC^_<TWZX5?BJSuLqp9}Rs^(MPaCX{*av}BTz#t(PsxtP8g0mNe zKmY0?a(TC*JtoJUiVDB|>}&M(ntRr7Mufgo8sreow%O*)9<B-%bSsAkFTWOXAKx?x zT+N&e-M0zPe)2`nbmsI_BL4M-CEu`K?x70L2BKm?#LKUmL<V(xSpLsDSW;a(nBN*P zX-?z3rKC$s3I58cP<KdSCg_s^Y3;R;urR4*rvwpnTD=&fuX(a0oq-tx?S#mQu=EkH zxuvD;ouWPb`L=5FKkd_5$-)Run5oLPAa|{0M!!i=uAUYs#*z_osYjRS!0zuscT52s zJl}78YaXMar!-OJX|fg~ZazLed3j6&Tt4-FfCuZ)?=uSQD@awDa@HNd2>+CAycWam zvU$7`KtbXnmG>oBwhwgCUz@~)XFl*3f(Rx4#fsy5@W{f4Vh+U6ELaGAOt%F24evET zQ00f3jmSUH3Ns>+&x(i)@a?2r6c(fd!GFQ@?}Kh7%Uk!MWj*0dV-MBAZod4Y0vrxF K+UD&)e);d1p;4Fs literal 0 HcmV?d00001 diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_doc.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_doc.png new file mode 100644 index 0000000000000000000000000000000000000000..ae4112e5dcfb32091e9a36ce8da14916251aadb8 GIT binary patch literal 515 zcmV+e0{s1nP)<h;3K|Lk000e1NJLTq001!n002M;0{{R3ggkFb0000~P)t-sOlffd z|NjPl%LabR1%Jy1f6IHx_kaKYd&u|(e#!=Z%LaeTe98I$|NlFi*C>(GqR;He<MiwG z`-Q;fRH)q+h|UXw&G-BMbGPEz>-V_Z@oc~I0`^pF0000BbW%=J00Db{x$&I>oJRO? ztSc!1000SaNLh0L01FcU01FcV0GgZ_0003#Nkl<Zc-rmO%WlIU5Cu@TwVPOQd4qBC z|NmJHvD?HZ0Tz+E@Li3Nj|Rq@>AG(02lh0apHJN;U+h)*o{X#{wxlnM4H+uLBVje- zfz*k6QYr38t@tC=;+NEml{5(ceQFi_{81KHzU$H^s7pZzz9l6QwoQ0RFz>F7VwXc4 z;(rQhjA#_Vdxex?6<!FxoO&TcUPuj0DhYo9)qXjJi8&Ewz)T<!G++#%81n|04UEhP z2Ur#?2?s>REFf7-J)_L^urOhH`K|QiF1S2>xq@D2r7+XU1j{sCtwF?>!ea7^h^Z8S zTX9wh;Ns#;LIT>k8OGBPlylmKj9~N+XucwiiQ>{_DU-;FV70d1TAHL&TC3YvsmxBd zHitOGTg1=^hSG+-OJCZ;cNt5Y{x+X!*Y9iJGW28D{Qz^}hiI)saI*ja002ovPDHLk FV1k$+>sSB) literal 0 HcmV?d00001 diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_flac.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_flac.png new file mode 100644 index 0000000000000000000000000000000000000000..1fa3e81484afe78769619c1d073974e7cc898a0b GIT binary patch literal 472 zcmV;}0Vn>6P)<h;3K|Lk000e1NJLTq001!n002M;0{{R3ggkFb0000*P)t-sOlfg) zLD-vh>2pHZb3xd0LD-{z?0<j%qJZvlLD-~$?*IS)hh5%~Y2o$p{HcoQ<=ptXqU_DQ z@qbd>+s^f#dg_~a<H?pEy#N3J3UpFVQviLS|G7q*0-f=HZJlud000SaNLh0L01FcU z01FcV0GgZ_0003aNkl<Zc-rmO&2ob<3<O{@g0^`5C)@D;kJ`a4X<|YpAKDXpWadL& zoiq%?G|qGyV?N%8CO7&m=13-zL`z0lG-OtYXF?kBM0DbjD8&QOiXWmDccK?N=@8$f zN0>K-Sj=IHAXamjA_!{^L=f$>j1dL7rB}4M#3lYgWS6^yEdYY|RxJ_`y#w^ZgNeEX z5bnad-DPJ_g!O>;YZbu*K-#-`mW1_)sQ^s5e#FBvPF|HmT|^-U98t(52_hG`AW}im zHUH{?NU@fYg#$`(sY5V8M2twA(ue@8mOUaFAgxPbN#t51ZvRiU(6+Ewg4q#e=1ew~ zvO3;&98xUFp3G3?5|{WBF?WJlx{wbUr7Qf9NxJmg-0H1AYP-ziGz_0glr!0}JTPSd O0000<MNUMnLSTYREYDK_ literal 0 HcmV?d00001 diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_gif.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_gif.png new file mode 100644 index 0000000000000000000000000000000000000000..bdbe2cddad286a5e720349c1000167deebb282bb GIT binary patch literal 451 zcmeAS@N?(olHy`uVBq!ia0vp^hCuAV!3-o@Z1TN;lv99Dh^tR#;j2Ro|Nk?*KEwcI z0LdS>gZ}@o2a;fR$p8QU|NVOL`I^tK_q%^QpZ)*ej}PYzzuqf+dtCI}<Brd_l3wwY z9{`%hRTAVE%uvht|D)S%rnx(<tYVe|ML7#RB8wRqxP?KOkzv*x2?hp6K~ERQkcwMx zubs_nG2mf&ki&a>QsKYDzxQXc-py^y*tl5k7IX1`ld_fxy@roXE}V|2t~YPL^Fn8v zs<P*yx;H0Px|t<KlKefC%UsmAxoDcXXxDW_)d=scn8f!{!S>L-`za4|E?-igCg!M8 ztuiUYYmJ7+&bY!es(K&Il|<Ki>=O#?bqMX^60mHznQ845Cvm!Z%G(EwaY9F#H?ozP z7*Fo+kU7`NZoyPAWuXu!hs)<+jb*1gPK(Jt`e+s_yElpL(yrd9DI9!uWgN4nFaK)W z6m)K#j?z`1C$DtMmaOkLxVU;tb;8{4D@q0eJH@s}h|Yah|3^~qBe!8mljE)lvwbd! q-b<OH_cuB3^;-MPa>->EXZVXo8gZ}x>vbI%Vho<HelF{r5}E+6L&N3( literal 0 HcmV?d00001 diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_html.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_html.png new file mode 100644 index 0000000000000000000000000000000000000000..aa4b93f98729508bceb7b925ace136cdff180c8a GIT binary patch literal 420 zcmeAS@N?(olHy`uVBq!ia0vp^hCuAV!3-o@Z1TN;lyQJhh^tR#;s5{tcb__c|J6Gn z2}bAczI+E3JO?Dd|M+?I(zRQUo?f_p@BXuwPhY={Fq!-wsGX}M$S;_I@&EsiJ8S1M zxm{Xe`Vc6}S>O>_%)r1c48n{Iv*t)JFfcNDx;TbZ+<JTMblwpK9@c=Y6F!gGpZ(vz ztto95kH*G{yJon)V`4q>P;pZMM`End9JOQ5%U^tISbb#TnuN}II@?}&6m^s{$$fOI zdE_zYk;9%O^}LVne;&E29dS!@sj0i^^ms$Jq6*(`38mxnA{N<)^(<>xvw6{Z-Mo*9 zO&ak&%99)w_PJ=<aP1M;6WyL*$F=URS(zG_(zKoq4=0tbjzcXTHA(_2&)qz7ZRxFP z#uHz6KKULJa{JCTeGhT-<*^yNg_u`_pK#}N&^Z&l$d22xC95|2BF_SsrL_gpXS5Vf zHP85eJHN2|zeuK#%4#RMO%b6#CR|<h<+n}L+Wi&pCrvYLkza5|WzLTkGAn^0#Ng@b K=d#Wzp$P!H)xuH$ literal 0 HcmV?d00001 diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_indd.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_indd.png new file mode 100644 index 0000000000000000000000000000000000000000..1c1acd814edee1bc4b35b7b43efe84d722e99c9a GIT binary patch literal 232 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM3?#3wJbMaAX$AO%xcX!kPV!nm*=NJ-$Zh>j z>w2Bn0vUZSYu|kM`1rx=&27im*BqG<yyfwOS8nX5_W*UVl?3?(GaP;H<>{f|rQ-|~ z;VkfoEM{Qf76xHPhFNn!HU)XQIEGZ*I(z;!UxR{xbKsf?wk!Yt$3{x%_6yvsoNjwk z<?)>bqBT(>Dd_<Vo4h!cME5KzSh3+{c!Ki=OONGW72nNi5!t=m&c37SIIrA7j_5dj YEq~5N_JFkufTlBey85}Sb4q9e07l7GQ2+n{ literal 0 HcmV?d00001 diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_jpeg.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_jpeg.png new file mode 100644 index 0000000000000000000000000000000000000000..9345579ac9f55ef698e73b9a9d09b6fd41abaac7 GIT binary patch literal 494 zcmV<K0TKR*P)<h;3K|Lk000e1NJLTq001!n002M;0{{R3ggkFb0000;P)t-sOlfiA zg#hA&0OExJ;)DR=g#iD}iU0q9|Idozg#iE0i~s-s{`&OhlpgQ5XZq#I^U9Cupf~s4 zx%Jhj{PE!N!G7$kP5j7xF?Y?M0000AbW%=J0DFJ`@kW{gow-Ox!RP=0010qNS#tmY z3ljhU3ljkVnw%H_00A~hL_t(|+U?fMZp0uA1<<rk+J?&`Jm>%av?7^y5)j37HH-0z zARXar5s7J<mUX6P3Gwwg74=Skm8i)=JyDXiFA6d<#FY?7TnJB`2~(U1S3C$?{1U$S zAp_!0Muhlah?}TR=?E>VQ#vAvigZNTmJnS*`Zg-cdWn~KN|Br%6dqwvSfBozL)miC z5K%g!AhiKxPNepQk*X;+7$ONE0I);>2>G)soFXORNdr1>_J!WZX6~c2Oc4>t!4HTH zfjr!R2ynC^Pf0U`jLGNF76_Z;%qkBwukx4x!|5tI7msTbwM}iI$mFO;j^hp}BIO_( zw?yS`;#pW=?=Ut*0%V^5Nq7lJbNOekIgWOfs`r}78TArRCgwpf_g=_Vt-V)xRZH)s kU)J0H)?c|<=5?8-Zwp#90}q#uegFUf07*qoM6N<$f*>~9W&i*H literal 0 HcmV?d00001 diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_jpg.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_jpg.png new file mode 100644 index 0000000000000000000000000000000000000000..9345579ac9f55ef698e73b9a9d09b6fd41abaac7 GIT binary patch literal 494 zcmV<K0TKR*P)<h;3K|Lk000e1NJLTq001!n002M;0{{R3ggkFb0000;P)t-sOlfiA zg#hA&0OExJ;)DR=g#iD}iU0q9|Idozg#iE0i~s-s{`&OhlpgQ5XZq#I^U9Cupf~s4 zx%Jhj{PE!N!G7$kP5j7xF?Y?M0000AbW%=J0DFJ`@kW{gow-Ox!RP=0010qNS#tmY z3ljhU3ljkVnw%H_00A~hL_t(|+U?fMZp0uA1<<rk+J?&`Jm>%av?7^y5)j37HH-0z zARXar5s7J<mUX6P3Gwwg74=Skm8i)=JyDXiFA6d<#FY?7TnJB`2~(U1S3C$?{1U$S zAp_!0Muhlah?}TR=?E>VQ#vAvigZNTmJnS*`Zg-cdWn~KN|Br%6dqwvSfBozL)miC z5K%g!AhiKxPNepQk*X;+7$ONE0I);>2>G)soFXORNdr1>_J!WZX6~c2Oc4>t!4HTH zfjr!R2ynC^Pf0U`jLGNF76_Z;%qkBwukx4x!|5tI7msTbwM}iI$mFO;j^hp}BIO_( zw?yS`;#pW=?=Ut*0%V^5Nq7lJbNOekIgWOfs`r}78TArRCgwpf_g=_Vt-V)xRZH)s kU)J0H)?c|<=5?8-Zwp#90}q#uegFUf07*qoM6N<$f*>~9W&i*H literal 0 HcmV?d00001 diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_js.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_js.png new file mode 100644 index 0000000000000000000000000000000000000000..6fca286d20742d4fd9dfdaf133e2acd78f5746cc GIT binary patch literal 444 zcmeAS@N?(olHy`uVBq!ia0vp^hCuAV!3-o@Z1TN;lwW{Ph^tR#p_Z1GwzhV}ekmXW z0>bx6*4Hxt+5i9l58o?QQBm>!{rkSYJ{ubw4-b#v;NVA(9$mO_A!v=vk|j$H960dn z*RQm+G?#kCO|xgu1e(lM666=mkp2JvN4MI!OgsNHi`@c>au#?*7Bet#3xhBt!>l<H zK*4{WE{-7;x87bm-Pdfu)B5lPXRhVZoerE|{>GQMm`)dRNj|@7>W}a9dp0|m9Wz<G z`0l!IHHNP0RadX(c{rN}PTHo&^@t_O*F!1HMQvVZfQ{hFAIeD|y`Ma4*EnLnl4JKP z9f9plF<X*m1__q$R$L;Jw7Th?`C1L@BZ~LJkJxCnvNshSd+~p}yGNjwRoWsGh6H_) z868yx?+ym)C{5_twP1$f!tb7Yjw{(PeA(<2AXS~<#kEnPZ92F8nZmS^Pur7h#5g}m z9(=A?eepG$)WI$H!#=R2^mYgvxb#NYy-uC~Io;w&UxC+yEb&jvww1N!XJt*VyZN&4 i_4Z4#F}Ec?v%a)dXqk9IV?Qt?7(8A5T-G@yGywo@U%7z* literal 0 HcmV?d00001 diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_mov.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_mov.png new file mode 100644 index 0000000000000000000000000000000000000000..dd413f79184662b0ccd67871b7309060fc7e5b19 GIT binary patch literal 475 zcmV<10VMv3P)<h;3K|Lk000e1NJLTq001!n002M;0{{R3ggkFb0000&P)t-sOlfg; zxVLq;w|2O<b-1^6xVQiR|E=5FfB*k=xVEp{+yDRnjl{$9`S`-%->=)+<?`{i+1Y@; zz1ZvPr_$1y%gVZS*C_x1019+cPE!DWdjCf80i6Q5h0b!f00009a7bBm000XU000XU z0RWnu7ytkPDM>^@RCwC$*4=W0AP@y$+Y_70VOM_N|5ZECCQZ-=yy}f-#$nd+13@o# znx@M#<Ik+-`Z+au!>g=jLoVovmMneIka<8n6O4!_GA16$pm-po;+G7II~f-@azOl$ zBf?xij*7ZUM<ip~RXQS@mh{AC5z`eRuLs2@r#Qta21T#|fVH>Ez7$TplJwS*Vu3PJ zJd#M!BDNJ$9;la)iXfy2=>wSnPr;5zNTjofjPPUzBBfSv^gF@>nY4h?eLq+0M8ekl zd&1s@qoURs!sO|2Kq#Z*+7+?Rx{{&1S{ZsGk(!g#-3aT@5r}2x`emT?fH%Tx27+n; zvX-Mv(M7E%WL5w3Pdvpb_QiY<%)JhIlcm>%Z*u8%`fc88ufL4$WnM1R^aY$-G%Q?u R3uFKQ002ovPDHLkV1lo2;D`VK literal 0 HcmV?d00001 diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_mp3.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_mp3.png new file mode 100644 index 0000000000000000000000000000000000000000..c1ce20e46406fcfdcf07594a0e4d83b5a1719a71 GIT binary patch literal 565 zcmV-50?Pe~P)<h;3K|Lk000e1NJLTq001!n002M;0{{R3ggkFb0001EP)t-sOlff} zCEY9~-7O{FEF|77B;Ssu{fUYHkf;AFB;Jpv{VgQkEhXNOsQ>@}|8Z;SG%VmhIN{97 z_hDS;kf;BtrSj<K{D*_>Ohx1P`2O+m{hF5WSW)GBc<kBN`M$gLgqQo*_k_y;000bh zQchC<xJ0^rNSXni@t(Wcvm|{0000SaNLh0L01FcU01FcV0GgZ_0004CNkl<Zc-rmO z*-pbS5Czb5ffATpZ;j(6OaK2X(Ubyh5^DPikb0jY%g5u9Bg<7)J?(1xF~{cRP|dQZ zdpR}>@<b^yCvRynBXx$jC6ps>NS?SRnc|A%if@uFzDU0KBn6^*J(P;(`H&W;8*#2u zCb&vMm}X8=Le7gcDM4~L6^cb3;vxQxP<^jTM6UrryKJEX&arY;xSq$|Wj)#Rhp>ZE z8tCUjg&-!t8)E@2MHo8|jE;yG=>WG7fQJRF_W>9L0B?bjYbjx&X+_wcT40jWA_}0B z6ahm#=v%cW3|9|Cq|pk1SNG`70#@V+vWUDZqv2hi2rT$!1ctPb=ZQjgl8D<%3q1@L z-dK#W4QuDI5T1{^Q{%^(w~NkYjE<;}yze0j0k~FAg74i)PpwJ*iI>jT@{2+`S19h} zA^w=C3qf7&3dJV%3SVTKdg;%z=Xc+BliN$Z*;ds%0b7<r^~C_W00000NkvXXu0mjf D!D#~W literal 0 HcmV?d00001 diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_mp4.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_mp4.png new file mode 100644 index 0000000000000000000000000000000000000000..9d64a53a85d9a5aff3b64ac3b485b726e20c732a GIT binary patch literal 500 zcmV<Q0So?#P)<h;3K|Lk000e1NJLTq001!n002M;0{{R3ggkFb0000>P)t-sOlfh9 znV$du|3gq!LQhmePgO!sRE?USe}8{NPgIYbq5uE??e6cbv$bw`d|6~@!p6w<_xIA( z)=gPnoTH_`#Knq~n0$tc;p60)HyAVk000VfQchC<o&o>4@qL^EMyD@N$N&HU32;bR za{vGf6951U69E94oEQKA0Xa!TK~#9!?bg|jgdh+FP-jZJTP`~){{NRwq<66`c$rKl z-Zz&66k<|M)AVgeA0x#1(yVf#y@;Ij#ECU&d9fm0fp{lWBHl=ucqN76g_MdXDHab> zF7Bj4h_6en5a(}ROd^|w5m98bFhYwp;lz4bLa-v}sZwM)#3BB*Fb=Xxgc+ZNHtDAe zw~^B&5h-km$<0~<a~K>j(CCx}FfMok<_#eL2MI_>2N#ytr2|wdLaA+G14%$o1hj?J z6g=cogr{w>-1BMBB*yD{!U$=~g$5L$sa!+=aNc_f=OZU<+EzE?9>EEyH~@AUmUy^9 zvm&kN@eT7jv>KMpQIRMpWh};My}AL4>FMsdx>jCuc7H<{V=Bc?4)MoCR|z_<L(bB2 qU3iwB>-5(-rCz@+t)*-GruhM+<u-2v3n%pe0000<MNUMnLSTYxHPgcY literal 0 HcmV?d00001 diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_pdf.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_pdf.png new file mode 100644 index 0000000000000000000000000000000000000000..95ab6c6751311aabe5834287830cc364c5c1f94d GIT binary patch literal 514 zcmeAS@N?(olHy`uVBq!ia0vp^hCuAV!3-o@Z1TN;R6>AHh^tR#;VD+u|NsA=W@SCa z#&#NrSXqB}cmJ=i2eHBEcW*CH!L!!ZzaKwdv#@xOnR!V=<Gi@|j~h3BK7Rae?b?fq zik}Z3zUA)z@8{3^5fOjhy?Z%z>bLs(SEZ%DA4<#w+QnTG<QL4ai*dJG?T)!jA7cwN z@}>cWISV`@i-EGjAk4@xYmNj11EZm*i(^Q|t+&@s=N&Q-U=6s;mSM7QoA6`zxDWs9 zBP}n82{j&{y{-GsRgaGsD|B3?6kWEiewn$%rmn;F+ZU}OU#(M}s+H%vSyxIvnke9I zBQS4=x=*3|jz{eqj+#I0)(=w1TjFx!adh0Ar>nf(Vx9nrk|NKdq)J`Q&4>7&J8e9r zX7({$N#eA~*%z1WOPNf<î=~h)@xVzeNO=kYo%=L#D-WnH5{=LF%*n89RKxIaj z$%5nRleL%hH>hV-ANYEL+g6}b;X=zk<=4gAgDfuAELuE6>gn?xT--cQ7W_~W>1Hz! z5v*jY%P1-0n!$GUxhucIUZKe8TE}-x{djS9NW11SH4RshDVy|J3_qPdv~6L4!RF3- zwO397-LVTTo^1ONy6Vod{b%a#edWIs`N~=Ki2BRp9p=9e9Eb#lD1)b~pUXO@geCyU C8RU5Y literal 0 HcmV?d00001 diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_png.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_png.png new file mode 100644 index 0000000000000000000000000000000000000000..9e5fad2de42334d8d49fa59ec4d03d4643554db0 GIT binary patch literal 491 zcmV<H0Tlj;P)<h;3K|Lk000e1NJLTq001!n002M;0{{R3ggkFb0000&P)t-sOlfiJ zkNVMC<L#09(OKisS>w@J<L!|8|9}6{TI2u!|M1B6)@kPa^#0<P?%H|j?6~vy<NM!* z?C7cS(_rQE()jdg*!BPb019+cPE!D$|NlnueVhWhga{4s00009a7bBm000XU000XU z0RWnu7ytkPIY~r8RCwC$*4d7PAP@ymr_k>9O0oO@zd9*8PTRPEkCS*G2tiKR)X;TZ zKMr&lrJk>@$b-(Jwxp+;D9KnC1sNK|H=!2sMcTxgG>R2z6(7<p{z$u6$OWN(URQ;B zUKV+jiU?{mN<~<;Ar+BIp=yFsxGFZe#Vu~pDzw#0H7!+b(}8g8CIC|+Vm1zdlRtw8 z*m%}ELb@~OegNmHrNmMPurD}J^585CX~0mFBDo_m5s{IH(v64_X@ZyE+?lX4m$&eU zH0Q=s5lS)Fgagt_ILI!UBLNp8#C=2nyqgdADu?Y_$e4gXoRyhoXJu-+j4sF;Vzj60 zC=)G_p5>4lMZo*aW3#Z6^!H}>P1IV`UQ*HgAHKm`oQmNh7-~1<O~%?Se3QO*(=YSL hcm1(-EyLJ%-7kZ1B|}W>D<}W}002ovPDHLkV1jSE=4Ai? literal 0 HcmV?d00001 diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_pproj.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_pproj.png new file mode 100644 index 0000000000000000000000000000000000000000..6308b56dcb2625a460584a4e3120e71d5a187d5a GIT binary patch literal 266 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM3?#3wJbMaASqAuoxcX!k&W_yH=d$+AhmW%( zw)Hu!19B&Nt)Jk&ZnD?<nPFQW-+Q$*XW#m|qt{P9IynE*zFFswue~<ae-q<Gt{$Kv zTqQw%!3_WZ|9|1(b>#W+EVHzHpeSd7M`SSr1Gg{;GcwGY1F}5P)5S5Q;?~*o-h2%P z94r@t)+l{j{C)o#Jt^5}lfvgZ&sjOn?M;z+ai)dAwo=6D!)M_JlY(650FhUploR5@ zq&4(TvfbiZ9&?8C+{JwdziItq`zwFzTa=?z;7Wlb_mv)Ilylu<mc5)iaSPBg22WQ% Jmvv4FO#pUVY*qjO literal 0 HcmV?d00001 diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_psd.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_psd.png new file mode 100644 index 0000000000000000000000000000000000000000..d44694afb6dc0ea717df8d56a03fa9a96762a207 GIT binary patch literal 537 zcmV+!0_OdRP)<h;3K|Lk000e1NJLTq001!n002M;0{{R3ggkFb0001HP)t-sOlff# zAy;SB?EwG(86j5~B35YC?0^6NXV>o;Ay*h8Rv95z86sC{*zo`V|9gO+?e6m3-sWi5 z?>0SbN>O`^k*m(o+aoPt`T6>2Zjrpc&Y+~au(QQ-c$s0w;7gUbSFp}Oe50s_SQ7vM z01R|ePE!E5o&oW@ngX3hetJJ2M&keg010qNS#tmY3ljhU3ljkVnw%H_00BWsL_t(| z+U?fePJ}QJ1z=ECMTbM#DgA|2-~S~|S<wXoEr~IjaIWU&OQ+{*vMei41+C^>Jw0Z# zJkwUr)q<216V=_6oZ@0eiUctwlp-c1P27@1aYItYH%S&>BwdVThxjCWMD=hV7ng|` zUWtgesR*w`#CtU-F`+MW5)-6fFFVB|hd9Ll5w%b|MT_Pqg_e9BcpN0Ji(a_a0E)v1 zKuIDifX1zf8h{aiA_*ub;KG1_SB2?`Jix;mx+Zckl31+^5k3Ie9&lC@Q3JpA^Q$0( zS}OqPILvXOfu6{^&_SB8fE;>m0dCN33pm>g2x8|Yw=miRt@UQB-4HuYo;XyCW@9Va zsV?j!y;7YG&2`FIZtKmiV)<pEnRklB9O93OVkanK7xE%`><V9`j9vQkybQN~p8A&} bFSG0e85Wc#n;Ez!00000NkvXXu0mjf5Y*xw literal 0 HcmV?d00001 diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_tiff.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_tiff.png new file mode 100644 index 0000000000000000000000000000000000000000..16f4bb475b8b36d4abbd53e4b75651abc986a68a GIT binary patch literal 460 zcmeAS@N?(olHy`uVBq!ia0vp^hCuAV!3-o@Z1TN;RBV7xh^tR#;XVtCY6gZXFoH1Z z85kgf{}~v7BKs{Z|NsAgq__9f^y&8w9QgL`-Jf5-)+Q!SP*r_=^5pf68y_4wva`H= zp{M8j`}do;xi2nTw!_$XwT6b&y^F~}i?~aI{DK)~?QrqfUCT80Bcqk^`<p;v&H|6f zVxX)r2s1Lwnj--e{O9T77*cWT?X}Z+hYSQ*1I!ttzE^!ZaO^DizyHSC!cGUpW`0~0 z8?v)|wa3MaJ>`q1+<I#ocWg#~x#-h|tuv;)InX)dO{JiquE2Q~p^q*pk0x>)WuDk2 zuOwEkB+h+Oz<iSOncM36=hS8%>ge`dW^icci~^;&(-L7l(~FmRZ0hv=vsGg5Zgb_T z{Ki5hCF}29FJ67Pewm@f#rgHKI6>a4iEah+Y^J3hHH{Bpw21w^RidvXM>=G!yR_Zz zot1&X%A(uXHVNc?%|GJO{CabXs#1|9)3y0(*Tcm&g!V7J&NAOO=TUgeIhI+wCrlQ( tBRX%%6TQC{?FHYbwtmswr7`svTd8!r1-pu6Ixr*{JYD@<);T3K0RYh3zj^=w literal 0 HcmV?d00001 diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_txt.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_txt.png new file mode 100644 index 0000000000000000000000000000000000000000..9215e1a8e39a1cefb3399ecb524396c7922119ec GIT binary patch literal 430 zcmeAS@N?(olHy`uVBq!ia0vp^hCuAV!3-o@Z1TN;lzo6th^tR#;nk~GH*em2?b<aU z31R&I|NqLBD-bSR<nG<O@87@w`Sa)Ay?ZZQxN!XV@#oK<_dYY82sDbjB*-tAVOH%N zx1IAC=Y9+nI~HpK6y_}Oh%5%m3WG2s!>l<HK*4{WE{-7;x87bmn0Hu#hb`e&Yw8Iz zi8H_LUq8vY%9Ua0@jCTXJ*P)aLm*$-xm>#g>m6%u$4<AN!|J}NQT4-O)q7&HoWhG# z4V0yf)aRXCQlYf@QM(U!&HeDB%{m?Vo0L9myS?Z^!Kqmnf_W5g2z&FiFVJ*2wdzH2 zr}rOWMZwvY$C5fcR?bxKs@K&LG)#ZSHDTt&(zsm4R*wZiOwl%Zp`F}?aVwNxKTBQt z$tk|MQt;)DD|-Z#?A*3p2p7^-<`R4*yhLZY(&TAt_*aXc-ql>^$Kv6;^~j3uMgM}A zF00ae6Z1$&UocUSWwo>1qqTZ9C(=rP)xL9I|2{pNv#$7^FjIC*dZmTH3}8qwc)I$z JtaD0e0syW|zjFWp literal 0 HcmV?d00001 diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_xls.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_xls.png new file mode 100644 index 0000000000000000000000000000000000000000..a3a43ea53c2464708a326c54fec753ece69d776b GIT binary patch literal 488 zcmeAS@N?(olHy`uVBq!ia0vp^hCuAV!3-o@Z1TN;R7ikNh^tR#VdWZz%?}+mJ$3*x zD%LP;dgK5^6{{H_Tp+pSiR1tO|8Kq6vH7w6xrYnB{d~Lcdh_Ic2Ho4079RC^`{hp4 zCc%y8v!1*=z3jT-iMvxTdCKnrn$KMl<QL4~GH<R&?X0;wKh9=Z<$4|{%vs<OSqzjF z24O~qS#u;97#Nj3T^vIyZoR#BJFnS*hb`g8E#4=80<77cD*u0<!;u^AaQ0!^Ewz8# z>pq<<Q&I^JEzOFmni*Fcc6*NIw79n?I)jqDXKlGL!OVFL=hF_+lfvg!f^8<oeDvMa zTBR>m9;9$3dSih6QKb;ij@MCVLWP`G%X)U?MtiBU#{Lf4cvg|`<L;xa%bhMh{1lvY zK~2kKbIbbJ7D>hp{Z|DXCeP+)e|TUn^X!fVf{PFEb{lL~&rdS-U~brZp3&ms%9E@! z<R<^VX)P@->lD0w_od0s8E?0KNZ?DCf4@*tNU-f;u0+bqtS!fkYFv6=JUeWoQ+|}A znP;N7h@p$ykxAm-CLZQxo7n2#^jjUV7t2-52$ue&=bOcSf2m*YuiJB;>#oZyO3$3V cYt=pm^UJMwKkTa%0EQuhr>mdKI;Vst02r^+eE<Le literal 0 HcmV?d00001 diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_xml.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_xml.png new file mode 100644 index 0000000000000000000000000000000000000000..777f56d0dd21d993fc7ffeb978431c92f999138d GIT binary patch literal 292 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE3?yBabR7dyiUB?$u0ENCBDKFo>V5-Bq1ykV zwZBAbe~Z@rf{2LL{k}QdaXnBmS4ogxFvBCa-O`2?xmPB6B`RnDML7#RB8wRqxP?KO zkzv*x383I)PZ!6Kid%axzvervz`^=}x#D%@fA+XtW!l-Uo4t?m3qKZj>^NZW)Oz0G zEkn6q!1@OLhDtUSUxwQ}7uXbT?$R^d;LWK1;lvk7jlHjBHTFHN?y#D$WU{vm!%DZ{ zLm3KvS$r3aij}8GF<guB%;0QXv$_4?G4ng-i=~n_>`!98ebKshMp=<nxBjuap0fXS e!f%~_#P*>^IBde9x95P)WAJqKb6Mw<&;$S}ByB_h literal 0 HcmV?d00001 diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_zip.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_zip.png new file mode 100644 index 0000000000000000000000000000000000000000..587554a70807b516ab418343040d3ddd35f58271 GIT binary patch literal 300 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM3?#3wJbMaAxd!-zxcX!kemuf&;u-gOj?l-$ ze6QMNp4BQnt5bTjMEv7H{^xayZ$5mSx^&sc-2yM4-~PD8=j~+4k7s>7!b4B(OxSh! zWL3|klRFX>mlsY28pu@=<QL5F|Ns9dFK@had;R^1bOKP6v%n*=n1O*?7=#%aX3YUP zq}<cRF{I+w)k~g2%?3O!f!og}2uw1VaOeLDl|?~`0_+d+<4ZFa3plO&pnpjq@ac!O zhvnKN8qYCU=rgeAG%nX!_SAIe2eFy40-=SCUW)P^c@AIMX4Fmy`tw+8k^KYP0xzBo u2P*_F7dHR9#8$d1MEc1x+p9JA(plLx*-Cb6u}cD-z~JfX=d#Wzp$PyP_In`! literal 0 HcmV?d00001 diff --git a/apps/android/app/src/test/java/com/makerocketgonow/pushthat/PageIconsTest.kt b/apps/android/app/src/test/java/com/makerocketgonow/pushthat/PageIconsTest.kt new file mode 100644 index 0000000..284ebb0 --- /dev/null +++ b/apps/android/app/src/test/java/com/makerocketgonow/pushthat/PageIconsTest.kt @@ -0,0 +1,121 @@ +package com.makerocketgonow.pushthat + +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.jsoup.Jsoup +import org.junit.Assert.assertEquals +import org.junit.Test + +/** Which icons a page offers, in what order, and the sizes they are cut to. Every page is made up. */ +class PageIconsTest { + + private fun candidates(head: String, pageUrl: String = "https://www.example.com/blog/post/"): List<String> = + PageIcons.candidates(Jsoup.parse("<html><head>$head</head><body></body></html>"), pageUrl.toHttpUrl()) + .map { it.toString() } + + @Test + fun touchIconsComeFirstThenIconsThenTheFavicon() { + val found = candidates( + """ + <link rel="icon" href="/i.png"> + <link rel="apple-touch-icon" href="touch.png"> + <link rel="shortcut icon" href="//cdn.example.net/s.ico"> + <link rel="apple-touch-icon-precomposed" href="https://example.org/p.png"> + <link rel="stylesheet" href="/style.css"> + """, + ) + assertEquals( + listOf( + "https://www.example.com/blog/post/touch.png", + "https://example.org/p.png", + "https://www.example.com/i.png", + "https://cdn.example.net/s.ico", + "https://www.example.com/favicon.ico", + ), + found, + ) + } + + @Test + fun relIsAListOfTokensInAnyCase() { + val found = candidates("""<link rel="Shortcut ICON" href="a.png"><link rel="mask-icon" href="m.png">""") + assertEquals(listOf("https://www.example.com/blog/post/a.png", "https://www.example.com/favicon.ico"), found) + } + + @Test + fun svgIconsAreLeftOutByTypeOrPath() { + val found = candidates( + """ + <link rel="icon" type="image/svg+xml" href="/by-type"> + <link rel="icon" href="/logo.SVG?v=2"> + <link rel="icon" type="image/png" href="/ok.png"> + <link rel="apple-touch-icon" href="/touch.svg"> + """, + ) + assertEquals( + listOf( + "https://www.example.com/touch.svg", + "https://www.example.com/ok.png", + "https://www.example.com/favicon.ico", + ), + found, + ) + } + + @Test + fun hrefsResolveAgainstTheFinalUrlNotABaseElement() { + val found = candidates( + """<base href="https://elsewhere.example/"><link rel="icon" href="../img/a.png">""", + pageUrl = "http://example.com:8080/blog/post/page.html?q=1", + ) + assertEquals(listOf("http://example.com:8080/blog/img/a.png", "http://example.com:8080/favicon.ico"), found) + } + + @Test + fun onlyHttpAndHttpsAreFetchedAndEachOnce() { + val found = candidates( + """ + <link rel="icon" href="data:image/png;base64,AAAA"> + <link rel="icon" href="javascript:void(0)"> + <link rel="icon" href="ftp://example.com/i.png"> + <link rel="icon" href=""> + <link rel="icon" href="/favicon.ico"> + <link rel="apple-touch-icon" href="/t.png"> + <link rel="icon" href="https://www.example.com/t.png"> + """, + ) + assertEquals(listOf("https://www.example.com/t.png", "https://www.example.com/favicon.ico"), found) + } + + @Test + fun aPageWithoutLinksStillHasItsFavicon() { + assertEquals(listOf("https://example.com/favicon.ico"), candidates("", pageUrl = "https://example.com")) + } + + @Test + fun manyLinksAreCutButTheFaviconStays() { + val head = (1..20).joinToString("") { """<link rel="icon" href="/i$it.png">""" } + val found = candidates(head) + assertEquals(PageIcons.MAX_LINK_CANDIDATES + 1, found.size) + assertEquals("https://www.example.com/i1.png", found.first()) + assertEquals("https://www.example.com/favicon.ico", found.last()) + } + + @Test + fun anIconIsScaledDownToFitKeepingItsAspectAndNeverUp() { + assertEquals(64 to 64, PageIcons.fit(180, 180, 64)) + assertEquals(64 to 32, PageIcons.fit(200, 100, 64)) + assertEquals(11 to 32, PageIcons.fit(100, 300, 32)) + assertEquals(16 to 16, PageIcons.fit(16, 16, 64)) + assertEquals(64 to 64, PageIcons.fit(64, 64, 64)) + assertEquals(64 to 1, PageIcons.fit(1000, 1, 64)) + } + + @Test + fun aLargeImageIsSampledButNeverBelowTheIconsSize() { + assertEquals(1, PageIcons.sampleSize(64, 64, 64)) + assertEquals(1, PageIcons.sampleSize(127, 20, 64)) + assertEquals(2, PageIcons.sampleSize(128, 128, 64)) + assertEquals(2, PageIcons.sampleSize(200, 200, 64)) + assertEquals(256, PageIcons.sampleSize(20_000, 10, 64)) + } +} diff --git a/apps/android/app/src/test/java/com/makerocketgonow/pushthat/e2ee/PushDocumentTest.kt b/apps/android/app/src/test/java/com/makerocketgonow/pushthat/e2ee/PushDocumentTest.kt index 7623606..34579ea 100644 --- a/apps/android/app/src/test/java/com/makerocketgonow/pushthat/e2ee/PushDocumentTest.kt +++ b/apps/android/app/src/test/java/com/makerocketgonow/pushthat/e2ee/PushDocumentTest.kt @@ -128,6 +128,73 @@ class PushDocumentTest { } } + // ------------------------------------------------------------------------------------- + // Icons + + /** Valid base64 of the right length for a full-size icon; the envelope never decodes it. */ + private val bigIcon = "A".repeat(5464) + + @Test + fun anIconTravelsInTheEnvelope() { + val content = PushContent.Url("https://example.com", "Example", "", icon = "iVBORw0KGgo=") + val fields = PreparedPush.prepare(keys, "push-11", content).fields() + assertEquals(PushDocument.Read.Opened(content), PushDocument.read(keys, "push-11", fields)) + } + + @Test + fun noIconWritesTheSameJsonAsBeforeIcons() { + val json = String(PushContent.Url("https://example.com", "Example", "").toJson(), Charsets.UTF_8) + assertEquals(setOf("url", "title", "ext"), org.json.JSONObject(json).keys().asSequence().toSet()) + } + + @Test + fun anEnvelopeFromBeforeIconsOrWithAnEmptyOneHasNone() { + val old = """{"url":"https://example.com","title":"Example","ext":""}""".toByteArray() + assertEquals("", (PushContent.parse("url", old) as PushContent.Url).icon) + val empty = """{"url":"https://example.com","title":"Example","ext":"","icon":""}""".toByteArray() + assertEquals("", (PushContent.parse("url", empty) as PushContent.Url).icon) + } + + private fun opened(prepared: PreparedPush?, id: String): PushContent.Url = + (PushDocument.read(keys, id, prepared!!.fields()) as PushDocument.Read.Opened).content as PushContent.Url + + @Test + fun aRetitleHappensForANewTitleOrAFirstIcon() { + val untitled = PushContent.Url("https://example.com", "", "") + assertEquals(PushContent.Url("https://example.com", "Example", ""), opened(PushDocument.retitle(keys, "r1", untitled, "Example", null), "r1")) + assertEquals(PushContent.Url("https://example.com", "", "", "QUJD"), opened(PushDocument.retitle(keys, "r1", untitled, null, "QUJD"), "r1")) + + val titled = PushContent.Url("https://example.com", "Example", "") + assertNull(PushDocument.retitle(keys, "r2", titled, "Example", null)) + assertNull(PushDocument.retitle(keys, "r2", titled, " ", "")) + assertEquals(PushContent.Url("https://example.com", "Example", "", "QUJD"), opened(PushDocument.retitle(keys, "r2", titled, "Example", "QUJD"), "r2")) + } + + @Test + fun aPushKeepsTheIconItHas() { + val withIcon = PushContent.Url("https://example.com", "Old", "", "QUJD") + // A scrape that finds none does not take it away. + assertEquals(withIcon.copy(title = "New"), opened(PushDocument.retitle(keys, "r3", withIcon, "New", null), "r3")) + // Nor is it replaced, and a different icon alone is no reason to rewrite. + assertEquals(withIcon.copy(title = "New"), opened(PushDocument.retitle(keys, "r3", withIcon, "New", "REVG"), "r3")) + assertNull(PushDocument.retitle(keys, "r3", withIcon, "Old", "REVG")) + } + + @Test + fun anIconThatDoesNotFitLeavesTheTitleAlone() { + // Fits with its title, not with a full-size icon as well. + val long = PushContent.Url("https://example.com/" + "a".repeat(8000), "", "") + assertEquals(long.copy(title = "Example"), opened(PushDocument.retitle(keys, "r4", long, "Example", bigIcon), "r4")) + // With no new title, there is nothing left to write. + assertNull(PushDocument.retitle(keys, "r4", long, null, bigIcon)) + assertNull(PushDocument.retitle(keys, "r4", long.copy(title = "Example"), "Example", bigIcon)) + // Even the push's own icon goes when the new title does not fit beside it. + assertEquals(long.copy(title = "Example"), opened(PushDocument.retitle(keys, "r4", long.copy(icon = bigIcon), "Example", null), "r4")) + + val absurd = PushContent.Url("https://example.com/" + "a".repeat(13_000), "", "") + assertThrows(PushTooLargeException::class.java) { PushDocument.retitle(keys, "r5", absurd, "Example", bigIcon) } + } + // ------------------------------------------------------------------------------------- // Reading diff --git a/apps/android/store/whatsnew/whatsnew-en-US b/apps/android/store/whatsnew/whatsnew-en-US index 145c21d..e250b9d 100644 --- a/apps/android/store/whatsnew/whatsnew-en-US +++ b/apps/android/store/whatsnew/whatsnew-en-US @@ -1,3 +1,3 @@ -Your pushes are now end-to-end encrypted: only devices you sign in on can read them. Signing in sets up your keys from your password and shows a recovery key. Save it, because it is the only way to read your pushes again if you forget your password. Change password and Show a new recovery key are in the menu. +Your pushes are now end-to-end encrypted: only devices you sign in on can read them. Signing in shows a recovery key. Save it, because it is the only way back to your pushes if you forget your password. Change password and Show a new recovery key are in the menu. -Notifications and search work on encrypted pushes. A share that cannot be confirmed is kept and retried instead of dropped, and a long press copies a file push's name. +A link's icon now comes from the page and is kept with the push, so drawing your list asks Google for nothing. Notifications and search work on encrypted pushes, and a share that cannot be confirmed is retried, not dropped. From 2f33538a11daefabba97870308615cfa09621560 Mon Sep 17 00:00:00 2001 From: C Deutsch <git@cdeutsch.com> Date: Thu, 12 Nov 2026 15:10:25 -0600 Subject: [PATCH 05/17] Carry a link's icon in the iOS and macOS pushes instead of asking Google for it `PushRowView` loaded every link's icon from Google's favicon service, which told Google the host of every pushed link each time the list was drawn, and loaded file-type icons from pushthat.link, which put encrypted files' types in its logs. `TitleScraperService` now also takes the page's icon in the pass that reads its title, shrinks it with ImageIO to the spec's bounds and seals it with the title (docs/e2ee.md, Icons). A push that has an icon keeps it, and when the icon would push the envelope past `enc.ct`'s bound the title is written alone. The row draws only what the push carries: its icon, the file-type icon from the asset catalog, or the placeholder, with no `AsyncImage` left. The candidate scanner and the image work live in `PushContent.swift`'s `PushIcon`, which the test target already compiles. The same change is made in both apps, since `apps/macos` is a fork of `apps/ios`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/ios/PushThat/Models/PushItem.swift | 12 +- .../file-aac.imageset/Contents.json | 12 + .../Assets.xcassets/file-aac.imageset/aac.png | Bin 0 -> 506 bytes .../file-ai.imageset/Contents.json | 12 + .../Assets.xcassets/file-ai.imageset/ai.png | Bin 0 -> 437 bytes .../file-apk.imageset/Contents.json | 12 + .../Assets.xcassets/file-apk.imageset/apk.png | Bin 0 -> 248 bytes .../file-avi.imageset/Contents.json | 12 + .../Assets.xcassets/file-avi.imageset/avi.png | Bin 0 -> 457 bytes .../file-css.imageset/Contents.json | 12 + .../Assets.xcassets/file-css.imageset/css.png | Bin 0 -> 290 bytes .../file-csv.imageset/Contents.json | 12 + .../Assets.xcassets/file-csv.imageset/csv.png | Bin 0 -> 3362 bytes .../file-doc.imageset/Contents.json | 12 + .../Assets.xcassets/file-doc.imageset/doc.png | Bin 0 -> 515 bytes .../file-flac.imageset/Contents.json | 12 + .../file-flac.imageset/flac.png | Bin 0 -> 472 bytes .../file-gif.imageset/Contents.json | 12 + .../Assets.xcassets/file-gif.imageset/gif.png | Bin 0 -> 451 bytes .../file-html.imageset/Contents.json | 12 + .../file-html.imageset/html.png | Bin 0 -> 420 bytes .../file-indd.imageset/Contents.json | 12 + .../file-indd.imageset/indd.png | Bin 0 -> 232 bytes .../file-jpeg.imageset/Contents.json | 12 + .../file-jpeg.imageset/jpeg.png | Bin 0 -> 494 bytes .../file-jpg.imageset/Contents.json | 12 + .../Assets.xcassets/file-jpg.imageset/jpg.png | Bin 0 -> 494 bytes .../file-js.imageset/Contents.json | 12 + .../Assets.xcassets/file-js.imageset/js.png | Bin 0 -> 444 bytes .../file-mov.imageset/Contents.json | 12 + .../Assets.xcassets/file-mov.imageset/mov.png | Bin 0 -> 475 bytes .../file-mp3.imageset/Contents.json | 12 + .../Assets.xcassets/file-mp3.imageset/mp3.png | Bin 0 -> 565 bytes .../file-mp4.imageset/Contents.json | 12 + .../Assets.xcassets/file-mp4.imageset/mp4.png | Bin 0 -> 500 bytes .../file-pdf.imageset/Contents.json | 12 + .../Assets.xcassets/file-pdf.imageset/pdf.png | Bin 0 -> 514 bytes .../file-png.imageset/Contents.json | 12 + .../Assets.xcassets/file-png.imageset/png.png | Bin 0 -> 491 bytes .../file-pproj.imageset/Contents.json | 12 + .../file-pproj.imageset/pproj.png | Bin 0 -> 266 bytes .../file-psd.imageset/Contents.json | 12 + .../Assets.xcassets/file-psd.imageset/psd.png | Bin 0 -> 537 bytes .../file-tiff.imageset/Contents.json | 12 + .../file-tiff.imageset/tiff.png | Bin 0 -> 460 bytes .../file-txt.imageset/Contents.json | 12 + .../Assets.xcassets/file-txt.imageset/txt.png | Bin 0 -> 430 bytes .../file-xls.imageset/Contents.json | 12 + .../Assets.xcassets/file-xls.imageset/xls.png | Bin 0 -> 488 bytes .../file-xml.imageset/Contents.json | 12 + .../Assets.xcassets/file-xml.imageset/xml.png | Bin 0 -> 292 bytes .../file-zip.imageset/Contents.json | 12 + .../Assets.xcassets/file-zip.imageset/zip.png | Bin 0 -> 300 bytes apps/ios/PushThat/Services/PushContent.swift | 288 +++++++++++++++++- .../ios/PushThat/Services/PushDocuments.swift | 13 +- .../Services/TitleScraperService.swift | 98 ++++-- .../PushThat/Utilities/FileTypeIcons.swift | 20 +- .../ViewModels/PushListViewModel.swift | 8 +- apps/ios/PushThat/Views/PushRowView.swift | 27 +- apps/ios/PushThat/Views/SwivelCheckView.swift | 59 ++-- .../NotificationService.swift | 2 +- apps/ios/PushThatTests/E2eeVectorTests.swift | 176 ++++++++++- apps/macos/PushThat/Models/PushItem.swift | 12 +- .../file-aac.imageset/Contents.json | 12 + .../Assets.xcassets/file-aac.imageset/aac.png | Bin 0 -> 506 bytes .../file-ai.imageset/Contents.json | 12 + .../Assets.xcassets/file-ai.imageset/ai.png | Bin 0 -> 437 bytes .../file-apk.imageset/Contents.json | 12 + .../Assets.xcassets/file-apk.imageset/apk.png | Bin 0 -> 248 bytes .../file-avi.imageset/Contents.json | 12 + .../Assets.xcassets/file-avi.imageset/avi.png | Bin 0 -> 457 bytes .../file-css.imageset/Contents.json | 12 + .../Assets.xcassets/file-css.imageset/css.png | Bin 0 -> 290 bytes .../file-csv.imageset/Contents.json | 12 + .../Assets.xcassets/file-csv.imageset/csv.png | Bin 0 -> 3362 bytes .../file-doc.imageset/Contents.json | 12 + .../Assets.xcassets/file-doc.imageset/doc.png | Bin 0 -> 515 bytes .../file-flac.imageset/Contents.json | 12 + .../file-flac.imageset/flac.png | Bin 0 -> 472 bytes .../file-gif.imageset/Contents.json | 12 + .../Assets.xcassets/file-gif.imageset/gif.png | Bin 0 -> 451 bytes .../file-html.imageset/Contents.json | 12 + .../file-html.imageset/html.png | Bin 0 -> 420 bytes .../file-indd.imageset/Contents.json | 12 + .../file-indd.imageset/indd.png | Bin 0 -> 232 bytes .../file-jpeg.imageset/Contents.json | 12 + .../file-jpeg.imageset/jpeg.png | Bin 0 -> 494 bytes .../file-jpg.imageset/Contents.json | 12 + .../Assets.xcassets/file-jpg.imageset/jpg.png | Bin 0 -> 494 bytes .../file-js.imageset/Contents.json | 12 + .../Assets.xcassets/file-js.imageset/js.png | Bin 0 -> 444 bytes .../file-mov.imageset/Contents.json | 12 + .../Assets.xcassets/file-mov.imageset/mov.png | Bin 0 -> 475 bytes .../file-mp3.imageset/Contents.json | 12 + .../Assets.xcassets/file-mp3.imageset/mp3.png | Bin 0 -> 565 bytes .../file-mp4.imageset/Contents.json | 12 + .../Assets.xcassets/file-mp4.imageset/mp4.png | Bin 0 -> 500 bytes .../file-pdf.imageset/Contents.json | 12 + .../Assets.xcassets/file-pdf.imageset/pdf.png | Bin 0 -> 514 bytes .../file-png.imageset/Contents.json | 12 + .../Assets.xcassets/file-png.imageset/png.png | Bin 0 -> 491 bytes .../file-pproj.imageset/Contents.json | 12 + .../file-pproj.imageset/pproj.png | Bin 0 -> 266 bytes .../file-psd.imageset/Contents.json | 12 + .../Assets.xcassets/file-psd.imageset/psd.png | Bin 0 -> 537 bytes .../file-tiff.imageset/Contents.json | 12 + .../file-tiff.imageset/tiff.png | Bin 0 -> 460 bytes .../file-txt.imageset/Contents.json | 12 + .../Assets.xcassets/file-txt.imageset/txt.png | Bin 0 -> 430 bytes .../file-xls.imageset/Contents.json | 12 + .../Assets.xcassets/file-xls.imageset/xls.png | Bin 0 -> 488 bytes .../file-xml.imageset/Contents.json | 12 + .../Assets.xcassets/file-xml.imageset/xml.png | Bin 0 -> 292 bytes .../file-zip.imageset/Contents.json | 12 + .../Assets.xcassets/file-zip.imageset/zip.png | Bin 0 -> 300 bytes .../macos/PushThat/Services/PushContent.swift | 288 +++++++++++++++++- .../PushThat/Services/PushDocuments.swift | 13 +- .../Services/TitleScraperService.swift | 98 ++++-- .../PushThat/Utilities/FileTypeIcons.swift | 20 +- .../ViewModels/PushListViewModel.swift | 8 +- apps/macos/PushThat/Views/PushRowView.swift | 27 +- .../PushThat/Views/SwivelCheckView.swift | 59 ++-- .../macos/PushThatTests/E2eeVectorTests.swift | 176 ++++++++++- 123 files changed, 1829 insertions(+), 199 deletions(-) create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-aac.imageset/Contents.json create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-aac.imageset/aac.png create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-ai.imageset/Contents.json create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-ai.imageset/ai.png create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-apk.imageset/Contents.json create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-apk.imageset/apk.png create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-avi.imageset/Contents.json create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-avi.imageset/avi.png create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-css.imageset/Contents.json create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-css.imageset/css.png create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-csv.imageset/Contents.json create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-csv.imageset/csv.png create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-doc.imageset/Contents.json create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-doc.imageset/doc.png create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-flac.imageset/Contents.json create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-flac.imageset/flac.png create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-gif.imageset/Contents.json create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-gif.imageset/gif.png create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-html.imageset/Contents.json create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-html.imageset/html.png create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-indd.imageset/Contents.json create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-indd.imageset/indd.png create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-jpeg.imageset/Contents.json create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-jpeg.imageset/jpeg.png create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-jpg.imageset/Contents.json create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-jpg.imageset/jpg.png create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-js.imageset/Contents.json create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-js.imageset/js.png create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-mov.imageset/Contents.json create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-mov.imageset/mov.png create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-mp3.imageset/Contents.json create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-mp3.imageset/mp3.png create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-mp4.imageset/Contents.json create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-mp4.imageset/mp4.png create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-pdf.imageset/Contents.json create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-pdf.imageset/pdf.png create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-png.imageset/Contents.json create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-png.imageset/png.png create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-pproj.imageset/Contents.json create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-pproj.imageset/pproj.png create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-psd.imageset/Contents.json create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-psd.imageset/psd.png create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-tiff.imageset/Contents.json create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-tiff.imageset/tiff.png create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-txt.imageset/Contents.json create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-txt.imageset/txt.png create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-xls.imageset/Contents.json create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-xls.imageset/xls.png create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-xml.imageset/Contents.json create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-xml.imageset/xml.png create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-zip.imageset/Contents.json create mode 100644 apps/ios/PushThat/Resources/Assets.xcassets/file-zip.imageset/zip.png create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-aac.imageset/Contents.json create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-aac.imageset/aac.png create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-ai.imageset/Contents.json create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-ai.imageset/ai.png create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-apk.imageset/Contents.json create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-apk.imageset/apk.png create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-avi.imageset/Contents.json create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-avi.imageset/avi.png create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-css.imageset/Contents.json create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-css.imageset/css.png create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-csv.imageset/Contents.json create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-csv.imageset/csv.png create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-doc.imageset/Contents.json create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-doc.imageset/doc.png create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-flac.imageset/Contents.json create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-flac.imageset/flac.png create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-gif.imageset/Contents.json create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-gif.imageset/gif.png create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-html.imageset/Contents.json create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-html.imageset/html.png create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-indd.imageset/Contents.json create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-indd.imageset/indd.png create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-jpeg.imageset/Contents.json create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-jpeg.imageset/jpeg.png create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-jpg.imageset/Contents.json create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-jpg.imageset/jpg.png create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-js.imageset/Contents.json create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-js.imageset/js.png create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-mov.imageset/Contents.json create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-mov.imageset/mov.png create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-mp3.imageset/Contents.json create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-mp3.imageset/mp3.png create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-mp4.imageset/Contents.json create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-mp4.imageset/mp4.png create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-pdf.imageset/Contents.json create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-pdf.imageset/pdf.png create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-png.imageset/Contents.json create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-png.imageset/png.png create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-pproj.imageset/Contents.json create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-pproj.imageset/pproj.png create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-psd.imageset/Contents.json create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-psd.imageset/psd.png create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-tiff.imageset/Contents.json create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-tiff.imageset/tiff.png create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-txt.imageset/Contents.json create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-txt.imageset/txt.png create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-xls.imageset/Contents.json create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-xls.imageset/xls.png create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-xml.imageset/Contents.json create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-xml.imageset/xml.png create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-zip.imageset/Contents.json create mode 100644 apps/macos/PushThat/Resources/Assets.xcassets/file-zip.imageset/zip.png diff --git a/apps/ios/PushThat/Models/PushItem.swift b/apps/ios/PushThat/Models/PushItem.swift index eb11503..4345991 100644 --- a/apps/ios/PushThat/Models/PushItem.swift +++ b/apps/ios/PushThat/Models/PushItem.swift @@ -20,6 +20,9 @@ struct PushItem: Identifiable, Equatable { let source: Source /// The push's `keyVersion`, which a retitle has to match. let keyVersion: Int? + /// An encrypted URL push's icon (`PushIcon`), nil when it has none. Kept as the + /// envelope holds it, so that a retitle writes it back unchanged. + let icon: String? enum PushType: String { case url @@ -77,7 +80,8 @@ struct PushItem: Identifiable, Equatable { type: PushType = .url, ext: String? = nil, source: Source = .plaintext, - keyVersion: Int? = nil + keyVersion: Int? = nil, + icon: String? = nil ) { self.id = id self.url = url @@ -88,6 +92,7 @@ struct PushItem: Identifiable, Equatable { self.ext = ext self.source = source self.keyVersion = keyVersion + self.icon = icon } /// A push document, opened with `keys` when it is encrypted. Nil for a document that is @@ -117,10 +122,11 @@ struct PushItem: Identifiable, Equatable { } switch content { - case let .url(url, title, ext): + case let .url(url, title, ext, icon): self.init( id: id, url: url, userId: userId, createdAt: createdAt, title: title, type: .url, - ext: ext.isEmpty ? nil : ext, source: .encrypted(file: nil, objectPath: nil), keyVersion: keyVersion + ext: ext.isEmpty ? nil : ext, source: .encrypted(file: nil, objectPath: nil), keyVersion: keyVersion, + icon: icon ) case let .file(file): self.init( diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-aac.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-aac.imageset/Contents.json new file mode 100644 index 0000000..b09a8c7 --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-aac.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "aac.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-aac.imageset/aac.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-aac.imageset/aac.png new file mode 100644 index 0000000000000000000000000000000000000000..229acde2be58dfaa68d92f1827325f92fb6571ab GIT binary patch literal 506 zcmV<W0R{evP)<h;3K|Lk000e1NJLTq001!n002M;0{{R3ggkFb0000>P)t-sOlfgX z_We)x{Rhs~2F}$6&eaCa)p!5@Px$`_&eTx&|NsC06w%mM-s9Z)`X|-f#`N|&+TVuf z>+1XbY~tpx@A8`K?tJCxKJN8NEv(7_000VfQchC<nx6l;MtuRD@%WSX!~g&Q32;bR za{vGf6951U69E94oEQKA0Y6DZK~#9!?bge3gD?yQP+CD!d}UjHLH_?sJ0{a6!4R8H zmuSIgj1NMFg}SaA`jK`+sjq9diVM99Rg!^fVomzGSdp<oyb@{=FQiR8lSc7GTE&Aj zi(k?%e#il#KCefG`nVnyOO=YasiI0nIJG7<Q4~>C!MCHLh*O;66s^KK%S*!6*~i+h z&;|e!6Hhq_F|P#x#%&8WFiC)kG$6?UE@v=+?9|}=&?(<`j)(&y2@K!YclvO87KDs- z^Z}lMm)%7l`JI74oR$RM0{V@xfJEmJ$ODshrYa)9`5H)&h{(Vb;NJ)hXd43pC5Uw? z?@qw1qtV-fm(O7kmNU(nCu{C0Z+C7dN!t3ENGF-B_W6uw5t(_r#-IMHQ=H<T#CQ;l wwF$Y1zBYvyG1MmgbzGKNzi;hj?1!%V20!F8tDQ=d?EnA(07*qoM6N<$g327~$N&HU literal 0 HcmV?d00001 diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-ai.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-ai.imageset/Contents.json new file mode 100644 index 0000000..b365aae --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-ai.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "ai.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-ai.imageset/ai.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-ai.imageset/ai.png new file mode 100644 index 0000000000000000000000000000000000000000..bc85e0f72727db2ba29df4b23f5ea87453f7ed6a GIT binary patch literal 437 zcmeAS@N?(olHy`uVBq!ia0vp^hCuAV!3-o@Z1TN;RA7Klh^tR#;p<KhAhOot2O=v? zAQJdrU;nPl6UesK5_s3`_5c6>7+(tqeNk^~<s<udES)#;{oB|5t!4R1{&#O(OANNV z)974ZlDsR=a!Qo0c2@}#&~UDjAirRSS@V8;bgN~WyK_mM*#@8}XMsm#F#`j)FbFd; z%$g$s6#U}p;uunK>+QAEdCZDDt%*T~p2y}Kyz^|*zW>c(QY&+rJU5C@-87^Af1+Z< z4GH6(X>rf)d`(()?o0SORpzfQzQ^{h=n(C2cM|&Okn(6A$5GjdUGYj{_AYw+I-Pd( zcZcnm?679t%u9mi*L_}g_+OvU_aWQ2s_9Q|XxB1lnMZ$F+YeX;HY^ghbJG{u<MZd! z9Q8$K7zAc4o1)hF!8nM+VF9NA=ck*(sVX}aqPEE}=!>Z)`s-LEo>SF!VH8{Bt0<=M zd78+f_va0>^UfKzoG}zSzT!{kdFvyJ|6C(fH|*uznfWI7p|5lP<@MI@cmLDa+bzqU bypQ3EVZ}fG`t|)l|1)^H`njxgN@xNA;={2Y literal 0 HcmV?d00001 diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-apk.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-apk.imageset/Contents.json new file mode 100644 index 0000000..71e9399 --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-apk.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "apk.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-apk.imageset/apk.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-apk.imageset/apk.png new file mode 100644 index 0000000000000000000000000000000000000000..e8092db4872e3a8639969a045c3a614af8328446 GIT binary patch literal 248 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE3?yBabR7dy5&=FTu0ENCKh`_GoNx7Zf#a=K z$NxJV-!F80Jke2Yo!Vod8kUkEzhH*n2HKTBOeKJP&H|6fVg?3oVGw3ym^B9^Smx>C z7*cWT?&X7g4GKKW0jpzw|F6$E(tUuX?^vA0heJ1*Zf_HD5tcFS(7Pdhz$(LqJ?bK} zvH?rVk@XBpoN@}6LKLj`F{|ZHS7jF{JsQN7a);rBH^YV}+)Gt;rq!NuscX*^cQ%l` oQgS7}g8%*89=rUPDQ_RomtV>wG3#WRBG3g4p00i_>zopr09smA(*OVf literal 0 HcmV?d00001 diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-avi.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-avi.imageset/Contents.json new file mode 100644 index 0000000..c3cd644 --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-avi.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "avi.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-avi.imageset/avi.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-avi.imageset/avi.png new file mode 100644 index 0000000000000000000000000000000000000000..08b8557281eb15824f499980ea6b05b5c7f0debf GIT binary patch literal 457 zcmV;)0XF`LP)<h;3K|Lk000e1NJLTq001!n002M;0{{R3ggkFb0000&P)t-sOlfid z|NohpnS+Caf`WpAf`WpAg1fuBe}8|2gM+-hy#N3I_4W0*xVWjQsm8{}iHV7mlarmD zo#^Q3+}zyL)6=gpK;{4d019+cPE!B@t^e^xeVqciBF*Vr00009a7bBm000XU000XU z0RWnu7ytkP7fD1xRCwC$*1K}VAPfaiXJqfjy5bH0|En3idF;Z(f+9s`d^?JRkZ80l z%eJrdYnbovOOqR&g?qA*CR(!Vq9N;mcqKF<UdWhuCWGRMjEWyJEWXLO_#zYHlgtRd zzv;qoS49z)yDEx^+!9TA(-KXPw`Njwxx^*@iHO-v3I|MzP|UgTG5VQ4LI!|j$%xdW zNHmhpgoETF5bps?#7JZyr?0YsC<2i(3Q+>9f`T=!-ftm306`OvKC18nTOXx~3}G9O z9tui=Q3b$6kFIU4<y9dlA?8Dn0GU+b>ct|5a1W@>%H-rcyHKa#T>bs%_<Xv0E^&!J z5$hyawGFw;u5IC6Hf_^yb31nZeYBT#-<IVA_na{v1((3g00000NkvXXu0mjfw_ecJ literal 0 HcmV?d00001 diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-css.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-css.imageset/Contents.json new file mode 100644 index 0000000..d63495d --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-css.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "css.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-css.imageset/css.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-css.imageset/css.png new file mode 100644 index 0000000000000000000000000000000000000000..56bca644d806acf9780163fade1e438e47fad684 GIT binary patch literal 290 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE3?yBabR7dyDgizru0ENCg0=re>wbyU{uTj} zb-#fO(b`|ab^k@`!R)%<&F2*pf$F(Sg8YIRWFH^d=25v-+{ZhmWVbF*n6tnmvKS~U z48n{Iv*t(u1$#YR978H@?Vaq+ci4c#g|Yro+PnJur+i}_eVZ=1%srY@(Im*RsD1^9 z-J(|vtN0Ejvn4PTuyC+51aUJwVQvWizKj2f<s#N&OYS$^I&po)dZW@wiyG9<=>#$` zato(F-oT))V)l=9Le#ZqdKYHv=a)Cu>C4p_IoGLP?=!Amp?V{wqMQ49ncMsNX<cQr Z=e>{=xvJUrfD!0022WQ%mvv4FO#lbWY0Urt literal 0 HcmV?d00001 diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-csv.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-csv.imageset/Contents.json new file mode 100644 index 0000000..df9c8ae --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-csv.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "csv.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-csv.imageset/csv.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-csv.imageset/csv.png new file mode 100644 index 0000000000000000000000000000000000000000..551320d993d3daa5e9308b74de1cd2f2a080ac51 GIT binary patch literal 3362 zcmeHKc~nzZ8o%#_5E9lFg$PyLC{~0}1yMnWf(2>{Q89$gh>oCwvIU5U<)M|dP{EN3 zvPfth5G=9_B3m?sDEm?&$QFsLvI!9aBxZunAD!tL&-^j{W6qg-&i%f7zwi6~-tXOW z?|JY3Kz6iK#p&Px0ICl5wub?L<wF=Kq2!K}wfU*s8IeiO2S{fE-*vZ0JKD4D&8m-+ zny&`6Mbm4%9ArJe$z-yp!N+Dek48%G@mbf!Qxlz0V+nE~hJM)b2r$)=m@cuRrfdS` zA#8xXYcK%HnkyXwDQS2BRK9nx-RI1BG1>PzJk>;t*haeh8(*<TCTvbR7+e@Hu-}4e z>R<Nv?B3V#J)fAK-uS+Si+pR%q}><q=zXyfNr0{P!(_G{3E&v&I3g5zM?Q0>%4gS< zi4Y+Q+9JTJ5g|IjWuVAlEdq~%8F&Va!U{lpJ$MKpHd+Z76F~q3Bgz`U0|A2fDI`9+ z%D@IJ!$jQgq8|g?Kxh}8u7OZA@sst)3GMIxE%ge?W(7(A#YBYyLqyN$j}Kp|a8?qx z^cU+Ro{+@;_u9xk>YD!|{Pg%pFNN}lSNMq7|2Q?;TQiTrpg+D=|I8*n1~FyD#jv<v zO~zz?1d4#Xq`iB_eJ&G$MM!JSj?IY71x5@2<NxM=W(~@AAOo3sl;_qs!NRjg^iO~V z#MT6sf4*T4sD7!!k@N<PnRHsVbXe|<@AP`;{|&g?ajH75Z0_!Pp{aSCSz{N$rs~Jg z+g+<YaBmqUAko|J5v!oAt~R$XKE&w95UYVKuSJ>Gy8(QQ+sn2Dur)Py$9(UD81^i+ zMKLkDLhGH2#Q7?w0+a3QmRzI*`*z5d)Qr?!QaS>7hgySt`HIJZjdy)>`IRWI#ud7H z&U4Gim)$xPV1m5f-F1tPZG2Xk7;RXY?u$;m$x(qK7Rx)1gu3_TxgLz=tkyVC@pglZ z4Al;W%ASQ$o=db{ZnppRQUUMQpj~M$AcK&(nj-b=+p-(Tmr}u#P3{!%Ia0r9&GJ{~ zL2u`1x9&aB(WuN9CJmIp>miS`BQ>H6jX6_df3&lgD!y3V&kec`W_gjX-51(TC|Kv0 zA*Y(m?gLxUQJ(o^_?#<fzRE!+YU^6MOJM-1jAYfNU*UEf<)>Owe{d<7L7^ba&LWUz zXq~W^mESUeZt0+!c}PZ=ldE1~R<TchgE^NOMjcZU2o1j*^r`I<`Bn_Ls^r*VGg=4z zL97h_S-H09rS3D@YP+{~A{7R47(Wjv0MuI`WyL#VMliO#b6t%nL#xs?Jv`r)%-6*h z-}<o+dU@$3-08E<3&_Ar*J;$0Pk)QP#`Nu7X9_a!%;zVBHk}vtHr9>);-ocbcAO+p zx^D<niL;~_M*X_^qNHBVpn<BjTMSFwU^93i_w8q2;gR-3%hEJ`^Po?OXL;44R>G#b zG`A5w#f$(Y@xw>V>00I&u?uy<Q?53Evt+|b>a2v>U=;=7Nk@KqQJ<_|QS_WsFW#N% z0`Q1vA)E8UavP~Gx_ishWeF_ob6D2=1+4mfA|>M0rk@y<bp&r(Oc&y$haQ^$23Bf_ z2w0$$<TDh=OKD+#QfgK~LW)az5XTCG&X~#ZqlbQ_MqF$V6oxo<_P-os5kWlHpFiXK z(}nPfKu7HwSDmgrMP=PY+KDNFEeL$`r694XEHtQl*HM}mUTx9J-u`^UAp;!I(m^*a z<FJo*-*#<GzRF@X0jqu6$R#5I(Dx<>1wIA)!gu4fXeUb|`J%^Hz|Kjcp`!^&mzpA+ zvEz$S*MV~cAvJ$krPzHu=Sh{Mr{*dXq)zSIh+G5X#@nB2DA9(=b%kfp8f*%9kd&ND zJFAli`4fV+$r6AYR1$f1Axx8krCN{v*gx!FbI(?Iu0nY=mdG`D9CGy7*`iQhl+yTp zF`<V7Rw2`_u;XfTrA3QWL--dn!QmUFLuFKm4qvZ4K&G%Ch_-I>(Ve#0f>;5^yXD@5 z0?F9P5=vEN8|sv<%L-h)el(b~<jK3?q$6P#u4f2w>ygRSf@kd6n;vaCWd07nDorCS z+dN{;s&j3b2lppbAg8mKtp{G~N0ETEM$OnPcOx~uoJuko9`4h|5xLk6o*LVXF%Qhs zQ}%1QTXRo9*}L>}os!U~2fl3lTGV-7EKGiLNI<`oJLZ_nb;4?^?=^1ozz`yrp=~(! zbyiHT!_PSeMiUB&cFL4rNAC?a83JV@S9N}S{K0q6qT9Q!#^#$c$}lYRuw4;ekUdQ& z@sY{q6u#s(EfG>62;}ElzOlGJ2M>FV&CLyZvS$l@#;*)b8UhvK{Ok;4ZlI>3gNJ?d z7h}}Z-o|@8Lcun0_f;P)NSn^QGUPAq@D6eK8=#~fNu9k>VPh}WJyx=f$+=&3Nz<3e zT#YDZL?mQ8S-jeCfnOc2KRqbyw2C@LLp_T4&H+^rp8D|Mn~PH38uv_*+mvF4`i^2; zMnf6Z6d0Pvcr11CXj;l)=hkhHce7UY3Q-*`l>a`N4s6}I>$G7XeQM5($PMOFXk+6! zS$l_Bn2bg5wj)i{vcQz#3vS}Wj7zerQbp13_$W-qTeaz=xRXQGmpyiuXSd||S%Pbk zCMTwLQD!GAwlk6z4dBym3UzTO_48C1XD-Ov$4EOjBGJ}5N7D+T3!BGKVs4?u?M9M9 zYXMz-Ew4T@w(OyzC^_<TWZX5?BJSuLqp9}Rs^(MPaCX{*av}BTz#t(PsxtP8g0mNe zKmY0?a(TC*JtoJUiVDB|>}&M(ntRr7Mufgo8sreow%O*)9<B-%bSsAkFTWOXAKx?x zT+N&e-M0zPe)2`nbmsI_BL4M-CEu`K?x70L2BKm?#LKUmL<V(xSpLsDSW;a(nBN*P zX-?z3rKC$s3I58cP<KdSCg_s^Y3;R;urR4*rvwpnTD=&fuX(a0oq-tx?S#mQu=EkH zxuvD;ouWPb`L=5FKkd_5$-)Run5oLPAa|{0M!!i=uAUYs#*z_osYjRS!0zuscT52s zJl}78YaXMar!-OJX|fg~ZazLed3j6&Tt4-FfCuZ)?=uSQD@awDa@HNd2>+CAycWam zvU$7`KtbXnmG>oBwhwgCUz@~)XFl*3f(Rx4#fsy5@W{f4Vh+U6ELaGAOt%F24evET zQ00f3jmSUH3Ns>+&x(i)@a?2r6c(fd!GFQ@?}Kh7%Uk!MWj*0dV-MBAZod4Y0vrxF K+UD&)e);d1p;4Fs literal 0 HcmV?d00001 diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-doc.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-doc.imageset/Contents.json new file mode 100644 index 0000000..0822a82 --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-doc.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "doc.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-doc.imageset/doc.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-doc.imageset/doc.png new file mode 100644 index 0000000000000000000000000000000000000000..ae4112e5dcfb32091e9a36ce8da14916251aadb8 GIT binary patch literal 515 zcmV+e0{s1nP)<h;3K|Lk000e1NJLTq001!n002M;0{{R3ggkFb0000~P)t-sOlffd z|NjPl%LabR1%Jy1f6IHx_kaKYd&u|(e#!=Z%LaeTe98I$|NlFi*C>(GqR;He<MiwG z`-Q;fRH)q+h|UXw&G-BMbGPEz>-V_Z@oc~I0`^pF0000BbW%=J00Db{x$&I>oJRO? ztSc!1000SaNLh0L01FcU01FcV0GgZ_0003#Nkl<Zc-rmO%WlIU5Cu@TwVPOQd4qBC z|NmJHvD?HZ0Tz+E@Li3Nj|Rq@>AG(02lh0apHJN;U+h)*o{X#{wxlnM4H+uLBVje- zfz*k6QYr38t@tC=;+NEml{5(ceQFi_{81KHzU$H^s7pZzz9l6QwoQ0RFz>F7VwXc4 z;(rQhjA#_Vdxex?6<!FxoO&TcUPuj0DhYo9)qXjJi8&Ewz)T<!G++#%81n|04UEhP z2Ur#?2?s>REFf7-J)_L^urOhH`K|QiF1S2>xq@D2r7+XU1j{sCtwF?>!ea7^h^Z8S zTX9wh;Ns#;LIT>k8OGBPlylmKj9~N+XucwiiQ>{_DU-;FV70d1TAHL&TC3YvsmxBd zHitOGTg1=^hSG+-OJCZ;cNt5Y{x+X!*Y9iJGW28D{Qz^}hiI)saI*ja002ovPDHLk FV1k$+>sSB) literal 0 HcmV?d00001 diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-flac.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-flac.imageset/Contents.json new file mode 100644 index 0000000..407a489 --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-flac.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "flac.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-flac.imageset/flac.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-flac.imageset/flac.png new file mode 100644 index 0000000000000000000000000000000000000000..1fa3e81484afe78769619c1d073974e7cc898a0b GIT binary patch literal 472 zcmV;}0Vn>6P)<h;3K|Lk000e1NJLTq001!n002M;0{{R3ggkFb0000*P)t-sOlfg) zLD-vh>2pHZb3xd0LD-{z?0<j%qJZvlLD-~$?*IS)hh5%~Y2o$p{HcoQ<=ptXqU_DQ z@qbd>+s^f#dg_~a<H?pEy#N3J3UpFVQviLS|G7q*0-f=HZJlud000SaNLh0L01FcU z01FcV0GgZ_0003aNkl<Zc-rmO&2ob<3<O{@g0^`5C)@D;kJ`a4X<|YpAKDXpWadL& zoiq%?G|qGyV?N%8CO7&m=13-zL`z0lG-OtYXF?kBM0DbjD8&QOiXWmDccK?N=@8$f zN0>K-Sj=IHAXamjA_!{^L=f$>j1dL7rB}4M#3lYgWS6^yEdYY|RxJ_`y#w^ZgNeEX z5bnad-DPJ_g!O>;YZbu*K-#-`mW1_)sQ^s5e#FBvPF|HmT|^-U98t(52_hG`AW}im zHUH{?NU@fYg#$`(sY5V8M2twA(ue@8mOUaFAgxPbN#t51ZvRiU(6+Ewg4q#e=1ew~ zvO3;&98xUFp3G3?5|{WBF?WJlx{wbUr7Qf9NxJmg-0H1AYP-ziGz_0glr!0}JTPSd O0000<MNUMnLSTYREYDK_ literal 0 HcmV?d00001 diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-gif.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-gif.imageset/Contents.json new file mode 100644 index 0000000..7710491 --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-gif.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "gif.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-gif.imageset/gif.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-gif.imageset/gif.png new file mode 100644 index 0000000000000000000000000000000000000000..bdbe2cddad286a5e720349c1000167deebb282bb GIT binary patch literal 451 zcmeAS@N?(olHy`uVBq!ia0vp^hCuAV!3-o@Z1TN;lv99Dh^tR#;j2Ro|Nk?*KEwcI z0LdS>gZ}@o2a;fR$p8QU|NVOL`I^tK_q%^QpZ)*ej}PYzzuqf+dtCI}<Brd_l3wwY z9{`%hRTAVE%uvht|D)S%rnx(<tYVe|ML7#RB8wRqxP?KOkzv*x2?hp6K~ERQkcwMx zubs_nG2mf&ki&a>QsKYDzxQXc-py^y*tl5k7IX1`ld_fxy@roXE}V|2t~YPL^Fn8v zs<P*yx;H0Px|t<KlKefC%UsmAxoDcXXxDW_)d=scn8f!{!S>L-`za4|E?-igCg!M8 ztuiUYYmJ7+&bY!es(K&Il|<Ki>=O#?bqMX^60mHznQ845Cvm!Z%G(EwaY9F#H?ozP z7*Fo+kU7`NZoyPAWuXu!hs)<+jb*1gPK(Jt`e+s_yElpL(yrd9DI9!uWgN4nFaK)W z6m)K#j?z`1C$DtMmaOkLxVU;tb;8{4D@q0eJH@s}h|Yah|3^~qBe!8mljE)lvwbd! q-b<OH_cuB3^;-MPa>->EXZVXo8gZ}x>vbI%Vho<HelF{r5}E+6L&N3( literal 0 HcmV?d00001 diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-html.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-html.imageset/Contents.json new file mode 100644 index 0000000..89e5132 --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-html.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "html.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-html.imageset/html.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-html.imageset/html.png new file mode 100644 index 0000000000000000000000000000000000000000..aa4b93f98729508bceb7b925ace136cdff180c8a GIT binary patch literal 420 zcmeAS@N?(olHy`uVBq!ia0vp^hCuAV!3-o@Z1TN;lyQJhh^tR#;s5{tcb__c|J6Gn z2}bAczI+E3JO?Dd|M+?I(zRQUo?f_p@BXuwPhY={Fq!-wsGX}M$S;_I@&EsiJ8S1M zxm{Xe`Vc6}S>O>_%)r1c48n{Iv*t)JFfcNDx;TbZ+<JTMblwpK9@c=Y6F!gGpZ(vz ztto95kH*G{yJon)V`4q>P;pZMM`End9JOQ5%U^tISbb#TnuN}II@?}&6m^s{$$fOI zdE_zYk;9%O^}LVne;&E29dS!@sj0i^^ms$Jq6*(`38mxnA{N<)^(<>xvw6{Z-Mo*9 zO&ak&%99)w_PJ=<aP1M;6WyL*$F=URS(zG_(zKoq4=0tbjzcXTHA(_2&)qz7ZRxFP z#uHz6KKULJa{JCTeGhT-<*^yNg_u`_pK#}N&^Z&l$d22xC95|2BF_SsrL_gpXS5Vf zHP85eJHN2|zeuK#%4#RMO%b6#CR|<h<+n}L+Wi&pCrvYLkza5|WzLTkGAn^0#Ng@b K=d#Wzp$P!H)xuH$ literal 0 HcmV?d00001 diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-indd.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-indd.imageset/Contents.json new file mode 100644 index 0000000..730ce27 --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-indd.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "indd.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-indd.imageset/indd.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-indd.imageset/indd.png new file mode 100644 index 0000000000000000000000000000000000000000..1c1acd814edee1bc4b35b7b43efe84d722e99c9a GIT binary patch literal 232 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM3?#3wJbMaAX$AO%xcX!kPV!nm*=NJ-$Zh>j z>w2Bn0vUZSYu|kM`1rx=&27im*BqG<yyfwOS8nX5_W*UVl?3?(GaP;H<>{f|rQ-|~ z;VkfoEM{Qf76xHPhFNn!HU)XQIEGZ*I(z;!UxR{xbKsf?wk!Yt$3{x%_6yvsoNjwk z<?)>bqBT(>Dd_<Vo4h!cME5KzSh3+{c!Ki=OONGW72nNi5!t=m&c37SIIrA7j_5dj YEq~5N_JFkufTlBey85}Sb4q9e07l7GQ2+n{ literal 0 HcmV?d00001 diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-jpeg.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-jpeg.imageset/Contents.json new file mode 100644 index 0000000..9dff12d --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-jpeg.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "jpeg.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-jpeg.imageset/jpeg.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-jpeg.imageset/jpeg.png new file mode 100644 index 0000000000000000000000000000000000000000..9345579ac9f55ef698e73b9a9d09b6fd41abaac7 GIT binary patch literal 494 zcmV<K0TKR*P)<h;3K|Lk000e1NJLTq001!n002M;0{{R3ggkFb0000;P)t-sOlfiA zg#hA&0OExJ;)DR=g#iD}iU0q9|Idozg#iE0i~s-s{`&OhlpgQ5XZq#I^U9Cupf~s4 zx%Jhj{PE!N!G7$kP5j7xF?Y?M0000AbW%=J0DFJ`@kW{gow-Ox!RP=0010qNS#tmY z3ljhU3ljkVnw%H_00A~hL_t(|+U?fMZp0uA1<<rk+J?&`Jm>%av?7^y5)j37HH-0z zARXar5s7J<mUX6P3Gwwg74=Skm8i)=JyDXiFA6d<#FY?7TnJB`2~(U1S3C$?{1U$S zAp_!0Muhlah?}TR=?E>VQ#vAvigZNTmJnS*`Zg-cdWn~KN|Br%6dqwvSfBozL)miC z5K%g!AhiKxPNepQk*X;+7$ONE0I);>2>G)soFXORNdr1>_J!WZX6~c2Oc4>t!4HTH zfjr!R2ynC^Pf0U`jLGNF76_Z;%qkBwukx4x!|5tI7msTbwM}iI$mFO;j^hp}BIO_( zw?yS`;#pW=?=Ut*0%V^5Nq7lJbNOekIgWOfs`r}78TArRCgwpf_g=_Vt-V)xRZH)s kU)J0H)?c|<=5?8-Zwp#90}q#uegFUf07*qoM6N<$f*>~9W&i*H literal 0 HcmV?d00001 diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-jpg.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-jpg.imageset/Contents.json new file mode 100644 index 0000000..eee8df3 --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-jpg.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "jpg.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-jpg.imageset/jpg.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-jpg.imageset/jpg.png new file mode 100644 index 0000000000000000000000000000000000000000..9345579ac9f55ef698e73b9a9d09b6fd41abaac7 GIT binary patch literal 494 zcmV<K0TKR*P)<h;3K|Lk000e1NJLTq001!n002M;0{{R3ggkFb0000;P)t-sOlfiA zg#hA&0OExJ;)DR=g#iD}iU0q9|Idozg#iE0i~s-s{`&OhlpgQ5XZq#I^U9Cupf~s4 zx%Jhj{PE!N!G7$kP5j7xF?Y?M0000AbW%=J0DFJ`@kW{gow-Ox!RP=0010qNS#tmY z3ljhU3ljkVnw%H_00A~hL_t(|+U?fMZp0uA1<<rk+J?&`Jm>%av?7^y5)j37HH-0z zARXar5s7J<mUX6P3Gwwg74=Skm8i)=JyDXiFA6d<#FY?7TnJB`2~(U1S3C$?{1U$S zAp_!0Muhlah?}TR=?E>VQ#vAvigZNTmJnS*`Zg-cdWn~KN|Br%6dqwvSfBozL)miC z5K%g!AhiKxPNepQk*X;+7$ONE0I);>2>G)soFXORNdr1>_J!WZX6~c2Oc4>t!4HTH zfjr!R2ynC^Pf0U`jLGNF76_Z;%qkBwukx4x!|5tI7msTbwM}iI$mFO;j^hp}BIO_( zw?yS`;#pW=?=Ut*0%V^5Nq7lJbNOekIgWOfs`r}78TArRCgwpf_g=_Vt-V)xRZH)s kU)J0H)?c|<=5?8-Zwp#90}q#uegFUf07*qoM6N<$f*>~9W&i*H literal 0 HcmV?d00001 diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-js.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-js.imageset/Contents.json new file mode 100644 index 0000000..a1c5520 --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-js.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "js.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-js.imageset/js.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-js.imageset/js.png new file mode 100644 index 0000000000000000000000000000000000000000..6fca286d20742d4fd9dfdaf133e2acd78f5746cc GIT binary patch literal 444 zcmeAS@N?(olHy`uVBq!ia0vp^hCuAV!3-o@Z1TN;lwW{Ph^tR#p_Z1GwzhV}ekmXW z0>bx6*4Hxt+5i9l58o?QQBm>!{rkSYJ{ubw4-b#v;NVA(9$mO_A!v=vk|j$H960dn z*RQm+G?#kCO|xgu1e(lM666=mkp2JvN4MI!OgsNHi`@c>au#?*7Bet#3xhBt!>l<H zK*4{WE{-7;x87bm-Pdfu)B5lPXRhVZoerE|{>GQMm`)dRNj|@7>W}a9dp0|m9Wz<G z`0l!IHHNP0RadX(c{rN}PTHo&^@t_O*F!1HMQvVZfQ{hFAIeD|y`Ma4*EnLnl4JKP z9f9plF<X*m1__q$R$L;Jw7Th?`C1L@BZ~LJkJxCnvNshSd+~p}yGNjwRoWsGh6H_) z868yx?+ym)C{5_twP1$f!tb7Yjw{(PeA(<2AXS~<#kEnPZ92F8nZmS^Pur7h#5g}m z9(=A?eepG$)WI$H!#=R2^mYgvxb#NYy-uC~Io;w&UxC+yEb&jvww1N!XJt*VyZN&4 i_4Z4#F}Ec?v%a)dXqk9IV?Qt?7(8A5T-G@yGywo@U%7z* literal 0 HcmV?d00001 diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-mov.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-mov.imageset/Contents.json new file mode 100644 index 0000000..acc0c7c --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-mov.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "mov.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-mov.imageset/mov.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-mov.imageset/mov.png new file mode 100644 index 0000000000000000000000000000000000000000..dd413f79184662b0ccd67871b7309060fc7e5b19 GIT binary patch literal 475 zcmV<10VMv3P)<h;3K|Lk000e1NJLTq001!n002M;0{{R3ggkFb0000&P)t-sOlfg; zxVLq;w|2O<b-1^6xVQiR|E=5FfB*k=xVEp{+yDRnjl{$9`S`-%->=)+<?`{i+1Y@; zz1ZvPr_$1y%gVZS*C_x1019+cPE!DWdjCf80i6Q5h0b!f00009a7bBm000XU000XU z0RWnu7ytkPDM>^@RCwC$*4=W0AP@y$+Y_70VOM_N|5ZECCQZ-=yy}f-#$nd+13@o# znx@M#<Ik+-`Z+au!>g=jLoVovmMneIka<8n6O4!_GA16$pm-po;+G7II~f-@azOl$ zBf?xij*7ZUM<ip~RXQS@mh{AC5z`eRuLs2@r#Qta21T#|fVH>Ez7$TplJwS*Vu3PJ zJd#M!BDNJ$9;la)iXfy2=>wSnPr;5zNTjofjPPUzBBfSv^gF@>nY4h?eLq+0M8ekl zd&1s@qoURs!sO|2Kq#Z*+7+?Rx{{&1S{ZsGk(!g#-3aT@5r}2x`emT?fH%Tx27+n; zvX-Mv(M7E%WL5w3Pdvpb_QiY<%)JhIlcm>%Z*u8%`fc88ufL4$WnM1R^aY$-G%Q?u R3uFKQ002ovPDHLkV1lo2;D`VK literal 0 HcmV?d00001 diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-mp3.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-mp3.imageset/Contents.json new file mode 100644 index 0000000..7897e4f --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-mp3.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "mp3.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-mp3.imageset/mp3.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-mp3.imageset/mp3.png new file mode 100644 index 0000000000000000000000000000000000000000..c1ce20e46406fcfdcf07594a0e4d83b5a1719a71 GIT binary patch literal 565 zcmV-50?Pe~P)<h;3K|Lk000e1NJLTq001!n002M;0{{R3ggkFb0001EP)t-sOlff} zCEY9~-7O{FEF|77B;Ssu{fUYHkf;AFB;Jpv{VgQkEhXNOsQ>@}|8Z;SG%VmhIN{97 z_hDS;kf;BtrSj<K{D*_>Ohx1P`2O+m{hF5WSW)GBc<kBN`M$gLgqQo*_k_y;000bh zQchC<xJ0^rNSXni@t(Wcvm|{0000SaNLh0L01FcU01FcV0GgZ_0004CNkl<Zc-rmO z*-pbS5Czb5ffATpZ;j(6OaK2X(Ubyh5^DPikb0jY%g5u9Bg<7)J?(1xF~{cRP|dQZ zdpR}>@<b^yCvRynBXx$jC6ps>NS?SRnc|A%if@uFzDU0KBn6^*J(P;(`H&W;8*#2u zCb&vMm}X8=Le7gcDM4~L6^cb3;vxQxP<^jTM6UrryKJEX&arY;xSq$|Wj)#Rhp>ZE z8tCUjg&-!t8)E@2MHo8|jE;yG=>WG7fQJRF_W>9L0B?bjYbjx&X+_wcT40jWA_}0B z6ahm#=v%cW3|9|Cq|pk1SNG`70#@V+vWUDZqv2hi2rT$!1ctPb=ZQjgl8D<%3q1@L z-dK#W4QuDI5T1{^Q{%^(w~NkYjE<;}yze0j0k~FAg74i)PpwJ*iI>jT@{2+`S19h} zA^w=C3qf7&3dJV%3SVTKdg;%z=Xc+BliN$Z*;ds%0b7<r^~C_W00000NkvXXu0mjf D!D#~W literal 0 HcmV?d00001 diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-mp4.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-mp4.imageset/Contents.json new file mode 100644 index 0000000..157d36a --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-mp4.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "mp4.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-mp4.imageset/mp4.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-mp4.imageset/mp4.png new file mode 100644 index 0000000000000000000000000000000000000000..9d64a53a85d9a5aff3b64ac3b485b726e20c732a GIT binary patch literal 500 zcmV<Q0So?#P)<h;3K|Lk000e1NJLTq001!n002M;0{{R3ggkFb0000>P)t-sOlfh9 znV$du|3gq!LQhmePgO!sRE?USe}8{NPgIYbq5uE??e6cbv$bw`d|6~@!p6w<_xIA( z)=gPnoTH_`#Knq~n0$tc;p60)HyAVk000VfQchC<o&o>4@qL^EMyD@N$N&HU32;bR za{vGf6951U69E94oEQKA0Xa!TK~#9!?bg|jgdh+FP-jZJTP`~){{NRwq<66`c$rKl z-Zz&66k<|M)AVgeA0x#1(yVf#y@;Ij#ECU&d9fm0fp{lWBHl=ucqN76g_MdXDHab> zF7Bj4h_6en5a(}ROd^|w5m98bFhYwp;lz4bLa-v}sZwM)#3BB*Fb=Xxgc+ZNHtDAe zw~^B&5h-km$<0~<a~K>j(CCx}FfMok<_#eL2MI_>2N#ytr2|wdLaA+G14%$o1hj?J z6g=cogr{w>-1BMBB*yD{!U$=~g$5L$sa!+=aNc_f=OZU<+EzE?9>EEyH~@AUmUy^9 zvm&kN@eT7jv>KMpQIRMpWh};My}AL4>FMsdx>jCuc7H<{V=Bc?4)MoCR|z_<L(bB2 qU3iwB>-5(-rCz@+t)*-GruhM+<u-2v3n%pe0000<MNUMnLSTYxHPgcY literal 0 HcmV?d00001 diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-pdf.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-pdf.imageset/Contents.json new file mode 100644 index 0000000..c9c4199 --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-pdf.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "pdf.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-pdf.imageset/pdf.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-pdf.imageset/pdf.png new file mode 100644 index 0000000000000000000000000000000000000000..95ab6c6751311aabe5834287830cc364c5c1f94d GIT binary patch literal 514 zcmeAS@N?(olHy`uVBq!ia0vp^hCuAV!3-o@Z1TN;R6>AHh^tR#;VD+u|NsA=W@SCa z#&#NrSXqB}cmJ=i2eHBEcW*CH!L!!ZzaKwdv#@xOnR!V=<Gi@|j~h3BK7Rae?b?fq zik}Z3zUA)z@8{3^5fOjhy?Z%z>bLs(SEZ%DA4<#w+QnTG<QL4ai*dJG?T)!jA7cwN z@}>cWISV`@i-EGjAk4@xYmNj11EZm*i(^Q|t+&@s=N&Q-U=6s;mSM7QoA6`zxDWs9 zBP}n82{j&{y{-GsRgaGsD|B3?6kWEiewn$%rmn;F+ZU}OU#(M}s+H%vSyxIvnke9I zBQS4=x=*3|jz{eqj+#I0)(=w1TjFx!adh0Ar>nf(Vx9nrk|NKdq)J`Q&4>7&J8e9r zX7({$N#eA~*%z1WOPNf<î=~h)@xVzeNO=kYo%=L#D-WnH5{=LF%*n89RKxIaj z$%5nRleL%hH>hV-ANYEL+g6}b;X=zk<=4gAgDfuAELuE6>gn?xT--cQ7W_~W>1Hz! z5v*jY%P1-0n!$GUxhucIUZKe8TE}-x{djS9NW11SH4RshDVy|J3_qPdv~6L4!RF3- zwO397-LVTTo^1ONy6Vod{b%a#edWIs`N~=Ki2BRp9p=9e9Eb#lD1)b~pUXO@geCyU C8RU5Y literal 0 HcmV?d00001 diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-png.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-png.imageset/Contents.json new file mode 100644 index 0000000..6508eb5 --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-png.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "png.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-png.imageset/png.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-png.imageset/png.png new file mode 100644 index 0000000000000000000000000000000000000000..9e5fad2de42334d8d49fa59ec4d03d4643554db0 GIT binary patch literal 491 zcmV<H0Tlj;P)<h;3K|Lk000e1NJLTq001!n002M;0{{R3ggkFb0000&P)t-sOlfiJ zkNVMC<L#09(OKisS>w@J<L!|8|9}6{TI2u!|M1B6)@kPa^#0<P?%H|j?6~vy<NM!* z?C7cS(_rQE()jdg*!BPb019+cPE!D$|NlnueVhWhga{4s00009a7bBm000XU000XU z0RWnu7ytkPIY~r8RCwC$*4d7PAP@ymr_k>9O0oO@zd9*8PTRPEkCS*G2tiKR)X;TZ zKMr&lrJk>@$b-(Jwxp+;D9KnC1sNK|H=!2sMcTxgG>R2z6(7<p{z$u6$OWN(URQ;B zUKV+jiU?{mN<~<;Ar+BIp=yFsxGFZe#Vu~pDzw#0H7!+b(}8g8CIC|+Vm1zdlRtw8 z*m%}ELb@~OegNmHrNmMPurD}J^585CX~0mFBDo_m5s{IH(v64_X@ZyE+?lX4m$&eU zH0Q=s5lS)Fgagt_ILI!UBLNp8#C=2nyqgdADu?Y_$e4gXoRyhoXJu-+j4sF;Vzj60 zC=)G_p5>4lMZo*aW3#Z6^!H}>P1IV`UQ*HgAHKm`oQmNh7-~1<O~%?Se3QO*(=YSL hcm1(-EyLJ%-7kZ1B|}W>D<}W}002ovPDHLkV1jSE=4Ai? literal 0 HcmV?d00001 diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-pproj.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-pproj.imageset/Contents.json new file mode 100644 index 0000000..a14a22b --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-pproj.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "pproj.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-pproj.imageset/pproj.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-pproj.imageset/pproj.png new file mode 100644 index 0000000000000000000000000000000000000000..6308b56dcb2625a460584a4e3120e71d5a187d5a GIT binary patch literal 266 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM3?#3wJbMaASqAuoxcX!k&W_yH=d$+AhmW%( zw)Hu!19B&Nt)Jk&ZnD?<nPFQW-+Q$*XW#m|qt{P9IynE*zFFswue~<ae-q<Gt{$Kv zTqQw%!3_WZ|9|1(b>#W+EVHzHpeSd7M`SSr1Gg{;GcwGY1F}5P)5S5Q;?~*o-h2%P z94r@t)+l{j{C)o#Jt^5}lfvgZ&sjOn?M;z+ai)dAwo=6D!)M_JlY(650FhUploR5@ zq&4(TvfbiZ9&?8C+{JwdziItq`zwFzTa=?z;7Wlb_mv)Ilylu<mc5)iaSPBg22WQ% Jmvv4FO#pUVY*qjO literal 0 HcmV?d00001 diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-psd.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-psd.imageset/Contents.json new file mode 100644 index 0000000..ba5a913 --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-psd.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "psd.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-psd.imageset/psd.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-psd.imageset/psd.png new file mode 100644 index 0000000000000000000000000000000000000000..d44694afb6dc0ea717df8d56a03fa9a96762a207 GIT binary patch literal 537 zcmV+!0_OdRP)<h;3K|Lk000e1NJLTq001!n002M;0{{R3ggkFb0001HP)t-sOlff# zAy;SB?EwG(86j5~B35YC?0^6NXV>o;Ay*h8Rv95z86sC{*zo`V|9gO+?e6m3-sWi5 z?>0SbN>O`^k*m(o+aoPt`T6>2Zjrpc&Y+~au(QQ-c$s0w;7gUbSFp}Oe50s_SQ7vM z01R|ePE!E5o&oW@ngX3hetJJ2M&keg010qNS#tmY3ljhU3ljkVnw%H_00BWsL_t(| z+U?fePJ}QJ1z=ECMTbM#DgA|2-~S~|S<wXoEr~IjaIWU&OQ+{*vMei41+C^>Jw0Z# zJkwUr)q<216V=_6oZ@0eiUctwlp-c1P27@1aYItYH%S&>BwdVThxjCWMD=hV7ng|` zUWtgesR*w`#CtU-F`+MW5)-6fFFVB|hd9Ll5w%b|MT_Pqg_e9BcpN0Ji(a_a0E)v1 zKuIDifX1zf8h{aiA_*ub;KG1_SB2?`Jix;mx+Zckl31+^5k3Ie9&lC@Q3JpA^Q$0( zS}OqPILvXOfu6{^&_SB8fE;>m0dCN33pm>g2x8|Yw=miRt@UQB-4HuYo;XyCW@9Va zsV?j!y;7YG&2`FIZtKmiV)<pEnRklB9O93OVkanK7xE%`><V9`j9vQkybQN~p8A&} bFSG0e85Wc#n;Ez!00000NkvXXu0mjf5Y*xw literal 0 HcmV?d00001 diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-tiff.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-tiff.imageset/Contents.json new file mode 100644 index 0000000..62e4cce --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-tiff.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "tiff.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-tiff.imageset/tiff.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-tiff.imageset/tiff.png new file mode 100644 index 0000000000000000000000000000000000000000..16f4bb475b8b36d4abbd53e4b75651abc986a68a GIT binary patch literal 460 zcmeAS@N?(olHy`uVBq!ia0vp^hCuAV!3-o@Z1TN;RBV7xh^tR#;XVtCY6gZXFoH1Z z85kgf{}~v7BKs{Z|NsAgq__9f^y&8w9QgL`-Jf5-)+Q!SP*r_=^5pf68y_4wva`H= zp{M8j`}do;xi2nTw!_$XwT6b&y^F~}i?~aI{DK)~?QrqfUCT80Bcqk^`<p;v&H|6f zVxX)r2s1Lwnj--e{O9T77*cWT?X}Z+hYSQ*1I!ttzE^!ZaO^DizyHSC!cGUpW`0~0 z8?v)|wa3MaJ>`q1+<I#ocWg#~x#-h|tuv;)InX)dO{JiquE2Q~p^q*pk0x>)WuDk2 zuOwEkB+h+Oz<iSOncM36=hS8%>ge`dW^icci~^;&(-L7l(~FmRZ0hv=vsGg5Zgb_T z{Ki5hCF}29FJ67Pewm@f#rgHKI6>a4iEah+Y^J3hHH{Bpw21w^RidvXM>=G!yR_Zz zot1&X%A(uXHVNc?%|GJO{CabXs#1|9)3y0(*Tcm&g!V7J&NAOO=TUgeIhI+wCrlQ( tBRX%%6TQC{?FHYbwtmswr7`svTd8!r1-pu6Ixr*{JYD@<);T3K0RYh3zj^=w literal 0 HcmV?d00001 diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-txt.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-txt.imageset/Contents.json new file mode 100644 index 0000000..7cc1eb6 --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-txt.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "txt.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-txt.imageset/txt.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-txt.imageset/txt.png new file mode 100644 index 0000000000000000000000000000000000000000..9215e1a8e39a1cefb3399ecb524396c7922119ec GIT binary patch literal 430 zcmeAS@N?(olHy`uVBq!ia0vp^hCuAV!3-o@Z1TN;lzo6th^tR#;nk~GH*em2?b<aU z31R&I|NqLBD-bSR<nG<O@87@w`Sa)Ay?ZZQxN!XV@#oK<_dYY82sDbjB*-tAVOH%N zx1IAC=Y9+nI~HpK6y_}Oh%5%m3WG2s!>l<HK*4{WE{-7;x87bmn0Hu#hb`e&Yw8Iz zi8H_LUq8vY%9Ua0@jCTXJ*P)aLm*$-xm>#g>m6%u$4<AN!|J}NQT4-O)q7&HoWhG# z4V0yf)aRXCQlYf@QM(U!&HeDB%{m?Vo0L9myS?Z^!Kqmnf_W5g2z&FiFVJ*2wdzH2 zr}rOWMZwvY$C5fcR?bxKs@K&LG)#ZSHDTt&(zsm4R*wZiOwl%Zp`F}?aVwNxKTBQt z$tk|MQt;)DD|-Z#?A*3p2p7^-<`R4*yhLZY(&TAt_*aXc-ql>^$Kv6;^~j3uMgM}A zF00ae6Z1$&UocUSWwo>1qqTZ9C(=rP)xL9I|2{pNv#$7^FjIC*dZmTH3}8qwc)I$z JtaD0e0syW|zjFWp literal 0 HcmV?d00001 diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-xls.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-xls.imageset/Contents.json new file mode 100644 index 0000000..4464de7 --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-xls.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "xls.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-xls.imageset/xls.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-xls.imageset/xls.png new file mode 100644 index 0000000000000000000000000000000000000000..a3a43ea53c2464708a326c54fec753ece69d776b GIT binary patch literal 488 zcmeAS@N?(olHy`uVBq!ia0vp^hCuAV!3-o@Z1TN;R7ikNh^tR#VdWZz%?}+mJ$3*x zD%LP;dgK5^6{{H_Tp+pSiR1tO|8Kq6vH7w6xrYnB{d~Lcdh_Ic2Ho4079RC^`{hp4 zCc%y8v!1*=z3jT-iMvxTdCKnrn$KMl<QL4~GH<R&?X0;wKh9=Z<$4|{%vs<OSqzjF z24O~qS#u;97#Nj3T^vIyZoR#BJFnS*hb`g8E#4=80<77cD*u0<!;u^AaQ0!^Ewz8# z>pq<<Q&I^JEzOFmni*Fcc6*NIw79n?I)jqDXKlGL!OVFL=hF_+lfvg!f^8<oeDvMa zTBR>m9;9$3dSih6QKb;ij@MCVLWP`G%X)U?MtiBU#{Lf4cvg|`<L;xa%bhMh{1lvY zK~2kKbIbbJ7D>hp{Z|DXCeP+)e|TUn^X!fVf{PFEb{lL~&rdS-U~brZp3&ms%9E@! z<R<^VX)P@->lD0w_od0s8E?0KNZ?DCf4@*tNU-f;u0+bqtS!fkYFv6=JUeWoQ+|}A znP;N7h@p$ykxAm-CLZQxo7n2#^jjUV7t2-52$ue&=bOcSf2m*YuiJB;>#oZyO3$3V cYt=pm^UJMwKkTa%0EQuhr>mdKI;Vst02r^+eE<Le literal 0 HcmV?d00001 diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-xml.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-xml.imageset/Contents.json new file mode 100644 index 0000000..f6a92e3 --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-xml.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "xml.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-xml.imageset/xml.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-xml.imageset/xml.png new file mode 100644 index 0000000000000000000000000000000000000000..777f56d0dd21d993fc7ffeb978431c92f999138d GIT binary patch literal 292 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE3?yBabR7dyiUB?$u0ENCBDKFo>V5-Bq1ykV zwZBAbe~Z@rf{2LL{k}QdaXnBmS4ogxFvBCa-O`2?xmPB6B`RnDML7#RB8wRqxP?KO zkzv*x383I)PZ!6Kid%axzvervz`^=}x#D%@fA+XtW!l-Uo4t?m3qKZj>^NZW)Oz0G zEkn6q!1@OLhDtUSUxwQ}7uXbT?$R^d;LWK1;lvk7jlHjBHTFHN?y#D$WU{vm!%DZ{ zLm3KvS$r3aij}8GF<guB%;0QXv$_4?G4ng-i=~n_>`!98ebKshMp=<nxBjuap0fXS e!f%~_#P*>^IBde9x95P)WAJqKb6Mw<&;$S}ByB_h literal 0 HcmV?d00001 diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-zip.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-zip.imageset/Contents.json new file mode 100644 index 0000000..eed17c7 --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-zip.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "zip.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-zip.imageset/zip.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-zip.imageset/zip.png new file mode 100644 index 0000000000000000000000000000000000000000..587554a70807b516ab418343040d3ddd35f58271 GIT binary patch literal 300 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM3?#3wJbMaAxd!-zxcX!kemuf&;u-gOj?l-$ ze6QMNp4BQnt5bTjMEv7H{^xayZ$5mSx^&sc-2yM4-~PD8=j~+4k7s>7!b4B(OxSh! zWL3|klRFX>mlsY28pu@=<QL5F|Ns9dFK@had;R^1bOKP6v%n*=n1O*?7=#%aX3YUP zq}<cRF{I+w)k~g2%?3O!f!og}2uw1VaOeLDl|?~`0_+d+<4ZFa3plO&pnpjq@ac!O zhvnKN8qYCU=rgeAG%nX!_SAIe2eFy40-=SCUW)P^c@AIMX4Fmy`tw+8k^KYP0xzBo u2P*_F7dHR9#8$d1MEc1x+p9JA(plLx*-Cb6u}cD-z~JfX=d#Wzp$PyP_In`! literal 0 HcmV?d00001 diff --git a/apps/ios/PushThat/Services/PushContent.swift b/apps/ios/PushThat/Services/PushContent.swift index c3f7ba1..fc1821f 100644 --- a/apps/ios/PushThat/Services/PushContent.swift +++ b/apps/ios/PushThat/Services/PushContent.swift @@ -1,13 +1,18 @@ import Foundation +import ImageIO +import UniformTypeIdentifiers /// What a push's envelope holds (docs/e2ee.md, "The push envelope"), and the keys this -/// device seals and opens it with. Foundation, CryptoKit and Security only: the app, the -/// share extension, the notification service extension and the tests all compile it, and -/// only the first two have Firestore. +/// device seals and opens it with. Foundation, CryptoKit, Security and ImageIO only: the +/// app, the share extension, the notification service extension and the tests all compile +/// it, and only the first two have Firestore. /// /// This file is the same in `apps/ios` and `apps/macos`. enum PushContent: Equatable { - case url(url: String, title: String, ext: String) + /// `icon` is the site's icon (`PushIcon`), nil when the push has none. It defaults to + /// nil so that a push made before a page is read, which is every push at creation, need + /// not say so. + case url(url: String, title: String, ext: String, icon: String? = nil) case file(PushFile) enum ContentError: LocalizedError { @@ -41,7 +46,7 @@ enum PushContent: Equatable { /// overlong title cannot crowd the URL's tokens out of the 3000. var searchText: String { switch self { - case let .url(url, title, _): return String(title.prefix(Self.maxTitleLength)) + " " + url + case let .url(url, title, _, _): return String(title.prefix(Self.maxTitleLength)) + " " + url case let .file(file): return file.name } } @@ -51,8 +56,12 @@ enum PushContent: Equatable { func json() throws -> Data { let object: [String: Any] switch self { - case let .url(url, title, ext): - object = ["url": url, "title": String(title.prefix(Self.maxTitleLength)), "ext": ext] + case let .url(url, title, ext, icon): + var fields: [String: Any] = ["url": url, "title": String(title.prefix(Self.maxTitleLength)), "ext": ext] + // Left out rather than written empty when there is none, which is how every + // push before icons reads and what the vectors' URL envelope holds. + if let icon, !icon.isEmpty { fields["icon"] = icon } + object = fields case let .file(file): if let fk = file.fk, let np = file.np { object = [ @@ -73,7 +82,10 @@ enum PushContent: Equatable { switch type { case "url": guard let url = object["url"] as? String else { throw ContentError.malformed } - self = .url(url: url, title: object["title"] as? String ?? "", ext: ext) + // Absent on a push from before icons, and "" means none as well (docs/e2ee.md, + // "Icons"), so both read as nil and nothing downstream tells them apart. + let icon = (object["icon"] as? String).flatMap { $0.isEmpty ? nil : $0 } + self = .url(url: url, title: object["title"] as? String ?? "", ext: ext, icon: icon) case "file": guard let name = object["name"] as? String else { throw ContentError.malformed } let mime = object["mime"] as? String ?? "" @@ -101,6 +113,17 @@ enum PushContent: Equatable { return (enc, tokens) } + /// A URL push sealed with its icon, or without it when the envelope with it would be + /// over the rules' bound (docs/e2ee.md, "Icons"): a long URL keeps its title rather than + /// losing both. Still too large without it, it throws `tooLarge` as before. + static func sealURL(url: String, title: String, ext: String, icon: String?, keys: PushKeys, pushId: String) throws -> (enc: E2eeCrypto.Envelope, searchTokens: [String]) { + do { + return try PushContent.url(url: url, title: title, ext: ext, icon: icon).seal(keys: keys, pushId: pushId) + } catch ContentError.tooLarge where icon != nil { + return try PushContent.url(url: url, title: title, ext: ext).seal(keys: keys, pushId: pushId) + } + } + /// Opens a push's envelope, or nil when this device cannot: a push made under another /// account key (`keyVersion` differs), or one that does not open or parse. Such a push /// is shown locked. The AAD is made from the document's own `userId` and id. @@ -202,3 +225,252 @@ struct PreparedPush: Codable, Equatable { var enc: E2eeCrypto.Envelope { E2eeCrypto.Envelope(v: encVersion, n: encNonce, ct: encCiphertext) } } + +/// A URL push's icon (docs/e2ee.md, "Icons"): the standard base64 of a PNG no larger than +/// 64 by 64 pixels and no more than 4096 bytes, carried in the envelope with the title so +/// that no device asks anyone for it in order to draw the list. Google's favicon service +/// drew it before, and so learned the host of every pushed link each time a list was drawn. +/// +/// What needs no network is here rather than in `TitleScraperService`, which does the +/// fetching: this file is compiled by both apps and by the tests (which have no Firebase, +/// and a new file would mean a new entry in each project), so the candidate order, the +/// bounds and the decoding for display are written once per app and checked. ImageIO +/// rather than UIImage or NSImage for the same reason: it opens ICO and PNG on iOS and +/// macOS alike, so this is the same in both apps. +enum PushIcon { + /// The longest side, in pixels, an icon is stored at. + static let maxSide = 64 + /// The side tried once more when the PNG at `maxSide` is over `maxBytes`. + static let fallbackSide = 32 + /// The most bytes of PNG an icon may be, 5464 characters of base64: with a URL and a + /// title it still fits `enc.ct`'s 16384 for every ordinary link. + static let maxBytes = 4096 + /// How much of each candidate is read. A larger file is passed over rather than cut, + /// since a truncated image can still decode, with its lower part missing. + static let maxDownloadBytes = 256 * 1024 + /// Frames longer than this on a side are passed over: a frame is decoded whole to be + /// shrunk, and 256 KiB of PNG can hold a frame that would take hundreds of megabytes. + /// No icon worth drawing at 64 pixels is that large. + static let maxSourceSide = 2048 + /// The most `<link>` candidates tried before `/favicon.ico`. A page names a handful at + /// most; the cap keeps one that names hundreds from turning a scrape into hundreds of + /// requests. The same on every client (docs/e2ee.md, "Icons"). + static let maxLinkCandidates = 8 + + // MARK: Candidates + + /// The URLs to try, best first, for the page whose HTML is `html` and whose final URL + /// (after redirects) is `pageURL`: every `apple-touch-icon` link, then every `icon` + /// link (`shortcut icon` included) that is not SVG by its type or its path, then + /// `/favicon.ico` at the page's origin. Only http and https, each once, and at most + /// `maxLinkCandidates` links. A `<base href>` is not followed, as on every client: the + /// final URL is what all of them agree on. + /// + /// SVG is left out because ImageIO does not draw it: fetching one would be a request + /// the site sees for nothing. The spec leaves the `apple-touch-icon` links unfiltered, + /// and so does this; one that does not decode is simply passed over. + static func candidates(html: String, pageURL: URL) -> [URL] { + var touchIcons: [URL] = [] + var icons: [URL] = [] + for attributes in linkTags(in: html) { + guard let rel = attributes["rel"], let href = attributes["href"], + let url = resolve(href, against: pageURL) + else { continue } + let rels = Set(rel.lowercased().split(whereSeparator: \.isWhitespace).map(String.init)) + if rels.contains("apple-touch-icon") || rels.contains("apple-touch-icon-precomposed") { + touchIcons.append(url) + } else if rels.contains("icon") { + let type = attributes["type"]?.trimmingCharacters(in: .whitespaces).lowercased() + guard type != "image/svg+xml", url.pathExtension.lowercased() != "svg" else { continue } + icons.append(url) + } + } + var seen = Set<URL>() + var all = Array((touchIcons + icons).filter { seen.insert($0).inserted }.prefix(maxLinkCandidates)) + if let favicon = favicon(of: pageURL), !all.contains(favicon) { all.append(favicon) } + return all + } + + /// The attributes of every `<link>` tag, names lowercased, values unquoted and their + /// common entities decoded. A scanner rather than a parser, as the scraper finds the + /// `<title>`: a link's attributes are all an icon needs, and there is no HTML parser in + /// the project to lean on. It takes attributes in any order, quoted either way or not at + /// all, in any case, and a quoted value holding a `>`. + static func linkTags(in html: String) -> [[String: String]] { + linkPattern.matches(in: html, range: NSRange(html.startIndex..., in: html)).compactMap { match in + guard let range = Range(match.range(at: 1), in: html) else { return nil } + let tag = String(html[range]) + var attributes: [String: String] = [:] + for attribute in attributePattern.matches(in: tag, range: NSRange(tag.startIndex..., in: tag)) { + guard let name = Range(attribute.range(at: 1), in: tag) else { continue } + let value = (2...4).lazy.compactMap { Range(attribute.range(at: $0), in: tag) }.first.map { String(tag[$0]) } ?? "" + let key = tag[name].lowercased() + // The first of a repeated attribute counts, as it does in a browser. + if attributes[key] == nil { attributes[key] = decodeEntities(value) } + } + return attributes + } + } + + private static let linkPattern = try! NSRegularExpression( + pattern: #"<link\b((?:[^>"']|"[^"]*"|'[^']*')*)>"#, options: .caseInsensitive + ) + private static let attributePattern = try! NSRegularExpression( + pattern: #"([^\s"'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?"# + ) + + /// The entities an `href` holds in practice, `&` between query parameters above + /// all. `&` goes last so that `&lt;` reads as the text `<`. + private static func decodeEntities(_ value: String) -> String { + guard value.contains("&") else { return value } + var out = value + for (entity, text) in [(""", "\""), (""", "\""), ("'", "'"), ("'", "'"), ("<", "<"), (">", ">"), ("&", "&"), ("&", "&")] { + out = out.replacingOccurrences(of: entity, with: text, options: .caseInsensitive) + } + return out + } + + private static func resolve(_ href: String, against page: URL) -> URL? { + let href = href.trimmingCharacters(in: .whitespacesAndNewlines) + guard !href.isEmpty, let url = URL(string: href, relativeTo: page)?.absoluteURL, + isWeb(url.scheme), url.host != nil + else { return nil } + return url + } + + /// `/favicon.ico` at the page's origin: its scheme, host and port, nothing else. + private static func favicon(of page: URL) -> URL? { + guard isWeb(page.scheme), let host = page.host else { return nil } + var components = URLComponents() + components.scheme = page.scheme?.lowercased() + components.host = host + components.port = page.port + components.path = "/favicon.ico" + return components.url + } + + private static func isWeb(_ scheme: String?) -> Bool { + let scheme = scheme?.lowercased() + return scheme == "http" || scheme == "https" + } + + // MARK: Encoding + + /// What a candidate's bytes came to. + enum Encoded: Equatable { + /// The icon, within the bounds. + case icon(String) + /// ImageIO could not open them (or they are over `maxDownloadBytes`): the next + /// candidate is tried. + case undecodable + /// They opened, but the PNG is over `maxBytes` at 32 by 32 as well. The first + /// candidate that decodes decides, so there is no icon and no further candidate + /// is tried, as on every client (docs/e2ee.md, "Icons"). + case tooLarge + } + + static func encode(_ data: Data) -> Encoded { + guard data.count <= maxDownloadBytes, + let source = CGImageSourceCreateWithData(data as CFData, nil), + let index = bestFrame(of: source), + let image = CGImageSourceCreateImageAtIndex(source, index, nil) + else { return .undecodable } + for side in [maxSide, fallbackSide] { + guard let png = png(of: image, fitting: side) else { return .undecodable } + if png.count <= maxBytes { return .icon(png.base64EncodedString()) } + } + return .tooLarge + } + + /// The frame to shrink, by its longer side: the smallest of at least `maxSide`, so an + /// ICO's 64-pixel frame, drawn for that size, wins over its 256 one shrunk; else the + /// largest there is. The first wins a tie, which takes an animated GIF's first frame. + private static func bestFrame(of source: CGImageSource) -> Int? { + var best: (index: Int, side: Int)? + for index in 0..<CGImageSourceGetCount(source) { + guard let side = longerSide(source, index), side <= maxSourceSide else { continue } + guard let current = best else { + best = (index, side) + continue + } + let better = side >= maxSide + ? current.side < maxSide || side < current.side + : current.side < maxSide && side > current.side + if better { best = (index, side) } + } + return best?.index + } + + private static func pixelSize(_ source: CGImageSource, _ index: Int) -> (width: Int, height: Int)? { + guard let properties = CGImageSourceCopyPropertiesAtIndex(source, index, nil) as? [CFString: Any], + let width = (properties[kCGImagePropertyPixelWidth] as? NSNumber)?.intValue, + let height = (properties[kCGImagePropertyPixelHeight] as? NSNumber)?.intValue, + width > 0, height > 0 + else { return nil } + return (width, height) + } + + private static func longerSide(_ source: CGImageSource, _ index: Int) -> Int? { + pixelSize(source, index).map { max($0.width, $0.height) } + } + + /// `image` scaled down to fit `side` by `side` with its aspect kept, never up, as PNG. + /// Drawn into a context of exactly the target size rather than through ImageIO's + /// thumbnails, whose handling of an image already smaller than the limit is not + /// documented. Drawn in sRGB, so an icon in another color space (a CMYK JPEG, a P3 PNG) + /// comes out as every platform will show it; ImageIO marks that with a short `sRGB` + /// chunk, not a whole ICC profile, so it costs a few bytes of the 4096. + private static func png(of image: CGImage, fitting side: Int) -> Data? { + let scale = min(1, Double(side) / Double(max(image.width, image.height))) + let width = max(1, Int((Double(image.width) * scale).rounded())) + let height = max(1, Int((Double(image.height) * scale).rounded())) + guard let sRGB = CGColorSpace(name: CGColorSpace.sRGB), let context = CGContext( + data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0, + space: sRGB, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { return nil } + context.interpolationQuality = .high + context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + guard let scaled = context.makeImage() else { return nil } + let data = NSMutableData() + guard let destination = CGImageDestinationCreateWithData(data, UTType.png.identifier as CFString, 1, nil) else { return nil } + CGImageDestinationAddImage(destination, scaled, nil) + guard CGImageDestinationFinalize(destination) else { return nil } + return data as Data + } + + // MARK: Showing + + /// The image to draw for a push's `icon`, or nil when it is not a PNG within the + /// bounds, which draws the placeholder. Decoded once per icon and kept, failures too: a + /// row's body is evaluated far more often than its push changes. + static func image(_ icon: String) -> CGImage? { + let key = icon as NSString + if let decoded = cache.object(forKey: key) { return decoded.image } + let image = decode(icon) + cache.setObject(Decoded(image), forKey: key) + return image + } + + /// Opens an `icon` as the spec bounds it. Only PNG, and only up to 64 by 64, checked + /// before any pixel is decoded: whatever wrote the envelope, a small file must not + /// become a large image in a list of them. + static func decode(_ icon: String) -> CGImage? { + guard let data = Data(base64Encoded: icon), + let source = CGImageSourceCreateWithData(data as CFData, nil), + CGImageSourceGetType(source) as String? == UTType.png.identifier, + let size = pixelSize(source, 0), size.width <= maxSide, size.height <= maxSide + else { return nil } + return CGImageSourceCreateImageAtIndex(source, 0, nil) + } + + private final class Decoded { + let image: CGImage? + init(_ image: CGImage?) { self.image = image } + } + + private static let cache: NSCache<NSString, Decoded> = { + let cache = NSCache<NSString, Decoded>() + cache.countLimit = 500 + return cache + }() +} diff --git a/apps/ios/PushThat/Services/PushDocuments.swift b/apps/ios/PushThat/Services/PushDocuments.swift index b5f2508..7a44fb1 100644 --- a/apps/ios/PushThat/Services/PushDocuments.swift +++ b/apps/ios/PushThat/Services/PushDocuments.swift @@ -137,11 +137,13 @@ enum PushDocuments { // MARK: - Titles - /// The scraped title written back to an encrypted URL push: exactly `enc` and + /// The scraped title and icon written back to an encrypted URL push: exactly `enc` and /// `searchTokens` (`isEncryptedRetitle` in the rules), the URL and ext as they were, the - /// new title under a fresh nonce. Only under the push's own key version, and only when - /// that is the one this device holds: a push made under another key is not ours to + /// title and icon under a fresh nonce. Only under the push's own key version, and only + /// when that is the one this device holds: a push made under another key is not ours to /// rewrite. Last write wins. + /// With the icon the envelope can be over the rules' bound on `enc.ct`, and then the + /// title is written alone (`PushContent.sealURL`). static func retitle( firestore: Firestore, keys: PushKeys, @@ -150,10 +152,11 @@ enum PushDocuments { keyVersion: Int?, url: String, ext: String, - title: String + title: String, + icon: String? = nil ) async throws { guard keyVersion == keys.keyVersion, userId == keys.uid else { return } - let sealed = try PushContent.url(url: url, title: title, ext: ext).seal(keys: keys, pushId: pushId) + let sealed = try PushContent.sealURL(url: url, title: title, ext: ext, icon: icon, keys: keys, pushId: pushId) try await firestore.collection("pushes").document(pushId).updateData([ "enc": sealed.enc.firestoreData, "searchTokens": sealed.searchTokens, diff --git a/apps/ios/PushThat/Services/TitleScraperService.swift b/apps/ios/PushThat/Services/TitleScraperService.swift index 3d10e34..0fc5f62 100644 --- a/apps/ios/PushThat/Services/TitleScraperService.swift +++ b/apps/ios/PushThat/Services/TitleScraperService.swift @@ -1,11 +1,12 @@ import Foundation import FirebaseFirestore -/// Fills in the title of an encrypted URL push from the page itself (docs/e2ee.md, -/// "Titles"). No server fetches it, since a server that fetched the URL would know it: a -/// push goes out with an empty title and a native device writes the title back, for its -/// own pushes and for one it received untitled (a web push, which has no way to read a -/// cross-origin page). +/// Fills in the title and the icon of an encrypted URL push from the page itself +/// (docs/e2ee.md, "Titles" and "Icons"). No server fetches them, since a server that +/// fetched the URL would know it: a push goes out with an empty title and a native device +/// writes the title back, for its own pushes and for one it received untitled (a web push, +/// which has no way to read a cross-origin page). The icon comes in the same pass, so that +/// no device has to ask anyone for it in order to draw the list. /// /// The app calls it for every untitled URL push the list shows, whoever made it, which /// covers the share extension's pushes (the extension is gone before a page could answer) @@ -20,39 +21,51 @@ actor TitleScraperService { private init() {} - /// Scrapes the page's title and rewrites the push's envelope with it: exactly `enc` and - /// `searchTokens`, only under the push's own key version (`PushDocuments.retitle`). A - /// failure is logged; the push keeps showing its URL. - func scrapeAndUpdate(pushId: String, userId: String, keyVersion: Int?, url: String, ext: String, keys: PushKeys) async { + /// Scrapes the page and rewrites the push's envelope with what it found: exactly `enc` + /// and `searchTokens`, only under the push's own key version (`PushDocuments.retitle`). + /// It is rewritten when the title changed or an icon was found for a push that had + /// none, and a push keeps its own icon when the scrape finds none. A push that already + /// has an icon does not fetch another, since the one it has would be kept either way. + /// A failure is logged; the push keeps showing what it showed. + func scrapeAndUpdate(pushId: String, userId: String, keyVersion: Int?, url: String, title: String, ext: String, icon: String?, keys: PushKeys) async { guard keyVersion == keys.keyVersion, attempted.insert(pushId).inserted else { return } - guard let title = await scrapeTitle(from: url), !title.isEmpty else { return } + guard let page = await scrape(from: url, withIcon: icon == nil) else { return } + // Cut as the envelope cuts it, so a long title read again is not taken for a change. + let scraped = page.title.map { String($0.prefix(PushContent.maxTitleLength)) } ?? "" + let newTitle = scraped.isEmpty ? title : scraped + guard newTitle != title || page.icon != nil else { return } do { try await PushDocuments.retitle( firestore: db, keys: keys, pushId: pushId, userId: userId, - keyVersion: keyVersion, url: url, ext: ext, title: title + keyVersion: keyVersion, url: url, ext: ext, title: newTitle, icon: icon ?? page.icon ) } catch { print("Failed to update title: \(type(of: error))") } } - /// How much of a page is read for its title. + /// How much of a page is read for its title and its icon links. private static let maxPageBytes = 256 * 1024 - /// Scrape the title from a URL - func scrapeTitle(from urlString: String) async -> String? { + /// Sent with the page's request and its icons' alike, so the site sees one browser. + private static let userAgent = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1" + + /// The page's title and, when `withIcon`, its icon (`PushIcon`), or nil when the URL is + /// not an HTML page that answered. The title is nil when the page has none this reads. + private func scrape(from urlString: String, withIcon: Bool) async -> (title: String?, icon: String?)? { guard let url = URL(string: urlString) else { return nil } do { var request = URLRequest(url: url) - request.setValue("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", forHTTPHeaderField: "User-Agent") + request.setValue(Self.userAgent, forHTTPHeaderField: "User-Agent") request.timeoutInterval = 10 // Streamed, not `data(for:)`: the pushes left untitled are mostly links to files // (PDFs, video, archives), which have no title to find, and the list asks for // every one it shows. Only an HTML page is read, and only its first 256 KiB, where // the <title> is; `timeoutInterval` bounds a stall, not a download, so without the - // cap a large file would be pulled into memory on every launch. + // cap a large file would be pulled into memory on every launch. Nor is an icon + // looked for on anything but a page: a link to a file shows its type's icon. let (bytes, response) = try await URLSession.shared.bytes(for: request) guard let httpResponse = response as? HTTPURLResponse, @@ -69,16 +82,18 @@ actor TitleScraperService { // The header's charset, else UTF-8, decoded strictly: a page in an encoding this // cannot read gets no title, rather than a garbled one written into the push for // every device. Up to three bytes are dropped first, the most the cap can cut from - // a character, so a page cut mid-character still reads. + // a character, so a page cut mid-character still reads. Such a page has no icon + // links to read either, but its origin's /favicon.ico is still a candidate. let encoding = httpResponse.textEncodingName .map { CFStringConvertIANACharSetNameToEncoding($0 as CFString) } .flatMap { $0 == kCFStringEncodingInvalidId ? nil : String.Encoding(rawValue: CFStringConvertEncodingToNSStringEncoding($0)) } ?? .utf8 - guard let html = (0...3).lazy.compactMap({ String(data: data.dropLast($0), encoding: encoding) }).first else { - return nil - } + let html = (0...3).lazy.compactMap({ String(data: data.dropLast($0), encoding: encoding) }).first ?? "" - return extractTitle(from: html) + // Icons resolve against the URL the page came from after redirects, as a + // browser resolves them, not against the one that was pushed. + let icon = withIcon ? await fetchIcon(from: PushIcon.candidates(html: html, pageURL: httpResponse.url ?? url)) : nil + return (extractTitle(from: html), icon) } catch { // The error's type only: a URLError carries the page's full URL. print("Failed to scrape title: \(type(of: error))") @@ -86,6 +101,47 @@ actor TitleScraperService { } } + /// The icon made from the first candidate that ImageIO opens (`PushIcon.encode`), or + /// nil when none opens or the one that does is too large even at 32 by 32. One at a + /// time, best first, so a page whose first candidate is good costs one request more + /// than its title. + private func fetchIcon(from candidates: [URL]) async -> String? { + for candidate in candidates { + guard let data = await download(candidate) else { continue } + switch PushIcon.encode(data) { + case let .icon(icon): return icon + case .tooLarge: return nil + case .undecodable: continue + } + } + return nil + } + + /// A candidate's bytes, or nil when it does not answer 200 or is over + /// `PushIcon.maxDownloadBytes`. Streamed as the page is, so a large file is given up at + /// the cap rather than pulled into memory first. + private func download(_ url: URL) async -> Data? { + var request = URLRequest(url: url) + request.setValue(Self.userAgent, forHTTPHeaderField: "User-Agent") + request.timeoutInterval = 10 + do { + let (bytes, response) = try await URLSession.shared.bytes(for: request) + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200, + httpResponse.expectedContentLength <= Int64(PushIcon.maxDownloadBytes) + else { return nil } + var data = Data() + for try await byte in bytes { + data.append(byte) + if data.count > PushIcon.maxDownloadBytes { return nil } + } + return data + } catch { + // The error's type only, as for the page: a URLError carries the full URL. + print("Failed to fetch icon: \(type(of: error))") + return nil + } + } + /// Extract title from HTML using regex /// Note: For production, consider using SwiftSoup for proper HTML parsing private func extractTitle(from html: String) -> String? { diff --git a/apps/ios/PushThat/Utilities/FileTypeIcons.swift b/apps/ios/PushThat/Utilities/FileTypeIcons.swift index 61cfb61..7146272 100644 --- a/apps/ios/PushThat/Utilities/FileTypeIcons.swift +++ b/apps/ios/PushThat/Utilities/FileTypeIcons.swift @@ -1,10 +1,15 @@ import Foundation -/// Maps file extensions to icons hosted on PushThat CDN -/// Matches the Android SUPPORTED_FILE_ICONS list exactly +/// The file types with an icon of their own, drawn from the app's asset catalog as +/// `file-<ext>` (copies of `apps/web/public/files/<ext>.png`). Matches the Android list. +/// +/// They were loaded from pushthat.link/files, which put the type of every encrypted file a +/// list showed in the hosting logs (docs/e2ee.md, "Icons"); shipped in the app, drawing +/// one asks nobody anything. struct FileTypeIcons { - /// Set of supported file extensions that have custom icons + /// The extensions that have an icon. The one list: an extension added here needs its + /// `file-<ext>` image set in Assets.xcassets, in both apps. static let supported: Set<String> = [ "pproj", "zip", "indd", "csv", "css", "pdf", "aac", "png", "xls", "avi", "txt", "mp3", "tiff", "psd", "jpg", "jpeg", @@ -12,11 +17,10 @@ struct FileTypeIcons { "xml", "apk" ] - /// Returns the URL for a file type icon, or nil if not supported - static func iconURL(for fileExtension: String) -> URL? { - let ext = fileExtension.lowercased() - guard supported.contains(ext) else { return nil } - return URL(string: "https://pushthat.link/files/\(ext).png") + /// The asset catalog name of the icon for `fileExtension`, or nil when it has none. + static func assetName(for fileExtension: String?) -> String? { + guard let ext = fileExtension?.lowercased(), supported.contains(ext) else { return nil } + return "file-\(ext)" } /// Returns a SF Symbol name appropriate for the file type diff --git a/apps/ios/PushThat/ViewModels/PushListViewModel.swift b/apps/ios/PushThat/ViewModels/PushListViewModel.swift index 3314b86..6cfa956 100644 --- a/apps/ios/PushThat/ViewModels/PushListViewModel.swift +++ b/apps/ios/PushThat/ViewModels/PushListViewModel.swift @@ -266,18 +266,22 @@ class PushListViewModel: ObservableObject { showError = true } - /// Untitled URL pushes get their page's title, whichever device made them + /// Untitled URL pushes get their page's title and icon, whichever device made them /// (`TitleScraperService`). In the background: the list does not wait for any page. + /// A titled push is not read again for an icon (docs/e2ee.md, "Icons"), so a push + /// from before icons keeps the placeholder rather than every list asking every site. private func scrapeUntitled(_ items: [PushItem], keys: PushKeys) { for item in items where item.type == .url && item.title.isEmpty { guard case .encrypted = item.source, let id = item.id else { continue } let url = item.url + let title = item.title let ext = item.ext ?? "" + let icon = item.icon let userId = item.userId let keyVersion = item.keyVersion Task.detached(priority: .utility) { await TitleScraperService.shared.scrapeAndUpdate( - pushId: id, userId: userId, keyVersion: keyVersion, url: url, ext: ext, keys: keys + pushId: id, userId: userId, keyVersion: keyVersion, url: url, title: title, ext: ext, icon: icon, keys: keys ) } } diff --git a/apps/ios/PushThat/Views/PushRowView.swift b/apps/ios/PushThat/Views/PushRowView.swift index 52e4872..f7fd64a 100644 --- a/apps/ios/PushThat/Views/PushRowView.swift +++ b/apps/ios/PushThat/Views/PushRowView.swift @@ -16,7 +16,7 @@ struct PushRowView: View { // Swivel checkbox / favicon SwivelCheckView( isChecked: isSelected, - imageURL: faviconURL, + image: icon, fallbackIcon: item.type == .file ? "doc.fill" : "link.circle.fill" ) .frame(width: 40, height: 40) @@ -126,18 +126,23 @@ struct PushRowView: View { return date.relativeFormatted() } - private var faviconURL: URL? { - // A locked push shows the placeholder: there is no URL or name to show one for. + /// What the row draws, all of it already on the device: nothing is fetched to draw a + /// row (docs/e2ee.md, "Icons"). A URL push draws the icon that came in it, else the + /// icon for its `ext` from the asset catalog (a link to a file has no page to take an + /// icon from); a file push draws its type's icon. Nil draws the placeholder a locked + /// push draws, also for an icon that does not decode. The order is the same on every + /// client. + /// + /// This was Google's favicon service for every link, which learned the host of each + /// pushed link whenever the list was drawn, and pushthat.link for file types, whose logs + /// learned the type of each encrypted file. + private var icon: Image? { + // A locked push shows the placeholder: there is nothing it can be known by. if item.isLocked { return nil } - - // Check for file extension icon first - if let ext = item.ext, FileTypeIcons.supported.contains(ext.lowercased()) { - return URL(string: "https://pushthat.link/files/\(ext.lowercased()).png") + if item.type == .url, let icon = item.icon, let image = PushIcon.image(icon) { + return Image(decorative: image, scale: 1) } - - // Fall back to Google favicon service - guard let urlHost = URL(string: item.url)?.host else { return nil } - return URL(string: "https://www.google.com/s2/favicons?domain=\(urlHost)&sz=256") + return FileTypeIcons.assetName(for: item.ext).map { Image($0) } } } diff --git a/apps/ios/PushThat/Views/SwivelCheckView.swift b/apps/ios/PushThat/Views/SwivelCheckView.swift index 18740d8..12fcaf1 100644 --- a/apps/ios/PushThat/Views/SwivelCheckView.swift +++ b/apps/ios/PushThat/Views/SwivelCheckView.swift @@ -2,9 +2,12 @@ import SwiftUI /// Custom 3D flip checkbox that shows a favicon on the front and a checkmark on the back /// Matches the Android SwivelCheckView behavior +/// +/// It takes an image the caller already holds, not a URL: drawing a row fetches nothing +/// (docs/e2ee.md, "Icons"). `fallbackIcon` is drawn when there is none. struct SwivelCheckView: View { let isChecked: Bool - let imageURL: URL? + let image: Image? var fallbackIcon: String = "link.circle.fill" @State private var rotation: Double = 0 @@ -40,23 +43,12 @@ struct SwivelCheckView: View { private var frontSide: some View { Group { - if let url = imageURL { - AsyncImage(url: url) { phase in - switch phase { - case .empty: - ProgressView() - .frame(width: 40, height: 40) - case .success(let image): - image - .resizable() - .aspectRatio(contentMode: .fit) - .clipShape(RoundedRectangle(cornerRadius: 8)) - case .failure: - fallbackIconView - @unknown default: - fallbackIconView - } - } + if let image { + image + .resizable() + .interpolation(.high) + .aspectRatio(contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 8)) } else { fallbackIconView } @@ -83,27 +75,17 @@ struct SwivelCheckView: View { #Preview { VStack(spacing: 20) { - // Unchecked state - SwivelCheckView( - isChecked: false, - imageURL: URL(string: "https://www.google.com/s2/favicons?domain=github.com&sz=256") - ) - .frame(width: 40, height: 40) + // Unchecked state, with no icon: the placeholder + SwivelCheckView(isChecked: false, image: nil) + .frame(width: 40, height: 40) // Checked state - SwivelCheckView( - isChecked: true, - imageURL: URL(string: "https://www.google.com/s2/favicons?domain=github.com&sz=256") - ) - .frame(width: 40, height: 40) + SwivelCheckView(isChecked: true, image: nil) + .frame(width: 40, height: 40) // With file icon - SwivelCheckView( - isChecked: false, - imageURL: URL(string: "https://pushthat.link/files/pdf.png"), - fallbackIcon: "doc.fill" - ) - .frame(width: 40, height: 40) + SwivelCheckView(isChecked: false, image: Image("file-pdf"), fallbackIcon: "doc.fill") + .frame(width: 40, height: 40) // Interactive demo SwivelCheckViewDemo() @@ -116,11 +98,8 @@ struct SwivelCheckViewDemo: View { var body: some View { VStack { - SwivelCheckView( - isChecked: isChecked, - imageURL: URL(string: "https://www.google.com/s2/favicons?domain=apple.com&sz=256") - ) - .frame(width: 64, height: 64) + SwivelCheckView(isChecked: isChecked, image: Image("file-zip")) + .frame(width: 64, height: 64) .onTapGesture { isChecked.toggle() } diff --git a/apps/ios/PushThatNotificationService/NotificationService.swift b/apps/ios/PushThatNotificationService/NotificationService.swift index 74fa510..09e4834 100644 --- a/apps/ios/PushThatNotificationService/NotificationService.swift +++ b/apps/ios/PushThatNotificationService/NotificationService.swift @@ -109,7 +109,7 @@ final class NotificationService: UNNotificationServiceExtension { let result = base.mutableCopy() as! UNMutableNotificationContent switch content { - case let .url(url, title, _): + case let .url(url, title, _, _): result.title = title.isEmpty ? (URL(string: url)?.host ?? url) : title result.body = url var userInfo = result.userInfo diff --git a/apps/ios/PushThatTests/E2eeVectorTests.swift b/apps/ios/PushThatTests/E2eeVectorTests.swift index 0ae6619..886aaa5 100644 --- a/apps/ios/PushThatTests/E2eeVectorTests.swift +++ b/apps/ios/PushThatTests/E2eeVectorTests.swift @@ -1,4 +1,6 @@ import XCTest +import ImageIO +import UniformTypeIdentifiers /// Checks `E2eeCrypto` and `SearchTokenGenerator` against vectors 1 to 6 of /// `packages/push-test/vectors.json` (docs/e2ee.md, "Test vectors"), byte for byte. The file is copied into the test bundle @@ -218,6 +220,7 @@ final class E2eeVectorTests: XCTestCase { let contents: [PushContent] = [ .url(url: "https://example.com/a", title: "", ext: ""), .url(url: "https://example.com/b.pdf", title: "Example \u{e9}", ext: "pdf"), + .url(url: "https://example.com/c", title: "Example", ext: "", icon: "iVBORw0KGgo="), .file(PushFile(name: "Report (final) v2.pdf", mime: "application/pdf", size: 3_145_729, ext: "pdf", fk: E2eeCrypto.newFileKey(), np: E2eeCrypto.newFilePrefix())), .file(PushFile(name: "old.txt", mime: "", size: 0, ext: "txt", fk: nil, np: nil)), @@ -245,7 +248,7 @@ final class E2eeVectorTests: XCTestCase { let long = PushContent.url(url: "https://example.com", title: String(repeating: "t", count: 5000), ext: "") let opened = try XCTUnwrap(PushContent.open(keys: keys, userId: keys.uid, pushId: "p", type: "url", keyVersion: 1, enc: PreparedPush(content: long, keys: keys, pushId: "p").enc)) - guard case let .url(_, title, _) = opened else { return XCTFail("not a URL push") } + guard case let .url(_, title, _, _) = opened else { return XCTFail("not a URL push") } XCTAssertEqual(title.count, PushContent.maxTitleLength) let absurd = PushContent.url(url: "https://example.com/" + String(repeating: "a", count: 13_000), title: "", ext: "") @@ -254,6 +257,45 @@ final class E2eeVectorTests: XCTestCase { } } + /// `icon` is optional: absent and "" both read as none, and none is not written, so a + /// push without one is the same plaintext as before icons. + func testURLIconIsOptional() throws { + let without = try XCTUnwrap(JSONSerialization.jsonObject(with: PushContent.url(url: "https://example.com", title: "T", ext: "").json()) as? [String: Any]) + XCTAssertNil(without["icon"]) + let empty = try XCTUnwrap(JSONSerialization.jsonObject(with: PushContent.url(url: "https://example.com", title: "T", ext: "", icon: "").json()) as? [String: Any]) + XCTAssertNil(empty["icon"]) + let with = try XCTUnwrap(JSONSerialization.jsonObject(with: PushContent.url(url: "https://example.com", title: "T", ext: "", icon: "AAAA").json()) as? [String: Any]) + XCTAssertEqual(with["icon"] as? String, "AAAA") + + XCTAssertEqual(try PushContent(json: Data(#"{"url":"https://example.com","title":"T","ext":""}"#.utf8), type: "url"), + .url(url: "https://example.com", title: "T", ext: "", icon: nil)) + XCTAssertEqual(try PushContent(json: Data(#"{"url":"https://example.com","title":"T","ext":"","icon":""}"#.utf8), type: "url"), + .url(url: "https://example.com", title: "T", ext: "", icon: nil)) + XCTAssertEqual(try PushContent(json: Data(#"{"url":"https://example.com","title":"T","ext":"","icon":"AAAA"}"#.utf8), type: "url"), + .url(url: "https://example.com", title: "T", ext: "", icon: "AAAA")) + // The icon is not searched. + XCTAssertEqual(PushContent.url(url: "https://example.com", title: "T", ext: "", icon: "AAAA").searchText, "T https://example.com") + } + + /// A retitle whose envelope would be over the bound with the icon writes the title alone. + func testRetitleDropsAnIconThatDoesNotFit() throws { + let keys = try vectorKeys() + let icon = String(repeating: "A", count: 5464) + let url = "https://example.com/" + String(repeating: "a", count: 9000) + let sealed = try PushContent.sealURL(url: url, title: "T", ext: "", icon: icon, keys: keys, pushId: "p") + XCTAssertEqual(PushContent.open(keys: keys, userId: keys.uid, pushId: "p", type: "url", keyVersion: 1, enc: sealed.enc), + .url(url: url, title: "T", ext: "", icon: nil)) + + let fits = try PushContent.sealURL(url: "https://example.com", title: "T", ext: "", icon: icon, keys: keys, pushId: "p") + XCTAssertEqual(PushContent.open(keys: keys, userId: keys.uid, pushId: "p", type: "url", keyVersion: 1, enc: fits.enc), + .url(url: "https://example.com", title: "T", ext: "", icon: icon)) + + let absurd = "https://example.com/" + String(repeating: "a", count: 13_000) + XCTAssertThrowsError(try PushContent.sealURL(url: absurd, title: "", ext: "", icon: icon, keys: keys, pushId: "p")) { error in + XCTAssertEqual(error as? PushContent.ContentError, .tooLarge) + } + } + // MARK: - Vector 4: the blind index func testSearchVector() throws { @@ -479,3 +521,135 @@ final class E2eeVectorTests: XCTestCase { XCTAssertEqual(PushFile.ext(of: "README"), "") } } + +/// The icon's capture and display (docs/e2ee.md, "Icons"). No network: the candidates are +/// read from a string and the images are made here. +final class PushIconTests: XCTestCase { + private let page = URL(string: "https://www.example.com:8443/dir/page?q=1#top")! + + func testCandidateOrder() { + let html = """ + <html><head> + <LINK href='/a.png' REL="Shortcut Icon"> + <link rel=icon type="image/svg+xml" href="/vector"> + <link rel="icon" href="logo.SVG?v=1"> + <link data-x="a>b" rel="apple-touch-icon" href="https://cdn.example.com/t.png?a=1&b=2"> + <link rel="apple-touch-icon-precomposed" href="javascript:void(0)"> + <link rel="stylesheet" href="s.css"> + <link rel="icon" href="//other.example.com/i.ico" /> + <linkx rel="icon" href="/not-a-link.png"> + <link rel="icon" href="/a.png"> + <link rel="icon" href="data:image/png;base64,AAAA"> + """ + XCTAssertEqual(PushIcon.candidates(html: html, pageURL: page).map(\.absoluteString), [ + "https://cdn.example.com/t.png?a=1&b=2", + "https://www.example.com:8443/a.png", + "https://other.example.com/i.ico", + "https://www.example.com:8443/favicon.ico", + ]) + } + + func testCandidatesResolveRelativeToThePage() { + XCTAssertEqual(PushIcon.candidates(html: #"<link href="icons/x.ico" rel="icon">"#, pageURL: page).map(\.absoluteString), [ + "https://www.example.com:8443/dir/icons/x.ico", + "https://www.example.com:8443/favicon.ico", + ]) + // A <base href> is not followed. + XCTAssertEqual(PushIcon.candidates(html: #"<base href="https://elsewhere.example.com/"><link rel="icon" href="x.ico">"#, pageURL: page).first?.absoluteString, + "https://www.example.com:8443/dir/x.ico") + // No links: only the origin's favicon. Not http: nothing. + XCTAssertEqual(PushIcon.candidates(html: "", pageURL: URL(string: "http://example.com/a/b")!).map(\.absoluteString), ["http://example.com/favicon.ico"]) + XCTAssertEqual(PushIcon.candidates(html: "", pageURL: URL(string: "ftp://example.com/a")!), []) + } + + func testAtMostEightLinksThenTheFavicon() { + let html = (1...20).map { #"<link rel="icon" href="/\#($0).png">"# }.joined() + let candidates = PushIcon.candidates(html: html, pageURL: page).map(\.path) + XCTAssertEqual(candidates, (1...8).map { "/\($0).png" } + ["/favicon.ico"]) + } + + func testEncodeScalesDownKeepingAspect() throws { + let icon = try XCTUnwrap(icon(PushIcon.encode(png(width: 200, height: 100)))) + let image = try XCTUnwrap(PushIcon.decode(icon)) + XCTAssertEqual([image.width, image.height], [64, 32]) + XCTAssertLessThanOrEqual(try XCTUnwrap(Data(base64Encoded: icon)).count, PushIcon.maxBytes) + } + + func testEncodeNeverScalesUp() throws { + let image = try XCTUnwrap(PushIcon.decode(try XCTUnwrap(icon(PushIcon.encode(png(width: 16, height: 16)))))) + XCTAssertEqual([image.width, image.height], [16, 16]) + } + + /// Gray noise is over 4096 bytes at 64 pixels and under it at 32. + func testEncodeFallsBackTo32() throws { + let icon = try XCTUnwrap(icon(PushIcon.encode(png(width: 64, height: 64, noise: .gray)))) + let image = try XCTUnwrap(PushIcon.decode(icon)) + XCTAssertEqual([image.width, image.height], [32, 32]) + XCTAssertLessThanOrEqual(try XCTUnwrap(Data(base64Encoded: icon)).count, PushIcon.maxBytes) + } + + /// Noise in every channel, alpha too, is over 4096 bytes even at 32 pixels. + func testEncodeGivesUpOverTheBound() { + XCTAssertEqual(PushIcon.encode(png(width: 48, height: 48, noise: .everyChannel)), .tooLarge) + } + + func testEncodeRefusesWhatDoesNotDecode() { + XCTAssertEqual(PushIcon.encode(Data("<html>not an image</html>".utf8)), .undecodable) + XCTAssertEqual(PushIcon.encode(Data(count: PushIcon.maxDownloadBytes + 1)), .undecodable) + } + + /// Display opens only a PNG within 64 by 64. + func testDecodeChecksTheBounds() throws { + XCTAssertNotNil(PushIcon.decode(png(width: 64, height: 64).base64EncodedString())) + XCTAssertNil(PushIcon.decode(png(width: 65, height: 10).base64EncodedString())) + XCTAssertNil(PushIcon.decode(jpeg(width: 16, height: 16).base64EncodedString())) + XCTAssertNil(PushIcon.decode("not base64")) + XCTAssertNil(PushIcon.decode("")) + } + + // MARK: - Images + + private func icon(_ encoded: PushIcon.Encoded) -> String? { + if case let .icon(icon) = encoded { return icon } + return nil + } + + private enum Noise { case none, gray, everyChannel } + + /// A test image: flat gray, or noise from a fixed seed, so every run makes the same one. + private func image(width: Int, height: Int, noise: Noise) -> CGImage { + let context = CGContext(data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: width * 4, + space: CGColorSpace(name: CGColorSpace.sRGB)!, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)! + let pixels = context.data!.assumingMemoryBound(to: UInt8.self) + var seed: UInt32 = 1 + for i in 0..<(width * height) { + seed = seed &* 1_664_525 &+ 1_013_904_223 + let byte = { (shift: UInt32) in UInt8(truncatingIfNeeded: seed >> shift) } + switch noise { + case .none: + pixels[i * 4] = 128; pixels[i * 4 + 1] = 128; pixels[i * 4 + 2] = 128; pixels[i * 4 + 3] = 255 + case .gray: + pixels[i * 4] = byte(24); pixels[i * 4 + 1] = byte(24); pixels[i * 4 + 2] = byte(24); pixels[i * 4 + 3] = 255 + case .everyChannel: + pixels[i * 4] = byte(24); pixels[i * 4 + 1] = byte(16); pixels[i * 4 + 2] = byte(8); pixels[i * 4 + 3] = max(byte(4), 1) + } + } + return context.makeImage()! + } + + private func png(width: Int, height: Int, noise: Noise = .none) -> Data { + encoded(image(width: width, height: height, noise: noise), as: .png) + } + + private func jpeg(width: Int, height: Int) -> Data { + encoded(image(width: width, height: height, noise: .none), as: .jpeg) + } + + private func encoded(_ image: CGImage, as type: UTType) -> Data { + let data = NSMutableData() + let destination = CGImageDestinationCreateWithData(data, type.identifier as CFString, 1, nil)! + CGImageDestinationAddImage(destination, image, nil) + CGImageDestinationFinalize(destination) + return data as Data + } +} diff --git a/apps/macos/PushThat/Models/PushItem.swift b/apps/macos/PushThat/Models/PushItem.swift index eb11503..4345991 100644 --- a/apps/macos/PushThat/Models/PushItem.swift +++ b/apps/macos/PushThat/Models/PushItem.swift @@ -20,6 +20,9 @@ struct PushItem: Identifiable, Equatable { let source: Source /// The push's `keyVersion`, which a retitle has to match. let keyVersion: Int? + /// An encrypted URL push's icon (`PushIcon`), nil when it has none. Kept as the + /// envelope holds it, so that a retitle writes it back unchanged. + let icon: String? enum PushType: String { case url @@ -77,7 +80,8 @@ struct PushItem: Identifiable, Equatable { type: PushType = .url, ext: String? = nil, source: Source = .plaintext, - keyVersion: Int? = nil + keyVersion: Int? = nil, + icon: String? = nil ) { self.id = id self.url = url @@ -88,6 +92,7 @@ struct PushItem: Identifiable, Equatable { self.ext = ext self.source = source self.keyVersion = keyVersion + self.icon = icon } /// A push document, opened with `keys` when it is encrypted. Nil for a document that is @@ -117,10 +122,11 @@ struct PushItem: Identifiable, Equatable { } switch content { - case let .url(url, title, ext): + case let .url(url, title, ext, icon): self.init( id: id, url: url, userId: userId, createdAt: createdAt, title: title, type: .url, - ext: ext.isEmpty ? nil : ext, source: .encrypted(file: nil, objectPath: nil), keyVersion: keyVersion + ext: ext.isEmpty ? nil : ext, source: .encrypted(file: nil, objectPath: nil), keyVersion: keyVersion, + icon: icon ) case let .file(file): self.init( diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-aac.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-aac.imageset/Contents.json new file mode 100644 index 0000000..b09a8c7 --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-aac.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "aac.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-aac.imageset/aac.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-aac.imageset/aac.png new file mode 100644 index 0000000000000000000000000000000000000000..229acde2be58dfaa68d92f1827325f92fb6571ab GIT binary patch literal 506 zcmV<W0R{evP)<h;3K|Lk000e1NJLTq001!n002M;0{{R3ggkFb0000>P)t-sOlfgX z_We)x{Rhs~2F}$6&eaCa)p!5@Px$`_&eTx&|NsC06w%mM-s9Z)`X|-f#`N|&+TVuf z>+1XbY~tpx@A8`K?tJCxKJN8NEv(7_000VfQchC<nx6l;MtuRD@%WSX!~g&Q32;bR za{vGf6951U69E94oEQKA0Y6DZK~#9!?bge3gD?yQP+CD!d}UjHLH_?sJ0{a6!4R8H zmuSIgj1NMFg}SaA`jK`+sjq9diVM99Rg!^fVomzGSdp<oyb@{=FQiR8lSc7GTE&Aj zi(k?%e#il#KCefG`nVnyOO=YasiI0nIJG7<Q4~>C!MCHLh*O;66s^KK%S*!6*~i+h z&;|e!6Hhq_F|P#x#%&8WFiC)kG$6?UE@v=+?9|}=&?(<`j)(&y2@K!YclvO87KDs- z^Z}lMm)%7l`JI74oR$RM0{V@xfJEmJ$ODshrYa)9`5H)&h{(Vb;NJ)hXd43pC5Uw? z?@qw1qtV-fm(O7kmNU(nCu{C0Z+C7dN!t3ENGF-B_W6uw5t(_r#-IMHQ=H<T#CQ;l wwF$Y1zBYvyG1MmgbzGKNzi;hj?1!%V20!F8tDQ=d?EnA(07*qoM6N<$g327~$N&HU literal 0 HcmV?d00001 diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-ai.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-ai.imageset/Contents.json new file mode 100644 index 0000000..b365aae --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-ai.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "ai.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-ai.imageset/ai.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-ai.imageset/ai.png new file mode 100644 index 0000000000000000000000000000000000000000..bc85e0f72727db2ba29df4b23f5ea87453f7ed6a GIT binary patch literal 437 zcmeAS@N?(olHy`uVBq!ia0vp^hCuAV!3-o@Z1TN;RA7Klh^tR#;p<KhAhOot2O=v? zAQJdrU;nPl6UesK5_s3`_5c6>7+(tqeNk^~<s<udES)#;{oB|5t!4R1{&#O(OANNV z)974ZlDsR=a!Qo0c2@}#&~UDjAirRSS@V8;bgN~WyK_mM*#@8}XMsm#F#`j)FbFd; z%$g$s6#U}p;uunK>+QAEdCZDDt%*T~p2y}Kyz^|*zW>c(QY&+rJU5C@-87^Af1+Z< z4GH6(X>rf)d`(()?o0SORpzfQzQ^{h=n(C2cM|&Okn(6A$5GjdUGYj{_AYw+I-Pd( zcZcnm?679t%u9mi*L_}g_+OvU_aWQ2s_9Q|XxB1lnMZ$F+YeX;HY^ghbJG{u<MZd! z9Q8$K7zAc4o1)hF!8nM+VF9NA=ck*(sVX}aqPEE}=!>Z)`s-LEo>SF!VH8{Bt0<=M zd78+f_va0>^UfKzoG}zSzT!{kdFvyJ|6C(fH|*uznfWI7p|5lP<@MI@cmLDa+bzqU bypQ3EVZ}fG`t|)l|1)^H`njxgN@xNA;={2Y literal 0 HcmV?d00001 diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-apk.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-apk.imageset/Contents.json new file mode 100644 index 0000000..71e9399 --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-apk.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "apk.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-apk.imageset/apk.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-apk.imageset/apk.png new file mode 100644 index 0000000000000000000000000000000000000000..e8092db4872e3a8639969a045c3a614af8328446 GIT binary patch literal 248 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE3?yBabR7dy5&=FTu0ENCKh`_GoNx7Zf#a=K z$NxJV-!F80Jke2Yo!Vod8kUkEzhH*n2HKTBOeKJP&H|6fVg?3oVGw3ym^B9^Smx>C z7*cWT?&X7g4GKKW0jpzw|F6$E(tUuX?^vA0heJ1*Zf_HD5tcFS(7Pdhz$(LqJ?bK} zvH?rVk@XBpoN@}6LKLj`F{|ZHS7jF{JsQN7a);rBH^YV}+)Gt;rq!NuscX*^cQ%l` oQgS7}g8%*89=rUPDQ_RomtV>wG3#WRBG3g4p00i_>zopr09smA(*OVf literal 0 HcmV?d00001 diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-avi.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-avi.imageset/Contents.json new file mode 100644 index 0000000..c3cd644 --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-avi.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "avi.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-avi.imageset/avi.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-avi.imageset/avi.png new file mode 100644 index 0000000000000000000000000000000000000000..08b8557281eb15824f499980ea6b05b5c7f0debf GIT binary patch literal 457 zcmV;)0XF`LP)<h;3K|Lk000e1NJLTq001!n002M;0{{R3ggkFb0000&P)t-sOlfid z|NohpnS+Caf`WpAf`WpAg1fuBe}8|2gM+-hy#N3I_4W0*xVWjQsm8{}iHV7mlarmD zo#^Q3+}zyL)6=gpK;{4d019+cPE!B@t^e^xeVqciBF*Vr00009a7bBm000XU000XU z0RWnu7ytkP7fD1xRCwC$*1K}VAPfaiXJqfjy5bH0|En3idF;Z(f+9s`d^?JRkZ80l z%eJrdYnbovOOqR&g?qA*CR(!Vq9N;mcqKF<UdWhuCWGRMjEWyJEWXLO_#zYHlgtRd zzv;qoS49z)yDEx^+!9TA(-KXPw`Njwxx^*@iHO-v3I|MzP|UgTG5VQ4LI!|j$%xdW zNHmhpgoETF5bps?#7JZyr?0YsC<2i(3Q+>9f`T=!-ftm306`OvKC18nTOXx~3}G9O z9tui=Q3b$6kFIU4<y9dlA?8Dn0GU+b>ct|5a1W@>%H-rcyHKa#T>bs%_<Xv0E^&!J z5$hyawGFw;u5IC6Hf_^yb31nZeYBT#-<IVA_na{v1((3g00000NkvXXu0mjfw_ecJ literal 0 HcmV?d00001 diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-css.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-css.imageset/Contents.json new file mode 100644 index 0000000..d63495d --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-css.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "css.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-css.imageset/css.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-css.imageset/css.png new file mode 100644 index 0000000000000000000000000000000000000000..56bca644d806acf9780163fade1e438e47fad684 GIT binary patch literal 290 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE3?yBabR7dyDgizru0ENCg0=re>wbyU{uTj} zb-#fO(b`|ab^k@`!R)%<&F2*pf$F(Sg8YIRWFH^d=25v-+{ZhmWVbF*n6tnmvKS~U z48n{Iv*t(u1$#YR978H@?Vaq+ci4c#g|Yro+PnJur+i}_eVZ=1%srY@(Im*RsD1^9 z-J(|vtN0Ejvn4PTuyC+51aUJwVQvWizKj2f<s#N&OYS$^I&po)dZW@wiyG9<=>#$` zato(F-oT))V)l=9Le#ZqdKYHv=a)Cu>C4p_IoGLP?=!Amp?V{wqMQ49ncMsNX<cQr Z=e>{=xvJUrfD!0022WQ%mvv4FO#lbWY0Urt literal 0 HcmV?d00001 diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-csv.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-csv.imageset/Contents.json new file mode 100644 index 0000000..df9c8ae --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-csv.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "csv.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-csv.imageset/csv.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-csv.imageset/csv.png new file mode 100644 index 0000000000000000000000000000000000000000..551320d993d3daa5e9308b74de1cd2f2a080ac51 GIT binary patch literal 3362 zcmeHKc~nzZ8o%#_5E9lFg$PyLC{~0}1yMnWf(2>{Q89$gh>oCwvIU5U<)M|dP{EN3 zvPfth5G=9_B3m?sDEm?&$QFsLvI!9aBxZunAD!tL&-^j{W6qg-&i%f7zwi6~-tXOW z?|JY3Kz6iK#p&Px0ICl5wub?L<wF=Kq2!K}wfU*s8IeiO2S{fE-*vZ0JKD4D&8m-+ zny&`6Mbm4%9ArJe$z-yp!N+Dek48%G@mbf!Qxlz0V+nE~hJM)b2r$)=m@cuRrfdS` zA#8xXYcK%HnkyXwDQS2BRK9nx-RI1BG1>PzJk>;t*haeh8(*<TCTvbR7+e@Hu-}4e z>R<Nv?B3V#J)fAK-uS+Si+pR%q}><q=zXyfNr0{P!(_G{3E&v&I3g5zM?Q0>%4gS< zi4Y+Q+9JTJ5g|IjWuVAlEdq~%8F&Va!U{lpJ$MKpHd+Z76F~q3Bgz`U0|A2fDI`9+ z%D@IJ!$jQgq8|g?Kxh}8u7OZA@sst)3GMIxE%ge?W(7(A#YBYyLqyN$j}Kp|a8?qx z^cU+Ro{+@;_u9xk>YD!|{Pg%pFNN}lSNMq7|2Q?;TQiTrpg+D=|I8*n1~FyD#jv<v zO~zz?1d4#Xq`iB_eJ&G$MM!JSj?IY71x5@2<NxM=W(~@AAOo3sl;_qs!NRjg^iO~V z#MT6sf4*T4sD7!!k@N<PnRHsVbXe|<@AP`;{|&g?ajH75Z0_!Pp{aSCSz{N$rs~Jg z+g+<YaBmqUAko|J5v!oAt~R$XKE&w95UYVKuSJ>Gy8(QQ+sn2Dur)Py$9(UD81^i+ zMKLkDLhGH2#Q7?w0+a3QmRzI*`*z5d)Qr?!QaS>7hgySt`HIJZjdy)>`IRWI#ud7H z&U4Gim)$xPV1m5f-F1tPZG2Xk7;RXY?u$;m$x(qK7Rx)1gu3_TxgLz=tkyVC@pglZ z4Al;W%ASQ$o=db{ZnppRQUUMQpj~M$AcK&(nj-b=+p-(Tmr}u#P3{!%Ia0r9&GJ{~ zL2u`1x9&aB(WuN9CJmIp>miS`BQ>H6jX6_df3&lgD!y3V&kec`W_gjX-51(TC|Kv0 zA*Y(m?gLxUQJ(o^_?#<fzRE!+YU^6MOJM-1jAYfNU*UEf<)>Owe{d<7L7^ba&LWUz zXq~W^mESUeZt0+!c}PZ=ldE1~R<TchgE^NOMjcZU2o1j*^r`I<`Bn_Ls^r*VGg=4z zL97h_S-H09rS3D@YP+{~A{7R47(Wjv0MuI`WyL#VMliO#b6t%nL#xs?Jv`r)%-6*h z-}<o+dU@$3-08E<3&_Ar*J;$0Pk)QP#`Nu7X9_a!%;zVBHk}vtHr9>);-ocbcAO+p zx^D<niL;~_M*X_^qNHBVpn<BjTMSFwU^93i_w8q2;gR-3%hEJ`^Po?OXL;44R>G#b zG`A5w#f$(Y@xw>V>00I&u?uy<Q?53Evt+|b>a2v>U=;=7Nk@KqQJ<_|QS_WsFW#N% z0`Q1vA)E8UavP~Gx_ishWeF_ob6D2=1+4mfA|>M0rk@y<bp&r(Oc&y$haQ^$23Bf_ z2w0$$<TDh=OKD+#QfgK~LW)az5XTCG&X~#ZqlbQ_MqF$V6oxo<_P-os5kWlHpFiXK z(}nPfKu7HwSDmgrMP=PY+KDNFEeL$`r694XEHtQl*HM}mUTx9J-u`^UAp;!I(m^*a z<FJo*-*#<GzRF@X0jqu6$R#5I(Dx<>1wIA)!gu4fXeUb|`J%^Hz|Kjcp`!^&mzpA+ zvEz$S*MV~cAvJ$krPzHu=Sh{Mr{*dXq)zSIh+G5X#@nB2DA9(=b%kfp8f*%9kd&ND zJFAli`4fV+$r6AYR1$f1Axx8krCN{v*gx!FbI(?Iu0nY=mdG`D9CGy7*`iQhl+yTp zF`<V7Rw2`_u;XfTrA3QWL--dn!QmUFLuFKm4qvZ4K&G%Ch_-I>(Ve#0f>;5^yXD@5 z0?F9P5=vEN8|sv<%L-h)el(b~<jK3?q$6P#u4f2w>ygRSf@kd6n;vaCWd07nDorCS z+dN{;s&j3b2lppbAg8mKtp{G~N0ETEM$OnPcOx~uoJuko9`4h|5xLk6o*LVXF%Qhs zQ}%1QTXRo9*}L>}os!U~2fl3lTGV-7EKGiLNI<`oJLZ_nb;4?^?=^1ozz`yrp=~(! zbyiHT!_PSeMiUB&cFL4rNAC?a83JV@S9N}S{K0q6qT9Q!#^#$c$}lYRuw4;ekUdQ& z@sY{q6u#s(EfG>62;}ElzOlGJ2M>FV&CLyZvS$l@#;*)b8UhvK{Ok;4ZlI>3gNJ?d z7h}}Z-o|@8Lcun0_f;P)NSn^QGUPAq@D6eK8=#~fNu9k>VPh}WJyx=f$+=&3Nz<3e zT#YDZL?mQ8S-jeCfnOc2KRqbyw2C@LLp_T4&H+^rp8D|Mn~PH38uv_*+mvF4`i^2; zMnf6Z6d0Pvcr11CXj;l)=hkhHce7UY3Q-*`l>a`N4s6}I>$G7XeQM5($PMOFXk+6! zS$l_Bn2bg5wj)i{vcQz#3vS}Wj7zerQbp13_$W-qTeaz=xRXQGmpyiuXSd||S%Pbk zCMTwLQD!GAwlk6z4dBym3UzTO_48C1XD-Ov$4EOjBGJ}5N7D+T3!BGKVs4?u?M9M9 zYXMz-Ew4T@w(OyzC^_<TWZX5?BJSuLqp9}Rs^(MPaCX{*av}BTz#t(PsxtP8g0mNe zKmY0?a(TC*JtoJUiVDB|>}&M(ntRr7Mufgo8sreow%O*)9<B-%bSsAkFTWOXAKx?x zT+N&e-M0zPe)2`nbmsI_BL4M-CEu`K?x70L2BKm?#LKUmL<V(xSpLsDSW;a(nBN*P zX-?z3rKC$s3I58cP<KdSCg_s^Y3;R;urR4*rvwpnTD=&fuX(a0oq-tx?S#mQu=EkH zxuvD;ouWPb`L=5FKkd_5$-)Run5oLPAa|{0M!!i=uAUYs#*z_osYjRS!0zuscT52s zJl}78YaXMar!-OJX|fg~ZazLed3j6&Tt4-FfCuZ)?=uSQD@awDa@HNd2>+CAycWam zvU$7`KtbXnmG>oBwhwgCUz@~)XFl*3f(Rx4#fsy5@W{f4Vh+U6ELaGAOt%F24evET zQ00f3jmSUH3Ns>+&x(i)@a?2r6c(fd!GFQ@?}Kh7%Uk!MWj*0dV-MBAZod4Y0vrxF K+UD&)e);d1p;4Fs literal 0 HcmV?d00001 diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-doc.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-doc.imageset/Contents.json new file mode 100644 index 0000000..0822a82 --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-doc.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "doc.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-doc.imageset/doc.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-doc.imageset/doc.png new file mode 100644 index 0000000000000000000000000000000000000000..ae4112e5dcfb32091e9a36ce8da14916251aadb8 GIT binary patch literal 515 zcmV+e0{s1nP)<h;3K|Lk000e1NJLTq001!n002M;0{{R3ggkFb0000~P)t-sOlffd z|NjPl%LabR1%Jy1f6IHx_kaKYd&u|(e#!=Z%LaeTe98I$|NlFi*C>(GqR;He<MiwG z`-Q;fRH)q+h|UXw&G-BMbGPEz>-V_Z@oc~I0`^pF0000BbW%=J00Db{x$&I>oJRO? ztSc!1000SaNLh0L01FcU01FcV0GgZ_0003#Nkl<Zc-rmO%WlIU5Cu@TwVPOQd4qBC z|NmJHvD?HZ0Tz+E@Li3Nj|Rq@>AG(02lh0apHJN;U+h)*o{X#{wxlnM4H+uLBVje- zfz*k6QYr38t@tC=;+NEml{5(ceQFi_{81KHzU$H^s7pZzz9l6QwoQ0RFz>F7VwXc4 z;(rQhjA#_Vdxex?6<!FxoO&TcUPuj0DhYo9)qXjJi8&Ewz)T<!G++#%81n|04UEhP z2Ur#?2?s>REFf7-J)_L^urOhH`K|QiF1S2>xq@D2r7+XU1j{sCtwF?>!ea7^h^Z8S zTX9wh;Ns#;LIT>k8OGBPlylmKj9~N+XucwiiQ>{_DU-;FV70d1TAHL&TC3YvsmxBd zHitOGTg1=^hSG+-OJCZ;cNt5Y{x+X!*Y9iJGW28D{Qz^}hiI)saI*ja002ovPDHLk FV1k$+>sSB) literal 0 HcmV?d00001 diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-flac.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-flac.imageset/Contents.json new file mode 100644 index 0000000..407a489 --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-flac.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "flac.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-flac.imageset/flac.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-flac.imageset/flac.png new file mode 100644 index 0000000000000000000000000000000000000000..1fa3e81484afe78769619c1d073974e7cc898a0b GIT binary patch literal 472 zcmV;}0Vn>6P)<h;3K|Lk000e1NJLTq001!n002M;0{{R3ggkFb0000*P)t-sOlfg) zLD-vh>2pHZb3xd0LD-{z?0<j%qJZvlLD-~$?*IS)hh5%~Y2o$p{HcoQ<=ptXqU_DQ z@qbd>+s^f#dg_~a<H?pEy#N3J3UpFVQviLS|G7q*0-f=HZJlud000SaNLh0L01FcU z01FcV0GgZ_0003aNkl<Zc-rmO&2ob<3<O{@g0^`5C)@D;kJ`a4X<|YpAKDXpWadL& zoiq%?G|qGyV?N%8CO7&m=13-zL`z0lG-OtYXF?kBM0DbjD8&QOiXWmDccK?N=@8$f zN0>K-Sj=IHAXamjA_!{^L=f$>j1dL7rB}4M#3lYgWS6^yEdYY|RxJ_`y#w^ZgNeEX z5bnad-DPJ_g!O>;YZbu*K-#-`mW1_)sQ^s5e#FBvPF|HmT|^-U98t(52_hG`AW}im zHUH{?NU@fYg#$`(sY5V8M2twA(ue@8mOUaFAgxPbN#t51ZvRiU(6+Ewg4q#e=1ew~ zvO3;&98xUFp3G3?5|{WBF?WJlx{wbUr7Qf9NxJmg-0H1AYP-ziGz_0glr!0}JTPSd O0000<MNUMnLSTYREYDK_ literal 0 HcmV?d00001 diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-gif.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-gif.imageset/Contents.json new file mode 100644 index 0000000..7710491 --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-gif.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "gif.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-gif.imageset/gif.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-gif.imageset/gif.png new file mode 100644 index 0000000000000000000000000000000000000000..bdbe2cddad286a5e720349c1000167deebb282bb GIT binary patch literal 451 zcmeAS@N?(olHy`uVBq!ia0vp^hCuAV!3-o@Z1TN;lv99Dh^tR#;j2Ro|Nk?*KEwcI z0LdS>gZ}@o2a;fR$p8QU|NVOL`I^tK_q%^QpZ)*ej}PYzzuqf+dtCI}<Brd_l3wwY z9{`%hRTAVE%uvht|D)S%rnx(<tYVe|ML7#RB8wRqxP?KOkzv*x2?hp6K~ERQkcwMx zubs_nG2mf&ki&a>QsKYDzxQXc-py^y*tl5k7IX1`ld_fxy@roXE}V|2t~YPL^Fn8v zs<P*yx;H0Px|t<KlKefC%UsmAxoDcXXxDW_)d=scn8f!{!S>L-`za4|E?-igCg!M8 ztuiUYYmJ7+&bY!es(K&Il|<Ki>=O#?bqMX^60mHznQ845Cvm!Z%G(EwaY9F#H?ozP z7*Fo+kU7`NZoyPAWuXu!hs)<+jb*1gPK(Jt`e+s_yElpL(yrd9DI9!uWgN4nFaK)W z6m)K#j?z`1C$DtMmaOkLxVU;tb;8{4D@q0eJH@s}h|Yah|3^~qBe!8mljE)lvwbd! q-b<OH_cuB3^;-MPa>->EXZVXo8gZ}x>vbI%Vho<HelF{r5}E+6L&N3( literal 0 HcmV?d00001 diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-html.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-html.imageset/Contents.json new file mode 100644 index 0000000..89e5132 --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-html.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "html.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-html.imageset/html.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-html.imageset/html.png new file mode 100644 index 0000000000000000000000000000000000000000..aa4b93f98729508bceb7b925ace136cdff180c8a GIT binary patch literal 420 zcmeAS@N?(olHy`uVBq!ia0vp^hCuAV!3-o@Z1TN;lyQJhh^tR#;s5{tcb__c|J6Gn z2}bAczI+E3JO?Dd|M+?I(zRQUo?f_p@BXuwPhY={Fq!-wsGX}M$S;_I@&EsiJ8S1M zxm{Xe`Vc6}S>O>_%)r1c48n{Iv*t)JFfcNDx;TbZ+<JTMblwpK9@c=Y6F!gGpZ(vz ztto95kH*G{yJon)V`4q>P;pZMM`End9JOQ5%U^tISbb#TnuN}II@?}&6m^s{$$fOI zdE_zYk;9%O^}LVne;&E29dS!@sj0i^^ms$Jq6*(`38mxnA{N<)^(<>xvw6{Z-Mo*9 zO&ak&%99)w_PJ=<aP1M;6WyL*$F=URS(zG_(zKoq4=0tbjzcXTHA(_2&)qz7ZRxFP z#uHz6KKULJa{JCTeGhT-<*^yNg_u`_pK#}N&^Z&l$d22xC95|2BF_SsrL_gpXS5Vf zHP85eJHN2|zeuK#%4#RMO%b6#CR|<h<+n}L+Wi&pCrvYLkza5|WzLTkGAn^0#Ng@b K=d#Wzp$P!H)xuH$ literal 0 HcmV?d00001 diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-indd.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-indd.imageset/Contents.json new file mode 100644 index 0000000..730ce27 --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-indd.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "indd.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-indd.imageset/indd.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-indd.imageset/indd.png new file mode 100644 index 0000000000000000000000000000000000000000..1c1acd814edee1bc4b35b7b43efe84d722e99c9a GIT binary patch literal 232 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM3?#3wJbMaAX$AO%xcX!kPV!nm*=NJ-$Zh>j z>w2Bn0vUZSYu|kM`1rx=&27im*BqG<yyfwOS8nX5_W*UVl?3?(GaP;H<>{f|rQ-|~ z;VkfoEM{Qf76xHPhFNn!HU)XQIEGZ*I(z;!UxR{xbKsf?wk!Yt$3{x%_6yvsoNjwk z<?)>bqBT(>Dd_<Vo4h!cME5KzSh3+{c!Ki=OONGW72nNi5!t=m&c37SIIrA7j_5dj YEq~5N_JFkufTlBey85}Sb4q9e07l7GQ2+n{ literal 0 HcmV?d00001 diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-jpeg.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-jpeg.imageset/Contents.json new file mode 100644 index 0000000..9dff12d --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-jpeg.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "jpeg.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-jpeg.imageset/jpeg.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-jpeg.imageset/jpeg.png new file mode 100644 index 0000000000000000000000000000000000000000..9345579ac9f55ef698e73b9a9d09b6fd41abaac7 GIT binary patch literal 494 zcmV<K0TKR*P)<h;3K|Lk000e1NJLTq001!n002M;0{{R3ggkFb0000;P)t-sOlfiA zg#hA&0OExJ;)DR=g#iD}iU0q9|Idozg#iE0i~s-s{`&OhlpgQ5XZq#I^U9Cupf~s4 zx%Jhj{PE!N!G7$kP5j7xF?Y?M0000AbW%=J0DFJ`@kW{gow-Ox!RP=0010qNS#tmY z3ljhU3ljkVnw%H_00A~hL_t(|+U?fMZp0uA1<<rk+J?&`Jm>%av?7^y5)j37HH-0z zARXar5s7J<mUX6P3Gwwg74=Skm8i)=JyDXiFA6d<#FY?7TnJB`2~(U1S3C$?{1U$S zAp_!0Muhlah?}TR=?E>VQ#vAvigZNTmJnS*`Zg-cdWn~KN|Br%6dqwvSfBozL)miC z5K%g!AhiKxPNepQk*X;+7$ONE0I);>2>G)soFXORNdr1>_J!WZX6~c2Oc4>t!4HTH zfjr!R2ynC^Pf0U`jLGNF76_Z;%qkBwukx4x!|5tI7msTbwM}iI$mFO;j^hp}BIO_( zw?yS`;#pW=?=Ut*0%V^5Nq7lJbNOekIgWOfs`r}78TArRCgwpf_g=_Vt-V)xRZH)s kU)J0H)?c|<=5?8-Zwp#90}q#uegFUf07*qoM6N<$f*>~9W&i*H literal 0 HcmV?d00001 diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-jpg.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-jpg.imageset/Contents.json new file mode 100644 index 0000000..eee8df3 --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-jpg.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "jpg.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-jpg.imageset/jpg.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-jpg.imageset/jpg.png new file mode 100644 index 0000000000000000000000000000000000000000..9345579ac9f55ef698e73b9a9d09b6fd41abaac7 GIT binary patch literal 494 zcmV<K0TKR*P)<h;3K|Lk000e1NJLTq001!n002M;0{{R3ggkFb0000;P)t-sOlfiA zg#hA&0OExJ;)DR=g#iD}iU0q9|Idozg#iE0i~s-s{`&OhlpgQ5XZq#I^U9Cupf~s4 zx%Jhj{PE!N!G7$kP5j7xF?Y?M0000AbW%=J0DFJ`@kW{gow-Ox!RP=0010qNS#tmY z3ljhU3ljkVnw%H_00A~hL_t(|+U?fMZp0uA1<<rk+J?&`Jm>%av?7^y5)j37HH-0z zARXar5s7J<mUX6P3Gwwg74=Skm8i)=JyDXiFA6d<#FY?7TnJB`2~(U1S3C$?{1U$S zAp_!0Muhlah?}TR=?E>VQ#vAvigZNTmJnS*`Zg-cdWn~KN|Br%6dqwvSfBozL)miC z5K%g!AhiKxPNepQk*X;+7$ONE0I);>2>G)soFXORNdr1>_J!WZX6~c2Oc4>t!4HTH zfjr!R2ynC^Pf0U`jLGNF76_Z;%qkBwukx4x!|5tI7msTbwM}iI$mFO;j^hp}BIO_( zw?yS`;#pW=?=Ut*0%V^5Nq7lJbNOekIgWOfs`r}78TArRCgwpf_g=_Vt-V)xRZH)s kU)J0H)?c|<=5?8-Zwp#90}q#uegFUf07*qoM6N<$f*>~9W&i*H literal 0 HcmV?d00001 diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-js.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-js.imageset/Contents.json new file mode 100644 index 0000000..a1c5520 --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-js.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "js.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-js.imageset/js.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-js.imageset/js.png new file mode 100644 index 0000000000000000000000000000000000000000..6fca286d20742d4fd9dfdaf133e2acd78f5746cc GIT binary patch literal 444 zcmeAS@N?(olHy`uVBq!ia0vp^hCuAV!3-o@Z1TN;lwW{Ph^tR#p_Z1GwzhV}ekmXW z0>bx6*4Hxt+5i9l58o?QQBm>!{rkSYJ{ubw4-b#v;NVA(9$mO_A!v=vk|j$H960dn z*RQm+G?#kCO|xgu1e(lM666=mkp2JvN4MI!OgsNHi`@c>au#?*7Bet#3xhBt!>l<H zK*4{WE{-7;x87bm-Pdfu)B5lPXRhVZoerE|{>GQMm`)dRNj|@7>W}a9dp0|m9Wz<G z`0l!IHHNP0RadX(c{rN}PTHo&^@t_O*F!1HMQvVZfQ{hFAIeD|y`Ma4*EnLnl4JKP z9f9plF<X*m1__q$R$L;Jw7Th?`C1L@BZ~LJkJxCnvNshSd+~p}yGNjwRoWsGh6H_) z868yx?+ym)C{5_twP1$f!tb7Yjw{(PeA(<2AXS~<#kEnPZ92F8nZmS^Pur7h#5g}m z9(=A?eepG$)WI$H!#=R2^mYgvxb#NYy-uC~Io;w&UxC+yEb&jvww1N!XJt*VyZN&4 i_4Z4#F}Ec?v%a)dXqk9IV?Qt?7(8A5T-G@yGywo@U%7z* literal 0 HcmV?d00001 diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-mov.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-mov.imageset/Contents.json new file mode 100644 index 0000000..acc0c7c --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-mov.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "mov.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-mov.imageset/mov.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-mov.imageset/mov.png new file mode 100644 index 0000000000000000000000000000000000000000..dd413f79184662b0ccd67871b7309060fc7e5b19 GIT binary patch literal 475 zcmV<10VMv3P)<h;3K|Lk000e1NJLTq001!n002M;0{{R3ggkFb0000&P)t-sOlfg; zxVLq;w|2O<b-1^6xVQiR|E=5FfB*k=xVEp{+yDRnjl{$9`S`-%->=)+<?`{i+1Y@; zz1ZvPr_$1y%gVZS*C_x1019+cPE!DWdjCf80i6Q5h0b!f00009a7bBm000XU000XU z0RWnu7ytkPDM>^@RCwC$*4=W0AP@y$+Y_70VOM_N|5ZECCQZ-=yy}f-#$nd+13@o# znx@M#<Ik+-`Z+au!>g=jLoVovmMneIka<8n6O4!_GA16$pm-po;+G7II~f-@azOl$ zBf?xij*7ZUM<ip~RXQS@mh{AC5z`eRuLs2@r#Qta21T#|fVH>Ez7$TplJwS*Vu3PJ zJd#M!BDNJ$9;la)iXfy2=>wSnPr;5zNTjofjPPUzBBfSv^gF@>nY4h?eLq+0M8ekl zd&1s@qoURs!sO|2Kq#Z*+7+?Rx{{&1S{ZsGk(!g#-3aT@5r}2x`emT?fH%Tx27+n; zvX-Mv(M7E%WL5w3Pdvpb_QiY<%)JhIlcm>%Z*u8%`fc88ufL4$WnM1R^aY$-G%Q?u R3uFKQ002ovPDHLkV1lo2;D`VK literal 0 HcmV?d00001 diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-mp3.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-mp3.imageset/Contents.json new file mode 100644 index 0000000..7897e4f --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-mp3.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "mp3.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-mp3.imageset/mp3.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-mp3.imageset/mp3.png new file mode 100644 index 0000000000000000000000000000000000000000..c1ce20e46406fcfdcf07594a0e4d83b5a1719a71 GIT binary patch literal 565 zcmV-50?Pe~P)<h;3K|Lk000e1NJLTq001!n002M;0{{R3ggkFb0001EP)t-sOlff} zCEY9~-7O{FEF|77B;Ssu{fUYHkf;AFB;Jpv{VgQkEhXNOsQ>@}|8Z;SG%VmhIN{97 z_hDS;kf;BtrSj<K{D*_>Ohx1P`2O+m{hF5WSW)GBc<kBN`M$gLgqQo*_k_y;000bh zQchC<xJ0^rNSXni@t(Wcvm|{0000SaNLh0L01FcU01FcV0GgZ_0004CNkl<Zc-rmO z*-pbS5Czb5ffATpZ;j(6OaK2X(Ubyh5^DPikb0jY%g5u9Bg<7)J?(1xF~{cRP|dQZ zdpR}>@<b^yCvRynBXx$jC6ps>NS?SRnc|A%if@uFzDU0KBn6^*J(P;(`H&W;8*#2u zCb&vMm}X8=Le7gcDM4~L6^cb3;vxQxP<^jTM6UrryKJEX&arY;xSq$|Wj)#Rhp>ZE z8tCUjg&-!t8)E@2MHo8|jE;yG=>WG7fQJRF_W>9L0B?bjYbjx&X+_wcT40jWA_}0B z6ahm#=v%cW3|9|Cq|pk1SNG`70#@V+vWUDZqv2hi2rT$!1ctPb=ZQjgl8D<%3q1@L z-dK#W4QuDI5T1{^Q{%^(w~NkYjE<;}yze0j0k~FAg74i)PpwJ*iI>jT@{2+`S19h} zA^w=C3qf7&3dJV%3SVTKdg;%z=Xc+BliN$Z*;ds%0b7<r^~C_W00000NkvXXu0mjf D!D#~W literal 0 HcmV?d00001 diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-mp4.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-mp4.imageset/Contents.json new file mode 100644 index 0000000..157d36a --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-mp4.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "mp4.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-mp4.imageset/mp4.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-mp4.imageset/mp4.png new file mode 100644 index 0000000000000000000000000000000000000000..9d64a53a85d9a5aff3b64ac3b485b726e20c732a GIT binary patch literal 500 zcmV<Q0So?#P)<h;3K|Lk000e1NJLTq001!n002M;0{{R3ggkFb0000>P)t-sOlfh9 znV$du|3gq!LQhmePgO!sRE?USe}8{NPgIYbq5uE??e6cbv$bw`d|6~@!p6w<_xIA( z)=gPnoTH_`#Knq~n0$tc;p60)HyAVk000VfQchC<o&o>4@qL^EMyD@N$N&HU32;bR za{vGf6951U69E94oEQKA0Xa!TK~#9!?bg|jgdh+FP-jZJTP`~){{NRwq<66`c$rKl z-Zz&66k<|M)AVgeA0x#1(yVf#y@;Ij#ECU&d9fm0fp{lWBHl=ucqN76g_MdXDHab> zF7Bj4h_6en5a(}ROd^|w5m98bFhYwp;lz4bLa-v}sZwM)#3BB*Fb=Xxgc+ZNHtDAe zw~^B&5h-km$<0~<a~K>j(CCx}FfMok<_#eL2MI_>2N#ytr2|wdLaA+G14%$o1hj?J z6g=cogr{w>-1BMBB*yD{!U$=~g$5L$sa!+=aNc_f=OZU<+EzE?9>EEyH~@AUmUy^9 zvm&kN@eT7jv>KMpQIRMpWh};My}AL4>FMsdx>jCuc7H<{V=Bc?4)MoCR|z_<L(bB2 qU3iwB>-5(-rCz@+t)*-GruhM+<u-2v3n%pe0000<MNUMnLSTYxHPgcY literal 0 HcmV?d00001 diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-pdf.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-pdf.imageset/Contents.json new file mode 100644 index 0000000..c9c4199 --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-pdf.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "pdf.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-pdf.imageset/pdf.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-pdf.imageset/pdf.png new file mode 100644 index 0000000000000000000000000000000000000000..95ab6c6751311aabe5834287830cc364c5c1f94d GIT binary patch literal 514 zcmeAS@N?(olHy`uVBq!ia0vp^hCuAV!3-o@Z1TN;R6>AHh^tR#;VD+u|NsA=W@SCa z#&#NrSXqB}cmJ=i2eHBEcW*CH!L!!ZzaKwdv#@xOnR!V=<Gi@|j~h3BK7Rae?b?fq zik}Z3zUA)z@8{3^5fOjhy?Z%z>bLs(SEZ%DA4<#w+QnTG<QL4ai*dJG?T)!jA7cwN z@}>cWISV`@i-EGjAk4@xYmNj11EZm*i(^Q|t+&@s=N&Q-U=6s;mSM7QoA6`zxDWs9 zBP}n82{j&{y{-GsRgaGsD|B3?6kWEiewn$%rmn;F+ZU}OU#(M}s+H%vSyxIvnke9I zBQS4=x=*3|jz{eqj+#I0)(=w1TjFx!adh0Ar>nf(Vx9nrk|NKdq)J`Q&4>7&J8e9r zX7({$N#eA~*%z1WOPNf<î=~h)@xVzeNO=kYo%=L#D-WnH5{=LF%*n89RKxIaj z$%5nRleL%hH>hV-ANYEL+g6}b;X=zk<=4gAgDfuAELuE6>gn?xT--cQ7W_~W>1Hz! z5v*jY%P1-0n!$GUxhucIUZKe8TE}-x{djS9NW11SH4RshDVy|J3_qPdv~6L4!RF3- zwO397-LVTTo^1ONy6Vod{b%a#edWIs`N~=Ki2BRp9p=9e9Eb#lD1)b~pUXO@geCyU C8RU5Y literal 0 HcmV?d00001 diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-png.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-png.imageset/Contents.json new file mode 100644 index 0000000..6508eb5 --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-png.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "png.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-png.imageset/png.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-png.imageset/png.png new file mode 100644 index 0000000000000000000000000000000000000000..9e5fad2de42334d8d49fa59ec4d03d4643554db0 GIT binary patch literal 491 zcmV<H0Tlj;P)<h;3K|Lk000e1NJLTq001!n002M;0{{R3ggkFb0000&P)t-sOlfiJ zkNVMC<L#09(OKisS>w@J<L!|8|9}6{TI2u!|M1B6)@kPa^#0<P?%H|j?6~vy<NM!* z?C7cS(_rQE()jdg*!BPb019+cPE!D$|NlnueVhWhga{4s00009a7bBm000XU000XU z0RWnu7ytkPIY~r8RCwC$*4d7PAP@ymr_k>9O0oO@zd9*8PTRPEkCS*G2tiKR)X;TZ zKMr&lrJk>@$b-(Jwxp+;D9KnC1sNK|H=!2sMcTxgG>R2z6(7<p{z$u6$OWN(URQ;B zUKV+jiU?{mN<~<;Ar+BIp=yFsxGFZe#Vu~pDzw#0H7!+b(}8g8CIC|+Vm1zdlRtw8 z*m%}ELb@~OegNmHrNmMPurD}J^585CX~0mFBDo_m5s{IH(v64_X@ZyE+?lX4m$&eU zH0Q=s5lS)Fgagt_ILI!UBLNp8#C=2nyqgdADu?Y_$e4gXoRyhoXJu-+j4sF;Vzj60 zC=)G_p5>4lMZo*aW3#Z6^!H}>P1IV`UQ*HgAHKm`oQmNh7-~1<O~%?Se3QO*(=YSL hcm1(-EyLJ%-7kZ1B|}W>D<}W}002ovPDHLkV1jSE=4Ai? literal 0 HcmV?d00001 diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-pproj.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-pproj.imageset/Contents.json new file mode 100644 index 0000000..a14a22b --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-pproj.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "pproj.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-pproj.imageset/pproj.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-pproj.imageset/pproj.png new file mode 100644 index 0000000000000000000000000000000000000000..6308b56dcb2625a460584a4e3120e71d5a187d5a GIT binary patch literal 266 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM3?#3wJbMaASqAuoxcX!k&W_yH=d$+AhmW%( zw)Hu!19B&Nt)Jk&ZnD?<nPFQW-+Q$*XW#m|qt{P9IynE*zFFswue~<ae-q<Gt{$Kv zTqQw%!3_WZ|9|1(b>#W+EVHzHpeSd7M`SSr1Gg{;GcwGY1F}5P)5S5Q;?~*o-h2%P z94r@t)+l{j{C)o#Jt^5}lfvgZ&sjOn?M;z+ai)dAwo=6D!)M_JlY(650FhUploR5@ zq&4(TvfbiZ9&?8C+{JwdziItq`zwFzTa=?z;7Wlb_mv)Ilylu<mc5)iaSPBg22WQ% Jmvv4FO#pUVY*qjO literal 0 HcmV?d00001 diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-psd.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-psd.imageset/Contents.json new file mode 100644 index 0000000..ba5a913 --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-psd.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "psd.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-psd.imageset/psd.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-psd.imageset/psd.png new file mode 100644 index 0000000000000000000000000000000000000000..d44694afb6dc0ea717df8d56a03fa9a96762a207 GIT binary patch literal 537 zcmV+!0_OdRP)<h;3K|Lk000e1NJLTq001!n002M;0{{R3ggkFb0001HP)t-sOlff# zAy;SB?EwG(86j5~B35YC?0^6NXV>o;Ay*h8Rv95z86sC{*zo`V|9gO+?e6m3-sWi5 z?>0SbN>O`^k*m(o+aoPt`T6>2Zjrpc&Y+~au(QQ-c$s0w;7gUbSFp}Oe50s_SQ7vM z01R|ePE!E5o&oW@ngX3hetJJ2M&keg010qNS#tmY3ljhU3ljkVnw%H_00BWsL_t(| z+U?fePJ}QJ1z=ECMTbM#DgA|2-~S~|S<wXoEr~IjaIWU&OQ+{*vMei41+C^>Jw0Z# zJkwUr)q<216V=_6oZ@0eiUctwlp-c1P27@1aYItYH%S&>BwdVThxjCWMD=hV7ng|` zUWtgesR*w`#CtU-F`+MW5)-6fFFVB|hd9Ll5w%b|MT_Pqg_e9BcpN0Ji(a_a0E)v1 zKuIDifX1zf8h{aiA_*ub;KG1_SB2?`Jix;mx+Zckl31+^5k3Ie9&lC@Q3JpA^Q$0( zS}OqPILvXOfu6{^&_SB8fE;>m0dCN33pm>g2x8|Yw=miRt@UQB-4HuYo;XyCW@9Va zsV?j!y;7YG&2`FIZtKmiV)<pEnRklB9O93OVkanK7xE%`><V9`j9vQkybQN~p8A&} bFSG0e85Wc#n;Ez!00000NkvXXu0mjf5Y*xw literal 0 HcmV?d00001 diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-tiff.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-tiff.imageset/Contents.json new file mode 100644 index 0000000..62e4cce --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-tiff.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "tiff.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-tiff.imageset/tiff.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-tiff.imageset/tiff.png new file mode 100644 index 0000000000000000000000000000000000000000..16f4bb475b8b36d4abbd53e4b75651abc986a68a GIT binary patch literal 460 zcmeAS@N?(olHy`uVBq!ia0vp^hCuAV!3-o@Z1TN;RBV7xh^tR#;XVtCY6gZXFoH1Z z85kgf{}~v7BKs{Z|NsAgq__9f^y&8w9QgL`-Jf5-)+Q!SP*r_=^5pf68y_4wva`H= zp{M8j`}do;xi2nTw!_$XwT6b&y^F~}i?~aI{DK)~?QrqfUCT80Bcqk^`<p;v&H|6f zVxX)r2s1Lwnj--e{O9T77*cWT?X}Z+hYSQ*1I!ttzE^!ZaO^DizyHSC!cGUpW`0~0 z8?v)|wa3MaJ>`q1+<I#ocWg#~x#-h|tuv;)InX)dO{JiquE2Q~p^q*pk0x>)WuDk2 zuOwEkB+h+Oz<iSOncM36=hS8%>ge`dW^icci~^;&(-L7l(~FmRZ0hv=vsGg5Zgb_T z{Ki5hCF}29FJ67Pewm@f#rgHKI6>a4iEah+Y^J3hHH{Bpw21w^RidvXM>=G!yR_Zz zot1&X%A(uXHVNc?%|GJO{CabXs#1|9)3y0(*Tcm&g!V7J&NAOO=TUgeIhI+wCrlQ( tBRX%%6TQC{?FHYbwtmswr7`svTd8!r1-pu6Ixr*{JYD@<);T3K0RYh3zj^=w literal 0 HcmV?d00001 diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-txt.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-txt.imageset/Contents.json new file mode 100644 index 0000000..7cc1eb6 --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-txt.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "txt.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-txt.imageset/txt.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-txt.imageset/txt.png new file mode 100644 index 0000000000000000000000000000000000000000..9215e1a8e39a1cefb3399ecb524396c7922119ec GIT binary patch literal 430 zcmeAS@N?(olHy`uVBq!ia0vp^hCuAV!3-o@Z1TN;lzo6th^tR#;nk~GH*em2?b<aU z31R&I|NqLBD-bSR<nG<O@87@w`Sa)Ay?ZZQxN!XV@#oK<_dYY82sDbjB*-tAVOH%N zx1IAC=Y9+nI~HpK6y_}Oh%5%m3WG2s!>l<HK*4{WE{-7;x87bmn0Hu#hb`e&Yw8Iz zi8H_LUq8vY%9Ua0@jCTXJ*P)aLm*$-xm>#g>m6%u$4<AN!|J}NQT4-O)q7&HoWhG# z4V0yf)aRXCQlYf@QM(U!&HeDB%{m?Vo0L9myS?Z^!Kqmnf_W5g2z&FiFVJ*2wdzH2 zr}rOWMZwvY$C5fcR?bxKs@K&LG)#ZSHDTt&(zsm4R*wZiOwl%Zp`F}?aVwNxKTBQt z$tk|MQt;)DD|-Z#?A*3p2p7^-<`R4*yhLZY(&TAt_*aXc-ql>^$Kv6;^~j3uMgM}A zF00ae6Z1$&UocUSWwo>1qqTZ9C(=rP)xL9I|2{pNv#$7^FjIC*dZmTH3}8qwc)I$z JtaD0e0syW|zjFWp literal 0 HcmV?d00001 diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-xls.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-xls.imageset/Contents.json new file mode 100644 index 0000000..4464de7 --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-xls.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "xls.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-xls.imageset/xls.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-xls.imageset/xls.png new file mode 100644 index 0000000000000000000000000000000000000000..a3a43ea53c2464708a326c54fec753ece69d776b GIT binary patch literal 488 zcmeAS@N?(olHy`uVBq!ia0vp^hCuAV!3-o@Z1TN;R7ikNh^tR#VdWZz%?}+mJ$3*x zD%LP;dgK5^6{{H_Tp+pSiR1tO|8Kq6vH7w6xrYnB{d~Lcdh_Ic2Ho4079RC^`{hp4 zCc%y8v!1*=z3jT-iMvxTdCKnrn$KMl<QL4~GH<R&?X0;wKh9=Z<$4|{%vs<OSqzjF z24O~qS#u;97#Nj3T^vIyZoR#BJFnS*hb`g8E#4=80<77cD*u0<!;u^AaQ0!^Ewz8# z>pq<<Q&I^JEzOFmni*Fcc6*NIw79n?I)jqDXKlGL!OVFL=hF_+lfvg!f^8<oeDvMa zTBR>m9;9$3dSih6QKb;ij@MCVLWP`G%X)U?MtiBU#{Lf4cvg|`<L;xa%bhMh{1lvY zK~2kKbIbbJ7D>hp{Z|DXCeP+)e|TUn^X!fVf{PFEb{lL~&rdS-U~brZp3&ms%9E@! z<R<^VX)P@->lD0w_od0s8E?0KNZ?DCf4@*tNU-f;u0+bqtS!fkYFv6=JUeWoQ+|}A znP;N7h@p$ykxAm-CLZQxo7n2#^jjUV7t2-52$ue&=bOcSf2m*YuiJB;>#oZyO3$3V cYt=pm^UJMwKkTa%0EQuhr>mdKI;Vst02r^+eE<Le literal 0 HcmV?d00001 diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-xml.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-xml.imageset/Contents.json new file mode 100644 index 0000000..f6a92e3 --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-xml.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "xml.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-xml.imageset/xml.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-xml.imageset/xml.png new file mode 100644 index 0000000000000000000000000000000000000000..777f56d0dd21d993fc7ffeb978431c92f999138d GIT binary patch literal 292 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE3?yBabR7dyiUB?$u0ENCBDKFo>V5-Bq1ykV zwZBAbe~Z@rf{2LL{k}QdaXnBmS4ogxFvBCa-O`2?xmPB6B`RnDML7#RB8wRqxP?KO zkzv*x383I)PZ!6Kid%axzvervz`^=}x#D%@fA+XtW!l-Uo4t?m3qKZj>^NZW)Oz0G zEkn6q!1@OLhDtUSUxwQ}7uXbT?$R^d;LWK1;lvk7jlHjBHTFHN?y#D$WU{vm!%DZ{ zLm3KvS$r3aij}8GF<guB%;0QXv$_4?G4ng-i=~n_>`!98ebKshMp=<nxBjuap0fXS e!f%~_#P*>^IBde9x95P)WAJqKb6Mw<&;$S}ByB_h literal 0 HcmV?d00001 diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-zip.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-zip.imageset/Contents.json new file mode 100644 index 0000000..eed17c7 --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-zip.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "zip.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-zip.imageset/zip.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-zip.imageset/zip.png new file mode 100644 index 0000000000000000000000000000000000000000..587554a70807b516ab418343040d3ddd35f58271 GIT binary patch literal 300 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM3?#3wJbMaAxd!-zxcX!kemuf&;u-gOj?l-$ ze6QMNp4BQnt5bTjMEv7H{^xayZ$5mSx^&sc-2yM4-~PD8=j~+4k7s>7!b4B(OxSh! zWL3|klRFX>mlsY28pu@=<QL5F|Ns9dFK@had;R^1bOKP6v%n*=n1O*?7=#%aX3YUP zq}<cRF{I+w)k~g2%?3O!f!og}2uw1VaOeLDl|?~`0_+d+<4ZFa3plO&pnpjq@ac!O zhvnKN8qYCU=rgeAG%nX!_SAIe2eFy40-=SCUW)P^c@AIMX4Fmy`tw+8k^KYP0xzBo u2P*_F7dHR9#8$d1MEc1x+p9JA(plLx*-Cb6u}cD-z~JfX=d#Wzp$PyP_In`! literal 0 HcmV?d00001 diff --git a/apps/macos/PushThat/Services/PushContent.swift b/apps/macos/PushThat/Services/PushContent.swift index c3f7ba1..fc1821f 100644 --- a/apps/macos/PushThat/Services/PushContent.swift +++ b/apps/macos/PushThat/Services/PushContent.swift @@ -1,13 +1,18 @@ import Foundation +import ImageIO +import UniformTypeIdentifiers /// What a push's envelope holds (docs/e2ee.md, "The push envelope"), and the keys this -/// device seals and opens it with. Foundation, CryptoKit and Security only: the app, the -/// share extension, the notification service extension and the tests all compile it, and -/// only the first two have Firestore. +/// device seals and opens it with. Foundation, CryptoKit, Security and ImageIO only: the +/// app, the share extension, the notification service extension and the tests all compile +/// it, and only the first two have Firestore. /// /// This file is the same in `apps/ios` and `apps/macos`. enum PushContent: Equatable { - case url(url: String, title: String, ext: String) + /// `icon` is the site's icon (`PushIcon`), nil when the push has none. It defaults to + /// nil so that a push made before a page is read, which is every push at creation, need + /// not say so. + case url(url: String, title: String, ext: String, icon: String? = nil) case file(PushFile) enum ContentError: LocalizedError { @@ -41,7 +46,7 @@ enum PushContent: Equatable { /// overlong title cannot crowd the URL's tokens out of the 3000. var searchText: String { switch self { - case let .url(url, title, _): return String(title.prefix(Self.maxTitleLength)) + " " + url + case let .url(url, title, _, _): return String(title.prefix(Self.maxTitleLength)) + " " + url case let .file(file): return file.name } } @@ -51,8 +56,12 @@ enum PushContent: Equatable { func json() throws -> Data { let object: [String: Any] switch self { - case let .url(url, title, ext): - object = ["url": url, "title": String(title.prefix(Self.maxTitleLength)), "ext": ext] + case let .url(url, title, ext, icon): + var fields: [String: Any] = ["url": url, "title": String(title.prefix(Self.maxTitleLength)), "ext": ext] + // Left out rather than written empty when there is none, which is how every + // push before icons reads and what the vectors' URL envelope holds. + if let icon, !icon.isEmpty { fields["icon"] = icon } + object = fields case let .file(file): if let fk = file.fk, let np = file.np { object = [ @@ -73,7 +82,10 @@ enum PushContent: Equatable { switch type { case "url": guard let url = object["url"] as? String else { throw ContentError.malformed } - self = .url(url: url, title: object["title"] as? String ?? "", ext: ext) + // Absent on a push from before icons, and "" means none as well (docs/e2ee.md, + // "Icons"), so both read as nil and nothing downstream tells them apart. + let icon = (object["icon"] as? String).flatMap { $0.isEmpty ? nil : $0 } + self = .url(url: url, title: object["title"] as? String ?? "", ext: ext, icon: icon) case "file": guard let name = object["name"] as? String else { throw ContentError.malformed } let mime = object["mime"] as? String ?? "" @@ -101,6 +113,17 @@ enum PushContent: Equatable { return (enc, tokens) } + /// A URL push sealed with its icon, or without it when the envelope with it would be + /// over the rules' bound (docs/e2ee.md, "Icons"): a long URL keeps its title rather than + /// losing both. Still too large without it, it throws `tooLarge` as before. + static func sealURL(url: String, title: String, ext: String, icon: String?, keys: PushKeys, pushId: String) throws -> (enc: E2eeCrypto.Envelope, searchTokens: [String]) { + do { + return try PushContent.url(url: url, title: title, ext: ext, icon: icon).seal(keys: keys, pushId: pushId) + } catch ContentError.tooLarge where icon != nil { + return try PushContent.url(url: url, title: title, ext: ext).seal(keys: keys, pushId: pushId) + } + } + /// Opens a push's envelope, or nil when this device cannot: a push made under another /// account key (`keyVersion` differs), or one that does not open or parse. Such a push /// is shown locked. The AAD is made from the document's own `userId` and id. @@ -202,3 +225,252 @@ struct PreparedPush: Codable, Equatable { var enc: E2eeCrypto.Envelope { E2eeCrypto.Envelope(v: encVersion, n: encNonce, ct: encCiphertext) } } + +/// A URL push's icon (docs/e2ee.md, "Icons"): the standard base64 of a PNG no larger than +/// 64 by 64 pixels and no more than 4096 bytes, carried in the envelope with the title so +/// that no device asks anyone for it in order to draw the list. Google's favicon service +/// drew it before, and so learned the host of every pushed link each time a list was drawn. +/// +/// What needs no network is here rather than in `TitleScraperService`, which does the +/// fetching: this file is compiled by both apps and by the tests (which have no Firebase, +/// and a new file would mean a new entry in each project), so the candidate order, the +/// bounds and the decoding for display are written once per app and checked. ImageIO +/// rather than UIImage or NSImage for the same reason: it opens ICO and PNG on iOS and +/// macOS alike, so this is the same in both apps. +enum PushIcon { + /// The longest side, in pixels, an icon is stored at. + static let maxSide = 64 + /// The side tried once more when the PNG at `maxSide` is over `maxBytes`. + static let fallbackSide = 32 + /// The most bytes of PNG an icon may be, 5464 characters of base64: with a URL and a + /// title it still fits `enc.ct`'s 16384 for every ordinary link. + static let maxBytes = 4096 + /// How much of each candidate is read. A larger file is passed over rather than cut, + /// since a truncated image can still decode, with its lower part missing. + static let maxDownloadBytes = 256 * 1024 + /// Frames longer than this on a side are passed over: a frame is decoded whole to be + /// shrunk, and 256 KiB of PNG can hold a frame that would take hundreds of megabytes. + /// No icon worth drawing at 64 pixels is that large. + static let maxSourceSide = 2048 + /// The most `<link>` candidates tried before `/favicon.ico`. A page names a handful at + /// most; the cap keeps one that names hundreds from turning a scrape into hundreds of + /// requests. The same on every client (docs/e2ee.md, "Icons"). + static let maxLinkCandidates = 8 + + // MARK: Candidates + + /// The URLs to try, best first, for the page whose HTML is `html` and whose final URL + /// (after redirects) is `pageURL`: every `apple-touch-icon` link, then every `icon` + /// link (`shortcut icon` included) that is not SVG by its type or its path, then + /// `/favicon.ico` at the page's origin. Only http and https, each once, and at most + /// `maxLinkCandidates` links. A `<base href>` is not followed, as on every client: the + /// final URL is what all of them agree on. + /// + /// SVG is left out because ImageIO does not draw it: fetching one would be a request + /// the site sees for nothing. The spec leaves the `apple-touch-icon` links unfiltered, + /// and so does this; one that does not decode is simply passed over. + static func candidates(html: String, pageURL: URL) -> [URL] { + var touchIcons: [URL] = [] + var icons: [URL] = [] + for attributes in linkTags(in: html) { + guard let rel = attributes["rel"], let href = attributes["href"], + let url = resolve(href, against: pageURL) + else { continue } + let rels = Set(rel.lowercased().split(whereSeparator: \.isWhitespace).map(String.init)) + if rels.contains("apple-touch-icon") || rels.contains("apple-touch-icon-precomposed") { + touchIcons.append(url) + } else if rels.contains("icon") { + let type = attributes["type"]?.trimmingCharacters(in: .whitespaces).lowercased() + guard type != "image/svg+xml", url.pathExtension.lowercased() != "svg" else { continue } + icons.append(url) + } + } + var seen = Set<URL>() + var all = Array((touchIcons + icons).filter { seen.insert($0).inserted }.prefix(maxLinkCandidates)) + if let favicon = favicon(of: pageURL), !all.contains(favicon) { all.append(favicon) } + return all + } + + /// The attributes of every `<link>` tag, names lowercased, values unquoted and their + /// common entities decoded. A scanner rather than a parser, as the scraper finds the + /// `<title>`: a link's attributes are all an icon needs, and there is no HTML parser in + /// the project to lean on. It takes attributes in any order, quoted either way or not at + /// all, in any case, and a quoted value holding a `>`. + static func linkTags(in html: String) -> [[String: String]] { + linkPattern.matches(in: html, range: NSRange(html.startIndex..., in: html)).compactMap { match in + guard let range = Range(match.range(at: 1), in: html) else { return nil } + let tag = String(html[range]) + var attributes: [String: String] = [:] + for attribute in attributePattern.matches(in: tag, range: NSRange(tag.startIndex..., in: tag)) { + guard let name = Range(attribute.range(at: 1), in: tag) else { continue } + let value = (2...4).lazy.compactMap { Range(attribute.range(at: $0), in: tag) }.first.map { String(tag[$0]) } ?? "" + let key = tag[name].lowercased() + // The first of a repeated attribute counts, as it does in a browser. + if attributes[key] == nil { attributes[key] = decodeEntities(value) } + } + return attributes + } + } + + private static let linkPattern = try! NSRegularExpression( + pattern: #"<link\b((?:[^>"']|"[^"]*"|'[^']*')*)>"#, options: .caseInsensitive + ) + private static let attributePattern = try! NSRegularExpression( + pattern: #"([^\s"'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?"# + ) + + /// The entities an `href` holds in practice, `&` between query parameters above + /// all. `&` goes last so that `&lt;` reads as the text `<`. + private static func decodeEntities(_ value: String) -> String { + guard value.contains("&") else { return value } + var out = value + for (entity, text) in [(""", "\""), (""", "\""), ("'", "'"), ("'", "'"), ("<", "<"), (">", ">"), ("&", "&"), ("&", "&")] { + out = out.replacingOccurrences(of: entity, with: text, options: .caseInsensitive) + } + return out + } + + private static func resolve(_ href: String, against page: URL) -> URL? { + let href = href.trimmingCharacters(in: .whitespacesAndNewlines) + guard !href.isEmpty, let url = URL(string: href, relativeTo: page)?.absoluteURL, + isWeb(url.scheme), url.host != nil + else { return nil } + return url + } + + /// `/favicon.ico` at the page's origin: its scheme, host and port, nothing else. + private static func favicon(of page: URL) -> URL? { + guard isWeb(page.scheme), let host = page.host else { return nil } + var components = URLComponents() + components.scheme = page.scheme?.lowercased() + components.host = host + components.port = page.port + components.path = "/favicon.ico" + return components.url + } + + private static func isWeb(_ scheme: String?) -> Bool { + let scheme = scheme?.lowercased() + return scheme == "http" || scheme == "https" + } + + // MARK: Encoding + + /// What a candidate's bytes came to. + enum Encoded: Equatable { + /// The icon, within the bounds. + case icon(String) + /// ImageIO could not open them (or they are over `maxDownloadBytes`): the next + /// candidate is tried. + case undecodable + /// They opened, but the PNG is over `maxBytes` at 32 by 32 as well. The first + /// candidate that decodes decides, so there is no icon and no further candidate + /// is tried, as on every client (docs/e2ee.md, "Icons"). + case tooLarge + } + + static func encode(_ data: Data) -> Encoded { + guard data.count <= maxDownloadBytes, + let source = CGImageSourceCreateWithData(data as CFData, nil), + let index = bestFrame(of: source), + let image = CGImageSourceCreateImageAtIndex(source, index, nil) + else { return .undecodable } + for side in [maxSide, fallbackSide] { + guard let png = png(of: image, fitting: side) else { return .undecodable } + if png.count <= maxBytes { return .icon(png.base64EncodedString()) } + } + return .tooLarge + } + + /// The frame to shrink, by its longer side: the smallest of at least `maxSide`, so an + /// ICO's 64-pixel frame, drawn for that size, wins over its 256 one shrunk; else the + /// largest there is. The first wins a tie, which takes an animated GIF's first frame. + private static func bestFrame(of source: CGImageSource) -> Int? { + var best: (index: Int, side: Int)? + for index in 0..<CGImageSourceGetCount(source) { + guard let side = longerSide(source, index), side <= maxSourceSide else { continue } + guard let current = best else { + best = (index, side) + continue + } + let better = side >= maxSide + ? current.side < maxSide || side < current.side + : current.side < maxSide && side > current.side + if better { best = (index, side) } + } + return best?.index + } + + private static func pixelSize(_ source: CGImageSource, _ index: Int) -> (width: Int, height: Int)? { + guard let properties = CGImageSourceCopyPropertiesAtIndex(source, index, nil) as? [CFString: Any], + let width = (properties[kCGImagePropertyPixelWidth] as? NSNumber)?.intValue, + let height = (properties[kCGImagePropertyPixelHeight] as? NSNumber)?.intValue, + width > 0, height > 0 + else { return nil } + return (width, height) + } + + private static func longerSide(_ source: CGImageSource, _ index: Int) -> Int? { + pixelSize(source, index).map { max($0.width, $0.height) } + } + + /// `image` scaled down to fit `side` by `side` with its aspect kept, never up, as PNG. + /// Drawn into a context of exactly the target size rather than through ImageIO's + /// thumbnails, whose handling of an image already smaller than the limit is not + /// documented. Drawn in sRGB, so an icon in another color space (a CMYK JPEG, a P3 PNG) + /// comes out as every platform will show it; ImageIO marks that with a short `sRGB` + /// chunk, not a whole ICC profile, so it costs a few bytes of the 4096. + private static func png(of image: CGImage, fitting side: Int) -> Data? { + let scale = min(1, Double(side) / Double(max(image.width, image.height))) + let width = max(1, Int((Double(image.width) * scale).rounded())) + let height = max(1, Int((Double(image.height) * scale).rounded())) + guard let sRGB = CGColorSpace(name: CGColorSpace.sRGB), let context = CGContext( + data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0, + space: sRGB, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { return nil } + context.interpolationQuality = .high + context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + guard let scaled = context.makeImage() else { return nil } + let data = NSMutableData() + guard let destination = CGImageDestinationCreateWithData(data, UTType.png.identifier as CFString, 1, nil) else { return nil } + CGImageDestinationAddImage(destination, scaled, nil) + guard CGImageDestinationFinalize(destination) else { return nil } + return data as Data + } + + // MARK: Showing + + /// The image to draw for a push's `icon`, or nil when it is not a PNG within the + /// bounds, which draws the placeholder. Decoded once per icon and kept, failures too: a + /// row's body is evaluated far more often than its push changes. + static func image(_ icon: String) -> CGImage? { + let key = icon as NSString + if let decoded = cache.object(forKey: key) { return decoded.image } + let image = decode(icon) + cache.setObject(Decoded(image), forKey: key) + return image + } + + /// Opens an `icon` as the spec bounds it. Only PNG, and only up to 64 by 64, checked + /// before any pixel is decoded: whatever wrote the envelope, a small file must not + /// become a large image in a list of them. + static func decode(_ icon: String) -> CGImage? { + guard let data = Data(base64Encoded: icon), + let source = CGImageSourceCreateWithData(data as CFData, nil), + CGImageSourceGetType(source) as String? == UTType.png.identifier, + let size = pixelSize(source, 0), size.width <= maxSide, size.height <= maxSide + else { return nil } + return CGImageSourceCreateImageAtIndex(source, 0, nil) + } + + private final class Decoded { + let image: CGImage? + init(_ image: CGImage?) { self.image = image } + } + + private static let cache: NSCache<NSString, Decoded> = { + let cache = NSCache<NSString, Decoded>() + cache.countLimit = 500 + return cache + }() +} diff --git a/apps/macos/PushThat/Services/PushDocuments.swift b/apps/macos/PushThat/Services/PushDocuments.swift index b5f2508..7a44fb1 100644 --- a/apps/macos/PushThat/Services/PushDocuments.swift +++ b/apps/macos/PushThat/Services/PushDocuments.swift @@ -137,11 +137,13 @@ enum PushDocuments { // MARK: - Titles - /// The scraped title written back to an encrypted URL push: exactly `enc` and + /// The scraped title and icon written back to an encrypted URL push: exactly `enc` and /// `searchTokens` (`isEncryptedRetitle` in the rules), the URL and ext as they were, the - /// new title under a fresh nonce. Only under the push's own key version, and only when - /// that is the one this device holds: a push made under another key is not ours to + /// title and icon under a fresh nonce. Only under the push's own key version, and only + /// when that is the one this device holds: a push made under another key is not ours to /// rewrite. Last write wins. + /// With the icon the envelope can be over the rules' bound on `enc.ct`, and then the + /// title is written alone (`PushContent.sealURL`). static func retitle( firestore: Firestore, keys: PushKeys, @@ -150,10 +152,11 @@ enum PushDocuments { keyVersion: Int?, url: String, ext: String, - title: String + title: String, + icon: String? = nil ) async throws { guard keyVersion == keys.keyVersion, userId == keys.uid else { return } - let sealed = try PushContent.url(url: url, title: title, ext: ext).seal(keys: keys, pushId: pushId) + let sealed = try PushContent.sealURL(url: url, title: title, ext: ext, icon: icon, keys: keys, pushId: pushId) try await firestore.collection("pushes").document(pushId).updateData([ "enc": sealed.enc.firestoreData, "searchTokens": sealed.searchTokens, diff --git a/apps/macos/PushThat/Services/TitleScraperService.swift b/apps/macos/PushThat/Services/TitleScraperService.swift index e953e70..7efe474 100644 --- a/apps/macos/PushThat/Services/TitleScraperService.swift +++ b/apps/macos/PushThat/Services/TitleScraperService.swift @@ -1,11 +1,12 @@ import Foundation import FirebaseFirestore -/// Fills in the title of an encrypted URL push from the page itself (docs/e2ee.md, -/// "Titles"). No server fetches it, since a server that fetched the URL would know it: a -/// push goes out with an empty title and a native device writes the title back, for its -/// own pushes and for one it received untitled (a web push, which has no way to read a -/// cross-origin page). +/// Fills in the title and the icon of an encrypted URL push from the page itself +/// (docs/e2ee.md, "Titles" and "Icons"). No server fetches them, since a server that +/// fetched the URL would know it: a push goes out with an empty title and a native device +/// writes the title back, for its own pushes and for one it received untitled (a web push, +/// which has no way to read a cross-origin page). The icon comes in the same pass, so that +/// no device has to ask anyone for it in order to draw the list. /// /// The app calls it for every untitled URL push the list shows, whoever made it, which /// covers the share extension's pushes (the extension is gone before a page could answer) @@ -20,39 +21,51 @@ actor TitleScraperService { private init() {} - /// Scrapes the page's title and rewrites the push's envelope with it: exactly `enc` and - /// `searchTokens`, only under the push's own key version (`PushDocuments.retitle`). A - /// failure is logged; the push keeps showing its URL. - func scrapeAndUpdate(pushId: String, userId: String, keyVersion: Int?, url: String, ext: String, keys: PushKeys) async { + /// Scrapes the page and rewrites the push's envelope with what it found: exactly `enc` + /// and `searchTokens`, only under the push's own key version (`PushDocuments.retitle`). + /// It is rewritten when the title changed or an icon was found for a push that had + /// none, and a push keeps its own icon when the scrape finds none. A push that already + /// has an icon does not fetch another, since the one it has would be kept either way. + /// A failure is logged; the push keeps showing what it showed. + func scrapeAndUpdate(pushId: String, userId: String, keyVersion: Int?, url: String, title: String, ext: String, icon: String?, keys: PushKeys) async { guard keyVersion == keys.keyVersion, attempted.insert(pushId).inserted else { return } - guard let title = await scrapeTitle(from: url), !title.isEmpty else { return } + guard let page = await scrape(from: url, withIcon: icon == nil) else { return } + // Cut as the envelope cuts it, so a long title read again is not taken for a change. + let scraped = page.title.map { String($0.prefix(PushContent.maxTitleLength)) } ?? "" + let newTitle = scraped.isEmpty ? title : scraped + guard newTitle != title || page.icon != nil else { return } do { try await PushDocuments.retitle( firestore: db, keys: keys, pushId: pushId, userId: userId, - keyVersion: keyVersion, url: url, ext: ext, title: title + keyVersion: keyVersion, url: url, ext: ext, title: newTitle, icon: icon ?? page.icon ) } catch { print("Failed to update title: \(type(of: error))") } } - /// How much of a page is read for its title. + /// How much of a page is read for its title and its icon links. private static let maxPageBytes = 256 * 1024 - /// Scrape the title from a URL - func scrapeTitle(from urlString: String) async -> String? { + /// Sent with the page's request and its icons' alike, so the site sees one browser. + private static let userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15" + + /// The page's title and, when `withIcon`, its icon (`PushIcon`), or nil when the URL is + /// not an HTML page that answered. The title is nil when the page has none this reads. + private func scrape(from urlString: String, withIcon: Bool) async -> (title: String?, icon: String?)? { guard let url = URL(string: urlString) else { return nil } do { var request = URLRequest(url: url) - request.setValue("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15", forHTTPHeaderField: "User-Agent") + request.setValue(Self.userAgent, forHTTPHeaderField: "User-Agent") request.timeoutInterval = 10 // Streamed, not `data(for:)`: the pushes left untitled are mostly links to files // (PDFs, video, archives), which have no title to find, and the list asks for // every one it shows. Only an HTML page is read, and only its first 256 KiB, where // the <title> is; `timeoutInterval` bounds a stall, not a download, so without the - // cap a large file would be pulled into memory on every launch. + // cap a large file would be pulled into memory on every launch. Nor is an icon + // looked for on anything but a page: a link to a file shows its type's icon. let (bytes, response) = try await URLSession.shared.bytes(for: request) guard let httpResponse = response as? HTTPURLResponse, @@ -69,16 +82,18 @@ actor TitleScraperService { // The header's charset, else UTF-8, decoded strictly: a page in an encoding this // cannot read gets no title, rather than a garbled one written into the push for // every device. Up to three bytes are dropped first, the most the cap can cut from - // a character, so a page cut mid-character still reads. + // a character, so a page cut mid-character still reads. Such a page has no icon + // links to read either, but its origin's /favicon.ico is still a candidate. let encoding = httpResponse.textEncodingName .map { CFStringConvertIANACharSetNameToEncoding($0 as CFString) } .flatMap { $0 == kCFStringEncodingInvalidId ? nil : String.Encoding(rawValue: CFStringConvertEncodingToNSStringEncoding($0)) } ?? .utf8 - guard let html = (0...3).lazy.compactMap({ String(data: data.dropLast($0), encoding: encoding) }).first else { - return nil - } + let html = (0...3).lazy.compactMap({ String(data: data.dropLast($0), encoding: encoding) }).first ?? "" - return extractTitle(from: html) + // Icons resolve against the URL the page came from after redirects, as a + // browser resolves them, not against the one that was pushed. + let icon = withIcon ? await fetchIcon(from: PushIcon.candidates(html: html, pageURL: httpResponse.url ?? url)) : nil + return (extractTitle(from: html), icon) } catch { // The error's type only: a URLError carries the page's full URL. print("Failed to scrape title: \(type(of: error))") @@ -86,6 +101,47 @@ actor TitleScraperService { } } + /// The icon made from the first candidate that ImageIO opens (`PushIcon.encode`), or + /// nil when none opens or the one that does is too large even at 32 by 32. One at a + /// time, best first, so a page whose first candidate is good costs one request more + /// than its title. + private func fetchIcon(from candidates: [URL]) async -> String? { + for candidate in candidates { + guard let data = await download(candidate) else { continue } + switch PushIcon.encode(data) { + case let .icon(icon): return icon + case .tooLarge: return nil + case .undecodable: continue + } + } + return nil + } + + /// A candidate's bytes, or nil when it does not answer 200 or is over + /// `PushIcon.maxDownloadBytes`. Streamed as the page is, so a large file is given up at + /// the cap rather than pulled into memory first. + private func download(_ url: URL) async -> Data? { + var request = URLRequest(url: url) + request.setValue(Self.userAgent, forHTTPHeaderField: "User-Agent") + request.timeoutInterval = 10 + do { + let (bytes, response) = try await URLSession.shared.bytes(for: request) + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200, + httpResponse.expectedContentLength <= Int64(PushIcon.maxDownloadBytes) + else { return nil } + var data = Data() + for try await byte in bytes { + data.append(byte) + if data.count > PushIcon.maxDownloadBytes { return nil } + } + return data + } catch { + // The error's type only, as for the page: a URLError carries the full URL. + print("Failed to fetch icon: \(type(of: error))") + return nil + } + } + /// Extract title from HTML using regex /// Note: For production, consider using SwiftSoup for proper HTML parsing private func extractTitle(from html: String) -> String? { diff --git a/apps/macos/PushThat/Utilities/FileTypeIcons.swift b/apps/macos/PushThat/Utilities/FileTypeIcons.swift index 61cfb61..7146272 100644 --- a/apps/macos/PushThat/Utilities/FileTypeIcons.swift +++ b/apps/macos/PushThat/Utilities/FileTypeIcons.swift @@ -1,10 +1,15 @@ import Foundation -/// Maps file extensions to icons hosted on PushThat CDN -/// Matches the Android SUPPORTED_FILE_ICONS list exactly +/// The file types with an icon of their own, drawn from the app's asset catalog as +/// `file-<ext>` (copies of `apps/web/public/files/<ext>.png`). Matches the Android list. +/// +/// They were loaded from pushthat.link/files, which put the type of every encrypted file a +/// list showed in the hosting logs (docs/e2ee.md, "Icons"); shipped in the app, drawing +/// one asks nobody anything. struct FileTypeIcons { - /// Set of supported file extensions that have custom icons + /// The extensions that have an icon. The one list: an extension added here needs its + /// `file-<ext>` image set in Assets.xcassets, in both apps. static let supported: Set<String> = [ "pproj", "zip", "indd", "csv", "css", "pdf", "aac", "png", "xls", "avi", "txt", "mp3", "tiff", "psd", "jpg", "jpeg", @@ -12,11 +17,10 @@ struct FileTypeIcons { "xml", "apk" ] - /// Returns the URL for a file type icon, or nil if not supported - static func iconURL(for fileExtension: String) -> URL? { - let ext = fileExtension.lowercased() - guard supported.contains(ext) else { return nil } - return URL(string: "https://pushthat.link/files/\(ext).png") + /// The asset catalog name of the icon for `fileExtension`, or nil when it has none. + static func assetName(for fileExtension: String?) -> String? { + guard let ext = fileExtension?.lowercased(), supported.contains(ext) else { return nil } + return "file-\(ext)" } /// Returns a SF Symbol name appropriate for the file type diff --git a/apps/macos/PushThat/ViewModels/PushListViewModel.swift b/apps/macos/PushThat/ViewModels/PushListViewModel.swift index 59ab556..c06ff2c 100644 --- a/apps/macos/PushThat/ViewModels/PushListViewModel.swift +++ b/apps/macos/PushThat/ViewModels/PushListViewModel.swift @@ -259,18 +259,22 @@ class PushListViewModel: ObservableObject { showError = true } - /// Untitled URL pushes get their page's title, whichever device made them + /// Untitled URL pushes get their page's title and icon, whichever device made them /// (`TitleScraperService`). In the background: the list does not wait for any page. + /// A titled push is not read again for an icon (docs/e2ee.md, "Icons"), so a push + /// from before icons keeps the placeholder rather than every list asking every site. private func scrapeUntitled(_ items: [PushItem], keys: PushKeys) { for item in items where item.type == .url && item.title.isEmpty { guard case .encrypted = item.source, let id = item.id else { continue } let url = item.url + let title = item.title let ext = item.ext ?? "" + let icon = item.icon let userId = item.userId let keyVersion = item.keyVersion Task.detached(priority: .utility) { await TitleScraperService.shared.scrapeAndUpdate( - pushId: id, userId: userId, keyVersion: keyVersion, url: url, ext: ext, keys: keys + pushId: id, userId: userId, keyVersion: keyVersion, url: url, title: title, ext: ext, icon: icon, keys: keys ) } } diff --git a/apps/macos/PushThat/Views/PushRowView.swift b/apps/macos/PushThat/Views/PushRowView.swift index 4fce0e4..1125434 100644 --- a/apps/macos/PushThat/Views/PushRowView.swift +++ b/apps/macos/PushThat/Views/PushRowView.swift @@ -16,7 +16,7 @@ struct PushRowView: View { // Swivel checkbox / favicon SwivelCheckView( isChecked: isSelected, - imageURL: faviconURL, + image: icon, fallbackIcon: item.type == .file ? "doc.fill" : "link.circle.fill" ) .frame(width: 40, height: 40) @@ -123,18 +123,23 @@ struct PushRowView: View { return date.relativeFormatted() } - private var faviconURL: URL? { - // A locked push shows the placeholder: there is no URL or name to show one for. + /// What the row draws, all of it already on the device: nothing is fetched to draw a + /// row (docs/e2ee.md, "Icons"). A URL push draws the icon that came in it, else the + /// icon for its `ext` from the asset catalog (a link to a file has no page to take an + /// icon from); a file push draws its type's icon. Nil draws the placeholder a locked + /// push draws, also for an icon that does not decode. The order is the same on every + /// client. + /// + /// This was Google's favicon service for every link, which learned the host of each + /// pushed link whenever the list was drawn, and pushthat.link for file types, whose logs + /// learned the type of each encrypted file. + private var icon: Image? { + // A locked push shows the placeholder: there is nothing it can be known by. if item.isLocked { return nil } - - // Check for file extension icon first - if let ext = item.ext, FileTypeIcons.supported.contains(ext.lowercased()) { - return URL(string: "https://pushthat.link/files/\(ext.lowercased()).png") + if item.type == .url, let icon = item.icon, let image = PushIcon.image(icon) { + return Image(decorative: image, scale: 1) } - - // Fall back to Google favicon service - guard let urlHost = URL(string: item.url)?.host else { return nil } - return URL(string: "https://www.google.com/s2/favicons?domain=\(urlHost)&sz=256") + return FileTypeIcons.assetName(for: item.ext).map { Image($0) } } } diff --git a/apps/macos/PushThat/Views/SwivelCheckView.swift b/apps/macos/PushThat/Views/SwivelCheckView.swift index 18740d8..12fcaf1 100644 --- a/apps/macos/PushThat/Views/SwivelCheckView.swift +++ b/apps/macos/PushThat/Views/SwivelCheckView.swift @@ -2,9 +2,12 @@ import SwiftUI /// Custom 3D flip checkbox that shows a favicon on the front and a checkmark on the back /// Matches the Android SwivelCheckView behavior +/// +/// It takes an image the caller already holds, not a URL: drawing a row fetches nothing +/// (docs/e2ee.md, "Icons"). `fallbackIcon` is drawn when there is none. struct SwivelCheckView: View { let isChecked: Bool - let imageURL: URL? + let image: Image? var fallbackIcon: String = "link.circle.fill" @State private var rotation: Double = 0 @@ -40,23 +43,12 @@ struct SwivelCheckView: View { private var frontSide: some View { Group { - if let url = imageURL { - AsyncImage(url: url) { phase in - switch phase { - case .empty: - ProgressView() - .frame(width: 40, height: 40) - case .success(let image): - image - .resizable() - .aspectRatio(contentMode: .fit) - .clipShape(RoundedRectangle(cornerRadius: 8)) - case .failure: - fallbackIconView - @unknown default: - fallbackIconView - } - } + if let image { + image + .resizable() + .interpolation(.high) + .aspectRatio(contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 8)) } else { fallbackIconView } @@ -83,27 +75,17 @@ struct SwivelCheckView: View { #Preview { VStack(spacing: 20) { - // Unchecked state - SwivelCheckView( - isChecked: false, - imageURL: URL(string: "https://www.google.com/s2/favicons?domain=github.com&sz=256") - ) - .frame(width: 40, height: 40) + // Unchecked state, with no icon: the placeholder + SwivelCheckView(isChecked: false, image: nil) + .frame(width: 40, height: 40) // Checked state - SwivelCheckView( - isChecked: true, - imageURL: URL(string: "https://www.google.com/s2/favicons?domain=github.com&sz=256") - ) - .frame(width: 40, height: 40) + SwivelCheckView(isChecked: true, image: nil) + .frame(width: 40, height: 40) // With file icon - SwivelCheckView( - isChecked: false, - imageURL: URL(string: "https://pushthat.link/files/pdf.png"), - fallbackIcon: "doc.fill" - ) - .frame(width: 40, height: 40) + SwivelCheckView(isChecked: false, image: Image("file-pdf"), fallbackIcon: "doc.fill") + .frame(width: 40, height: 40) // Interactive demo SwivelCheckViewDemo() @@ -116,11 +98,8 @@ struct SwivelCheckViewDemo: View { var body: some View { VStack { - SwivelCheckView( - isChecked: isChecked, - imageURL: URL(string: "https://www.google.com/s2/favicons?domain=apple.com&sz=256") - ) - .frame(width: 64, height: 64) + SwivelCheckView(isChecked: isChecked, image: Image("file-zip")) + .frame(width: 64, height: 64) .onTapGesture { isChecked.toggle() } diff --git a/apps/macos/PushThatTests/E2eeVectorTests.swift b/apps/macos/PushThatTests/E2eeVectorTests.swift index 0ae6619..886aaa5 100644 --- a/apps/macos/PushThatTests/E2eeVectorTests.swift +++ b/apps/macos/PushThatTests/E2eeVectorTests.swift @@ -1,4 +1,6 @@ import XCTest +import ImageIO +import UniformTypeIdentifiers /// Checks `E2eeCrypto` and `SearchTokenGenerator` against vectors 1 to 6 of /// `packages/push-test/vectors.json` (docs/e2ee.md, "Test vectors"), byte for byte. The file is copied into the test bundle @@ -218,6 +220,7 @@ final class E2eeVectorTests: XCTestCase { let contents: [PushContent] = [ .url(url: "https://example.com/a", title: "", ext: ""), .url(url: "https://example.com/b.pdf", title: "Example \u{e9}", ext: "pdf"), + .url(url: "https://example.com/c", title: "Example", ext: "", icon: "iVBORw0KGgo="), .file(PushFile(name: "Report (final) v2.pdf", mime: "application/pdf", size: 3_145_729, ext: "pdf", fk: E2eeCrypto.newFileKey(), np: E2eeCrypto.newFilePrefix())), .file(PushFile(name: "old.txt", mime: "", size: 0, ext: "txt", fk: nil, np: nil)), @@ -245,7 +248,7 @@ final class E2eeVectorTests: XCTestCase { let long = PushContent.url(url: "https://example.com", title: String(repeating: "t", count: 5000), ext: "") let opened = try XCTUnwrap(PushContent.open(keys: keys, userId: keys.uid, pushId: "p", type: "url", keyVersion: 1, enc: PreparedPush(content: long, keys: keys, pushId: "p").enc)) - guard case let .url(_, title, _) = opened else { return XCTFail("not a URL push") } + guard case let .url(_, title, _, _) = opened else { return XCTFail("not a URL push") } XCTAssertEqual(title.count, PushContent.maxTitleLength) let absurd = PushContent.url(url: "https://example.com/" + String(repeating: "a", count: 13_000), title: "", ext: "") @@ -254,6 +257,45 @@ final class E2eeVectorTests: XCTestCase { } } + /// `icon` is optional: absent and "" both read as none, and none is not written, so a + /// push without one is the same plaintext as before icons. + func testURLIconIsOptional() throws { + let without = try XCTUnwrap(JSONSerialization.jsonObject(with: PushContent.url(url: "https://example.com", title: "T", ext: "").json()) as? [String: Any]) + XCTAssertNil(without["icon"]) + let empty = try XCTUnwrap(JSONSerialization.jsonObject(with: PushContent.url(url: "https://example.com", title: "T", ext: "", icon: "").json()) as? [String: Any]) + XCTAssertNil(empty["icon"]) + let with = try XCTUnwrap(JSONSerialization.jsonObject(with: PushContent.url(url: "https://example.com", title: "T", ext: "", icon: "AAAA").json()) as? [String: Any]) + XCTAssertEqual(with["icon"] as? String, "AAAA") + + XCTAssertEqual(try PushContent(json: Data(#"{"url":"https://example.com","title":"T","ext":""}"#.utf8), type: "url"), + .url(url: "https://example.com", title: "T", ext: "", icon: nil)) + XCTAssertEqual(try PushContent(json: Data(#"{"url":"https://example.com","title":"T","ext":"","icon":""}"#.utf8), type: "url"), + .url(url: "https://example.com", title: "T", ext: "", icon: nil)) + XCTAssertEqual(try PushContent(json: Data(#"{"url":"https://example.com","title":"T","ext":"","icon":"AAAA"}"#.utf8), type: "url"), + .url(url: "https://example.com", title: "T", ext: "", icon: "AAAA")) + // The icon is not searched. + XCTAssertEqual(PushContent.url(url: "https://example.com", title: "T", ext: "", icon: "AAAA").searchText, "T https://example.com") + } + + /// A retitle whose envelope would be over the bound with the icon writes the title alone. + func testRetitleDropsAnIconThatDoesNotFit() throws { + let keys = try vectorKeys() + let icon = String(repeating: "A", count: 5464) + let url = "https://example.com/" + String(repeating: "a", count: 9000) + let sealed = try PushContent.sealURL(url: url, title: "T", ext: "", icon: icon, keys: keys, pushId: "p") + XCTAssertEqual(PushContent.open(keys: keys, userId: keys.uid, pushId: "p", type: "url", keyVersion: 1, enc: sealed.enc), + .url(url: url, title: "T", ext: "", icon: nil)) + + let fits = try PushContent.sealURL(url: "https://example.com", title: "T", ext: "", icon: icon, keys: keys, pushId: "p") + XCTAssertEqual(PushContent.open(keys: keys, userId: keys.uid, pushId: "p", type: "url", keyVersion: 1, enc: fits.enc), + .url(url: "https://example.com", title: "T", ext: "", icon: icon)) + + let absurd = "https://example.com/" + String(repeating: "a", count: 13_000) + XCTAssertThrowsError(try PushContent.sealURL(url: absurd, title: "", ext: "", icon: icon, keys: keys, pushId: "p")) { error in + XCTAssertEqual(error as? PushContent.ContentError, .tooLarge) + } + } + // MARK: - Vector 4: the blind index func testSearchVector() throws { @@ -479,3 +521,135 @@ final class E2eeVectorTests: XCTestCase { XCTAssertEqual(PushFile.ext(of: "README"), "") } } + +/// The icon's capture and display (docs/e2ee.md, "Icons"). No network: the candidates are +/// read from a string and the images are made here. +final class PushIconTests: XCTestCase { + private let page = URL(string: "https://www.example.com:8443/dir/page?q=1#top")! + + func testCandidateOrder() { + let html = """ + <html><head> + <LINK href='/a.png' REL="Shortcut Icon"> + <link rel=icon type="image/svg+xml" href="/vector"> + <link rel="icon" href="logo.SVG?v=1"> + <link data-x="a>b" rel="apple-touch-icon" href="https://cdn.example.com/t.png?a=1&b=2"> + <link rel="apple-touch-icon-precomposed" href="javascript:void(0)"> + <link rel="stylesheet" href="s.css"> + <link rel="icon" href="//other.example.com/i.ico" /> + <linkx rel="icon" href="/not-a-link.png"> + <link rel="icon" href="/a.png"> + <link rel="icon" href="data:image/png;base64,AAAA"> + """ + XCTAssertEqual(PushIcon.candidates(html: html, pageURL: page).map(\.absoluteString), [ + "https://cdn.example.com/t.png?a=1&b=2", + "https://www.example.com:8443/a.png", + "https://other.example.com/i.ico", + "https://www.example.com:8443/favicon.ico", + ]) + } + + func testCandidatesResolveRelativeToThePage() { + XCTAssertEqual(PushIcon.candidates(html: #"<link href="icons/x.ico" rel="icon">"#, pageURL: page).map(\.absoluteString), [ + "https://www.example.com:8443/dir/icons/x.ico", + "https://www.example.com:8443/favicon.ico", + ]) + // A <base href> is not followed. + XCTAssertEqual(PushIcon.candidates(html: #"<base href="https://elsewhere.example.com/"><link rel="icon" href="x.ico">"#, pageURL: page).first?.absoluteString, + "https://www.example.com:8443/dir/x.ico") + // No links: only the origin's favicon. Not http: nothing. + XCTAssertEqual(PushIcon.candidates(html: "", pageURL: URL(string: "http://example.com/a/b")!).map(\.absoluteString), ["http://example.com/favicon.ico"]) + XCTAssertEqual(PushIcon.candidates(html: "", pageURL: URL(string: "ftp://example.com/a")!), []) + } + + func testAtMostEightLinksThenTheFavicon() { + let html = (1...20).map { #"<link rel="icon" href="/\#($0).png">"# }.joined() + let candidates = PushIcon.candidates(html: html, pageURL: page).map(\.path) + XCTAssertEqual(candidates, (1...8).map { "/\($0).png" } + ["/favicon.ico"]) + } + + func testEncodeScalesDownKeepingAspect() throws { + let icon = try XCTUnwrap(icon(PushIcon.encode(png(width: 200, height: 100)))) + let image = try XCTUnwrap(PushIcon.decode(icon)) + XCTAssertEqual([image.width, image.height], [64, 32]) + XCTAssertLessThanOrEqual(try XCTUnwrap(Data(base64Encoded: icon)).count, PushIcon.maxBytes) + } + + func testEncodeNeverScalesUp() throws { + let image = try XCTUnwrap(PushIcon.decode(try XCTUnwrap(icon(PushIcon.encode(png(width: 16, height: 16)))))) + XCTAssertEqual([image.width, image.height], [16, 16]) + } + + /// Gray noise is over 4096 bytes at 64 pixels and under it at 32. + func testEncodeFallsBackTo32() throws { + let icon = try XCTUnwrap(icon(PushIcon.encode(png(width: 64, height: 64, noise: .gray)))) + let image = try XCTUnwrap(PushIcon.decode(icon)) + XCTAssertEqual([image.width, image.height], [32, 32]) + XCTAssertLessThanOrEqual(try XCTUnwrap(Data(base64Encoded: icon)).count, PushIcon.maxBytes) + } + + /// Noise in every channel, alpha too, is over 4096 bytes even at 32 pixels. + func testEncodeGivesUpOverTheBound() { + XCTAssertEqual(PushIcon.encode(png(width: 48, height: 48, noise: .everyChannel)), .tooLarge) + } + + func testEncodeRefusesWhatDoesNotDecode() { + XCTAssertEqual(PushIcon.encode(Data("<html>not an image</html>".utf8)), .undecodable) + XCTAssertEqual(PushIcon.encode(Data(count: PushIcon.maxDownloadBytes + 1)), .undecodable) + } + + /// Display opens only a PNG within 64 by 64. + func testDecodeChecksTheBounds() throws { + XCTAssertNotNil(PushIcon.decode(png(width: 64, height: 64).base64EncodedString())) + XCTAssertNil(PushIcon.decode(png(width: 65, height: 10).base64EncodedString())) + XCTAssertNil(PushIcon.decode(jpeg(width: 16, height: 16).base64EncodedString())) + XCTAssertNil(PushIcon.decode("not base64")) + XCTAssertNil(PushIcon.decode("")) + } + + // MARK: - Images + + private func icon(_ encoded: PushIcon.Encoded) -> String? { + if case let .icon(icon) = encoded { return icon } + return nil + } + + private enum Noise { case none, gray, everyChannel } + + /// A test image: flat gray, or noise from a fixed seed, so every run makes the same one. + private func image(width: Int, height: Int, noise: Noise) -> CGImage { + let context = CGContext(data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: width * 4, + space: CGColorSpace(name: CGColorSpace.sRGB)!, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)! + let pixels = context.data!.assumingMemoryBound(to: UInt8.self) + var seed: UInt32 = 1 + for i in 0..<(width * height) { + seed = seed &* 1_664_525 &+ 1_013_904_223 + let byte = { (shift: UInt32) in UInt8(truncatingIfNeeded: seed >> shift) } + switch noise { + case .none: + pixels[i * 4] = 128; pixels[i * 4 + 1] = 128; pixels[i * 4 + 2] = 128; pixels[i * 4 + 3] = 255 + case .gray: + pixels[i * 4] = byte(24); pixels[i * 4 + 1] = byte(24); pixels[i * 4 + 2] = byte(24); pixels[i * 4 + 3] = 255 + case .everyChannel: + pixels[i * 4] = byte(24); pixels[i * 4 + 1] = byte(16); pixels[i * 4 + 2] = byte(8); pixels[i * 4 + 3] = max(byte(4), 1) + } + } + return context.makeImage()! + } + + private func png(width: Int, height: Int, noise: Noise = .none) -> Data { + encoded(image(width: width, height: height, noise: noise), as: .png) + } + + private func jpeg(width: Int, height: Int) -> Data { + encoded(image(width: width, height: height, noise: .none), as: .jpeg) + } + + private func encoded(_ image: CGImage, as type: UTType) -> Data { + let data = NSMutableData() + let destination = CGImageDestinationCreateWithData(data, type.identifier as CFString, 1, nil)! + CGImageDestinationAddImage(destination, image, nil) + CGImageDestinationFinalize(destination) + return data as Data + } +} From 124a9a78f84cfa0cc253c4eb0ee495085db40bb7 Mon Sep 17 00:00:00 2001 From: C Deutsch <git@cdeutsch.com> Date: Thu, 12 Nov 2026 15:35:32 -0600 Subject: [PATCH 06/17] Write Android's scraped title and icon over the push as it is at the write The worker read the push, scraped for seconds, then wrote a blind update. Two devices scraping the same web push used to write the same title, so the last write did no harm, but their icons can differ (one device's decoder may open an icon another's refuses). A title-only write landing after a title-and-icon one dropped the icon for good, because a titled push is never scraped again. The write now runs in a transaction that re-reads the push and hands that to `PushDocument.retitle`, which keeps an icon the push gained meanwhile and writes nothing when nothing is new. The too-large case is caught inside the transaction, so the worker's own catch for it is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../makerocketgonow/pushthat/ShareActivity.kt | 41 +++++++++++++++---- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/ShareActivity.kt b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/ShareActivity.kt index 6e3befc..e38a811 100644 --- a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/ShareActivity.kt +++ b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/ShareActivity.kt @@ -13,6 +13,7 @@ import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.auth +import com.google.firebase.firestore.DocumentReference import com.google.firebase.firestore.FirebaseFirestoreException import com.google.firebase.firestore.Source import com.google.firebase.firestore.firestore @@ -562,9 +563,8 @@ class TitleScrapingWorker( is PushDocument.Read.Opened -> { val content = push.content as? PushContent.Url ?: return Result.success() val page = scrape(content.url, withIcon = content.icon.isEmpty()) ?: return Result.success() - val prepared = PushDocument.retitle(keys, documentId, content, page.title, page.icon) - ?: return Result.success() - mapOf("enc" to prepared.enc.toMap(), "searchTokens" to prepared.searchTokens) + writeScraped(ref, keys, documentId, page) + return Result.success() } is PushDocument.Read.Plain -> { if (push.type != PushContent.TYPE_URL) return Result.success() @@ -582,12 +582,6 @@ class TitleScrapingWorker( Log.e(TAG, "Error saving scraped title", e) // A refusal is final: the push is gone or is no longer this account's to change. if (e.code == FirebaseFirestoreException.Code.PERMISSION_DENIED) Result.failure() else Result.retry() - } catch (e: PushTooLargeException) { - // The URL with the scraped title does not fit `enc.ct`'s bound, and the page will - // give the same title next time: the push keeps the title it has. Retried, as the - // catch below would, it ran for as long as WorkManager kept the job. - Log.w(TAG, "The scraped title does not fit the push") - Result.success() } catch (e: Exception) { Log.e(TAG, "Error saving scraped title", e) Result.retry() @@ -597,6 +591,35 @@ class TitleScrapingWorker( /** What a scrape found: either may be null. */ private class Scraped(val title: String?, val icon: String?) + /** + * Writes what [page] found over the push as it is now, not as it was read before the + * scrape. The scrape takes seconds, and another device scraping the same push may write + * meanwhile. With titles alone that did no harm, since both wrote the same title; with + * icons the writes differ (one device's decoder can open an icon another's refuses), and + * a title-only write landing after a title-and-icon one dropped the icon for good, since + * a titled push is not scraped again. Re-read in a transaction, `retitle` keeps an icon + * the push gained meanwhile and writes nothing when nothing is new. + */ + private suspend fun writeScraped(ref: DocumentReference, keys: PushKeys, id: String, page: Scraped) { + db.runTransaction { tx -> + val data = tx.get(ref).data ?: return@runTransaction null + val fresh = (PushDocument.read(keys, id, data) as? PushDocument.Read.Opened)?.content as? PushContent.Url + ?: return@runTransaction null + val prepared = try { + PushDocument.retitle(keys, id, fresh, page.title, page.icon) + } catch (e: PushTooLargeException) { + // The URL with the scraped title does not fit `enc.ct`'s bound, and the page + // will give the same title next time: the push keeps the title it has. Left + // to `doWork`'s catch-all it was retried for as long as WorkManager kept the + // job, and thrown out of a transaction it would reach it as Firestore's. + Log.w(TAG, "The scraped title does not fit the push") + null + } ?: return@runTransaction null + tx.update(ref, mapOf("enc" to prepared.enc.toMap(), "searchTokens" to prepared.searchTokens)) + null + }.await() + } + /** * [url]'s title and, when [withIcon], its icon as the envelope's `icon`. Null when there * is no HTML page to read (an error, a file, a URL OkHttp refuses). From 5a194d86edc566ba1b7b273992939ade88c8469a Mon Sep 17 00:00:00 2001 From: C Deutsch <git@cdeutsch.com> Date: Thu, 12 Nov 2026 15:37:56 -0600 Subject: [PATCH 07/17] Rewrite an Apple push only for something new, over the push as it is at the write Two things were wrong with how iOS and macOS wrote a scrape back. The write was a blind update of what the list had read before a scrape that takes seconds, so when another device wrote the same push meanwhile, a title-only write could land after its title-and-icon one and drop the icon for good, since a titled push is never scraped again. And a scrape whose only find was an icon too large to fit sealed the same title again, a write every listening device received, repeated on every launch for a page with no title. `PushContent.rescraped` now holds the rules Android's `PushDocument.retitle` follows: write for a new title or a first icon, keep the icon a push has, write the title alone when the icon does not fit, and nothing when nothing is new. `PushDocuments.retitle` applies it in a transaction to the push as it is at the write. The same change is made in both apps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/ios/PushThat/Services/PushContent.swift | 32 +++++++++--- .../ios/PushThat/Services/PushDocuments.swift | 51 +++++++++++-------- .../Services/TitleScraperService.swift | 23 +++------ .../ViewModels/PushListViewModel.swift | 7 +-- apps/ios/PushThatTests/E2eeVectorTests.swift | 44 ++++++++++++---- .../macos/PushThat/Services/PushContent.swift | 32 +++++++++--- .../PushThat/Services/PushDocuments.swift | 51 +++++++++++-------- .../Services/TitleScraperService.swift | 23 +++------ .../ViewModels/PushListViewModel.swift | 7 +-- .../macos/PushThatTests/E2eeVectorTests.swift | 44 ++++++++++++---- 10 files changed, 194 insertions(+), 120 deletions(-) diff --git a/apps/ios/PushThat/Services/PushContent.swift b/apps/ios/PushThat/Services/PushContent.swift index fc1821f..11481a5 100644 --- a/apps/ios/PushThat/Services/PushContent.swift +++ b/apps/ios/PushThat/Services/PushContent.swift @@ -113,14 +113,32 @@ enum PushContent: Equatable { return (enc, tokens) } - /// A URL push sealed with its icon, or without it when the envelope with it would be - /// over the rules' bound (docs/e2ee.md, "Icons"): a long URL keeps its title rather than - /// losing both. Still too large without it, it throws `tooLarge` as before. - static func sealURL(url: String, title: String, ext: String, icon: String?, keys: PushKeys, pushId: String) throws -> (enc: E2eeCrypto.Envelope, searchTokens: [String]) { + /// The rewrite of a URL push after its page was scraped (docs/e2ee.md, "Titles" and + /// "Icons"), or nil when there is nothing to write: the rules Android's + /// `PushDocument.retitle` follows. It is rewritten when the title changed or an icon was + /// found for a push that had none, and a push keeps the icon it has. When the envelope + /// with an icon would be over the rules' bound the title is written alone, since the + /// title is what the row is read by. With no new title that leaves nothing to write: + /// sealing the same title again cost every listening device a write, and for a page with + /// no title, one on every launch. Still too large without the icon, it throws `tooLarge` + /// as before icons. + static func rescraped(_ current: PushContent, title: String?, icon: String?, keys: PushKeys, pushId: String) throws -> (enc: E2eeCrypto.Envelope, searchTokens: [String])? { + guard case let .url(url, oldTitle, ext, oldIcon) = current else { return nil } + // Cut as the envelope cuts it, so a long title read again is not taken for a change. + let newTitle = title + .map { String($0.prefix(maxTitleLength)) } + .flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || $0 == oldTitle ? nil : $0 } + let hadIcon = !(oldIcon ?? "").isEmpty + let newIcon = icon.flatMap { $0.isEmpty || hadIcon ? nil : $0 } + guard newTitle != nil || newIcon != nil else { return nil } + let keptIcon = newIcon ?? oldIcon do { - return try PushContent.url(url: url, title: title, ext: ext, icon: icon).seal(keys: keys, pushId: pushId) - } catch ContentError.tooLarge where icon != nil { - return try PushContent.url(url: url, title: title, ext: ext).seal(keys: keys, pushId: pushId) + return try PushContent.url(url: url, title: newTitle ?? oldTitle, ext: ext, icon: keptIcon).seal(keys: keys, pushId: pushId) + } catch ContentError.tooLarge where !(keptIcon ?? "").isEmpty { + // The icon goes even when it was the push's own: the URL and new title did not + // fit beside it, and the title is worth more than the picture. + guard let newTitle else { return nil } + return try PushContent.url(url: url, title: newTitle, ext: ext).seal(keys: keys, pushId: pushId) } } diff --git a/apps/ios/PushThat/Services/PushDocuments.swift b/apps/ios/PushThat/Services/PushDocuments.swift index 7a44fb1..722d2d1 100644 --- a/apps/ios/PushThat/Services/PushDocuments.swift +++ b/apps/ios/PushThat/Services/PushDocuments.swift @@ -139,28 +139,35 @@ enum PushDocuments { /// The scraped title and icon written back to an encrypted URL push: exactly `enc` and /// `searchTokens` (`isEncryptedRetitle` in the rules), the URL and ext as they were, the - /// title and icon under a fresh nonce. Only under the push's own key version, and only - /// when that is the one this device holds: a push made under another key is not ours to - /// rewrite. Last write wins. - /// With the icon the envelope can be over the rules' bound on `enc.ct`, and then the - /// title is written alone (`PushContent.sealURL`). - static func retitle( - firestore: Firestore, - keys: PushKeys, - pushId: String, - userId: String, - keyVersion: Int?, - url: String, - ext: String, - title: String, - icon: String? = nil - ) async throws { - guard keyVersion == keys.keyVersion, userId == keys.uid else { return } - let sealed = try PushContent.sealURL(url: url, title: title, ext: ext, icon: icon, keys: keys, pushId: pushId) - try await firestore.collection("pushes").document(pushId).updateData([ - "enc": sealed.enc.firestoreData, - "searchTokens": sealed.searchTokens, - ]) + /// title and icon under a fresh nonce, by `PushContent.rescraped`'s rules. Only a push + /// this device can open: one under another key version is not ours to rewrite. + /// + /// Written over the push as it is at the write, in a transaction, not as the list read + /// it before the scrape. The scrape takes seconds, and another device scraping the same + /// push may write meanwhile. With titles alone that did no harm, since both wrote the + /// same title; with icons the writes differ (one device's decoder can open an icon + /// another's refuses), and a title-only write landing after a title-and-icon one dropped + /// the icon for good, since a titled push is not scraped again. + static func retitle(firestore: Firestore, keys: PushKeys, pushId: String, title: String?, icon: String?) async throws { + let ref = firestore.collection("pushes").document(pushId) + _ = try await firestore.runTransaction { transaction, errorPointer in + do { + let data = try transaction.getDocument(ref).data() ?? [:] + guard let userId = data["userId"] as? String, + let type = data["type"] as? String, + let enc = E2eeCrypto.Envelope(firestore: data["enc"]), + let current = PushContent.open( + keys: keys, userId: userId, pushId: pushId, type: type, + keyVersion: (data["keyVersion"] as? NSNumber)?.intValue, enc: enc + ), + let sealed = try PushContent.rescraped(current, title: title, icon: icon, keys: keys, pushId: pushId) + else { return nil } + transaction.updateData(["enc": sealed.enc.firestoreData, "searchTokens": sealed.searchTokens], forDocument: ref) + } catch { + errorPointer?.pointee = error as NSError + } + return nil + } } // MARK: - Errors diff --git a/apps/ios/PushThat/Services/TitleScraperService.swift b/apps/ios/PushThat/Services/TitleScraperService.swift index 0fc5f62..d0ad00f 100644 --- a/apps/ios/PushThat/Services/TitleScraperService.swift +++ b/apps/ios/PushThat/Services/TitleScraperService.swift @@ -21,24 +21,15 @@ actor TitleScraperService { private init() {} - /// Scrapes the page and rewrites the push's envelope with what it found: exactly `enc` - /// and `searchTokens`, only under the push's own key version (`PushDocuments.retitle`). - /// It is rewritten when the title changed or an icon was found for a push that had - /// none, and a push keeps its own icon when the scrape finds none. A push that already - /// has an icon does not fetch another, since the one it has would be kept either way. - /// A failure is logged; the push keeps showing what it showed. - func scrapeAndUpdate(pushId: String, userId: String, keyVersion: Int?, url: String, title: String, ext: String, icon: String?, keys: PushKeys) async { + /// Scrapes the page and rewrites the push's envelope with what it found + /// (`PushDocuments.retitle`), only under the push's own key version. A push that already + /// has an icon does not fetch another, since it keeps the one it has either way. A + /// failure is logged; the push keeps showing what it showed. + func scrapeAndUpdate(pushId: String, keyVersion: Int?, url: String, hasIcon: Bool, keys: PushKeys) async { guard keyVersion == keys.keyVersion, attempted.insert(pushId).inserted else { return } - guard let page = await scrape(from: url, withIcon: icon == nil) else { return } - // Cut as the envelope cuts it, so a long title read again is not taken for a change. - let scraped = page.title.map { String($0.prefix(PushContent.maxTitleLength)) } ?? "" - let newTitle = scraped.isEmpty ? title : scraped - guard newTitle != title || page.icon != nil else { return } + guard let page = await scrape(from: url, withIcon: !hasIcon), page.title != nil || page.icon != nil else { return } do { - try await PushDocuments.retitle( - firestore: db, keys: keys, pushId: pushId, userId: userId, - keyVersion: keyVersion, url: url, ext: ext, title: newTitle, icon: icon ?? page.icon - ) + try await PushDocuments.retitle(firestore: db, keys: keys, pushId: pushId, title: page.title, icon: page.icon) } catch { print("Failed to update title: \(type(of: error))") } diff --git a/apps/ios/PushThat/ViewModels/PushListViewModel.swift b/apps/ios/PushThat/ViewModels/PushListViewModel.swift index 6cfa956..57df87e 100644 --- a/apps/ios/PushThat/ViewModels/PushListViewModel.swift +++ b/apps/ios/PushThat/ViewModels/PushListViewModel.swift @@ -274,14 +274,11 @@ class PushListViewModel: ObservableObject { for item in items where item.type == .url && item.title.isEmpty { guard case .encrypted = item.source, let id = item.id else { continue } let url = item.url - let title = item.title - let ext = item.ext ?? "" - let icon = item.icon - let userId = item.userId + let hasIcon = !(item.icon ?? "").isEmpty let keyVersion = item.keyVersion Task.detached(priority: .utility) { await TitleScraperService.shared.scrapeAndUpdate( - pushId: id, userId: userId, keyVersion: keyVersion, url: url, title: title, ext: ext, icon: icon, keys: keys + pushId: id, keyVersion: keyVersion, url: url, hasIcon: hasIcon, keys: keys ) } } diff --git a/apps/ios/PushThatTests/E2eeVectorTests.swift b/apps/ios/PushThatTests/E2eeVectorTests.swift index 886aaa5..2792582 100644 --- a/apps/ios/PushThatTests/E2eeVectorTests.swift +++ b/apps/ios/PushThatTests/E2eeVectorTests.swift @@ -277,21 +277,45 @@ final class E2eeVectorTests: XCTestCase { XCTAssertEqual(PushContent.url(url: "https://example.com", title: "T", ext: "", icon: "AAAA").searchText, "T https://example.com") } - /// A retitle whose envelope would be over the bound with the icon writes the title alone. - func testRetitleDropsAnIconThatDoesNotFit() throws { + /// What a scrape writes back (`PushContent.rescraped`), opened again to compare. + private func rescrape(_ current: PushContent, title: String?, icon: String?) throws -> PushContent?? { let keys = try vectorKeys() + guard let sealed = try PushContent.rescraped(current, title: title, icon: icon, keys: keys, pushId: "p") else { return .some(nil) } + return PushContent.open(keys: keys, userId: keys.uid, pushId: "p", type: "url", keyVersion: 1, enc: sealed.enc) + } + + /// A rewrite happens for a new title or a first icon, keeps the icon a push has, and + /// does not happen when the scrape found nothing new. + func testARescrapeWritesOnlyANewTitleOrAFirstIcon() throws { + let url = "https://example.com" + let untitled = PushContent.url(url: url, title: "", ext: "", icon: nil) + let titled = PushContent.url(url: url, title: "T", ext: "", icon: "AAAA") + + XCTAssertEqual(try rescrape(untitled, title: "T", icon: "AAAA"), .url(url: url, title: "T", ext: "", icon: "AAAA")) + XCTAssertEqual(try rescrape(untitled, title: nil, icon: "AAAA"), .url(url: url, title: "", ext: "", icon: "AAAA")) + XCTAssertEqual(try rescrape(untitled, title: "T", icon: nil), .url(url: url, title: "T", ext: "", icon: nil)) + // An icon the push has is kept, and not replaced by another. + XCTAssertEqual(try rescrape(titled, title: "U", icon: "BBBB"), .url(url: url, title: "U", ext: "", icon: "AAAA")) + // Nothing new: the same title, a blank one, or an icon where there is one already. + XCTAssertEqual(try rescrape(titled, title: "T", icon: "BBBB"), .some(nil)) + XCTAssertEqual(try rescrape(untitled, title: " ", icon: nil), .some(nil)) + XCTAssertEqual(try rescrape(untitled, title: nil, icon: ""), .some(nil)) + } + + /// When the envelope with the icon would be over the bound, a new title is written alone, + /// and an icon that was the only new thing is no reason to write. + func testARescrapeDropsAnIconThatDoesNotFit() throws { let icon = String(repeating: "A", count: 5464) - let url = "https://example.com/" + String(repeating: "a", count: 9000) - let sealed = try PushContent.sealURL(url: url, title: "T", ext: "", icon: icon, keys: keys, pushId: "p") - XCTAssertEqual(PushContent.open(keys: keys, userId: keys.uid, pushId: "p", type: "url", keyVersion: 1, enc: sealed.enc), - .url(url: url, title: "T", ext: "", icon: nil)) + let long = "https://example.com/" + String(repeating: "a", count: 9000) + let untitled = PushContent.url(url: long, title: "", ext: "", icon: nil) - let fits = try PushContent.sealURL(url: "https://example.com", title: "T", ext: "", icon: icon, keys: keys, pushId: "p") - XCTAssertEqual(PushContent.open(keys: keys, userId: keys.uid, pushId: "p", type: "url", keyVersion: 1, enc: fits.enc), + XCTAssertEqual(try rescrape(untitled, title: "T", icon: icon), .url(url: long, title: "T", ext: "", icon: nil)) + XCTAssertEqual(try rescrape(untitled, title: nil, icon: icon), .some(nil)) + XCTAssertEqual(try rescrape(.url(url: "https://example.com", title: "", ext: "", icon: nil), title: "T", icon: icon), .url(url: "https://example.com", title: "T", ext: "", icon: icon)) - let absurd = "https://example.com/" + String(repeating: "a", count: 13_000) - XCTAssertThrowsError(try PushContent.sealURL(url: absurd, title: "", ext: "", icon: icon, keys: keys, pushId: "p")) { error in + let absurd = PushContent.url(url: "https://example.com/" + String(repeating: "a", count: 13_000), title: "", ext: "", icon: nil) + XCTAssertThrowsError(try rescrape(absurd, title: "T", icon: icon)) { error in XCTAssertEqual(error as? PushContent.ContentError, .tooLarge) } } diff --git a/apps/macos/PushThat/Services/PushContent.swift b/apps/macos/PushThat/Services/PushContent.swift index fc1821f..11481a5 100644 --- a/apps/macos/PushThat/Services/PushContent.swift +++ b/apps/macos/PushThat/Services/PushContent.swift @@ -113,14 +113,32 @@ enum PushContent: Equatable { return (enc, tokens) } - /// A URL push sealed with its icon, or without it when the envelope with it would be - /// over the rules' bound (docs/e2ee.md, "Icons"): a long URL keeps its title rather than - /// losing both. Still too large without it, it throws `tooLarge` as before. - static func sealURL(url: String, title: String, ext: String, icon: String?, keys: PushKeys, pushId: String) throws -> (enc: E2eeCrypto.Envelope, searchTokens: [String]) { + /// The rewrite of a URL push after its page was scraped (docs/e2ee.md, "Titles" and + /// "Icons"), or nil when there is nothing to write: the rules Android's + /// `PushDocument.retitle` follows. It is rewritten when the title changed or an icon was + /// found for a push that had none, and a push keeps the icon it has. When the envelope + /// with an icon would be over the rules' bound the title is written alone, since the + /// title is what the row is read by. With no new title that leaves nothing to write: + /// sealing the same title again cost every listening device a write, and for a page with + /// no title, one on every launch. Still too large without the icon, it throws `tooLarge` + /// as before icons. + static func rescraped(_ current: PushContent, title: String?, icon: String?, keys: PushKeys, pushId: String) throws -> (enc: E2eeCrypto.Envelope, searchTokens: [String])? { + guard case let .url(url, oldTitle, ext, oldIcon) = current else { return nil } + // Cut as the envelope cuts it, so a long title read again is not taken for a change. + let newTitle = title + .map { String($0.prefix(maxTitleLength)) } + .flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || $0 == oldTitle ? nil : $0 } + let hadIcon = !(oldIcon ?? "").isEmpty + let newIcon = icon.flatMap { $0.isEmpty || hadIcon ? nil : $0 } + guard newTitle != nil || newIcon != nil else { return nil } + let keptIcon = newIcon ?? oldIcon do { - return try PushContent.url(url: url, title: title, ext: ext, icon: icon).seal(keys: keys, pushId: pushId) - } catch ContentError.tooLarge where icon != nil { - return try PushContent.url(url: url, title: title, ext: ext).seal(keys: keys, pushId: pushId) + return try PushContent.url(url: url, title: newTitle ?? oldTitle, ext: ext, icon: keptIcon).seal(keys: keys, pushId: pushId) + } catch ContentError.tooLarge where !(keptIcon ?? "").isEmpty { + // The icon goes even when it was the push's own: the URL and new title did not + // fit beside it, and the title is worth more than the picture. + guard let newTitle else { return nil } + return try PushContent.url(url: url, title: newTitle, ext: ext).seal(keys: keys, pushId: pushId) } } diff --git a/apps/macos/PushThat/Services/PushDocuments.swift b/apps/macos/PushThat/Services/PushDocuments.swift index 7a44fb1..722d2d1 100644 --- a/apps/macos/PushThat/Services/PushDocuments.swift +++ b/apps/macos/PushThat/Services/PushDocuments.swift @@ -139,28 +139,35 @@ enum PushDocuments { /// The scraped title and icon written back to an encrypted URL push: exactly `enc` and /// `searchTokens` (`isEncryptedRetitle` in the rules), the URL and ext as they were, the - /// title and icon under a fresh nonce. Only under the push's own key version, and only - /// when that is the one this device holds: a push made under another key is not ours to - /// rewrite. Last write wins. - /// With the icon the envelope can be over the rules' bound on `enc.ct`, and then the - /// title is written alone (`PushContent.sealURL`). - static func retitle( - firestore: Firestore, - keys: PushKeys, - pushId: String, - userId: String, - keyVersion: Int?, - url: String, - ext: String, - title: String, - icon: String? = nil - ) async throws { - guard keyVersion == keys.keyVersion, userId == keys.uid else { return } - let sealed = try PushContent.sealURL(url: url, title: title, ext: ext, icon: icon, keys: keys, pushId: pushId) - try await firestore.collection("pushes").document(pushId).updateData([ - "enc": sealed.enc.firestoreData, - "searchTokens": sealed.searchTokens, - ]) + /// title and icon under a fresh nonce, by `PushContent.rescraped`'s rules. Only a push + /// this device can open: one under another key version is not ours to rewrite. + /// + /// Written over the push as it is at the write, in a transaction, not as the list read + /// it before the scrape. The scrape takes seconds, and another device scraping the same + /// push may write meanwhile. With titles alone that did no harm, since both wrote the + /// same title; with icons the writes differ (one device's decoder can open an icon + /// another's refuses), and a title-only write landing after a title-and-icon one dropped + /// the icon for good, since a titled push is not scraped again. + static func retitle(firestore: Firestore, keys: PushKeys, pushId: String, title: String?, icon: String?) async throws { + let ref = firestore.collection("pushes").document(pushId) + _ = try await firestore.runTransaction { transaction, errorPointer in + do { + let data = try transaction.getDocument(ref).data() ?? [:] + guard let userId = data["userId"] as? String, + let type = data["type"] as? String, + let enc = E2eeCrypto.Envelope(firestore: data["enc"]), + let current = PushContent.open( + keys: keys, userId: userId, pushId: pushId, type: type, + keyVersion: (data["keyVersion"] as? NSNumber)?.intValue, enc: enc + ), + let sealed = try PushContent.rescraped(current, title: title, icon: icon, keys: keys, pushId: pushId) + else { return nil } + transaction.updateData(["enc": sealed.enc.firestoreData, "searchTokens": sealed.searchTokens], forDocument: ref) + } catch { + errorPointer?.pointee = error as NSError + } + return nil + } } // MARK: - Errors diff --git a/apps/macos/PushThat/Services/TitleScraperService.swift b/apps/macos/PushThat/Services/TitleScraperService.swift index 7efe474..f29df6d 100644 --- a/apps/macos/PushThat/Services/TitleScraperService.swift +++ b/apps/macos/PushThat/Services/TitleScraperService.swift @@ -21,24 +21,15 @@ actor TitleScraperService { private init() {} - /// Scrapes the page and rewrites the push's envelope with what it found: exactly `enc` - /// and `searchTokens`, only under the push's own key version (`PushDocuments.retitle`). - /// It is rewritten when the title changed or an icon was found for a push that had - /// none, and a push keeps its own icon when the scrape finds none. A push that already - /// has an icon does not fetch another, since the one it has would be kept either way. - /// A failure is logged; the push keeps showing what it showed. - func scrapeAndUpdate(pushId: String, userId: String, keyVersion: Int?, url: String, title: String, ext: String, icon: String?, keys: PushKeys) async { + /// Scrapes the page and rewrites the push's envelope with what it found + /// (`PushDocuments.retitle`), only under the push's own key version. A push that already + /// has an icon does not fetch another, since it keeps the one it has either way. A + /// failure is logged; the push keeps showing what it showed. + func scrapeAndUpdate(pushId: String, keyVersion: Int?, url: String, hasIcon: Bool, keys: PushKeys) async { guard keyVersion == keys.keyVersion, attempted.insert(pushId).inserted else { return } - guard let page = await scrape(from: url, withIcon: icon == nil) else { return } - // Cut as the envelope cuts it, so a long title read again is not taken for a change. - let scraped = page.title.map { String($0.prefix(PushContent.maxTitleLength)) } ?? "" - let newTitle = scraped.isEmpty ? title : scraped - guard newTitle != title || page.icon != nil else { return } + guard let page = await scrape(from: url, withIcon: !hasIcon), page.title != nil || page.icon != nil else { return } do { - try await PushDocuments.retitle( - firestore: db, keys: keys, pushId: pushId, userId: userId, - keyVersion: keyVersion, url: url, ext: ext, title: newTitle, icon: icon ?? page.icon - ) + try await PushDocuments.retitle(firestore: db, keys: keys, pushId: pushId, title: page.title, icon: page.icon) } catch { print("Failed to update title: \(type(of: error))") } diff --git a/apps/macos/PushThat/ViewModels/PushListViewModel.swift b/apps/macos/PushThat/ViewModels/PushListViewModel.swift index c06ff2c..51c1ef0 100644 --- a/apps/macos/PushThat/ViewModels/PushListViewModel.swift +++ b/apps/macos/PushThat/ViewModels/PushListViewModel.swift @@ -267,14 +267,11 @@ class PushListViewModel: ObservableObject { for item in items where item.type == .url && item.title.isEmpty { guard case .encrypted = item.source, let id = item.id else { continue } let url = item.url - let title = item.title - let ext = item.ext ?? "" - let icon = item.icon - let userId = item.userId + let hasIcon = !(item.icon ?? "").isEmpty let keyVersion = item.keyVersion Task.detached(priority: .utility) { await TitleScraperService.shared.scrapeAndUpdate( - pushId: id, userId: userId, keyVersion: keyVersion, url: url, title: title, ext: ext, icon: icon, keys: keys + pushId: id, keyVersion: keyVersion, url: url, hasIcon: hasIcon, keys: keys ) } } diff --git a/apps/macos/PushThatTests/E2eeVectorTests.swift b/apps/macos/PushThatTests/E2eeVectorTests.swift index 886aaa5..2792582 100644 --- a/apps/macos/PushThatTests/E2eeVectorTests.swift +++ b/apps/macos/PushThatTests/E2eeVectorTests.swift @@ -277,21 +277,45 @@ final class E2eeVectorTests: XCTestCase { XCTAssertEqual(PushContent.url(url: "https://example.com", title: "T", ext: "", icon: "AAAA").searchText, "T https://example.com") } - /// A retitle whose envelope would be over the bound with the icon writes the title alone. - func testRetitleDropsAnIconThatDoesNotFit() throws { + /// What a scrape writes back (`PushContent.rescraped`), opened again to compare. + private func rescrape(_ current: PushContent, title: String?, icon: String?) throws -> PushContent?? { let keys = try vectorKeys() + guard let sealed = try PushContent.rescraped(current, title: title, icon: icon, keys: keys, pushId: "p") else { return .some(nil) } + return PushContent.open(keys: keys, userId: keys.uid, pushId: "p", type: "url", keyVersion: 1, enc: sealed.enc) + } + + /// A rewrite happens for a new title or a first icon, keeps the icon a push has, and + /// does not happen when the scrape found nothing new. + func testARescrapeWritesOnlyANewTitleOrAFirstIcon() throws { + let url = "https://example.com" + let untitled = PushContent.url(url: url, title: "", ext: "", icon: nil) + let titled = PushContent.url(url: url, title: "T", ext: "", icon: "AAAA") + + XCTAssertEqual(try rescrape(untitled, title: "T", icon: "AAAA"), .url(url: url, title: "T", ext: "", icon: "AAAA")) + XCTAssertEqual(try rescrape(untitled, title: nil, icon: "AAAA"), .url(url: url, title: "", ext: "", icon: "AAAA")) + XCTAssertEqual(try rescrape(untitled, title: "T", icon: nil), .url(url: url, title: "T", ext: "", icon: nil)) + // An icon the push has is kept, and not replaced by another. + XCTAssertEqual(try rescrape(titled, title: "U", icon: "BBBB"), .url(url: url, title: "U", ext: "", icon: "AAAA")) + // Nothing new: the same title, a blank one, or an icon where there is one already. + XCTAssertEqual(try rescrape(titled, title: "T", icon: "BBBB"), .some(nil)) + XCTAssertEqual(try rescrape(untitled, title: " ", icon: nil), .some(nil)) + XCTAssertEqual(try rescrape(untitled, title: nil, icon: ""), .some(nil)) + } + + /// When the envelope with the icon would be over the bound, a new title is written alone, + /// and an icon that was the only new thing is no reason to write. + func testARescrapeDropsAnIconThatDoesNotFit() throws { let icon = String(repeating: "A", count: 5464) - let url = "https://example.com/" + String(repeating: "a", count: 9000) - let sealed = try PushContent.sealURL(url: url, title: "T", ext: "", icon: icon, keys: keys, pushId: "p") - XCTAssertEqual(PushContent.open(keys: keys, userId: keys.uid, pushId: "p", type: "url", keyVersion: 1, enc: sealed.enc), - .url(url: url, title: "T", ext: "", icon: nil)) + let long = "https://example.com/" + String(repeating: "a", count: 9000) + let untitled = PushContent.url(url: long, title: "", ext: "", icon: nil) - let fits = try PushContent.sealURL(url: "https://example.com", title: "T", ext: "", icon: icon, keys: keys, pushId: "p") - XCTAssertEqual(PushContent.open(keys: keys, userId: keys.uid, pushId: "p", type: "url", keyVersion: 1, enc: fits.enc), + XCTAssertEqual(try rescrape(untitled, title: "T", icon: icon), .url(url: long, title: "T", ext: "", icon: nil)) + XCTAssertEqual(try rescrape(untitled, title: nil, icon: icon), .some(nil)) + XCTAssertEqual(try rescrape(.url(url: "https://example.com", title: "", ext: "", icon: nil), title: "T", icon: icon), .url(url: "https://example.com", title: "T", ext: "", icon: icon)) - let absurd = "https://example.com/" + String(repeating: "a", count: 13_000) - XCTAssertThrowsError(try PushContent.sealURL(url: absurd, title: "", ext: "", icon: icon, keys: keys, pushId: "p")) { error in + let absurd = PushContent.url(url: "https://example.com/" + String(repeating: "a", count: 13_000), title: "", ext: "", icon: nil) + XCTAssertThrowsError(try rescrape(absurd, title: "T", icon: icon)) { error in XCTAssertEqual(error as? PushContent.ContentError, .tooLarge) } } From 2f819e0251ee33c2b129e93fceffb8045d4a1a8c Mon Sep 17 00:00:00 2001 From: C Deutsch <git@cdeutsch.com> Date: Thu, 12 Nov 2026 15:39:52 -0600 Subject: [PATCH 08/17] Give a scrape's icon requests a time budget and stop them when the job stops The scrape's timeouts bounded only the gap between bytes, so a server sending a byte every few seconds held a request open indefinitely. That was tolerable for one page, but the icon adds up to nine requests after it. On Android the candidate loop also blocked on an IO thread and ran on through every candidate after WorkManager had stopped the worker. Android now gives every request of a scrape a 15-second `callTimeout` and checks between candidates both that the worker is still running and a 30-second budget for the icons. iOS and macOS check the same budget, and the task's cancellation, between candidates and while one downloads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../makerocketgonow/pushthat/ShareActivity.kt | 22 +++++++++++++++++-- .../Services/TitleScraperService.swift | 13 +++++++++-- .../Services/TitleScraperService.swift | 13 +++++++++-- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/ShareActivity.kt b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/ShareActivity.kt index e38a811..270bd13 100644 --- a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/ShareActivity.kt +++ b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/ShareActivity.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.net.Uri import android.os.Build import android.os.Bundle +import android.os.SystemClock import android.util.Log import android.view.animation.Animation import android.view.animation.RotateAnimation @@ -32,6 +33,8 @@ import com.makerocketgonow.pushthat.e2ee.PushKeys import com.makerocketgonow.pushthat.e2ee.PushStore import com.makerocketgonow.pushthat.e2ee.PushTooLargeException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.tasks.await @@ -626,7 +629,10 @@ class TitleScrapingWorker( */ private suspend fun scrape(url: String, withIcon: Boolean): Scraped? { return withContext(Dispatchers.IO) { - val client = OkHttpClient() + // `callTimeout` bounds a whole request, where the default timeouts bound only a + // gap between bytes: a server sending one byte every few seconds held a request + // open indefinitely, and the icon adds up to nine requests to the page's. + val client = OkHttpClient.Builder().callTimeout(CALL_TIMEOUT_SECONDS, TimeUnit.SECONDS).build() try { val request = Request.Builder() .url(url) @@ -677,8 +683,14 @@ class TitleScrapingWorker( * nothing about it may cost the push its title. Logged by the exception's type only, * since its message can name the host. */ - private fun fetchIcon(client: OkHttpClient, candidates: List<HttpUrl>): String? { + private suspend fun fetchIcon(client: OkHttpClient, candidates: List<HttpUrl>): String? { + // Stopped between candidates when WorkManager stops the worker (the network went, or + // its time ran out), and after a budget for the lot: the requests block, so without + // these checks the loop ran on through every candidate on an IO thread regardless. + val deadline = SystemClock.elapsedRealtime() + ICON_BUDGET_MS for (candidate in candidates) { + currentCoroutineContext().ensureActive() + if (SystemClock.elapsedRealtime() > deadline) return null try { val request = Request.Builder().url(candidate).header("User-Agent", USER_AGENT).build() val bytes = client.newCall(request).execute().use { response -> @@ -710,6 +722,12 @@ class TitleScrapingWorker( private const val USER_AGENT = "Mozilla/5.0" + /** The longest any one request of a scrape may take, start to finish. */ + private const val CALL_TIMEOUT_SECONDS = 15L + + /** How long the icon candidates are tried for, after the page. */ + private const val ICON_BUDGET_MS = 30_000L + fun enqueue(context: Context, documentId: String) { val constraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) diff --git a/apps/ios/PushThat/Services/TitleScraperService.swift b/apps/ios/PushThat/Services/TitleScraperService.swift index d0ad00f..207afab 100644 --- a/apps/ios/PushThat/Services/TitleScraperService.swift +++ b/apps/ios/PushThat/Services/TitleScraperService.swift @@ -38,6 +38,9 @@ actor TitleScraperService { /// How much of a page is read for its title and its icon links. private static let maxPageBytes = 256 * 1024 + /// How long the icon candidates are tried for, after the page. + private static let iconBudget: TimeInterval = 30 + /// Sent with the page's request and its icons' alike, so the site sees one browser. private static let userAgent = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1" @@ -97,8 +100,13 @@ actor TitleScraperService { /// time, best first, so a page whose first candidate is good costs one request more /// than its title. private func fetchIcon(from candidates: [URL]) async -> String? { + // A budget for the lot, checked between candidates and while one downloads: + // `timeoutInterval` bounds a gap between bytes, not a request, so a server sending a + // byte every few seconds held one open indefinitely, and there can be nine. + let deadline = Date().addingTimeInterval(Self.iconBudget) for candidate in candidates { - guard let data = await download(candidate) else { continue } + guard !Task.isCancelled, Date() < deadline else { return nil } + guard let data = await download(candidate, until: deadline) else { continue } switch PushIcon.encode(data) { case let .icon(icon): return icon case .tooLarge: return nil @@ -111,7 +119,7 @@ actor TitleScraperService { /// A candidate's bytes, or nil when it does not answer 200 or is over /// `PushIcon.maxDownloadBytes`. Streamed as the page is, so a large file is given up at /// the cap rather than pulled into memory first. - private func download(_ url: URL) async -> Data? { + private func download(_ url: URL, until deadline: Date) async -> Data? { var request = URLRequest(url: url) request.setValue(Self.userAgent, forHTTPHeaderField: "User-Agent") request.timeoutInterval = 10 @@ -124,6 +132,7 @@ actor TitleScraperService { for try await byte in bytes { data.append(byte) if data.count > PushIcon.maxDownloadBytes { return nil } + if data.count % 4096 == 0, Task.isCancelled || Date() >= deadline { return nil } } return data } catch { diff --git a/apps/macos/PushThat/Services/TitleScraperService.swift b/apps/macos/PushThat/Services/TitleScraperService.swift index f29df6d..f311a55 100644 --- a/apps/macos/PushThat/Services/TitleScraperService.swift +++ b/apps/macos/PushThat/Services/TitleScraperService.swift @@ -38,6 +38,9 @@ actor TitleScraperService { /// How much of a page is read for its title and its icon links. private static let maxPageBytes = 256 * 1024 + /// How long the icon candidates are tried for, after the page. + private static let iconBudget: TimeInterval = 30 + /// Sent with the page's request and its icons' alike, so the site sees one browser. private static let userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15" @@ -97,8 +100,13 @@ actor TitleScraperService { /// time, best first, so a page whose first candidate is good costs one request more /// than its title. private func fetchIcon(from candidates: [URL]) async -> String? { + // A budget for the lot, checked between candidates and while one downloads: + // `timeoutInterval` bounds a gap between bytes, not a request, so a server sending a + // byte every few seconds held one open indefinitely, and there can be nine. + let deadline = Date().addingTimeInterval(Self.iconBudget) for candidate in candidates { - guard let data = await download(candidate) else { continue } + guard !Task.isCancelled, Date() < deadline else { return nil } + guard let data = await download(candidate, until: deadline) else { continue } switch PushIcon.encode(data) { case let .icon(icon): return icon case .tooLarge: return nil @@ -111,7 +119,7 @@ actor TitleScraperService { /// A candidate's bytes, or nil when it does not answer 200 or is over /// `PushIcon.maxDownloadBytes`. Streamed as the page is, so a large file is given up at /// the cap rather than pulled into memory first. - private func download(_ url: URL) async -> Data? { + private func download(_ url: URL, until deadline: Date) async -> Data? { var request = URLRequest(url: url) request.setValue(Self.userAgent, forHTTPHeaderField: "User-Agent") request.timeoutInterval = 10 @@ -124,6 +132,7 @@ actor TitleScraperService { for try await byte in bytes { data.append(byte) if data.count > PushIcon.maxDownloadBytes { return nil } + if data.count % 4096 == 0, Task.isCancelled || Date() >= deadline { return nil } } return data } catch { From 6f7bca83e849c3243c1014e021aa90bba2ebec6d Mon Sep 17 00:00:00 2001 From: C Deutsch <git@cdeutsch.com> Date: Thu, 12 Nov 2026 15:40:12 -0600 Subject: [PATCH 09/17] Look up web file icons in a Map so a file's name cannot reach Object's properties `FILE_ICONS` was a plain object indexed by the extension, which comes from a file's name. A file named with an extension such as `constructor` or `__proto__` got what the object inherits: a function, which React drops so the row drew nothing, or an object, which became a request for `[object Object]`. A `Map` answers only for the icons it holds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/web/src/components/PushesList.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/PushesList.tsx b/apps/web/src/components/PushesList.tsx index ddaed25..213c909 100644 --- a/apps/web/src/components/PushesList.tsx +++ b/apps/web/src/components/PushesList.tsx @@ -22,7 +22,9 @@ interface PushesListProps { * hosting logs (docs/e2ee.md, Icons). The PNGs in `public/files` stay, for app versions * from before this change, which still load them from there. */ -const FILE_ICONS: Record<string, string> = Object.fromEntries( +// A Map, not an object: an extension comes from a file's name, and an object would answer +// `constructor` or `__proto__` with what it inherits. +const FILE_ICONS = new Map<string, string>( Object.entries( import.meta.glob<string>('../assets/file-icons/*.svg', { eager: true, query: '?inline', import: 'default' }) ).map(([path, src]) => [path.slice(path.lastIndexOf('/') + 1, -'.svg'.length), src]) @@ -40,7 +42,7 @@ const PLACEHOLDER = '/pushthat-mono.svg'; const iconSrc = (push: Push, broken: boolean): string => { if (push.locked) return PLACEHOLDER; if (push.icon && !broken) return `data:image/png;base64,${push.icon}`; - return (push.ext && FILE_ICONS[push.ext.toLowerCase()]) || PLACEHOLDER; + return (push.ext && FILE_ICONS.get(push.ext.toLowerCase())) || PLACEHOLDER; }; /** From 316e2e5fd9432d5a1c6248e3a4c7c954f8be7095 Mon Sep 17 00:00:00 2001 From: C Deutsch <git@cdeutsch.com> Date: Thu, 12 Nov 2026 15:40:23 -0600 Subject: [PATCH 10/17] Tell Android users that links from before the update show a plain icon Links pushed before this version carry no icon and are not fetched again for one, so on the phone they change from a site's logo to the placeholder. Unexplained, that reads as a bug. The note now says so, trimmed elsewhere to stay under Play's 500 characters. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/android/store/whatsnew/whatsnew-en-US | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/android/store/whatsnew/whatsnew-en-US b/apps/android/store/whatsnew/whatsnew-en-US index e250b9d..7d8d1ed 100644 --- a/apps/android/store/whatsnew/whatsnew-en-US +++ b/apps/android/store/whatsnew/whatsnew-en-US @@ -1,3 +1,3 @@ -Your pushes are now end-to-end encrypted: only devices you sign in on can read them. Signing in shows a recovery key. Save it, because it is the only way back to your pushes if you forget your password. Change password and Show a new recovery key are in the menu. +Your pushes are now end-to-end encrypted: only devices you sign in on can read them. Signing in shows a recovery key. Save it: it is the only way back to your pushes if you forget your password. Change password and Show a new recovery key are in the menu. -A link's icon now comes from the page and is kept with the push, so drawing your list asks Google for nothing. Notifications and search work on encrypted pushes, and a share that cannot be confirmed is retried, not dropped. +A link's icon now comes from its page and travels with the push, so your list never asks Google. Older links show a plain icon. Notifications and search work on encrypted pushes, and an unconfirmed share is retried, not dropped. From bcdb5497ed1e9d4ee95f6e748b73faa1fec92624 Mon Sep 17 00:00:00 2001 From: C Deutsch <git@cdeutsch.com> Date: Thu, 12 Nov 2026 15:43:26 -0600 Subject: [PATCH 11/17] Draw a page icon on every client only when it is a PNG within the spec's bounds The clients disagreed on which icons they would draw. iOS and macOS opened only a PNG within 64 by 64, Android drew anything `BitmapFactory` opened (sampled to up to 127 pixels a side, past what its cache's size comment allowed) and decoded a refused icon again on every bind, and the web drew whatever the browser decoded behind `data:image/png`. Every writer produces in-spec icons today, but one that did not would draw on two platforms and fall back on the third. Now every client reads the header first and draws only a PNG of at most 64 by 64 and 4096 bytes, as the spec now says. Android remembers icons it refused, and the web's check moved to `pageIcon.ts` so `npm run test:keys` covers it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../com/makerocketgonow/pushthat/PageIcons.kt | 16 ++++++ .../pushthat/SwivelCheckView.kt | 25 +++++---- apps/ios/PushThat/Services/PushContent.swift | 2 +- .../macos/PushThat/Services/PushContent.swift | 2 +- apps/web/package.json | 2 +- apps/web/src/services/pageIcon.ts | 30 ++++++++++ apps/web/src/services/pushes.ts | 15 +---- apps/web/tests/pageIcon.mjs | 55 +++++++++++++++++++ docs/e2ee.md | 2 +- 9 files changed, 120 insertions(+), 29 deletions(-) create mode 100644 apps/web/src/services/pageIcon.ts create mode 100644 apps/web/tests/pageIcon.mjs diff --git a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/PageIcons.kt b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/PageIcons.kt index 50e025a..7db7c79 100644 --- a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/PageIcons.kt +++ b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/PageIcons.kt @@ -106,6 +106,22 @@ object PageIcons { return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options) } + /** + * An envelope's `icon` for a row, or null when it is not what the spec says one is: a + * PNG of at most [MAX_SIDE] a side and [MAX_ICON_BYTES], checked before decoding. Every + * client draws exactly that and nothing else (docs/e2ee.md, Icons), so an icon some + * other writer got wrong is passed over the same way everywhere rather than drawing on + * one platform and not another, and a PNG claiming to be huge is never decoded. + */ + fun decodeForDisplay(bytes: ByteArray): Bitmap? { + if (bytes.size > MAX_ICON_BYTES) return null + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + if (bounds.outMimeType != "image/png") return null + if (bounds.outWidth !in 1..MAX_SIDE || bounds.outHeight !in 1..MAX_SIDE) return null + return BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + } + /** * [bitmap] as the envelope's `icon`: fit to [MAX_SIDE], PNG, base64; at [FALLBACK_SIDE] * when that is over [MAX_ICON_BYTES]; null when that is too. Null is no icon, not a diff --git a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/SwivelCheckView.kt b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/SwivelCheckView.kt index 97b1049..e26ab67 100644 --- a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/SwivelCheckView.kt +++ b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/SwivelCheckView.kt @@ -96,24 +96,27 @@ class SwivelCheckView @JvmOverloads constructor( companion object { /** - * Page icons already decoded, by their base64. Decoding one is cheap (at most 64 by - * 64 and 4 KB), but a bind happens on every scroll, so each is decoded once on the - * main thread rather than per bind or through a loader thread. Keyed by the string - * rather than the push, so a push whose icon changes is never drawn with the old one. - * 256 icons of at most 16 KB each is about 4 MB. + * Page icons already decoded, by their base64. Decoding one is cheap (a PNG of at + * most 64 by 64 and 4 KB), but a bind happens on every scroll, so each is decoded once + * on the main thread rather than per bind or through a loader thread. Keyed by the + * string rather than the push, so a push whose icon changes is never drawn with the + * old one. 256 icons of at most 16 KB each is about 4 MB. */ private val decodedIcons = LruCache<String, Bitmap>(256) + /** Icons that would not draw, remembered so a bind does not decode them again. */ + private val refusedIcons = LruCache<String, Unit>(64) + private fun decodedIcon(icon: String): Bitmap? { decodedIcons.get(icon)?.let { return it } - val bitmap = try { - // Sampled like a scraped icon: the envelope says 64 by 64, but a PNG of a few - // KB can claim to be far larger, and decoded whole it would not fit in memory. - PageIcons.decode(E2ee.fromBase64(icon)) + if (refusedIcons.get(icon) != null) return null + val bytes = try { + E2ee.fromBase64(icon) } catch (e: IllegalArgumentException) { null - } ?: return null - decodedIcons.put(icon, bitmap) + } + val bitmap = bytes?.let { PageIcons.decodeForDisplay(it) } + if (bitmap == null) refusedIcons.put(icon, Unit) else decodedIcons.put(icon, bitmap) return bitmap } diff --git a/apps/ios/PushThat/Services/PushContent.swift b/apps/ios/PushThat/Services/PushContent.swift index 11481a5..52d32b0 100644 --- a/apps/ios/PushThat/Services/PushContent.swift +++ b/apps/ios/PushThat/Services/PushContent.swift @@ -473,7 +473,7 @@ enum PushIcon { /// before any pixel is decoded: whatever wrote the envelope, a small file must not /// become a large image in a list of them. static func decode(_ icon: String) -> CGImage? { - guard let data = Data(base64Encoded: icon), + guard let data = Data(base64Encoded: icon), data.count <= maxBytes, let source = CGImageSourceCreateWithData(data as CFData, nil), CGImageSourceGetType(source) as String? == UTType.png.identifier, let size = pixelSize(source, 0), size.width <= maxSide, size.height <= maxSide diff --git a/apps/macos/PushThat/Services/PushContent.swift b/apps/macos/PushThat/Services/PushContent.swift index 11481a5..52d32b0 100644 --- a/apps/macos/PushThat/Services/PushContent.swift +++ b/apps/macos/PushThat/Services/PushContent.swift @@ -473,7 +473,7 @@ enum PushIcon { /// before any pixel is decoded: whatever wrote the envelope, a small file must not /// become a large image in a list of them. static func decode(_ icon: String) -> CGImage? { - guard let data = Data(base64Encoded: icon), + guard let data = Data(base64Encoded: icon), data.count <= maxBytes, let source = CGImageSourceCreateWithData(data as CFData, nil), CGImageSourceGetType(source) as String? == UTType.png.identifier, let size = pixelSize(source, 0), size.width <= maxSide, size.height <= maxSide diff --git a/apps/web/package.json b/apps/web/package.json index 5013451..4a644cf 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,7 +12,7 @@ "preview": "vite preview", "preview:firebase": "firebase emulators:start", "test:rules": "firebase emulators:exec --project demo-pushthat --only firestore,storage \"node --test --test-concurrency=1 tests/*.test.mjs\"", - "test:keys": "node --test tests/e2ee.vectors.mjs tests/keyDecisions.mjs tests/pushRefusal.mjs tests/viewable.mjs", + "test:keys": "node --test tests/e2ee.vectors.mjs tests/keyDecisions.mjs tests/pushRefusal.mjs tests/viewable.mjs tests/pageIcon.mjs", "format": "prettier --write .", "format:check": "prettier --check .", "spellcheck": "cspell -c ../../cspell.json --gitignore \"**/*.{js,json,jsx,md,ts,tsx}\"" diff --git a/apps/web/src/services/pageIcon.ts b/apps/web/src/services/pageIcon.ts new file mode 100644 index 0000000..1b18592 --- /dev/null +++ b/apps/web/src/services/pageIcon.ts @@ -0,0 +1,30 @@ +// A URL push's page icon as the list may draw it (docs/e2ee.md, Icons). Apart from +// `pushes.ts`, which needs Firebase, so that `npm run test:keys` checks it in plain Node. + +// 4096 bytes of PNG in padded base64, at most 64 pixels a side (docs/e2ee.md, Icons). +const MAX_ICON_LENGTH = 5464; +const MAX_ICON_SIDE = 64; +const BASE64 = /^[A-Za-z0-9+/]+={0,2}$/; +const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + +/** + * The envelope's icon, if it is one the list can show. It ends up in a `data:` URL, so it + * must be base64 and nothing else: a string that is not cannot smuggle anything into the + * `src`. It must also be what every client draws and nothing else, a PNG within 64 by 64, + * read from its header before the browser decodes it, so an icon some other writer got + * wrong is passed over here as it is on the phones. A PNG that passes and still + * will not decode is the `img`'s `onError`. + */ +export const iconOf = (value: unknown): string | undefined => { + if (typeof value !== 'string' || value.length > MAX_ICON_LENGTH || value.length % 4 !== 0 || !BASE64.test(value)) { + return undefined; + } + // The signature, then IHDR's length and type, then its width and height: 24 bytes, which + // are the first 32 characters of the base64. + const header = Uint8Array.from(atob(value.slice(0, 32)), (c) => c.charCodeAt(0)); + if (header.length < 24 || PNG_SIGNATURE.some((byte, i) => header[i] !== byte)) return undefined; + const view = new DataView(header.buffer); + const width = view.getUint32(16); + const height = view.getUint32(20); + return width >= 1 && width <= MAX_ICON_SIDE && height >= 1 && height <= MAX_ICON_SIDE ? value : undefined; +}; diff --git a/apps/web/src/services/pushes.ts b/apps/web/src/services/pushes.ts index 3824681..0d20d46 100644 --- a/apps/web/src/services/pushes.ts +++ b/apps/web/src/services/pushes.ts @@ -46,6 +46,7 @@ import { searchWord, } from './e2ee'; import { deviceId, loadAccountKey, rememberKeyVersion } from './keyStore'; +import { iconOf } from './pageIcon'; /** * Pushes as docs/e2ee.md has them since phase 4 of that plan: written with their content in @@ -149,20 +150,6 @@ const isEnc = (value: unknown): value is Enc => const stringOr = (value: unknown, fallback = ''): string => (typeof value === 'string' ? value : fallback); -// 4096 bytes of PNG in padded base64 (docs/e2ee.md, Icons). -const MAX_ICON_LENGTH = 5464; -const BASE64 = /^[A-Za-z0-9+/]+={0,2}$/; - -/** - * The envelope's icon, if it is one the list can show. It ends up in a `data:` URL, so it - * must be base64 and nothing else: a string that is not cannot smuggle anything into the - * `src`, and a PNG that will not decode is the `img`'s `onError`, not this check's. - */ -const iconOf = (value: unknown): string | undefined => - typeof value === 'string' && value.length <= MAX_ICON_LENGTH && value.length % 4 === 0 && BASE64.test(value) - ? value - : undefined; - export async function readPush(snapshot: DocumentSnapshot<DocumentData>, keys: PushKeys | undefined): Promise<Push> { const data = snapshot.data() ?? {}; const base = { diff --git a/apps/web/tests/pageIcon.mjs b/apps/web/tests/pageIcon.mjs new file mode 100644 index 0000000..cec0fa3 --- /dev/null +++ b/apps/web/tests/pageIcon.mjs @@ -0,0 +1,55 @@ +// Which page icons the list draws (src/services/pageIcon.ts): only what the envelope's +// `icon` may hold, a PNG of at most 64 by 64 in base64, read from its header, so that every +// client passes over the same wrong ones. Run by `npm run test:keys`, in plain Node. +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import { iconOf } from '../src/services/pageIcon.ts'; + +/** The first 33 bytes of a PNG of `width` by `height`: its signature and its IHDR. */ +const pngHeader = (width, height) => { + const bytes = new Uint8Array(33); + bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52]); + const view = new DataView(bytes.buffer); + view.setUint32(16, width); + view.setUint32(20, height); + return Buffer.from(bytes).toString('base64'); +}; + +describe('iconOf', () => { + test('takes a PNG within 64 by 64', () => { + for (const [w, h] of [ + [64, 64], + [1, 1], + [64, 32], + [16, 16], + ]) { + assert.equal(iconOf(pngHeader(w, h)), pngHeader(w, h)); + } + }); + + test('passes over a PNG larger than 64 a side, or with no pixels', () => { + for (const [w, h] of [ + [65, 64], + [64, 65], + [4096, 4096], + [0, 16], + ]) { + assert.equal(iconOf(pngHeader(w, h)), undefined); + } + }); + + test('passes over what is not a PNG', () => { + const gif = Buffer.from('GIF89a' + '\0'.repeat(27), 'binary').toString('base64'); + assert.equal(iconOf(gif), undefined); + assert.equal(iconOf(Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"/>').toString('base64')), undefined); + }); + + test('passes over a string that is not base64, or is too long, before reading it', () => { + assert.equal(iconOf(undefined), undefined); + assert.equal(iconOf(''), undefined); + assert.equal(iconOf(`${pngHeader(16, 16)}"onerror="x`), undefined); + assert.equal(iconOf(pngHeader(16, 16).slice(0, -1)), undefined); + assert.equal(iconOf(pngHeader(16, 16) + 'A'.repeat(5464)), undefined); + }); +}); diff --git a/docs/e2ee.md b/docs/e2ee.md index 3257481..101043c 100644 --- a/docs/e2ee.md +++ b/docs/e2ee.md @@ -197,7 +197,7 @@ A row shows the linked site's icon, and the icon travels in the envelope with th **Writing it.** The icon goes into the rewrite that fills in the title, so the rule (`enc` and `searchTokens`, `isEncryptedRetitle`) is unchanged. A rewrite happens when the title changed or an icon was found for a push that had none. A push that has an icon is not asked for one again and keeps it, and `icon` is written only when there is one, so a push without it encrypts the same bytes as before. 4096 bytes is 5464 characters of base64, which leaves the URL and title room inside `enc.ct`'s 16384 for every ordinary link; when the envelope with the icon would not fit, the icon is dropped and the title written alone. Which pushes are scraped is as before: a device's own, and a received one with no title. Old pushes are not scraped again for an icon. -**Showing it.** A URL push draws its decoded icon; without one, or when it does not decode, the icon for its `ext` (a link to a PDF has no page to take an icon from), and failing that the placeholder a locked push draws. A file push draws the icon for its `ext` or the placeholder. Nothing is fetched to draw a row. The web app builds a `data:image/png;base64,` URL only from a string of base64 characters. A file push draws the icon for its `ext` from images shipped inside each app, never from pushthat.link, whose logs would otherwise see the type of every encrypted file a list showed. +**Showing it.** A URL push draws its decoded icon; without one, or when it does not decode, the icon for its `ext` (a link to a PDF has no page to take an icon from), and failing that the placeholder a locked push draws. A file push draws the icon for its `ext` or the placeholder. Nothing is fetched to draw a row. Every client draws only what the field may hold, a PNG of at most 64 by 64 and 4096 bytes, read from its header before it is decoded, so an icon some other writer got wrong is passed over the same way on every client rather than drawing on one platform and not another. The web app builds a `data:image/png;base64,` URL only from a string of base64 characters. A file push draws the icon for its `ext` from images shipped inside each app, never from pushthat.link, whose logs would otherwise see the type of every encrypted file a list showed. ## Rules From 4cbc00039e889c8ed3c3ef988a36ff474ca3ba27 Mon Sep 17 00:00:00 2001 From: C Deutsch <git@cdeutsch.com> Date: Thu, 12 Nov 2026 15:43:43 -0600 Subject: [PATCH 12/17] Remember a web icon that would not decode by the icon, not by the push `brokenIcons` was keyed by push id and lives as long as the list. A push whose icon changed while the list was open (a later scrape, or an icon dropped for a title that no longer fit) kept drawing the fallback for an icon it no longer had. The phones key their caches by the icon string for this reason, and so does the web now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/web/src/components/PushesList.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/PushesList.tsx b/apps/web/src/components/PushesList.tsx index 213c909..60256b9 100644 --- a/apps/web/src/components/PushesList.tsx +++ b/apps/web/src/components/PushesList.tsx @@ -77,6 +77,8 @@ export default function PushesList({ onSelectedPushesChange, }: PushesListProps) { // Page icons that would not decode, which then draw the type's icon or the placeholder. + // By the icon, not the push, as the phones' caches are: a push whose icon changes while + // the list is open is drawn with its new one. const [brokenIcons, setBrokenIcons] = useState<Set<string>>(new Set()); const [opening, setOpening] = useState<string | undefined>(undefined); // What opening a row has to say (a locked push, a file that did not decrypt), shown above @@ -153,10 +155,13 @@ export default function PushesList({ <CheckIcon /> ) : ( <img - src={iconSrc(push, brokenIcons.has(push.id))} + src={iconSrc(push, !!push.icon && brokenIcons.has(push.icon))} alt="" className={styles.favicon} - onError={() => setBrokenIcons((prev) => new Set(prev).add(push.id))} + onError={() => { + const { icon } = push; + if (icon) setBrokenIcons((prev) => new Set(prev).add(icon)); + }} /> )} </div> From bca6fbd4feafad22011986fa64753c46a745b63a Mon Sep 17 00:00:00 2001 From: C Deutsch <git@cdeutsch.com> Date: Thu, 12 Nov 2026 15:43:49 -0600 Subject: [PATCH 13/17] Say once in the spec where a file push's icon comes from The "Showing it" paragraph said what a file push draws twice, two sentences apart. The two are now one sentence. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/e2ee.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/e2ee.md b/docs/e2ee.md index 101043c..0148c9a 100644 --- a/docs/e2ee.md +++ b/docs/e2ee.md @@ -197,7 +197,7 @@ A row shows the linked site's icon, and the icon travels in the envelope with th **Writing it.** The icon goes into the rewrite that fills in the title, so the rule (`enc` and `searchTokens`, `isEncryptedRetitle`) is unchanged. A rewrite happens when the title changed or an icon was found for a push that had none. A push that has an icon is not asked for one again and keeps it, and `icon` is written only when there is one, so a push without it encrypts the same bytes as before. 4096 bytes is 5464 characters of base64, which leaves the URL and title room inside `enc.ct`'s 16384 for every ordinary link; when the envelope with the icon would not fit, the icon is dropped and the title written alone. Which pushes are scraped is as before: a device's own, and a received one with no title. Old pushes are not scraped again for an icon. -**Showing it.** A URL push draws its decoded icon; without one, or when it does not decode, the icon for its `ext` (a link to a PDF has no page to take an icon from), and failing that the placeholder a locked push draws. A file push draws the icon for its `ext` or the placeholder. Nothing is fetched to draw a row. Every client draws only what the field may hold, a PNG of at most 64 by 64 and 4096 bytes, read from its header before it is decoded, so an icon some other writer got wrong is passed over the same way on every client rather than drawing on one platform and not another. The web app builds a `data:image/png;base64,` URL only from a string of base64 characters. A file push draws the icon for its `ext` from images shipped inside each app, never from pushthat.link, whose logs would otherwise see the type of every encrypted file a list showed. +**Showing it.** A URL push draws its decoded icon; without one, or when it does not decode, the icon for its `ext` (a link to a PDF has no page to take an icon from), and failing that the placeholder a locked push draws. A file push draws the icon for its `ext` or the placeholder. Nothing is fetched to draw a row: the file-type icons ship inside each app, never from pushthat.link, whose logs would otherwise see the type of every encrypted file a list showed. Every client draws only what the field may hold, a PNG of at most 64 by 64 and 4096 bytes, read from its header before it is decoded, so an icon some other writer got wrong is passed over the same way on every client rather than drawing on one platform and not another. The web app builds a `data:image/png;base64,` URL only from a string of base64 characters. ## Rules From 472aafa1ab5c77e92a38ac2e65353ec579d3b8c4 Mon Sep 17 00:00:00 2001 From: C Deutsch <git@cdeutsch.com> Date: Thu, 12 Nov 2026 15:43:55 -0600 Subject: [PATCH 14/17] Say in the spec that a scrape is written over the push as it is at the write The clients now write a scrape in a transaction that re-reads the push, so that a title-only write cannot drop an icon another device wrote meanwhile. The Icons section described the rewrite without it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/e2ee.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/e2ee.md b/docs/e2ee.md index 0148c9a..bd6396b 100644 --- a/docs/e2ee.md +++ b/docs/e2ee.md @@ -195,7 +195,7 @@ A row shows the linked site's icon, and the icon travels in the envelope with th **Capturing it.** The device that scrapes the title (Titles, above: Android, iOS and macOS) fetches the icon in the same pass, from the page it already downloaded. The candidates, in order: every `<link rel>` naming `apple-touch-icon` or `apple-touch-icon-precomposed`, then every one naming `icon` (including `shortcut icon`) whose `type` is not `image/svg+xml` and whose path does not end in `.svg`, then `/favicon.ico` at the page's origin. Each `href` is resolved against the page's final URL, after redirects (a `<base>` is not followed); only `http` and `https` are fetched. At most eight `<link>` candidates are tried before `/favicon.ico`, so a page full of broken links cannot hold the scraper for hundreds of requests, and a candidate over 256 KiB is skipped rather than decoded cut short. The first candidate that the platform's own image decoder opens decides: it is scaled down to fit 64 by 64 with its aspect kept (never up) and encoded as PNG; over 4096 bytes, it is scaled to fit 32 by 32 and tried once more, and over that there is no icon, without trying the next candidate. Only an encrypted URL push is given one; a plaintext push left from before encryption is not. The decoders differ (an ICO with several sizes may yield a different frame on each platform), which is why the vectors do not pin an icon: what is fixed is the field and its bounds, not the pixels. -**Writing it.** The icon goes into the rewrite that fills in the title, so the rule (`enc` and `searchTokens`, `isEncryptedRetitle`) is unchanged. A rewrite happens when the title changed or an icon was found for a push that had none. A push that has an icon is not asked for one again and keeps it, and `icon` is written only when there is one, so a push without it encrypts the same bytes as before. 4096 bytes is 5464 characters of base64, which leaves the URL and title room inside `enc.ct`'s 16384 for every ordinary link; when the envelope with the icon would not fit, the icon is dropped and the title written alone. Which pushes are scraped is as before: a device's own, and a received one with no title. Old pushes are not scraped again for an icon. +**Writing it.** The icon goes into the rewrite that fills in the title, so the rule (`enc` and `searchTokens`, `isEncryptedRetitle`) is unchanged. A rewrite happens when the title changed or an icon was found for a push that had none. A push that has an icon is not asked for one again and keeps it, and `icon` is written only when there is one, so a push without it encrypts the same bytes as before. 4096 bytes is 5464 characters of base64, which leaves the URL and title room inside `enc.ct`'s 16384 for every ordinary link; when the envelope with the icon would not fit, the icon is dropped and the title written alone. Which pushes are scraped is as before: a device's own, and a received one with no title. Old pushes are not scraped again for an icon. The rewrite is applied in a transaction to the push as it is at the write, not as it was read before the scrape: two devices scraping one push no longer write the same thing, since one may find an icon the other's decoder refuses, and a title-only write landing last would otherwise drop the icon for good, a titled push being scraped no more. **Showing it.** A URL push draws its decoded icon; without one, or when it does not decode, the icon for its `ext` (a link to a PDF has no page to take an icon from), and failing that the placeholder a locked push draws. A file push draws the icon for its `ext` or the placeholder. Nothing is fetched to draw a row: the file-type icons ship inside each app, never from pushthat.link, whose logs would otherwise see the type of every encrypted file a list showed. Every client draws only what the field may hold, a PNG of at most 64 by 64 and 4096 bytes, read from its header before it is decoded, so an icon some other writer got wrong is passed over the same way on every client rather than drawing on one platform and not another. The web app builds a `data:image/png;base64,` URL only from a string of base64 characters. From 87f49b93a634068f1edef68cd5b475e6bef44d8b Mon Sep 17 00:00:00 2001 From: C Deutsch <git@cdeutsch.com> Date: Thu, 12 Nov 2026 15:47:16 -0600 Subject: [PATCH 15/17] Bound each Apple scrape request start to finish with a resource timeout The icon budget was checked between candidates and only every 4096 bytes during a download, so a server sending one byte every nine seconds stayed under the 10-second idle timeout and held a download open for hours before the first check. The page request had no bound at all, and the cancellation check could never fire, since nothing cancels the detached scrape. Every request of a scrape now goes through a session whose resource timeout bounds it to 15 seconds, as `callTimeout` does on Android, and the 30-second budget is checked between candidates. Same change in both apps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../Services/TitleScraperService.swift | 26 ++++++++++++------- .../Services/TitleScraperService.swift | 26 ++++++++++++------- 2 files changed, 34 insertions(+), 18 deletions(-) diff --git a/apps/ios/PushThat/Services/TitleScraperService.swift b/apps/ios/PushThat/Services/TitleScraperService.swift index 207afab..593e528 100644 --- a/apps/ios/PushThat/Services/TitleScraperService.swift +++ b/apps/ios/PushThat/Services/TitleScraperService.swift @@ -41,6 +41,16 @@ actor TitleScraperService { /// How long the icon candidates are tried for, after the page. private static let iconBudget: TimeInterval = 30 + /// The session every request of a scrape goes through. `timeoutInterval` bounds only a + /// gap between bytes, so a server sending one byte every few seconds held a request open + /// indefinitely, and the icon adds up to nine requests to the page's. The resource + /// timeout bounds each one start to finish, as `callTimeout` does on Android. + private static let session: URLSession = { + let configuration = URLSessionConfiguration.default + configuration.timeoutIntervalForResource = 15 + return URLSession(configuration: configuration) + }() + /// Sent with the page's request and its icons' alike, so the site sees one browser. private static let userAgent = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1" @@ -60,7 +70,7 @@ actor TitleScraperService { // the <title> is; `timeoutInterval` bounds a stall, not a download, so without the // cap a large file would be pulled into memory on every launch. Nor is an icon // looked for on anything but a page: a link to a file shows its type's icon. - let (bytes, response) = try await URLSession.shared.bytes(for: request) + let (bytes, response) = try await Self.session.bytes(for: request) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200, @@ -100,13 +110,12 @@ actor TitleScraperService { /// time, best first, so a page whose first candidate is good costs one request more /// than its title. private func fetchIcon(from candidates: [URL]) async -> String? { - // A budget for the lot, checked between candidates and while one downloads: - // `timeoutInterval` bounds a gap between bytes, not a request, so a server sending a - // byte every few seconds held one open indefinitely, and there can be nine. + // A budget for the lot, checked between candidates: each request is bounded by the + // session, and nine of them in a row still made a long scrape. let deadline = Date().addingTimeInterval(Self.iconBudget) for candidate in candidates { - guard !Task.isCancelled, Date() < deadline else { return nil } - guard let data = await download(candidate, until: deadline) else { continue } + guard Date() < deadline else { return nil } + guard let data = await download(candidate) else { continue } switch PushIcon.encode(data) { case let .icon(icon): return icon case .tooLarge: return nil @@ -119,12 +128,12 @@ actor TitleScraperService { /// A candidate's bytes, or nil when it does not answer 200 or is over /// `PushIcon.maxDownloadBytes`. Streamed as the page is, so a large file is given up at /// the cap rather than pulled into memory first. - private func download(_ url: URL, until deadline: Date) async -> Data? { + private func download(_ url: URL) async -> Data? { var request = URLRequest(url: url) request.setValue(Self.userAgent, forHTTPHeaderField: "User-Agent") request.timeoutInterval = 10 do { - let (bytes, response) = try await URLSession.shared.bytes(for: request) + let (bytes, response) = try await Self.session.bytes(for: request) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200, httpResponse.expectedContentLength <= Int64(PushIcon.maxDownloadBytes) else { return nil } @@ -132,7 +141,6 @@ actor TitleScraperService { for try await byte in bytes { data.append(byte) if data.count > PushIcon.maxDownloadBytes { return nil } - if data.count % 4096 == 0, Task.isCancelled || Date() >= deadline { return nil } } return data } catch { diff --git a/apps/macos/PushThat/Services/TitleScraperService.swift b/apps/macos/PushThat/Services/TitleScraperService.swift index f311a55..0e2dcdb 100644 --- a/apps/macos/PushThat/Services/TitleScraperService.swift +++ b/apps/macos/PushThat/Services/TitleScraperService.swift @@ -41,6 +41,16 @@ actor TitleScraperService { /// How long the icon candidates are tried for, after the page. private static let iconBudget: TimeInterval = 30 + /// The session every request of a scrape goes through. `timeoutInterval` bounds only a + /// gap between bytes, so a server sending one byte every few seconds held a request open + /// indefinitely, and the icon adds up to nine requests to the page's. The resource + /// timeout bounds each one start to finish, as `callTimeout` does on Android. + private static let session: URLSession = { + let configuration = URLSessionConfiguration.default + configuration.timeoutIntervalForResource = 15 + return URLSession(configuration: configuration) + }() + /// Sent with the page's request and its icons' alike, so the site sees one browser. private static let userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15" @@ -60,7 +70,7 @@ actor TitleScraperService { // the <title> is; `timeoutInterval` bounds a stall, not a download, so without the // cap a large file would be pulled into memory on every launch. Nor is an icon // looked for on anything but a page: a link to a file shows its type's icon. - let (bytes, response) = try await URLSession.shared.bytes(for: request) + let (bytes, response) = try await Self.session.bytes(for: request) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200, @@ -100,13 +110,12 @@ actor TitleScraperService { /// time, best first, so a page whose first candidate is good costs one request more /// than its title. private func fetchIcon(from candidates: [URL]) async -> String? { - // A budget for the lot, checked between candidates and while one downloads: - // `timeoutInterval` bounds a gap between bytes, not a request, so a server sending a - // byte every few seconds held one open indefinitely, and there can be nine. + // A budget for the lot, checked between candidates: each request is bounded by the + // session, and nine of them in a row still made a long scrape. let deadline = Date().addingTimeInterval(Self.iconBudget) for candidate in candidates { - guard !Task.isCancelled, Date() < deadline else { return nil } - guard let data = await download(candidate, until: deadline) else { continue } + guard Date() < deadline else { return nil } + guard let data = await download(candidate) else { continue } switch PushIcon.encode(data) { case let .icon(icon): return icon case .tooLarge: return nil @@ -119,12 +128,12 @@ actor TitleScraperService { /// A candidate's bytes, or nil when it does not answer 200 or is over /// `PushIcon.maxDownloadBytes`. Streamed as the page is, so a large file is given up at /// the cap rather than pulled into memory first. - private func download(_ url: URL, until deadline: Date) async -> Data? { + private func download(_ url: URL) async -> Data? { var request = URLRequest(url: url) request.setValue(Self.userAgent, forHTTPHeaderField: "User-Agent") request.timeoutInterval = 10 do { - let (bytes, response) = try await URLSession.shared.bytes(for: request) + let (bytes, response) = try await Self.session.bytes(for: request) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200, httpResponse.expectedContentLength <= Int64(PushIcon.maxDownloadBytes) else { return nil } @@ -132,7 +141,6 @@ actor TitleScraperService { for try await byte in bytes { data.append(byte) if data.count > PushIcon.maxDownloadBytes { return nil } - if data.count % 4096 == 0, Task.isCancelled || Date() >= deadline { return nil } } return data } catch { From bacc51aa5634ec3dae5b9d64ed605145f0117e5b Mon Sep 17 00:00:00 2001 From: C Deutsch <git@cdeutsch.com> Date: Thu, 12 Nov 2026 15:47:43 -0600 Subject: [PATCH 16/17] Bound the web's page icon in bytes, as the phones do The web capped the icon at 5464 characters, which is 4096 bytes only when padded: without padding it holds 4098. Android, iOS and macOS refuse anything over 4096 bytes, so an icon of 4097 or 4098 bytes drew on the web and nowhere else, which is the disagreement the display check exists to remove. The check now counts the bytes the base64 decodes to. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/web/src/services/pageIcon.ts | 11 ++++++----- apps/web/tests/pageIcon.mjs | 11 +++++++++++ 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/apps/web/src/services/pageIcon.ts b/apps/web/src/services/pageIcon.ts index 1b18592..d7c1952 100644 --- a/apps/web/src/services/pageIcon.ts +++ b/apps/web/src/services/pageIcon.ts @@ -1,8 +1,8 @@ // A URL push's page icon as the list may draw it (docs/e2ee.md, Icons). Apart from // `pushes.ts`, which needs Firebase, so that `npm run test:keys` checks it in plain Node. -// 4096 bytes of PNG in padded base64, at most 64 pixels a side (docs/e2ee.md, Icons). -const MAX_ICON_LENGTH = 5464; +// 4096 bytes of PNG, at most 64 pixels a side (docs/e2ee.md, Icons). +const MAX_ICON_BYTES = 4096; const MAX_ICON_SIDE = 64; const BASE64 = /^[A-Za-z0-9+/]+={0,2}$/; const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; @@ -16,9 +16,10 @@ const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; * will not decode is the `img`'s `onError`. */ export const iconOf = (value: unknown): string | undefined => { - if (typeof value !== 'string' || value.length > MAX_ICON_LENGTH || value.length % 4 !== 0 || !BASE64.test(value)) { - return undefined; - } + if (typeof value !== 'string' || value.length % 4 !== 0 || !BASE64.test(value)) return undefined; + // Counted in bytes, as the phones count it: 5464 characters without padding hold 4098. + const padding = value.endsWith('==') ? 2 : value.endsWith('=') ? 1 : 0; + if ((value.length / 4) * 3 - padding > MAX_ICON_BYTES) return undefined; // The signature, then IHDR's length and type, then its width and height: 24 bytes, which // are the first 32 characters of the base64. const header = Uint8Array.from(atob(value.slice(0, 32)), (c) => c.charCodeAt(0)); diff --git a/apps/web/tests/pageIcon.mjs b/apps/web/tests/pageIcon.mjs index cec0fa3..9ebfbcc 100644 --- a/apps/web/tests/pageIcon.mjs +++ b/apps/web/tests/pageIcon.mjs @@ -52,4 +52,15 @@ describe('iconOf', () => { assert.equal(iconOf(pngHeader(16, 16).slice(0, -1)), undefined); assert.equal(iconOf(pngHeader(16, 16) + 'A'.repeat(5464)), undefined); }); + + test('takes 4096 bytes and not one more', () => { + const icon = (bytes) => { + const png = Buffer.alloc(bytes); + Buffer.from(pngHeader(16, 16), 'base64').copy(png); + return png.toString('base64'); + }; + assert.equal(iconOf(icon(4096)), icon(4096)); + assert.equal(iconOf(icon(4097)), undefined); + assert.equal(iconOf(icon(4098)), undefined); + }); }); From 9a0ccaa37e1b6980c70df8ae579476629ae22792 Mon Sep 17 00:00:00 2001 From: C Deutsch <git@cdeutsch.com> Date: Thu, 12 Nov 2026 15:48:01 -0600 Subject: [PATCH 17/17] Compare Android's scraped title with the stored one after cutting it `PushDocument.retitle` compared the scraped title uncut with the stored one, which the envelope keeps cut to 1000 characters. A page whose title was over the limit therefore always counted as changed, and every scrape of it rewrote the same cut title under a fresh nonce. It now cuts first, as iOS and macOS do. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../java/com/makerocketgonow/pushthat/e2ee/PushDocument.kt | 3 ++- .../com/makerocketgonow/pushthat/e2ee/PushDocumentTest.kt | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/e2ee/PushDocument.kt b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/e2ee/PushDocument.kt index 9d6dbfe..b331b3e 100644 --- a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/e2ee/PushDocument.kt +++ b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/e2ee/PushDocument.kt @@ -287,7 +287,8 @@ object PushDocument { * [PushTooLargeException] when even the title alone does not fit, as before icons. */ fun retitle(keys: PushKeys, id: String, content: PushContent.Url, title: String?, icon: String?): PreparedPush? { - val newTitle = title?.takeIf { it.isNotBlank() && it != content.title } + // Cut as the envelope cuts it, so a long title read again is not taken for a change. + val newTitle = title?.let { PreparedPush.cutTitle(it) }?.takeIf { it.isNotBlank() && it != content.title } val newIcon = icon?.takeIf { it.isNotEmpty() && content.icon.isEmpty() } if (newTitle == null && newIcon == null) return null val updated = content.copy(title = newTitle ?: content.title, icon = newIcon ?: content.icon) diff --git a/apps/android/app/src/test/java/com/makerocketgonow/pushthat/e2ee/PushDocumentTest.kt b/apps/android/app/src/test/java/com/makerocketgonow/pushthat/e2ee/PushDocumentTest.kt index 34579ea..e9a6381 100644 --- a/apps/android/app/src/test/java/com/makerocketgonow/pushthat/e2ee/PushDocumentTest.kt +++ b/apps/android/app/src/test/java/com/makerocketgonow/pushthat/e2ee/PushDocumentTest.kt @@ -168,6 +168,11 @@ class PushDocumentTest { assertNull(PushDocument.retitle(keys, "r2", titled, "Example", null)) assertNull(PushDocument.retitle(keys, "r2", titled, " ", "")) assertEquals(PushContent.Url("https://example.com", "Example", "", "QUJD"), opened(PushDocument.retitle(keys, "r2", titled, "Example", "QUJD"), "r2")) + + // A title over the limit is stored cut, and read again it is the same title. + val long = "x".repeat(PreparedPush.MAX_TITLE_LENGTH + 50) + val cut = PushContent.Url("https://example.com", PreparedPush.cutTitle(long), "") + assertNull(PushDocument.retitle(keys, "r2", cut, long, null)) } @Test