# 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 ``, four ``, 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 ``, so it stays scannable in dark mode. - `` 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 `` (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-.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: , url: }`. 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 `` 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 `` 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 `
` (a plain element, so `@js` compiles there): - under the link field, a row with `` and, in a `` wrapper, ``; - `` holding `
{!! $qrCodeSvg !!}
` (the SVG is generated from the app's own URL — no user input), then, for a password-protected share, ``, and actions `` and ``. "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 `.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-.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.