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:
co-authored by
Claude Opus 5
parent
fbe358b2e5
commit
1352533667
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,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'
|
||||
|
||||
@@ -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,14 +11,51 @@
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4">
|
||||
<x-input
|
||||
:label="__('Share Link')"
|
||||
:value="route('share.download', $share)"
|
||||
readonly
|
||||
copyable
|
||||
icon="link"
|
||||
data-test="share-link"
|
||||
/>
|
||||
{{-- 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="$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" />
|
||||
|
||||
@@ -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]],
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
@@ -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'));
|
||||
});
|
||||
Reference in New Issue
Block a user