From 5e7e9510fb7c9355a229a07f310701ae1f6f4afb Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Sun, 14 Dec 2025 22:11:23 +0000 Subject: [PATCH] Refactored electron code and added spotlight feature. --- assets/stylesheets/App.css | 7 + package.json | 5 +- public/electron.js | 226 +++----- public/mainWindow.js | 196 +++++++ public/spotlightWindow.js | 136 +++++ src/App.jsx | 15 +- .../Dashboard/context/AuthContext.jsx | 254 ++++++--- .../Dashboard/context/ElectronContext.jsx | 57 +- .../Dashboard/context/SpotlightContext.jsx | 526 +++++++++++------- vite.config.js | 2 +- yarn.lock | 117 +++- 11 files changed, 1111 insertions(+), 430 deletions(-) create mode 100644 public/mainWindow.js create mode 100644 public/spotlightWindow.js diff --git a/assets/stylesheets/App.css b/assets/stylesheets/App.css index d5447a4..e5c18b8 100644 --- a/assets/stylesheets/App.css +++ b/assets/stylesheets/App.css @@ -400,3 +400,10 @@ body { .ant-btn-variant-outlined.ant-btn.ant-btn-icon-only { min-width: 32px; } + +.electron-spotlight-content .ant-input-outlined { + background-color: transparent !important; + border: 1px solid transparent !important; + border-radius: 0 !important; + box-shadow: none !important; +} diff --git a/package.json b/package.json index 3582ed1..a0ba862 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "eslint-plugin-react-refresh": "^0.4.20", "gcode-preview": "^2.18.0", "keycloak-js": "^26.2.0", + "keytar": "^7.9.0", "loglevel": "^1.9.2", "moment": "^2.30.1", "online-3d-viewer": "^0.16.0", @@ -73,10 +74,10 @@ "description": "3D Printer ERP and Control Software.", "scripts": { "dev": "cross-env NODE_ENV=development vite", - "electron": "cross-env ELECTRON_START_URL=http://0.0.0.0:5173 && cross-env NODE_ENV=development && electron .", + "electron": "cross-env ELECTRON_START_URL=http://0.0.0.0:5780 && cross-env NODE_ENV=development && electron .", "start": "serve -s build", "build": "vite build", - "dev:electron": "concurrently \"cross-env NODE_ENV=development vite --no-open\" \"cross-env ELECTRON_START_URL=http://localhost:5173 cross-env NODE_ENV=development electron public/electron.js\"", + "dev:electron": "concurrently \"cross-env NODE_ENV=development vite --no-open\" \"cross-env ELECTRON_START_URL=http://localhost:5780 cross-env NODE_ENV=development electron public/electron.js\"", "build:electron": "vite build && electron-builder", "deploy": "vite build && wrangler pages deploy" }, diff --git a/public/electron.js b/public/electron.js index 7997f24..7eec412 100644 --- a/public/electron.js +++ b/public/electron.js @@ -1,62 +1,44 @@ -import { app, BrowserWindow, ipcMain, shell, Menu } from 'electron' -import path, { dirname } from 'path' -import { fileURLToPath } from 'url' +import { app, ipcMain, shell, globalShortcut } from 'electron' +import { createRequire } from 'module' +import { + registerGlobalShortcuts, + setupSpotlightIPC +} from './spotlightWindow.js' +import { + createWindow, + setupMainWindowIPC, + setupMainWindowAppEvents, + setupDevAuthServer +} from './mainWindow.js' -const __filename = fileURLToPath(import.meta.url) -const __dirname = dirname(__filename) - -let win - -function createWindow() { - win = new BrowserWindow({ - width: 1200, - height: 800, - frame: false, - titleBarStyle: 'hiddenInset', - trafficLightPosition: { x: 14, y: 12 }, - backgroundColor: '#141414', - icon: path.join(__dirname, './logo512.png'), - webPreferences: { - nodeIntegration: true, - contextIsolation: false - } - }) - - // Set up custom menu bar - const env = (process.env.NODE_ENV || 'development').trim() - if (env === 'development') { - const devMenu = [ - { - label: 'Developer', - submenu: [ - { - label: 'Toggle Developer Tools', - accelerator: - process.platform === 'darwin' ? 'Alt+Command+I' : 'Ctrl+Shift+I', - click: () => { - win.webContents.toggleDevTools() - } - } - ] - } - ] - const menu = Menu.buildFromTemplate(devMenu) - Menu.setApplicationMenu(menu) - } else { - Menu.setApplicationMenu(null) - } - - // For development, load from localhost; for production, load the built index.html - if (process.env.ELECTRON_START_URL) { - win.loadURL(process.env.ELECTRON_START_URL) - } else { - win.loadFile(path.join(__dirname, '../build/index.html')) - } - - setupWindowEvents() +// --- Keytar-backed auth session storage (main process) --- +const require = createRequire(import.meta.url) +let keytar = null +try { + // keytar is a native module; in some dev environments it may not be built yet. + keytar = require('keytar') +} catch (e) { + console.warn( + '[keytar] Not available; auth session persistence will be disabled.', + e?.message || e + ) } -app.whenReady().then(createWindow) +const KEYTAR_SERVICE = app.name || 'Farm Control' +const KEYTAR_ACCOUNT = 'authSession' + +app.whenReady().then(() => { + createWindow() + registerGlobalShortcuts() + setupSpotlightIPC() + setupMainWindowIPC() + setupMainWindowAppEvents(app) + setupDevAuthServer() +}) + +app.on('will-quit', () => { + globalShortcut.unregisterAll() +}) // IPC handler to get OS ipcMain.handle('os-info', () => { @@ -65,112 +47,46 @@ ipcMain.handle('os-info', () => { } }) -// IPC handler to get window state -ipcMain.handle('window-state', () => { - return { - isFullScreen: win ? win.isFullScreen() : false, - isMaximized: win ? win.isMaximized() : false +ipcMain.handle('auth-session-get', async () => { + try { + if (!keytar) return null + const raw = await keytar.getPassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT) + if (!raw) return null + return JSON.parse(raw) + } catch (e) { + console.warn('[keytar] Failed to read auth session.', e?.message || e) + return null } }) -// Emit events to renderer when window is maximized/unmaximized -function setupWindowEvents() { - if (!win) return - win.on('maximize', () => { - win.webContents.send('window-state', { - isMaximized: true - }) - }) - win.on('enter-full-screen', () => { - console.log('Entered fullscreen') - win.webContents.send('window-state', { - isFullScreen: true - }) - }) - win.on('leave-full-screen', () => { - win.webContents.send('window-state', { - isFullScreen: false - }) - }) - win.on('unmaximize', () => { - win.webContents.send('window-state', { - isMaximized: false - }) - }) -} - -// IPC handlers for window controls -ipcMain.on('window-control', (event, action) => { - if (!win) return - switch (action) { - case 'minimize': - win.minimize() - break - case 'maximize': - if (win.isMaximized()) { - win.unmaximize() - } else { - win.maximize() - } - break - case 'close': - win.close() - break - default: - break +ipcMain.handle('auth-session-set', async (event, session) => { + try { + if (!keytar) return false + if (!session || typeof session !== 'object') return false + await keytar.setPassword( + KEYTAR_SERVICE, + KEYTAR_ACCOUNT, + JSON.stringify(session) + ) + return true + } catch (e) { + console.warn('[keytar] Failed to write auth session.', e?.message || e) + return false } }) -// Add this after other ipcMain handlers +ipcMain.handle('auth-session-clear', async () => { + try { + if (!keytar) return false + return await keytar.deletePassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT) + } catch (e) { + console.warn('[keytar] Failed to clear auth session.', e?.message || e) + return false + } +}) + +// IPC handler for opening external URLs ipcMain.handle('open-external-url', (event, url) => { console.log('Opening external url...') shell.openExternal(url) }) - -app.on('open-url', (event, url) => { - event.preventDefault() - console.log('App opened with URL:', url) - if (url.startsWith('farmcontrol://app')) { - // Extract the path/query after 'farmcontrol://app' - const redirectPath = url.replace('farmcontrol://app', '') || '/' - if (win && win.webContents) { - win.webContents.send('navigate', redirectPath) - } - } -}) - -app.on('window-all-closed', () => { - if (process.platform !== 'darwin') app.quit() -}) - -app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) createWindow() -}) - -const env = (process.env.NODE_ENV || 'development').trim() -console.log(env) -if (env == 'development') { - console.log('Starting development auth web server...') - import('express').then(({ default: express }) => { - const app = express() - const port = 3500 - - app.use((req, res) => { - const redirectPath = req.originalUrl - res.send( - `Open Farmcontrol to continue... (Redirect path: ${redirectPath})` - ) - if (win && win.webContents) { - win.webContents.send('navigate', redirectPath) - win.show() - win.focus() - } - }) - - app.listen(port, () => { - console.log(`Dev auth server running on http://localhost:${port}`) - }) - }) -} else { - console.log('Will use url scheme instead of auth server.') -} diff --git a/public/mainWindow.js b/public/mainWindow.js new file mode 100644 index 0000000..aeaaf4c --- /dev/null +++ b/public/mainWindow.js @@ -0,0 +1,196 @@ +import { BrowserWindow, ipcMain, Menu } from 'electron' +import path, { dirname } from 'path' +import { fileURLToPath } from 'url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) + +let win + +function attachKeyboardShortcuts(browserWindow) { + if (!browserWindow) return + // Keyboard shortcuts for the main window can be added here if needed +} + +function setupWindowEvents() { + if (!win) return + win.on('maximize', () => { + win.webContents.send('window-state', { + isMaximized: true + }) + }) + win.on('enter-full-screen', () => { + console.log('Entered fullscreen') + win.webContents.send('window-state', { + isFullScreen: true + }) + }) + win.on('leave-full-screen', () => { + win.webContents.send('window-state', { + isFullScreen: false + }) + }) + win.on('unmaximize', () => { + win.webContents.send('window-state', { + isMaximized: false + }) + }) +} + +export function createWindow() { + win = new BrowserWindow({ + width: 1200, + height: 800, + frame: false, + titleBarStyle: 'hiddenInset', + trafficLightPosition: { x: 14, y: 12 }, + backgroundColor: '#141414', + icon: path.join(__dirname, './logo512.png'), + webPreferences: { + nodeIntegration: true, + contextIsolation: false + } + }) + + // Set up custom menu bar + const env = (process.env.NODE_ENV || 'development').trim() + if (env === 'development') { + const devMenu = [ + { + label: 'Developer', + submenu: [ + { + label: 'Toggle Developer Tools', + accelerator: + process.platform === 'darwin' ? 'Alt+Command+I' : 'Ctrl+Shift+I', + click: () => { + win.webContents.toggleDevTools() + } + } + ] + } + ] + const menu = Menu.buildFromTemplate(devMenu) + Menu.setApplicationMenu(menu) + } else { + Menu.setApplicationMenu(null) + } + + // For development, load from localhost; for production, load the built index.html + if (process.env.ELECTRON_START_URL) { + win.loadURL(process.env.ELECTRON_START_URL) + } else { + win.loadFile(path.join(__dirname, '../build/index.html')) + } + + setupWindowEvents() + attachKeyboardShortcuts(win) +} + +export function getWindow() { + return win +} + +export function setupMainWindowIPC() { + // IPC handler to get window state + ipcMain.handle('window-state', () => { + if (!win || win.isDestroyed()) + return { isFullScreen: false, isMaximized: false } + return { + isFullScreen: win ? win.isFullScreen() : false, + isMaximized: win ? win.isMaximized() : false + } + }) + + // IPC handlers for window controls + ipcMain.on('window-control', (event, action) => { + if (!win) return + switch (action) { + case 'minimize': + win.minimize() + break + case 'maximize': + if (win.isMaximized()) { + win.unmaximize() + } else { + win.maximize() + } + break + case 'close': + win.close() + break + default: + break + } + }) + + ipcMain.handle('open-internal-url', (event, url) => { + if (!win || win.isDestroyed()) { + createWindow() + // Wait for window to finish loading before sending navigate event + win.webContents.once('did-finish-load', () => { + setTimeout(() => { + win.webContents.send('navigate', url) + win.show() + win.focus() + }, 100) + }) + } else { + win.webContents.send('navigate', url) + win.show() + win.focus() + } + return true + }) +} + +export function setupMainWindowAppEvents(app) { + app.on('window-all-closed', () => { + if (process.platform !== 'darwin') app.quit() + }) + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) createWindow() + }) + + app.on('open-url', (event, url) => { + event.preventDefault() + console.log('App opened with URL:', url) + if (url.startsWith('farmcontrol://app')) { + // Extract the path/query after 'farmcontrol://app' + const redirectPath = url.replace('farmcontrol://app', '') || '/' + if (win && win.webContents) { + win.webContents.send('navigate', redirectPath) + } + } + }) +} + +export function setupDevAuthServer() { + const env = (process.env.NODE_ENV || 'development').trim() + if (env == 'development') { + console.log('Starting development auth web server...') + import('express').then(({ default: express }) => { + const app = express() + const port = 3500 + + app.use((req, res) => { + const redirectPath = req.originalUrl + res.send( + `Open Farmcontrol to continue... (Redirect path: ${redirectPath})` + ) + if (win && win.webContents) { + win.webContents.send('navigate', redirectPath) + win.show() + win.focus() + } + }) + + app.listen(port, () => { + console.log(`Dev auth server running on http://localhost:${port}`) + }) + }) + } else { + console.log('Will use url scheme instead of auth server.') + } +} diff --git a/public/spotlightWindow.js b/public/spotlightWindow.js new file mode 100644 index 0000000..d8b45c3 --- /dev/null +++ b/public/spotlightWindow.js @@ -0,0 +1,136 @@ +import { BrowserWindow, ipcMain, globalShortcut } from 'electron' +import path, { dirname } from 'path' +import { fileURLToPath } from 'url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) + +let spotlightWin + +function getSpotlightRouteUrl() { + const routePath = '/dashboard/electron/spotlightcontent' + + // Dev: BrowserRouter, so use a real path. + if (process.env.ELECTRON_START_URL) { + const base = String(process.env.ELECTRON_START_URL).replace(/\/$/, '') + return `${base}${routePath}` + } + + // Prod (file://): App.jsx chooses HashRouter when `index.html` is in the URL. + // So we must load `index.html#/electron/spotlightcontent`. + return { + filePath: path.join(__dirname, '../build/index.html'), + hash: routePath + } +} + +export function openSpotlightContentWindow() { + // If already open, just focus it (avoids accidental window spam). + if (spotlightWin && !spotlightWin.isDestroyed()) { + spotlightWin.show() + spotlightWin.focus() + return + } + + spotlightWin = new BrowserWindow({ + width: 700, + height: 40, + frame: false, + resizable: false, + center: true, + vibrancy: 'menu', + transparent: true, + icon: path.join(__dirname, './logo512.png'), + webPreferences: { + nodeIntegration: true, + contextIsolation: false + } + }) + + const target = getSpotlightRouteUrl() + if (typeof target === 'string') { + spotlightWin.loadURL(target) + } else { + spotlightWin.loadFile(target.filePath, { hash: target.hash }) + } + + // Hide spotlight window instead of destroying it when closed + spotlightWin.on('close', (event) => { + event.preventDefault() + if (spotlightWin && !spotlightWin.isDestroyed()) { + spotlightWin.hide() + } + }) + + // Hide spotlight window when it loses focus (clicking outside) + spotlightWin.on('blur', () => { + if (spotlightWin && !spotlightWin.isDestroyed()) { + spotlightWin.hide() + } + }) + + attachSpotlightKeyboardShortcuts(spotlightWin) +} + +function attachSpotlightKeyboardShortcuts(browserWindow) { + if (!browserWindow) return + + browserWindow.webContents.on('before-input-event', (event, input) => { + // ESC -> hide SpotlightContent window (if it's the spotlight window) + if ( + input?.type === 'keyDown' && + String(input?.key || '').toLowerCase() === 'escape' && + browserWindow === spotlightWin + ) { + event.preventDefault() + if (spotlightWin && !spotlightWin.isDestroyed()) { + spotlightWin.hide() + } + return + } + + // Alt+Shift+Q -> open SpotlightContent window + if ( + input?.type === 'keyDown' && + input?.alt === true && + input?.shift === true && + String(input?.key || '').toLowerCase() === 'q' + ) { + event.preventDefault() + openSpotlightContentWindow() + } + }) +} + +export function registerGlobalShortcuts() { + try { + const registered = globalShortcut.register('Alt+Shift+Q', () => { + openSpotlightContentWindow() + }) + + if (!registered) { + console.warn('[globalShortcut] Failed to register Alt+Shift+Q') + } + } catch (e) { + console.warn( + '[globalShortcut] Error registering Alt+Shift+Q', + e?.message || e + ) + } +} + +export function setupSpotlightIPC() { + // IPC handler to resize spotlight window + ipcMain.handle('spotlight-window-resize', (event, height) => { + if (!spotlightWin || spotlightWin.isDestroyed()) return false + try { + const currentBounds = spotlightWin.getBounds() + spotlightWin.setSize(currentBounds.width, height) + spotlightWin.center() + return true + } catch (e) { + console.warn('[spotlight] Failed to resize window.', e?.message || e) + return false + } + }) +} diff --git a/src/App.jsx b/src/App.jsx index 7e87bd7..8387f2a 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -12,7 +12,10 @@ import PrivateRoute from './components/PrivateRoute' import '../assets/stylesheets/App.css' import { PrintServerProvider } from './components/Dashboard/context/PrintServerContext.jsx' import { AuthProvider } from './components/Dashboard/context/AuthContext.jsx' -import { SpotlightProvider } from './components/Dashboard/context/SpotlightContext.jsx' +import { + SpotlightProvider, + ElectronSpotlightContentPage +} from './components/Dashboard/context/SpotlightContext.jsx' import { ActionsModalProvider } from './components/Dashboard/context/ActionsModalContext.jsx' import { @@ -58,6 +61,16 @@ const AppContent = () => { + ( + + )} + /> + } + /> { const [showUnauthorizedModal, setShowUnauthorizedModal] = useState(false) const [showAuthErrorModal, setShowAuthErrorModal] = useState(false) const [authError, setAuthError] = useState(null) - const { openExternalUrl, isElectron } = useContext(ElectronContext) + const { + openExternalUrl, + isElectron, + getAuthSession, + setAuthSession, + clearAuthSession + } = useContext(ElectronContext) const location = useLocation() const navigate = useNavigate() @@ -71,57 +77,128 @@ const AuthProvider = ({ children }) => { redirectType = 'app-scheme' } - // Check if cookies are enabled and show warning if not + const extractUserFromAuthData = (authData) => { + if (!authData || typeof authData !== 'object') return null + if (authData.user && typeof authData.user === 'object') return authData.user + + // Some endpoints may return the "user" fields at the top-level. Only treat it + // as a user object if it looks like one (avoid confusing token-only payloads). + const looksLikeUser = + authData._id || authData.username || authData.email || authData.name + if (looksLikeUser) return authData + + return null + } + + const isSessionExpired = (session) => { + if (!session?.expiresAt) return true + const now = new Date() + const expirationDate = new Date(session.expiresAt) + return expirationDate <= now + } + + const persistSession = useCallback( + async ({ token: nextToken, expiresAt: nextExpiresAt, user: nextUser }) => { + if (isElectron) { + return await setAuthSession({ + token: nextToken, + expiresAt: nextExpiresAt, + user: nextUser + }) + } + return setAuthCookies({ + access_token: nextToken, + expires_at: nextExpiresAt, + user: nextUser + }) + }, + [isElectron, setAuthSession] + ) + + const clearPersistedSession = useCallback(async () => { + if (isElectron) { + return await clearAuthSession() + } + clearAuthCookies() + return true + }, [isElectron, clearAuthSession]) + + // Check if cookies are enabled and show warning if not (web only) useEffect(() => { + if (isElectron) return if (!areCookiesEnabled()) { messageApi.warning( 'Cookies are disabled. Login state may not persist between tabs.' ) } - }, [messageApi]) + }, [messageApi, isElectron]) - // Read token from cookies if present + // Read token from cookies (web) or keytar (electron) if present useEffect(() => { - try { - console.log( - 'Retreiving token from cookies...', - getAuthCookies(), - validateAuthCookies() - ) - // First validate existing cookies to clean up expired ones - if (validateAuthCookies()) { - const { - token: storedToken, - expiresAt: storedExpiresAt, - user: storedUser - } = getAuthCookies() - console.log('Retrieved from cookies:', { - storedUser, - storedToken, - storedExpiresAt - }) + let cancelled = false - setToken(storedToken) - setUserProfile(storedUser) - setExpiresAt(storedExpiresAt) - setAuthenticated(true) - } else { - setAuthenticated(false) - setUserProfile(null) - setShowUnauthorizedModal(true) + const load = async () => { + try { + if (isElectron) { + const session = await getAuthSession() + if ( + !cancelled && + session && + session.token && + !isSessionExpired(session) + ) { + setToken(session.token) + setUserProfile(session.user) + setExpiresAt(session.expiresAt) + setAuthenticated(true) + } else if (!cancelled) { + setAuthenticated(false) + setUserProfile(null) + setShowUnauthorizedModal(true) + } + } else { + // First validate existing cookies to clean up expired ones + if (validateAuthCookies()) { + const { + token: storedToken, + expiresAt: storedExpiresAt, + user: storedUser + } = getAuthCookies() + + if (!cancelled) { + setToken(storedToken) + setUserProfile(storedUser) + setExpiresAt(storedExpiresAt) + setAuthenticated(true) + } + } else if (!cancelled) { + setAuthenticated(false) + setUserProfile(null) + setShowUnauthorizedModal(true) + } + } + } catch (error) { + console.error('Error loading persisted auth session:', error) + await clearPersistedSession() + if (!cancelled) { + setAuthenticated(false) + setUserProfile(null) + setShowUnauthorizedModal(true) + } + } finally { + if (!cancelled) setRetreivedTokenFromCookies(true) } - } catch (error) { - console.error('Error reading auth cookies:', error) - clearAuthCookies() - setAuthenticated(false) - setUserProfile(null) - setShowUnauthorizedModal(true) } - setRetreivedTokenFromCookies(true) - }, []) + + load() + return () => { + cancelled = true + } + }, [isElectron, getAuthSession, clearPersistedSession]) // Set up cookie synchronization between tabs useEffect(() => { + if (isElectron) return const cleanupCookieSync = setupCookieSync(() => { // When cookies change in another tab, re-validate and update state try { @@ -159,16 +236,19 @@ const AuthProvider = ({ children }) => { }) return cleanupCookieSync - }, [token, expiresAt, userProfile]) + }, [token, expiresAt, userProfile, isElectron]) - const logout = useCallback((redirectUri = '/login') => { - setAuthenticated(false) - setToken(null) - setExpiresAt(null) - setUserProfile(null) - clearAuthCookies() - window.location.href = `${config.backendUrl}/auth/logout?redirect_uri=${encodeURIComponent(redirectUri)}` - }, []) + const logout = useCallback( + (redirectUri = '/login') => { + setAuthenticated(false) + setToken(null) + setExpiresAt(null) + setUserProfile(null) + clearPersistedSession() + window.location.href = `${config.backendUrl}/auth/logout?redirect_uri=${encodeURIComponent(redirectUri)}` + }, + [clearPersistedSession] + ) // Login using query parameters const loginWithSSO = useCallback( @@ -210,19 +290,23 @@ const AuthProvider = ({ children }) => { logger.debug('Got auth token!') const authData = response.data - setToken(authData.access_token) - setExpiresAt(authData.expires_at) - setUserProfile(authData) + const nextToken = authData.access_token + const nextExpiresAt = authData.expires_at + const nextUser = extractUserFromAuthData(authData) - // Store in cookies for persistence between tabs - const cookieSuccess = setAuthCookies({ - user: authData, - access_token: authData.access_token, - expires_at: authData.expires_at + setToken(nextToken) + setExpiresAt(nextExpiresAt) + setUserProfile(nextUser) + + // Persist session (cookies on web, keytar on electron) + const persisted = await persistSession({ + token: nextToken, + expiresAt: nextExpiresAt, + user: nextUser }) - if (!cookieSuccess) { + if (!persisted) { messageApi.warning( - 'Authentication successful but failed to save login state. You may need to log in again if you close this tab.' + 'Authentication successful but failed to save login state. You may need to log in again when you restart the app.' ) } @@ -253,7 +337,14 @@ const AuthProvider = ({ children }) => { setLoading(false) } }, - [isElectron, navigate, location.search, location.pathname, messageApi] + [ + isElectron, + navigate, + location.search, + location.pathname, + messageApi, + persistSession + ] ) // Function to check if the user is logged in @@ -272,13 +363,20 @@ const AuthProvider = ({ children }) => { logger.debug('Got auth token!') const authData = response.data - setToken(authData.access_token) - setExpiresAt(authData.expires_at) - setUserProfile(authData) + const nextToken = authData.access_token + const nextExpiresAt = authData.expires_at + const nextUser = extractUserFromAuthData(authData) - // Update cookies with fresh data - const cookieSuccess = setAuthCookies(authData) - if (!cookieSuccess) { + setToken(nextToken) + setExpiresAt(nextExpiresAt) + setUserProfile(nextUser) + + const persisted = await persistSession({ + token: nextToken, + expiresAt: nextExpiresAt, + user: nextUser + }) + if (!persisted) { messageApi.warning( 'Failed to update login state. You may need to log in again if you close this tab.' ) @@ -300,13 +398,13 @@ const AuthProvider = ({ children }) => { } finally { setLoading(false) } - }, [token, messageApi]) + }, [token, messageApi, persistSession]) const setUnauthenticated = () => { setToken(null) setExpiresAt(null) setUserProfile(null) - clearAuthCookies() + clearPersistedSession() setAuthenticated(false) if (showSessionExpiredModal == false) { setShowUnauthorizedModal(true) @@ -324,12 +422,19 @@ const AuthProvider = ({ children }) => { if (response.status === 200 && response.data) { const authData = response.data - setToken(authData.access_token) - setExpiresAt(authData.expires_at) + const nextToken = authData.access_token + const nextExpiresAt = authData.expires_at + const nextUser = extractUserFromAuthData(authData) || userProfile - // Update cookies with fresh token data - const cookieSuccess = setAuthCookies(authData) - if (!cookieSuccess) { + setToken(nextToken) + setExpiresAt(nextExpiresAt) + + const persisted = await persistSession({ + token: nextToken, + expiresAt: nextExpiresAt, + user: nextUser + }) + if (!persisted) { messageApi.warning( 'Failed to update login state. You may need to log in again if you close this tab.' ) @@ -338,7 +443,7 @@ const AuthProvider = ({ children }) => { } catch (error) { console.error('Token refresh failed', error) } - }, [token, messageApi]) + }, [token, messageApi, persistSession, userProfile]) const handleSessionExpiredModalOk = () => { setShowSessionExpiredModal(false) @@ -419,7 +524,8 @@ const AuthProvider = ({ children }) => { } } } else { - // Check cookies directly if expiresAt is not set in state + // Check cookies directly if expiresAt is not set in state (web only) + if (isElectron) return const expiryInfo = checkAuthCookiesExpiry(5) // Check if expiring within 5 minutes if (expiryInfo.isExpiringSoon && expiryInfo.minutesRemaining <= 1) { // Show notification for cookies expiring soon @@ -478,7 +584,7 @@ const AuthProvider = ({ children }) => { clearInterval(intervalId) } } - }, [expiresAt, authenticated, notificationApi, refreshToken]) + }, [expiresAt, authenticated, notificationApi, refreshToken, isElectron]) useEffect(() => { const authCode = diff --git a/src/components/Dashboard/context/ElectronContext.jsx b/src/components/Dashboard/context/ElectronContext.jsx index a633379..3db5329 100644 --- a/src/components/Dashboard/context/ElectronContext.jsx +++ b/src/components/Dashboard/context/ElectronContext.jsx @@ -47,6 +47,15 @@ const ElectronProvider = ({ children }) => { return false } + // Function to open internal URL via Electron + const openInternalUrl = (url) => { + if (electronAvailable && ipcRenderer) { + ipcRenderer.invoke('open-internal-url', url) + return true + } + return false + } + useEffect(() => { if (!ipcRenderer) return @@ -96,6 +105,45 @@ const ElectronProvider = ({ children }) => { } } + const getAuthSession = async () => { + if (!electronAvailable || !ipcRenderer) return null + return await ipcRenderer.invoke('auth-session-get') + } + + const setAuthSession = async (session) => { + if (!electronAvailable || !ipcRenderer) return false + return await ipcRenderer.invoke('auth-session-set', session) + } + + const clearAuthSession = async () => { + if (!electronAvailable || !ipcRenderer) return false + return await ipcRenderer.invoke('auth-session-clear') + } + + // Backwards-compatible helpers + const getToken = async () => { + const session = await getAuthSession() + return session?.token || null + } + + const setToken = async (token) => { + const session = (await getAuthSession()) || {} + return await setAuthSession({ ...session, token }) + } + + const resizeSpotlightWindow = async (height) => { + if (!electronAvailable || !ipcRenderer) return false + try { + return await ipcRenderer.invoke('spotlight-window-resize', height) + } catch (error) { + console.warn( + '[ElectronContext] Failed to resize spotlight window:', + error + ) + return false + } + } + return ( { isFullScreen, isElectron: electronAvailable, handleWindowControl, - openExternalUrl + openExternalUrl, + openInternalUrl, + getAuthSession, + setAuthSession, + clearAuthSession, + getToken, + setToken, + resizeSpotlightWindow }} > {children} diff --git a/src/components/Dashboard/context/SpotlightContext.jsx b/src/components/Dashboard/context/SpotlightContext.jsx index 3a72117..0c1f87f 100644 --- a/src/components/Dashboard/context/SpotlightContext.jsx +++ b/src/components/Dashboard/context/SpotlightContext.jsx @@ -7,7 +7,9 @@ import { Spin, message, Form, - Button + Button, + Card, + Divider } from 'antd' import { createContext, @@ -32,20 +34,27 @@ import { import InfoCircleIcon from '../../Icons/InfoCircleIcon' import { ApiServerContext } from './ApiServerContext' import { AuthContext } from './AuthContext' +import { ElectronContext } from './ElectronContext' const SpotlightContext = createContext() -const SpotlightProvider = ({ children }) => { +const SpotlightContent = ({ + isActive, + openKey, + defaultQuery, + onRequestClose, + isElectron +}) => { const { Text } = Typography const navigate = useNavigate() - const [showModal, setShowModal] = useState(false) const [loading, setLoading] = useState(false) const [query, setQuery] = useState('') const [listData, setListData] = useState([]) const [messageApi, contextHolder] = message.useMessage() - const [inputPrefix, setInputPrefix] = useState({ prefix: '', mode: null }) + const [inputPrefix, setInputPrefix] = useState(null) const { fetchSpotlightData } = useContext(ApiServerContext) const { token } = useContext(AuthContext) + const { openInternalUrl } = useContext(ElectronContext) // Refs for throttling/debouncing const lastFetchTime = useRef(0) @@ -54,39 +63,6 @@ const SpotlightProvider = ({ children }) => { const inputRef = useRef(null) const formRef = useRef(null) - const showSpotlight = (defaultQuery = '') => { - setQuery(defaultQuery) - setShowModal(true) - - // Set prefix based on default query if provided - if (defaultQuery) { - // Check if the default query contains a prefix - const upperQuery = defaultQuery.toUpperCase() - const prefixInfo = parsePrefix(upperQuery) - - if (prefixInfo) { - setInputPrefix(prefixInfo) - // Set the query to only the part after the prefix and mode character - const remainingValue = defaultQuery.substring( - prefixInfo.prefix.length + 1 - ) - setQuery(remainingValue) - checkAndFetchData(defaultQuery) - } else { - setInputPrefix(null) - checkAndFetchData(defaultQuery) - } - } else { - setInputPrefix(null) - // Only clear data if we're opening with an empty query and no existing data - if (listData.length === 0) { - setListData([]) - } - } - - // Focus will be handled in useEffect for proper timing after modal renders - } - // Helper function to parse prefix and mode from query const parsePrefix = (query) => { // Check for prefix format: XXX: or XXX? or XXX^ @@ -98,7 +74,7 @@ const SpotlightProvider = ({ children }) => { if ([':', '?', '^'].includes(modeChar)) { const prefixModel = getModelByPrefix(potentialPrefix) - if (prefixModel.prefix === potentialPrefix) { + if (prefixModel && prefixModel.prefix === potentialPrefix) { return { ...prefixModel, mode: modeChar @@ -283,7 +259,7 @@ const SpotlightProvider = ({ children }) => { // Helper to get row actions and default action for a model const getRowActions = (model) => - (model.actions || []).filter((action) => action.row === true) + (model?.actions || []).filter((action) => action.row === true) const getDefaultRowAction = (model) => getRowActions(model).find((action) => action.default) @@ -294,8 +270,12 @@ const SpotlightProvider = ({ children }) => { const itemId = item._id || item.id const url = action.url(itemId) if (url && url !== '#') { - navigate(url) - setShowModal(false) + if (isElectron) { + openInternalUrl(url) + } else { + navigate(url) + } + if (onRequestClose) onRequestClose() } } } @@ -309,7 +289,7 @@ const SpotlightProvider = ({ children }) => { const item = listData[0] let type = item.type || item.objectType || inputPrefix?.type const model = getModelByName(type) - const defaultAction = getDefaultRowAction(model) + const defaultAction = model ? getDefaultRowAction(model) : null if (defaultAction) { triggerRowAction(defaultAction, item) } @@ -323,7 +303,7 @@ const SpotlightProvider = ({ children }) => { const item = listData[index] let type = item.type || item.objectType || inputPrefix?.type const model = getModelByName(type) - const defaultAction = getDefaultRowAction(model) + const defaultAction = model ? getDefaultRowAction(model) : null if (defaultAction) { triggerRowAction(defaultAction, item) } @@ -341,7 +321,7 @@ const SpotlightProvider = ({ children }) => { // Focus and select text in input when modal becomes visible useEffect(() => { - if (showModal && inputRef.current) { + if (isActive && inputRef.current) { // Use a small timeout to ensure the modal is fully rendered and visible setTimeout(() => { const input = inputRef.current.input @@ -351,25 +331,52 @@ const SpotlightProvider = ({ children }) => { } }, 50) } - }, [showModal]) + }, [isActive]) // Focus input when inputPrefix changes useEffect(() => { - if (showModal) { - // Only clear data if there's no existing data and no current query - if (listData.length === 0 && !query) { - setListData([]) - } + if (isActive) { focusInput() } - }, [inputPrefix, showModal]) + }, [inputPrefix, isActive]) // Update form value when query changes useEffect(() => { - if (showModal && formRef.current) { + if (isActive && formRef.current) { formRef.current.setFieldsValue({ query: query }) } - }, [query, showModal]) + }, [query, isActive]) + + // Apply/refresh defaultQuery when the component is opened (modal) or mounted (standalone) + useEffect(() => { + if (!isActive) return + + const nextDefaultQuery = defaultQuery || '' + + // Set prefix based on default query if provided + if (nextDefaultQuery) { + const upperQuery = nextDefaultQuery.toUpperCase() + const prefixInfo = parsePrefix(upperQuery) + + if (prefixInfo) { + setInputPrefix(prefixInfo) + const remainingValue = nextDefaultQuery.substring( + prefixInfo.prefix.length + 1 + ) + setQuery(remainingValue) + checkAndFetchData(nextDefaultQuery) + } else { + setInputPrefix(null) + setQuery(nextDefaultQuery) + checkAndFetchData(nextDefaultQuery) + } + } else { + setQuery('') + setInputPrefix(null) + // Keep previous listData behavior (don’t force-clear on open) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [openKey, isActive]) // Cleanup on unmount useEffect(() => { @@ -394,9 +401,182 @@ const SpotlightProvider = ({ children }) => { } } + const spotlightContent = ( + +
+ + + {inputPrefix.prefix} + + {inputPrefix.mode} + + + ) : undefined + } + suffix={ + + {inputPrefix?.mode && ( + + {getModeDescription(inputPrefix.mode)} + + )} + } + spinning={loading} + size='small' + /> + + } + onChange={handleInputChange} + onKeyDown={handleKeyDown} + /> + +
+ + {listData.length > 0 && ( + <> + {isElectron && } +
+ { + let type = item.objectType || inputPrefix?.type + const model = getModelByName(type) + const Icon = model?.icon + const rowActions = getRowActions(model) + let shortcutText = '' + if (index === 0) { + shortcutText = 'ENTER' + } else if (index <= 9) { + shortcutText = index.toString() + } + return ( + + + + {Icon ? : null} + + {item.name ? ( + + {item.name} + + ) : null} + {item?.state ? ( + + ) : null} + + + + {rowActions + .filter((action) => !action?.default) + .map((action, i) => ( +
+ + )} +
+ ) + + return ( + <> + {contextHolder} + {spotlightContent} + + ) +} + +SpotlightContent.propTypes = { + isActive: PropTypes.bool, + openKey: PropTypes.number, + defaultQuery: PropTypes.string, + onRequestClose: PropTypes.func, + isElectron: PropTypes.bool +} + +SpotlightContent.defaultProps = { + isActive: true, + openKey: 0, + defaultQuery: '', + onRequestClose: null +} + +const SpotlightProvider = ({ children }) => { + const [showModal, setShowModal] = useState(false) + const [openKey, setOpenKey] = useState(0) + const [defaultQuery, setDefaultQuery] = useState('') + + const showSpotlight = (nextDefaultQuery = '') => { + setDefaultQuery(nextDefaultQuery) + setOpenKey((k) => k + 1) + setShowModal(true) + } + return ( - {contextHolder} setShowModal(false)} @@ -406,140 +586,12 @@ const SpotlightProvider = ({ children }) => { styles={{ content: { padding: 0 } }} destroyOnHidden={true} > - -
- - - {inputPrefix.prefix} - - {inputPrefix.mode} - - - ) : undefined - } - suffix={ - - {inputPrefix?.mode && ( - - {getModeDescription(inputPrefix.mode)} - - )} - } - spinning={loading} - size='small' - /> - - } - onChange={handleInputChange} - onKeyDown={handleKeyDown} - /> - -
- - {listData.length > 0 && ( -
- { - let type = item.objectType || inputPrefix?.type - const model = getModelByName(type) - const Icon = model.icon - const rowActions = getRowActions(model) - let shortcutText = '' - if (index === 0) { - shortcutText = 'ENTER' - } else if (index <= 9) { - shortcutText = index.toString() - } - return ( - - - - {Icon ? : null} - - {item.name ? ( - - {item.name} - - ) : null} - {item?.state ? ( - - ) : null} - - - - {rowActions - .filter((action) => !action?.default) - .map((action, i) => ( -
- )} -
+ setShowModal(false)} + />
{children}
@@ -550,4 +602,100 @@ SpotlightProvider.propTypes = { children: PropTypes.node.isRequired } -export { SpotlightProvider, SpotlightContext } +const ElectronSpotlightContentPage = () => { + const cardRef = useRef(null) + const resizeTimeoutRef = useRef(null) + const { resizeSpotlightWindow, isElectron } = useContext(ElectronContext) + + // Function to measure and resize window + const updateWindowHeight = () => { + if (!cardRef.current || !isElectron || !resizeSpotlightWindow) return + + // Clear any pending resize + if (resizeTimeoutRef.current) { + clearTimeout(resizeTimeoutRef.current) + } + + // Debounce the resize to avoid too many calls + resizeTimeoutRef.current = setTimeout(() => { + try { + const cardElement = cardRef.current + if (!cardElement) return + + // Get the scroll height of the card + const scrollHeight = cardElement.scrollHeight + + // Add some padding for better appearance (e.g., 10px top and bottom) + const padding = 0 + const newHeight = scrollHeight + padding * 2 + + // Set minimum height to prevent window from being too small + const minHeight = 30 + const finalHeight = Math.max(newHeight, minHeight) + + // Call IPC to resize the window + resizeSpotlightWindow(finalHeight) + } catch (error) { + console.warn('Failed to update spotlight window height:', error) + } + }, 100) // 100ms debounce + } + + // Use ResizeObserver to watch for content changes + useEffect(() => { + if (!cardRef.current || !isElectron || !resizeSpotlightWindow) return + + const cardElement = cardRef.current + + // Create ResizeObserver to watch for size changes + const resizeObserver = new ResizeObserver(() => { + updateWindowHeight() + }) + + resizeObserver.observe(cardElement) + + // Initial measurement after a short delay to ensure content is rendered + const initialTimeout = setTimeout(() => { + updateWindowHeight() + }, 200) + + return () => { + resizeObserver.disconnect() + if (resizeTimeoutRef.current) { + clearTimeout(resizeTimeoutRef.current) + } + clearTimeout(initialTimeout) + } + }, [isElectron, resizeSpotlightWindow]) + + return ( +
+ + + +
+ ) +} + +export { + SpotlightProvider, + SpotlightContext, + SpotlightContent, + ElectronSpotlightContentPage +} diff --git a/vite.config.js b/vite.config.js index d524602..89a9a35 100644 --- a/vite.config.js +++ b/vite.config.js @@ -26,7 +26,7 @@ export default defineConfig({ server: { allowedHosts: ['dev.tombutcher.work'], host: '0.0.0.0', - port: 5173, + port: 5780, open: false } }) diff --git a/yarn.lock b/yarn.lock index 8faf68c..069fe35 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3355,7 +3355,7 @@ baseline-browser-mapping@^2.8.25: resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.8.32.tgz#5de72358cf363ac41e7d642af239f6ac5ed1270a" integrity sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw== -bl@^4.1.0: +bl@^4.0.3, bl@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a" integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w== @@ -3671,6 +3671,11 @@ character-reference-invalid@^2.0.0: resolved "https://registry.yarnpkg.com/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz#85c66b041e43b47210faf401278abf808ac45cb9" integrity sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw== +chownr@^1.1.1: + version "1.1.4" + resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" + integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg== + chownr@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece" @@ -4376,7 +4381,7 @@ dequal@^2.0.0: resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== -detect-libc@^2.0.1: +detect-libc@^2.0.0, detect-libc@^2.0.1: version "2.1.2" resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad" integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== @@ -4635,7 +4640,7 @@ encoding@^0.1.13: dependencies: iconv-lite "^0.6.2" -end-of-stream@^1.1.0: +end-of-stream@^1.1.0, end-of-stream@^1.4.1: version "1.4.5" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.5.tgz#7344d711dea40e0b74abc2ed49778743ccedb08c" integrity sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg== @@ -5203,6 +5208,11 @@ execa@^5.1.1: signal-exit "^3.0.3" strip-final-newline "^2.0.0" +expand-template@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/expand-template/-/expand-template-2.0.3.tgz#6e14b3fcee0f3a6340ecb57d2e8918692052a47c" + integrity sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg== + exponential-backoff@^3.1.1: version "3.1.3" resolved "https://registry.yarnpkg.com/exponential-backoff/-/exponential-backoff-3.1.3.tgz#51cf92c1c0493c766053f9d3abee4434c244d2f6" @@ -5480,6 +5490,11 @@ fresh@^2.0.0: resolved "https://registry.yarnpkg.com/fresh/-/fresh-2.0.0.tgz#8dd7df6a1b3a1b3a5cf186c05a5dd267622635a4" integrity sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A== +fs-constants@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" + integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== + fs-extra@^10.0.0, fs-extra@^10.1.0: version "10.1.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.1.0.tgz#02873cfbc4084dde127eaa5f9905eef2325d1abf" @@ -5648,6 +5663,11 @@ get-symbol-description@^1.1.0: es-errors "^1.3.0" get-intrinsic "^1.2.6" +github-from-package@0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/github-from-package/-/github-from-package-0.0.0.tgz#97fb5d96bfde8973313f20e8288ef9a167fa64ce" + integrity sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw== + gl-matrix@^3.3.0, gl-matrix@^3.4.3: version "3.4.4" resolved "https://registry.yarnpkg.com/gl-matrix/-/gl-matrix-3.4.4.tgz#7789ee4982f62c7a7af447ee488f3bd6b0c77003" @@ -6505,6 +6525,14 @@ keycloak-js@^26.2.0: resolved "https://registry.yarnpkg.com/keycloak-js/-/keycloak-js-26.2.1.tgz#644e8b8403268f7a39c3980183e22a6fb64f17db" integrity sha512-bZt6fQj/TLBAmivXSxSlqAJxBx/knNZDQGJIW4ensGYGN4N6tUKV8Zj3Y7/LOV8eIpvWsvqV70fbACihK8Ze0Q== +keytar@^7.9.0: + version "7.9.0" + resolved "https://registry.yarnpkg.com/keytar/-/keytar-7.9.0.tgz#4c6225708f51b50cbf77c5aae81721964c2918cb" + integrity sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ== + dependencies: + node-addon-api "^4.3.0" + prebuild-install "^7.0.1" + keyv@^4.0.0, keyv@^4.5.3, keyv@^4.5.4: version "4.5.4" resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" @@ -7301,7 +7329,7 @@ minimatch@^9.0.3, minimatch@^9.0.4: dependencies: brace-expansion "^2.0.1" -minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6: +minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.5, minimist@^1.2.6: version "1.2.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== @@ -7370,6 +7398,11 @@ minizlib@^2.1.1, minizlib@^2.1.2: minipass "^3.0.0" yallist "^4.0.0" +mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3: + version "0.5.3" + resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" + integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== + mkdirp@^1.0.3, mkdirp@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" @@ -7431,6 +7464,11 @@ nanopop@2.3.0: resolved "https://registry.yarnpkg.com/nanopop/-/nanopop-2.3.0.tgz#a5f672fba27d45d6ecbd0b59789c040072915123" integrity sha512-fzN+T2K7/Ah25XU02MJkPZ5q4Tj5FpjmIYq4rvoHX4yb16HzFdCO6JxFFn5Y/oBhQ8no8fUZavnyIv9/+xkBBw== +napi-build-utils@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/napi-build-utils/-/napi-build-utils-2.0.0.tgz#13c22c0187fcfccce1461844136372a47ddc027e" + integrity sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA== + natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" @@ -7454,7 +7492,7 @@ no-case@^3.0.4: lower-case "^2.0.2" tslib "^2.0.3" -node-abi@^3.45.0: +node-abi@^3.3.0, node-abi@^3.45.0: version "3.85.0" resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.85.0.tgz#b115d575e52b2495ef08372b058e13d202875a7d" integrity sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg== @@ -7466,6 +7504,11 @@ node-addon-api@^1.6.3: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-1.7.2.tgz#3df30b95720b53c24e59948b49532b662444f54d" integrity sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg== +node-addon-api@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-4.3.0.tgz#52a1a0b475193e0928e98e0426a0d1254782b77f" + integrity sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ== + node-api-version@^0.2.0: version "0.2.1" resolved "https://registry.yarnpkg.com/node-api-version/-/node-api-version-0.2.1.tgz#19bad54f6d65628cbee4e607a325e4488ace2de9" @@ -7922,6 +7965,24 @@ postcss@^8.5.6: picocolors "^1.1.1" source-map-js "^1.2.1" +prebuild-install@^7.0.1: + version "7.1.3" + resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-7.1.3.tgz#d630abad2b147443f20a212917beae68b8092eec" + integrity sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug== + dependencies: + detect-libc "^2.0.0" + expand-template "^2.0.3" + github-from-package "0.0.0" + minimist "^1.2.3" + mkdirp-classic "^0.5.3" + napi-build-utils "^2.0.0" + node-abi "^3.3.0" + pump "^3.0.0" + rc "^1.2.7" + simple-get "^4.0.0" + tar-fs "^2.0.0" + tunnel-agent "^0.6.0" + prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" @@ -8429,7 +8490,7 @@ rc-virtual-list@^3.14.2, rc-virtual-list@^3.5.1, rc-virtual-list@^3.5.2: rc-resize-observer "^1.0.0" rc-util "^5.36.0" -rc@^1.0.1, rc@^1.1.6: +rc@^1.0.1, rc@^1.1.6, rc@^1.2.7: version "1.2.8" resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== @@ -8544,7 +8605,7 @@ read-pkg@^2.0.0: normalize-package-data "^2.3.2" path-type "^2.0.0" -readable-stream@^3.4.0: +readable-stream@^3.1.1, readable-stream@^3.4.0: version "3.6.2" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== @@ -9072,6 +9133,20 @@ signal-exit@^4.0.1: resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== +simple-concat@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/simple-concat/-/simple-concat-1.0.1.tgz#f46976082ba35c2263f1c8ab5edfe26c41c9552f" + integrity sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q== + +simple-get@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-4.0.1.tgz#4a39db549287c979d352112fa03fd99fd6bc3543" + integrity sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA== + dependencies: + decompress-response "^6.0.0" + once "^1.3.1" + simple-concat "^1.0.0" + simple-swizzle@^0.2.2: version "0.2.4" resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.4.tgz#a8d11a45a11600d6a1ecdff6363329e3648c3667" @@ -9563,6 +9638,27 @@ synckit@^0.11.7: dependencies: "@pkgr/core" "^0.2.9" +tar-fs@^2.0.0: + version "2.1.4" + resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.4.tgz#800824dbf4ef06ded9afea4acafe71c67c76b930" + integrity sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ== + dependencies: + chownr "^1.1.1" + mkdirp-classic "^0.5.2" + pump "^3.0.0" + tar-stream "^2.1.4" + +tar-stream@^2.1.4: + version "2.2.0" + resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287" + integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ== + dependencies: + bl "^4.0.3" + end-of-stream "^1.4.1" + fs-constants "^1.0.0" + inherits "^2.0.3" + readable-stream "^3.1.1" + tar@^6.0.5, tar@^6.1.11, tar@^6.1.12, tar@^6.2.1: version "6.2.1" resolved "https://registry.yarnpkg.com/tar/-/tar-6.2.1.tgz#717549c541bc3c2af15751bea94b1dd068d4b03a" @@ -9725,6 +9821,13 @@ tsparticles@^3.9.1: "@tsparticles/updater-twinkle" "3.9.1" "@tsparticles/updater-wobble" "3.9.1" +tunnel-agent@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" + integrity sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w== + dependencies: + safe-buffer "^5.0.1" + type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"