From cf8662312e1e3d1a2cd042c5b86265c3636045aa Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 19 Oct 2026 15:40:31 +0700 Subject: [PATCH] feat(releases): one universal DMG from the updates.tablepro.app feed --- app/Http/Controllers/DownloadController.php | 7 +- app/Services/Releases/MacRelease.php | 50 +++--- app/Services/Releases/MacReleaseService.php | 62 +++----- app/Services/Releases/PlatformCatalog.php | 35 ++--- app/Services/Releases/SparkleAppcast.php | 71 +++++---- resources/data/content/de/download.json | 6 +- resources/data/content/de/faq.json | 2 +- resources/data/content/en/download.json | 6 +- resources/data/content/en/faq.json | 2 +- resources/data/content/es/download.json | 6 +- resources/data/content/es/faq.json | 2 +- resources/data/content/fr/download.json | 6 +- resources/data/content/fr/faq.json | 2 +- resources/data/content/id/download.json | 6 +- resources/data/content/id/faq.json | 2 +- resources/data/content/it/download.json | 6 +- resources/data/content/it/faq.json | 2 +- resources/data/content/ja/download.json | 4 +- resources/data/content/ja/faq.json | 2 +- resources/data/content/ko/download.json | 4 +- resources/data/content/ko/faq.json | 2 +- resources/data/content/pt-BR/download.json | 6 +- resources/data/content/pt-BR/faq.json | 2 +- resources/data/content/vi/download.json | 6 +- resources/data/content/vi/faq.json | 2 +- resources/data/content/zh-Hans/download.json | 4 +- resources/data/content/zh-Hans/faq.json | 2 +- resources/data/content/zh-Hant/download.json | 4 +- resources/data/content/zh-Hant/faq.json | 2 +- resources/data/platforms.json | 10 +- resources/js/components/compare/model.ts | 4 +- .../js/components/compare/tablepro-facts.ts | 3 +- .../js/components/download/action-pair.tsx | 2 +- resources/js/components/download/mac-card.tsx | 148 ++++++------------ resources/js/components/download/types.ts | 5 +- resources/js/components/home/availability.ts | 4 +- resources/js/i18n/messages/de/download.ts | 11 +- resources/js/i18n/messages/en/download.ts | 22 +-- resources/js/i18n/messages/es/download.ts | 11 +- resources/js/i18n/messages/fr/download.ts | 11 +- resources/js/i18n/messages/id/download.ts | 11 +- resources/js/i18n/messages/it/download.ts | 11 +- resources/js/i18n/messages/ja/download.ts | 11 +- resources/js/i18n/messages/ko/download.ts | 11 +- resources/js/i18n/messages/pt-BR/download.ts | 11 +- resources/js/i18n/messages/vi/download.ts | 12 +- .../js/i18n/messages/zh-Hans/download.ts | 11 +- .../js/i18n/messages/zh-Hant/download.ts | 11 +- resources/js/lib/data/platforms.ts | 19 +-- resources/js/lib/device.ts | 79 +--------- resources/js/pages/Download.tsx | 44 ++---- resources/js/types/globals.d.ts | 16 -- tests/Feature/Data/PlatformsDataTest.php | 15 +- tests/Feature/Releases/DownloadPageTest.php | 63 ++++---- .../Releases/MacReleaseServiceTest.php | 129 +++++++-------- tests/Feature/Releases/ReleaseFixtures.php | 59 ++++--- tests/js/compare-model.test.ts | 10 ++ tests/js/device.test.ts | 75 +-------- 58 files changed, 405 insertions(+), 737 deletions(-) diff --git a/app/Http/Controllers/DownloadController.php b/app/Http/Controllers/DownloadController.php index ff9fa860..d81073a5 100644 --- a/app/Http/Controllers/DownloadController.php +++ b/app/Http/Controllers/DownloadController.php @@ -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 { diff --git a/app/Services/Releases/MacRelease.php b/app/Services/Releases/MacRelease.php index 027aac15..4bd0eaac 100644 --- a/app/Services/Releases/MacRelease.php +++ b/app/Services/Releases/MacRelease.php @@ -10,8 +10,8 @@ * `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 @@ -19,7 +19,7 @@ * 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 { @@ -32,31 +32,30 @@ /** * @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, @@ -64,39 +63,28 @@ public static function unavailable(string $repo, string $releasesUrl): self } /** - * 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 $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'], @@ -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, @@ -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 { diff --git a/app/Services/Releases/MacReleaseService.php b/app/Services/Releases/MacReleaseService.php index b5555137..db4f9090 100644 --- a/app/Services/Releases/MacReleaseService.php +++ b/app/Services/Releases/MacReleaseService.php @@ -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 @@ -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. @@ -227,16 +227,10 @@ 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; @@ -244,7 +238,7 @@ public function fromGitHub(): ?MacRelease 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, @@ -252,9 +246,9 @@ public function fromGitHub(): ?MacRelease } /** - * 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 { @@ -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, diff --git a/app/Services/Releases/PlatformCatalog.php b/app/Services/Releases/PlatformCatalog.php index 12cafdfe..cfe3647c 100644 --- a/app/Services/Releases/PlatformCatalog.php +++ b/app/Services/Releases/PlatformCatalog.php @@ -11,8 +11,8 @@ * 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 @@ -20,16 +20,11 @@ * * @phpstan-type Requirements array{systems: list, 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, requirements?: Requirements, architectures?: list, destinations?: list, release?: array{version?: string, build?: string, publishedAt?: string}, floorVersion?: string, price?: array{amount?: int|float, inAppPurchases?: bool}, storefrontExclusions?: list} + * @phpstan-type Platform array{id: string, status: string, deviceNames?: list, requirements?: Requirements, architectures?: list, universalBinary?: bool, assetTemplate?: string, destinations?: list, release?: array{version?: string, build?: string, publishedAt?: string}, floorVersion?: string, price?: array{amount?: int|float, inAppPurchases?: bool}, storefrontExclusions?: list} */ 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>|null @@ -127,26 +122,20 @@ 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; @@ -154,7 +143,7 @@ public function macAssetUrl(string $arch, string $version): ?string return null; } - return strtr($template, ['{version}' => $version, '{arch}' => $arch]); + return strtr($template, ['{version}' => $version]); } /** diff --git a/app/Services/Releases/SparkleAppcast.php b/app/Services/Releases/SparkleAppcast.php index 64d63d47..d95b86cd 100644 --- a/app/Services/Releases/SparkleAppcast.php +++ b/app/Services/Releases/SparkleAppcast.php @@ -7,31 +7,34 @@ use SimpleXMLElement; /** - * The Sparkle feed the installed Mac app updates from. + * The Sparkle feed the installed Mac app updates from, at `updates.tablepro.app`. * - * Read as the fallback when the GitHub releases API fails. It is served from - * `raw.githubusercontent.com`, which is not subject to the API's - * unauthenticated limit of 60 calls an hour, and the release job writes it only - * after the release's assets are uploaded (`build.yml:330-405`), so a version - * here always has its DMGs. + * Read as the fallback when the GitHub releases API fails. It is a GitHub + * Pages site, which is not subject to the API's unauthenticated limit of 60 + * calls an hour, and the app's release workflow writes an item only after the + * release's assets are uploaded, so a version here always has its DMG. * - * Only the top item is read. Items come in per-architecture pairs for the same - * version, newest first; the feed carries the update zips, never the DMGs, so - * the DMG names come from `platforms.json` rather than from the enclosure. + * Stable and beta share the feed; a beta item carries `sparkle:channel`. The + * current release is the item with no channel and the highest + * `sparkle:version` (the build number), wherever it sits in the feed. The + * enclosures are the update archives, never the DMG, so the DMG name comes + * from `platforms.json` rather than from the feed. */ class SparkleAppcast { + public const FEED_URL = 'https://updates.tablepro.app/appcast.xml'; + private const SPARKLE_NAMESPACE = 'http://www.andymatuschak.org/xml-namespaces/sparkle'; private const TIMEOUT_SECONDS = 5; public function feedUrl(): string { - return 'https://raw.githubusercontent.com/' . config('services.github.repo') . '/main/appcast.xml'; + return self::FEED_URL; } /** - * The newest item of the live feed, or null when it cannot be read. + * The current stable item of the live feed, or null when it cannot be read. * * @return array{version: string, publishedAt: string|null, minimumSystemVersion: string|null}|null */ @@ -51,7 +54,8 @@ public function latest(): ?array } /** - * The top item of a feed document, or null when it holds no usable item. + * The stable item with the highest build in a feed document, or null when + * it holds none. * * @return array{version: string, publishedAt: string|null, minimumSystemVersion: string|null}|null */ @@ -70,30 +74,41 @@ public static function parse(string $xml): ?array libxml_use_internal_errors($previous); } - if ($document === false || ! isset($document->channel->item[0])) { + if ($document === false || ! isset($document->channel->item)) { return null; } - $item = $document->channel->item[0]; - $sparkle = $item->children(self::SPARKLE_NAMESPACE); + $latest = null; + $latestBuild = -1; - $version = trim((string) $sparkle->shortVersionString); + foreach ($document->channel->item as $item) { + $sparkle = $item->children(self::SPARKLE_NAMESPACE); - if ($version === '') { - $version = trim((string) $item->title); - } + if (trim((string) $sparkle->channel) !== '') { + continue; + } - if (preg_match('/^\d+(\.\d+)*$/', $version) !== 1) { - return null; - } + $build = trim((string) $sparkle->version); + $version = trim((string) $sparkle->shortVersionString); - $minimum = trim((string) $sparkle->minimumSystemVersion); + if ($version === '') { + $version = trim((string) $item->title); + } + + if (preg_match('/^\d+$/', $build) !== 1 || preg_match('/^\d+(\.\d+)*$/', $version) !== 1 || (int) $build <= $latestBuild) { + continue; + } + + $minimum = trim((string) $sparkle->minimumSystemVersion); + $latestBuild = (int) $build; + $latest = [ + 'version' => $version, + 'publishedAt' => self::date(trim((string) $item->pubDate)), + 'minimumSystemVersion' => $minimum !== '' ? $minimum : null, + ]; + } - return [ - 'version' => $version, - 'publishedAt' => self::date(trim((string) $item->pubDate)), - 'minimumSystemVersion' => $minimum !== '' ? $minimum : null, - ]; + return $latest; } /** diff --git a/resources/data/content/de/download.json b/resources/data/content/de/download.json index e27fb4c9..dfbf1330 100644 --- a/resources/data/content/de/download.json +++ b/resources/data/content/de/download.json @@ -1,7 +1,7 @@ { "seo": { "title": "TablePro herunterladen", - "description": "Lade TablePro mit separaten Builds für Apple silicon und Intel herunter oder installiere es mit Homebrew. Die App für iPhone und iPad gibt es im App Store." + "description": "Lade TablePro für Macs mit Apple silicon und Intel herunter oder installiere es mit Homebrew. Die App für iPhone und iPad gibt es im App Store." }, "og": { "kicker": "Download", @@ -33,7 +33,7 @@ "Öffne TablePro aus Programme. Wähle Open, wenn macOS um Bestätigung bittet.", "Füge eine Verbindung hinzu oder teste die enthaltene Chinook-SQLite-Datenbank über Help > Open Sample Database." ], - "signing": "Beide Versionen sind mit einer Developer ID signiert und von Apple notarisiert. Du musst daher keine Sicherheitseinstellung ändern, um TablePro zu öffnen.", + "signing": "TablePro ist mit einer Developer ID signiert und von Apple notarisiert. Du musst daher keine Sicherheitseinstellung ändern, um TablePro zu öffnen.", "drivers": "Gängige Treiber sind enthalten. Andere benötigen Internet für den ersten Download und werden unabhängig von der App aktualisiert.", "databases": "Unterstützte Datenbanken", "guide": { @@ -56,7 +56,7 @@ }, "olderVersions": { "title": "Ältere Versionen und Versionshinweise", - "body": "Jede Version mit ihren Hinweisen und beiden Disk-Images findest du auf GitHub Releases. Das Änderungsprotokoll listet die Änderungen jeder Version auf.", + "body": "Jede Version mit ihren Hinweisen und Disk-Images findest du auf GitHub Releases. Das Änderungsprotokoll listet die Änderungen jeder Version auf.", "releases": "Alle Versionen auf GitHub", "changelog": "Änderungsprotokoll (Englisch)" } diff --git a/resources/data/content/de/faq.json b/resources/data/content/de/faq.json index 417dacd5..d5fbd8b3 100644 --- a/resources/data/content/de/faq.json +++ b/resources/data/content/de/faq.json @@ -74,7 +74,7 @@ "id": "mac-requirements", "question": "Auf welchen Macs läuft TablePro?", "answer": [ - "Die Mac-App benötigt {macRequirement}. Wähle Apple silicon oder Intel auf der Downloadseite oder lass Homebrew den Build auswählen." + "Die Mac-App benötigt {macRequirement} und läuft nativ auf Apple silicon und Intel. Lade sie von der Downloadseite oder installiere sie mit Homebrew." ] }, { diff --git a/resources/data/content/en/download.json b/resources/data/content/en/download.json index 1346e9c2..e23f265c 100644 --- a/resources/data/content/en/download.json +++ b/resources/data/content/en/download.json @@ -1,7 +1,7 @@ { "seo": { "title": "Download TablePro", - "description": "Download TablePro, with separate builds for Apple silicon and Intel, or install it with Homebrew. The iPhone and iPad app is on the App Store." + "description": "Download TablePro for Apple silicon and Intel Macs, or install it with Homebrew. The iPhone and iPad app is on the App Store." }, "og": { "kicker": "Download", @@ -33,7 +33,7 @@ "Open TablePro from Applications. Choose Open when macOS asks you to confirm.", "Add a connection, or try the bundled Chinook SQLite database through Help > Open Sample Database." ], - "signing": "Both builds are signed with a Developer ID and notarized by Apple, so you don’t need to change any security setting to open TablePro.", + "signing": "TablePro is signed with a Developer ID and notarized by Apple, so you don’t need to change any security setting to open it.", "drivers": "Common drivers are bundled. Others need an internet connection to download on first use and update separately from the app.", "databases": "Supported databases", "guide": { @@ -56,7 +56,7 @@ }, "olderVersions": { "title": "Older versions and release notes", - "body": "Every release, with its notes and both disk images, is on GitHub Releases. The changelog lists what changed in each version.", + "body": "Every release, with its notes and disk images, is on GitHub Releases. The changelog lists what changed in each version.", "releases": "All releases on GitHub", "changelog": "Changelog" } diff --git a/resources/data/content/en/faq.json b/resources/data/content/en/faq.json index fb2bfdff..0dc2817a 100644 --- a/resources/data/content/en/faq.json +++ b/resources/data/content/en/faq.json @@ -74,7 +74,7 @@ "id": "mac-requirements", "question": "Which Macs does TablePro run on?", "answer": [ - "The Mac app requires {macRequirement}. Choose Apple silicon or Intel on the download page, or let Homebrew select the build." + "The Mac app requires {macRequirement} and runs natively on Apple silicon and Intel. Get it from the download page or with Homebrew." ] }, { diff --git a/resources/data/content/es/download.json b/resources/data/content/es/download.json index f2dbb341..249d6c44 100644 --- a/resources/data/content/es/download.json +++ b/resources/data/content/es/download.json @@ -1,7 +1,7 @@ { "seo": { "title": "Descargar TablePro", - "description": "Descarga TablePro, con versiones separadas para Apple silicon e Intel, o instálalo con Homebrew. La app para iPhone y iPad está en la App Store." + "description": "Descarga TablePro para Mac con Apple silicon e Intel, o instálalo con Homebrew. La app para iPhone y iPad está en la App Store." }, "og": { "kicker": "Descargar", @@ -33,7 +33,7 @@ "Abre TablePro desde Aplicaciones. Elige Open cuando macOS pida confirmación.", "Añade una conexión o prueba la base SQLite Chinook incluida desde Help > Open Sample Database." ], - "signing": "Ambas versiones están firmadas con un Developer ID y notarizadas por Apple, así que no necesitas cambiar ningún ajuste de seguridad para abrir TablePro.", + "signing": "TablePro está firmado con un Developer ID y notarizado por Apple, así que no necesitas cambiar ningún ajuste de seguridad para abrirlo.", "drivers": "Los controladores habituales vienen incluidos. Los demás necesitan internet para la primera descarga y se actualizan por separado de la app.", "databases": "Bases de datos compatibles", "guide": { @@ -56,7 +56,7 @@ }, "olderVersions": { "title": "Versiones anteriores y notas de versión", - "body": "Todas las versiones, con sus notas y ambas imágenes de disco, están en GitHub Releases. El historial de cambios enumera las novedades de cada versión.", + "body": "Todas las versiones, con sus notas e imágenes de disco, están en GitHub Releases. El historial de cambios enumera las novedades de cada versión.", "releases": "Todas las versiones en GitHub", "changelog": "Historial de cambios (inglés)" } diff --git a/resources/data/content/es/faq.json b/resources/data/content/es/faq.json index ac42b40f..5d962ad9 100644 --- a/resources/data/content/es/faq.json +++ b/resources/data/content/es/faq.json @@ -74,7 +74,7 @@ "id": "mac-requirements", "question": "¿En qué Mac funciona TablePro?", "answer": [ - "La app para Mac necesita {macRequirement}. Elige Apple silicon o Intel en la página de descarga, o deja que Homebrew elija la versión." + "La app para Mac necesita {macRequirement} y funciona de forma nativa en Apple silicon e Intel. Descárgala desde la página de descarga o instálala con Homebrew." ] }, { diff --git a/resources/data/content/fr/download.json b/resources/data/content/fr/download.json index 0b7b4f41..d9cf9cfa 100644 --- a/resources/data/content/fr/download.json +++ b/resources/data/content/fr/download.json @@ -1,7 +1,7 @@ { "seo": { "title": "Télécharger TablePro", - "description": "Téléchargez TablePro en version Apple silicon ou Intel, ou installez-le avec Homebrew. L’app iPhone et iPad est sur l’App Store." + "description": "Téléchargez TablePro pour les Mac Apple silicon et Intel, ou installez-le avec Homebrew. L’app iPhone et iPad est sur l’App Store." }, "og": { "kicker": "Télécharger", @@ -33,7 +33,7 @@ "Ouvrez TablePro depuis Applications. Choisissez Ouvrir (Open) lorsque macOS demande confirmation.", "Ajoutez une connexion ou essayez la base SQLite Chinook incluse via Aide > Ouvrir la base de données d’exemple (Help > Open Sample Database)." ], - "signing": "Les deux versions sont signées avec un Developer ID et notariées par Apple. Vous n’avez donc aucun réglage de sécurité à modifier pour ouvrir TablePro.", + "signing": "TablePro est signé avec un Developer ID et notarié par Apple. Vous n’avez donc aucun réglage de sécurité à modifier pour l’ouvrir.", "drivers": "Les pilotes courants sont inclus. Les autres nécessitent internet pour le premier téléchargement et se mettent à jour séparément de l’app.", "databases": "Bases de données prises en charge", "guide": { @@ -56,7 +56,7 @@ }, "olderVersions": { "title": "Anciennes versions et notes de version", - "body": "Chaque version, avec ses notes et ses deux images disque, figure sur GitHub Releases. Le journal des modifications liste les changements de chaque version.", + "body": "Chaque version, avec ses notes et ses images disque, figure sur GitHub Releases. Le journal des modifications liste les changements de chaque version.", "releases": "Toutes les versions sur GitHub", "changelog": "Journal des modifications (anglais)" } diff --git a/resources/data/content/fr/faq.json b/resources/data/content/fr/faq.json index 4f239506..d936a66c 100644 --- a/resources/data/content/fr/faq.json +++ b/resources/data/content/fr/faq.json @@ -74,7 +74,7 @@ "id": "mac-requirements", "question": "Sur quels Mac TablePro fonctionne-t-il ?", "answer": [ - "L’app Mac nécessite {macRequirement}. Choisissez Apple silicon ou Intel sur la page de téléchargement, ou laissez Homebrew sélectionner la version." + "L’app Mac nécessite {macRequirement} et fonctionne nativement sur Apple silicon et Intel. Téléchargez-la sur la page de téléchargement ou installez-la avec Homebrew." ] }, { diff --git a/resources/data/content/id/download.json b/resources/data/content/id/download.json index f0c37444..d6996966 100644 --- a/resources/data/content/id/download.json +++ b/resources/data/content/id/download.json @@ -1,7 +1,7 @@ { "seo": { "title": "Unduh TablePro", - "description": "Unduh TablePro, dengan versi terpisah untuk Apple silicon dan Intel, atau instal dengan Homebrew. Aplikasi iPhone dan iPad ada di App Store." + "description": "Unduh TablePro untuk Mac Apple silicon dan Intel, atau instal dengan Homebrew. Aplikasi iPhone dan iPad ada di App Store." }, "og": { "kicker": "Unduh", @@ -33,7 +33,7 @@ "Buka TablePro dari Aplikasi. Pilih Open ketika macOS meminta Anda untuk mengonfirmasi.", "Tambahkan koneksi, atau coba paket database Chinook SQLite melalui Help > Open Sample Database." ], - "signing": "Kedua build ditandatangani dengan Developer ID dan dinotarisasi Apple, sehingga Anda tidak perlu mengubah pengaturan keamanan untuk membuka TablePro.", + "signing": "TablePro ditandatangani dengan Developer ID dan dinotarisasi Apple, sehingga Anda tidak perlu mengubah pengaturan keamanan untuk membukanya.", "drivers": "Driver umum dibundel. Yang lain memerlukan koneksi internet untuk mengunduh pada penggunaan pertama dan memperbarui secara terpisah dari aplikasi.", "databases": "Database yang didukung", "guide": { @@ -56,7 +56,7 @@ }, "olderVersions": { "title": "Versi lama dan catatan rilis", - "body": "Setiap rilis beserta catatan dan kedua disk image tersedia di GitHub Releases. Catatan perubahan mencantumkan perubahan dalam setiap versi.", + "body": "Setiap rilis beserta catatan dan disk image-nya tersedia di GitHub Releases. Catatan perubahan mencantumkan perubahan dalam setiap versi.", "releases": "Semua rilis di GitHub", "changelog": "Catatan perubahan (bahasa Inggris)" } diff --git a/resources/data/content/id/faq.json b/resources/data/content/id/faq.json index 6c02b960..7f8d8518 100644 --- a/resources/data/content/id/faq.json +++ b/resources/data/content/id/faq.json @@ -74,7 +74,7 @@ "id": "mac-requirements", "question": "Mac apa yang dapat menjalankan TablePro?", "answer": [ - "App Mac memerlukan {macRequirement}. Pilih Apple silicon atau Intel di halaman unduhan, atau biarkan Homebrew memilih build." + "App Mac memerlukan {macRequirement} dan berjalan native di Apple silicon dan Intel. Unduh dari halaman unduhan atau instal dengan Homebrew." ] }, { diff --git a/resources/data/content/it/download.json b/resources/data/content/it/download.json index 5f4b4207..72ef8042 100644 --- a/resources/data/content/it/download.json +++ b/resources/data/content/it/download.json @@ -1,7 +1,7 @@ { "seo": { "title": "Scarica TablePro", - "description": "Scarica TablePro, con build separate per Apple silicon e Intel, oppure installalo con Homebrew. L'app per iPhone e iPad si trova sullo App Store." + "description": "Scarica TablePro per Mac con Apple silicon e Intel, oppure installalo con Homebrew. L'app per iPhone e iPad si trova sullo App Store." }, "og": { "kicker": "Download", @@ -33,7 +33,7 @@ "Apri TablePro da Applicazioni. Scegli Open quando macOS ti chiede di confermare.", "Aggiungi una connessione o prova il database Chinook SQLite in bundle tramite Help > Open Sample Database." ], - "signing": "Entrambe le build sono firmate con un Developer ID e autenticate da Apple, quindi non devi modificare alcuna imposto di sicurezza per aprire TablePro.", + "signing": "TablePro è firmato con un Developer ID e autenticato da Apple, quindi non devi modificare alcuna impostazione di sicurezza per aprirlo.", "drivers": "I driver comuni sono raggruppati. Altri necessitano di una connessione Internet per scaricare al primo utilizzo e aggiornare separatamente dall'app.", "databases": "Database supportati", "guide": { @@ -56,7 +56,7 @@ }, "olderVersions": { "title": "Versioni precedenti e note di rilascio", - "body": "Ogni rilascio, con le note e le due immagini disco, si trova su GitHub Releases. Il registro delle modifiche elenca le novità di ogni versione.", + "body": "Ogni rilascio, con le note e le immagini disco, si trova su GitHub Releases. Il registro delle modifiche elenca le novità di ogni versione.", "releases": "Tutti i rilasci su GitHub", "changelog": "Registro delle modifiche (inglese)" } diff --git a/resources/data/content/it/faq.json b/resources/data/content/it/faq.json index bc0383d3..e955bc3d 100644 --- a/resources/data/content/it/faq.json +++ b/resources/data/content/it/faq.json @@ -74,7 +74,7 @@ "id": "mac-requirements", "question": "Su quali Mac funziona TablePro?", "answer": [ - "L’app Mac richiede {macRequirement}. Scegli Apple silicon o Intel nella pagina di download, oppure lascia scegliere la build a Homebrew." + "L’app Mac richiede {macRequirement} e funziona nativamente su Apple silicon e Intel. Scaricala dalla pagina di download oppure installala con Homebrew." ] }, { diff --git a/resources/data/content/ja/download.json b/resources/data/content/ja/download.json index d90fd772..2127901d 100644 --- a/resources/data/content/ja/download.json +++ b/resources/data/content/ja/download.json @@ -33,7 +33,7 @@ "アプリケーションから TablePro を開きます。macOS の確認が表示されたら Open を選びます。", "接続を追加するか、Help > Open Sample Database から同梱の Chinook SQLite データベースを試せます。" ], - "signing": "どちらのビルドも Developer ID で署名され、Apple の公証を受けています。TablePro を開くためにセキュリティ設定を変更する必要はありません。", + "signing": "TablePro は Developer ID で署名され、Apple の公証を受けています。開くためにセキュリティ設定を変更する必要はありません。", "drivers": "一般的なドライバーは同梱されています。その他は初回にインターネットからダウンロードし、アプリとは別に更新されます。", "databases": "対応データベース", "guide": { @@ -56,7 +56,7 @@ }, "olderVersions": { "title": "以前のバージョンとリリースノート", - "body": "すべてのリリースのノートと両方のディスクイメージは GitHub Releases にあります。各バージョンの変更内容は変更履歴で確認できます。", + "body": "すべてのリリースのノートとディスクイメージは GitHub Releases にあります。各バージョンの変更内容は変更履歴で確認できます。", "releases": "GitHub の全リリース", "changelog": "変更履歴(英語)" } diff --git a/resources/data/content/ja/faq.json b/resources/data/content/ja/faq.json index 411fb302..6a966639 100644 --- a/resources/data/content/ja/faq.json +++ b/resources/data/content/ja/faq.json @@ -74,7 +74,7 @@ "id": "mac-requirements", "question": "どの Mac で TablePro が動きますか?", "answer": [ - "Mac アプリには {macRequirement} が必要です。ダウンロードページで Apple silicon・Intel を選ぶか、Homebrew に選択を任せられます。" + "Mac アプリには {macRequirement} が必要で、Apple silicon と Intel でネイティブ動作します。ダウンロードページから入手するか、Homebrew でインストールできます。" ] }, { diff --git a/resources/data/content/ko/download.json b/resources/data/content/ko/download.json index 2819adb8..500089da 100644 --- a/resources/data/content/ko/download.json +++ b/resources/data/content/ko/download.json @@ -33,7 +33,7 @@ "응용 프로그램에서 TablePro를 여세요. macOS가 확인을 요청하면 열기 (Open)을 선택하세요.", "연결을 추가하거나 도움말 > 샘플 데이터베이스 열기 (Help > Open Sample Database)에서 내장된 Chinook SQLite 데이터베이스를 사용해 보세요." ], - "signing": "두 빌드 모두 Developer ID로 서명되었으며 Apple의 공증을 받았습니다. TablePro를 열기 위해 보안 설정을 변경할 필요가 없습니다.", + "signing": "TablePro는 Developer ID로 서명되었으며 Apple의 공증을 받았습니다. 열기 위해 보안 설정을 변경할 필요가 없습니다.", "drivers": "일반적인 드라이버는 앱에 포함됩니다. 그 밖의 드라이버는 처음 사용할 때 인터넷으로 다운로드되며 앱과 별도로 업데이트됩니다.", "databases": "지원 데이터베이스", "guide": { @@ -56,7 +56,7 @@ }, "olderVersions": { "title": "이전 버전 및 릴리스 노트", - "body": "모든 릴리스의 노트와 두 디스크 이미지는 GitHub Releases에 있습니다. 변경 기록에는 각 버전의 변경 사항이 나와 있습니다.", + "body": "모든 릴리스의 노트와 디스크 이미지는 GitHub Releases에 있습니다. 변경 기록에는 각 버전의 변경 사항이 나와 있습니다.", "releases": "GitHub의 모든 릴리스", "changelog": "변경 기록 (영어)" } diff --git a/resources/data/content/ko/faq.json b/resources/data/content/ko/faq.json index f6c01a1e..577ff9b5 100644 --- a/resources/data/content/ko/faq.json +++ b/resources/data/content/ko/faq.json @@ -74,7 +74,7 @@ "id": "mac-requirements", "question": "어떤 Mac에서 TablePro를 사용할 수 있나요?", "answer": [ - "Mac 앱에는 {macRequirement}이 필요합니다. 다운로드 페이지에서 Apple silicon 또는 Intel을 선택하거나 Homebrew로 빌드를 선택하세요." + "Mac 앱에는 {macRequirement}이 필요하며 Apple silicon과 Intel에서 네이티브로 실행됩니다. 다운로드 페이지에서 받거나 Homebrew로 설치하세요." ] }, { diff --git a/resources/data/content/pt-BR/download.json b/resources/data/content/pt-BR/download.json index a208ad11..d865bf29 100644 --- a/resources/data/content/pt-BR/download.json +++ b/resources/data/content/pt-BR/download.json @@ -1,7 +1,7 @@ { "seo": { "title": "Baixar TablePro", - "description": "Baixe TablePro, com compilações separadas para Apple silicon e Intel, ou instale-o com Homebrew. O aplicativo para iPhone e iPad está no App Store." + "description": "Baixe TablePro para Mac com Apple silicon e Intel, ou instale-o com Homebrew. O aplicativo para iPhone e iPad está no App Store." }, "og": { "kicker": "Download", @@ -33,7 +33,7 @@ "Abra TablePro em Aplicativos. Escolha Open quando macOS solicitar sua confirmação.", "Adicione uma conexão ou experimente o banco de dados Chinook SQLite incluído por meio de Help > Open Sample Database." ], - "signing": "As duas versões são assinadas com Developer ID e autenticadas pela Apple, portanto você não precisa alterar configurações de segurança para abrir TablePro.", + "signing": "TablePro é assinado com Developer ID e autenticado pela Apple, portanto você não precisa alterar configurações de segurança para abri-lo.", "drivers": "Drivers comuns estão incluídos. Outros precisam de uma conexão com a Internet para fazer o download no primeiro uso e atualizar separadamente do aplicativo.", "databases": "Bancos de dados compatíveis", "guide": { @@ -56,7 +56,7 @@ }, "olderVersions": { "title": "Versões anteriores e notas de versão", - "body": "Todas as versões, com suas notas e as duas imagens de disco, estão no GitHub Releases. O histórico de alterações lista o que mudou em cada versão.", + "body": "Todas as versões, com suas notas e imagens de disco, estão no GitHub Releases. O histórico de alterações lista o que mudou em cada versão.", "releases": "Todas as versões no GitHub", "changelog": "Histórico de alterações (inglês)" } diff --git a/resources/data/content/pt-BR/faq.json b/resources/data/content/pt-BR/faq.json index 139bf13d..0efe1664 100644 --- a/resources/data/content/pt-BR/faq.json +++ b/resources/data/content/pt-BR/faq.json @@ -74,7 +74,7 @@ "id": "mac-requirements", "question": "Em quais Macs TablePro funciona?", "answer": [ - "O app para Mac exige {macRequirement}. Escolha Apple silicon ou Intel na página de download, ou deixe o Homebrew selecionar a versão." + "O app para Mac exige {macRequirement} e roda nativamente em Apple silicon e Intel. Baixe-o na página de download ou instale-o com o Homebrew." ] }, { diff --git a/resources/data/content/vi/download.json b/resources/data/content/vi/download.json index 7d1c6182..9211ea85 100644 --- a/resources/data/content/vi/download.json +++ b/resources/data/content/vi/download.json @@ -1,7 +1,7 @@ { "seo": { "title": "Tải TablePro", - "description": "Tải TablePro với bản build riêng cho Apple silicon và Intel, hoặc cài bằng Homebrew. Ứng dụng iPhone và iPad có trên App Store." + "description": "Tải TablePro cho máy Mac dùng Apple silicon và Intel, hoặc cài bằng Homebrew. Ứng dụng iPhone và iPad có trên App Store." }, "og": { "kicker": "Tải về", @@ -33,7 +33,7 @@ "Mở TablePro từ Applications. Chọn Open khi macOS hỏi xác nhận.", "Thêm connection, hoặc thử cơ sở dữ liệu SQLite Chinook có sẵn qua Help > Open Sample Database." ], - "signing": "Cả hai bản build đều được ký bằng Developer ID và được Apple notarize, nên bạn không cần thay đổi cài đặt bảo mật nào để mở TablePro.", + "signing": "TablePro được ký bằng Developer ID và được Apple notarize, nên bạn không cần thay đổi cài đặt bảo mật nào để mở ứng dụng.", "drivers": "Các driver phổ biến có sẵn. Driver khác cần internet để tải vào lần đầu sử dụng và cập nhật riêng.", "databases": "Cơ sở dữ liệu được hỗ trợ", "guide": { @@ -56,7 +56,7 @@ }, "olderVersions": { "title": "Phiên bản cũ và ghi chú phát hành", - "body": "Mọi bản phát hành, kèm ghi chú và cả hai file DMG, đều có trên GitHub Releases. Changelog liệt kê những gì đã thay đổi trong từng phiên bản.", + "body": "Mọi bản phát hành, kèm ghi chú và file DMG, đều có trên GitHub Releases. Changelog liệt kê những gì đã thay đổi trong từng phiên bản.", "releases": "Tất cả bản phát hành trên GitHub", "changelog": "Changelog (tiếng Anh)" } diff --git a/resources/data/content/vi/faq.json b/resources/data/content/vi/faq.json index e8d0e016..0eefc6a7 100644 --- a/resources/data/content/vi/faq.json +++ b/resources/data/content/vi/faq.json @@ -74,7 +74,7 @@ "id": "mac-requirements", "question": "TablePro chạy trên những máy Mac nào?", "answer": [ - "Ứng dụng Mac cần {macRequirement}. Chọn Apple silicon hoặc Intel trên trang tải về, hoặc để Homebrew chọn bản build." + "Ứng dụng Mac cần {macRequirement} và chạy native trên Apple silicon và Intel. Tải về từ trang tải về hoặc cài bằng Homebrew." ] }, { diff --git a/resources/data/content/zh-Hans/download.json b/resources/data/content/zh-Hans/download.json index a072d841..be4a1564 100644 --- a/resources/data/content/zh-Hans/download.json +++ b/resources/data/content/zh-Hans/download.json @@ -33,7 +33,7 @@ "从“应用程序”打开 TablePro。macOS 请求确认时,选择 打开 (Open)。", "添加连接,或通过 帮助 > 打开示例数据库 (Help > Open Sample Database) 试用内置的 Chinook SQLite 示例数据库。" ], - "signing": "两个构建版本均使用 Developer ID 签名并经 Apple 公证,打开 TablePro 无需更改任何安全设置。", + "signing": "TablePro 使用 Developer ID 签名并经 Apple 公证,打开时无需更改任何安全设置。", "drivers": "常用驱动已内置。其他驱动在首次使用时需要联网下载,并独立于 App 更新。", "databases": "受支持的数据库", "guide": { @@ -56,7 +56,7 @@ }, "olderVersions": { "title": "旧版本和发行说明", - "body": "每个版本的说明和两个磁盘映像均在 GitHub Releases 上。更新日志列出了各版本的改动。", + "body": "每个版本的说明和磁盘映像均在 GitHub Releases 上。更新日志列出了各版本的改动。", "releases": "GitHub 上的所有版本", "changelog": "更新日志(英语)" } diff --git a/resources/data/content/zh-Hans/faq.json b/resources/data/content/zh-Hans/faq.json index 27fbdb58..19e80740 100644 --- a/resources/data/content/zh-Hans/faq.json +++ b/resources/data/content/zh-Hans/faq.json @@ -74,7 +74,7 @@ "id": "mac-requirements", "question": "哪些 Mac 可以运行 TablePro?", "answer": [ - "Mac App 需要 {macRequirement}。在下载页面选择 Apple silicon 或 Intel,或由 Homebrew 自动选择版本。" + "Mac App 需要 {macRequirement},可在 Apple silicon 和 Intel 上原生运行。可从下载页面获取,或通过 Homebrew 安装。" ] }, { diff --git a/resources/data/content/zh-Hant/download.json b/resources/data/content/zh-Hant/download.json index 997dae48..b11eb705 100644 --- a/resources/data/content/zh-Hant/download.json +++ b/resources/data/content/zh-Hant/download.json @@ -33,7 +33,7 @@ "從「應用程式」開啟 TablePro。macOS 要求確認時,選擇 開啟 (Open)。", "新增連線,或透過 輔助說明 > 開啟範例資料庫 (Help > Open Sample Database) 試用內建的 Chinook SQLite 範例資料庫。" ], - "signing": "兩個建置版本均使用 Developer ID 簽署並經 Apple 公證,開啟 TablePro 無需更改任何安全性設定。", + "signing": "TablePro 使用 Developer ID 簽署並經 Apple 公證,開啟時無需更改任何安全性設定。", "drivers": "常用驅動程式已內建。其他驅動程式在首次使用時需要連網下載,並獨立於 App 更新。", "databases": "支援的資料庫", "guide": { @@ -56,7 +56,7 @@ }, "olderVersions": { "title": "舊版本與版本說明", - "body": "每個版本的說明與兩個磁碟映像檔均在 GitHub Releases 上。更新紀錄列出了各版本的變更。", + "body": "每個版本的說明與磁碟映像檔均在 GitHub Releases 上。更新紀錄列出了各版本的變更。", "releases": "GitHub 上的所有版本", "changelog": "更新紀錄(英文)" } diff --git a/resources/data/content/zh-Hant/faq.json b/resources/data/content/zh-Hant/faq.json index 9800f6a9..77f78286 100644 --- a/resources/data/content/zh-Hant/faq.json +++ b/resources/data/content/zh-Hant/faq.json @@ -74,7 +74,7 @@ "id": "mac-requirements", "question": "哪些 Mac 可以執行 TablePro?", "answer": [ - "Mac App 需要 {macRequirement}。在下載頁面選擇 Apple silicon 或 Intel,或由 Homebrew 自動選擇版本。" + "Mac App 需要 {macRequirement},可在 Apple silicon 和 Intel 上原生執行。可從下載頁面取得,或透過 Homebrew 安裝。" ] }, { diff --git a/resources/data/platforms.json b/resources/data/platforms.json index f9593ccb..ad5b29c0 100644 --- a/resources/data/platforms.json +++ b/resources/data/platforms.json @@ -12,15 +12,13 @@ "displayVersion": "13", "releaseName": "Ventura" }, - "architectures": [ - { "id": "arm64", "assetTemplate": "TablePro-{version}-arm64.dmg" }, - { "id": "x86_64", "assetTemplate": "TablePro-{version}-x86_64.dmg" } - ], - "universalBinary": false, + "architectures": ["arm64", "x86_64"], + "universalBinary": true, + "assetTemplate": "TablePro-{version}.dmg", "destinations": [ { "kind": "dmg", - "urlTemplate": "https://github.com/TableProApp/TablePro/releases/download/v{version}/TablePro-{version}-{arch}.dmg" + "urlTemplate": "https://github.com/TableProApp/TablePro/releases/download/v{version}/TablePro-{version}.dmg" }, { "kind": "homebrew", diff --git a/resources/js/components/compare/model.ts b/resources/js/components/compare/model.ts index f2b106a6..37fcea4d 100644 --- a/resources/js/components/compare/model.ts +++ b/resources/js/components/compare/model.ts @@ -65,6 +65,8 @@ export interface TableProFacts { /** `iOS and iPadOS 18 or later`, or null while there is no iPhone and iPad app. */ iosRequirement: string | null; macArchitectures: ('arm64' | 'x86_64')[]; + /** One app for both architectures, from `platforms.json`'s `universalBinary`. */ + macUniversal: boolean; /** `activations`: the Macs one Starter license covers. */ starter: { monthly: number; yearly: number; lifetime: number; activations: number }; team: { monthly: number; yearly: number; lifetime: number; minSeats: number }; @@ -508,7 +510,7 @@ export function tableproCell(row: Exclude | 'technology', switch (row) { case 'platforms': { const lines: CellLine[] = [{ text: facts.devices }, { text: facts.macRequirement, muted: true }]; - const builds = buildsLabel(facts.macArchitectures, labels); + const builds = buildsLabel(facts.macArchitectures, labels, facts.macUniversal); if (builds !== null) { lines.push({ text: builds, muted: true }); diff --git a/resources/js/components/compare/tablepro-facts.ts b/resources/js/components/compare/tablepro-facts.ts index 23c7ff8a..866afd2b 100644 --- a/resources/js/components/compare/tablepro-facts.ts +++ b/resources/js/components/compare/tablepro-facts.ts @@ -27,7 +27,8 @@ export function tableproFacts(productId: string | null, featuredEngines: string[ devices: deviceList(m.common.list), macRequirement: requirementText(mac, m.platforms), iosRequirement: ios !== null ? requirementText(ios, m.platforms) : null, - macArchitectures: mac.architectures.map((architecture) => architecture.id), + macArchitectures: mac.architectures, + macUniversal: mac.universalBinary, starter: { ...PRICING.tiers.starter.prices, activations: PRICING.tiers.starter.activations }, team: { ...PRICING.tiers.team.prices, minSeats: PRICING.tiers.team.seats.min }, iosFree: ios !== null && ios.price.amount === 0 && !ios.price.inAppPurchases, diff --git a/resources/js/components/download/action-pair.tsx b/resources/js/components/download/action-pair.tsx index 4000ccf8..7f3a78aa 100644 --- a/resources/js/components/download/action-pair.tsx +++ b/resources/js/components/download/action-pair.tsx @@ -34,7 +34,7 @@ interface ActionPairProps { * had drifted apart in button size, glyph and caption spacing. * * "Download for Mac" is the large primary button with the Apple glyph and - * goes to /download, which offers both builds; it never starts a download. + * goes to /download, which offers the DMG; it never starts a download. * The App Store badge (40px, Apple's minimum) sits centred in a 48px row, the * button's height, so the two captions start on one line. Side by side from * 640px, stacked below, each caption under its own action. diff --git a/resources/js/components/download/mac-card.tsx b/resources/js/components/download/mac-card.tsx index 4c867b00..f21cf379 100644 --- a/resources/js/components/download/mac-card.tsx +++ b/resources/js/components/download/mac-card.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, type ReactNode } from 'react'; +import { useState, type ReactNode } from 'react'; import { ChevronRight, Download } from 'lucide-react'; import Badge from '@/components/ui/badge'; import Button from '@/components/ui/button'; @@ -7,13 +7,11 @@ import { InlineCode } from '@/components/ui/code'; import TextLink, { textLinkClasses } from '@/components/ui/text-link'; import { Trans, useI18n } from '@/i18n'; import { trackDownload } from '@/lib/analytics'; -import { buildVariant, chipHelpOpen, type DeviceKind, type MacArch } from '@/lib/device'; +import type { DeviceKind } from '@/lib/device'; import { cn } from '@/lib/utils'; import CommandBlock from './command-block'; import { megabytes, requirementLine } from './format'; -import type { DownloadContent, LinksProp, MacProp, ReleaseAsset, ReleaseProp } from './types'; - -const BUILDS: readonly MacArch[] = ['arm64', 'x86_64']; +import type { DownloadContent, LinksProp, MacProp, ReleaseProp } from './types'; /** `…` in the copy marks an app label or menu path. */ export const UI_TAGS = { ui: (text: string) => {text} }; @@ -25,42 +23,27 @@ interface MacCardProps { links: LinksProp; /** Null until mounted: the server renders the same markup for every device. */ device: DeviceKind | null; - /** Chromium's architecture hint, null when the browser gave none (Safari, Firefox), undefined until it has answered. */ - hint: MacArch | null | undefined; className?: string; } /** - * The Mac card (`#mac`): the release badge, both builds, the help for choosing - * one, each build's SHA-256 when the release data carries it, and the other - * ways to install (design-system §5.3.18 `PlatformCard`, - * §8.7). It is a cell of the download page's `CellGrid`, so it draws no box of - * its own, and its inner rule runs from wall to wall (§4.7). + * The Mac card (`#mac`): the release badge, the universal DMG, its SHA-256 + * when the release data carries it, and the other ways to install + * (design-system §5.3.18 `PlatformCard`, §8.7). It is a cell of the download + * page's `CellGrid`, so it draws no box of its own, and its inner rule runs + * from wall to wall (§4.7). * - * Both DMG links are rendered on the server with their real URLs. The - * browser's hint can only make one of them primary; with no hint both stay - * equal and "Which Mac do I have?" opens. Nothing navigates on its own, and - * nothing claims a download started: - * the "Next, install it" panel appears only after the reader clicked a build, - * and offers the same file again in case nothing arrived. + * The DMG link is rendered on the server with its real URL. Nothing navigates + * on its own, and nothing claims a download started: the "Next, install it" + * panel appears only after the reader clicked the button, and offers the same + * file again in case nothing arrived. */ -export default function MacCard({ content, release, mac, links, device, hint, className }: MacCardProps) { +export default function MacCard({ content, release, mac, links, device, className }: MacCardProps) { const { m, fmt } = useI18n(); - const [clicked, setClicked] = useState(null); - const help = useRef(null); - const openHelp = chipHelpOpen(device, hint); - - useEffect(() => { - if (openHelp && help.current) { - help.current.open = true; - } - }, [openHelp]); + const [clicked, setClicked] = useState(false); const available = release.source !== 'unavailable'; - const chosen = clicked !== null ? release.assets[clicked] : null; - const checksums = BUILDS.map((build) => release.assets[build]).filter( - (asset): asset is ReleaseAsset & { name: string; sha256: string } => asset.name !== null && asset.sha256 !== null, - ); + const { asset } = release; let badge: string | null = null; @@ -74,9 +57,9 @@ export default function MacCard({ content, release, mac, links, device, hint, cl /** * The file name in Plex Mono, as identifiers are (design-system §3.1), and * only the size in tabular figures: tabular figures in Inter spaced the - * name's hyphens out ("TablePro - 0.77.0 - arm64.dmg"). + * name's hyphen out ("TablePro - 0.82.0.dmg"). */ - function fileLabel(asset: ReleaseAsset): ReactNode { + function fileLabel(): ReactNode { if (asset.name === null) { return null; } @@ -95,13 +78,8 @@ export default function MacCard({ content, release, mac, links, device, hint, cl ); } - let status = ''; - - if (device === 'ios' || device === 'other') { - status = m.download.onAnotherDevice; - } else if (device === 'mac' && hint) { - status = fmt(m.download.detected, { chip: m.platforms.architectures[hint] }); - } + const file = fileLabel(); + const status = device === 'ios' || device === 'other' ? m.download.onAnotherDevice : ''; return (
@@ -117,39 +95,32 @@ export default function MacCard({ content, release, mac, links, device, hint, cl

)} -
    - {BUILDS.map((build) => { - const asset = release.assets[build]; - const file = fileLabel(asset); - - return ( -
  • - - {file !== null &&

    {file}

    } -
  • - ); - })} -
+
+
+ + {file !== null &&

{file}

} +
+
{/* - * Room reserved on the server, so the detected chip appears - * without moving anything below it: two lines below 640px, where - * either sentence wraps in a phone-width card, one above. Not a + * Room reserved on the server, so the sentence for another device + * appears without moving anything below it: two lines below + * 640px, where it wraps in a phone-width card, one above. Not a * live region: it changes on load, not in answer to anything the * reader did. */} @@ -157,18 +128,7 @@ export default function MacCard({ content, release, mac, links, device, hint, cl {status}

-
- - -

{m.download.whichMac.body}

-
- - {checksums.length > 0 && ( + {asset.name !== null && asset.sha256 !== null && (
{text} }} />

-
    - {checksums.map((asset) => ( -
  • - {asset.name} - {asset.sha256} -
  • - ))} -
+

+ {asset.name} + {asset.sha256} +

)} - {chosen !== null && chosen.name !== null && ( + {clicked && asset.name !== null && ( -

{fmt(m.download.afterClick.body, { file: chosen.name })}

+

{fmt(m.download.afterClick.body, { file: asset.name })}

( - trackDownload('download-retry', 'mac')} className={textLinkClasses('inline')}> + trackDownload('download-retry', 'mac')} className={textLinkClasses('inline')}> {text} ), diff --git a/resources/js/components/download/types.ts b/resources/js/components/download/types.ts index 6fb9a01c..635ac853 100644 --- a/resources/js/components/download/types.ts +++ b/resources/js/components/download/types.ts @@ -4,7 +4,6 @@ * holds every other locale to the same keys. */ import type { PostSummary } from '@/components/blog/post-list'; -import type { MacArch } from '@/lib/device'; import type { Requirements } from '@/lib/data/platforms'; export type { Requirements }; @@ -12,7 +11,7 @@ export type { Requirements }; export type DownloadContent = typeof import('@data/content/en/download.json'); export interface ReleaseAsset { - /** The DMG's file name, or null when the release is unavailable. */ + /** The universal DMG's file name, or null when the release is unavailable. */ name: string | null; /** The DMG, or GitHub's latest-release page when the release is unavailable. */ url: string; @@ -27,7 +26,7 @@ export interface ReleaseProp { publishedAt: string | null; /** Formatted in PHP for the page's locale, so SSR and the browser agree. */ publishedAtFormatted: string | null; - assets: Record; + asset: ReleaseAsset; releaseUrl: string; releasesUrl: string; source: 'github' | 'appcast' | 'unavailable'; diff --git a/resources/js/components/home/availability.ts b/resources/js/components/home/availability.ts index 4c63e108..a316b80d 100644 --- a/resources/js/components/home/availability.ts +++ b/resources/js/components/home/availability.ts @@ -81,11 +81,11 @@ export function availability(m: Messages, captions: { mac: string; ios: string } }; } -/** The Mac build architectures as a plain list: "Apple silicon and Intel". */ +/** The Mac architectures as a plain list: "Apple silicon and Intel". */ export function macArchitectureList(m: Messages): string { return keepTogether( joinList( - macPlatform().architectures.map((architecture) => m.platforms.architectures[architecture.id]), + macPlatform().architectures.map((architecture) => m.platforms.architectures[architecture]), m.common.list, ), ); diff --git a/resources/js/i18n/messages/de/download.ts b/resources/js/i18n/messages/de/download.ts index 83bc0ef0..0a91db62 100644 --- a/resources/js/i18n/messages/de/download.ts +++ b/resources/js/i18n/messages/de/download.ts @@ -2,17 +2,13 @@ import type { Messages } from '../../types.ts'; export default { "macCta": "Für Mac herunterladen", - "builds": { - "arm64": "Für Apple silicon herunterladen", - "x86_64": "Für Intel herunterladen" - }, "release": { "dated": "Version {version}, veröffentlicht am {date}", "undated": "Version {version}", "badge": "v{version} · {date}", "badgeUndated": "v{version}", "notes": "Versionshinweise (Englisch)", - "unavailable": "Die Versionsdetails sind nicht verfügbar. Beide Schaltflächen öffnen die neueste Version auf GitHub; wähle dort einen Build." + "unavailable": "Die Versionsdetails sind nicht verfügbar. Die Schaltfläche öffnet die neueste Version auf GitHub." }, "file": { "sized": "{name} · {size} MB", @@ -22,12 +18,7 @@ export default { "group": "." } }, - "detected": "Dein Browser meldet einen Mac mit {chip}.", "onAnotherDevice": "Öffne diese Seite auf deinem Mac, um die Mac-App zu installieren.", - "whichMac": { - "summary": "Welchen Mac habe ich?", - "body": "Wähle im Apple-Menü „Über diesen Mac“. Die Zeile „Chip“ bedeutet Apple silicon; eine Zeile „Prozessor“ mit Intel bedeutet Intel." - }, "checksum": { "summary": "Download überprüfen", "body": "Führe im Terminal shasum -a 256 für die Datei aus. Das Ergebnis muss mit der SHA-256-Prüfsumme unten übereinstimmen." diff --git a/resources/js/i18n/messages/en/download.ts b/resources/js/i18n/messages/en/download.ts index e6868f00..02f208f5 100644 --- a/resources/js/i18n/messages/en/download.ts +++ b/resources/js/i18n/messages/en/download.ts @@ -6,32 +6,25 @@ * purchases", "Linux") are the `platforms` namespace's. * * - `macCta` is the availability-layer Mac action (positioning §4), for the - * hero, the closing band, the mobile menu and the Free plan. - * - `builds` are the two architecture buttons; the browser may highlight one, - * never choose (resources/js/lib/device.ts). + * hero, the closing band, the mobile menu, the Free plan and the DMG button. * - `release.badge` is the version badge on the Mac card; `{date}` arrives * formatted by the server for the page's locale. * - `otherPlatforms.joiner` joins the platform names in a negative sentence, * so the last one takes "or": "not available for Linux or Windows". - * - `checksum` is the disclosure under the builds. It renders only when the - * release data carries a SHA-256 for a build. + * - `checksum` is the disclosure under the DMG button. It renders only when + * the release data carries the DMG's SHA-256. * - Nothing here says a download has started. `afterClick` appears only after - * the reader clicked a build, and says what to do if nothing arrived. + * the reader clicked the DMG button, and says what to do if nothing arrived. */ export default { macCta: 'Download for Mac', - builds: { - arm64: 'Download for Apple silicon', - x86_64: 'Download for Intel', - }, release: { dated: 'Version {version}, released {date}', undated: 'Version {version}', badge: 'v{version} · {date}', badgeUndated: 'v{version}', notes: 'Release notes', - unavailable: - 'Release details are unavailable. Both buttons open the latest release on GitHub; choose a build there.', + unavailable: 'Release details are unavailable. The button opens the latest release on GitHub.', }, file: { sized: '{name} · {size} MB', @@ -41,12 +34,7 @@ export default { group: ',', }, }, - detected: 'Your browser reports a Mac with {chip}.', onAnotherDevice: 'To install the Mac app, open this page on your Mac.', - whichMac: { - summary: 'Which Mac do I have?', - body: 'Choose About This Mac in the Apple menu. A Chip line means Apple silicon; a Processor line naming Intel means Intel.', - }, checksum: { summary: 'Verify your download', body: 'Run shasum -a 256 on the file in Terminal. The result should match the SHA-256 checksum below.', diff --git a/resources/js/i18n/messages/es/download.ts b/resources/js/i18n/messages/es/download.ts index ca45415c..324f3a2e 100644 --- a/resources/js/i18n/messages/es/download.ts +++ b/resources/js/i18n/messages/es/download.ts @@ -2,17 +2,13 @@ import type { Messages } from '../../types.ts'; export default { "macCta": "Descargar para Mac", - "builds": { - "arm64": "Descargar para Apple silicon", - "x86_64": "Descargar para Intel" - }, "release": { "dated": "Versión {version}, publicada el {date}", "undated": "Versión {version}", "badge": "v{version} · {date}", "badgeUndated": "v{version}", "notes": "Notas de versión (inglés)", - "unavailable": "Los detalles de la versión no están disponibles. Ambos botones abren la última versión en GitHub; elige allí una compilación." + "unavailable": "Los detalles de la versión no están disponibles. El botón abre la última versión en GitHub." }, "file": { "sized": "{name} · {size} MB", @@ -22,12 +18,7 @@ export default { "group": "." } }, - "detected": "Tu navegador indica que tienes un Mac con {chip}.", "onAnotherDevice": "Para instalar la app para Mac, abre esta página en tu Mac.", - "whichMac": { - "summary": "¿Qué Mac tengo?", - "body": "Elige «Acerca de este Mac» en el menú Apple. Una línea «Chip» indica Apple silicon; una línea «Procesador» que menciona Intel indica Intel." - }, "checksum": { "summary": "Verifica tu descarga", "body": "Ejecuta shasum -a 256 sobre el archivo en Terminal. El resultado debe coincidir con la suma SHA-256 de abajo." diff --git a/resources/js/i18n/messages/fr/download.ts b/resources/js/i18n/messages/fr/download.ts index 699f15ea..87366df4 100644 --- a/resources/js/i18n/messages/fr/download.ts +++ b/resources/js/i18n/messages/fr/download.ts @@ -2,17 +2,13 @@ import type { Messages } from '../../types.ts'; export default { "macCta": "Télécharger pour Mac", - "builds": { - "arm64": "Télécharger pour Apple silicon", - "x86_64": "Télécharger pour Intel" - }, "release": { "dated": "Version {version}, publiée le {date}", "undated": "Version {version}", "badge": "v{version} · {date}", "badgeUndated": "v{version}", "notes": "Notes de version (anglais)", - "unavailable": "Les détails de version sont indisponibles. Les deux boutons ouvrent la dernière version sur GitHub ; choisissez-y une version de l’app." + "unavailable": "Les détails de version sont indisponibles. Le bouton ouvre la dernière version sur GitHub." }, "file": { "sized": "{name} · {size} Mo", @@ -22,12 +18,7 @@ export default { "group": " " } }, - "detected": "Votre navigateur indique un Mac avec {chip}.", "onAnotherDevice": "Pour installer l’application Mac, ouvrez cette page sur votre Mac.", - "whichMac": { - "summary": "Quel Mac ai-je ?", - "body": "Choisissez « À propos de ce Mac » dans le menu Apple. La ligne « Puce » indique Apple silicon ; la ligne « Processeur » mentionnant Intel indique Intel." - }, "checksum": { "summary": "Vérifier votre téléchargement", "body": "Exécutez shasum -a 256 sur le fichier dans Terminal. Le résultat doit correspondre à la somme de contrôle SHA-256 ci-dessous." diff --git a/resources/js/i18n/messages/id/download.ts b/resources/js/i18n/messages/id/download.ts index ad85e48e..3d97649e 100644 --- a/resources/js/i18n/messages/id/download.ts +++ b/resources/js/i18n/messages/id/download.ts @@ -2,17 +2,13 @@ import type { Messages } from '../../types.ts'; export default { "macCta": "Unduh untuk Mac", - "builds": { - "arm64": "Unduh untuk Apple silicon", - "x86_64": "Unduh untuk Intel" - }, "release": { "dated": "Versi {version}, dirilis {date}", "undated": "Versi {version}", "badge": "v{version} · {date}", "badgeUndated": "v{version}", "notes": "Catatan rilis (bahasa Inggris)", - "unavailable": "Detail rilis tidak tersedia. Kedua tombol membuka rilis terbaru di GitHub; pilih build di sana." + "unavailable": "Detail rilis tidak tersedia. Tombol ini membuka rilis terbaru di GitHub." }, "file": { "sized": "{name} · {size} MB", @@ -22,12 +18,7 @@ export default { "group": "." } }, - "detected": "Browser Anda melaporkan Mac dengan {chip}.", "onAnotherDevice": "Untuk menginstal aplikasi Mac, buka halaman ini di Mac Anda.", - "whichMac": { - "summary": "Mac apa yang saya miliki?", - "body": "Pilih Mengenai Mac Ini di menu Apple. Baris Chip menunjukkan Apple silicon; baris Prosesor yang menyebut Intel menunjukkan Intel." - }, "checksum": { "summary": "Verifikasi unduhan Anda", "body": "Jalankan shasum -a 256 pada file tersebut di Terminal. Hasilnya harus sama dengan checksum SHA-256 di bawah." diff --git a/resources/js/i18n/messages/it/download.ts b/resources/js/i18n/messages/it/download.ts index 84afd2bb..0a362d1f 100644 --- a/resources/js/i18n/messages/it/download.ts +++ b/resources/js/i18n/messages/it/download.ts @@ -2,17 +2,13 @@ import type { Messages } from '../../types.ts'; export default { "macCta": "Scarica per Mac", - "builds": { - "arm64": "Scarica per Apple silicon", - "x86_64": "Scarica per Intel" - }, "release": { "dated": "Versione {version}, rilasciata il {date}", "undated": "Versione {version}", "badge": "v{version} · {date}", "badgeUndated": "v{version}", "notes": "Note di rilascio (inglese)", - "unavailable": "Dettagli del rilascio non disponibili. Entrambi i pulsanti aprono l’ultima versione su GitHub; scegli lì la build." + "unavailable": "Dettagli del rilascio non disponibili. Il pulsante apre l’ultima versione su GitHub." }, "file": { "sized": "{name} · {size} MB", @@ -22,12 +18,7 @@ export default { "group": "." } }, - "detected": "Il browser indica un Mac con {chip}.", "onAnotherDevice": "Per installare l’app per Mac, apri questa pagina sul tuo Mac.", - "whichMac": { - "summary": "Quale Mac ho?", - "body": "Scegli Informazioni su questo Mac dal menu Apple. La voce Chip indica Apple silicon; una voce Processore con Intel indica Intel." - }, "checksum": { "summary": "Verifica il download", "body": "Esegui shasum -a 256 sul file nel Terminale. Il risultato deve corrispondere al checksum SHA-256 qui sotto." diff --git a/resources/js/i18n/messages/ja/download.ts b/resources/js/i18n/messages/ja/download.ts index 13800dda..3b9202fd 100644 --- a/resources/js/i18n/messages/ja/download.ts +++ b/resources/js/i18n/messages/ja/download.ts @@ -2,17 +2,13 @@ import type { Messages } from '../../types.ts'; export default { "macCta": "Mac 版をダウンロード", - "builds": { - "arm64": "Apple silicon 版をダウンロード", - "x86_64": "Intel 版をダウンロード" - }, "release": { "dated": "バージョン {version}、{date} リリース", "undated": "バージョン {version}", "badge": "v{version} · {date}", "badgeUndated": "v{version}", "notes": "リリースノート(英語)", - "unavailable": "リリース情報を取得できません。どちらのボタンも GitHub の最新リリースを開きます。そこでビルドを選んでください。" + "unavailable": "リリース情報を取得できません。ボタンは GitHub の最新リリースを開きます。" }, "file": { "sized": "{name} · {size} MB", @@ -22,12 +18,7 @@ export default { "group": "," } }, - "detected": "ブラウザーによると、お使いの Mac は {chip} 搭載です。", "onAnotherDevice": "Mac アプリをインストールするには、Mac でこのページを開いてください。", - "whichMac": { - "summary": "自分の Mac の種類を確認する", - "body": "Apple メニューで「この Mac について」を選びます。「チップ」欄があれば Apple silicon Mac、「プロセッサ」欄に Intel とあれば Intel Mac です。" - }, "checksum": { "summary": "ダウンロードを検証する", "body": "ターミナルでファイルに対して shasum -a 256 を実行します。結果が下の SHA-256 チェックサムと一致することを確認してください。" diff --git a/resources/js/i18n/messages/ko/download.ts b/resources/js/i18n/messages/ko/download.ts index d0f41e69..9b3380b7 100644 --- a/resources/js/i18n/messages/ko/download.ts +++ b/resources/js/i18n/messages/ko/download.ts @@ -2,17 +2,13 @@ import type { Messages } from '../../types.ts'; export default { "macCta": "Mac용 다운로드", - "builds": { - "arm64": "Apple silicon용 다운로드", - "x86_64": "Intel용 다운로드" - }, "release": { "dated": "버전 {version}, {date} 출시", "undated": "버전 {version}", "badge": "v{version} · {date}", "badgeUndated": "v{version}", "notes": "릴리스 노트 (영어)", - "unavailable": "릴리스 정보를 불러올 수 없습니다. 두 버튼 모두 GitHub의 최신 릴리스를 엽니다. 거기에서 빌드를 선택하세요." + "unavailable": "릴리스 정보를 불러올 수 없습니다. 버튼을 누르면 GitHub의 최신 릴리스가 열립니다." }, "file": { "sized": "{name} · {size} MB", @@ -22,12 +18,7 @@ export default { "group": "," } }, - "detected": "브라우저 정보에 따르면 {chip} 탑재 Mac을 사용 중입니다.", "onAnotherDevice": "Mac 앱을 설치하려면 Mac에서 이 페이지를 여세요.", - "whichMac": { - "summary": "내 Mac은 어떤 모델인가요?", - "body": "Apple 메뉴에서 '이 Mac에 관하여'를 선택하세요. 칩 항목이 있으면 Apple silicon Mac이며, 프로세서 항목에 Intel이 표시되면 Intel Mac입니다." - }, "checksum": { "summary": "다운로드 파일 확인", "body": "터미널에서 파일에 shasum -a 256을 실행하세요. 결과가 아래 SHA-256 체크섬과 일치해야 합니다." diff --git a/resources/js/i18n/messages/pt-BR/download.ts b/resources/js/i18n/messages/pt-BR/download.ts index 27301dd0..46dac043 100644 --- a/resources/js/i18n/messages/pt-BR/download.ts +++ b/resources/js/i18n/messages/pt-BR/download.ts @@ -2,17 +2,13 @@ import type { Messages } from '../../types.ts'; export default { "macCta": "Baixar para Mac", - "builds": { - "arm64": "Baixar para Apple silicon", - "x86_64": "Baixar para Intel" - }, "release": { "dated": "Versão {version}, lançada em {date}", "undated": "Versão {version}", "badge": "v{version} · {date}", "badgeUndated": "v{version}", "notes": "Notas de versão (inglês)", - "unavailable": "Detalhes do lançamento indisponíveis. Os dois botões abrem a última versão no GitHub; escolha a versão lá." + "unavailable": "Detalhes do lançamento indisponíveis. O botão abre a última versão no GitHub." }, "file": { "sized": "{name} · {size} MB", @@ -22,12 +18,7 @@ export default { "group": "." } }, - "detected": "Seu navegador informa que seu Mac usa {chip}.", "onAnotherDevice": "Para instalar o app para Mac, abra esta página no seu Mac.", - "whichMac": { - "summary": "Qual é o meu Mac?", - "body": "Escolha Sobre Este Mac no menu Apple. A linha Chip indica Apple silicon; a linha Processador com Intel indica Intel." - }, "checksum": { "summary": "Verifique seu download", "body": "Execute shasum -a 256 no arquivo pelo Terminal. O resultado deve ser igual à soma de verificação SHA-256 abaixo." diff --git a/resources/js/i18n/messages/vi/download.ts b/resources/js/i18n/messages/vi/download.ts index 2dd0c652..1ec5ad65 100644 --- a/resources/js/i18n/messages/vi/download.ts +++ b/resources/js/i18n/messages/vi/download.ts @@ -7,18 +7,13 @@ import type { Messages } from '../../types.ts'; */ export default { macCta: 'Tải về cho Mac', - builds: { - arm64: 'Tải bản cho Apple silicon', - x86_64: 'Tải bản cho Intel', - }, release: { dated: 'Phiên bản {version}, phát hành ngày {date}', undated: 'Phiên bản {version}', badge: 'v{version} · {date}', badgeUndated: 'v{version}', notes: 'Ghi chú phát hành (tiếng Anh)', - unavailable: - 'Không tải được thông tin bản phát hành. Cả hai nút mở bản mới nhất trên GitHub; chọn bản build tại đó.', + unavailable: 'Không tải được thông tin bản phát hành. Nút tải về mở bản mới nhất trên GitHub.', }, file: { sized: '{name} · {size} MB', @@ -28,12 +23,7 @@ export default { group: '.', }, }, - detected: 'Trình duyệt cho biết máy Mac của bạn dùng {chip}.', onAnotherDevice: 'Để cài ứng dụng Mac, hãy mở trang này trên máy Mac của bạn.', - whichMac: { - summary: 'Máy Mac của bạn dùng chip nào?', - body: 'Chọn Giới thiệu về máy Mac này (About This Mac) trong menu Apple. Mục Chip nghĩa là Apple silicon; mục Bộ xử lý (Processor) ghi Intel nghĩa là Intel.', - }, checksum: { summary: 'Kiểm tra file đã tải về', body: 'Chạy shasum -a 256 với file đó trong Terminal. Kết quả phải khớp với checksum SHA-256 bên dưới.', diff --git a/resources/js/i18n/messages/zh-Hans/download.ts b/resources/js/i18n/messages/zh-Hans/download.ts index 90b37400..037ab168 100644 --- a/resources/js/i18n/messages/zh-Hans/download.ts +++ b/resources/js/i18n/messages/zh-Hans/download.ts @@ -2,17 +2,13 @@ import type { Messages } from '../../types.ts'; export default { "macCta": "下载 Mac 版", - "builds": { - "arm64": "下载 Apple silicon 版", - "x86_64": "下载 Intel 版" - }, "release": { "dated": "版本 {version},发布于 {date}", "undated": "版本 {version}", "badge": "v{version} · {date}", "badgeUndated": "v{version}", "notes": "发行说明(英语)", - "unavailable": "无法获取版本信息。两个按钮都会打开 GitHub 上的最新版本,请在那里选择安装包。" + "unavailable": "无法获取版本信息。按钮会打开 GitHub 上的最新版本。" }, "file": { "sized": "{name} · {size} MB", @@ -22,12 +18,7 @@ export default { "group": "," } }, - "detected": "浏览器显示您的 Mac 使用 {chip}。", "onAnotherDevice": "要安装 Mac 应用,请在 Mac 上打开此页面。", - "whichMac": { - "summary": "我的 Mac 是哪种型号?", - "body": "在 Apple 菜单中选择“关于本机”。显示“芯片”一栏的是 Apple 芯片 Mac;“处理器”一栏显示 Intel 的是 Intel Mac。" - }, "checksum": { "summary": "验证下载的文件", "body": "在终端中对该文件运行 shasum -a 256,结果应与下方的 SHA-256 校验和一致。" diff --git a/resources/js/i18n/messages/zh-Hant/download.ts b/resources/js/i18n/messages/zh-Hant/download.ts index b1888ab7..28d5ff82 100644 --- a/resources/js/i18n/messages/zh-Hant/download.ts +++ b/resources/js/i18n/messages/zh-Hant/download.ts @@ -2,17 +2,13 @@ import type { Messages } from '../../types.ts'; export default { "macCta": "下載 Mac 版", - "builds": { - "arm64": "下載 Apple silicon 版", - "x86_64": "下載 Intel 版" - }, "release": { "dated": "版本 {version},發佈於 {date}", "undated": "版本 {version}", "badge": "v{version} · {date}", "badgeUndated": "v{version}", "notes": "版本說明(英文)", - "unavailable": "無法取得版本資訊。兩個按鈕都會開啟 GitHub 上的最新版本,請在那裡選擇安裝套件。" + "unavailable": "無法取得版本資訊。按鈕會開啟 GitHub 上的最新版本。" }, "file": { "sized": "{name} · {size} MB", @@ -22,12 +18,7 @@ export default { "group": "," } }, - "detected": "瀏覽器顯示您的 Mac 使用 {chip}。", "onAnotherDevice": "若要安裝 Mac App,請在 Mac 上開啟此頁面。", - "whichMac": { - "summary": "我的 Mac 是哪種型號?", - "body": "在 Apple 選單中選擇「關於這台 Mac」。顯示「晶片」欄位的是 Apple 晶片 Mac;「處理器」欄位顯示 Intel 的是 Intel Mac。" - }, "checksum": { "summary": "驗證下載的檔案", "body": "在終端機中對該檔案執行 shasum -a 256,結果應與下方的 SHA-256 檢查碼一致。" diff --git a/resources/js/lib/data/platforms.ts b/resources/js/lib/data/platforms.ts index 10ccb3cd..91626bfc 100644 --- a/resources/js/lib/data/platforms.ts +++ b/resources/js/lib/data/platforms.ts @@ -33,12 +33,6 @@ export interface Requirements { releaseName: string | null; } -export interface Architecture { - id: MacArchitecture; - /** The DMG file name, with `{version}` for the release version. */ - assetTemplate: string; -} - export type Destination = | { kind: 'dmg'; urlTemplate: string } | { kind: 'homebrew'; command: string; url: string } @@ -70,9 +64,12 @@ interface ReleasedPlatformBase { export interface MacPlatform extends ReleasedPlatformBase { id: 'mac'; - architectures: Architecture[]; - /** Two separate builds. Never "Universal". */ - universalBinary: false; + /** The chips the app runs on natively, in display order. */ + architectures: MacArchitecture[]; + /** One DMG runs on every architecture. */ + universalBinary: boolean; + /** The DMG file name, with `{version}` for the release version. */ + assetTemplate: string; /** The oldest version any channel still serves. */ floorVersion: string; } @@ -167,10 +164,10 @@ export function requirementText(platform: ReleasedPlatform, strings: PlatformStr return interpolate(releaseName === null ? strings.requirement.unnamed : strings.requirement.named, values); } -/** `Apple silicon or Intel`, in the Mac build order. */ +/** `Apple silicon or Intel`, in the data order. */ export function architecturesText(strings: PlatformStrings): string { return joinList( - macPlatform().architectures.map((architecture) => strings.architectures[architecture.id]), + macPlatform().architectures.map((architecture) => strings.architectures[architecture]), { separator: ', ', last: strings.architectures.joiner }, ); } diff --git a/resources/js/lib/device.ts b/resources/js/lib/device.ts index 8064ef05..15b2d16b 100644 --- a/resources/js/lib/device.ts +++ b/resources/js/lib/device.ts @@ -1,19 +1,13 @@ /** - * What the download page may infer about the reader's device, and what it may - * not (architecture §1.13). + * What the download page may infer about the reader's device (architecture + * §1.13). The page never acts on it: the Mac DMG is a server-rendered link, + * and nothing starts a download on its own. The result only decides whether + * the App Store card comes first, and whether the Mac card says to open the + * page on a Mac. * - * The page never acts on any of this. Both Mac builds are server-rendered - * links, and nothing starts a download on its own. The result only decides - * emphasis: which build's button is primary, and whether the App Store card - * comes first. - * - * - An iPhone or iPad is never a Mac. iPhone user agents contain "like Mac OS - * X", which is how the pre-rebuild page sent iPhones to the Apple silicon - * DMG. iPadOS Safari sends a desktop "Macintosh" user agent by default, so a - * "Macintosh" with more than one touch point is an iPad. - * - Only Chromium's `architecture` client hint says which chip a Mac has. - * Safari and Firefox report "Intel Mac OS X" on every Mac, so without the - * hint the page never guesses, and opens "Which Mac do I have?" instead. + * An iPhone or iPad is never a Mac. iPhone user agents contain "like Mac OS + * X", and iPadOS Safari sends a desktop "Macintosh" user agent by default, so + * a "Macintosh" with more than one touch point is an iPad. * * Pure, with no imports, so `node --test` loads it directly * (tests/js/device.test.ts). @@ -21,15 +15,6 @@ export type DeviceKind = 'mac' | 'ios' | 'other'; -export type MacArch = 'arm64' | 'x86_64'; - -/** The shape of Chromium's `navigator.userAgentData`, as far as this module uses it. */ -export interface ArchitectureHintSource { - getHighEntropyValues(hints: string[]): Promise<{ architecture?: string }>; -} - -export type ButtonVariant = 'primary' | 'secondary'; - /** * `ios` for an iPhone, iPad or iPod (including iPadOS's desktop user agent), * `mac` for a Mac, `other` for anything else. @@ -46,54 +31,6 @@ export function classifyDevice(userAgent: string, maxTouchPoints: number): Devic return 'other'; } -/** Chromium's `architecture` value as a build: `arm` → `arm64`, `x86` → `x86_64`, else nothing. */ -export function archFromHint(architecture: string | null | undefined): MacArch | null { - if (architecture === 'arm') { - return 'arm64'; - } - - if (architecture === 'x86') { - return 'x86_64'; - } - - return null; -} - -/** - * The Mac's architecture from Chromium's client hint, or null when the browser - * has no hint (Safari, Firefox), refuses it, or reports something else. - */ -export async function archHint(source: ArchitectureHintSource | null | undefined): Promise { - if (!source || typeof source.getHighEntropyValues !== 'function') { - return null; - } - - try { - const hints = await source.getHighEntropyValues(['architecture']); - - return archFromHint(hints?.architecture); - } catch { - return null; - } -} - -/** - * Which variant a Mac build's button takes. - * - * Primary only for the build a Mac's own hint names. With no hint, both stay - * secondary, so neither looks like the answer: an Intel Mac in Safari is - * exactly the reader the pre-rebuild page sent the wrong build. Both variants - * have the same dimensions, so the change after hydration shifts nothing. - */ -export function buildVariant(build: MacArch, device: DeviceKind, hint: MacArch | null): ButtonVariant { - return device === 'mac' && hint === build ? 'primary' : 'secondary'; -} - -// "Which Mac do I have?" opens by itself on a Mac whose browser gave no hint. `undefined` is a hint still pending. -export function chipHelpOpen(device: DeviceKind | null, hint: MacArch | null | undefined): boolean { - return device === 'mac' && hint === null; -} - /** The App Store card leads on an iPhone or iPad, and only there. */ export function appStoreFirst(device: DeviceKind): boolean { return device === 'ios'; diff --git a/resources/js/pages/Download.tsx b/resources/js/pages/Download.tsx index e723f6f7..44cf0c64 100644 --- a/resources/js/pages/Download.tsx +++ b/resources/js/pages/Download.tsx @@ -15,8 +15,9 @@ import MacCard, { UI_TAGS } from '@/components/download/mac-card'; import type { DownloadPageProps } from '@/components/download/types'; import { LOCALES, Trans, useI18n, type Messages } from '@/i18n'; import { joinList, keepTogether } from '@/i18n/format'; +import { architecturesText } from '@/lib/data/platforms'; import { PRICING } from '@/lib/data/pricing'; -import { appStoreFirst, archHint, classifyDevice, type DeviceKind, type MacArch } from '@/lib/device'; +import { appStoreFirst, classifyDevice, type DeviceKind } from '@/lib/device'; import { absoluteUrl, graph, iosAppNode, macAppId, macAppNode, webPageNode, type JsonLdGraph } from '@/lib/structured-data'; import LandingLayout from '@/layouts/landing-layout'; @@ -46,10 +47,6 @@ function downloadJsonLd({ baseUrl, inLanguage, m, fmt, path, content, release, m const pageUrl = absoluteUrl(baseUrl, path('/download')); const deviceNames = [...mac.deviceNames, ...(ios?.deviceNames ?? [])]; const live = release.source !== 'unavailable' && release.version !== null; - const architectures = joinList( - (Object.keys(release.assets) as MacArch[]).map((arch) => m.platforms.architectures[arch]), - { separator: ', ', last: m.platforms.architectures.joiner }, - ); return graph([ webPageNode(context, { @@ -69,7 +66,7 @@ function downloadJsonLd({ baseUrl, inLanguage, m, fmt, path, content, release, m alternateName: m.seo.macApp.alternateName, subCategory: m.seo.macApp.subCategory, operatingSystem: mac.requirements.systems.join(', '), - requirements: `${requirementLine(mac.requirements, m.platforms)}, ${architectures}`, + requirements: `${requirementLine(mac.requirements, m.platforms)}, ${architecturesText(m.platforms)}`, licenseUrl: links.license, version: live ? release.version : null, downloadUrl: pageUrl, @@ -91,41 +88,22 @@ function downloadJsonLd({ baseUrl, inLanguage, m, fmt, path, content, release, m /** * `/download` and `/vi/download` (sitemap §A.1, design-system §8.7). * - * The server renders everything a reader needs: both Mac builds with their - * real DMG URLs, the Homebrew command and the App Store card. After mount the - * page reads the device only to adjust emphasis (resources/js/lib/device.ts): - * on an iPhone or iPad the App Store card comes first and neither Mac build is - * highlighted; on a Mac whose Chromium browser reports its architecture, that - * build's button becomes primary. Nothing starts a download on its own, and no - * copy says a download started. + * The server renders everything a reader needs: the universal DMG with its + * real URL, the Homebrew command and the App Store card. After mount the page + * reads the device only to adjust emphasis (resources/js/lib/device.ts): on an + * iPhone or iPad the App Store card comes first. Nothing starts a download on + * its own, and no copy says a download started. * - * If no release source has ever answered, both buttons open GitHub's latest + * If no release source has ever answered, the button opens GitHub's latest * release, no version is shown, and the Mac card says why. */ export default function Download({ content, release, mac, ios, unreleased, links, featuredEngines, latestPost }: DownloadPageProps) { const { canonicalBaseUrl } = usePage().props; const { locale, m, fmt, path } = useI18n(); const [device, setDevice] = useState(null); - // Undefined until the browser has answered; only a Mac is asked. - const [hint, setHint] = useState(undefined); useEffect(() => { - let cancelled = false; - const kind = classifyDevice(navigator.userAgent, navigator.maxTouchPoints ?? 0); - - setDevice(kind); - - if (kind === 'mac') { - void archHint(navigator.userAgentData).then((arch) => { - if (!cancelled) { - setHint(arch); - } - }); - } - - return () => { - cancelled = true; - }; + setDevice(classifyDevice(navigator.userAgent, navigator.maxTouchPoints ?? 0)); }, []); const available = release.source !== 'unavailable' && release.version !== null; @@ -136,7 +114,7 @@ export default function Download({ content, release, mac, ios, unreleased, links .map((id) => m.platforms.names[id]); const macCard = ( - + ); const iosCard = ios !== null ? : null; diff --git a/resources/js/types/globals.d.ts b/resources/js/types/globals.d.ts index 7f60d131..9126510e 100644 --- a/resources/js/types/globals.d.ts +++ b/resources/js/types/globals.d.ts @@ -2,22 +2,6 @@ * Ambient declarations for APIs the DOM lib does not cover. */ -/** - * User-Agent Client Hints. Chromium-only and still not in TypeScript's DOM lib, - * so it is declared here rather than cast away at each call site. Optional - * because Safari and Firefox do not implement it — the download page falls back - * to the user-agent string when it is absent. - * - * @see https://wicg.github.io/ua-client-hints/ - */ -interface NavigatorUAData { - getHighEntropyValues(hints: string[]): Promise<{ architecture?: string; platform?: string }>; -} - -interface Navigator { - userAgentData?: NavigatorUAData; -} - /** * The SSR bundle runs under Node, where process.env is how the port is * configured. Declared minimally rather than pulling @types/node in, which diff --git a/tests/Feature/Data/PlatformsDataTest.php b/tests/Feature/Data/PlatformsDataTest.php index f0d36c69..e94e6751 100644 --- a/tests/Feature/Data/PlatformsDataTest.php +++ b/tests/Feature/Data/PlatformsDataTest.php @@ -95,20 +95,19 @@ function platformsDataEntry(string $id): array expect(preg_match('/macOS\s*14|"minVersion":\s*"14/', $raw))->toBe(0); }); -it('ships separate Apple silicon and Intel builds, never a universal binary', function (): void { +it('ships one universal DMG for Apple silicon and Intel', function (): void { $mac = platformsDataEntry('mac'); - expect($mac['universalBinary'])->toBeFalse(); - expect(array_column($mac['architectures'], 'id'))->toBe(['arm64', 'x86_64']); - - foreach ($mac['architectures'] as $architecture) { - expect($architecture['assetTemplate'])->toBe("TablePro-{version}-{$architecture['id']}.dmg"); - } + expect($mac['universalBinary'])->toBeTrue(); + expect($mac['architectures'])->toBe(['arm64', 'x86_64']); + expect($mac['assetTemplate'])->toBe('TablePro-{version}.dmg'); $destinations = collect($mac['destinations'])->keyBy('kind'); expect($destinations->keys()->all())->toBe(['dmg', 'homebrew', 'releases']); - expect($destinations['dmg']['urlTemplate'])->toContain('{version}')->toContain('{arch}'); + expect($destinations['dmg']['urlTemplate']) + ->toBe('https://github.com/TableProApp/TablePro/releases/download/v{version}/' . $mac['assetTemplate']) + ->not->toContain('{arch}'); expect($destinations['homebrew']['command'])->toBe('brew install --cask tablepro'); expect($destinations['releases']['url'])->toBe('https://github.com/TableProApp/TablePro/releases'); }); diff --git a/tests/Feature/Releases/DownloadPageTest.php b/tests/Feature/Releases/DownloadPageTest.php index 6c67ae2c..add238dd 100644 --- a/tests/Feature/Releases/DownloadPageTest.php +++ b/tests/Feature/Releases/DownloadPageTest.php @@ -45,9 +45,9 @@ function fakeLiveRelease(): void ->where('release.source', 'github') ->where('release.version', '0.77.0') ->where('release.publishedAtFormatted', 'October 2, 2026') - ->where('release.assets.arm64.url', 'https://github.com/TableProApp/TablePro/releases/download/v0.77.0/TablePro-0.77.0-arm64.dmg') - ->where('release.assets.arm64.bytes', 22_943_352) - ->where('release.assets.x86_64.url', 'https://github.com/TableProApp/TablePro/releases/download/v0.77.0/TablePro-0.77.0-x86_64.dmg') + ->where('release.asset.name', 'TablePro-0.77.0.dmg') + ->where('release.asset.url', 'https://github.com/TableProApp/TablePro/releases/download/v0.77.0/TablePro-0.77.0.dmg') + ->where('release.asset.bytes', 65_432_179) ->where('mac.requirements.minVersion', '13.0') ->where('mac.requirements.releaseName', 'Ventura') ->where('mac.homebrewCommand', 'brew install --cask tablepro') @@ -188,9 +188,8 @@ function downloadNewestReleasePost(): array ->where('release.source', 'unavailable') ->where('release.version', null) ->where('release.publishedAtFormatted', null) - ->where('release.assets.arm64.url', 'https://github.com/TableProApp/TablePro/releases/latest') - ->where('release.assets.x86_64.url', 'https://github.com/TableProApp/TablePro/releases/latest') - ->where('release.assets.arm64.name', null) + ->where('release.asset.url', 'https://github.com/TableProApp/TablePro/releases/latest') + ->where('release.asset.name', null) ->where('mac.homebrewTrails', false)); }); @@ -248,15 +247,16 @@ function downloadNewestReleasePost(): array expect(preg_match('/chậm hơn|dòng Chip|nơi bạn chọn/u', $text))->toBe(0); }); -it('server-renders both builds as links, says nothing about a download starting, and shows no checksum block without digests', function (): void { +it('server-renders the universal DMG as a link, says nothing about a download starting, and shows no checksum block without a digest', function (): void { fakeLiveRelease(); $pages = ['/download' => ssrHtml('/download'), '/vi/download' => ssrHtml('/vi/download')]; foreach ($pages as $html) { expect($html) - ->toContain('href="https://github.com/TableProApp/TablePro/releases/download/v0.77.0/TablePro-0.77.0-arm64.dmg"') - ->toContain('href="https://github.com/TableProApp/TablePro/releases/download/v0.77.0/TablePro-0.77.0-x86_64.dmg"') + ->toContain('href="https://github.com/TableProApp/TablePro/releases/download/v0.77.0/TablePro-0.77.0.dmg"') + ->not->toContain('arm64.dmg') + ->not->toContain('x86_64.dmg') ->toContain('href="https://apps.apple.com/app/tablepro/id6761621829"') ->toContain('brew install --cask tablepro') ->toContain('id="mac"') @@ -278,53 +278,46 @@ function downloadNewestReleasePost(): array } expect($pages['/download'])->toContain('Requires macOS 13 Ventura or later') - ->toContain('Download for Apple silicon') - ->toContain('Download for Intel') - ->toContain('Which Mac do I have?') + ->toContain('Download for Mac') + ->not->toContain('Download for Apple silicon') + ->not->toContain('Which Mac do I have?') ->toContain('v0.77.0 · October 2, 2026') - ->toContain('TablePro-0.77.0-arm64.dmg · 22.9 MB') + ->toContain('TablePro-0.77.0.dmg · 65.4 MB') ->toContain('Requires iOS and iPadOS 18 or later') ->toContain('Free, with no in-app purchases') ->toContain('not available for Linux or Windows') ->not->toContain('Verify your download') - ->not->toContain('shasum') - // The server cannot know the browser, so "Which Mac do I have?" renders closed; chipHelpOpen() opens it (tests/js/device.test.ts). - ->toMatch('/

not->toContain('shasum'); expect($pages['/vi/download'])->toContain('Yêu cầu macOS 13 Ventura trở lên') - ->toContain('Tải bản cho Apple silicon') - ->toContain('Tải bản cho Intel') + ->toContain('Tải về cho Mac') ->toContain('v0.77.0 · 2 tháng 10 năm 2026') - ->toContain('TablePro-0.77.0-arm64.dmg · 22,9 MB') + ->toContain('TablePro-0.77.0.dmg · 65,4 MB') ->toContain('Yêu cầu iOS và iPadOS 18 trở lên') ->toContain('Miễn phí, không có mua hàng trong ứng dụng') ->toContain('cho Linux hoặc Windows'); })->group('ssr'); -it('sends each DMG’s SHA-256 to the page when the release carries one', function (): void { - $arm64 = str_repeat('a1', 32); - $x86_64 = str_repeat('b2', 32); +it('sends the DMG’s SHA-256 to the page when the release carries one', function (): void { + $sha256 = str_repeat('a1', 32); - Http::fake([RELEASES_FAKE_GITHUB_LATEST => Http::response(githubReleasePayloadWithDigests($arm64, $x86_64))]); + Http::fake([RELEASES_FAKE_GITHUB_LATEST => Http::response(githubReleasePayloadWithDigest($sha256))]); Artisan::call('release:refresh'); - get('/download')->assertInertia(fn(AssertableInertia $page) => $page - ->where('release.assets.arm64.sha256', $arm64) - ->where('release.assets.x86_64.sha256', $x86_64)); + get('/download')->assertInertia(fn(AssertableInertia $page) => $page->where('release.asset.sha256', $sha256)); }); -it('server-renders each build’s SHA-256 with the command that checks it, in English and a translated page', function (): void { - $arm64 = str_repeat('a1', 32); - $x86_64 = str_repeat('b2', 32); +it('server-renders the DMG’s SHA-256 with the command that checks it, in English and a translated page', function (): void { + $sha256 = str_repeat('a1', 32); - Http::fake([RELEASES_FAKE_GITHUB_LATEST => Http::response(githubReleasePayloadWithDigests($arm64, $x86_64))]); + Http::fake([RELEASES_FAKE_GITHUB_LATEST => Http::response(githubReleasePayloadWithDigest($sha256))]); Artisan::call('release:refresh'); - expect(ssrHtml('/download'))->toContain('Verify your download', $arm64, $x86_64, 'shasum -a 256'); - expect(ssrHtml('/vi/download'))->toContain($arm64, $x86_64, 'shasum -a 256')->not->toContain('Verify your download'); + expect(ssrHtml('/download'))->toContain('Verify your download', 'TablePro-0.77.0.dmg', $sha256, 'shasum -a 256'); + expect(ssrHtml('/vi/download'))->toContain($sha256, 'shasum -a 256')->not->toContain('Verify your download'); })->group('ssr'); -it('server-renders no version and sends both buttons to the latest release when no source answers', function (): void { +it('server-renders no version and sends the button to the latest release when no source answers', function (): void { Http::fake([ RELEASES_FAKE_GITHUB_LATEST => Http::response('', 500), RELEASES_FAKE_APPCAST => Http::response('', 500), @@ -332,8 +325,8 @@ function downloadNewestReleasePost(): array $html = ssrHtml('/download'); - expect(substr_count($html, 'href="https://github.com/TableProApp/TablePro/releases/latest"'))->toBeGreaterThanOrEqual(2) - ->and($html)->toContain('Release details are unavailable. Both buttons open the latest release on GitHub; choose a build there.') + expect(substr_count($html, 'href="https://github.com/TableProApp/TablePro/releases/latest"'))->toBeGreaterThanOrEqual(1) + ->and($html)->toContain('Release details are unavailable. The button opens the latest release on GitHub.') ->not->toContain('softwareVersion') ->not->toContain('.dmg"'); })->group('ssr'); diff --git a/tests/Feature/Releases/MacReleaseServiceTest.php b/tests/Feature/Releases/MacReleaseServiceTest.php index bf876c2c..d295ce9c 100644 --- a/tests/Feature/Releases/MacReleaseServiceTest.php +++ b/tests/Feature/Releases/MacReleaseServiceTest.php @@ -46,7 +46,7 @@ function githubApiCalls(): int return Http::recorded(fn(Request $request): bool => str_contains($request->url(), 'api.github.com'))->count(); } -it('reads the app release from releases/latest, with both DMGs and their sizes', function (): void { +it('reads the app release from releases/latest, with the universal DMG and its size', function (): void { Http::fake([RELEASES_FAKE_GITHUB_LATEST => Http::response(githubReleasePayload())]); $release = macRelease(); @@ -54,14 +54,12 @@ function githubApiCalls(): int expect($release->source)->toBe(MacRelease::SOURCE_GITHUB) ->and($release->version)->toBe('0.77.0') ->and($release->publishedAt)->toBe('2026-10-02') - ->and($release->assets['arm64'])->toBe([ - 'name' => 'TablePro-0.77.0-arm64.dmg', - 'url' => 'https://github.com/TableProApp/TablePro/releases/download/v0.77.0/TablePro-0.77.0-arm64.dmg', - 'bytes' => 22_943_352, + ->and($release->asset)->toBe([ + 'name' => 'TablePro-0.77.0.dmg', + 'url' => 'https://github.com/TableProApp/TablePro/releases/download/v0.77.0/TablePro-0.77.0.dmg', + 'bytes' => 65_432_179, 'sha256' => null, ]) - ->and($release->assets['x86_64']['name'])->toBe('TablePro-0.77.0-x86_64.dmg') - ->and($release->assets['x86_64']['bytes'])->toBe(26_202_607) ->and($release->releaseUrl)->toBe('https://github.com/TableProApp/TablePro/releases/tag/v0.77.0') ->and($release->releasesUrl)->toBe('https://github.com/TableProApp/TablePro/releases'); @@ -69,32 +67,27 @@ function githubApiCalls(): int Http::assertNotSent(fn(Request $request): bool => str_contains($request->url(), '/releases?') || str_ends_with($request->url(), '/releases')); }); -it('keeps each DMG’s SHA-256 when the releases API reports one, in the cache and in the last good copy', function (): void { - $arm64 = str_repeat('a1', 32); +it('keeps the DMG’s SHA-256 when the releases API reports one, in the cache and in the last good copy', function (): void { + $sha256 = str_repeat('a1', 32); - Http::fake([RELEASES_FAKE_GITHUB_LATEST => Http::response(githubReleasePayload(assets: [ - githubAsset('TablePro-0.77.0-arm64.dmg', 22_943_352, digest: "sha256:{$arm64}"), - githubAsset('TablePro-0.77.0-x86_64.dmg', 26_202_607), - ]))]); + Http::fake([RELEASES_FAKE_GITHUB_LATEST => Http::response(githubReleasePayloadWithDigest($sha256))]); $release = macRelease(); - expect($release->assets['arm64']['sha256'])->toBe($arm64) - ->and($release->assets['x86_64']['sha256'])->toBeNull() - ->and($release->toProps('en')['assets']['arm64']['sha256'])->toBe($arm64) - ->and(releases()->lastGood()?->assets['arm64']['sha256'])->toBe($arm64); + expect($release->asset['sha256'])->toBe($sha256) + ->and($release->toProps('en')['asset']['sha256'])->toBe($sha256) + ->and(releases()->lastGood()?->asset['sha256'])->toBe($sha256); }); it('states no checksum for a digest that is not a SHA-256', function (mixed $digest): void { Http::fake([RELEASES_FAKE_GITHUB_LATEST => Http::response(githubReleasePayload(assets: [ - [...githubAsset('TablePro-0.77.0-arm64.dmg', 22_943_352), 'digest' => $digest], - githubAsset('TablePro-0.77.0-x86_64.dmg', 26_202_607), + [...githubAsset('TablePro-0.77.0.dmg', 65_432_179), 'digest' => $digest], ]))]); $release = macRelease(); expect($release->source)->toBe(MacRelease::SOURCE_GITHUB) - ->and($release->assets['arm64']['sha256'])->toBeNull(); + ->and($release->asset['sha256'])->toBeNull(); })->with([ 'another algorithm' => ['sha512:' . str_repeat('ab', 64)], 'too short' => ['sha256:abc123'], @@ -103,19 +96,31 @@ function githubApiCalls(): int 'not a string' => [42], ]); -it('reads a copy stored before checksums were kept', function (): void { - $asset = ['name' => 'TablePro-0.77.0-arm64.dmg', 'url' => 'https://github.com/TableProApp/TablePro/releases/download/v0.77.0/TablePro-0.77.0-arm64.dmg', 'bytes' => 1]; - +it('reads a copy stored without a checksum', function (): void { $release = MacRelease::fromArray([ 'version' => '0.77.0', 'publishedAt' => '2026-10-02', - 'assets' => ['arm64' => $asset, 'x86_64' => $asset], + 'asset' => ['name' => 'TablePro-0.77.0.dmg', 'url' => 'https://github.com/TableProApp/TablePro/releases/download/v0.77.0/TablePro-0.77.0.dmg', 'bytes' => 1], 'releaseUrl' => 'https://github.com/TableProApp/TablePro/releases/tag/v0.77.0', 'releasesUrl' => 'https://github.com/TableProApp/TablePro/releases', 'source' => MacRelease::SOURCE_GITHUB, ]); - expect($release?->assets['arm64']['sha256'])->toBeNull(); + expect($release?->asset['sha256'])->toBeNull(); +}); + +// The copy stored before 0.82.0 pointed at a DMG per architecture; it is refreshed, never served. +it('ignores a copy stored with a DMG per architecture', function (): void { + $asset = ['name' => 'TablePro-0.81.0-arm64.dmg', 'url' => 'https://github.com/TableProApp/TablePro/releases/download/v0.81.0/TablePro-0.81.0-arm64.dmg', 'bytes' => 1, 'sha256' => null]; + + expect(MacRelease::fromArray([ + 'version' => '0.81.0', + 'publishedAt' => '2026-10-16', + 'assets' => ['arm64' => $asset, 'x86_64' => $asset], + 'releaseUrl' => 'https://github.com/TableProApp/TablePro/releases/tag/v0.81.0', + 'releasesUrl' => 'https://github.com/TableProApp/TablePro/releases', + 'source' => MacRelease::SOURCE_GITHUB, + ]))->toBeNull(); }); it('formats the release date for the reader in PHP, in each locale', function (): void { @@ -142,16 +147,20 @@ function githubApiCalls(): int ->and($release->version)->toBe('0.77.0'); }); -it('rejects an app release missing one architecture', function (): void { +it('rejects an app release without the universal DMG', function (array $assets): void { Http::fake([ - RELEASES_FAKE_GITHUB_LATEST => Http::response(githubReleasePayload(assets: [ - githubAsset('TablePro-0.77.0-arm64.dmg', 22_943_352), - ])), + RELEASES_FAKE_GITHUB_LATEST => Http::response(githubReleasePayload(assets: $assets)), RELEASES_FAKE_APPCAST => Http::response(appcastXml('0.76.1')), ]); expect(macRelease())->source->toBe(MacRelease::SOURCE_APPCAST)->version->toBe('0.76.1'); -}); +})->with([ + 'a DMG per architecture' => [[ + githubAsset('TablePro-0.77.0-arm64.dmg', 22_943_352), + githubAsset('TablePro-0.77.0-x86_64.dmg', 26_202_607), + ]], + 'only the update archive' => [[githubAsset('TablePro-0.77.0.tar.xz', 41_754_460)]], +]); it('rejects an asset still uploading, a draft and a prerelease', function (array $payload): void { Http::fake([ @@ -162,15 +171,14 @@ function githubApiCalls(): int expect(macRelease()->source)->toBe(MacRelease::SOURCE_APPCAST); })->with([ 'uploading' => [githubReleasePayload(assets: [ - githubAsset('TablePro-0.77.0-arm64.dmg', 22_943_352), - [...githubAsset('TablePro-0.77.0-x86_64.dmg', 0), 'state' => 'new'], + [...githubAsset('TablePro-0.77.0.dmg', 0), 'state' => 'new'], ])], 'draft' => [githubReleasePayload(overrides: ['draft' => true])], 'prerelease' => [githubReleasePayload(overrides: ['prerelease' => true])], 'not a version tag' => [githubReleasePayload('nightly')], ]); -it('builds the DMG links from platforms.json when it falls back to the appcast', function (): void { +it('builds the DMG link from platforms.json when it falls back to the appcast', function (): void { Http::fake([ RELEASES_FAKE_GITHUB_LATEST => Http::response(['message' => 'API rate limit exceeded'], 403), RELEASES_FAKE_APPCAST => Http::response(appcastXml('0.77.0', 'Fri, 02 Oct 2026 04:15:25 +0000')), @@ -179,20 +187,20 @@ function githubApiCalls(): int $release = macRelease(); expect($release->source)->toBe(MacRelease::SOURCE_APPCAST) + ->and($release->version)->toBe('0.77.0') ->and($release->publishedAt)->toBe('2026-10-02') - ->and($release->assets['arm64'])->toBe([ - 'name' => 'TablePro-0.77.0-arm64.dmg', - 'url' => 'https://github.com/TableProApp/TablePro/releases/download/v0.77.0/TablePro-0.77.0-arm64.dmg', + ->and($release->asset)->toBe([ + 'name' => 'TablePro-0.77.0.dmg', + 'url' => 'https://github.com/TableProApp/TablePro/releases/download/v0.77.0/TablePro-0.77.0.dmg', 'bytes' => null, 'sha256' => null, ]) - ->and($release->assets['x86_64']['url'])->toBe('https://github.com/TableProApp/TablePro/releases/download/v0.77.0/TablePro-0.77.0-x86_64.dmg') ->and($release->releaseUrl)->toBe('https://github.com/TableProApp/TablePro/releases/tag/v0.77.0'); }); -it('takes the fallback DMG links from the dmg destination’s urlTemplate when platforms.json has one', function (): void { +it('takes the fallback DMG link from the dmg destination’s urlTemplate when platforms.json has one', function (): void { bindReleaseFixturePlatforms(['mac.destinations' => [ - ['kind' => 'dmg', 'urlTemplate' => 'https://mirror.example/tablepro/v{version}/TablePro-{version}-{arch}.dmg'], + ['kind' => 'dmg', 'urlTemplate' => 'https://mirror.example/tablepro/v{version}/TablePro-{version}.dmg'], ['kind' => 'releases', 'url' => 'https://github.com/TableProApp/TablePro/releases'], ]]); Http::fake([ @@ -203,8 +211,7 @@ function githubApiCalls(): int $release = macRelease(); expect($release->source)->toBe(MacRelease::SOURCE_APPCAST) - ->and($release->assets['arm64']['url'])->toBe('https://mirror.example/tablepro/v0.77.0/TablePro-0.77.0-arm64.dmg') - ->and($release->assets['x86_64']['url'])->toBe('https://mirror.example/tablepro/v0.77.0/TablePro-0.77.0-x86_64.dmg'); + ->and($release->asset['url'])->toBe('https://mirror.example/tablepro/v0.77.0/TablePro-0.77.0.dmg'); }); it('answers a page from what the last refresh stored, without calling anything', function (): void { @@ -246,10 +253,7 @@ function githubApiCalls(): int it('serves the last good copy while the stored one is missing, and refreshes after the response', function (): void { Http::fakeSequence(RELEASES_FAKE_GITHUB_LATEST) - ->push(githubReleasePayload('v0.76.1', assets: [ - githubAsset('TablePro-0.76.1-arm64.dmg', 1, 'v0.76.1'), - githubAsset('TablePro-0.76.1-x86_64.dmg', 2, 'v0.76.1'), - ])) + ->push(githubReleasePayload('v0.76.1')) ->push(githubReleasePayload()); expect(macRelease()->version)->toBe('0.76.1'); @@ -294,10 +298,7 @@ function githubApiCalls(): int it('serves the last good copy when both sources fail, and pages stop asking for 15 minutes', function (): void { Http::fakeSequence(RELEASES_FAKE_GITHUB_LATEST) - ->push(githubReleasePayload('v0.76.1', assets: [ - githubAsset('TablePro-0.76.1-arm64.dmg', 1, 'v0.76.1'), - githubAsset('TablePro-0.76.1-x86_64.dmg', 2, 'v0.76.1'), - ])) + ->push(githubReleasePayload('v0.76.1')) ->whenEmpty(Http::response('', 502)); Http::fake([RELEASES_FAKE_APPCAST => Http::response('', 503)]); @@ -308,7 +309,7 @@ function githubApiCalls(): int $stale = servePage(); expect($stale->version)->toBe('0.76.1') - ->and($stale->assets['arm64']['url'])->toEndWith('/TablePro-0.76.1-arm64.dmg') + ->and($stale->asset['url'])->toEndWith('/TablePro-0.76.1.dmg') ->and(releases()->failing())->toBeTrue(); $calls = githubApiCalls(); @@ -357,7 +358,7 @@ function githubApiCalls(): int expect($release->source)->toBe(MacRelease::SOURCE_GITHUB) ->and($release->version)->toBe('0.77.0') - ->and($release->assets['arm64']['url'])->toEndWith('/TablePro-0.77.0-arm64.dmg') + ->and($release->asset['url'])->toEndWith('/TablePro-0.77.0.dmg') ->and(Storage::disk(MacReleaseService::LAST_GOOD_DISK)->exists(MacReleaseService::LAST_GOOD_FILE))->toBeTrue(); }); @@ -371,7 +372,7 @@ function githubApiCalls(): int expect(macRelease()->source)->toBe(MacRelease::SOURCE_UNAVAILABLE); }); -it('is unavailable when nothing ever answered: no version, and both buttons open the latest release page', function (): void { +it('is unavailable when nothing ever answered: no version, and the button opens the latest release page', function (): void { Http::fake([ RELEASES_FAKE_GITHUB_LATEST => Http::failedConnection(), RELEASES_FAKE_APPCAST => Http::failedConnection(), @@ -383,8 +384,7 @@ function githubApiCalls(): int ->and($release->isAvailable())->toBeFalse() ->and($release->version)->toBeNull() ->and($release->publishedAt)->toBeNull() - ->and($release->assets['arm64'])->toBe(['name' => null, 'url' => 'https://github.com/TableProApp/TablePro/releases/latest', 'bytes' => null, 'sha256' => null]) - ->and($release->assets['x86_64']['url'])->toBe('https://github.com/TableProApp/TablePro/releases/latest') + ->and($release->asset)->toBe(['name' => null, 'url' => 'https://github.com/TableProApp/TablePro/releases/latest', 'bytes' => null, 'sha256' => null]) ->and($release->toProps('en')['publishedAtFormatted'])->toBeNull(); }); @@ -422,10 +422,7 @@ function githubApiCalls(): int it('fails the scheduled refresh when no source answers, and keeps serving the last good copy', function (): void { Http::fakeSequence(RELEASES_FAKE_GITHUB_LATEST) - ->push(githubReleasePayload('v0.76.1', assets: [ - githubAsset('TablePro-0.76.1-arm64.dmg', 1, 'v0.76.1'), - githubAsset('TablePro-0.76.1-x86_64.dmg', 2, 'v0.76.1'), - ])) + ->push(githubReleasePayload('v0.76.1')) ->whenEmpty(Http::response('', 502)); Http::fake([RELEASES_FAKE_APPCAST => Http::response('', 503)]); @@ -461,7 +458,7 @@ function githubApiCalls(): int expect(githubApiCalls())->toBe(0); }); -it('reads only the top appcast item, and ignores a feed it cannot parse', function (): void { +it('reads the stable appcast item, skipping a newer beta above it, and ignores a feed it cannot parse', function (): void { expect(SparkleAppcast::parse(appcastXml('0.77.0', 'Fri, 02 Oct 2026 04:15:25 +0000', '13.0')))->toBe([ 'version' => '0.77.0', 'publishedAt' => '2026-10-02', @@ -474,7 +471,17 @@ function githubApiCalls(): int ->and(SparkleAppcast::parse('latest'))->toBeNull(); }); +it('picks the stable item with the highest build, wherever it sits in the feed', function (): void { + $item = fn(string $version, int $build, string $extra = ''): string => "{$version}{$build}{$version}{$extra}"; + $feed = fn(string ...$items): string => '' . implode('', $items) . ''; + + // A patch on an older line can be published after the newer stable, and lands above it. + expect(SparkleAppcast::parse($feed($item('0.81.1', 810199), $item('0.82.0', 820099), $item('0.81.0', 810099)))['version'] ?? null)->toBe('0.82.0') + ->and(SparkleAppcast::parse($feed($item('0.83.0-beta.1', 830001, 'beta'), $item('0.82.0', 820099)))['version'] ?? null)->toBe('0.82.0') + ->and(SparkleAppcast::parse($feed($item('0.83.0', 830099, 'beta'))))->toBeNull() + ->and(SparkleAppcast::parse($feed($item('0.82.0', 820099), "0.83.00.83.0"))['version'] ?? null)->toBe('0.82.0'); +}); + it('reads the appcast from the feed the app itself uses', function (): void { - expect(app(SparkleAppcast::class)->feedUrl()) - ->toBe('https://raw.githubusercontent.com/TableProApp/TablePro/main/appcast.xml'); + expect(app(SparkleAppcast::class)->feedUrl())->toBe('https://updates.tablepro.app/appcast.xml'); }); diff --git a/tests/Feature/Releases/ReleaseFixtures.php b/tests/Feature/Releases/ReleaseFixtures.php index 23985b03..abfdc2c7 100644 --- a/tests/Feature/Releases/ReleaseFixtures.php +++ b/tests/Feature/Releases/ReleaseFixtures.php @@ -5,7 +5,7 @@ const RELEASES_FAKE_GITHUB_LATEST = 'api.github.com/repos/TableProApp/TablePro/releases/latest'; -const RELEASES_FAKE_APPCAST = 'raw.githubusercontent.com/TableProApp/TablePro/main/appcast.xml'; +const RELEASES_FAKE_APPCAST = 'updates.tablepro.app/appcast.xml'; /** * @param array $overrides @@ -20,11 +20,9 @@ function bindReleaseFixturePlatforms(array $overrides = []): array 'status' => 'released', 'deviceNames' => ['Mac'], 'requirements' => ['systems' => ['macOS'], 'minVersion' => '13.0', 'displayVersion' => '13', 'releaseName' => 'Ventura'], - 'architectures' => [ - ['id' => 'arm64', 'assetTemplate' => 'TablePro-{version}-arm64.dmg'], - ['id' => 'x86_64', 'assetTemplate' => 'TablePro-{version}-x86_64.dmg'], - ], - 'universalBinary' => false, + 'architectures' => ['arm64', 'x86_64'], + 'universalBinary' => true, + 'assetTemplate' => 'TablePro-{version}.dmg', 'destinations' => [ ['kind' => 'dmg'], ['kind' => 'homebrew', 'command' => 'brew install --cask tablepro'], @@ -79,10 +77,10 @@ function githubReleasePayload(string $tag = 'v0.77.0', array $overrides = [], ?a 'html_url' => "https://github.com/TableProApp/TablePro/releases/tag/{$tag}", 'published_at' => '2026-10-02T04:15:45Z', 'assets' => $assets ?? [ - githubAsset("TablePro-{$version}-arm64.dmg", 22_943_352), - githubAsset("TablePro-{$version}-x86_64.dmg", 26_202_607), - githubAsset("TablePro-{$version}-arm64.zip", 31_475_263), - githubAsset("TablePro-{$version}-x86_64.zip", 33_766_590), + githubAsset("OracleDriver-{$version}.zip", 10_224_221, $tag), + githubAsset("TablePro-{$version}-dSYMs.zip", 182_735_436, $tag), + githubAsset("TablePro-{$version}.dmg", 65_432_179, $tag), + githubAsset("TablePro-{$version}.tar.xz", 41_754_460, $tag), ], ...$overrides, ]; @@ -105,42 +103,57 @@ function githubAsset(string $name, int $size, string $tag = 'v0.77.0', ?string $ /** * @return array */ -function githubReleasePayloadWithDigests(string $arm64, string $x86_64): array +function githubReleasePayloadWithDigest(string $sha256): array { return githubReleasePayload(assets: [ - githubAsset('TablePro-0.77.0-arm64.dmg', 22_943_352, digest: "sha256:{$arm64}"), - githubAsset('TablePro-0.77.0-x86_64.dmg', 26_202_607, digest: "sha256:{$x86_64}"), + githubAsset('TablePro-0.77.0.dmg', 65_432_179, digest: "sha256:{$sha256}"), ]); } +/** + * The app's build number for a stable version: `0.82.0` is 820099. + */ +function appcastBuild(string $version): int +{ + [$major, $minor, $patch] = array_map('intval', explode('.', $version . '.0.0')); + + return (($major * 1000 + $minor) * 100 + $patch) * 100 + 99; +} + +/** + * The feed as the release workflow writes it: a newer beta on top, then the + * stable `$version`, then an older stable. + */ function appcastXml(string $version = '0.77.0', string $pubDate = 'Fri, 02 Oct 2026 04:15:25 +0000', string $minimum = '13.0'): string { + $build = appcastBuild($version); + return << TablePro - {$version} - {$pubDate} - 134 - {$version} - {$minimum} - arm64 - + 0.99.0-beta.1 + Sat, 03 Oct 2026 04:15:25 +0000 + beta + 990001 + 0.99.0-beta.1 + 14.0 + {$version} {$pubDate} - 134 + {$build} {$version} {$minimum} - + 0.76.1 Mon, 28 Sep 2026 22:46:49 +0000 - 133 + 760199 0.76.1 13.0 diff --git a/tests/js/compare-model.test.ts b/tests/js/compare-model.test.ts index adfde001..35908431 100644 --- a/tests/js/compare-model.test.ts +++ b/tests/js/compare-model.test.ts @@ -52,6 +52,7 @@ const facts: TableProFacts = { macRequirement: 'macOS 13 Ventura or later', iosRequirement: 'iOS and iPadOS 18 or later', macArchitectures: ['arm64', 'x86_64'], + macUniversal: true, starter: { ...pricing.tiers.starter.prices, activations: pricing.tiers.starter.activations }, team: { ...pricing.tiers.team.prices, minSeats: pricing.tiers.team.seats.min }, iosFree: true, @@ -228,5 +229,14 @@ test('calls a single app for both architectures universal, and keeps "separate b } assert.ok(lines('dbeaver').includes(hub.en.labels.builds.both), 'DBeaver ships a DMG per architecture'); + + const ours = (macUniversal: boolean) => + glanceRows(product('dbeaver'), comparisons.rows, { ...facts, macUniversal }, context('en')) + .find((row) => row.key === 'platforms') + ?.tablepro?.lines.map((line) => line.text) ?? []; + + assert.ok(ours(true).includes(hub.en.labels.builds.universal), 'TablePro ships one universal DMG'); + assert.ok(!ours(true).includes(hub.en.labels.builds.both)); + assert.ok(ours(false).includes(hub.en.labels.builds.both)); }); diff --git a/tests/js/device.test.ts b/tests/js/device.test.ts index 6c175608..88e055bc 100644 --- a/tests/js/device.test.ts +++ b/tests/js/device.test.ts @@ -2,15 +2,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; -import { - appStoreFirst, - archFromHint, - archHint, - buildVariant, - chipHelpOpen, - classifyDevice, - type ArchitectureHintSource, -} from '../../resources/js/lib/device.ts'; +import { appStoreFirst, classifyDevice } from '../../resources/js/lib/device.ts'; /* * The download page's device rules (architecture §1.13). Nothing here may make @@ -30,10 +22,6 @@ const UA = { linux: 'Mozilla/5.0 (X11; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0', }; -function hints(architecture: string | undefined): ArchitectureHintSource { - return { getHighEntropyValues: async () => ({ architecture }) }; -} - test('an iPhone is never a Mac, although its user agent says "like Mac OS X"', () => { assert.equal(classifyDevice(UA.iPhone, 5), 'ios'); assert.equal(classifyDevice(UA.iPhone, 0), 'ios'); @@ -60,73 +48,12 @@ test('Windows, Linux and Android are neither', () => { assert.equal(classifyDevice('', 0), 'other'); }); -test('the Chromium architecture hint maps to a build, and nothing else does', () => { - assert.equal(archFromHint('arm'), 'arm64'); - assert.equal(archFromHint('x86'), 'x86_64'); - assert.equal(archFromHint(''), null); - assert.equal(archFromHint('ARM'), null); - assert.equal(archFromHint(undefined), null); - assert.equal(archFromHint(null), null); -}); - -test('Intel Chrome reports x86 and Apple silicon Chrome reports arm', async () => { - assert.equal(await archHint(hints('x86')), 'x86_64'); - assert.equal(await archHint(hints('arm')), 'arm64'); -}); - -test('Safari and Firefox have no hint, so the page does not guess', async () => { - assert.equal(await archHint(undefined), null); - assert.equal(await archHint(null), null); - assert.equal(await archHint({} as ArchitectureHintSource), null); - assert.equal(await archHint(hints(undefined)), null); -}); - -test('a refused or failing hint is no hint', async () => { - const failing: ArchitectureHintSource = { - getHighEntropyValues: async () => { - throw new Error('NotAllowedError'); - }, - }; - - assert.equal(await archHint(failing), null); -}); - -test('only the build a Mac hint names becomes primary', () => { - assert.equal(buildVariant('x86_64', 'mac', 'x86_64'), 'primary'); - assert.equal(buildVariant('arm64', 'mac', 'x86_64'), 'secondary'); - assert.equal(buildVariant('arm64', 'mac', 'arm64'), 'primary'); - assert.equal(buildVariant('x86_64', 'mac', 'arm64'), 'secondary'); -}); - -test('with no hint, both builds stay equal: Safari on an Intel Mac is not pointed at Apple silicon', () => { - assert.equal(buildVariant('arm64', 'mac', null), 'secondary'); - assert.equal(buildVariant('x86_64', 'mac', null), 'secondary'); -}); - -test('on an iPhone, an iPad or another system, neither Mac build is primary', () => { - for (const device of ['ios', 'other'] as const) { - assert.equal(buildVariant('arm64', device, 'arm64'), 'secondary'); - assert.equal(buildVariant('x86_64', device, 'x86_64'), 'secondary'); - assert.equal(buildVariant('arm64', device, null), 'secondary'); - } -}); - test('the App Store card leads only on an iPhone or iPad', () => { assert.equal(appStoreFirst('ios'), true); assert.equal(appStoreFirst('mac'), false); assert.equal(appStoreFirst('other'), false); }); -test('with no hint on a Mac, "Which Mac do I have?" opens; a pending or answered hint leaves it closed', () => { - assert.equal(chipHelpOpen('mac', null), true); - assert.equal(chipHelpOpen('mac', undefined), false); - assert.equal(chipHelpOpen('mac', 'arm64'), false); - assert.equal(chipHelpOpen('mac', 'x86_64'), false); - assert.equal(chipHelpOpen('ios', null), false); - assert.equal(chipHelpOpen('other', null), false); - assert.equal(chipHelpOpen(null, null), false); -}); - /* * The root template marks an iPhone or iPad with an `ios` class before first * paint, which puts the App Store badge first and keeps the license banner off