feat(wg): compact QR + meaningful download filename (v0.9.44)

- QR: 0.9.42 over-enlarged it; bring the show-once peer QR back to a balanced
  ~240px (still crispEdges on a white quiet-zone frame) so it scans on a phone
  without dominating the modal.
- Download filename now becomes a useful tunnel name on import: build it from
  the endpoint host + peer name (e.g. "10.10.90.165-laptop.conf") instead of the
  generic "clusev-wireguard.conf" — WireGuard apps name a tunnel after the file.
  A QR scan can't carry a name (the app prompts for one); the import hint now
  explains both paths.

Tests: download-filename cases (host+peer, fallback); 349 pass, Pint clean,
/wireguard loads 200, lang parity 83/83.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/v1-foundation v0.9.44
boban 2026-06-21 22:25:41 +02:00
parent dc6d2fae59
commit 920dfb02bf
7 changed files with 50 additions and 11 deletions

View File

@ -13,7 +13,17 @@ getaggte Releases (Kanal `stable`, optional `beta`) — niemals Entwicklungs-Bui
_Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._ _Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._
## [0.9.43] - 2026-06-21 ## [0.9.44] - 2026-06-21
### Geändert
- **Peer-QR-Code kompakter.** Nach der Vergrößerung in 0.9.42 war der QR zu groß; er ist jetzt auf
ein ausgewogenes Maß (~240 px) reduziert — weiterhin pixelgenau (`crispEdges`) auf weißem Rahmen,
damit das Scannen am Handy zuverlässig bleibt.
- **Download-Dateiname als Tunnel-Name.** Die heruntergeladene Client-Config heißt jetzt
`<endpoint-host>-<peer>.conf` (z. B. `10.10.90.165-laptop.conf`) statt `clusev-wireguard.conf`.
WireGuard-Apps benennen einen Tunnel nach der Datei, sodass der Tunnel sinnvoll vorbenannt ist.
(Beim QR-Scan fragt die WireGuard-App selbst nach einem Namen — das gibt der QR-Standard nicht her;
ein Hinweis im Einmal-Dialog erklärt beide Wege.)
### Behoben ### Behoben
- **Endpoint ohne Port machte jede Client-Config ungültig.** Wurde beim Einrichten/Ändern nur ein - **Endpoint ohne Port machte jede Client-Config ungültig.** Wurde beim Einrichten/Ändern nur ein

View File

