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

10 KiB
Raw Blame History

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.