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
This commit is contained in:
Andreas Reinhold / reini
2026-09-13 11:46:07 +02:00
co-authored by Claude Opus 5
parent fbe358b2e5
commit 1352533667
11 changed files with 416 additions and 10 deletions
+5
View File
@@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [2.0.0] - 2026-09-13
### Added
- The share created page offers the link as a QR code: "Show QR code" opens it in a dialog (full screen on a phone) and "Download" saves it as a PNG. For a password-protected share the dialog reminds that recipients also need the password; the code holds only the link.
- A "Share…" button on the same page opens the device's share sheet with the link, where the browser has one (mostly phones and Safari).
### Changed
- The interface is rebuilt on [Livewire Material](https://gitea.nonameweb.ch/noNameWEB/livewire-material), a Material 3 Expressive component library, replacing Mary UI and DaisyUI. Every page — upload, share created, download, sign-in, settings, admin and the setup wizard — uses its components, in a colour scheme generated from SealShare's indigo.
+1 -1
View File
@@ -5,7 +5,7 @@ A simple, self-hosted file sharing solution built with Laravel. Upload files, ge
## Features
- **File Uploading** — Drag & drop or browse to upload single/multiple files and folders with real-time progress
- **Shareable Links** — Each upload generates a unique link for recipients
- **Shareable Links** — Each upload generates a unique link for recipients, also as a QR code (saved as a PNG) or through the device's share sheet
- **End-to-End Encryption** — All files encrypted at rest using AES-256-GCM (chunked, streaming)
- **Password Protection** — Optionally protect shares with a password
- **Expiration** — Shares auto-expire after a configurable duration (1 hour to 30 days)
+9 -1
View File
@@ -2,7 +2,9 @@
namespace App\Livewire;
use App\Models\Setting;
use App\Models\Share;
use App\Services\QrCodeService;
use Livewire\Attributes\Layout;
use Livewire\Component;
@@ -18,6 +20,12 @@ class ShareCreated extends Component
public function render(): mixed
{
return view('livewire.share-created');
$shareUrl = route('share.download', $this->share);
return view('livewire.share-created', [
'shareUrl' => $shareUrl,
'qrCodeSvg' => app(QrCodeService::class)->svg($shareUrl),
'siteTitle' => Setting::get('site_title') ?: config('app.name'),
]);
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace App\Services;
use BaconQrCode\Common\ErrorCorrectionLevel;
use BaconQrCode\Encoder\Encoder;
use BaconQrCode\Renderer\Color\Rgb;
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
use BaconQrCode\Renderer\ImageRenderer;
use BaconQrCode\Renderer\RendererStyle\Fill;
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
use BaconQrCode\Writer;
class QrCodeService
{
/**
* The QR code's width and height in the SVG, in pixels: the size a canvas draws it at.
*/
public const SIZE = 1024;
/**
* Draw the contents as a QR code in SVG: black on white with a four-module quiet zone and
* error correction M, the most reliable to scan from a screen or a print. The XML declaration
* is dropped so the markup can sit inline in a page.
*/
public function svg(string $contents): string
{
$svg = (new Writer(
new ImageRenderer(
new RendererStyle(self::SIZE, 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());
return trim(substr($svg, strpos($svg, "\n") + 1));
}
}
+149
View File
@@ -0,0 +1,149 @@
# 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.
+1
View File
@@ -1,2 +1,3 @@
// Livewire Material. Alpine is bundled and started by Livewire 4: never import it here as well.
import '../../vendor/nonameweb/livewire-material/resources/js/material.js'
import './share-created.js'
+76
View File
@@ -0,0 +1,76 @@
/**
* `shareActions`: the ways the share created page hands a share over besides copying its link —
* the device's share sheet, where the browser has one, and the QR code in its dialog saved as a
* PNG. Both carry only the link.
*
* The PNG is drawn in the browser from the dialog's SVG, which is 1024 pixels square, so the
* server needs no image extension and every browser rasterises it at full size.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('shareActions', ({ url, title, filename, messages }) => ({
open: false,
canShare: typeof navigator.share === 'function',
async share() {
try {
await navigator.share({ title, url })
} catch (error) {
if (error?.name !== 'AbortError') {
window.materialToast(messages.shareFailed, { type: 'error' })
}
}
},
async downloadQrCode(svg) {
try {
const png = await rasterise(svg)
const link = document.createElement('a')
const href = URL.createObjectURL(png)
link.href = href
link.download = filename
link.click()
setTimeout(() => URL.revokeObjectURL(href), 0)
} catch {
window.materialToast(messages.downloadFailed, { type: 'error' })
}
},
}))
})
/**
* The SVG element as a PNG blob, at the SVG's own width and height, on white.
*/
async function rasterise(svg) {
const width = Number(svg.getAttribute('width'))
const height = Number(svg.getAttribute('height'))
const source = URL.createObjectURL(new Blob([new XMLSerializer().serializeToString(svg)], { type: 'image/svg+xml' }))
try {
const image = new Image()
image.src = source
await image.decode()
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const context = canvas.getContext('2d')
context.imageSmoothingEnabled = false
context.fillStyle = '#ffffff'
context.fillRect(0, 0, width, height)
context.drawImage(image, 0, 0, width, height)
const png = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'))
if (!png) {
throw new Error('The canvas gave no image.')
}
return png
} finally {
URL.revokeObjectURL(source)
}
}
@@ -11,15 +11,52 @@
</div>
<div class="grid gap-4">
{{-- Besides the link: a QR code in a dialog, saved as a PNG in the browser, and the device's
share sheet where there is one (resources/js/share-created.js). Both carry the link only. --}}
<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.')]),
})"
class="grid gap-3"
data-test="share-actions"
>
<x-input
:label="__('Share Link')"
:value="route('share.download', $share)"
:value="$shareUrl"
readonly
copyable
icon="link"
data-test="share-link"
/>
<div class="flex flex-wrap gap-2">
<x-button :label="__('Show QR code')" icon="qr_code_2" variant="tonal" x-on:click="open = true" data-test="show-qr-code" />
<span x-show="canShare" x-cloak class="inline-flex">
<x-button :label="__('Share…')" icon="share" variant="tonal" x-on:click="share()" data-test="share-sheet" />
</span>
</div>
<x-modal fullscreen :title="__('Scan to open the share')" data-test="qr-code-dialog">
{{-- White in either theme: a scanner needs the contrast. The SVG is drawn from the app's own URL. --}}
<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>
@if ($share->isPasswordProtected())
<div class="mt-4">
<x-alert color="info" icon="lock" :title="__('Recipients also need the password.')" />
</div>
@endif
<x-slot:actions>
<x-button :label="__('Close')" x-on:click="close()" />
<x-button :label="__('Download')" icon="download" variant="tonal" x-on:click="downloadQrCode($el.closest('dialog').querySelector('[data-qr-code] svg'))" data-test="download-qr-code" />
</x-slot:actions>
</x-modal>
</div>
<div class="grid grid-cols-2 gap-3">
<x-stat :title="__('Files')" :value="$share->files->count()" icon="description" />
<x-stat :title="__('Total Size')" :value="Number::fileSize($share->total_size)" icon="hard_drive" />
+45
View File
@@ -54,6 +54,51 @@ test('a new share\'s link can be copied from the page the upload leads to', func
->assertSee('Copied to the clipboard');
});
test('a new share\'s QR code opens in a dialog and saves as a PNG', function () {
$share = Share::factory()->withPassword()->create();
$page = ready(visit(route('share.created', $share, false)));
$page->click('[data-test="show-qr-code"]')
->assertScript("document.querySelector('[data-test=\"qr-code-dialog\"]').open")
->assertScript("getComputedStyle(document.querySelector('[data-qr-code]')).backgroundColor === 'rgb(255, 255, 255)'")
->assertScript("document.querySelector('[data-qr-code] svg').getBoundingClientRect().width > 200")
->assertSee('Recipients also need the password.');
// Record what would be saved instead of saving it.
$page->script('window.eval("URL.revokeObjectURL = () => {}; HTMLAnchorElement.prototype.click = function () { window.saved = { name: this.download, href: this.href } }")');
$page->click('[data-test="download-qr-code"]')
->assertScript("window.eval('window.saved?.name') === 'share-{$share->token}.png'");
// A QR code is roughly a third to a half dark; a blank or failed drawing is not.
$page->script("window.eval(\"(async () => { const blob = await (await fetch(window.saved.href)).blob(); const bitmap = await createImageBitmap(blob); const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); const context = canvas.getContext('2d'); context.drawImage(bitmap, 0, 0); const pixels = context.getImageData(0, 0, bitmap.width, bitmap.height).data; let dark = 0; for (let i = 0; i < pixels.length; i += 4) { if (pixels[i] < 128) { dark++ } } window.png = { type: blob.type, width: bitmap.width, dark: dark / (pixels.length / 4) } })()\")");
$page->assertScript("window.eval('window.png?.type') === 'image/png'")
->assertScript("window.eval('window.png.width') === 1024")
->assertScript("window.eval('window.png.dark') > 0.2 && window.eval('window.png.dark') < 0.6")
->assertNoJavaScriptErrors();
});
test('the share sheet gets the link, and says so only when it fails for another reason than a cancel', function () {
$share = Share::factory()->create();
$actions = "Alpine.\$data(document.querySelector('[data-test=share-actions]'))";
$page = ready(visit(route('share.created', $share, false)));
// Shown only where the browser has a share sheet.
$page->assertScript("window.eval(\"getComputedStyle(document.querySelector('[data-test=share-sheet]').parentElement).display === 'none'\") === (typeof navigator.share !== 'function')");
$page->script("window.eval(\"navigator.share = async (data) => { window.shared = data }; {$actions}.share()\")");
$page->assertScript("window.eval('window.shared?.url') === '".route('share.download', $share)."'");
$page->script("window.eval(\"navigator.share = async () => { throw new DOMException('Cancelled', 'AbortError') }; {$actions}.share()\")");
$page->wait(0.3)->assertDontSee('The share sheet could not open.');
$page->script("window.eval(\"navigator.share = async () => { throw new DOMException('Not allowed', 'NotAllowedError') }; {$actions}.share()\")");
$page->assertSee('The share sheet could not open.');
});
test('a recipient on a phone unlocks a password-protected share and sees its files', function () {
$share = app(ShareService::class)->createShare(
[['file' => UploadedFile::fake()->create('holiday-photos.zip', 120), 'relativePath' => null]],
+27
View File
@@ -0,0 +1,27 @@
<?php
use App\Models\Share;
use App\Services\QrCodeService;
test('the share created page offers the link as a QR code and through the share sheet', function () {
$share = Share::factory()->create();
$url = route('share.download', $share);
$response = $this->get(route('share.created', $share));
$response->assertOk()
->assertSee($url, false)
->assertSee('data-test="show-qr-code"', false)
->assertSee('data-test="share-sheet"', false)
->assertSee('share-'.$share->token.'.png')
->assertSee('<div data-qr-code class="mx-auto aspect-square w-full max-w-80 rounded-corner-lg bg-white p-2 [&>svg]:size-full">'.app(QrCodeService::class)->svg($url).'</div>', false)
->assertDontSee('Recipients also need the password.');
});
test('the QR code dialog reminds that a protected share also needs its password', function () {
$share = Share::factory()->withPassword()->create();
$this->get(route('share.created', $share))
->assertOk()
->assertSee('Recipients also need the password.');
});
+21
View File
@@ -0,0 +1,21 @@
<?php
use App\Services\QrCodeService;
test('it draws a 1024 pixel QR code as inline SVG, black on white', function () {
$svg = (new QrCodeService)->svg('https://share.example.com/s/aBcDeFgHiJkLmNoP');
expect($svg)->toStartWith('<svg ')
->not->toContain('<?xml')
->toContain('width="1024" height="1024"')
->toContain('fill="#ffffff"')
->toContain('fill="#000000"');
});
test('the same contents give the same code, other contents another', function () {
$service = new QrCodeService;
expect($service->svg('https://share.example.com/s/aBcDeFgHiJkLmNoP'))
->toBe($service->svg('https://share.example.com/s/aBcDeFgHiJkLmNoP'))
->not->toBe($service->svg('https://share.example.com/s/zYxWvUtSrQpOnMlK'));
});