@ -59,6 +59,11 @@ class Index extends Component
public ?string $resultQr = null; public ?string $resultQr = null;
/** Peer name of the in-flight request, carried to the result so the download gets a meaningful filename. */
public ?string $pendingName = null;
public ?string $resultName = null;
public function setWindow(int $seconds): void public function setWindow(int $seconds): void
{ {
$this->window = $this->clampWindow($seconds); $this->window = $this->clampWindow($seconds);
@ -76,6 +81,7 @@ class Index extends Component
$name = $this->newPeer; $name = $this->newPeer;
$this->pendingId = $bridge->request('add-peer', ['name' => $name]); $this->pendingId = $bridge->request('add-peer', ['name' => $name]);
$this->pendingAction = 'add-peer'; $this->pendingAction = 'add-peer';
$this->pendingName = $name;
$this->newPeer = ''; $this->newPeer = '';
$this->audit('wg.add-peer', $name); $this->audit('wg.add-peer', $name);
} }
@ -106,6 +112,7 @@ class Index extends Component
'endpoint' => $this->setupEndpoint, 'name' => $this->setupPeer, 'endpoint' => $this->setupEndpoint, 'name' => $this->setupPeer,
]); ]);
$this->pendingAction = 'setup'; $this->pendingAction = 'setup';
$this->pendingName = $this->setupPeer;
$this->audit('wg.setup', $this->setupSubnet); $this->audit('wg.setup', $this->setupSubnet);
} }
@ -324,6 +331,7 @@ class Index extends Component
if ($res['ok'] && in_array($this->pendingAction, ['add-peer', 'setup'], true) && $res['config'] !== null) { if ($res['ok'] && in_array($this->pendingAction, ['add-peer', 'setup'], true) && $res['config'] !== null) {
$this->resultConfig = $res['config']; $this->resultConfig = $res['config'];
$this->resultQr = $res['qr']; $this->resultQr = $res['qr'];
$this->resultName = $this->pendingName;
} elseif (! $res['ok']) { } elseif (! $res['ok']) {
$msg = ($res['message'] ?? '') !== '' ? $res['message'] : __('wireguard.action_failed'); $msg = ($res['message'] ?? '') !== '' ? $res['message'] : __('wireguard.action_failed');
$this->dispatch('notify', message: $msg, level: 'error'); $this->dispatch('notify', message: $msg, level: 'error');
@ -331,15 +339,21 @@ class Index extends Component
$this->dispatch('notify', message: __('wireguard.action_done')); $this->dispatch('notify', message: __('wireguard.action_done'));
} }
$this->pendingAction = null; $this->pendingAction = null;
$this->pendingName = null;
} }
public function dismissResult(): void public function dismissResult(): void
{ {
$this->resultConfig = null; $this->resultConfig = null;
$this->resultQr = null; $this->resultQr = null;
$this->resultName = null;
} }
/** Download the show-once client config as a .conf file (for importing on a PC). */ /**
* Download the show-once client config as a .conf file. The filename becomes the tunnel name when
* imported (WireGuard apps name a tunnel after the file), so build it from the endpoint host + peer
* name e.g. "10.10.90.165-laptop.conf". (A QR scan can't carry a name; the app prompts for one.)
*/
public function downloadConfig(): ?StreamedResponse public function downloadConfig(): ?StreamedResponse
{ {
if ($this->resultConfig === null) { if ($this->resultConfig === null) {
@ -347,9 +361,14 @@ class Index extends Component
} }
$cfg = $this->resultConfig; $cfg = $this->resultConfig;
$host = preg_match('/^Endpoint\s*=\s*([^:\s]+)/m', $cfg, $m) === 1 ? $m[1] : 'clusev';
$parts = array_filter([$host, (string) $this->resultName]);
$base = preg_replace('/[^A-Za-z0-9._-]+/', '-', implode('-', $parts));
$base = trim((string) $base, '-.') ?: 'clusev-wireguard';
return response()->streamDownload(function () use ($cfg) { return response()->streamDownload(function () use ($cfg) {
echo $cfg; echo $cfg;
}, 'clusev-wireguard.conf', ['Content-Type' => 'text/plain; charset=UTF-8']); }, $base.'.conf', ['Content-Type' => 'text/plain; charset=UTF-8']);
} }
/** Clamp a traffic window to one of the allowed values, falling back to the shortest. */ /** Clamp a traffic window to one of the allowed values, falling back to the shortest. */

View File

