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)