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
10 KiB
10 KiB
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-alpinewithintl,pcntl,zipadded; it hasxmlwriterandiconv, and neithergdnorimagick. bacon/bacon-qr-codev3.1.1 is installed throughlaravel/fortify(^3.0), which draws the two-factor setup QR withWriter+ImageRenderer+SvgImageBackEndand 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) rendersresources/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 routeshare/{share:token}/createdsits behindsystem.password, like the upload page.- The two-factor dialog (
pages/settings/⚡two-factor) is the in-app pattern: the SVG inline on abg-whitepanel inside<x-modal fullscreen>, so it stays scannable in dark mode. <x-modal>withoutwire:modelopens fromopenin the surrounding Alpine scope and givesclose();materialToast()is global;resources/js/app.jsimports 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
ShareCreatedyet;tests/Browser/SealShareTest.phpcopies 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 nogd/imagickin the Docker images. - Require
bacon/bacon-qr-code:^3.0directly — 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.shareexists (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
materialbranch — 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
openis Alpine state, not a Livewire property. App\Services\QrCodeService::svg(string $contents): string— one place that knows Bacon's API;ShareCreated::render()passesshareUrl,qrCodeSvgandsiteTitleto the view (asFileUploader::render()passessiteTitle), so the URL is built once.- The share and download behaviour lives in
resources/js/share-created.js— anAlpine.data('shareActions', …)withcanShare,share()anddownloadQrCode(), imported byapp.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
- Dependency.
composer require bacon/bacon-qr-code:^3.0(stays at v3.1.1). - Service.
php artisan make:class Services/QrCodeService:svg(string $contents): stringrenders withnew 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. - Component.
ShareCreated::render()builds$shareUrl = route('share.download', $this->share)and passesshareUrl,qrCodeSvg(from the service) andsiteTitle(Setting::get('site_title') ?: config('app.name')) to the view; the link field uses$shareUrl. - JavaScript.
resources/js/share-created.jsregisters onalpine:initAlpine.data('shareActions', ({ url, title, filename, messages }) => …),messagesholding the translatedshareFailedanddownloadFailed:open: falsefor the dialog;canShare:typeof navigator.share === 'function', read once at init;share():navigator.share({ title, url }), ignoringAbortError, otherwisematerialToast(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$refsfrom the outer one would not reach it), serialises it, loads it into anImagefrom a Blob URL, draws it on a 1024 × 1024 canvas with a white fill andimageSmoothingEnabled = false,toBlob('image/png'), clicks a temporary<a download>namedfilename, and revokes both object URLs; a failed load or an empty blob showsmaterialToast(messages.downloadFailed, { type: 'error' }).resources/js/app.jsimports it after the package.
- 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@jscompiles 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.
- under the link field, a row with
- 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 exactlyQrCodeService::svg(route('share.download', $share))inside the dialog, the "Show QR code" and "Share…" buttons, and the download filenameshare-<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/pngblob namedshare-<token>.png(recorded by stubbingHTMLAnchorElement.prototype.clickthroughwindow.eval), with no JavaScript errors; - the Share button is hidden where
navigator.shareis missing, andshare()passes the link to a stubbednavigator.share, stays quiet onAbortErrorand shows the error snackbar on any other rejection.
- "Show QR code" opens the dialog with the QR on a white panel; Download produces an
DesignLanguageTestkeeps passing (qr_code_2,share,downloadare Material Symbols;bg-whiteis 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_URLor 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.