diff --git a/scripts/startup.sh b/scripts/startup.sh
index c715655..d9d3e93 100644
--- a/scripts/startup.sh
+++ b/scripts/startup.sh
@@ -1,15 +1,5 @@
#!/bin/bash
set -e
-# Create runtime config with environment variables
-mkdir -p /app/frontend/dist/assets
-cat > /app/frontend/dist/assets/runtime-config.js <
-
+
Pad.ws
-
diff --git a/src/frontend/index.tsx b/src/frontend/index.tsx
index 35c5414..2d4d0b3 100644
--- a/src/frontend/index.tsx
+++ b/src/frontend/index.tsx
@@ -15,6 +15,7 @@ import type * as TExcalidraw from "@atyrode/excalidraw";
import App from "./src/App";
import AuthGate from "./src/AuthGate";
+import { BuildVersionCheck } from "./src/BuildVersionCheck";
declare global {
@@ -31,6 +32,7 @@ async function initApp() {
+
{ }}
diff --git a/src/frontend/src/AuthGate.tsx b/src/frontend/src/AuthGate.tsx
index f2a7fb7..6434020 100644
--- a/src/frontend/src/AuthGate.tsx
+++ b/src/frontend/src/AuthGate.tsx
@@ -1,5 +1,6 @@
import React, { useEffect, useRef, useState } from "react";
import { useAuthCheck } from "./api/hooks";
+import { getAppConfig } from "./api/configService";
/**
* If unauthenticated, it shows the AuthModal as an overlay, but still renders the app behind it.
@@ -19,26 +20,42 @@ export default function AuthGate({ children }: { children: React.ReactNode }) {
useEffect(() => {
// Only run the Coder OIDC priming once per session, after auth is confirmed
if (isAuthenticated === true && !coderAuthDone) {
- const iframe = document.createElement("iframe");
- iframe.style.display = "none";
- // Use runtime config if available, fall back to import.meta.env
- const coderUrl = window.RUNTIME_CONFIG?.CODER_URL || import.meta.env.CODER_URL;
- iframe.src = `${coderUrl}/api/v2/users/oidc/callback`;
- console.debug(`[pad.ws] (Silently) Priming Coder OIDC session for ${coderUrl}`);
+ const setupIframe = async () => {
+ try {
+ // Get config from API
+ const config = await getAppConfig();
+
+ if (!config.coderUrl) {
+ console.warn('[pad.ws] Coder URL not found, skipping OIDC priming');
+ setCoderAuthDone(true);
+ return;
+ }
+
+ const iframe = document.createElement("iframe");
+ iframe.style.display = "none";
+ iframe.src = `${config.coderUrl}/api/v2/users/oidc/callback`;
+ console.debug(`[pad.ws] (Silently) Priming Coder OIDC session for ${config.coderUrl}`);
- // Remove iframe as soon as it loads, or after 2s fallback
- const cleanup = () => {
- if (iframe.parentNode) iframe.parentNode.removeChild(iframe);
- setCoderAuthDone(true);
- };
-
- iframe.onload = cleanup;
- document.body.appendChild(iframe);
- iframeRef.current = iframe;
+ // Remove iframe as soon as it loads, or after fallback timeout
+ const cleanup = () => {
+ if (iframe.parentNode) iframe.parentNode.removeChild(iframe);
+ setCoderAuthDone(true);
+ };
- // Fallback: remove iframe after 5s if onload doesn't fire
- timeoutRef.current = window.setTimeout(cleanup, 5000);
+ iframe.onload = cleanup;
+ document.body.appendChild(iframe);
+ iframeRef.current = iframe;
+ // Fallback: remove iframe after 5s if onload doesn't fire
+ timeoutRef.current = window.setTimeout(cleanup, 5000);
+ } catch (error) {
+ console.error('[pad.ws] Error setting up Coder OIDC priming:', error);
+ setCoderAuthDone(true);
+ }
+ };
+
+ setupIframe();
+
// Cleanup on unmount or re-run
return () => {
if (iframeRef.current && iframeRef.current.parentNode) {
diff --git a/src/frontend/src/BuildVersionCheck.tsx b/src/frontend/src/BuildVersionCheck.tsx
new file mode 100644
index 0000000..98bce93
--- /dev/null
+++ b/src/frontend/src/BuildVersionCheck.tsx
@@ -0,0 +1,61 @@
+import { useEffect, useState, useCallback } from 'react';
+import { useBuildInfo, useSaveCanvas } from './api/hooks';
+import { saveCurrentCanvas } from './utils/canvasUtils';
+
+/**
+ * Component that checks for application version changes and refreshes the page when needed.
+ * This component doesn't render anything visible.
+ */
+export function BuildVersionCheck() {
+ // Store the initial build hash when the component first loads
+ const [initialBuildHash, setInitialBuildHash] = useState(null);
+
+ // Query for the current build info from the server
+ const { data: buildInfo } = useBuildInfo();
+
+ // Get the saveCanvas mutation
+ const { mutate: saveCanvas } = useSaveCanvas({
+ onSuccess: () => {
+ console.debug("[pad.ws] Canvas saved before refresh");
+ // Refresh the page immediately after saving
+ window.location.reload();
+ },
+ onError: (error) => {
+ console.error("[pad.ws] Failed to save canvas before refresh:", error);
+ // Refresh anyway even if save fails
+ window.location.reload();
+ }
+ });
+
+ // Function to handle version update
+ const handleVersionUpdate = useCallback(() => {
+ // Save the canvas and then refresh
+ saveCurrentCanvas(
+ saveCanvas,
+ undefined, // No success callback needed as it's handled in the useSaveCanvas hook
+ () => window.location.reload() // On error, just refresh
+ );
+ }, [saveCanvas]);
+
+ useEffect(() => {
+ // On first load, store the initial build hash
+ if (buildInfo?.buildHash && initialBuildHash === null) {
+ console.log('Initial build hash:', buildInfo.buildHash);
+ setInitialBuildHash(buildInfo.buildHash);
+ }
+
+ // If we have both values and they don't match, a new version is available
+ if (initialBuildHash !== null &&
+ buildInfo?.buildHash &&
+ initialBuildHash !== buildInfo.buildHash) {
+
+ console.log('New version detected. Current:', initialBuildHash, 'New:', buildInfo.buildHash);
+
+ // Save the canvas and then refresh
+ handleVersionUpdate();
+ }
+ }, [buildInfo, initialBuildHash, handleVersionUpdate]);
+
+ // This component doesn't render anything
+ return null;
+}
diff --git a/src/frontend/src/api/configService.ts b/src/frontend/src/api/configService.ts
new file mode 100644
index 0000000..66050cf
--- /dev/null
+++ b/src/frontend/src/api/configService.ts
@@ -0,0 +1,39 @@
+import { fetchApi } from './apiUtils';
+
+/**
+ * Application configuration interface
+ */
+export interface AppConfig {
+ coderUrl: string;
+ posthogKey: string;
+ posthogHost: string;
+}
+
+// Cache the config to avoid unnecessary API calls
+let cachedConfig: AppConfig | null = null;
+
+/**
+ * Get the application configuration from the API
+ * @returns The application configuration
+ */
+export async function getAppConfig(): Promise {
+ // Return cached config if available
+ if (cachedConfig) {
+ return cachedConfig;
+ }
+
+ try {
+ // Fetch config from API
+ const config = await fetchApi('/api/app/config');
+ cachedConfig = config;
+ return config;
+ } catch (error) {
+ console.error('[pad.ws] Failed to load application configuration:', error);
+ // Return default values as fallback
+ return {
+ coderUrl: '',
+ posthogKey: '',
+ posthogHost: ''
+ };
+ }
+}
diff --git a/src/frontend/src/api/hooks.ts b/src/frontend/src/api/hooks.ts
index 6747eca..c4a7a5e 100644
--- a/src/frontend/src/api/hooks.ts
+++ b/src/frontend/src/api/hooks.ts
@@ -39,6 +39,11 @@ export interface CanvasBackupsResponse {
backups: CanvasBackup[];
}
+export interface BuildInfo {
+ buildHash: string;
+ timestamp: number;
+}
+
// API functions
export const api = {
// Authentication
@@ -134,6 +139,16 @@ export const api = {
throw error;
}
},
+
+ // Build Info
+ getBuildInfo: async (): Promise => {
+ try {
+ const result = await fetchApi('/api/app/build-info');
+ return result;
+ } catch (error) {
+ throw error;
+ }
+ },
};
// Query hooks
@@ -192,6 +207,15 @@ export function useCanvasBackups(limit: number = 10, options?: UseQueryOptions) {
+ return useQuery({
+ queryKey: ['buildInfo'],
+ queryFn: api.getBuildInfo,
+ refetchInterval: 60000, // Check every minute
+ ...options,
+ });
+}
+
// Mutation hooks
export function useStartWorkspace(options?: UseMutationOptions) {
return useMutation({
diff --git a/src/frontend/src/global.d.ts b/src/frontend/src/global.d.ts
index db580b0..70713f3 100644
--- a/src/frontend/src/global.d.ts
+++ b/src/frontend/src/global.d.ts
@@ -1,8 +1,3 @@
interface Window {
- RUNTIME_CONFIG?: {
- CODER_URL: string;
- VITE_PUBLIC_POSTHOG_KEY: string;
- VITE_PUBLIC_POSTHOG_HOST: string;
- };
ExcalidrawLib: any;
}
diff --git a/src/frontend/src/utils/canvasUtils.ts b/src/frontend/src/utils/canvasUtils.ts
index 79d2c02..c6578c8 100644
--- a/src/frontend/src/utils/canvasUtils.ts
+++ b/src/frontend/src/utils/canvasUtils.ts
@@ -1,4 +1,6 @@
import { DEFAULT_SETTINGS } from '../types/settings';
+import type { ExcalidrawImperativeAPI } from "@atyrode/excalidraw/types";
+import { CanvasData } from '../api/hooks';
/**
*
@@ -42,3 +44,59 @@ export function normalizeCanvasData(data: any) {
return { ...data, appState };
}
+
+/**
+ * Saves the current canvas state using the Excalidraw API
+ * @param saveCanvas The saveCanvas mutation function from useSaveCanvas hook
+ * @param onSuccess Optional callback to run after successful save
+ * @param onError Optional callback to run if save fails
+ */
+export function saveCurrentCanvas(
+ saveCanvas: (data: CanvasData) => void,
+ onSuccess?: () => void,
+ onError?: (error: any) => void
+) {
+ try {
+ // Get the excalidrawAPI from the window object
+ const excalidrawAPI = (window as any).excalidrawAPI as ExcalidrawImperativeAPI | null;
+
+ if (excalidrawAPI) {
+ // Get the current elements, state, and files
+ const elements = excalidrawAPI.getSceneElements();
+ const appState = excalidrawAPI.getAppState();
+ const files = excalidrawAPI.getFiles();
+
+ // Save the canvas data
+ saveCanvas({
+ elements: [...elements] as any[], // Convert readonly array to mutable array
+ appState,
+ files
+ });
+
+ // Call onSuccess callback if provided
+ if (onSuccess) {
+ onSuccess();
+ }
+
+ return true;
+ } else {
+ console.warn("[pad.ws] ExcalidrawAPI not available");
+
+ // Call onError callback if provided
+ if (onError) {
+ onError(new Error("ExcalidrawAPI not available"));
+ }
+
+ return false;
+ }
+ } catch (error) {
+ console.error("[pad.ws] Error saving canvas:", error);
+
+ // Call onError callback if provided
+ if (onError) {
+ onError(error);
+ }
+
+ return false;
+ }
+}
diff --git a/src/frontend/src/utils/posthog.ts b/src/frontend/src/utils/posthog.ts
index 3ac4cfd..212eb14 100644
--- a/src/frontend/src/utils/posthog.ts
+++ b/src/frontend/src/utils/posthog.ts
@@ -1,17 +1,20 @@
import posthog from 'posthog-js';
+import { getAppConfig } from '../api/configService';
-const posthogKey = window.RUNTIME_CONFIG?.VITE_PUBLIC_POSTHOG_KEY || import.meta.env.VITE_PUBLIC_POSTHOG_KEY;
-const posthogHost = window.RUNTIME_CONFIG?.VITE_PUBLIC_POSTHOG_HOST || import.meta.env.VITE_PUBLIC_POSTHOG_HOST;
+// Initialize PostHog with empty values first
+posthog.init('', { api_host: '' });
-// Initialize PostHog
-if (posthogKey) {
- posthog.init(posthogKey, {
- api_host: posthogHost,
- });
- console.debug('[pad.ws] PostHog initialized successfully');
-} else {
- console.warn('[pad.ws] PostHog API key not found. Analytics will not be tracked.');
-}
+// Then update with real values when config is loaded
+getAppConfig().then(config => {
+ if (config.posthogKey) {
+ posthog.init(config.posthogKey, {
+ api_host: config.posthogHost,
+ });
+ console.debug('[pad.ws] PostHog initialized successfully');
+ } else {
+ console.warn('[pad.ws] PostHog API key not found. Analytics will not be tracked.');
+ }
+});
// Helper function to track custom events
export const capture = (eventName: string, properties?: Record) => {
@@ -19,4 +22,4 @@ export const capture = (eventName: string, properties?: Record) =>
};
// Export PostHog instance for direct use
-export default posthog;
\ No newline at end of file
+export default posthog;
diff --git a/src/frontend/vite.config.mts b/src/frontend/vite.config.mts
index e68ceb2..47e988d 100644
--- a/src/frontend/vite.config.mts
+++ b/src/frontend/vite.config.mts
@@ -1,4 +1,32 @@
-import { defineConfig, loadEnv } from "vite";
+import { defineConfig, loadEnv, Plugin } from "vite";
+import fs from "fs";
+import path from "path";
+
+// Create a plugin to generate build-info.json during build
+const generateBuildInfoPlugin = (): Plugin => ({
+ name: 'generate-build-info',
+ closeBundle() {
+ // Generate a unique build hash (timestamp + random string)
+ const buildInfo = {
+ buildHash: Date.now().toString(36) + Math.random().toString(36).substring(2),
+ timestamp: Date.now()
+ };
+
+ // Ensure the dist directory exists
+ const distDir = path.resolve(__dirname, 'dist');
+ if (!fs.existsSync(distDir)) {
+ fs.mkdirSync(distDir, { recursive: true });
+ }
+
+ // Write to the output directory
+ fs.writeFileSync(
+ path.resolve(distDir, 'build-info.json'),
+ JSON.stringify(buildInfo, null, 2)
+ );
+
+ console.log('Generated build-info.json with hash:', buildInfo.buildHash);
+ }
+});
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
@@ -24,6 +52,9 @@ export default defineConfig(({ mode }) => {
'import.meta.env.CODER_URL': JSON.stringify(env.CODER_URL),
},
publicDir: "public",
+ plugins: [
+ generateBuildInfoPlugin(),
+ ],
optimizeDeps: {
esbuildOptions: {
// Bumping to 2022 due to "Arbitrary module namespace identifier names" not being