diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index a59b5627..7ad858cc 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -167,6 +167,7 @@ export type WorkspaceEventType = | "instance.dataChanged" | "instance.event" | "instance.eventStatus" + | "app.releaseAvailable" export type WorkspaceEventPayload = | { type: "workspace.created"; workspace: WorkspaceDescriptor } @@ -179,6 +180,7 @@ export type WorkspaceEventPayload = | { type: "instance.dataChanged"; instanceId: string; data: InstanceData } | { type: "instance.event"; instanceId: string; event: InstanceStreamEvent } | { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string } + | { type: "app.releaseAvailable"; release: LatestReleaseInfo } export interface NetworkAddress { ip: string @@ -187,6 +189,15 @@ export interface NetworkAddress { url: string } +export interface LatestReleaseInfo { + version: string + tag: string + url: string + channel: "stable" | "dev" + publishedAt?: string + notes?: string +} + export interface ServerMeta { /** Base URL clients should target for REST calls (useful for Electron embedding). */ httpBaseUrl: string @@ -204,6 +215,8 @@ export interface ServerMeta { workspaceRoot: string /** Reachable addresses for this server, external first. */ addresses: NetworkAddress[] + /** Optional metadata about the most recent public release. */ + latestRelease?: LatestReleaseInfo } export type { diff --git a/packages/server/src/events/bus.ts b/packages/server/src/events/bus.ts index 61453024..3d417ce8 100644 --- a/packages/server/src/events/bus.ts +++ b/packages/server/src/events/bus.ts @@ -29,6 +29,7 @@ export class EventBus extends EventEmitter { this.on("instance.dataChanged", handler) this.on("instance.event", handler) this.on("instance.eventStatus", handler) + this.on("app.releaseAvailable", handler) return () => { this.off("workspace.created", handler) this.off("workspace.started", handler) @@ -40,6 +41,7 @@ export class EventBus extends EventEmitter { this.off("instance.dataChanged", handler) this.off("instance.event", handler) this.off("instance.eventStatus", handler) + this.off("app.releaseAvailable", handler) } } } diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 73eca663..8144faae 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -17,6 +17,7 @@ import { InstanceStore } from "./storage/instance-store" import { InstanceEventBridge } from "./workspaces/instance-events" import { createLogger } from "./logger" import { launchInBrowser } from "./launcher" +import { startReleaseMonitor } from "./releases/release-monitor" const require = createRequire(import.meta.url) @@ -149,6 +150,19 @@ async function main() { addresses: [], } + const releaseMonitor = startReleaseMonitor({ + currentVersion: packageJson.version, + logger: logger.child({ component: "release-monitor" }), + onUpdate: (release) => { + if (release) { + serverMeta.latestRelease = release + eventBus.publish({ type: "app.releaseAvailable", release }) + } else { + delete serverMeta.latestRelease + } + }, + }) + const server = createHttpServer({ host: options.host, port: options.port, @@ -196,6 +210,8 @@ async function main() { logger.error({ err: error }, "Workspace manager shutdown failed") } + releaseMonitor.stop() + logger.info("Exiting process") process.exit(0) } diff --git a/packages/server/src/releases/release-monitor.ts b/packages/server/src/releases/release-monitor.ts new file mode 100644 index 00000000..2fd80c99 --- /dev/null +++ b/packages/server/src/releases/release-monitor.ts @@ -0,0 +1,141 @@ +import { fetch } from "undici" +import type { LatestReleaseInfo } from "../api-types" +import type { Logger } from "../logger" + +const RELEASES_API_URL = "https://api.github.com/repos/NeuralNomadsAI/CodeNomad/releases/latest" +interface ReleaseMonitorOptions { + currentVersion: string + logger: Logger + onUpdate: (release: LatestReleaseInfo | null) => void +} + +interface GithubReleaseResponse { + tag_name?: string + name?: string + html_url?: string + body?: string + published_at?: string + created_at?: string + prerelease?: boolean +} + +interface NormalizedVersion { + major: number + minor: number + patch: number + prerelease: string | null +} + +export interface ReleaseMonitor { + stop(): void +} + +export function startReleaseMonitor(options: ReleaseMonitorOptions): ReleaseMonitor { + let stopped = false + + const refreshRelease = async () => { + if (stopped) return + try { + const release = await fetchLatestRelease(options) + options.onUpdate(release) + } catch (error) { + options.logger.warn({ err: error }, "Failed to refresh release information") + } + } + + void refreshRelease() + + return { + stop() { + stopped = true + }, + } +} + +async function fetchLatestRelease(options: ReleaseMonitorOptions): Promise { + const response = await fetch(RELEASES_API_URL, { + headers: { + Accept: "application/vnd.github+json", + "User-Agent": "CodeNomad-CLI", + }, + }) + + if (!response.ok) { + throw new Error(`Release API responded with ${response.status}`) + } + + const json = (await response.json()) as GithubReleaseResponse + const tagFromServer = json.tag_name || json.name + if (!tagFromServer) { + return null + } + + const normalizedVersion = stripTagPrefix(tagFromServer) + if (!normalizedVersion) { + return null + } + + const current = parseVersion(options.currentVersion) + const remote = parseVersion(normalizedVersion) + + if (compareVersions(remote, current) <= 0) { + return null + } + + return { + version: normalizedVersion, + tag: tagFromServer, + url: json.html_url ?? `https://github.com/NeuralNomadsAI/CodeNomad/releases/tag/${encodeURIComponent(tagFromServer)}`, + channel: json.prerelease || normalizedVersion.includes("-") ? "dev" : "stable", + publishedAt: json.published_at ?? json.created_at, + notes: json.body, + } +} + +function stripTagPrefix(tag: string | undefined): string | null { + if (!tag) return null + const trimmed = tag.trim() + if (!trimmed) return null + return trimmed.replace(/^v/i, "") +} + +function parseVersion(value: string): NormalizedVersion { + const normalized = stripTagPrefix(value) ?? "0.0.0" + const [core, prerelease = null] = normalized.split("-", 2) + const [major = 0, minor = 0, patch = 0] = core.split(".").map((segment) => { + const parsed = Number.parseInt(segment, 10) + return Number.isFinite(parsed) ? parsed : 0 + }) + return { + major, + minor, + patch, + prerelease, + } +} + +function compareVersions(a: NormalizedVersion, b: NormalizedVersion): number { + if (a.major !== b.major) { + return a.major > b.major ? 1 : -1 + } + if (a.minor !== b.minor) { + return a.minor > b.minor ? 1 : -1 + } + if (a.patch !== b.patch) { + return a.patch > b.patch ? 1 : -1 + } + + const aPre = a.prerelease && a.prerelease.length > 0 ? a.prerelease : null + const bPre = b.prerelease && b.prerelease.length > 0 ? b.prerelease : null + + if (aPre === bPre) { + return 0 + } + if (!aPre) { + return 1 + } + if (!bPre) { + return -1 + } + return aPre.localeCompare(bPre) +} diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 7e38bb6b..187bd683 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -13,6 +13,7 @@ import { useTheme } from "./lib/theme" import { useCommands } from "./lib/hooks/use-commands" import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle" import { getLogger } from "./lib/logger" +import { initReleaseNotifications } from "./stores/releases" import { hasInstances, isSelectingFolder, @@ -67,6 +68,10 @@ const App: Component = () => { void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error)) }) + createEffect(() => { + initReleaseNotifications() + }) + const activeInstance = createMemo(() => getActiveInstance()) const activeSessionIdForInstance = createMemo(() => { const instance = activeInstance() @@ -352,9 +357,8 @@ const App: Component = () => { setRemoteAccessOpen(false)} /> - - { ) } + export default App diff --git a/packages/ui/src/components/folder-selection-view.tsx b/packages/ui/src/components/folder-selection-view.tsx index 92e9ce34..51e469c3 100644 --- a/packages/ui/src/components/folder-selection-view.tsx +++ b/packages/ui/src/components/folder-selection-view.tsx @@ -8,6 +8,7 @@ import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/nat const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href + interface FolderSelectionViewProps { onSelectFolder: (folder: string, binaryPath?: string) => void isLoading?: boolean @@ -253,6 +254,8 @@ const FolderSelectionView: Component = (props) => {
0} fallback={
diff --git a/packages/ui/src/lib/notifications.tsx b/packages/ui/src/lib/notifications.tsx index 75da3370..04d98301 100644 --- a/packages/ui/src/lib/notifications.tsx +++ b/packages/ui/src/lib/notifications.tsx @@ -2,11 +2,23 @@ import toast from "solid-toast" export type ToastVariant = "info" | "success" | "warning" | "error" +export type ToastHandle = { + id: string + dismiss: () => void +} + +type ToastPosition = "top-left" | "top-right" | "top-center" | "bottom-left" | "bottom-right" | "bottom-center" + export type ToastPayload = { title?: string message: string variant: ToastVariant duration?: number + position?: ToastPosition + action?: { + label: string + href: string + } } const variantAccent: Record< @@ -44,11 +56,11 @@ const variantAccent: Record< }, } -export function showToastNotification(payload: ToastPayload) { +export function showToastNotification(payload: ToastPayload): ToastHandle { const accent = variantAccent[payload.variant] const duration = payload.duration ?? 10000 - toast.custom( + const id = toast.custom( () => (
@@ -56,16 +68,32 @@ export function showToastNotification(payload: ToastPayload) {
{payload.title &&

{payload.title}

}

{payload.message}

+ {payload.action && ( + + {payload.action.label} + + )}
), { duration, + position: payload.position ?? "top-right", ariaProps: { role: "status", "aria-live": "polite", }, }, ) + + return { + id, + dismiss: () => toast.dismiss(id), + } } diff --git a/packages/ui/src/stores/releases.ts b/packages/ui/src/stores/releases.ts new file mode 100644 index 00000000..e993513d --- /dev/null +++ b/packages/ui/src/stores/releases.ts @@ -0,0 +1,70 @@ +import { createSignal } from "solid-js" +import type { LatestReleaseInfo, WorkspaceEventPayload } from "../../../server/src/api-types" +import { getServerMeta } from "../lib/server-meta" +import { serverEvents } from "../lib/server-events" +import { showToastNotification, ToastHandle } from "../lib/notifications" +import { getLogger } from "../lib/logger" + +const log = getLogger("actions") + +const [availableRelease, setAvailableRelease] = createSignal(null) + +let initialized = false +let activeToast: ToastHandle | null = null + +function dismissActiveToast() { + if (activeToast) { + activeToast.dismiss() + activeToast = null + } +} + +export function initReleaseNotifications() { + if (initialized) { + return + } + initialized = true + + void refreshFromMeta() + + serverEvents.on("app.releaseAvailable", (event) => { + const typedEvent = event as Extract + applyRelease(typedEvent.release) + }) +} + +async function refreshFromMeta() { + try { + const meta = await getServerMeta(true) + if (meta.latestRelease) { + applyRelease(meta.latestRelease) + } + } catch (error) { + log.warn("Unable to load server metadata for release info", error) + } +} + +function applyRelease(release: LatestReleaseInfo | null | undefined) { + if (!release) { + setAvailableRelease(null) + dismissActiveToast() + return + } + setAvailableRelease(release) + dismissActiveToast() + activeToast = showToastNotification({ + title: `CodeNomad ${release.version}`, + message: release.channel === "dev" ? "Dev release build available." : "New stable build on GitHub.", + variant: "info", + duration: Number.POSITIVE_INFINITY, + position: "bottom-right", + action: { + label: "View release", + href: release.url, + }, + }) +} + +export function useAvailableRelease() { + return availableRelease +}