diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index 04b1bac77fa9..3a42348381de 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -201,13 +201,37 @@ describe("getDesktopUpdateActionError", () => { describe("desktop update UI helpers", () => { it("builds the stable release URL for a downloaded version", () => { - expect(getDesktopUpdateReleaseUrl("0.0.30")).toBe( + expect(getDesktopUpdateReleaseUrl("0.0.30", "pingdotgg/t3code")).toBe( "https://github.com/pingdotgg/t3code/releases/tag/v0.0.30", ); }); + it("uses the configured desktop update repository for release URLs", () => { + expect(getDesktopUpdateReleaseUrl("1.2.3", "JustYannicc/t3code")).toBe( + "https://github.com/JustYannicc/t3code/releases/tag/v1.2.3", + ); + expect(getDesktopUpdateReleaseHistoryUrl("JustYannicc/t3code")).toBe( + "https://github.com/JustYannicc/t3code/releases", + ); + }); + + it.each([ + "owner/repo/extra", + "owner#notes/repo", + "owner/repo#notes", + "owner?tab=notes/repo", + "owner/repo?tab=notes", + ])("falls back to upstream for an invalid configured repository: %s", (repository) => { + expect(getDesktopUpdateReleaseHistoryUrl(repository)).toBe( + "https://github.com/pingdotgg/t3code/releases", + ); + expect(getDesktopUpdateReleaseUrl("1.2.3", repository)).toBe( + "https://github.com/pingdotgg/t3code/releases/tag/v1.2.3", + ); + }); + it("builds the nightly release URL without dropping its version suffix", () => { - expect(getDesktopUpdateReleaseUrl("0.0.30-nightly.20260728.931")).toBe( + expect(getDesktopUpdateReleaseUrl("0.0.30-nightly.20260728.931", "pingdotgg/t3code")).toBe( "https://github.com/pingdotgg/t3code/releases/tag/v0.0.30-nightly.20260728.931", ); }); @@ -218,7 +242,7 @@ describe("desktop update UI helpers", () => { }); it("builds the release history URL", () => { - expect(getDesktopUpdateReleaseHistoryUrl()).toBe( + expect(getDesktopUpdateReleaseHistoryUrl("pingdotgg/t3code")).toBe( "https://github.com/pingdotgg/t3code/releases", ); }); diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index aa7411e70b2f..bbef4bf5a4e3 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -2,8 +2,24 @@ import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/con export type DesktopUpdateButtonAction = "download" | "install" | "none"; -const DESKTOP_RELEASE_HISTORY_URL = "https://github.com/pingdotgg/t3code/releases"; -const DESKTOP_RELEASE_TAG_URL = `${DESKTOP_RELEASE_HISTORY_URL}/tag`; +const DEFAULT_DESKTOP_RELEASE_REPOSITORY = "pingdotgg/t3code"; +const GITHUB_REPOSITORY_SEGMENT = /^[A-Za-z0-9._-]+$/u; + +function isValidRepositorySegment(value: string | undefined): value is string { + return ( + value !== undefined && value !== "." && value !== ".." && GITHUB_REPOSITORY_SEGMENT.test(value) + ); +} + +function desktopReleaseRepositoryUrl(repository: string | undefined): string { + const configuredRepository = repository?.trim() ?? ""; + const [owner, name, ...extra] = configuredRepository.split("/"); + const releaseRepository = + isValidRepositorySegment(owner) && isValidRepositorySegment(name) && extra.length === 0 + ? configuredRepository + : DEFAULT_DESKTOP_RELEASE_REPOSITORY; + return `https://github.com/${releaseRepository}/releases`; +} /** * The main process fills `downloadedVersion` from the updater's `update-downloaded` @@ -14,14 +30,19 @@ export function getDesktopUpdateDownloadedVersion(state: DesktopUpdateState): st return state.downloadedVersion ?? state.availableVersion; } -export function getDesktopUpdateReleaseUrl(version: string | null): string | null { +export function getDesktopUpdateReleaseUrl( + version: string | null, + repository: string | undefined = import.meta.env.T3CODE_DESKTOP_UPDATE_REPOSITORY, +): string | null { const normalizedVersion = version?.trim(); if (!normalizedVersion) return null; - return `${DESKTOP_RELEASE_TAG_URL}/v${encodeURIComponent(normalizedVersion)}`; + return `${desktopReleaseRepositoryUrl(repository)}/tag/v${encodeURIComponent(normalizedVersion)}`; } -export function getDesktopUpdateReleaseHistoryUrl(): string { - return DESKTOP_RELEASE_HISTORY_URL; +export function getDesktopUpdateReleaseHistoryUrl( + repository: string | undefined = import.meta.env.T3CODE_DESKTOP_UPDATE_REPOSITORY, +): string { + return desktopReleaseRepositoryUrl(repository); } export function resolveDesktopUpdateButtonAction( diff --git a/apps/web/src/components/desktopUpdate.toast.test.tsx b/apps/web/src/components/desktopUpdate.toast.test.tsx index 76e6a9a01af7..af95240e7f90 100644 --- a/apps/web/src/components/desktopUpdate.toast.test.tsx +++ b/apps/web/src/components/desktopUpdate.toast.test.tsx @@ -1,5 +1,5 @@ import { isValidElement, type ReactElement, type ReactNode } from "react"; -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import type { DesktopUpdateState } from "@t3tools/contracts"; const testState = vi.hoisted(() => ({ @@ -64,6 +64,24 @@ describe("showDesktopUpdateDownloadedToast", () => { testState.addToast.mockReset(); }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("opens release notes from the configured repository", async () => { + vi.stubEnv("T3CODE_DESKTOP_UPDATE_REPOSITORY", "JustYannicc/t3code"); + const openExternal = vi.fn().mockResolvedValue(true); + + showDesktopUpdateDownloadedToast({ openExternal }, downloadedState()); + findReleaseNotesLink(getDescription())?.props.onClick?.(); + + await vi.waitFor(() => { + expect(openExternal).toHaveBeenCalledWith( + "https://github.com/JustYannicc/t3code/releases/tag/v0.0.30", + ); + }); + }); + it("opens the downloaded version's release notes", async () => { const openExternal = vi.fn().mockResolvedValue(true); diff --git a/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.test.tsx b/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.test.tsx index d229e2d3cabb..540eff3c6655 100644 --- a/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.test.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.test.tsx @@ -1,6 +1,6 @@ import type { DesktopUpdateState } from "@t3tools/contracts"; import { isValidElement, type MouseEvent, type ReactElement, type ReactNode } from "react"; -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; const testState = vi.hoisted(() => ({ addToast: vi.fn(), @@ -74,6 +74,26 @@ describe("SidebarUpdateReleaseNotes", () => { testState.addToast.mockReset(); }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("uses the configured repository for release links", () => { + vi.stubEnv("T3CODE_DESKTOP_UPDATE_REPOSITORY", "JustYannicc/t3code"); + const anchors = collectAnchors( + renderNotes({ + ...baseState, + releaseNotes: [{ version: "0.0.36-nightly.3", items: ["Change 3"], totalItems: 1 }], + omittedReleaseCount: 1, + }), + ); + + expect(anchors.map(({ props }) => props.href)).toEqual([ + "https://github.com/JustYannicc/t3code/releases/tag/v0.0.36-nightly.3", + "https://github.com/JustYannicc/t3code/releases", + ]); + }); + it("links each preview to its exact release and labels hidden changes", () => { const anchors = collectAnchors( renderNotes({ diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 31260c8ae666..acc73a399a3c 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -13,6 +13,7 @@ interface ImportMetaEnv { readonly VITE_RELAY_OTLP_TRACES_URL: string; readonly VITE_RELAY_OTLP_TRACES_DATASET: string; readonly VITE_RELAY_OTLP_TRACES_TOKEN: string; + readonly T3CODE_DESKTOP_UPDATE_REPOSITORY: string; readonly APP_VERSION: string; } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index cd4f48a64fcf..cd50954cd761 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -42,6 +42,10 @@ const configuredRelayTracingDataset = repoEnv.VITE_RELAY_OTLP_TRACES_DATASET?.tr const configuredRelayTracingToken = repoEnv.VITE_RELAY_OTLP_TRACES_TOKEN?.trim() || ""; const configuredHostedAppChannel = process.env.VITE_HOSTED_APP_CHANNEL?.trim() || ""; const configuredAppVersion = process.env.APP_VERSION?.trim() || pkg.version; +const configuredDesktopUpdateRepository = + process.env.T3CODE_DESKTOP_UPDATE_REPOSITORY?.trim() || + process.env.GITHUB_REPOSITORY?.trim() || + ""; const configuredHostedAppUrl = (() => { const explicitHostedAppUrl = process.env.VITE_HOSTED_APP_URL?.trim(); if (explicitHostedAppUrl) { @@ -215,6 +219,9 @@ export default defineConfig(() => { "import.meta.env.VITE_RELAY_OTLP_TRACES_TOKEN": JSON.stringify(configuredRelayTracingToken), "import.meta.env.VITE_HOSTED_APP_URL": JSON.stringify(configuredHostedAppUrl ?? ""), "import.meta.env.VITE_HOSTED_APP_CHANNEL": JSON.stringify(configuredHostedAppChannel), + "import.meta.env.T3CODE_DESKTOP_UPDATE_REPOSITORY": JSON.stringify( + configuredDesktopUpdateRepository, + ), "import.meta.env.APP_VERSION": JSON.stringify(configuredAppVersion), }, resolve: {