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
7 changes: 3 additions & 4 deletions app/Http/Controllers/DownloadController.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,13 @@
* 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 URLs and sizes from the release service, the App Store and GitHub
* 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, so the
* external-URL guard in `Data/FactsDataTest` holds by construction.
*
* Both DMG links are in the server-rendered HTML. The browser only ever
* highlights one of them (resources/js/lib/device.ts); nothing starts a
* download on its own.
* The DMG link is in the server-rendered HTML; nothing starts a download on
* its own.
*/
class DownloadController extends Controller
{
Expand Down
50 changes: 19 additions & 31 deletions app/Services/Releases/MacRelease.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,16 @@
* `source` says where: `github` (the releases API, with file sizes and
* SHA-256 digests), `appcast` (the Sparkle feed, with neither) or
* `unavailable` (neither answered and there is no earlier good copy). An
* unavailable release has no version, and both of its asset URLs are GitHub's
* "latest release" page, so a button still leads somewhere real while the page
* unavailable release has no version, and its asset URL is GitHub's "latest
* release" page, so the button still leads somewhere real while the page
* states no version it cannot stand behind.
*
* `toArray()` is the cached, locale-neutral form. `toProps()` adds the date
* formatted for the reader's locale, which is why the date is never cached
* pre-formatted.
*
* @phpstan-type Asset array{name: string|null, url: string, bytes: int|null, sha256: string|null}
* @phpstan-type Cached array{version: string|null, publishedAt: string|null, assets: array{arm64: Asset, x86_64: Asset}, releaseUrl: string, releasesUrl: string, source: string}
* @phpstan-type Cached array{version: string|null, publishedAt: string|null, asset: Asset, releaseUrl: string, releasesUrl: string, source: string}
*/
final readonly class MacRelease
{
Expand All @@ -32,71 +32,59 @@
/**
* @param string|null $version e.g. `0.77.0`, without the `v`
* @param string|null $publishedAt `Y-m-d`
* @param array{arm64: array{name: string|null, url: string, bytes: int|null, sha256: string|null}, x86_64: array{name: string|null, url: string, bytes: int|null, sha256: string|null}} $assets
* @param array{name: string|null, url: string, bytes: int|null, sha256: string|null} $asset the universal DMG
* @param string $releaseUrl this release's page (notes and every asset), or the latest-release page
* @param string $releasesUrl every release
*/
public function __construct(
public ?string $version,
public ?string $publishedAt,
public array $assets,
public array $asset,
public string $releaseUrl,
public string $releasesUrl,
public string $source,
) {}

/**
* No release data at all: both buttons go to GitHub's latest-release page.
* No release data at all: the button goes to GitHub's latest-release page.
*/
public static function unavailable(string $repo, string $releasesUrl): self
{
$latest = "https://github.com/{$repo}/releases/latest";
$asset = ['name' => null, 'url' => $latest, 'bytes' => null, 'sha256' => null];

return new self(
version: null,
publishedAt: null,
assets: ['arm64' => $asset, 'x86_64' => $asset],
asset: ['name' => null, 'url' => $latest, 'bytes' => null, 'sha256' => null],
releaseUrl: $latest,
releasesUrl: $releasesUrl,
source: self::SOURCE_UNAVAILABLE,
);
}

/**
* Rebuilds a cached copy, or returns null when the copy is not one.
* Rebuilds a cached copy, or returns null when the copy is not one. A copy
* stored before the universal DMG, with a DMG per architecture, is not one.
*
* @param array<string, mixed> $data
*/
public static function fromArray(array $data): ?self
{
$assets = $data['assets'] ?? null;
$asset = $data['asset'] ?? null;

if (! is_array($assets) || ! is_string($data['releaseUrl'] ?? null) || ! is_string($data['releasesUrl'] ?? null) || ! is_string($data['source'] ?? null)) {
if (! is_array($asset) || ! is_string($asset['url'] ?? null) || ! is_string($data['releaseUrl'] ?? null) || ! is_string($data['releasesUrl'] ?? null) || ! is_string($data['source'] ?? null)) {
return null;
}

$normalized = [];

foreach (PlatformCatalog::MAC_ARCHITECTURES as $arch) {
$asset = $assets[$arch] ?? null;

if (! is_array($asset) || ! is_string($asset['url'] ?? null)) {
return null;
}

$normalized[$arch] = [
return new self(
version: is_string($data['version'] ?? null) ? $data['version'] : null,
publishedAt: is_string($data['publishedAt'] ?? null) ? $data['publishedAt'] : null,
asset: [
'name' => is_string($asset['name'] ?? null) ? $asset['name'] : null,
'url' => $asset['url'],
'bytes' => is_int($asset['bytes'] ?? null) ? $asset['bytes'] : null,
'sha256' => is_string($asset['sha256'] ?? null) ? $asset['sha256'] : null,
];
}

return new self(
version: is_string($data['version'] ?? null) ? $data['version'] : null,
publishedAt: is_string($data['publishedAt'] ?? null) ? $data['publishedAt'] : null,
assets: $normalized,
],
releaseUrl: $data['releaseUrl'],
releasesUrl: $data['releasesUrl'],
source: $data['source'],
Expand All @@ -109,14 +97,14 @@ public function isAvailable(): bool
}

/**
* @return array{version: string|null, publishedAt: string|null, assets: array{arm64: array{name: string|null, url: string, bytes: int|null, sha256: string|null}, x86_64: array{name: string|null, url: string, bytes: int|null, sha256: string|null}}, releaseUrl: string, releasesUrl: string, source: string}
* @return array{version: string|null, publishedAt: string|null, asset: array{name: string|null, url: string, bytes: int|null, sha256: string|null}, releaseUrl: string, releasesUrl: string, source: string}
*/
public function toArray(): array
{
return [
'version' => $this->version,
'publishedAt' => $this->publishedAt,
'assets' => $this->assets,
'asset' => $this->asset,
'releaseUrl' => $this->releaseUrl,
'releasesUrl' => $this->releasesUrl,
'source' => $this->source,
Expand All @@ -132,7 +120,7 @@ public function toArray(): array
* (architecture §1.5). `LL` is "October 2, 2026" in English and
* "2 tháng 10 năm 2026" in Vietnamese.
*
* @return array{version: string|null, publishedAt: string|null, publishedAtFormatted: string|null, assets: array{arm64: array{name: string|null, url: string, bytes: int|null, sha256: string|null}, x86_64: array{name: string|null, url: string, bytes: int|null, sha256: string|null}}, releaseUrl: string, releasesUrl: string, source: string}
* @return array{version: string|null, publishedAt: string|null, publishedAtFormatted: string|null, asset: array{name: string|null, url: string, bytes: int|null, sha256: string|null}, releaseUrl: string, releasesUrl: string, source: string}
*/
public function toProps(string $locale): array
{
Expand Down
62 changes: 25 additions & 37 deletions app/Services/Releases/MacReleaseService.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,17 @@
/**
* The current Mac release, for the download buttons (architecture §1.13).
*
* Primary source: GitHub's `releases/latest` for the app repository. Plugin
* releases share that repository but are published with `--latest=false`
* (`build-plugin.yml:325`), so "latest" is the app; a release is still
* accepted only when it is neither a draft nor a prerelease and carries both
* DMGs, named for its own version. That rejects a plugin release that slipped
* through, and an app release caught halfway through its upload.
* Primary source: GitHub's `releases/latest` for the app repository. Older
* plugin releases share that repository but were published with
* `--latest=false`, so "latest" is the app; a release is still accepted only
* when it is neither a draft nor a prerelease and carries the DMG, named for
* its own version. That rejects a plugin release that slipped through, and an
* app release caught halfway through its upload.
*
* Fallback: the top item of the Sparkle appcast, with the DMG names and URLs
* built from `platforms.json` (`assetTemplate`, and the `dmg` destination's
* `urlTemplate`). Never the first item of the full releases list, which plugin
* releases crowd.
* Fallback: the current stable item of the Sparkle appcast, with the DMG name
* and URL built from `platforms.json` (`assetTemplate`, and the `dmg`
* destination's `urlTemplate`). Never the first item of the full releases
* list, which plugin releases crowd.
*
* **A request never waits for GitHub.** `release:refresh`, scheduled every 15
* minutes (routes/console.php), calls the sources; a page only reads what it
Expand All @@ -40,7 +40,7 @@
* good copy keeps being served.
* - When `releases:mac` is missing (after `cache:clear`, or if the scheduler
* has stopped), a request serves the last good copy, or `unavailable` if
* there never was one (both buttons open GitHub's latest-release page and no
* there never was one (the button opens GitHub's latest-release page and no
* version is shown), and asks for one refresh after its response has been
* sent (`defer()`): never while the failure marker stands, and under a lock,
* so concurrent requests start one refresh between them.
Expand Down Expand Up @@ -227,34 +227,28 @@ public function fromGitHub(): ?MacRelease
return null;
}

$assets = [];
$asset = $this->findAsset($release['assets'] ?? [], $this->platforms->macAssetName($version));

foreach (PlatformCatalog::MAC_ARCHITECTURES as $arch) {
$asset = $this->findAsset($release['assets'] ?? [], $this->platforms->macAssetName($arch, $version));

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

$assets[$arch] = $asset;
if ($asset === null) {
return null;
}

$htmlUrl = $release['html_url'] ?? null;

return new MacRelease(
version: $version,
publishedAt: $this->date($release['published_at'] ?? null),
assets: $assets,
asset: $asset,
releaseUrl: is_string($htmlUrl) && str_starts_with($htmlUrl, 'https://') ? $htmlUrl : $this->tagUrl($version),
releasesUrl: $this->releasesUrl(),
source: MacRelease::SOURCE_GITHUB,
);
}

/**
* The release from the Sparkle appcast's top item, or null. Sizes and
* checksums are not in the feed (its enclosures are the update zips), so
* they stay null.
* The release from the Sparkle appcast's current stable item, or null.
* Sizes and checksums are not in the feed (its enclosures are the update
* archives), so they stay null.
*/
public function fromAppcast(): ?MacRelease
{
Expand All @@ -265,24 +259,18 @@ public function fromAppcast(): ?MacRelease
}

$version = $item['version'];
$assets = [];

foreach (PlatformCatalog::MAC_ARCHITECTURES as $arch) {
$name = $this->platforms->macAssetName($arch, $version);
$name = $this->platforms->macAssetName($version);

$assets[$arch] = [
return new MacRelease(
version: $version,
publishedAt: $item['publishedAt'],
asset: [
'name' => $name,
'url' => $this->platforms->macAssetUrl($arch, $version)
'url' => $this->platforms->macAssetUrl($version)
?? 'https://github.com/' . $this->repo() . "/releases/download/v{$version}/" . rawurlencode($name),
'bytes' => null,
'sha256' => null,
];
}

return new MacRelease(
version: $version,
publishedAt: $item['publishedAt'],
assets: $assets,
],
releaseUrl: $this->tagUrl($version),
releasesUrl: $this->releasesUrl(),
source: MacRelease::SOURCE_APPCAST,
Expand Down
35 changes: 12 additions & 23 deletions app/Services/Releases/PlatformCatalog.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,25 +11,20 @@
* The file is edited by hand (architecture §1.8); this class only reads it,
* for the release service, `release:check` and the download page. It never
* types a fact of its own except the DMG naming pattern, which is a fallback
* for a file that names none: `TablePro-{version}-{arch}.dmg` has held from
* 0.73 to 0.77 and is enforced by the app's release job (`build.yml:254-270`).
* for a file that names none: since 0.82.0 the app's release workflow uploads
* one universal `TablePro-{version}.dmg`.
*
* A missing or unreadable file reads as empty rather than throwing, so a
* broken data file degrades the download page to its GitHub fallback instead
* of taking it down. `Data/PlatformsDataTest` is what keeps the file valid.
*
* @phpstan-type Requirements array{systems: list<string>, minVersion: string, displayVersion: string, releaseName: string|null}
* @phpstan-type Destination array{kind: string, url?: string, command?: string}
* @phpstan-type Platform array{id: string, status: string, deviceNames?: list<string>, requirements?: Requirements, architectures?: list<array{id: string, assetTemplate?: string}>, destinations?: list<Destination>, release?: array{version?: string, build?: string, publishedAt?: string}, floorVersion?: string, price?: array{amount?: int|float, inAppPurchases?: bool}, storefrontExclusions?: list<string>}
* @phpstan-type Platform array{id: string, status: string, deviceNames?: list<string>, requirements?: Requirements, architectures?: list<string>, universalBinary?: bool, assetTemplate?: string, destinations?: list<Destination>, release?: array{version?: string, build?: string, publishedAt?: string}, floorVersion?: string, price?: array{amount?: int|float, inAppPurchases?: bool}, storefrontExclusions?: list<string>}
*/
class PlatformCatalog
{
/**
* The two architectures the Mac app ships as separate builds, in display order.
*/
public const MAC_ARCHITECTURES = ['arm64', 'x86_64'];

private const DEFAULT_ASSET_TEMPLATE = 'TablePro-{version}-{arch}.dmg';
private const DEFAULT_ASSET_TEMPLATE = 'TablePro-{version}.dmg';

/**
* @var list<array<string, mixed>>|null
Expand Down Expand Up @@ -127,34 +122,28 @@ public function destination(string $id, string $kind): ?array
}

/**
* The DMG file name for one Mac architecture and version.
* The DMG file name for one Mac version.
*/
public function macAssetName(string $arch, string $version): string
public function macAssetName(string $version): string
{
$template = self::DEFAULT_ASSET_TEMPLATE;

foreach ($this->find('mac')['architectures'] ?? [] as $architecture) {
if (is_array($architecture) && ($architecture['id'] ?? null) === $arch && is_string($architecture['assetTemplate'] ?? null)) {
$template = $architecture['assetTemplate'];
}
}
$template = $this->find('mac')['assetTemplate'] ?? null;

return strtr($template, ['{version}' => $version, '{arch}' => $arch]);
return strtr(is_string($template) && $template !== '' ? $template : self::DEFAULT_ASSET_TEMPLATE, ['{version}' => $version]);
}

/**
* The DMG's download URL for one Mac architecture and version, from the
* `dmg` destination's `urlTemplate`, or null when the file names none.
* The DMG's download URL for one Mac version, from the `dmg` destination's
* `urlTemplate`, or null when the file names none.
*/
public function macAssetUrl(string $arch, string $version): ?string
public function macAssetUrl(string $version): ?string
{
$template = $this->destination('mac', 'dmg')['urlTemplate'] ?? null;

if (! is_string($template) || ! str_starts_with($template, 'https://')) {
return null;
}

return strtr($template, ['{version}' => $version, '{arch}' => $arch]);
return strtr($template, ['{version}' => $version]);
}

/**
Expand Down
Loading