@ -2,7 +2,7 @@
return [ return [
// First tagged release is v0.1.0 (semantic, not -dev). // First tagged release is v0.1.0 (semantic, not -dev).
'version' => '0.9.43', 'version' => '0.9.44',
// Deployed commit + branch. install.sh bakes these into .env from the host's .git (the prod // Deployed commit + branch. install.sh bakes these into .env from the host's .git (the prod
// image ships no .git); the Versions page prefers them and falls back to a live .git read in // image ships no .git); the Versions page prefers them and falls back to a live .git read in

View File

@ -45,7 +45,7 @@ return [
'new_peer_once' => 'Diese Konfiguration wird NUR EINMAL angezeigt und nicht gespeichert. Jetzt importieren (QR scannen oder Text kopieren).', 'new_peer_once' => 'Diese Konfiguration wird NUR EINMAL angezeigt und nicht gespeichert. Jetzt importieren (QR scannen oder Text kopieren).',
'close' => 'Schließen', 'close' => 'Schließen',
'download' => 'Herunterladen', 'download' => 'Herunterladen',
'import_hint' => 'QR-Code mit der WireGuard-App scannen (Handy) oder Konfiguration herunterladen und importieren (PC).', 'import_hint' => 'QR mit der WireGuard-App scannen (Handy; die App fragt nach einem Tunnel-Namen) oder herunterladen und importieren (PC) — der Dateiname wird zum Tunnel-Namen.',
'settings_title' => 'Einstellungen', 'settings_title' => 'Einstellungen',
'apply' => 'Anwenden', 'apply' => 'Anwenden',
'gate_toggle' => 'Gate', 'gate_toggle' => 'Gate',

View File

@ -45,7 +45,7 @@ return [
'new_peer_once' => 'This configuration is shown ONCE and never stored. Import it now (scan the QR or copy the text).', 'new_peer_once' => 'This configuration is shown ONCE and never stored. Import it now (scan the QR or copy the text).',
'close' => 'Close', 'close' => 'Close',
'download' => 'Download', 'download' => 'Download',
'import_hint' => 'Scan the QR code with the WireGuard app (phone) or download the configuration and import it (PC).', 'import_hint' => 'Scan the QR with the WireGuard app (phone; it asks for a tunnel name) or download and import it (PC) — the filename becomes the tunnel name.',
'settings_title' => 'Settings', 'settings_title' => 'Settings',
'apply' => 'Apply', 'apply' => 'Apply',
'gate_toggle' => 'Gate', 'gate_toggle' => 'Gate',

View File

@ -273,9 +273,9 @@
<p class="mt-1 text-sm text-warning">{{ __('wireguard.new_peer_once') }}</p> <p class="mt-1 text-sm text-warning">{{ __('wireguard.new_peer_once') }}</p>
<p class="mt-1 font-mono text-[11px] text-ink-4">{{ __('wireguard.import_hint') }}</p> <p class="mt-1 font-mono text-[11px] text-ink-4">{{ __('wireguard.import_hint') }}</p>
@if ($resultQr) @if ($resultQr)
{{-- White frame + generous size + crispEdges: a WireGuard config QR is dense (~57 {{-- White frame + crispEdges: a WireGuard config QR is dense (~57 modules); too
modules); too small or anti-aliased and a phone camera misreads it. --}} small or anti-aliased and a phone camera misreads it. --}}
<div class="mx-auto mt-4 w-full max-w-[20rem] rounded-lg bg-white p-3"> <div class="mx-auto mt-4 w-full max-w-[15rem] rounded-lg bg-white p-3">
<div class="aspect-square w-full [&>svg]:h-full [&>svg]:w-full [&>svg]:[shape-rendering:crispEdges]">{!! $resultQr !!}</div> <div class="aspect-square w-full [&>svg]:h-full [&>svg]:w-full [&>svg]:[shape-rendering:crispEdges]">{!! $resultQr !!}</div>
</div> </div>
@endif @endif

View File

@ -205,12 +205,22 @@ class WireguardPageTest extends TestCase
->assertHasErrors('setupSubnet'); ->assertHasErrors('setupSubnet');
} }
public function test_download_config_streams_a_conf_file(): void public function test_download_config_filename_uses_endpoint_host_and_peer_name(): void
{
// The .conf filename becomes the tunnel name on import — build it from endpoint host + peer.
Livewire::test(Index::class)
->set('resultConfig', "[Interface]\nPrivateKey = x\n\n[Peer]\nEndpoint = 10.10.90.165:51820")
->set('resultName', 'laptop')
->call('downloadConfig')
->assertFileDownloaded('10.10.90.165-laptop.conf');
}
public function test_download_config_falls_back_to_a_default_filename(): void
{ {
Livewire::test(Index::class) Livewire::test(Index::class)
->set('resultConfig', "[Interface]\nPrivateKey = x") ->set('resultConfig', "[Interface]\nPrivateKey = x")
->call('downloadConfig') ->call('downloadConfig')
->assertFileDownloaded('clusev-wireguard.conf'); ->assertFileDownloaded('clusev.conf');
} }
public function test_poll_result_times_out_when_the_host_does_not_respond(): void public function test_poll_result_times_out_when_the_host_does_not_respond(): void