frontend: add version

rabbitprincess committed Mar 12, 2026 at 22:38 UTC 176cf1b8865e65c8dc11735e91a88ac20040f701
11 files changed +78 -16
cmd/demo-app/main.go
+1 -1
@@ -59,7 +59,7 @@ func runDemo() error {
59
60 relayURLs := utils.SplitCSV(flagRelayURLs)
61 if flagDefaultRelays {
62 - relayURLs = sdk.WithDefaultRelayURLs(ctx, relayURLs...)
62 + relayURLs = sdk.WithDefaultRelayURLs(ctx, "", relayURLs...)
63 }
64 relayURLs, err := utils.NormalizeRelayURLs(relayURLs)
65 if err != nil {
cmd/portal-tunnel/main.go
+1 -1
@@ -64,7 +64,7 @@ func runTunnel() error {
64
65 relayURLs := utils.SplitCSV(flagRelayURLs)
66 if flagDefaultRelays {
67 - relayURLs = sdk.WithDefaultRelayURLs(ctx, relayURLs...)
67 + relayURLs = sdk.WithDefaultRelayURLs(ctx, "", relayURLs...)
68 }
69 relayURLs, err := utils.NormalizeRelayURLs(relayURLs)
70 if err != nil {
cmd/relay-server/frontend.go
+2
@@ -13,6 +13,7 @@ import (
13
14 "github.com/gosuda/portal/v2/portal"
15 "github.com/gosuda/portal/v2/portal/admin"
16 + "github.com/gosuda/portal/v2/types"
17 )
18
19 type readDirFileFS interface {
@@ -162,6 +163,7 @@ func (f *Frontend) injectOGMetadata(htmlContent, title, description, imageURL st
163 "[%OG_TITLE%]", html.EscapeString(title),
164 "[%OG_DESCRIPTION%]", html.EscapeString(description),
165 "[%OG_IMAGE_URL%]", html.EscapeString(imageURL),
166 + "[%RELEASE_VERSION%]", html.EscapeString(types.ReleaseVersion),
167 )
168 return replacer.Replace(htmlContent)
169 }
frontend/AGENTS.md
+2 -2
@@ -22,8 +22,8 @@ High-signal constraints for the relay-server frontend. Only items expensive to r
22 Vite plugin `rename-index` (`vite.config.ts`) performs this post-build. Go backend serves `portal.html`, not `index.html`. The rename is skipped when `VITEST` is set.
23 - Why: any tooling or script assuming `index.html` post-build will fail.
24
25 -5. **OG metadata placeholders must match between HTML and Go.**
26 - `index.html` (renamed to `portal.html`) contains `[%OG_TITLE%]`, `[%OG_DESCRIPTION%]`, `[%OG_IMAGE_URL%]`. Server-side substitution happens in `cmd/relay-server/frontend.go`.
25 +5. **HTML metadata placeholders must match between HTML and Go.**
26 + `index.html` (renamed to `portal.html`) contains `[%OG_TITLE%]`, `[%OG_DESCRIPTION%]`, `[%OG_IMAGE_URL%]`, `[%RELEASE_VERSION%]`. Server-side substitution happens in `cmd/relay-server/frontend.go`.
27 - Why: renaming a placeholder in one place without the other leaves raw placeholder strings in production HTML.
28
29 6. **Frontend admin action helpers currently outpace the Go runtime.**
frontend/index.html
+1
@@ -15,6 +15,7 @@
15 <meta name="twitter:title" content="[%OG_TITLE%]" />
16 <meta name="twitter:description" content="[%OG_DESCRIPTION%]" />
17 <meta name="twitter:image" content="[%OG_IMAGE_URL%]" />
18 + <meta name="portal-release-version" content="[%RELEASE_VERSION%]" />
19 <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
20 <title>Portal - Local to web. Instant access.</title>
21 </head>
frontend/src/components/Header.tsx
+12 -3
@@ -8,6 +8,7 @@ import {
8 TooltipTrigger,
9 } from "@/components/ui/tooltip";
10 import { TunnelCommandModal } from "@/components/TunnelCommandModal";
11 +import { getReleaseVersion } from "@/lib/releaseVersion";
12 import clsx from "clsx";
13
14 interface HeaderProps {
@@ -18,6 +19,7 @@ interface HeaderProps {
19
20 export function Header({ title = "PORTAL", isAdmin, onLogout }: HeaderProps) {
21 const [theme, setTheme] = useState<"light" | "dark">("dark");
22 + const releaseVersion = getReleaseVersion();
23
24 useEffect(() => {
25 // Check localStorage for saved theme
@@ -61,9 +63,16 @@ export function Header({ title = "PORTAL", isAdmin, onLogout }: HeaderProps) {
63 ></path>
64 </svg>
65 </div>
64 - <h2 className="text-foreground text-lg font-bold leading-tight tracking-[0.3em]">
65 - {title}
66 - </h2>
66 + <div className="flex flex-wrap items-center gap-2">
67 + <h2 className="text-foreground text-lg font-bold leading-tight tracking-[0.3em]">
68 + {title}
69 + </h2>
70 + {releaseVersion && (
71 + <span className="rounded-full border border-border bg-secondary px-2 py-0.5 text-xs font-medium text-text-muted">
72 + {releaseVersion}
73 + </span>
74 + )}
75 + </div>
76 </div>
77 <div className="flex items-center gap-1 sm:gap-3">
78 <a
frontend/src/lib/releaseVersion.test.ts new
+21
@@ -0,0 +1,21 @@
1 +import { beforeEach, describe, expect, it } from "vitest";
2 +import {
3 + getReleaseVersion,
4 + RELEASE_VERSION_META_NAME,
5 +} from "@/lib/releaseVersion";
6 +
7 +describe("getReleaseVersion", () => {
8 + beforeEach(() => {
9 + document.head.innerHTML = "";
10 + });
11 +
12 + it("reads the release version from the portal meta tag", () => {
13 + document.head.innerHTML = `<meta name="${RELEASE_VERSION_META_NAME}" content=" v2.0.4 " />`;
14 +
15 + expect(getReleaseVersion(document)).toBe("v2.0.4");
16 + });
17 +
18 + it("returns an empty string when the version meta tag is missing", () => {
19 + expect(getReleaseVersion(document)).toBe("");
20 + });
21 +});
frontend/src/lib/releaseVersion.ts new
+17
@@ -0,0 +1,17 @@
1 +export const RELEASE_VERSION_META_NAME = "portal-release-version";
2 +
3 +export function getReleaseVersion(doc?: Document): string {
4 + const targetDoc =
5 + doc ?? (typeof document !== "undefined" ? document : undefined);
6 + if (!targetDoc) {
7 + return "";
8 + }
9 +
10 + return (
11 + targetDoc
12 + .querySelector<HTMLMetaElement>(
13 + `meta[name="${RELEASE_VERSION_META_NAME}"]`
14 + )
15 + ?.content.trim() || ""
16 + );
17 +}
frontend/src/pages/AdminLogin.tsx
+12 -3
@@ -1,6 +1,7 @@
1 import { useState, useEffect, FormEvent } from "react";
2 import { useNavigate } from "react-router-dom";
3 import { KeyRound, ShieldCheck } from "lucide-react";
4 +import { getReleaseVersion } from "@/lib/releaseVersion";
5 import { useAuth } from "@/hooks/useAuth";
6
7 export function AdminLogin() {
@@ -17,6 +18,7 @@ export function AdminLogin() {
18 const [key, setKey] = useState("");
19 const [error, setError] = useState("");
20 const [submitting, setSubmitting] = useState(false);
21 + const releaseVersion = getReleaseVersion();
22
23 // Redirect if already authenticated
24 useEffect(() => {
@@ -81,9 +83,16 @@ export function AdminLogin() {
83 ></path>
84 </svg>
85 </div>
84 - <h2 className="text-foreground text-lg font-bold leading-tight tracking-[0.3em]">
85 - PORTAL ADMIN
86 - </h2>
86 + <div className="flex flex-wrap items-center gap-2">
87 + <h2 className="text-foreground text-lg font-bold leading-tight tracking-[0.3em]">
88 + PORTAL ADMIN
89 + </h2>
90 + {releaseVersion && (
91 + <span className="rounded-full border border-border bg-secondary px-2 py-0.5 text-xs font-medium text-text-muted">
92 + {releaseVersion}
93 + </span>
94 + )}
95 + </div>
96 </div>
97 </header>
98
sdk/registry.go
+6 -4
@@ -5,19 +5,21 @@ import (
5 "encoding/json"
6 "net/http"
7
8 + "github.com/gosuda/portal/v2/types"
9 "github.com/gosuda/portal/v2/utils"
10 )
11
11 -const PortalRelayRegistryURL = "https://raw.githubusercontent.com/gosuda/portal/main/registry.json"
12 -
12 // WithDefaultRelayURLs fetches the default Portal relay registry and appends
13 // any explicit relay inputs before normalization.
15 -func WithDefaultRelayURLs(ctx context.Context, explicit ...string) []string {
14 +func WithDefaultRelayURLs(ctx context.Context, registryURL string, explicit ...string) []string {
15 if ctx == nil {
16 ctx = context.Background()
17 }
18 + if registryURL == "" {
19 + registryURL = types.PortalRelayRegistryURL
20 + }
21
20 - req, err := http.NewRequestWithContext(ctx, http.MethodGet, PortalRelayRegistryURL, nil)
22 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, registryURL, nil)
23 if err != nil {
24 return explicit
25 }
types/types.go
+3 -2
@@ -1,8 +1,9 @@
1 package types
2
3 const (
4 - ReleaseVersion = "v2.0.4"
5 - SDKProtocolVersion = "1"
4 + ReleaseVersion = "v2.0.5"
5 + SDKProtocolVersion = "1"
6 + PortalRelayRegistryURL = "https://raw.githubusercontent.com/gosuda/portal/main/registry.json"
7
8 HeaderReverseToken = "X-Portal-Token"
9 MarkerKeepalive = byte(0x00)