| author | |
| committer | |
| log | 3c32bc8fd1f5970e65988e36937cb8e2921437f9 |
| tree | c24c888016ba4469a7d38dd014fad5553c6aa62b |
| parent | 6efdf67e157a3c5e88b120dcf6857176534d4aa8 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius@macmini.local>6 files changed, 74 insertions(+), 6 deletions(-)
apps/web/src/hostedPairing.test.ts+12| ... | @@ -79,4 +79,16 @@ describe("hostedPairing", () => { | ... | @@ -79,4 +79,16 @@ describe("hostedPairing", () => { |
| 79 | vi.stubEnv("VITE_HTTP_URL", "https://backend.example.com"); | 79 | vi.stubEnv("VITE_HTTP_URL", "https://backend.example.com"); |
| 80 | expect(isHostedStaticApp(new URL("https://preview.t3.codes/"))).toBe(false); | 80 | expect(isHostedStaticApp(new URL("https://preview.t3.codes/"))).toBe(false); |
| 81 | }); | 81 | }); |
| 82 | |||
| 83 | it("detects hosted channel aliases as static apps", () => { | ||
| 84 | vi.stubEnv("VITE_HOSTED_APP_URL", "https://app.t3.codes"); | ||
| 85 | vi.stubEnv("VITE_HOSTED_APP_CHANNEL", "nightly"); | ||
| 86 | vi.stubEnv("VITE_HTTP_URL", ""); | ||
| 87 | vi.stubEnv("VITE_WS_URL", ""); | ||
| 88 | |||
| 89 | expect(isHostedStaticApp(new URL("https://nightly.app.t3.codes/"))).toBe(true); | ||
| 90 | |||
| 91 | vi.stubEnv("VITE_HTTP_URL", "https://backend.example.com"); | ||
| 92 | expect(isHostedStaticApp(new URL("https://nightly.app.t3.codes/"))).toBe(false); | ||
| 93 | }); | ||
| 82 | }); | 94 | }); |
apps/web/src/hostedPairing.ts+9| ... | @@ -18,6 +18,11 @@ function configuredBackendUrl(): string { | ... | @@ -18,6 +18,11 @@ function configuredBackendUrl(): string { |
| 18 | return import.meta.env.VITE_HTTP_URL?.trim() || import.meta.env.VITE_WS_URL?.trim() || ""; | 18 | return import.meta.env.VITE_HTTP_URL?.trim() || import.meta.env.VITE_WS_URL?.trim() || ""; |
| 19 | } | 19 | } |
| 20 | 20 | ||
| 21 | function configuredHostedAppChannel(): HostedAppChannel | null { | ||
| 22 | const channel = import.meta.env.VITE_HOSTED_APP_CHANNEL?.trim().toLowerCase(); | ||
| 23 | return channel === "latest" || channel === "nightly" ? channel : null; | ||
| 24 | } | ||
| 25 | |||
| 21 | function originFromUrl(value: string): string | null { | 26 | function originFromUrl(value: string): string | null { |
| 22 | try { | 27 | try { |
| 23 | return new URL(value).origin; | 28 | return new URL(value).origin; |
| ... | @@ -31,6 +36,10 @@ export function isHostedStaticApp(url: URL = new URL(window.location.href)): boo | ... | @@ -31,6 +36,10 @@ export function isHostedStaticApp(url: URL = new URL(window.location.href)): boo |
| 31 | return false; | 36 | return false; |
| 32 | } | 37 | } |
| 33 | 38 | ||
| 39 | if (configuredHostedAppChannel()) { | ||
| 40 | return true; | ||
| 41 | } | ||
| 42 | |||
| 34 | const hostedOrigin = originFromUrl(configuredHostedAppUrl()); | 43 | const hostedOrigin = originFromUrl(configuredHostedAppUrl()); |
| 35 | return hostedOrigin !== null && url.origin === hostedOrigin; | 44 | return hostedOrigin !== null && url.origin === hostedOrigin; |
| 36 | } | 45 | } |
apps/web/vercel.ts+1-1| ... | @@ -18,7 +18,7 @@ function channelCookie(channel: "latest" | "nightly"): string { | ... | @@ -18,7 +18,7 @@ function channelCookie(channel: "latest" | "nightly"): string { |
| 18 | 18 | ||
| 19 | export const config: VercelConfig = { | 19 | export const config: VercelConfig = { |
| 20 | buildCommand: | 20 | buildCommand: |
| 21 | "turbo build --filter @t3tools/web && bun ../../scripts/apply-web-brand-assets.ts production", | 21 | 'turbo build --filter @t3tools/web && bun ../../scripts/apply-web-brand-assets.ts --channel "${VITE_HOSTED_APP_CHANNEL:-latest}"', |
| 22 | git: { | 22 | git: { |
| 23 | deploymentEnabled: false, | 23 | deploymentEnabled: false, |
| 24 | }, | 24 | }, |
scripts/apply-web-brand-assets.ts+20-4| ... | @@ -6,11 +6,17 @@ import * as Effect from "effect/Effect"; | ... | @@ -6,11 +6,17 @@ import * as Effect from "effect/Effect"; |
| 6 | import * as FileSystem from "effect/FileSystem"; | 6 | import * as FileSystem from "effect/FileSystem"; |
| 7 | import * as Option from "effect/Option"; | 7 | import * as Option from "effect/Option"; |
| 8 | import * as Path from "effect/Path"; | 8 | import * as Path from "effect/Path"; |
| 9 | import { Argument, Command } from "effect/unstable/cli"; | 9 | import { Argument, Command, Flag } from "effect/unstable/cli"; |
| 10 | import { resolveWebIconOverrides, type WebAssetBrand } from "./lib/brand-assets.ts"; | 10 | import { |
| 11 | resolveWebAssetBrandForChannel, | ||
| 12 | resolveWebIconOverrides, | ||
| 13 | WEB_ASSET_CHANNELS, | ||
| 14 | type WebAssetBrand, | ||
| 15 | } from "./lib/brand-assets.ts"; | ||
| 11 | 16 | ||
| 12 | const WEB_ASSET_BRANDS = [ | 17 | const WEB_ASSET_BRANDS = [ |
| 13 | "development", | 18 | "development", |
| 19 | "nightly", | ||
| 14 | "production", | 20 | "production", |
| 15 | ] as const satisfies ReadonlyArray<WebAssetBrand>; | 21 | ] as const satisfies ReadonlyArray<WebAssetBrand>; |
| 16 | 22 | ||
| ... | @@ -38,15 +44,25 @@ export const applyWebBrandAssetsCommand = Command.make( | ... | @@ -38,15 +44,25 @@ export const applyWebBrandAssetsCommand = Command.make( |
| 38 | { | 44 | { |
| 39 | brand: Argument.choice("brand", WEB_ASSET_BRANDS).pipe( | 45 | brand: Argument.choice("brand", WEB_ASSET_BRANDS).pipe( |
| 40 | Argument.withDescription("Asset brand to copy into the hosted web output directory."), | 46 | Argument.withDescription("Asset brand to copy into the hosted web output directory."), |
| 47 | Argument.optional, | ||
| 48 | ), | ||
| 49 | channel: Flag.choice("channel", WEB_ASSET_CHANNELS).pipe( | ||
| 50 | Flag.withDescription("Hosted release channel to map to a web asset brand."), | ||
| 51 | Flag.optional, | ||
| 41 | ), | 52 | ), |
| 42 | targetDirectory: Argument.string("target-directory").pipe( | 53 | targetDirectory: Argument.string("target-directory").pipe( |
| 43 | Argument.withDescription("Output directory that contains the hosted web build assets."), | 54 | Argument.withDescription("Output directory that contains the hosted web build assets."), |
| 44 | Argument.optional, | 55 | Argument.optional, |
| 45 | ), | 56 | ), |
| 46 | }, | 57 | }, |
| 47 | ({ brand, targetDirectory }) => | 58 | ({ brand, channel, targetDirectory }) => |
| 48 | applyWebBrandAssets( | 59 | applyWebBrandAssets( |
| 49 | brand, | 60 | Option.getOrElse(brand, () => |
| 61 | Option.match(channel, { | ||
| 62 | onNone: () => "production" as const, | ||
| 63 | onSome: resolveWebAssetBrandForChannel, | ||
| 64 | }), | ||
| 65 | ), | ||
| 50 | Option.getOrElse(targetDirectory, () => "apps/web/dist"), | 66 | Option.getOrElse(targetDirectory, () => "apps/web/dist"), |
| 51 | ), | 67 | ), |
| 52 | ).pipe(Command.withDescription("Copy web brand assets into a built hosted web app.")); | 68 | ).pipe(Command.withDescription("Copy web brand assets into a built hosted web app.")); |
scripts/lib/brand-assets.test.ts+13| ... | @@ -4,6 +4,7 @@ import { | ... | @@ -4,6 +4,7 @@ import { |
| 4 | BRAND_ASSET_PATHS, | 4 | BRAND_ASSET_PATHS, |
| 5 | DEVELOPMENT_ICON_OVERRIDES, | 5 | DEVELOPMENT_ICON_OVERRIDES, |
| 6 | PUBLISH_ICON_OVERRIDES, | 6 | PUBLISH_ICON_OVERRIDES, |
| 7 | resolveWebAssetBrandForChannel, | ||
| 7 | resolveWebIconOverrides, | 8 | resolveWebIconOverrides, |
| 8 | } from "./brand-assets.ts"; | 9 | } from "./brand-assets.ts"; |
| 9 | 10 | ||
| ... | @@ -42,4 +43,16 @@ describe("brand-assets", () => { | ... | @@ -42,4 +43,16 @@ describe("brand-assets", () => { |
| 42 | targetRelativePath: "apps/web/dist/apple-touch-icon.png", | 43 | targetRelativePath: "apps/web/dist/apple-touch-icon.png", |
| 43 | }); | 44 | }); |
| 44 | }); | 45 | }); |
| 46 | |||
| 47 | it("maps hosted nightly web assets to nightly icons", () => { | ||
| 48 | expect(resolveWebIconOverrides("nightly", "apps/web/dist")).toContainEqual({ | ||
| 49 | sourceRelativePath: BRAND_ASSET_PATHS.nightlyWebFaviconIco, | ||
| 50 | targetRelativePath: "apps/web/dist/favicon.ico", | ||
| 51 | }); | ||
| 52 | }); | ||
| 53 | |||
| 54 | it("maps hosted release channels to web asset brands", () => { | ||
| 55 | expect(resolveWebAssetBrandForChannel("latest")).toBe("production"); | ||
| 56 | expect(resolveWebAssetBrandForChannel("nightly")).toBe("nightly"); | ||
| 57 | }); | ||
| 45 | }); | 58 | }); |
scripts/lib/brand-assets.ts+19-1| ... | @@ -10,6 +10,10 @@ export const BRAND_ASSET_PATHS = { | ... | @@ -10,6 +10,10 @@ export const BRAND_ASSET_PATHS = { |
| 10 | nightlyMacIconPng: "assets/nightly/blueprint-macos-1024.png", | 10 | nightlyMacIconPng: "assets/nightly/blueprint-macos-1024.png", |
| 11 | nightlyLinuxIconPng: "assets/nightly/blueprint-universal-1024.png", | 11 | nightlyLinuxIconPng: "assets/nightly/blueprint-universal-1024.png", |
| 12 | nightlyWindowsIconIco: "assets/nightly/blueprint-windows.ico", | 12 | nightlyWindowsIconIco: "assets/nightly/blueprint-windows.ico", |
| 13 | nightlyWebFaviconIco: "assets/nightly/blueprint-web-favicon.ico", | ||
| 14 | nightlyWebFavicon16Png: "assets/nightly/blueprint-web-favicon-16x16.png", | ||
| 15 | nightlyWebFavicon32Png: "assets/nightly/blueprint-web-favicon-32x32.png", | ||
| 16 | nightlyWebAppleTouchIconPng: "assets/nightly/blueprint-web-apple-touch-180.png", | ||
| 13 | 17 | ||
| 14 | developmentDesktopIconPng: "assets/dev/blueprint-macos-1024.png", | 18 | developmentDesktopIconPng: "assets/dev/blueprint-macos-1024.png", |
| 15 | developmentWindowsIconIco: "assets/dev/blueprint-windows.ico", | 19 | developmentWindowsIconIco: "assets/dev/blueprint-windows.ico", |
| ... | @@ -19,7 +23,15 @@ export const BRAND_ASSET_PATHS = { | ... | @@ -19,7 +23,15 @@ export const BRAND_ASSET_PATHS = { |
| 19 | developmentWebAppleTouchIconPng: "assets/dev/blueprint-web-apple-touch-180.png", | 23 | developmentWebAppleTouchIconPng: "assets/dev/blueprint-web-apple-touch-180.png", |
| 20 | } as const; | 24 | } as const; |
| 21 | 25 | ||
| 22 | export type WebAssetBrand = "development" | "production"; | 26 | export type WebAssetBrand = "development" | "nightly" | "production"; |
| 27 | |||
| 28 | export const WEB_ASSET_CHANNELS = ["latest", "nightly"] as const; | ||
| 29 | |||
| 30 | export type WebAssetChannel = (typeof WEB_ASSET_CHANNELS)[number]; | ||
| 31 | |||
| 32 | export function resolveWebAssetBrandForChannel(channel: WebAssetChannel): WebAssetBrand { | ||
| 33 | return channel === "nightly" ? "nightly" : "production"; | ||
| 34 | } | ||
| 23 | 35 | ||
| 24 | export interface IconOverride { | 36 | export interface IconOverride { |
| 25 | readonly sourceRelativePath: string; | 37 | readonly sourceRelativePath: string; |
| ... | @@ -40,6 +52,12 @@ const WEB_ICON_SOURCE_PATHS_BY_BRAND = { | ... | @@ -40,6 +52,12 @@ const WEB_ICON_SOURCE_PATHS_BY_BRAND = { |
| 40 | favicon32Png: BRAND_ASSET_PATHS.developmentWebFavicon32Png, | 52 | favicon32Png: BRAND_ASSET_PATHS.developmentWebFavicon32Png, |
| 41 | appleTouchIconPng: BRAND_ASSET_PATHS.developmentWebAppleTouchIconPng, | 53 | appleTouchIconPng: BRAND_ASSET_PATHS.developmentWebAppleTouchIconPng, |
| 42 | }, | 54 | }, |
| 55 | nightly: { | ||
| 56 | faviconIco: BRAND_ASSET_PATHS.nightlyWebFaviconIco, | ||
| 57 | favicon16Png: BRAND_ASSET_PATHS.nightlyWebFavicon16Png, | ||
| 58 | favicon32Png: BRAND_ASSET_PATHS.nightlyWebFavicon32Png, | ||
| 59 | appleTouchIconPng: BRAND_ASSET_PATHS.nightlyWebAppleTouchIconPng, | ||
| 60 | }, | ||
| 43 | production: { | 61 | production: { |
| 44 | faviconIco: BRAND_ASSET_PATHS.productionWebFaviconIco, | 62 | faviconIco: BRAND_ASSET_PATHS.productionWebFaviconIco, |
| 45 | favicon16Png: BRAND_ASSET_PATHS.productionWebFavicon16Png, | 63 | favicon16Png: BRAND_ASSET_PATHS.productionWebFavicon16Png, |