diff --git a/src/main/analytics/api.ts b/src/main/analytics/api.ts index 0df7b74..3761b4f 100644 --- a/src/main/analytics/api.ts +++ b/src/main/analytics/api.ts @@ -39,7 +39,6 @@ export async function handleRegisterUser(): Promise<{ } analyticsPostInterval = setInterval(async () => { - console.log("Posting user active"); await handlePostUserActive(); }, 15000); // 15 seconds @@ -54,7 +53,6 @@ export async function handleUserActiveExit(): Promise { if (userActiveId && analyticsPostInterval) { clearInterval(analyticsPostInterval); - console.log("Cleared analyticsPostInterval"); const res = await fetch(postUrl, { method: "POST", headers: { diff --git a/src/main/deeplinking/app.ts b/src/main/deeplinking/app.ts new file mode 100644 index 0000000..2b7cc01 --- /dev/null +++ b/src/main/deeplinking/app.ts @@ -0,0 +1,102 @@ +import { dialog } from "electron"; +import { openInMainWindow } from "../utils/openInMainWindow"; +import { getConfig, patchConfig } from "../ipc/config"; +import { + ActionType, + Event, + eventTypeReadableMap, +} from "../../shared/config/config_types"; + +export async function handleOpenWhitelisted( + url: URL, + _groups: RegExpMatchArray | null, +) { + await openInMainWindow(`${url.pathname}${url.search}`); +} + +export async function handlePatchConfig( + url: URL, + _groups: RegExpMatchArray | null, +) { + try { + const config = JSON.parse(url.searchParams.get("config") ?? "{}"); + + if (!config) return; + + const { response } = await dialog.showMessageBox({ + type: "question", + defaultId: 0, + buttons: ["Apply config changes", "Cancel"], + title: "Do you want to apply config changes?", + message: "Do you want to apply config changes?", + detail: `Config patch:\n\n${JSON.stringify(config, null, 2)}`, + }); + + if (response === 1) return; + + await patchConfig(config); + } catch (e) { + dialog.showErrorBox("Error", "Invalid config"); + } +} + +export async function handleAddEvent( + url: URL, + _groups: RegExpMatchArray | null, +) { + const getReadableEvent = (event: Event) => { + const name = event.name; + const triggers = event.triggers + .map((trigger) => eventTypeReadableMap[trigger]) + .join(", "); + + const actions = event.actions + .map((action, index) => { + let actionString = `${index + 1}. ${action.type}`; + switch (action.type) { + case ActionType.On: + actionString += `: ${action.color ? `RGB = ${action.color.r}, ${action.color.g}, ${action.color.b}` : "N/A"}, Brightness = ${action.brightness ?? "N/A"}`; + break; + case ActionType.Off: + break; + case ActionType.Delay: + actionString += `: ${action.delay ?? "N/A"}ms`; + break; + case ActionType.GoBackToCurrentStatus: + break; + default: + break; + } + return actionString; + }) + .join("\n"); + + return `Event:\n\nName: ${name}\nTriggers: ${triggers}\nActions:\n${actions}`; + }; + + try { + const event = JSON.parse(url.searchParams.get("event") ?? "{}"); + + if (!event) return; + + const { response } = await dialog.showMessageBox({ + type: "question", + defaultId: 0, + buttons: ["Apply config changes", "Cancel"], + title: "Add shared event?", + message: "Do you want to add this shared event to your config?", + detail: getReadableEvent(event), + }); + + if (response === 1) return; + + const config = await getConfig(); + const events = config.events ?? []; + const nextId = Math.max(...events.map((e) => e.id)) + 1; + await patchConfig({ + events: [...events, { ...event, id: nextId }], + }); + } catch (e) { + dialog.showErrorBox("Error", "Invalid config"); + } +} diff --git a/src/main/deeplinking/index.ts b/src/main/deeplinking/index.ts new file mode 100644 index 0000000..c2fa13f --- /dev/null +++ b/src/main/deeplinking/index.ts @@ -0,0 +1,107 @@ +import path from "path"; +import { app, ipcMain } from "electron"; +import { createMainWindow, mainWindow } from ".."; +import { + handleAddEvent, + handleOpenWhitelisted, + handlePatchConfig, +} from "./app"; + +interface IRoute { + host: string; + match: RegExp; + handler: (uri: URL, groups: RegExpMatchArray | null) => void; +} + +const protocols = ["f1mvlightsintegration", "f1mvli", "f1mvlights"]; +const uriRegex = new RegExp(`^(${protocols.join("|")})://(.*)$`); + +const router: IRoute[] = [ + { + host: "app", + match: /^\/settings/i, + handler: handleOpenWhitelisted, + }, + { + host: "app", + match: /^\/config\/patch/i, + handler: handlePatchConfig, + }, + { + host: "app", + match: /^\/event-editor/i, + handler: handleOpenWhitelisted, + }, + { + host: "app", + match: /^\/config\/add-event/i, + handler: handleAddEvent, + }, +]; + +function handleOpenURL(event: Electron.Event | undefined, url: string) { + event?.preventDefault(); + + const uri = new URL(url); + const { pathname, host } = uri; + + const route = router.find((r) => r.host === host && r.match.test(pathname)); + if (!route) return; + + route.handler(uri, pathname.match(route.match)); +} + +function handleSecondInstance(event: Electron.Event, argv: string[]) { + if (process.platform === "win32" || process.platform === "linux") { + const uri = argv.find((arg) => uriRegex.test(arg)); + if (uri) handleOpenURL(event, uri); + } + + if (!mainWindow) createMainWindow(); + if (mainWindow?.isMinimized()) mainWindow.restore(); + + mainWindow?.focus(); +} + +export function handleDeepLink(argv: string[]) { + const uri = argv.find((arg) => uriRegex.test(arg)); + if (uri) handleOpenURL(undefined, uri); +} + +export function registerDeepLink() { + try { + if (process.env.NODE_ENV !== "development") { + if (process.defaultApp) { + if (process.argv.length >= 2) { + protocols.forEach((protocol) => { + app.setAsDefaultProtocolClient(protocol, process.execPath, [ + path.resolve(process.argv[1]), + ]); + }); + } + } else { + protocols.forEach((protocol) => { + app.setAsDefaultProtocolClient(protocol); + }); + } + } + + app.on("open-url", handleOpenURL); + app.on("second-instance", handleSecondInstance); + + ipcMain.handle("f1mvli:deep-link:open-url", handleOpenURL); + } catch (e: any) { + console.error("Failed to register F1MV Lights Integration protocol:", e); + } + + return () => { + app.off("open-url", handleOpenURL); + app.off("second-instance", handleSecondInstance); + + ipcMain.removeHandler("f1mvli:deep-link:open-url"); + + protocols.forEach((protocol) => { + app.removeAsDefaultProtocolClient(protocol); + }); + }; +} diff --git a/src/main/index.ts b/src/main/index.ts index e2e2b72..0387a65 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -21,6 +21,7 @@ import { registerEventManagerIPCHandlers } from "./ipc/eventManager"; import { registerIntegrationsIPCHandlers } from "./ipc/integrations"; import { initializeIntegrations } from "./initIntegrations"; import { handleRegisterUser, handleUserActiveExit } from "./analytics/api"; +import { registerDeepLink } from "./deeplinking"; Sentry.init({ dsn: "https://e64c3ec745124566b849043192e58711@o4504289317879808.ingest.sentry.io/4504289338392576", @@ -31,39 +32,12 @@ Sentry.init({ tracesSampleRate: 1.0, }); -// handle deep-link protocol -if (process.defaultApp) { - if (process.argv.length >= 2) { - app.setAsDefaultProtocolClient("f1mvli", process.execPath, [ - path.resolve(process.argv[1]), - ]); - } -} else { - app.setAsDefaultProtocolClient("f1mvli"); -} - // Disable GPU Acceleration for Windows 7 if (release().startsWith("6.1")) app.disableHardwareAcceleration(); // Set application name for Windows 10+ notifications if (process.platform === "win32") app.setAppUserModelId(app.getName()); -app.on("open-url", async (event) => { - event.preventDefault(); - if (win) { - win.show(); - win.focus(); - } -}); - -app.on("second-instance", async () => { - if (win) { - if (win.isMinimized()) win.restore(); - win.show(); - win.focus(); - } -}); - if (!app.requestSingleInstanceLock()) { app.quit(); process.exit(0); @@ -78,13 +52,13 @@ process.env.PUBLIC = process.env.VITE_DEV_SERVER_URL // Remove electron security warnings // process.env['ELECTRON_DISABLE_SECURITY_WARNINGS'] = 'true' -let win: BrowserWindow | null = null; +export let mainWindow: BrowserWindow | null = null; export let availablePort: number | null = null; export const preload = path.join(__dirname, "../preload/index.js"); -export const url = process.env.VITE_DEV_SERVER_URL; +export const devServerUrl = process.env.VITE_DEV_SERVER_URL; -async function createWindow() { - win = new BrowserWindow({ +export async function createMainWindow() { + mainWindow = new BrowserWindow({ title: "F1MV Lights Integration", icon: join(process.env.PUBLIC, "favicon.ico"), width: 1100, @@ -104,11 +78,11 @@ async function createWindow() { if (process.env.VITE_DEV_SERVER_URL) { // electron-vite-vue#298 - await win.loadURL(url); - win.webContents.openDevTools({ mode: "detach" }); - win.setMenuBarVisibility(false); + await mainWindow.loadURL(devServerUrl); + mainWindow.webContents.openDevTools({ mode: "detach" }); + mainWindow.setMenuBarVisibility(false); } else { - win.setMenuBarVisibility(false); + mainWindow.setMenuBarVisibility(false); availablePort = await portfinder.getPortPromise({ port: 30303, host: "localhost", @@ -119,22 +93,25 @@ async function createWindow() { }); }); server.listen(availablePort, () => { - win?.loadURL(`http://localhost:${availablePort}/index.html`); + mainWindow?.loadURL(`http://localhost:${availablePort}/index.html`); }); } - win.webContents.setWindowOpenHandler(({ url }) => { + mainWindow.webContents.setWindowOpenHandler(({ url }) => { if (url.startsWith("https:" || "http:")) shell.openExternal(url); return { action: "deny" }; }); - win.webContents.on("will-navigate", (event: Electron.Event, url: string) => { - if (url.startsWith("https:" || "http:") && !url.includes("localhost")) { - event.preventDefault(); - shell.openExternal(url); - } - }); + mainWindow.webContents.on( + "will-navigate", + (event: Electron.Event, url: string) => { + if (url.startsWith("https:" || "http:") && !url.includes("localhost")) { + event.preventDefault(); + shell.openExternal(url); + } + }, + ); - win.webContents.setZoomLevel(0); + mainWindow.webContents.setZoomLevel(0); } let _configIPCCleanup: () => void; @@ -148,7 +125,7 @@ let _integrationsIPCCleanup: () => void; app.whenReady().then(onReady); function onReady() { - createWindow(); + createMainWindow(); _configIPCCleanup = registerConfigIPCHandlers(); _updaterIPCCleanup = registerUpdaterIPCHandlers(); @@ -172,6 +149,7 @@ function onReady() { } log.transports.file.level = globalConfig.debugMode ? "debug" : "info"; log.info("App starting..."); + registerDeepLink(); startLiveTimingDataPolling(); fetchAuthoritativeConfig(); setInterval( @@ -187,7 +165,7 @@ function onReady() { app.on("window-all-closed", async () => { await handleUserActiveExit(); - win = null; + mainWindow = null; //todo: add cleanup for integrations @@ -199,6 +177,6 @@ app.on("activate", () => { if (allWindows.length) { allWindows[0].focus(); } else { - createWindow(); + createMainWindow(); } }); diff --git a/src/main/utils/openInMainWindow.ts b/src/main/utils/openInMainWindow.ts new file mode 100644 index 0000000..ac6e6f4 --- /dev/null +++ b/src/main/utils/openInMainWindow.ts @@ -0,0 +1,9 @@ +import { mainWindow, availablePort } from ".."; + +export async function openInMainWindow(pathname: string) { + const baseUrl = + process.env.VITE_DEV_SERVER_URL || `http://localhost:${availablePort}`; + const url = new URL(baseUrl); + url.hash = `#${pathname}${url.search}`; + await mainWindow?.loadURL(url.toString()); +} diff --git a/src/renderer/pages/EventEditor.tsx b/src/renderer/pages/EventEditor.tsx index b0f7c65..839425e 100644 --- a/src/renderer/pages/EventEditor.tsx +++ b/src/renderer/pages/EventEditor.tsx @@ -61,7 +61,10 @@ export function EventEditorPage() { (id: GridRowId) => { const event = config.events.find((event) => event.id === id); if (!event) return; - window.navigator.clipboard.writeText(JSON.stringify(event)); + const url = + "https://api.jstt.me/api/v2/f1mvli/go/app/config/add-event?event=" + + JSON.stringify(event); + window.navigator.clipboard.writeText(url); enqueueSnackbar("Event url copied to clipboard", { variant: "success" }); }, [config],