diff --git a/.replit b/.replit index 76d2fa09..86074f7e 100644 --- a/.replit +++ b/.replit @@ -60,6 +60,10 @@ externalPort = 3000 localPort = 40437 externalPort = 3001 +[[ports]] +localPort = 45997 +externalPort = 3002 + [deployment] deploymentTarget = "autoscale" run = ["node", "dist/server/production.mjs"] diff --git a/client/App.tsx b/client/App.tsx index 986b320e..6384b617 100644 --- a/client/App.tsx +++ b/client/App.tsx @@ -164,7 +164,6 @@ import StaffLearningPortal from "./pages/staff/StaffLearningPortal"; import StaffPerformanceReviews from "./pages/staff/StaffPerformanceReviews"; import StaffProjectTracking from "./pages/staff/StaffProjectTracking"; import StaffTeamHandbook from "./pages/staff/StaffTeamHandbook"; -import Overlay from "./pages/Overlay"; const queryClient = new QueryClient(); @@ -185,7 +184,6 @@ const App = () => ( {/* Subdomain Passport (aethex.me and aethex.space) handles its own redirect if not a subdomain */} } /> - } /> } /> } /> + + + + + + AeThex + + + + + + +
+ + + diff --git a/client/desktop-overlay.html b/client/desktop-overlay.html new file mode 100644 index 00000000..b3a3686e --- /dev/null +++ b/client/desktop-overlay.html @@ -0,0 +1,25 @@ + + + + + + + AeThex Overlay + + + + + + +
+ + + diff --git a/client/components/DesktopShell.tsx b/client/desktop/components/DesktopShell.tsx similarity index 52% rename from client/components/DesktopShell.tsx rename to client/desktop/components/DesktopShell.tsx index 0045d146..fbbf5607 100644 --- a/client/components/DesktopShell.tsx +++ b/client/desktop/components/DesktopShell.tsx @@ -1,7 +1,12 @@ import TitleBar from "./TitleBar"; import { ReactNode } from "react"; -export default function DesktopShell({ children }: { children: ReactNode }) { +interface DesktopShellProps { + children: ReactNode; + title?: string; +} + +export default function DesktopShell({ children, title }: DesktopShellProps) { return (
- -
{children}
+ +
{children}
); } - diff --git a/client/desktop/components/Overlay.tsx b/client/desktop/components/Overlay.tsx new file mode 100644 index 00000000..502241d7 --- /dev/null +++ b/client/desktop/components/Overlay.tsx @@ -0,0 +1,207 @@ +import { useState } from "react"; +import "../types/preload"; + +interface OverlayProps { + defaultPath?: string; +} + +export default function Overlay({ defaultPath = "" }: OverlayProps) { + const [dir, setDir] = useState(defaultPath); + const [status, setStatus] = useState<"idle" | "watching" | "error">("idle"); + const [error, setError] = useState(null); + + const start = async () => { + try { + setError(null); + await window.aeBridge?.startWatcher(dir); + setStatus("watching"); + } catch (e) { + const message = e instanceof Error ? e.message : "Failed to start watcher"; + setError(message); + setStatus("error"); + } + }; + + const stop = async () => { + try { + setError(null); + await window.aeBridge?.stopWatcher(); + setStatus("idle"); + } catch (e) { + const message = e instanceof Error ? e.message : "Failed to stop watcher"; + setError(message); + } + }; + + return ( +
+
+ AeThex Overlay +
+ +
+
+ 📂 File Watcher +
+ + + setDir(e.target.value)} + placeholder="Enter folder path..." + style={{ + width: "100%", + padding: 10, + borderRadius: 8, + border: "1px solid #1f2937", + background: "#0f172a", + color: "#e5e7eb", + marginBottom: 12, + fontSize: 13, + }} + /> + +
+ + +
+ +
+ + + {status === "watching" + ? "Watching for changes..." + : status === "error" + ? "Error" + : "Idle"} + +
+ + {error && ( +
+ {error} +
+ )} + +
+ PII is scrubbed locally before processing +
+
+
+ ); +} diff --git a/client/components/TitleBar.tsx b/client/desktop/components/TitleBar.tsx similarity index 62% rename from client/components/TitleBar.tsx rename to client/desktop/components/TitleBar.tsx index db68e46d..32f96848 100644 --- a/client/components/TitleBar.tsx +++ b/client/desktop/components/TitleBar.tsx @@ -1,12 +1,25 @@ -import { useState, CSSProperties } from "react"; +import { useState, CSSProperties, useEffect } from "react"; +import type { AeBridge } from "../types/preload"; -export default function TitleBar() { +interface TitleBarProps { + title?: string; +} + +export default function TitleBar({ title = "AeThex Terminal" }: TitleBarProps) { const [pinned, setPinned] = useState(false); - const call = async (method: string) => { - const api = (window as any)?.aeBridge; - if (!api || !api[method]) return; - const res = await api[method](); + useEffect(() => { + const bridge = window.aeBridge; + if (bridge?.isPinned) { + bridge.isPinned().then(setPinned).catch(() => {}); + } + }, []); + + const call = async (method: keyof AeBridge) => { + const api = window.aeBridge; + if (!api || typeof api[method] !== "function") return; + const fn = api[method] as () => Promise; + const res = await fn(); if (method === "togglePin") setPinned(res); }; @@ -19,20 +32,18 @@ export default function TitleBar() { padding: "0 12px", background: "#050814", color: "#9ca3af", - // @ts-ignore - Electron-specific property WebkitAppRegion: "drag", borderBottom: "1px solid #0f172a", letterSpacing: "0.08em", fontSize: 12, } as CSSProperties} > -
AeThex Terminal
+
{title}
@@ -41,14 +52,14 @@ export default function TitleBar() { style={btnStyle(pinned ? "#38bdf8" : "#1f2937")} title="Pin / Unpin" > - {pinned ? "Pinned" : "Pin"} + {pinned ? "📌" : "Pin"}
); } -function btnStyle(bg: string) { +function btnStyle(bg: string): CSSProperties { return { border: "1px solid #111827", background: bg, @@ -78,7 +89,9 @@ function btnStyle(bg: string) { padding: "4px 8px", cursor: "pointer", fontSize: 12, - minWidth: 46, - } as React.CSSProperties; + minWidth: 36, + display: "flex", + alignItems: "center", + justifyContent: "center", + }; } - diff --git a/client/desktop/components/index.ts b/client/desktop/components/index.ts new file mode 100644 index 00000000..146336f7 --- /dev/null +++ b/client/desktop/components/index.ts @@ -0,0 +1,3 @@ +export { default as TitleBar } from "./TitleBar"; +export { default as DesktopShell } from "./DesktopShell"; +export { default as Overlay } from "./Overlay"; diff --git a/client/desktop/desktop-main.tsx b/client/desktop/desktop-main.tsx new file mode 100644 index 00000000..4e9ae227 --- /dev/null +++ b/client/desktop/desktop-main.tsx @@ -0,0 +1,23 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { DesktopShell } from "./components"; +import ErrorBoundary from "../components/ErrorBoundary"; +import App from "../App"; +import "../global.css"; + +const container = document.getElementById("root"); +if (!container) { + throw new Error("Root element not found"); +} + +const root = createRoot(container); + +root.render( + + + + + + + +); diff --git a/client/desktop/desktop-overlay.tsx b/client/desktop/desktop-overlay.tsx new file mode 100644 index 00000000..94890d44 --- /dev/null +++ b/client/desktop/desktop-overlay.tsx @@ -0,0 +1,17 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { Overlay } from "./components"; +import "../global.css"; + +const container = document.getElementById("root"); +if (!container) { + throw new Error("Root element not found"); +} + +const root = createRoot(container); + +root.render( + + + +); diff --git a/client/desktop/index.ts b/client/desktop/index.ts new file mode 100644 index 00000000..40b494c5 --- /dev/null +++ b/client/desktop/index.ts @@ -0,0 +1 @@ +export * from "./components"; diff --git a/client/desktop/types/preload.d.ts b/client/desktop/types/preload.d.ts new file mode 100644 index 00000000..a693d775 --- /dev/null +++ b/client/desktop/types/preload.d.ts @@ -0,0 +1,23 @@ +export interface ClipboardAlertPayload { + original: string; + scrubbed: string; +} + +export interface AeBridge { + startWatcher: (dir: string) => Promise; + stopWatcher: () => Promise; + togglePin: () => Promise; + isPinned: () => Promise; + close: () => Promise; + minimize: () => Promise; + maximize: () => Promise; + onClipboardAlert: (callback: (payload: ClipboardAlertPayload) => void) => () => void; +} + +declare global { + interface Window { + aeBridge?: AeBridge; + } +} + +export {}; diff --git a/client/main.tsx b/client/main.tsx index f8fa6ee8..f292d029 100644 --- a/client/main.tsx +++ b/client/main.tsx @@ -1,8 +1,6 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import App from "./App"; -import Overlay from "./pages/Overlay"; -import DesktopShell from "./components/DesktopShell"; import ErrorBoundary from "./components/ErrorBoundary"; const container = document.getElementById("root"); @@ -12,23 +10,10 @@ if (!container) { const root = createRoot(container); -const hash = typeof window !== "undefined" ? window.location.hash : ""; -if (hash.startsWith("#/overlay")) { - root.render( - - - - - , - ); -} else { - root.render( - - - - - - - , - ); -} +root.render( + + + + + +); diff --git a/client/pages/Overlay.tsx b/client/pages/Overlay.tsx deleted file mode 100644 index 76450ea5..00000000 --- a/client/pages/Overlay.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { useState } from "react"; - -const defaultPath = "C:/Projects/Roblox"; // change as needed - -export default function Overlay() { - const [dir, setDir] = useState(defaultPath); - const [status, setStatus] = useState<"idle" | "watching">("idle"); - - const start = async () => { - try { - await (window as any)?.aeBridge?.startWatcher(dir); - setStatus("watching"); - } catch (e) { - console.error(e); - } - }; - - const stop = async () => { - try { - await (window as any)?.aeBridge?.stopWatcher(); - setStatus("idle"); - } catch (e) { - console.error(e); - } - }; - - return ( -
-
- AeThex Overlay — On-Top Sidecar -
-
-
- File Watcher -
- - setDir(e.target.value)} - style={{ - width: "100%", - padding: 10, - borderRadius: 8, - border: "1px solid #1f2937", - background: "#0f172a", - color: "#e5e7eb", - marginBottom: 10, - }} - /> -
- - - - Status: {status} - -
-
- PII is scrubbed locally before any processing. -
-
-
- ); -} - diff --git a/electron/ipc.js b/electron/ipc.js new file mode 100644 index 00000000..712cd0af --- /dev/null +++ b/electron/ipc.js @@ -0,0 +1,52 @@ +import { ipcMain } from "electron"; +import { getMainWindow } from "./windows.js"; +import { startWatcher, stopWatcher } from "../services/watcher.js"; + +let pinned = false; + +export function registerIpcHandlers() { + ipcMain.handle("watcher:start", async (_event, dir) => { + if (!dir || typeof dir !== "string") { + throw new Error("Invalid directory path"); + } + await startWatcher(dir); + return true; + }); + + ipcMain.handle("watcher:stop", async () => { + await stopWatcher(); + return true; + }); + + ipcMain.handle("window:toggle-pin", () => { + const mainWindow = getMainWindow(); + pinned = !pinned; + mainWindow?.setAlwaysOnTop(pinned, "floating"); + return pinned; + }); + + ipcMain.handle("window:close", () => { + const mainWindow = getMainWindow(); + mainWindow?.close(); + }); + + ipcMain.handle("window:minimize", () => { + const mainWindow = getMainWindow(); + mainWindow?.minimize(); + }); + + ipcMain.handle("window:maximize", () => { + const mainWindow = getMainWindow(); + if (!mainWindow) return false; + if (mainWindow.isMaximized()) { + mainWindow.unmaximize(); + } else { + mainWindow.maximize(); + } + return mainWindow.isMaximized(); + }); + + ipcMain.handle("window:is-pinned", () => { + return pinned; + }); +} diff --git a/electron/main.js b/electron/main.js index 4ddb5781..74daf346 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1,139 +1,35 @@ +import { app, globalShortcut } from "electron"; import { - app, - BrowserWindow, - ipcMain, - globalShortcut, - clipboard, -} from "electron"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { startWatcher, stopWatcher } from "../services/watcher.js"; -import { scrubPII } from "../services/pii-scrub.js"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -let mainWindow = null; -let overlayWindow = null; -let pinned = false; -let sentinelInterval = null; - -function getRendererUrl() { - return ( - process.env.VITE_DEV_SERVER_URL || - `file://${path.join(__dirname, "../dist/spa/index.html")}` - ); -} - -function createMainWindow() { - mainWindow = new BrowserWindow({ - width: 1280, - height: 800, - frame: false, - titleBarStyle: "hidden", - backgroundColor: "#030712", - webPreferences: { - preload: path.join(__dirname, "preload.js"), - contextIsolation: true, - nodeIntegration: false, - }, - }); - - mainWindow.loadURL(getRendererUrl()); -} - -function createOverlayWindow() { - overlayWindow = new BrowserWindow({ - width: 420, - height: 640, - transparent: true, - frame: false, - alwaysOnTop: true, - resizable: true, - focusable: true, - backgroundColor: "#00000000", - webPreferences: { - preload: path.join(__dirname, "preload.js"), - contextIsolation: true, - nodeIntegration: false, - }, - }); - - overlayWindow.setAlwaysOnTop(true, "floating"); - const base = getRendererUrl(); - // Assumes your SPA has a route for /overlay - overlayWindow.loadURL(base + "#/overlay"); -} - -function toggleMainVisibility() { - if (!mainWindow) return; - if (mainWindow.isVisible()) { - mainWindow.hide(); - } else { - mainWindow.show(); - mainWindow.focus(); - } -} - -function startClipboardSentinel() { - if (sentinelInterval) return; - let last = clipboard.readText(); - sentinelInterval = setInterval(() => { - const now = clipboard.readText(); - if (now !== last) { - last = now; - const scrubbed = scrubPII(now); - if (scrubbed !== now) { - mainWindow?.webContents.send("sentinel:clipboard-alert", now); - } - } - }, 1500); -} + createMainWindow, + createOverlayWindow, + toggleMainVisibility, +} from "./windows.js"; +import { registerIpcHandlers } from "./ipc.js"; +import { startClipboardSentinel, stopClipboardSentinel } from "./sentinel.js"; app.whenReady().then(() => { + registerIpcHandlers(); + createMainWindow(); createOverlayWindow(); + startClipboardSentinel(); - // Global hotkey to toggle visibility globalShortcut.register("Alt+Space", toggleMainVisibility); - - ipcMain.handle("watcher:start", async (_e, dir) => { - await startWatcher(dir); - return true; - }); - - ipcMain.handle("watcher:stop", async () => { - await stopWatcher(); - return true; - }); - - ipcMain.handle("window:toggle-pin", () => { - pinned = !pinned; - mainWindow?.setAlwaysOnTop(pinned, "floating"); - return pinned; - }); - - ipcMain.handle("window:close", () => { - mainWindow?.close(); - }); - - ipcMain.handle("window:minimize", () => { - mainWindow?.minimize(); - }); - - ipcMain.handle("window:maximize", () => { - if (!mainWindow) return false; - if (mainWindow.isMaximized()) { - mainWindow.unmaximize(); - } else { - mainWindow.maximize(); - } - return mainWindow.isMaximized(); - }); }); app.on("window-all-closed", () => { - if (process.platform !== "darwin") app.quit(); + stopClipboardSentinel(); + if (process.platform !== "darwin") { + app.quit(); + } }); +app.on("activate", () => { + createMainWindow(); +}); + +app.on("will-quit", () => { + globalShortcut.unregisterAll(); + stopClipboardSentinel(); +}); diff --git a/electron/preload.js b/electron/preload.js index 1925b7a2..0c1e8e79 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -3,11 +3,19 @@ import { contextBridge, ipcRenderer } from "electron"; contextBridge.exposeInMainWorld("aeBridge", { startWatcher: (dir) => ipcRenderer.invoke("watcher:start", dir), stopWatcher: () => ipcRenderer.invoke("watcher:stop"), + togglePin: () => ipcRenderer.invoke("window:toggle-pin"), + isPinned: () => ipcRenderer.invoke("window:is-pinned"), close: () => ipcRenderer.invoke("window:close"), minimize: () => ipcRenderer.invoke("window:minimize"), maximize: () => ipcRenderer.invoke("window:maximize"), - onClipboardAlert: (fn) => - ipcRenderer.on("sentinel:clipboard-alert", (_e, payload) => fn(payload)), -}); + onClipboardAlert: (callback) => { + ipcRenderer.on("sentinel:clipboard-alert", (_event, payload) => + callback(payload) + ); + return () => { + ipcRenderer.removeAllListeners("sentinel:clipboard-alert"); + }; + }, +}); diff --git a/electron/sentinel.js b/electron/sentinel.js new file mode 100644 index 00000000..face3480 --- /dev/null +++ b/electron/sentinel.js @@ -0,0 +1,33 @@ +import { clipboard } from "electron"; +import { getMainWindow } from "./windows.js"; +import { scrubPII } from "../services/pii-scrub.js"; + +let sentinelInterval = null; + +export function startClipboardSentinel() { + if (sentinelInterval) return; + + let lastClipboard = clipboard.readText(); + + sentinelInterval = setInterval(() => { + const current = clipboard.readText(); + if (current !== lastClipboard) { + lastClipboard = current; + const scrubbed = scrubPII(current); + if (scrubbed !== current) { + const mainWindow = getMainWindow(); + mainWindow?.webContents.send("sentinel:clipboard-alert", { + original: current, + scrubbed: scrubbed, + }); + } + } + }, 1500); +} + +export function stopClipboardSentinel() { + if (sentinelInterval) { + clearInterval(sentinelInterval); + sentinelInterval = null; + } +} diff --git a/electron/windows.js b/electron/windows.js new file mode 100644 index 00000000..a169b0b2 --- /dev/null +++ b/electron/windows.js @@ -0,0 +1,92 @@ +import { BrowserWindow } from "electron"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +let mainWindow = null; +let overlayWindow = null; + +export function getMainWindow() { + return mainWindow; +} + +export function getOverlayWindow() { + return overlayWindow; +} + +export function getRendererUrl(entryFile = "desktop-main.html") { + if (process.env.VITE_DEV_SERVER_URL) { + return `${process.env.VITE_DEV_SERVER_URL}/${entryFile}`; + } + return `file://${path.join(__dirname, "../dist/desktop", entryFile)}`; +} + +export function createMainWindow() { + mainWindow = new BrowserWindow({ + width: 1280, + height: 800, + minWidth: 800, + minHeight: 600, + frame: false, + titleBarStyle: "hidden", + backgroundColor: "#030712", + show: false, + webPreferences: { + preload: path.join(__dirname, "preload.js"), + contextIsolation: true, + nodeIntegration: false, + }, + }); + + mainWindow.loadURL(getRendererUrl("desktop-main.html")); + + mainWindow.once("ready-to-show", () => { + mainWindow.show(); + }); + + mainWindow.on("closed", () => { + mainWindow = null; + }); + + return mainWindow; +} + +export function createOverlayWindow() { + overlayWindow = new BrowserWindow({ + width: 380, + height: 320, + transparent: true, + frame: false, + alwaysOnTop: true, + resizable: true, + focusable: true, + skipTaskbar: true, + backgroundColor: "#00000000", + webPreferences: { + preload: path.join(__dirname, "preload.js"), + contextIsolation: true, + nodeIntegration: false, + }, + }); + + overlayWindow.setAlwaysOnTop(true, "floating"); + overlayWindow.loadURL(getRendererUrl("desktop-overlay.html")); + + overlayWindow.on("closed", () => { + overlayWindow = null; + }); + + return overlayWindow; +} + +export function toggleMainVisibility() { + if (!mainWindow) return; + if (mainWindow.isVisible()) { + mainWindow.hide(); + } else { + mainWindow.show(); + mainWindow.focus(); + } +} diff --git a/package.json b/package.json index d914bbcd..c3be3f77 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "main": "electron/main.js", "pkg": { "assets": [ - "dist/spa/*" + "dist/spa/*", + "dist/desktop/*" ], "scripts": [ "dist/server/**/*.js" @@ -25,9 +26,10 @@ "format.fix": "prettier --write .", "typecheck": "tsc", "desktop:dev": "concurrently -k \"npm:desktop:renderer\" \"npm:desktop:electron\"", - "desktop:renderer": "npm run dev -- --host --port 5173", + "desktop:renderer": "vite --config vite.desktop.config.ts --host --port 5173", "desktop:electron": "cross-env VITE_DEV_SERVER_URL=http://localhost:5173 electron .", - "desktop:build": "npm run build && electron-builder -c electron-builder.yml" + "desktop:build": "npm run build:desktop && electron-builder -c electron-builder.yml", + "build:desktop": "vite build --config vite.desktop.config.ts" }, "dependencies": { "@builder.io/react": "^8.2.8", diff --git a/replit.md b/replit.md index ef98c8bc..4cd581cf 100644 --- a/replit.md +++ b/replit.md @@ -15,18 +15,34 @@ AeThex is a full-stack web application built with React, Vite, Express, and Supa ## Project Structure ``` -├── client/ # React frontend code -│ ├── components/ # React components -│ ├── pages/ # Page components -│ ├── lib/ # Utility libraries -│ ├── hooks/ # Custom React hooks -│ └── contexts/ # React contexts -├── server/ # Express backend -│ └── index.ts # Main server file with API routes -├── api/ # API route handlers -├── discord-bot/ # Discord bot integration -├── docs/ # Documentation files -└── shared/ # Shared code between client/server +├── client/ # React frontend code (web) +│ ├── components/ # React components +│ ├── pages/ # Page components +│ ├── lib/ # Utility libraries +│ ├── hooks/ # Custom React hooks +│ ├── contexts/ # React contexts +│ ├── desktop/ # Desktop-specific React code +│ │ ├── components/ # TitleBar, DesktopShell, Overlay +│ │ ├── desktop-main.tsx # Desktop app entry point +│ │ └── desktop-overlay.tsx # Overlay window entry point +│ ├── main.tsx # Web app entry point +│ ├── desktop-main.html # Desktop HTML entry +│ └── desktop-overlay.html # Overlay HTML entry +├── electron/ # Electron main process +│ ├── main.js # Electron entry point +│ ├── preload.js # Secure IPC bridge +│ ├── windows.js # Window management +│ ├── ipc.js # IPC handlers +│ └── sentinel.js # Clipboard PII monitoring +├── server/ # Express backend +│ └── index.ts # Main server file with API routes +├── services/ # Backend services +│ ├── watcher.js # File watcher for dev workflow +│ └── pii-scrub.js # PII scrubbing utility +├── api/ # API route handlers +├── discord-bot/ # Discord bot integration +├── docs/ # Documentation files +└── shared/ # Shared code between client/server ``` diff --git a/vite.desktop.config.ts b/vite.desktop.config.ts new file mode 100644 index 00000000..3b50c30a --- /dev/null +++ b/vite.desktop.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react-swc"; +import path from "path"; + +export default defineConfig({ + root: "client", + base: "./", + build: { + outDir: "../dist/desktop", + emptyOutDir: true, + rollupOptions: { + input: { + main: path.resolve(__dirname, "client/desktop-main.html"), + overlay: path.resolve(__dirname, "client/desktop-overlay.html"), + }, + }, + }, + plugins: [react()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./client"), + "@shared": path.resolve(__dirname, "./shared"), + "@assets": path.resolve(__dirname, "./attached_assets"), + }, + }, +});