Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 118 additions & 96 deletions app/Http/Controllers/DownloadController.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,36 @@

namespace App\Http\Controllers;

use App\Services\Blog\BlogService;
use App\Services\Content\SiteFacts;
use App\Services\Releases\LinuxRelease;
use App\Services\Releases\LinuxReleaseService;
use App\Services\Releases\MacReleaseService;
use App\Services\Releases\PlatformCatalog;
use App\Support\Content\ContentRepository;
use App\Support\Localization\Locales;
use App\Support\Seo\BlogPosts;
use App\Support\Seo\PageRegistry;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\File;
use Inertia\Inertia;
use Inertia\Response;

/**
* The download page, `/download` and `/vi/download` (sitemap §A.1, §E.5).
*
* Everything the page links to outside this site arrives here, as props: the
* DMG URL and size from the release service, the App Store and GitHub
* Releases URLs from `platforms.json`, and the docs, source and licence links
* from `facts.json`. No URL is typed into the page or its copy.
*
* The DMG link is in the server-rendered HTML; nothing starts a download on
* its own.
*/
// Every URL and fact the page shows arrives as a prop from the data files and the release services. Linux is sent only
// once platforms.json says it is released, the same way an unreleased iPhone app sends null.
class DownloadController extends Controller
{
public function __invoke(ContentRepository $content, MacReleaseService $releases, PlatformCatalog $platforms, BlogService $blog, PageRegistry $registry): Response
{
// Which install method a package format belongs to, so a file row can name its distribution.
private const PACKAGE_METHOD = ['deb' => 'ppa', 'rpm' => 'copr', 'flatpak' => 'flatpak'];

public function __invoke(
ContentRepository $content,
MacReleaseService $releases,
LinuxReleaseService $linuxReleases,
PlatformCatalog $platforms,
SiteFacts $facts,
): Response {
$locale = App::getLocale();
$release = $releases->latest();
$floor = $platforms->macFloorVersion();
$linux = $platforms->isReleased('linux') ? $this->linux($platforms, $linuxReleases->latest(), $locale) : null;

return Inertia::render('Download', [
'content' => $content->page('download', $locale),
Expand All @@ -41,126 +41,148 @@ public function __invoke(ContentRepository $content, MacReleaseService $releases
'homebrewCommand' => $this->string($platforms->destination('mac', 'homebrew')['command'] ?? null),
'homebrewTrails' => $floor !== null && $release->version !== null && version_compare($floor, $release->version, '<'),
],
'ios' => $this->ios($platforms),
'ios' => $this->ios($platforms, $facts, $locale),
'linux' => $linux,
'unreleased' => $platforms->unreleased(),
'links' => $this->links(),
'featuredEngines' => $this->featuredEngines(),
'latestPost' => $this->latestPost($blog, $registry, $locale),
'links' => $this->links($facts),
'featuredEngines' => $facts->featuredEngineNames(),
]);
}

/**
* The newest release post whose URL is a page, in the reader's language
* when it is written in it.
*
* @return array{slug: string, locale: string, kind: string, title: string, description: string, date: string, dateFormatted: string, url: string}|null
* @return array{deviceNames: list<string>, requirements: array{systems: list<string>, minVersion: string, displayVersion: string, releaseName: string|null}, appStoreUrl: string|null, free: bool, inAppPurchases: bool, version: string|null, publishedAtFormatted: string|null}|null
*/
private function latestPost(BlogService $blog, PageRegistry $registry, string $locale): ?array
{
foreach ($blog->all(Locales::default()) as $post) {
$post = $blog->find($post->slug, $locale) ?? $post;

if ($post->release !== null && ($registry->find(BlogPosts::ROUTE, ['slug' => $post->slug])?->renders($post->locale) ?? false)) {
return $post->summary($locale);
}
}

return null;
}

/**
* The iPhone and iPad card's facts, or null while that app is not released.
*
* @return array{deviceNames: list<string>, requirements: array{systems: list<string>, minVersion: string, displayVersion: string, releaseName: string|null}, appStoreUrl: string|null, free: bool, inAppPurchases: bool}|null
*/
private function ios(PlatformCatalog $platforms): ?array
private function ios(PlatformCatalog $platforms, SiteFacts $facts, string $locale): ?array
{
$summary = $platforms->summary('ios');

if ($summary === null) {
return null;
}

$price = $platforms->find('ios')['price'] ?? [];
$platform = $platforms->find('ios') ?? [];
$price = is_array($platform['price'] ?? null) ? $platform['price'] : [];
$release = is_array($platform['release'] ?? null) ? $platform['release'] : [];

return [
...$summary,
'appStoreUrl' => $this->url($platforms->destination('ios', 'app-store')['url'] ?? null) ?? $this->url($this->facts()['appStore'] ?? null),
'free' => is_array($price) && is_numeric($price['amount'] ?? null) && (float) $price['amount'] === 0.0,
'inAppPurchases' => is_array($price) && ($price['inAppPurchases'] ?? false) === true,
'appStoreUrl' => $this->url($platforms->destination('ios', 'app-store')['url'] ?? null) ?? $facts->links()['appStore'],
'free' => is_numeric($price['amount'] ?? null) && (float) $price['amount'] === 0.0,
'inAppPurchases' => ($price['inAppPurchases'] ?? false) === true,
'version' => $this->string($release['version'] ?? null),
'publishedAtFormatted' => $this->date($release['publishedAt'] ?? null, $locale),
];
}

/**
* The outbound links the page shows or states in its structured data, each
* null when the data does not name it.
*
* @return array{docs: string|null, changelog: string|null, source: string|null, license: string|null}
* @return array{deviceNames: list<string>, channel: string, distributions: list<array{id: string, name: string|null, versions: list<string>, via: string}>, desktop: array{name: string, version: string}|null, architectures: list<string>, methods: list<array{kind: string, commands: list<string>, update: string|null}>, packages: list<array{format: string, series: string|null, distribution: string|null, files: array<string, array{name: string, url: string, bytes: int|null}|null>}>, checksums: array{name: string, url: string|null}|null, verify: string|null, release: array{version: string, publishedOn: string|null, releaseUrl: string, releasesUrl: string}|null}
*/
private function links(): array
private function linux(PlatformCatalog $platforms, ?LinuxRelease $release, string $locale): array
{
$facts = $this->facts();
$docs = $this->url($facts['docs'] ?? null);
$platform = $platforms->find('linux') ?? [];
$packages = $platforms->linuxPackages();
$architectures = $this->strings($platform['architectures'] ?? []);
$desktop = $platform['desktop'] ?? null;

$distributions = [];

foreach (is_array($platform['distributions'] ?? null) ? $platform['distributions'] : [] as $distribution) {
if (is_array($distribution) && is_string($distribution['id'] ?? null) && is_string($distribution['via'] ?? null)) {
$distributions[] = [
'id' => $distribution['id'],
'name' => $this->string($distribution['name'] ?? null),
'versions' => $this->strings($distribution['versions'] ?? []),
'via' => $distribution['via'],
];
}
}

$methods = [];

foreach (is_array($platform['destinations'] ?? null) ? $platform['destinations'] : [] as $destination) {
$commands = $this->strings(is_array($destination) ? ($destination['commands'] ?? []) : []);

if ($commands !== [] && in_array($destination['kind'] ?? null, self::PACKAGE_METHOD, true)) {
$methods[] = ['kind' => $destination['kind'], 'commands' => $commands, 'update' => $this->string($destination['update'] ?? null)];
}
}

$byName = [];

foreach ($release?->assets ?? [] as $asset) {
$byName[$asset['name']] = ['name' => $asset['name'], 'url' => $asset['url'], 'bytes' => $asset['bytes']];
}

$rows = [];

foreach ($release !== null ? ($packages['assets'] ?? []) : [] as $asset) {
$via = self::PACKAGE_METHOD[$asset['format']] ?? null;
$named = collect($distributions)->first(fn(array $distribution): bool => $distribution['via'] === $via && $distribution['name'] !== null);

foreach ($asset['series'] === [] ? [null] : $asset['series'] as $series) {
$files = [];

foreach ($architectures as $architecture) {
$name = strtr($asset['template'], ['{version}' => $release->version, '{series}' => (string) $series, '{arch}' => $asset['arch'][$architecture] ?? $architecture]);
$files[$architecture] = $byName[$name] ?? null;
}

if (array_filter($files) !== []) {
$rows[] = ['format' => $asset['format'], 'series' => $series, 'distribution' => $named['name'] ?? null, 'files' => $files];
}
}
}

return [
'docs' => $docs,
'changelog' => $this->url($facts['changelog'] ?? null) ?? ($docs !== null ? rtrim($docs, '/') . '/changelog' : null),
'source' => $this->url($facts['github'] ?? null) ?? 'https://github.com/' . config('services.github.repo'),
'license' => $this->url($facts['license'] ?? null),
'deviceNames' => $this->strings($platform['deviceNames'] ?? []),
'channel' => $platforms->linuxChannel(),
'distributions' => $distributions,
'desktop' => is_array($desktop) && is_string($desktop['name'] ?? null) && is_string($desktop['version'] ?? null) ? ['name' => $desktop['name'], 'version' => $desktop['version']] : null,
'architectures' => $architectures,
'methods' => $methods,
'packages' => $rows,
'checksums' => $packages !== null && is_string($packages['checksums'] ?? null) ? ['name' => $packages['checksums'], 'url' => $release?->checksumsUrl] : null,
'verify' => $this->string($packages['verify'] ?? null),
'release' => $release === null ? null : [
'version' => $release->version,
'publishedOn' => $this->date($release->publishedAt, $locale),
'releaseUrl' => $release->releaseUrl,
'releasesUrl' => $release->releasesUrl,
],
];
}

/**
* `facts.json` → `links`, or nothing when the file is missing or malformed.
*
* @return array<string, mixed>
* @return array{docs: string|null, changelog: string|null, source: string|null, license: string|null, issues: string|null}
*/
private function facts(): array
private function links(SiteFacts $facts): array
{
$facts = $this->json('facts.json');
$links = $facts->links();

return is_array($facts['links'] ?? null) ? $facts['links'] : [];
return [
'docs' => $links['docs'],
'changelog' => $links['changelog'] ?? ($links['docs'] !== null ? rtrim($links['docs'], '/') . '/changelog' : null),
'source' => $links['github'] ?? 'https://github.com/' . config('services.github.repo'),
'license' => $links['license'],
'issues' => $links['issues'],
];
}

/**
* The names of the featured, published engines in data order, for the
* `{featuredEngines}` slot of the Mac app's description in the page's
* structured data (positioning §6.3). Read here rather than in the page,
* so the page's bundle does not carry all of `engines.json` for six names.
*
* @return list<string>
*/
private function featuredEngines(): array
private function date(mixed $date, string $locale): ?string
{
$names = [];

foreach ($this->json('engines.json') as $engine) {
if (is_array($engine) && ($engine['featured'] ?? false) === true && ($engine['state'] ?? null) === 'published' && is_string($engine['name'] ?? null)) {
$names[] = $engine['name'];
}
if (! is_string($date) || preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) !== 1) {
return null;
}

return $names;
return CarbonImmutable::createFromFormat('!Y-m-d', $date)?->locale($locale)->isoFormat('LL') ?: null;
}

/**
* A file in `resources/data`, decoded, or an empty array when it is missing
* or malformed: the page degrades instead of failing. Each file's `Data`
* test is what keeps it valid.
*
* @return array<array-key, mixed>
* @return list<string>
*/
private function json(string $file): array
private function strings(mixed $values): array
{
$path = resource_path('data/' . $file);

if (! File::isFile($path)) {
return [];
}

$decoded = json_decode((string) File::get($path), true);

return is_array($decoded) ? $decoded : [];
return is_array($values) ? array_values(array_filter($values, fn(mixed $value): bool => is_string($value) && $value !== '')) : [];
}

private function url(mixed $value): ?string
Expand Down
21 changes: 9 additions & 12 deletions docs/rebuild/design/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -1089,17 +1089,14 @@ release state. Its tests use `Http::fake`.

| Function | Rule |
|---|---|
| `classifyDevice(ua, maxTouchPoints)` | `ios` when `/iPhone\|iPad\|iPod/` matches, or when `Macintosh` matches and `maxTouchPoints > 1` (iPadOS desktop UA). `mac` when `Macintosh` matches. Otherwise `other`. **An iPhone UA ("like Mac OS X") is never `mac`**; the old page treated it as one |
| `archHint()` | Chromium `navigator.userAgentData.getHighEntropyValues(['architecture'])`: `arm` gives `arm64`, `x86` gives `x86_64`, anything else `null`. Safari and Firefox give `null`, and the page then never guesses |

**Behaviour.** SSR renders both Mac buttons with their real DMG URLs from the server props, plus the Homebrew card
(`brew install --cask tablepro`, with a note that Homebrew can lag a release; the cask version is not shown) and the
App Store card. After mount, an `ios` device swaps card order so the App Store card leads, and the Mac card says
"Download on your Mac". On a `mac` device with a hint, the matching button gets the primary variant (same
dimensions, so no layout shift) plus "Detected: Apple silicon". With no hint, both buttons stay equal under an "About
This Mac → Chip" help line. **Nothing starts on its own**, and no copy says a download is starting. After a click the
page may say "If the download didn't begin, use this link". `trackDownload(location, platform)` is unchanged. No
Windows or Linux buttons appear.
| `classifyDevice(ua, maxTouchPoints)` | `ios` when `/iPhone\|iPad\|iPod/` matches, or when `Macintosh` matches and `maxTouchPoints > 1` (iPadOS desktop UA). `mac` when `Macintosh` matches. `other` for Android and ChromeOS, `linux` for any other `Linux` or `X11`. Otherwise `other`. **An iPhone UA ("like Mac OS X") is never `mac`**. The root template's head script repeats the `ios` and `linux` rules as classes, before first paint (`tests/js/device.test.ts` runs it against this function) |

**Behaviour.** SSR renders one panel per released platform with real URLs from the server props (the DMG, the App
Store, the Linux commands and files) and shows the Mac panel. Before paint, the head script's `ios` or `linux` class
shows that platform's panel instead; after mount `classifyDevice()` keeps the same choice, or the one a `#mac`, `#ios`
or `#linux` link names (design-system §8.7). **Nothing starts on its own**, and no copy says a download is starting.
After a click the page may say "If the download didn't begin, use this link". `trackDownload(location, platform)` takes
`mac`, `ios` or `linux`. No Windows button appears.

### 1.14 Forms and cross-app contracts

Expand All @@ -1109,7 +1106,7 @@ Windows or Linux buttons appear.
| `POST /discount/preview {code}` | Unchanged. The invalid-coupon toast text comes from the catalog |
| `POST /newsletter/subscribe {email}` | Adds `locale`. Fallback strings in `use-email-form.ts:40-56` move to the `forms` namespace. 429 always uses the catalog, because the platform's 429 text is shared with the license API and stays English |
| `GET /api/newsletter/stats` | **The public site no longer calls it.** The new footer shows no subscriber count (sitemap §B.3, design-system §11.1, item 6), so `footer.tsx:122`'s lazy fetch goes. The endpoint stays as a platform contract. `Contracts/PublicContractsTest` asserts that no file in `resources/js` fetches it |
| GA events `download_click{location, platform}`, `checkout_started{tier, cycle}`, `newsletter_signup_clicked{source}` | Names and parameters unchanged. The new layout may add new `location` values; `platform` stays `mac\|ios` |
| GA events `download_click{location, platform}`, `checkout_started{tier, cycle}`, `newsletter_signup_clicked{source}` | Names and parameters unchanged. The new layout may add new `location` values; `platform` is `mac\|ios\|linux` |
| Storage keys `tablepro:analytics-consent`, `theme`, `tablepro:attribution` | Unchanged. `theme` now has writers (§1.10). `tablepro:banner-dismissed` is retired: the banner cannot be closed (2026-10-22) |
| Consent head order | Unchanged (pinned) |
| Inbound URLs `/?ref=…#pricing`, `/download`, `/account`, `/privacy` | Unchanged (§1.1) |
Expand Down
Loading