Files
SealShare/docs/plans/share-qr-code.md
T
Andreas Reinhold / reiniandClaude Opus 5 1352533667 Offer a new share as a QR code and through the share sheet
The share created page gets "Show QR code", a dialog with the link as a
QR code (black on white, full screen on a phone) that downloads as a PNG
drawn in the browser, with a password reminder for protected shares; and
"Share…", which opens the device's share sheet where there is one. Both
carry only the link.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 11:46:07 +02:00

150 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Share by QR code and share sheet
## Goal
After an upload, the share created page offers two more ways to hand a share over besides
copying the link: a QR code, in a dialog, that another device scans (and that downloads as a
PNG for chats and mails), and, where the browser has one, the device's native share sheet. Both
carry only the share's link — never a password.
## Context
- Laravel 13.31, Livewire 4.4, Livewire Material 1.0.x, Pest 5 with browser tests, Octane
(FrankenPHP). Production image `dunglas/frankenphp:php8.5-alpine` with `intl`, `pcntl`, `zip`
added; it has `xmlwriter` and `iconv`, and neither `gd` nor `imagick`.
- `bacon/bacon-qr-code` v3.1.1 is installed through `laravel/fortify` (`^3.0`), which draws the
two-factor setup QR with `Writer` + `ImageRenderer` + `SvgImageBackEnd` and strips the XML
declaration (`TwoFactorAuthenticatable::twoFactorQrCodeSvg()`). SVG needs no image extension;
a server-side PNG would.
- `app/Livewire/ShareCreated.php` (`#[Layout('layouts.app')]`, `public Share $share`) renders
`resources/views/livewire/share-created.blade.php`: the link as
`<x-input :value="route('share.download', $share)" readonly copyable data-test="share-link">`,
four `<x-stat>`, an info alert for password-protected shares, and "Upload More". The route
`share/{share:token}/created` sits behind `system.password`, like the upload page.
- The two-factor dialog (`pages/settings/⚡two-factor`) is the in-app pattern: the SVG inline
on a `bg-white` panel inside `<x-modal fullscreen>`, so it stays scannable in dark mode.
- `<x-modal>` without `wire:model` opens from `open` in the surrounding Alpine scope and gives
`close()`; `materialToast()` is global; `resources/js/app.js` imports only the package JS.
- Services live in `app/Services` (`ShareService`, `FileEncryptionService`). Share passwords
are hashed and turned into a key; the plain password is never stored.
- No feature test covers `ShareCreated` yet; `tests/Browser/SealShareTest.php` copies the link
on that page.
## Decisions
- **Only on the share created page** — that is where a share is handed over; the admin
dashboard and the download page stay as they are.
- **A "Show QR code" button opens a dialog** — the page stays as calm as now; the dialog is
`<x-modal fullscreen>` (the whole screen on a phone, to hold up to another camera) with the
QR on a white panel.
- **Download is a PNG made in the browser** — the dialog's SVG is drawn onto a canvas and saved
as `share-<token>.png`; no server route and no `gd`/`imagick` in the Docker images.
- **Require `bacon/bacon-qr-code:^3.0` directly** — the version already installed through
Fortify, declared so SealShare does not depend on Fortify keeping it.
- **Password-protected shares get a note in the dialog** — "Recipients also need the password."
The QR holds the link only.
- **A "Share…" button opens the native share sheet** — shown only where `navigator.share` exists
(mostly phones and Safari), sharing `{ title: <site title>, url: <share link> }`.
Cancelling the sheet (`AbortError`) does nothing; any other failure shows an error snackbar.
- **In 2.0.0, on the `material` branch** — 2.0.0 is not released and the page was just rebuilt
there; one PR, one changelog entry.
- **Black modules on white, a four-module quiet zone, error correction M** — the most reliable
to scan from a screen or a print; the site's theme does not tint it.
- **The SVG is drawn at 1024 × 1024** — CSS scales it down in the dialog, and the canvas draws it
at its own size, so the PNG is sharp in every browser (Safari rasterises an SVG at its
intrinsic size).
- **Generated server-side with the page, opened client-side** — the SVG is a few kilobytes and
the dialog needs no round trip; the dialog's `open` is Alpine state, not a Livewire property.
- **`App\Services\QrCodeService::svg(string $contents): string`** — one place that knows Bacon's
API; `ShareCreated::render()` passes `shareUrl`, `qrCodeSvg` and `siteTitle` to the view (as
`FileUploader::render()` passes `siteTitle`), so the URL is built once.
- **The share and download behaviour lives in `resources/js/share-created.js`** — an
`Alpine.data('shareActions', …)` with `canShare`, `share()` and `downloadQrCode()`, imported
by `app.js`, instead of long inline Alpine in the view.
## Out of scope
- QR codes on the admin dashboard or the download page.
- Sharing the QR image itself through the share sheet (`navigator.share({ files })`).
- An SVG download, a server-rendered PNG, or a print layout.
- A logo in the middle of the QR, or colours from the theme.
- Putting the password (or any secret beyond the link's token) into the QR or the share sheet.
## Implementation steps
1. **Dependency.** `composer require bacon/bacon-qr-code:^3.0` (stays at v3.1.1).
2. **Service.** `php artisan make:class Services/QrCodeService`: `svg(string $contents): string`
renders with `new Writer(new ImageRenderer(new RendererStyle(1024, 4, null, null,
Fill::uniformColor(new Rgb(255, 255, 255), new Rgb(0, 0, 0))), new SvgImageBackEnd))`,
`writeString($contents, Encoder::DEFAULT_BYTE_MODE_ENCODING, ErrorCorrectionLevel::M())`, and
drops the XML declaration as Fortify does.
3. **Component.** `ShareCreated::render()` builds `$shareUrl = route('share.download',
$this->share)` and passes `shareUrl`, `qrCodeSvg` (from the service) and `siteTitle`
(`Setting::get('site_title') ?: config('app.name')`) to the view; the link field uses
`$shareUrl`.
4. **JavaScript.** `resources/js/share-created.js` registers on `alpine:init`
`Alpine.data('shareActions', ({ url, title, filename, messages }) => …)`, `messages` holding
the translated `shareFailed` and `downloadFailed`:
- `open: false` for the dialog;
- `canShare`: `typeof navigator.share === 'function'`, read once at init;
- `share()`: `navigator.share({ title, url })`, ignoring `AbortError`, otherwise
`materialToast(messages.shareFailed, { type: 'error' })`;
- `downloadQrCode(svg)`: takes the `<svg>` element (the button passes
`$el.closest('dialog').querySelector('[data-qr-code] svg')` — the dialog has its own Alpine
scope, so `$refs` from the outer one would not reach it), serialises it, loads it into an
`Image` from a Blob URL, draws it on a 1024 × 1024 canvas with a white fill and
`imageSmoothingEnabled = false`, `toBlob('image/png')`, clicks a temporary `<a download>`
named `filename`, and revokes both object URLs; a failed load or an empty blob shows
`materialToast(messages.downloadFailed, { type: 'error' })`.
`resources/js/app.js` imports it after the package.
5. **View.** In `share-created.blade.php`, wrap the link and actions in
`<div x-data="shareActions({ url: @js($shareUrl), title: @js($siteTitle), filename: @js('share-'.$share->token.'.png'), messages: @js(['shareFailed' => __('The share sheet could not open.'), 'downloadFailed' => __('The QR code could not be saved.')]) })">`
(a plain element, so `@js` compiles there):
- under the link field, a row with `<x-button :label="__('Show QR code')" icon="qr_code_2"
variant="tonal" x-on:click="open = true" data-test="show-qr-code" />` and, in a
`<span x-show="canShare" x-cloak>` wrapper, `<x-button :label="__('Share…')" icon="share"
variant="tonal" x-on:click="share()" data-test="share-sheet" />`;
- `<x-modal fullscreen :title="__('Scan to open the share')">` holding
`<div data-qr-code class="mx-auto aspect-square w-full max-w-80 rounded-corner-lg bg-white p-2 [&>svg]:size-full">{!! $qrCodeSvg !!}</div>`
(the SVG is generated from the app's own URL — no user input), then, for a
password-protected share, `<x-alert color="info" icon="lock" :title="__('Recipients also need the password.')" />`,
and actions `<x-button :label="__('Download')" icon="download" x-on:click="downloadQrCode($el.closest('dialog').querySelector('[data-qr-code] svg'))" data-test="download-qr-code" />`
and `<x-button :label="__('Close')" x-on:click="close()" />`.
"Upload More" and the stats stay where they are.
6. **Docs.** README: the "Shareable Links" feature line mentions the QR code and share sheet.
CHANGELOG `2.0.0`: an "Added" section (before "Changed", as Keep a Changelog orders them)
with an entry for both.
## Testing
- `tests/Unit/QrCodeServiceTest.php` (the service needs no application):
`svg()` returns markup starting with `<svg`, without an XML declaration, 1024 wide, and the
same markup for the same contents and different markup for different contents.
- `tests/Feature/ShareCreatedTest.php` (new): the page shows the link, and its HTML contains
exactly `QrCodeService::svg(route('share.download', $share))` inside the dialog, the
"Show QR code" and "Share…" buttons, and the download filename `share-<token>.png`; the
password note appears for a protected share and not for an open one.
- `tests/Browser/SealShareTest.php`:
- "Show QR code" opens the dialog with the QR on a white panel; Download produces an
`image/png` blob named `share-<token>.png` (recorded by stubbing
`HTMLAnchorElement.prototype.click` through `window.eval`), with no JavaScript errors;
- the Share button is hidden where `navigator.share` is missing, and `share()` passes the
link to a stubbed `navigator.share`, stays quiet on `AbortError` and shows the error snackbar
on any other rejection.
- `DesignLanguageTest` keeps passing (`qr_code_2`, `share`, `download` are Material Symbols;
`bg-white` is a token).
- Narrow runs per step, then the full suite.
## Risks and open questions
- **Scanning reliability** is not proven by the tests (no decoder in the stack): the feature
test pins the SVG to Bacon's output for the exact URL, and a manual scan with a phone during
review is the check.
- **The QR is only as right as the link.** Behind a reverse proxy with a wrong `APP_URL` or
trusted-proxy setting, both point at the wrong host — unchanged from today.
- **Safari and canvas.** Drawing an SVG from a Blob URL onto a canvas works in current
Chrome, Firefox and Safari without tainting the canvas; the browser test runs in Chromium
locally and in CI, and the three-engine check is manual.
- **Share sheet on desktop** exists in Safari and Chromium on some platforms and not in
Firefox; the button's absence there is by design.