mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 10:04:18 +00:00
The installed app's icons follow the chosen logo, drawn by the browser (#229)
An app installed from the browser or added to an iPhone home screen kept the wax seal. The manifest and apple-touch-icon now name the chosen logo; the browser draws the PNGs on a canvas and uploads them per user, so the image needs no librsvg. Builds on #92. Closes #141. Co-authored-by: mani <15911342+Flouss@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
20 files changed
+1598
-26
No files matched your search
@@ -8,6 +8,15 @@ The published image tags: `latest` follows the most recent release below,
|
||||
|
||||
## Unreleased
|
||||
|
||||
**The installed app has the icon you picked, on iPhone and Android too.** The tab icon followed
|
||||
Settings → Appearance → Logo, but an app installed from the browser or added to a home screen kept
|
||||
the wax seal, because its icons came from fixed files. The install manifest and the iOS home-screen
|
||||
icon now name your choice. Your browser draws them as PNGs when you pick a logo and uploads them for
|
||||
your account, so the image needs nothing new to draw them; a logo picked before this is drawn by the
|
||||
next page you open. An app that is already installed keeps its old icon until the browser refreshes
|
||||
it or you reinstall. No migration. [#141](https://github.com/karatektus/pl_mail/issues/141), building
|
||||
on #92 by @Flouss.
|
||||
|
||||
**The weekly cleanup no longer deletes profile pictures, signature images and backgrounds.** Every
|
||||
Sunday at 05:00 `app:prune:blobs` removed week-old files it found no record for anywhere under
|
||||
`var/uploads`, `var/attachments` and `var/raw`. Profile pictures, signature images and appearance
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { csrfToken } from './csrf.js';
|
||||
|
||||
/**
|
||||
* The installed app's icons, drawn by this browser and handed to the server
|
||||
* (#141).
|
||||
*
|
||||
* An app installed from the browser, or put on an iPhone's home screen, takes
|
||||
* its icon from PNGs: the manifest's, and iOS's apple-touch-icon. The logo is
|
||||
* an SVG with 330 faces. Drawing those PNGs on the server took librsvg in the
|
||||
* image, about 60 MB for everybody, and the Imagick already there paints a
|
||||
* gradient stroke black. A browser draws the SVG correctly by definition, so
|
||||
* the page draws it on a canvas, at the four sizes, and uploads them. The
|
||||
* server checks each one is a PNG of exactly its size and keeps it for this
|
||||
* user only (AppIconStorage).
|
||||
*
|
||||
* Two callers: the appearance pane when a logo is picked, and any page whose
|
||||
* user has no PNGs for their current choice yet (core--app-icons).
|
||||
*/
|
||||
|
||||
/** name => [which SVG, pixels square]; the names AppIconStorage::ICONS uses. */
|
||||
const ICONS = {
|
||||
'tile-192': ['tile', 192],
|
||||
'tile-512': ['tile', 512],
|
||||
'bleed-512': ['bleed', 512],
|
||||
'bleed-180': ['bleed', 180],
|
||||
};
|
||||
|
||||
/**
|
||||
* One SVG drawn as a PNG of $size by $size.
|
||||
*
|
||||
* The SVG's own width and height are set to the target first, so the browser
|
||||
* rasterises the vector at that size rather than scaling up a 72-pixel
|
||||
* drawing. It is loaded from a blob URL: same origin, so the canvas is not
|
||||
* tainted and toBlob() is allowed (the layout's img-src allows blob:).
|
||||
*/
|
||||
async function draw(svgUrl, size) {
|
||||
const response = await fetch(svgUrl, { credentials: 'same-origin' });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`${svgUrl} answered ${response.status}`);
|
||||
}
|
||||
|
||||
const svg = new DOMParser().parseFromString(await response.text(), 'image/svg+xml').documentElement;
|
||||
|
||||
svg.setAttribute('width', String(size));
|
||||
svg.setAttribute('height', String(size));
|
||||
|
||||
const url = URL.createObjectURL(new Blob([new XMLSerializer().serializeToString(svg)], { type: 'image/svg+xml' }));
|
||||
|
||||
try {
|
||||
const image = new Image();
|
||||
image.src = url;
|
||||
await image.decode();
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
canvas.getContext('2d').drawImage(image, 0, 0, size, size);
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error('toBlob gave nothing'))), 'image/png');
|
||||
});
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the four PNGs of one choice and upload them.
|
||||
*
|
||||
* $tileUrl and $bleedUrl are the choice's tile and bleed SVGs as the server
|
||||
* generated them: `/branding/{icon|bleed}/{motif}/{paint}.svg?v={version}`,
|
||||
* so the choice and the drawing's version are read off the tile's URL rather
|
||||
* than passed alongside it, and cannot disagree with what was drawn.
|
||||
*
|
||||
* Resolves to the upload's status. 409 is not an error: the page is older
|
||||
* than the drawing (a deploy happened), and the next page load draws the
|
||||
* current one.
|
||||
*/
|
||||
export async function uploadAppIcons({ uploadUrl, tileUrl, bleedUrl }) {
|
||||
const tile = new URL(tileUrl, window.location.origin);
|
||||
const [, motif, paint] = tile.pathname.match(/\/([^/]+)\/([^/]+)\.svg$/) ?? [];
|
||||
|
||||
if (!motif || !paint) {
|
||||
throw new Error(`not a logo URL: ${tileUrl}`);
|
||||
}
|
||||
|
||||
const body = new FormData();
|
||||
body.append('motif', motif);
|
||||
body.append('paint', paint);
|
||||
body.append('version', tile.searchParams.get('v') ?? '');
|
||||
|
||||
for (const [name, [form, size]] of Object.entries(ICONS)) {
|
||||
body.append(name, await draw(form === 'tile' ? tileUrl : bleedUrl, size), `${name}.png`);
|
||||
}
|
||||
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': csrfToken() },
|
||||
body,
|
||||
});
|
||||
|
||||
return response.status;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
import { uploadAppIcons } from "../../app_icons.js"
|
||||
|
||||
/**
|
||||
* Draws the installed app's icons once, when the server says the user's PNGs
|
||||
* for their current logo are missing (#141).
|
||||
*
|
||||
* That happens for a logo picked before the PNGs existed, and after a deploy
|
||||
* that changed the drawing (its version is part of where they are kept). The
|
||||
* layout renders this element only then, so most pages never load it. Until
|
||||
* the upload lands, the icon route answers with the wax seal.
|
||||
*
|
||||
* Once per choice per document: Turbo renders the layout again on every
|
||||
* visit, and the element keeps coming back until a page is rendered after the
|
||||
* upload. The set below stops those visits from drawing it again.
|
||||
*/
|
||||
const done = new Set();
|
||||
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
tileUrl: String,
|
||||
bleedUrl: String,
|
||||
uploadUrl: String,
|
||||
};
|
||||
|
||||
async connect() {
|
||||
if (done.has(this.tileUrlValue)) {
|
||||
return;
|
||||
}
|
||||
|
||||
done.add(this.tileUrlValue);
|
||||
|
||||
try {
|
||||
await uploadAppIcons({
|
||||
uploadUrl: this.uploadUrlValue,
|
||||
tileUrl: this.tileUrlValue,
|
||||
bleedUrl: this.bleedUrlValue,
|
||||
});
|
||||
} catch (error) {
|
||||
// Nothing the user can act on, and the wax seal stands in: let a
|
||||
// later page load try again rather than shouting about it.
|
||||
done.delete(this.tileUrlValue);
|
||||
console.warn("app icons: could not draw or upload", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
import { csrfToken, jsonCsrfHeaders } from '../../csrf.js';
|
||||
import { requestFailed } from '../../request_errors.js';
|
||||
import { uploadAppIcons } from '../../app_icons.js';
|
||||
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
@@ -8,6 +9,7 @@ export default class extends Controller {
|
||||
uploadUrl: String,
|
||||
importUrl: String,
|
||||
resetUrl: String,
|
||||
appIconsUrl: String,
|
||||
logoDefault: String,
|
||||
};
|
||||
|
||||
@@ -23,6 +25,11 @@ export default class extends Controller {
|
||||
this.root = document.documentElement;
|
||||
this.pending = null;
|
||||
|
||||
// The choice the installed app's PNGs were last drawn for. The page's
|
||||
// own choice is the layout's business (core--app-icons draws it when it
|
||||
// is missing), so only a change from here on is drawn by this pane.
|
||||
this.appIconsChoice = this.hasLogoMotifTarget ? `${this.logoMotifTarget.value}/${this.logoPaint()}` : null;
|
||||
|
||||
if (this.hasInkColorFieldTarget && this.inkColorFieldTarget.value !== '') {
|
||||
this.applyInk();
|
||||
}
|
||||
@@ -574,26 +581,69 @@ export default class extends Controller {
|
||||
document.querySelectorAll('link[rel="icon"][type="image/svg+xml"]').forEach((link) => {
|
||||
this.setIfChanged(link, 'href', this.iconUrl(motif, paint, 'logoFavicon'));
|
||||
});
|
||||
|
||||
// The install manifest and iOS's home-screen icon name their pictures
|
||||
// by the same choice, so an app installed from this page, before a
|
||||
// reload, gets the pick (#141).
|
||||
document.querySelectorAll('link[rel="manifest"]').forEach((link) => {
|
||||
this.setIfChanged(link, 'href', this.iconUrl(motif, paint, 'logoManifest'));
|
||||
});
|
||||
|
||||
document.querySelectorAll('link[rel="apple-touch-icon"]').forEach((link) => {
|
||||
this.setIfChanged(link, 'href', this.iconUrl(motif, paint, 'logoAppleTouch'));
|
||||
});
|
||||
|
||||
this.drawAppIcons(motif, paint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the installed app's PNGs for a new choice and upload them (see
|
||||
* app_icons.js for why the browser draws them).
|
||||
*
|
||||
* Only for a choice that changed: repaintLogo() also runs for a theme
|
||||
* switch that leaves the icon as it was. And not on every click: somebody
|
||||
* walking through the colourways would upload four PNGs per tile, so it
|
||||
* waits until the clicking stops. A page left before that finds the PNGs
|
||||
* missing on the next load, and draws them there.
|
||||
*/
|
||||
drawAppIcons(motif, paint) {
|
||||
const choice = `${motif}/${paint}`;
|
||||
|
||||
if (!this.hasAppIconsUrlValue || choice === this.appIconsChoice) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.appIconsChoice = choice;
|
||||
clearTimeout(this.appIconsTimer);
|
||||
|
||||
this.appIconsTimer = setTimeout(() => {
|
||||
uploadAppIcons({
|
||||
uploadUrl: this.appIconsUrlValue,
|
||||
tileUrl: this.iconUrl(motif, paint),
|
||||
bleedUrl: this.iconUrl(motif, paint, 'logoBleed'),
|
||||
}).catch((error) => console.warn('app icons: could not draw or upload', error));
|
||||
}, 600);
|
||||
}
|
||||
|
||||
/**
|
||||
* One motif in one paint, as a tile (the default) or in another of the
|
||||
* forms its button carries a URL for: `logoFavicon`, `logoBar`,
|
||||
* `logoBarDark`.
|
||||
* forms its button carries a URL for: `logoFavicon`, `logoBleed`,
|
||||
* `logoManifest`, `logoAppleTouch`, `logoBar`, `logoBarDark`.
|
||||
*
|
||||
* Built from the motif button's own URL rather than from a pattern: that
|
||||
* URL IS the route for the motif in its original design, generated by the
|
||||
* server with the drawing's version in its query, and a paint is the last
|
||||
* path segment — so any other paint is the same URL with `original.svg`
|
||||
* swapped for `<paint>.svg`, and the query survives untouched. A pattern
|
||||
* rendered for the script to fill could not be generated at all: the
|
||||
* route's requirements refuse a placeholder.
|
||||
* server with the drawing's version (and for the installed app's, the
|
||||
* user's key) in it, and a paint is the last path segment — so any other
|
||||
* paint is the same URL with `original.svg` (or `.webmanifest`, `.png`)
|
||||
* swapped for `<paint>` and the same extension, and the query survives
|
||||
* untouched. A pattern rendered for the script to fill could not be
|
||||
* generated at all: the route's requirements refuse a placeholder.
|
||||
*/
|
||||
iconUrl(motif, paint, form = 'logoIcon') {
|
||||
const tile = this.element.querySelector(`[data-logo-motif="${motif}"]`);
|
||||
const url = new URL(tile.dataset[form], window.location.origin);
|
||||
|
||||
url.pathname = url.pathname.replace(/[^/]+\.svg$/, `${paint}.svg`);
|
||||
url.pathname = url.pathname.replace(/[^/]+(\.(?:svg|webmanifest|png))$/, `${paint}$1`);
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
@@ -160,6 +160,11 @@ services:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
$storageDir: '%env(APP_STORAGE_DIR)%'
|
||||
App\Domain\Helper\AppIconStorage:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
$storageDir: '%env(APP_STORAGE_DIR)%'
|
||||
$secret: '%kernel.secret%'
|
||||
App\Command\Maintenance\PruneBlobsCommand:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- translated-from: features/appearance.md sha1:963b5496c55b39c78ef14815a4c3c0e5b2ae8f78 -->
|
||||
<!-- translated-from: features/appearance.md sha1:07ca79710a14017f094316936796ec53ba56ebb6 -->
|
||||
|
||||
# Darstellung
|
||||
|
||||
@@ -64,6 +64,12 @@ Oberflächen hat — ein tintendunkles Symbol verschwindet so nie in einer dunkl
|
||||
Tab-Symbol behält die Kachel bei jedem Symbol außer dem pl-Zeichen, weil eine Zeichnung ohne
|
||||
Hintergrund in Tab-Größe kaum zu erkennen ist.
|
||||
|
||||
Eine aus dem Browser installierte App, oder plMail auf dem Home-Bildschirm eines iPhones, trägt es
|
||||
ebenfalls. Dieses Symbol zeichnet dein Browser, wenn du ein Logo wählst, und es wird für dein Konto
|
||||
aufbewahrt. Nach einem Update, das die Zeichnung ändert, zeichnet die erste Seite, die du öffnest,
|
||||
es im Hintergrund neu. Eine schon installierte App behält ihr altes Symbol, bis der Browser es
|
||||
auffrischt oder du sie neu installierst.
|
||||
|
||||
## Die Live-Vorschau
|
||||
|
||||
Neben den Bedienelementen steht eine zweite Karte, die eine Beispiel-Seitenleiste, eine
|
||||
|
||||
@@ -56,6 +56,11 @@ dark-chrome colours, so an ink-dark icon never disappears into a dark top bar. T
|
||||
the tile for every icon but the pl mark, because a drawing with nothing behind it is hard to make
|
||||
out at the size of a tab.
|
||||
|
||||
An app installed from the browser, or plMail added to an iPhone's Home Screen, wears it too. Your
|
||||
browser draws that icon when you pick a logo, and it is kept for your account. After an update that
|
||||
changes the drawing, the first page you open draws it again in the background. An app that is
|
||||
already installed keeps its old icon until the browser refreshes it or you install it again.
|
||||
|
||||
## The live preview
|
||||
|
||||
Beside the controls sits a second card showing a sample sidebar, message list and reading pane in
|
||||
|
||||
@@ -6,9 +6,11 @@ namespace App\Controller;
|
||||
|
||||
use App\Domain\Enum\Theme\LogoMotif;
|
||||
use App\Domain\Enum\Theme\LogoStyle;
|
||||
use App\Domain\Helper\AppIconStorage;
|
||||
use App\Infrastructure\Routing\LogoPaintRequirement;
|
||||
use App\Service\Appearance\LogoIcons;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
@@ -16,7 +18,8 @@ use Symfony\Component\Routing\Requirement\EnumRequirement;
|
||||
|
||||
/**
|
||||
* The logo, drawn three ways: as the icon tile, as the tab icon, and as the
|
||||
* top bar wears it.
|
||||
* top bar wears it. Beside them, the installed app's: its manifest, and the
|
||||
* PNGs the user's browser drew for it (#141).
|
||||
*
|
||||
* plMail's logo has three hundred and thirty faces (Settings → Appearance →
|
||||
* Logo: ten icons, each in its own design or thirty-two colourways), and every
|
||||
@@ -33,8 +36,8 @@ use Symfony\Component\Routing\Requirement\EnumRequirement;
|
||||
* already fetched correctly (the one the page loaded with). Drawn from the URL,
|
||||
* there is nothing for the save to race.
|
||||
*
|
||||
* All three are public and, at the current drawing version, immutable. See
|
||||
* serve() for why that is safe.
|
||||
* All of them are public and, at the current drawing version, immutable. See
|
||||
* serve() for why that is safe, and appIcon() for the one exception.
|
||||
*/
|
||||
final class BrandingController extends AbstractController
|
||||
{
|
||||
@@ -54,7 +57,10 @@ final class BrandingController extends AbstractController
|
||||
private const int UNVERSIONED = 3_600;
|
||||
|
||||
public function __construct(
|
||||
private readonly LogoIcons $icons,
|
||||
private readonly LogoIcons $icons,
|
||||
private readonly AppIconStorage $appIcons,
|
||||
#[Autowire('%kernel.project_dir%')]
|
||||
private readonly string $projectDir,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -103,6 +109,112 @@ final class BrandingController extends AbstractController
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tile with its ground out to every edge, for what cuts its own
|
||||
* corners: iOS's home-screen icon and a manifest's maskable icon. The
|
||||
* browser draws this into the PNGs of appIcon(); see icon.svg.twig.
|
||||
*/
|
||||
#[Route(
|
||||
'/branding/bleed/{motif}/{paint}.svg',
|
||||
name: 'app_branding_bleed',
|
||||
requirements: ['motif' => new EnumRequirement(LogoMotif::class), 'paint' => new LogoPaintRequirement()],
|
||||
methods: ['GET'],
|
||||
)]
|
||||
public function bleed(Request $request, LogoMotif $motif, string $paint): Response
|
||||
{
|
||||
$style = LogoStyle::tryFrom($paint);
|
||||
|
||||
return $this->serve($request, 'bleed.' . LogoIcons::choice($motif, $style), fn (): string => $this->tile($motif, $style, true));
|
||||
}
|
||||
|
||||
/**
|
||||
* The install manifest: public/manifest.webmanifest with the choice's icons.
|
||||
*
|
||||
* Everything but the icons is read from that file, so the app's name, start
|
||||
* page and colours have one home, and the file stays what an installer that
|
||||
* never loaded a page would find.
|
||||
*
|
||||
* `?u=` is the user's key (AppIconStorage): with it the icons are that
|
||||
* user's PNGs of this choice, without it (a page nobody is signed in to)
|
||||
* the file's own wax seal. Only the choice's icons and nothing else: a
|
||||
* launcher chooses among a manifest's icons the way Firefox chooses a tab
|
||||
* icon (see _favicon.html.twig), and a second candidate that is the same
|
||||
* for everyone wins for someone.
|
||||
*/
|
||||
#[Route(
|
||||
'/branding/manifest/{motif}/{paint}.webmanifest',
|
||||
name: 'app_branding_manifest',
|
||||
requirements: ['motif' => new EnumRequirement(LogoMotif::class), 'paint' => new LogoPaintRequirement()],
|
||||
methods: ['GET'],
|
||||
)]
|
||||
public function manifest(Request $request, LogoMotif $motif, string $paint): Response
|
||||
{
|
||||
$style = LogoStyle::tryFrom($paint);
|
||||
$key = (string) $request->query->get('u', '');
|
||||
|
||||
if ('' !== $key && 1 !== preg_match('/^' . AppIconStorage::KEY_PATTERN . '$/', $key)) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
|
||||
return $this->serve(
|
||||
$request,
|
||||
'manifest.' . LogoIcons::choice($motif, $style) . ('' === $key ? '' : '.' . $key),
|
||||
fn (): string => $this->manifestJson($motif, $style, '' === $key ? null : $key),
|
||||
'application/manifest+json',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One of a user's installed-app PNGs, as their browser drew it.
|
||||
*
|
||||
* The one picture here that is not a drawing of its URL: it is a file the
|
||||
* user uploaded (AppIconStorage), and it may not be there yet, for a choice
|
||||
* picked before this existed or after a deploy that changed the drawing.
|
||||
* Then the wax seal's static PNG of the same shape stands in, so a manifest
|
||||
* never names an icon that 404s, and it is served `no-cache`: the user's own
|
||||
* arrives with the next page load, and must not be shadowed by the stand-in
|
||||
* for a year.
|
||||
*/
|
||||
#[Route(
|
||||
'/branding/app-icon/{key}/{name}/{motif}/{paint}.png',
|
||||
name: 'app_branding_app_icon',
|
||||
requirements: [
|
||||
'key' => AppIconStorage::KEY_PATTERN,
|
||||
'name' => 'tile-192|tile-512|bleed-512|bleed-180',
|
||||
'motif' => new EnumRequirement(LogoMotif::class),
|
||||
'paint' => new LogoPaintRequirement(),
|
||||
],
|
||||
methods: ['GET'],
|
||||
)]
|
||||
public function appIcon(Request $request, string $key, string $name, LogoMotif $motif, string $paint): Response
|
||||
{
|
||||
$choice = LogoIcons::choice($motif, LogoStyle::tryFrom($paint));
|
||||
$version = $this->icons->version();
|
||||
$stored = $this->appIcons->pathFor($key, $choice, $version, $name);
|
||||
|
||||
if (null === $stored) {
|
||||
$response = new Response(headers: ['Content-Type' => 'image/png']);
|
||||
$response->setPublic();
|
||||
$response->headers->addCacheControlDirective('no-cache');
|
||||
$response->setEtag('app-icon.fallback.' . $name . '.' . $version);
|
||||
|
||||
if ($response->isNotModified($request)) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
return $response->setContent((string) file_get_contents($this->projectDir . '/public/' . AppIconStorage::FALLBACK[$name]));
|
||||
}
|
||||
|
||||
// The upload can be replaced under the same URL (the same choice drawn
|
||||
// again), so the ETag is the file's, not only the URL's.
|
||||
return $this->serve(
|
||||
$request,
|
||||
sprintf('app-icon.%s.%s.%s.%s', $key, $choice, $name, (string) filemtime($stored)),
|
||||
static fn (): string => (string) file_get_contents($stored),
|
||||
'image/png',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The logo as the app's own chrome wears it: the top bar's.
|
||||
*
|
||||
@@ -147,11 +259,11 @@ final class BrandingController extends AbstractController
|
||||
*
|
||||
* @param \Closure(): string $draw
|
||||
*/
|
||||
private function serve(Request $request, string $choice, \Closure $draw): Response
|
||||
private function serve(Request $request, string $choice, \Closure $draw, string $type = 'image/svg+xml'): Response
|
||||
{
|
||||
$version = $this->icons->version();
|
||||
|
||||
$response = new Response(headers: ['Content-Type' => 'image/svg+xml']);
|
||||
$response = new Response(headers: ['Content-Type' => $type]);
|
||||
$response->setPublic();
|
||||
$response->setEtag($choice . '.' . $version);
|
||||
|
||||
@@ -171,8 +283,42 @@ final class BrandingController extends AbstractController
|
||||
return $response;
|
||||
}
|
||||
|
||||
/** One tile: the glyph on its ground, clipped to the rounded square. */
|
||||
private function tile(LogoMotif $motif, ?LogoStyle $paint): string
|
||||
/**
|
||||
* The static manifest with the icons replaced: the user's PNGs of this
|
||||
* choice when there is a key, the file's own wax seal otherwise.
|
||||
*/
|
||||
private function manifestJson(LogoMotif $motif, ?LogoStyle $paint, ?string $key): string
|
||||
{
|
||||
/** @var array<string, mixed> $manifest */
|
||||
$manifest = json_decode(
|
||||
(string) file_get_contents($this->projectDir . '/public/manifest.webmanifest'),
|
||||
true,
|
||||
flags: JSON_THROW_ON_ERROR,
|
||||
);
|
||||
|
||||
if (null !== $key) {
|
||||
$icon = fn (string $name, string $purpose): array => [
|
||||
'src' => $this->icons->appIconUrl($name, $key, $motif, $paint),
|
||||
'sizes' => sprintf('%1$dx%1$d', AppIconStorage::ICONS[$name]),
|
||||
'type' => 'image/png',
|
||||
'purpose' => $purpose,
|
||||
];
|
||||
|
||||
$manifest['icons'] = [
|
||||
$icon('tile-192', 'any'),
|
||||
$icon('tile-512', 'any'),
|
||||
$icon('bleed-512', 'maskable'),
|
||||
];
|
||||
}
|
||||
|
||||
return json_encode($manifest, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
|
||||
}
|
||||
|
||||
/**
|
||||
* One tile: the glyph on its ground, clipped to the rounded square, or
|
||||
* with the ground out to every edge when $bleed (see icon.svg.twig).
|
||||
*/
|
||||
private function tile(LogoMotif $motif, ?LogoStyle $paint, bool $bleed = false): string
|
||||
{
|
||||
[$parts, $glyphRamp, $tileRamp] = self::fill($motif->paints($paint));
|
||||
|
||||
@@ -181,6 +327,7 @@ final class BrandingController extends AbstractController
|
||||
'p' => $parts,
|
||||
'glyph_ramp' => $glyphRamp,
|
||||
'tile_ramp' => $tileRamp,
|
||||
'bleed' => $bleed,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,12 @@ namespace App\Controller\Settings;
|
||||
use App\Controller\ChecksCsrf;
|
||||
use App\Domain\Enum\Mail\ReadingPaneMode;
|
||||
use App\Domain\Enum\Theme\BackgroundKind;
|
||||
use App\Domain\Enum\Theme\LogoMotif;
|
||||
use App\Domain\Enum\Theme\LogoStyle;
|
||||
use App\Domain\Helper\AppIconStorage;
|
||||
use App\Entity\Embeddable\Appearance;
|
||||
use App\Entity\User\User;
|
||||
use App\Service\Appearance\LogoIcons;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
@@ -144,6 +148,61 @@ final class AppearanceController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the installed app's PNGs, as this user's browser drew them (#141).
|
||||
*
|
||||
* The appearance pane draws the chosen icon into four PNGs on a canvas
|
||||
* when a logo is picked, and any page does when the current choice's are
|
||||
* missing (see app_icons.js); this stores them, for this user only. Why
|
||||
* the browser draws them rather than the server is in AppIconStorage.
|
||||
*
|
||||
* Nothing the browser says is taken on trust: the choice has to be one
|
||||
* the routes can name, and each file a PNG of exactly its size and small
|
||||
* (AppIconStorage::isAcceptable()). A drawing version that is not this
|
||||
* server's means the page was loaded before a deploy changed the drawing:
|
||||
* 409, and the page's next load draws the current one.
|
||||
*/
|
||||
#[Route('/app-icons', name: 'app_icons', methods: ['POST'])]
|
||||
public function appIcons(Request $request, AppIconStorage $storage, LogoIcons $icons): Response
|
||||
{
|
||||
$this->assertCsrf($request, 'ajax');
|
||||
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
$motif = LogoMotif::tryFrom($request->request->getString('motif'));
|
||||
$paint = $request->request->getString('paint');
|
||||
|
||||
if (null === $motif || false === in_array($paint, LogoMotif::paintWires(), true)) {
|
||||
return new Response(null, Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
if ($icons->version() !== $request->request->getString('version')) {
|
||||
return new Response(null, Response::HTTP_CONFLICT);
|
||||
}
|
||||
|
||||
$pngs = [];
|
||||
|
||||
foreach (array_keys(AppIconStorage::ICONS) as $name) {
|
||||
$file = $request->files->get($name);
|
||||
$bytes = $file instanceof UploadedFile && true === $file->isValid() ? (string) file_get_contents($file->getPathname()) : '';
|
||||
|
||||
if (false === $storage->isAcceptable($name, $bytes)) {
|
||||
return new Response(null, Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$pngs[$name] = $bytes;
|
||||
}
|
||||
|
||||
$choice = LogoIcons::choice($motif, LogoStyle::tryFrom($paint));
|
||||
|
||||
$storage->store((string) $user->id, $choice, $icons->version(), $pngs);
|
||||
|
||||
// Which choice was kept: several uploads can be in flight (a pick, and
|
||||
// the layout drawing a missing one), and the answer says which is which.
|
||||
return $this->json(['choice' => $choice]);
|
||||
}
|
||||
|
||||
#[Route('/background', name: 'background_upload', methods: ['POST'])]
|
||||
public function uploadBackground(Request $request): JsonResponse
|
||||
{
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Helper;
|
||||
|
||||
/**
|
||||
* The installed app's icons, as PNGs the user's own browser drew (#141).
|
||||
*
|
||||
* iOS takes nothing but a PNG for its home-screen icon and a launcher wants
|
||||
* raster icons from the manifest, while the logo is an SVG with 330 faces.
|
||||
* Drawing those on the server needed librsvg in the image (about 60 MB for
|
||||
* everybody, for a feature only people with a home-screen icon and a
|
||||
* non-default logo notice), and the Imagick already in the image paints a
|
||||
* gradient stroke black. A browser draws the SVG correctly by definition, so
|
||||
* the appearance pane draws the four PNGs on a canvas and uploads them, and
|
||||
* this keeps them.
|
||||
*
|
||||
* WHY PER USER, AND WHY A KEY INSTEAD OF THE USER ID
|
||||
* ──────────────────────────────────────────────────
|
||||
* Per user, not shared per choice: an upload only ever changes its uploader's
|
||||
* own icons, so a page that uploads something odd draws it for nobody else.
|
||||
* A browser fetches a manifest and its icons without credentials, so the URL
|
||||
* has to say whose icons they are. It says so with an HMAC of the user id
|
||||
* rather than the id: nobody can walk the ids and collect everybody's logo.
|
||||
*
|
||||
* WHAT IS KEPT
|
||||
* ────────────
|
||||
* One choice per user: `<key>/<motif>.<paint>.<version>/<form>-<size>.png`. A
|
||||
* new upload replaces whatever was there, because only the current choice is
|
||||
* ever linked (an older one could only be named by a page loaded before the
|
||||
* pick, and that page's icon route falls back to the wax seal). The drawing's
|
||||
* version is in the directory, so a deploy that changes the drawing makes the
|
||||
* old PNGs missing, and the next page load draws them again.
|
||||
*
|
||||
* Not under `uploads/`: the weekly blob prune sweeps that tree for files no
|
||||
* row names (#214), and nothing here has a row. Losing these would cost
|
||||
* nothing worse than a redraw, but they have no business being swept.
|
||||
*/
|
||||
readonly class AppIconStorage
|
||||
{
|
||||
/**
|
||||
* The four PNGs, by the name the upload and the URL use: the rounded tile
|
||||
* at a manifest's two sizes, the ground-to-the-edge form as its maskable
|
||||
* icon, and the same at 180 for iOS's apple-touch-icon.
|
||||
*
|
||||
* @var array<string, int> name => pixels square
|
||||
*/
|
||||
public const array ICONS = [
|
||||
'tile-192' => 192,
|
||||
'tile-512' => 512,
|
||||
'bleed-512' => 512,
|
||||
'bleed-180' => 180,
|
||||
];
|
||||
|
||||
/**
|
||||
* The wax seal's static PNG for each, served when a user's own is missing,
|
||||
* so a manifest never names an icon that 404s.
|
||||
*
|
||||
* @var array<string, string> name => path under public/
|
||||
*/
|
||||
public const array FALLBACK = [
|
||||
'tile-192' => 'icons/icon-192.png',
|
||||
'tile-512' => 'icons/icon-512.png',
|
||||
'bleed-512' => 'icons/icon-maskable-512.png',
|
||||
'bleed-180' => 'icons/apple-touch-icon.png',
|
||||
];
|
||||
|
||||
/**
|
||||
* Far above what a flat logo needs at 512 (a browser's PNG of one is tens
|
||||
* of KB), far below anything worth abusing.
|
||||
*/
|
||||
public const int MAX_BYTES = 512 * 1024;
|
||||
|
||||
/** A user's key as the URL spells it; the routes' requirement. */
|
||||
public const string KEY_PATTERN = '[0-9a-f]{20}';
|
||||
|
||||
private const string PNG_SIGNATURE = "\x89PNG\r\n\x1a\n";
|
||||
|
||||
public function __construct(
|
||||
private string $projectDir,
|
||||
private string $storageDir,
|
||||
private string $secret,
|
||||
) {
|
||||
}
|
||||
|
||||
/** The URL's stand-in for a user id: stable, and not reversible to it. */
|
||||
public function keyFor(string $userId): string
|
||||
{
|
||||
return substr(hash_hmac('sha256', 'app-icons:' . $userId, $this->secret), 0, 20);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether $bytes may be stored as $name: a real PNG, exactly the size the
|
||||
* name promises, and small. The browser's word is not taken for any of it.
|
||||
*/
|
||||
public function isAcceptable(string $name, string $bytes): bool
|
||||
{
|
||||
$size = self::ICONS[$name] ?? null;
|
||||
|
||||
if (null === $size || strlen($bytes) > self::MAX_BYTES || false === str_starts_with($bytes, self::PNG_SIGNATURE)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$info = @getimagesizefromstring($bytes);
|
||||
|
||||
return false !== $info && IMAGETYPE_PNG === $info[2] && $size === $info[0] && $size === $info[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep a user's four PNGs for one choice, replacing whatever they had.
|
||||
*
|
||||
* Written into a fresh directory and renamed into place, so a request
|
||||
* reading them never sees half a set.
|
||||
*
|
||||
* @param array<string, string> $pngs name => bytes, every name in ICONS, each isAcceptable()
|
||||
*/
|
||||
public function store(string $userId, string $choice, string $version, array $pngs): void
|
||||
{
|
||||
$root = $this->root($this->keyFor($userId));
|
||||
$staging = $root . '/.incoming-' . bin2hex(random_bytes(6));
|
||||
|
||||
if (false === is_dir($staging) && false === mkdir($staging, 0755, true) && false === is_dir($staging)) {
|
||||
throw new \RuntimeException(sprintf('Could not create %s.', $staging));
|
||||
}
|
||||
|
||||
foreach (self::ICONS as $name => $size) {
|
||||
file_put_contents($staging . '/' . $name . '.png', $pngs[$name]);
|
||||
}
|
||||
|
||||
$final = $root . '/' . $this->set($choice, $version);
|
||||
|
||||
$this->deleteTree($final);
|
||||
rename($staging, $final);
|
||||
|
||||
// Only the choice just stored is ever linked; see the class comment.
|
||||
foreach (glob($root . '/*', GLOB_ONLYDIR) ?: [] as $other) {
|
||||
if ($other !== $final) {
|
||||
$this->deleteTree($other);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the user's four PNGs for this choice and drawing are all there. */
|
||||
public function has(string $userId, string $choice, string $version): bool
|
||||
{
|
||||
$directory = $this->root($this->keyFor($userId)) . '/' . $this->set($choice, $version);
|
||||
|
||||
foreach (array_keys(self::ICONS) as $name) {
|
||||
if (false === is_file($directory . '/' . $name . '.png')) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored PNG for a key, choice, drawing and name, or null when there is
|
||||
* none. $key is the URL's, so it is checked here too, not only by the route.
|
||||
*/
|
||||
public function pathFor(string $key, string $choice, string $version, string $name): ?string
|
||||
{
|
||||
if (1 !== preg_match('/^' . self::KEY_PATTERN . '$/', $key) || false === isset(self::ICONS[$name])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$path = $this->root($key) . '/' . $this->set($choice, $version) . '/' . $name . '.png';
|
||||
|
||||
return true === is_file($path) ? $path : null;
|
||||
}
|
||||
|
||||
public function deleteAllFor(string $userId): void
|
||||
{
|
||||
$this->deleteTree($this->root($this->keyFor($userId)));
|
||||
}
|
||||
|
||||
private function root(string $key): string
|
||||
{
|
||||
return $this->projectDir . '/' . $this->storageDir . '/app-icons/' . $key;
|
||||
}
|
||||
|
||||
/** `<motif>.<paint>.<version>`: the choice as LogoIcons::choice() spells it, and the drawing. */
|
||||
private function set(string $choice, string $version): string
|
||||
{
|
||||
return $choice . '.' . $version;
|
||||
}
|
||||
|
||||
private function deleteTree(string $directory): void
|
||||
{
|
||||
if (false === is_dir($directory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// scandir, not glob: the staging directories start with a dot, and
|
||||
// glob's way of matching those (GLOB_BRACE) is missing on some libcs.
|
||||
foreach (array_diff(scandir($directory) ?: [], ['.', '..']) as $entry) {
|
||||
$path = $directory . '/' . $entry;
|
||||
|
||||
true === is_dir($path) ? $this->deleteTree($path) : @unlink($path);
|
||||
}
|
||||
|
||||
@rmdir($directory);
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,9 @@ final class LogoIcons
|
||||
'templates/branding/*.svg.twig',
|
||||
'templates/branding/motif/*.svg.twig',
|
||||
'templates/_partials/_logo_mark.html.twig',
|
||||
// Not a drawing, but the manifest route serves it and promises its URL
|
||||
// is immutable, so an edit to it must move the URL like one.
|
||||
'public/manifest.webmanifest',
|
||||
];
|
||||
|
||||
/** @var list<class-string> */
|
||||
@@ -94,6 +97,38 @@ final class LogoIcons
|
||||
return $this->generate('app_branding_favicon', $motif, $paint);
|
||||
}
|
||||
|
||||
/**
|
||||
* One tile with its ground out to every edge: what the installed app's
|
||||
* maskable and iOS icons are drawn from (see icon.svg.twig). The browser
|
||||
* draws it into PNGs; nothing shows it as it is.
|
||||
*/
|
||||
public function bleedUrl(LogoMotif $motif, ?LogoStyle $paint = null): string
|
||||
{
|
||||
return $this->generate('app_branding_bleed', $motif, $paint);
|
||||
}
|
||||
|
||||
/**
|
||||
* The install manifest for one choice, with the icons of the user whose
|
||||
* key it carries, or the wax seal's static PNGs without one (a page nobody
|
||||
* is signed in to).
|
||||
*
|
||||
* Same rule as the tab icon: a manifest is fetched without credentials, so
|
||||
* the URL has to carry everything it depends on.
|
||||
*/
|
||||
public function manifestUrl(LogoMotif $motif, ?LogoStyle $paint = null, ?string $key = null): string
|
||||
{
|
||||
return $this->generate('app_branding_manifest', $motif, $paint, null === $key ? [] : ['u' => $key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* One of a user's installed-app PNGs for one choice; $name is a key of
|
||||
* AppIconStorage::ICONS (`tile-192`, `tile-512`, `bleed-512`, `bleed-180`).
|
||||
*/
|
||||
public function appIconUrl(string $name, string $key, LogoMotif $motif, ?LogoStyle $paint = null): string
|
||||
{
|
||||
return $this->generate('app_branding_app_icon', $motif, $paint, ['key' => $key, 'name' => $name]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The top bar's logo for one choice: the glyph bare, for light chrome or
|
||||
* for dark.
|
||||
@@ -122,11 +157,11 @@ final class LogoIcons
|
||||
}
|
||||
|
||||
/**
|
||||
* The paint is the path's last segment, before `.svg`, and every query
|
||||
* The paint is the path's last segment, before its extension, and every query
|
||||
* parameter comes after it: the appearance pane builds each colourway's
|
||||
* URL by swapping that one segment in the motif's own URL.
|
||||
*
|
||||
* @param array<string, int> $query
|
||||
* @param array<string, int|string> $query
|
||||
*/
|
||||
private function generate(string $route, LogoMotif $motif, ?LogoStyle $paint, array $query = []): string
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Demo;
|
||||
|
||||
use App\Domain\Helper\AppIconStorage;
|
||||
use App\Domain\Helper\AvatarStorage;
|
||||
use App\Domain\Helper\SignatureStorage;
|
||||
use App\Entity\User\User;
|
||||
@@ -43,6 +44,7 @@ final readonly class DemoUserEraser
|
||||
private DemoMode $demoMode,
|
||||
private AvatarStorage $avatars,
|
||||
private SignatureStorage $signatures,
|
||||
private AppIconStorage $appIcons,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -86,6 +88,7 @@ final readonly class DemoUserEraser
|
||||
// leaves an orphan nothing will ever find again.
|
||||
$this->avatars->deleteAllFor((string) $user->id);
|
||||
$this->signatures->deleteAllFor((string) $user->id);
|
||||
$this->appIcons->deleteAllFor((string) $user->id);
|
||||
|
||||
$this->entityManager->remove($user);
|
||||
$this->entityManager->flush();
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Twig;
|
||||
|
||||
use App\Domain\Helper\AppIconStorage;
|
||||
use App\Entity\Embeddable\Appearance;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use App\Infrastructure\Event\Subscriber\AppearanceCookieSubscriber;
|
||||
@@ -34,6 +35,8 @@ final class AppearanceExtension extends AbstractExtension
|
||||
// The icon URLs, spelled in one place with the drawing's version in
|
||||
// them — see LogoIcons for why every one of them carries it.
|
||||
private readonly LogoIcons $logoIcons,
|
||||
// The installed app's PNGs, which the user's own browser draws (#141).
|
||||
private readonly AppIconStorage $appIcons,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -50,6 +53,10 @@ final class AppearanceExtension extends AbstractExtension
|
||||
new TwigFunction('logo_icon_url', $this->logoIcons->url(...)),
|
||||
new TwigFunction('logo_favicon_url', $this->logoIcons->faviconUrl(...)),
|
||||
new TwigFunction('logo_url', $this->logoIcons->logoUrl(...)),
|
||||
new TwigFunction('logo_bleed_url', $this->logoIcons->bleedUrl(...)),
|
||||
new TwigFunction('logo_manifest_url', $this->logoIcons->manifestUrl(...)),
|
||||
new TwigFunction('logo_app_icon_url', $this->logoIcons->appIconUrl(...)),
|
||||
new TwigFunction('app_icons', $this->appIcons(...)),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -108,4 +115,32 @@ final class AppearanceExtension extends AbstractExtension
|
||||
true === $user instanceof User ? $user->id : null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The signed-in user's installed-app icons: the key their URLs carry, and
|
||||
* whether the PNGs for the current choice still have to be drawn (a choice
|
||||
* made before this existed, or a deploy that changed the drawing). Null
|
||||
* for nobody: a page without a user links the static wax seal.
|
||||
*
|
||||
* @return array{key: string, missing: bool}|null
|
||||
*/
|
||||
public function appIcons(): ?array
|
||||
{
|
||||
$user = $this->security->getUser();
|
||||
|
||||
if (false === $user instanceof User) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$appearance = $user->appearance;
|
||||
|
||||
return [
|
||||
'key' => $this->appIcons->keyFor((string) $user->id),
|
||||
'missing' => false === $this->appIcons->has(
|
||||
(string) $user->id,
|
||||
LogoIcons::choice($appearance->effectiveLogoMotif(), $appearance->effectiveLogoPaint()),
|
||||
$this->logoIcons->version(),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -100,9 +100,19 @@
|
||||
<meta name="title-count-key" content="{% block title_count_key %}role:inbox{% endblock %}">
|
||||
{% include '_partials/_favicon.html.twig' %}
|
||||
{# PWA: iOS only offers Web Push to apps added to the Home Screen, and it
|
||||
needs the apple-touch-icon specifically — it ignores the manifest icons. #}
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
|
||||
needs the apple-touch-icon specifically — it ignores the manifest icons.
|
||||
|
||||
Both follow the logo picked in Settings → Appearance (#141), as PNGs the
|
||||
user's own browser drew (see AppIconStorage for why not the server). The
|
||||
manifest is a route with the choice and the user's key in its URL, like
|
||||
the tab icon: it is fetched without credentials. The apple-touch-icon is
|
||||
the form that fills its square, because iOS rounds the corners itself
|
||||
and paints a transparent one black. Nobody signed in: the wax seal. #}
|
||||
{% set _app_icons = app_icons() %}
|
||||
{% set _logo_motif = appearance().effectiveLogoMotif %}
|
||||
{% set _logo_paint = appearance().effectiveLogoPaint %}
|
||||
<link rel="manifest" href="{{ logo_manifest_url(_logo_motif, _logo_paint, _app_icons ? _app_icons.key : null) }}">
|
||||
<link rel="apple-touch-icon" href="{{ _app_icons ? logo_app_icon_url('bleed-180', _app_icons.key, _logo_motif, _logo_paint) : '/icons/apple-touch-icon.png' }}">
|
||||
{# The token the fetch-based endpoints check. Stimulus controllers that
|
||||
build their own request body cannot carry a form field, so they read this
|
||||
and send it as X-CSRF-Token. Every such endpoint validates against the
|
||||
@@ -424,5 +434,16 @@
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{# The installed app's PNGs for the current choice are not drawn yet: a logo
|
||||
picked before #141, or a deploy that changed the drawing. This page draws
|
||||
and uploads them once (core--app-icons); until then the icon route serves
|
||||
the wax seal. #}
|
||||
{% if _app_icons and _app_icons.missing %}
|
||||
<div hidden
|
||||
data-controller="core--app-icons"
|
||||
data-core--app-icons-tile-url-value="{{ logo_icon_url(_logo_motif, _logo_paint) }}"
|
||||
data-core--app-icons-bleed-url-value="{{ logo_bleed_url(_logo_motif, _logo_paint) }}"
|
||||
data-core--app-icons-upload-url-value="{{ path('app_appearance_app_icons') }}"></div>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -21,9 +21,9 @@
|
||||
the smallest icon that is big enough for the tab, which is the 192px PNG.
|
||||
A static file can only wear the product default, so every account saw the
|
||||
wax seal in its tabs whatever icon it had chosen. A second candidate can
|
||||
only be wrong for someone. The PNGs still serve the places that need a
|
||||
raster and have no per-account route: the apple-touch-icon in app.html.twig
|
||||
and the manifest's install icons.
|
||||
only be wrong for someone. The PNGs that rasters need follow the choice
|
||||
too: the install manifest's icons and the apple-touch-icon in app.html.twig
|
||||
are the user's own, drawn by their browser (#141, AppIconStorage).
|
||||
|
||||
The SVG comes from a route rather than a file, because the logo is a
|
||||
per-user setting now (Settings → Appearance → Logo: an icon, then its
|
||||
|
||||
@@ -14,12 +14,24 @@
|
||||
p the paints by part: '#rrggbb', 'url(#g)', 'url(#t)' or null
|
||||
glyph_ramp seven stops when a glyph part is a sweep, else null
|
||||
tile_ramp seven stops when the ground is one, else null
|
||||
bleed true for the ground out to every edge of the 108 canvas, with
|
||||
no rounded clip (see below), else the rounded tile
|
||||
|
||||
THE BLEED
|
||||
A launcher or iOS cuts the corners itself: iOS rounds the square it is given
|
||||
and paints a transparent corner black, and an Android maskable icon is masked
|
||||
to whatever shape the launcher uses. Both want the ground to the edge, with
|
||||
the glyph inside the safe circle, and the 108 canvas is exactly that: the
|
||||
glyph sits in the middle 72, as it does in the tile, so the bleed is the same
|
||||
picture with its edges let out. (It is the geometry of the fixed
|
||||
apple-touch-icon.png and icon-maskable-512.png the installed app wore.)
|
||||
|
||||
Both gradients are userSpaceOnUse, so a ramp runs corner to corner across
|
||||
the glyph — (8,6) to (40,42) on its grid — rather than restarting inside
|
||||
every stroke it paints, and the ground's across the visible square.
|
||||
#}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="18 18 72 72" width="72" height="72">
|
||||
{% set bleed = bleed|default(false) %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="{{ bleed ? '0 0 108 108' : '18 18 72 72' }}" width="{{ bleed ? 108 : 72 }}" height="{{ bleed ? 108 : 72 }}">
|
||||
<defs>
|
||||
{% if glyph_ramp %}
|
||||
<linearGradient id="g" gradientUnits="userSpaceOnUse" x1="8" y1="6" x2="40" y2="42">
|
||||
@@ -31,9 +43,15 @@
|
||||
{%- for stop in tile_ramp %}<stop offset="{{ '%.3f'|format(loop.index0 / 6) }}" stop-color="{{ stop }}"/>{% endfor -%}
|
||||
</linearGradient>
|
||||
{% endif %}
|
||||
{% if not bleed %}
|
||||
<clipPath id="tile"><rect x="18" y="18" width="72" height="72" rx="16"/></clipPath>
|
||||
{% endif %}
|
||||
</defs>
|
||||
{% if bleed %}
|
||||
<g>
|
||||
{% else %}
|
||||
<g clip-path="url(#tile)">
|
||||
{% endif %}
|
||||
<rect width="108" height="108" fill="{{ p.background }}"/>
|
||||
<g transform="translate(25.2 25.2) scale(1.2)">{% include 'branding/motif/' ~ motif.value ~ '.svg.twig' with { p: p } only %}</g>
|
||||
</g>
|
||||
|
||||
@@ -91,6 +91,7 @@
|
||||
data-settings--appearance-upload-url-value="{{ path('app_appearance_background_upload') }}"
|
||||
data-settings--appearance-import-url-value="{{ path('app_appearance_import') }}"
|
||||
data-settings--appearance-reset-url-value="{{ path('app_appearance_reset') }}"
|
||||
data-settings--appearance-app-icons-url-value="{{ path('app_appearance_app_icons') }}"
|
||||
{# What a linked pl mark wears on a theme with no namesake colourway —
|
||||
LogoStyle::DEFAULT, read here rather than spelled in the script. #}
|
||||
data-settings--appearance-logo-default-value="{{ constant('App\\Domain\\Enum\\Theme\\LogoStyle::DEFAULT').value }}">
|
||||
@@ -407,12 +408,17 @@
|
||||
</span>
|
||||
<div class="grid grid-cols-[repeat(auto-fill,minmax(5.5rem,1fr))] gap-3"
|
||||
role="group" aria-labelledby="logo-motif-label">
|
||||
{# The installed app's URLs carry the user's key; see AppIconStorage. #}
|
||||
{% set _app_icon_key = app_icons().key %}
|
||||
{% for motif in enum('App\\Domain\\Enum\\Theme\\LogoMotif').cases %}
|
||||
{% set _url = logo_icon_url(motif) %}
|
||||
<button type="button"
|
||||
data-logo-motif="{{ motif.value }}"
|
||||
data-logo-icon="{{ _url }}"
|
||||
data-logo-favicon="{{ logo_favicon_url(motif) }}"
|
||||
data-logo-bleed="{{ logo_bleed_url(motif) }}"
|
||||
data-logo-manifest="{{ logo_manifest_url(motif, null, _app_icon_key) }}"
|
||||
data-logo-apple-touch="{{ logo_app_icon_url('bleed-180', _app_icon_key, motif) }}"
|
||||
data-logo-bar="{{ logo_url(motif) }}"
|
||||
data-logo-bar-dark="{{ logo_url(motif, null, true) }}"
|
||||
data-action="click->settings--appearance#pickMotif"
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller;
|
||||
|
||||
use App\Domain\Enum\Theme\LogoMotif;
|
||||
use App\Domain\Enum\Theme\LogoStyle;
|
||||
use App\Domain\Helper\AppIconStorage;
|
||||
use App\Entity\User\User;
|
||||
use App\Repository\User\UserRepository;
|
||||
use App\Service\Appearance\LogoIcons;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
/**
|
||||
* The installed app's PNGs: drawn by the user's browser, kept per user, and
|
||||
* served to a manifest that is fetched without credentials (#141).
|
||||
*
|
||||
* The server never draws them (see AppIconStorage for why), so what is pinned
|
||||
* here is what it does with somebody else's drawing: it takes only PNGs of
|
||||
* exactly the right size, only for the uploader, and it never names an icon
|
||||
* that is not there.
|
||||
*/
|
||||
final class AppIconsTest extends WebTestCase
|
||||
{
|
||||
private const string ADMIN_EMAIL = 'e2e-admin@plmail.test';
|
||||
private const string USER_EMAIL = 'e2e@plmail.test';
|
||||
private const string UPLOAD = '/settings/appearance/app-icons';
|
||||
|
||||
private KernelBrowser $client;
|
||||
private AppIconStorage $storage;
|
||||
private string $scratch;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->client = static::createClient();
|
||||
$this->client->disableReboot();
|
||||
|
||||
$this->storage = static::getContainer()->get(AppIconStorage::class);
|
||||
$this->scratch = sys_get_temp_dir() . '/plmail-app-icons-' . bin2hex(random_bytes(4));
|
||||
mkdir($this->scratch);
|
||||
|
||||
foreach ([self::ADMIN_EMAIL, self::USER_EMAIL] as $email) {
|
||||
$this->storage->deleteAllFor((string) $this->user($email)->id);
|
||||
}
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
foreach ([self::ADMIN_EMAIL, self::USER_EMAIL] as $email) {
|
||||
$user = $this->user($email);
|
||||
$this->storage->deleteAllFor((string) $user->id);
|
||||
|
||||
$user->appearance->logoMotif = LogoMotif::DEFAULT;
|
||||
$user->appearance->logoOriginal = true;
|
||||
$user->appearance->logoStyle = LogoStyle::DEFAULT;
|
||||
$user->appearance->logoLinked = true;
|
||||
}
|
||||
|
||||
static::getContainer()->get(EntityManagerInterface::class)->flush();
|
||||
|
||||
array_map('unlink', glob($this->scratch . '/*') ?: []);
|
||||
rmdir($this->scratch);
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testUploadedPngsAreWhatTheManifestsIconsServe(): void
|
||||
{
|
||||
$admin = $this->signIn(self::ADMIN_EMAIL);
|
||||
|
||||
$this->upload('blue-horn', 'ocean', $this->pngs(0x1d4ed8));
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertSame(['choice' => 'blue-horn.ocean'], json_decode((string) $this->client->getResponse()->getContent(), true));
|
||||
|
||||
$manifest = $this->manifestFor($admin, 'blue-horn', 'ocean');
|
||||
|
||||
foreach ($manifest['icons'] as $icon) {
|
||||
$this->client->request('GET', $icon['src']);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertSame('image/png', $this->client->getResponse()->headers->get('Content-Type'));
|
||||
|
||||
$cache = (string) $this->client->getResponse()->headers->get('Cache-Control');
|
||||
self::assertStringContainsString('immutable', $cache, 'the user\'s own, at the current drawing, is kept');
|
||||
self::assertStringNotContainsString('private', $cache);
|
||||
|
||||
$image = imagecreatefromstring((string) $this->client->getResponse()->getContent());
|
||||
self::assertNotFalse($image);
|
||||
self::assertSame(0x1d4ed8, imagecolorat($image, 5, 5) & 0xFFFFFF, 'the uploaded picture, not the wax seal');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A choice the browser has not drawn yet (picked before this existed, or a
|
||||
* deploy changed the drawing): the wax seal of the same shape stands in, so
|
||||
* a manifest never names a 404, and `no-cache` so it does not outlive the
|
||||
* real one.
|
||||
*/
|
||||
public function testAMissingIconIsTheWaxSealAndNotKept(): void
|
||||
{
|
||||
$admin = $this->signIn(self::ADMIN_EMAIL);
|
||||
$root = (string) static::getContainer()->getParameter('kernel.project_dir') . '/public/';
|
||||
|
||||
foreach (AppIconStorage::FALLBACK as $name => $file) {
|
||||
$this->client->request('GET', $this->iconUrl($admin, $name, 'blue-horn', 'ocean'));
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertSame((string) file_get_contents($root . $file), (string) $this->client->getResponse()->getContent(), $name);
|
||||
self::assertStringContainsString('no-cache', (string) $this->client->getResponse()->headers->get('Cache-Control'));
|
||||
}
|
||||
}
|
||||
|
||||
/** @return iterable<string, array{\Closure(self): array<string, string>}> */
|
||||
public static function badUploads(): iterable
|
||||
{
|
||||
yield 'a size that is not the name\'s' => [static fn (self $t): array => ['tile-192' => $t->png(256, 0)] + $t->pngs(0)];
|
||||
yield 'not a PNG' => [static fn (self $t): array => ['tile-512' => $t->jpeg(512)] + $t->pngs(0)];
|
||||
yield 'not an image at all' => [static fn (self $t): array => ['bleed-180' => '<svg xmlns="http://www.w3.org/2000/svg"/>'] + $t->pngs(0)];
|
||||
yield 'too big' => [static fn (self $t): array => ['tile-512' => $t->png(512, 0) . str_repeat("\0", AppIconStorage::MAX_BYTES)] + $t->pngs(0)];
|
||||
yield 'one missing' => [static fn (self $t): array => array_diff_key($t->pngs(0), ['bleed-512' => true])];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \Closure(self): array<string, string> $files
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\DataProvider('badUploads')]
|
||||
public function testOnlyPngsOfExactlyTheirSizeAreKept(\Closure $files): void
|
||||
{
|
||||
$admin = $this->signIn(self::ADMIN_EMAIL);
|
||||
|
||||
$this->upload('blue-horn', 'ocean', $files($this));
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertFalse($this->storage->has((string) $admin->id, 'blue-horn.ocean', $this->version()), 'nothing half-stored');
|
||||
}
|
||||
|
||||
public function testAChoiceTheRoutesCannotNameIsRefused(): void
|
||||
{
|
||||
$this->signIn(self::ADMIN_EMAIL);
|
||||
|
||||
$this->upload('chartreuse', 'original', $this->pngs(0));
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
|
||||
$this->upload('pl', 'chartreuse-dreams', $this->pngs(0));
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
/** A page loaded before a deploy changed the drawing: its drawing is not this server's. */
|
||||
public function testADrawingFromAnotherVersionIsRefused(): void
|
||||
{
|
||||
$this->signIn(self::ADMIN_EMAIL);
|
||||
|
||||
$this->upload('blue-horn', 'ocean', $this->pngs(0), version: '0123456789ab');
|
||||
|
||||
self::assertResponseStatusCodeSame(409);
|
||||
}
|
||||
|
||||
public function testAnUploadNeedsTheToken(): void
|
||||
{
|
||||
$this->signIn(self::ADMIN_EMAIL);
|
||||
|
||||
$this->upload('blue-horn', 'ocean', $this->pngs(0), token: 'forged');
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testNobodySignedInCannotUpload(): void
|
||||
{
|
||||
$this->upload('blue-horn', 'ocean', $this->pngs(0), token: 'none');
|
||||
|
||||
self::assertResponseRedirects();
|
||||
self::assertStringContainsString('/login', (string) $this->client->getResponse()->headers->get('Location'));
|
||||
}
|
||||
|
||||
/** An upload is the uploader's: it changes nobody else's icons. */
|
||||
public function testAnUploadOnlyEverChangesTheUploadersIcons(): void
|
||||
{
|
||||
$other = $this->user(self::USER_EMAIL);
|
||||
$this->storage->store((string) $other->id, 'blue-horn.ocean', $this->version(), $this->pngs(0x00ff00));
|
||||
|
||||
$this->signIn(self::ADMIN_EMAIL);
|
||||
$this->upload('blue-horn', 'ocean', $this->pngs(0xff0000));
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$this->client->request('GET', $this->iconUrl($other, 'tile-192', 'blue-horn', 'ocean'));
|
||||
$image = imagecreatefromstring((string) $this->client->getResponse()->getContent());
|
||||
|
||||
self::assertNotFalse($image);
|
||||
self::assertSame(0x00ff00, imagecolorat($image, 5, 5) & 0xFFFFFF);
|
||||
self::assertNotSame(
|
||||
$this->storage->keyFor((string) $other->id),
|
||||
$this->storage->keyFor((string) $this->user(self::ADMIN_EMAIL)->id),
|
||||
);
|
||||
}
|
||||
|
||||
/** Only the current choice is ever linked, so only it is kept. */
|
||||
public function testANewChoiceReplacesTheOldOne(): void
|
||||
{
|
||||
$admin = $this->signIn(self::ADMIN_EMAIL);
|
||||
|
||||
$this->upload('blue-horn', 'ocean', $this->pngs(0));
|
||||
$this->upload('pl', 'postal', $this->pngs(0));
|
||||
|
||||
self::assertTrue($this->storage->has((string) $admin->id, 'pl.postal', $this->version()));
|
||||
self::assertFalse($this->storage->has((string) $admin->id, 'blue-horn.ocean', $this->version()));
|
||||
}
|
||||
|
||||
/**
|
||||
* The page draws them only when they are missing: the layout carries the
|
||||
* element that does it until an upload for the current choice has landed.
|
||||
*/
|
||||
public function testThePageAsksForTheIconsOnlyWhileTheyAreMissing(): void
|
||||
{
|
||||
$admin = $this->signIn(self::ADMIN_EMAIL);
|
||||
|
||||
$admin->appearance->logoMotif = LogoMotif::BlueHorn;
|
||||
$admin->appearance->logoOriginal = true;
|
||||
static::getContainer()->get(EntityManagerInterface::class)->flush();
|
||||
|
||||
$drawer = $this->client->request('GET', '/mail/inbox')->filter('[data-controller="core--app-icons"]');
|
||||
|
||||
self::assertCount(1, $drawer);
|
||||
self::assertStringStartsWith('/branding/icon/blue-horn/original.svg?v=', (string) $drawer->attr('data-core--app-icons-tile-url-value'));
|
||||
self::assertStringStartsWith('/branding/bleed/blue-horn/original.svg?v=', (string) $drawer->attr('data-core--app-icons-bleed-url-value'));
|
||||
|
||||
$this->upload('blue-horn', 'original', $this->pngs(0));
|
||||
|
||||
self::assertCount(0, $this->client->request('GET', '/mail/inbox')->filter('[data-controller="core--app-icons"]'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The bleed is the tile with its edges let out: the 108 canvas, no rounded
|
||||
* clip, so iOS and a maskable icon can cut their own corners.
|
||||
*/
|
||||
public function testTheBleedSvgIsTheWholeCanvasWithoutTheRoundedClip(): void
|
||||
{
|
||||
$this->client->request('GET', '/branding/bleed/wax-seal/original.svg');
|
||||
$bleed = (string) $this->client->getResponse()->getContent();
|
||||
|
||||
$this->client->request('GET', '/branding/icon/wax-seal/original.svg');
|
||||
$tile = (string) $this->client->getResponse()->getContent();
|
||||
|
||||
self::assertStringContainsString('viewBox="0 0 108 108"', $bleed);
|
||||
self::assertStringNotContainsString('clip-path', $bleed);
|
||||
|
||||
// The companion: the tile still is the rounded one.
|
||||
self::assertStringContainsString('viewBox="18 18 72 72"', $tile);
|
||||
self::assertStringContainsString('clip-path="url(#tile)"', $tile);
|
||||
}
|
||||
|
||||
/** @return array<string, string> a valid set, every name at its size, filled with $rgb */
|
||||
public function pngs(int $rgb): array
|
||||
{
|
||||
$pngs = [];
|
||||
|
||||
foreach (AppIconStorage::ICONS as $name => $size) {
|
||||
$pngs[$name] = $this->png($size, $rgb);
|
||||
}
|
||||
|
||||
return $pngs;
|
||||
}
|
||||
|
||||
public function png(int $size, int $rgb): string
|
||||
{
|
||||
$image = imagecreatetruecolor($size, $size);
|
||||
imagefill($image, 0, 0, $rgb);
|
||||
|
||||
ob_start();
|
||||
imagepng($image);
|
||||
|
||||
return (string) ob_get_clean();
|
||||
}
|
||||
|
||||
public function jpeg(int $size): string
|
||||
{
|
||||
ob_start();
|
||||
imagejpeg(imagecreatetruecolor($size, $size));
|
||||
|
||||
return (string) ob_get_clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $pngs name => bytes
|
||||
*/
|
||||
private function upload(string $motif, string $paint, array $pngs, ?string $version = null, ?string $token = null): void
|
||||
{
|
||||
$files = [];
|
||||
|
||||
foreach ($pngs as $name => $bytes) {
|
||||
$path = $this->scratch . '/' . $name . '-' . bin2hex(random_bytes(3)) . '.png';
|
||||
file_put_contents($path, $bytes);
|
||||
$files[$name] = new UploadedFile($path, $name . '.png', 'image/png', test: true);
|
||||
}
|
||||
|
||||
if (null === $token) {
|
||||
// The layout's `ajax` token, as the page sends it.
|
||||
$token = (string) $this->client->request('GET', '/settings?section=appearance')
|
||||
->filter('meta[name="csrf-token"]')->attr('content');
|
||||
}
|
||||
|
||||
$this->client->request(
|
||||
'POST',
|
||||
self::UPLOAD,
|
||||
['motif' => $motif, 'paint' => $paint, 'version' => $version ?? $this->version()],
|
||||
$files,
|
||||
['HTTP_X_CSRF_TOKEN' => $token],
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array{icons: list<array{src: string}>} */
|
||||
private function manifestFor(User $user, string $motif, string $paint): array
|
||||
{
|
||||
$this->client->request('GET', sprintf(
|
||||
'/branding/manifest/%s/%s.webmanifest?u=%s',
|
||||
$motif,
|
||||
$paint,
|
||||
$this->storage->keyFor((string) $user->id),
|
||||
));
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
/** @var array{icons: list<array{src: string}>} */
|
||||
return json_decode((string) $this->client->getResponse()->getContent(), true, flags: JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
private function iconUrl(User $user, string $name, string $motif, string $paint): string
|
||||
{
|
||||
return static::getContainer()->get(LogoIcons::class)->appIconUrl(
|
||||
$name,
|
||||
$this->storage->keyFor((string) $user->id),
|
||||
LogoMotif::from($motif),
|
||||
LogoStyle::tryFrom($paint),
|
||||
);
|
||||
}
|
||||
|
||||
private function version(): string
|
||||
{
|
||||
return static::getContainer()->get(LogoIcons::class)->version();
|
||||
}
|
||||
|
||||
private function signIn(string $email): User
|
||||
{
|
||||
$user = $this->user($email);
|
||||
$this->client->loginUser($user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function user(string $email): User
|
||||
{
|
||||
$user = static::getContainer()->get(UserRepository::class)->findOneBy(['email' => $email]);
|
||||
|
||||
if (false === $user instanceof User) {
|
||||
self::markTestSkipped('run `app:test:seed-user` and `app:test:seed-user --admin` first');
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller;
|
||||
|
||||
use App\Domain\Enum\Theme\LogoMotif;
|
||||
use App\Domain\Enum\Theme\LogoStyle;
|
||||
use App\Domain\Helper\AppIconStorage;
|
||||
use App\Entity\User\User;
|
||||
use App\Repository\User\UserRepository;
|
||||
use App\Service\Appearance\LogoIcons;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
/**
|
||||
* The installed app wears the icon the person picked (#141).
|
||||
*
|
||||
* The tab icon followed the choice (BrandingFaviconTest) while the install
|
||||
* icon stayed the wax seal: a PWA's icon comes from its manifest, and the
|
||||
* manifest was one static file. It is a route now, with the choice in the URL
|
||||
* like every other picture of the logo, and the route draws what the URL names
|
||||
* and never what the session holds: a manifest is fetched without
|
||||
* credentials, so a session could not speak for it even if it were allowed to.
|
||||
* The user's key in the URL says whose PNGs it names (AppIconStorage).
|
||||
*
|
||||
* From #92 by @Flouss, adapted to PNGs the browser draws.
|
||||
*/
|
||||
final class BrandingManifestTest extends WebTestCase
|
||||
{
|
||||
private const string ADMIN_EMAIL = 'e2e-admin@plmail.test';
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (null !== $user = $this->find()) {
|
||||
$user->appearance->logoMotif = LogoMotif::DEFAULT;
|
||||
$user->appearance->logoOriginal = true;
|
||||
$user->appearance->logoStyle = LogoStyle::DEFAULT;
|
||||
$user->appearance->logoLinked = true;
|
||||
static::getContainer()->get(EntityManagerInterface::class)->flush();
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testAnAnonymousPageLinksTheDefaultManifestWithoutAKey(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$href = $this->manifestLink($client, '/login');
|
||||
|
||||
// Hand-spelled: the default is the wax seal in its own design, and the
|
||||
// route's path is `<motif>/<paint>`.
|
||||
self::assertStringStartsWith('/branding/manifest/wax-seal/original.webmanifest?v=', $href);
|
||||
self::assertStringNotContainsString('u=', $href);
|
||||
}
|
||||
|
||||
public function testAPageLinksTheManifestOfTheUsersChoiceAndKey(): void
|
||||
{
|
||||
[$client, $user] = $this->signedIn();
|
||||
|
||||
$user->appearance->logoMotif = LogoMotif::BlueHorn;
|
||||
$user->appearance->logoOriginal = true;
|
||||
static::getContainer()->get(EntityManagerInterface::class)->flush();
|
||||
|
||||
$href = $this->manifestLink($client, '/mail/inbox');
|
||||
|
||||
self::assertStringStartsWith('/branding/manifest/blue-horn/original.webmanifest?u=' . $this->key($user) . '&v=', $href);
|
||||
|
||||
// The paint is part of the choice, so a recolour is a new URL, which is
|
||||
// what makes the browser look again.
|
||||
$user->appearance->logoMotif = LogoMotif::Pl;
|
||||
$user->appearance->logoStyle = LogoStyle::Postal;
|
||||
$user->appearance->logoLinked = false;
|
||||
static::getContainer()->get(EntityManagerInterface::class)->flush();
|
||||
|
||||
self::assertStringStartsWith('/branding/manifest/pl/postal.webmanifest?u=', $this->manifestLink($client, '/mail/inbox'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The point of the route: the installed app's icons are the choice's, as
|
||||
* the user's PNGs. The rounded tile at the two sizes a manifest is
|
||||
* expected to carry, and the ground-to-the-edge form as the maskable icon.
|
||||
* One picture per purpose and size: a static candidate beside them is how
|
||||
* the wax seal outvoted the tab icon in Firefox.
|
||||
*/
|
||||
public function testWithAKeyTheInstallIconsAreTheUsersPngsOfTheChoice(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$key = str_repeat('ab', 10);
|
||||
|
||||
$manifest = $this->manifest($client, '/branding/manifest/blue-horn/ocean.webmanifest?u=' . $key);
|
||||
|
||||
// The presence companion: no icons at all would pass the loop below.
|
||||
self::assertCount(3, $manifest['icons'], 'a tile at 192 and 512, and the maskable 512');
|
||||
|
||||
$expected = [
|
||||
['tile-192', '192x192', 'any'],
|
||||
['tile-512', '512x512', 'any'],
|
||||
['bleed-512', '512x512', 'maskable'],
|
||||
];
|
||||
|
||||
foreach ($expected as $i => [$name, $sizes, $purpose]) {
|
||||
$icon = $manifest['icons'][$i];
|
||||
|
||||
self::assertSame('image/png', $icon['type']);
|
||||
self::assertSame($sizes, $icon['sizes']);
|
||||
self::assertSame($purpose, $icon['purpose']);
|
||||
self::assertStringStartsWith(sprintf('/branding/app-icon/%s/%s/blue-horn/ocean.png?v=', $key, $name), $icon['src']);
|
||||
}
|
||||
}
|
||||
|
||||
/** Nobody signed in: the static file's own wax seal, as it always was. */
|
||||
public function testWithoutAKeyTheInstallIconsAreTheStaticFilesOwn(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$manifest = $this->manifest($client, '/branding/manifest/wax-seal/original.webmanifest');
|
||||
|
||||
self::assertSame($this->staticManifest()['icons'], $manifest['icons']);
|
||||
}
|
||||
|
||||
/**
|
||||
* iOS ignores the manifest for its home-screen icon and reads the page's
|
||||
* apple-touch-icon, which has to be a PNG. iOS rounds the corners itself and
|
||||
* paints a transparent one black, hence the form that fills the square.
|
||||
*/
|
||||
public function testTheHomeScreenIconOfIosIsTheUsersBleedPng(): void
|
||||
{
|
||||
[$client, $user] = $this->signedIn();
|
||||
|
||||
$user->appearance->logoMotif = LogoMotif::BlueHorn;
|
||||
$user->appearance->logoOriginal = true;
|
||||
static::getContainer()->get(EntityManagerInterface::class)->flush();
|
||||
|
||||
$link = $client->request('GET', '/mail/inbox')->filter('link[rel="apple-touch-icon"]');
|
||||
|
||||
self::assertCount(1, $link);
|
||||
self::assertStringStartsWith(
|
||||
sprintf('/branding/app-icon/%s/bleed-180/blue-horn/original.png?v=', $this->key($user)),
|
||||
(string) $link->attr('href'),
|
||||
);
|
||||
}
|
||||
|
||||
public function testAnAnonymousPageLinksTheStaticHomeScreenIcon(): void
|
||||
{
|
||||
$link = static::createClient()->request('GET', '/login')->filter('link[rel="apple-touch-icon"]');
|
||||
|
||||
self::assertCount(1, $link);
|
||||
self::assertSame('/icons/apple-touch-icon.png', $link->attr('href'));
|
||||
}
|
||||
|
||||
/** Everything but the icons is the static manifest's, so the two cannot drift. */
|
||||
public function testTheRestOfTheManifestIsTheStaticFilesOwn(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$static = $this->staticManifest();
|
||||
$dynamic = $this->manifest($client, '/branding/manifest/wax-seal/original.webmanifest?u=' . str_repeat('ab', 10));
|
||||
|
||||
unset($static['icons'], $dynamic['icons']);
|
||||
|
||||
// Presence companion: an empty static file would equal an empty route.
|
||||
self::assertSame('/mail/inbox', $static['start_url']);
|
||||
self::assertSame($static, $dynamic);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drawn from the URL, not the session: the manifest is fetched without
|
||||
* credentials, and the appearance pane re-points it before the save lands.
|
||||
*/
|
||||
public function testTheManifestNamesWhatItsUrlNamesNotWhatTheSessionHolds(): void
|
||||
{
|
||||
[$client, $user] = $this->signedIn();
|
||||
|
||||
$user->appearance->logoMotif = LogoMotif::Pl;
|
||||
$user->appearance->logoStyle = LogoStyle::Postal;
|
||||
$user->appearance->logoLinked = false;
|
||||
static::getContainer()->get(EntityManagerInterface::class)->flush();
|
||||
|
||||
$manifest = $this->manifest($client, '/branding/manifest/blue-horn/original.webmanifest?u=' . $this->key($user));
|
||||
|
||||
self::assertCount(3, $manifest['icons']);
|
||||
|
||||
foreach ($manifest['icons'] as $icon) {
|
||||
self::assertStringContainsString('/blue-horn/original.png', $icon['src']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The manifest route promises its URL is immutable, so a change to the file
|
||||
* it serves has to move the URL, or every browser keeps the old app name or
|
||||
* colour for a year.
|
||||
*/
|
||||
public function testEditingTheStaticManifestMovesTheVersion(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
|
||||
$dir = sys_get_temp_dir() . '/plmail-manifest-' . bin2hex(random_bytes(4));
|
||||
mkdir($dir . '/public', recursive: true);
|
||||
|
||||
$versionWith = static function (string $manifest) use ($dir): string {
|
||||
file_put_contents($dir . '/public/manifest.webmanifest', $manifest);
|
||||
|
||||
// A fresh instance each time: the version is memoised per object.
|
||||
return new LogoIcons(static::getContainer()->get(UrlGeneratorInterface::class), $dir)->version();
|
||||
};
|
||||
|
||||
try {
|
||||
$first = $versionWith('{"name":"plMail"}');
|
||||
|
||||
// The stable half, so "always different" would not pass.
|
||||
self::assertSame($first, $versionWith('{"name":"plMail"}'));
|
||||
self::assertNotSame($first, $versionWith('{"name":"plMail2"}'));
|
||||
} finally {
|
||||
@unlink($dir . '/public/manifest.webmanifest');
|
||||
@rmdir($dir . '/public');
|
||||
@rmdir($dir);
|
||||
}
|
||||
}
|
||||
|
||||
public function testTheManifestIsPublicAndTheRightType(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$client->request('GET', '/branding/manifest/wax-seal/original.webmanifest');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertSame('application/manifest+json', $client->getResponse()->headers->get('Content-Type'));
|
||||
self::assertStringNotContainsString('private', (string) $client->getResponse()->headers->get('Cache-Control'));
|
||||
self::assertTrue($client->getResponse()->headers->has('ETag'));
|
||||
}
|
||||
|
||||
/** Two users' manifests for one choice are two documents, so one ETag must not answer for both. */
|
||||
public function testTheKeyIsPartOfTheManifestsEtag(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$client->request('GET', '/branding/manifest/wax-seal/original.webmanifest?u=' . str_repeat('ab', 10));
|
||||
$first = $client->getResponse()->headers->get('ETag');
|
||||
|
||||
$client->request('GET', '/branding/manifest/wax-seal/original.webmanifest?u=' . str_repeat('cd', 10));
|
||||
|
||||
self::assertNotSame($first, $client->getResponse()->headers->get('ETag'));
|
||||
}
|
||||
|
||||
public function testAnUnknownChoiceOrAMalformedKeyHasNoManifest(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
foreach ([
|
||||
'/branding/manifest/chartreuse/original.webmanifest',
|
||||
'/branding/manifest/pl/chartreuse-dreams.webmanifest',
|
||||
'/branding/manifest/pl/original.webmanifest?u=../../etc',
|
||||
'/branding/manifest/pl/original.webmanifest?u=ABABABABABABABABABAB',
|
||||
] as $url) {
|
||||
$client->request('GET', $url);
|
||||
self::assertResponseStatusCodeSame(404, $url);
|
||||
}
|
||||
}
|
||||
|
||||
/** The manifest a page links, as the href the browser would fetch. */
|
||||
private function manifestLink(KernelBrowser $client, string $page): string
|
||||
{
|
||||
$link = $client->request('GET', $page)->filter('link[rel="manifest"]');
|
||||
|
||||
// Presence companion: a page with no manifest link at all would pass
|
||||
// any "does not link the static file" check.
|
||||
self::assertCount(1, $link, 'exactly one manifest is linked');
|
||||
|
||||
return (string) $link->attr('href');
|
||||
}
|
||||
|
||||
/** @return array{icons: list<array{src: string, sizes: string, type: string, purpose: string}>, start_url: string} */
|
||||
private function manifest(KernelBrowser $client, string $url): array
|
||||
{
|
||||
$client->request('GET', $url);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
/** @var array{icons: list<array{src: string, sizes: string, type: string, purpose: string}>, start_url: string} */
|
||||
return json_decode((string) $client->getResponse()->getContent(), true, flags: JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function staticManifest(): array
|
||||
{
|
||||
/** @var array<string, mixed> */
|
||||
return json_decode(
|
||||
(string) file_get_contents(self::getContainer()->getParameter('kernel.project_dir') . '/public/manifest.webmanifest'),
|
||||
true,
|
||||
flags: JSON_THROW_ON_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
private function key(User $user): string
|
||||
{
|
||||
return static::getContainer()->get(AppIconStorage::class)->keyFor((string) $user->id);
|
||||
}
|
||||
|
||||
/** @return array{KernelBrowser, User} */
|
||||
private function signedIn(): array
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$user = $this->find();
|
||||
|
||||
if (null === $user) {
|
||||
self::markTestSkipped('run `app:test:seed-user --admin` first');
|
||||
}
|
||||
|
||||
$client->loginUser($user);
|
||||
|
||||
return [$client, $user];
|
||||
}
|
||||
|
||||
private function find(): ?User
|
||||
{
|
||||
$user = static::getContainer()->get(UserRepository::class)
|
||||
->findOneBy(['email' => self::ADMIN_EMAIL]);
|
||||
|
||||
return $user instanceof User ? $user : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { test, expect, type Page } from "../support/test";
|
||||
import { ajaxPost } from "../support/config";
|
||||
|
||||
/**
|
||||
* The installed app's icons follow the logo (#141).
|
||||
*
|
||||
* The server never draws these PNGs: the browser draws the chosen icon on a
|
||||
* canvas and uploads it (assets/app_icons.js), so only a real browser can show
|
||||
* the whole path works: a pick draws four PNGs of the right sizes, the server
|
||||
* keeps them, and the manifest and iOS icon the page links serve them instead
|
||||
* of the wax seal.
|
||||
*
|
||||
* "Serves them" is told apart from the wax seal by the cache header: the
|
||||
* user's own PNG is immutable at the current drawing, the stand-in is
|
||||
* `no-cache` (BrandingController::appIcon()).
|
||||
*/
|
||||
|
||||
const PANEL = '[data-controller="settings--appearance"]';
|
||||
const UPLOAD = "/settings/appearance/app-icons";
|
||||
|
||||
/**
|
||||
* The upload of one choice, as the server names it (`<motif>.<paint>`). Not
|
||||
* just any upload: opening a page can draw the user's current choice too, when
|
||||
* it is missing, and that one must not be mistaken for the pick's. Told apart
|
||||
* by the answer, because Chromium does not expose a multipart body with files
|
||||
* to the test.
|
||||
*/
|
||||
function uploadOf(page: Page, choice: string) {
|
||||
return page.waitForResponse(async (r) => {
|
||||
if (!r.url().endsWith(UPLOAD) || r.request().method() !== "POST") {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return (await r.json()).choice === choice;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Width and height from a PNG's IHDR chunk, which always starts at byte 16. */
|
||||
function pngSize(bytes: Buffer): [number, number] {
|
||||
expect(bytes.subarray(1, 4).toString("latin1"), "a PNG").toBe("PNG");
|
||||
|
||||
return [bytes.readUInt32BE(16), bytes.readUInt32BE(20)];
|
||||
}
|
||||
|
||||
async function linked(page: Page, rel: string): Promise<string> {
|
||||
const href = await page.locator(`link[rel="${rel}"]`).getAttribute("href");
|
||||
|
||||
expect(href, `the page links a ${rel}`).toBeTruthy();
|
||||
|
||||
return href as string;
|
||||
}
|
||||
|
||||
/** The user's own PNG at that URL, of that size. */
|
||||
async function expectOwnIcon(page: Page, url: string, size: number): Promise<void> {
|
||||
const response = await page.request.get(url);
|
||||
|
||||
expect(response.status(), url).toBe(200);
|
||||
expect(response.headers()["content-type"]).toBe("image/png");
|
||||
expect(response.headers()["cache-control"], `${url} is the user's own, not the wax seal`).toContain("immutable");
|
||||
expect(pngSize(await response.body())).toEqual([size, size]);
|
||||
}
|
||||
|
||||
/** Back to the default logo, which is where both tests start and must leave the user. */
|
||||
async function reset(page: Page): Promise<void> {
|
||||
await page.goto("/settings?section=appearance");
|
||||
await page.locator(PANEL).getByRole("button", { name: /reset/i }).first().click();
|
||||
await page.waitForResponse((r) => r.url().includes("/appearance/reset")).catch(() => {});
|
||||
}
|
||||
|
||||
test.describe("appearance — the installed app's icons", () => {
|
||||
// Before as well as after: picking the logo that is already chosen is not
|
||||
// a change and draws nothing, so the test must not inherit a blue horn.
|
||||
test.beforeEach(async ({ page }) => reset(page));
|
||||
|
||||
// The logo is this worker's user's, and the rest of the suite reads it.
|
||||
test.afterEach(async ({ page }) => reset(page));
|
||||
|
||||
test("picking a logo draws its PNGs, and the manifest and iOS icon serve them", async ({ page }) => {
|
||||
await page.goto("/settings?section=appearance");
|
||||
await expect(page.locator(PANEL)).toBeVisible();
|
||||
|
||||
const uploaded = uploadOf(page, "blue-horn.original");
|
||||
await page.locator(`${PANEL} [data-logo-motif="blue-horn"]`).click();
|
||||
expect((await uploaded).status(), "the server kept all four").toBe(200);
|
||||
|
||||
// Re-pointed on the pick, before any reload.
|
||||
const manifestUrl = await linked(page, "manifest");
|
||||
expect(manifestUrl).toContain("/branding/manifest/blue-horn/original.webmanifest?u=");
|
||||
|
||||
const manifest = await (await page.request.get(manifestUrl)).json();
|
||||
const icons = manifest.icons as { src: string; sizes: string; purpose: string }[];
|
||||
|
||||
expect(icons.map((icon) => [icon.sizes, icon.purpose])).toEqual([
|
||||
["192x192", "any"],
|
||||
["512x512", "any"],
|
||||
["512x512", "maskable"],
|
||||
]);
|
||||
|
||||
for (const icon of icons) {
|
||||
await expectOwnIcon(page, icon.src, Number(icon.sizes.split("x")[0]));
|
||||
}
|
||||
|
||||
const appleTouch = await linked(page, "apple-touch-icon");
|
||||
expect(appleTouch).toContain("/bleed-180/blue-horn/original.png");
|
||||
await expectOwnIcon(page, appleTouch, 180);
|
||||
});
|
||||
|
||||
test("a page whose PNGs are missing draws them once", async ({ page }) => {
|
||||
// Saved without the pane, so nothing has drawn this choice: only one
|
||||
// choice is kept per user, and the last one drawn was not this.
|
||||
await page.goto("/settings?section=appearance");
|
||||
const saved = await ajaxPost(page, "/settings/appearance", { logoMotif: "pl", logoOriginal: "0", logoLinked: "0", logoStyle: "postal" });
|
||||
expect(saved.ok()).toBe(true);
|
||||
|
||||
const uploaded = uploadOf(page, "pl.postal");
|
||||
await page.goto("/mail/inbox");
|
||||
expect((await uploaded).status()).toBe(200);
|
||||
|
||||
await expectOwnIcon(page, await linked(page, "apple-touch-icon"), 180);
|
||||
|
||||
// Drawn now, so the next page does not ask again.
|
||||
await page.goto("/mail/inbox");
|
||||
await expect(page.locator('[data-controller="core--app-icons"]')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user