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
150 lines
10 KiB
Markdown
150 lines
10 KiB
Markdown
# 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.
|