diff --git a/gcs/electron/main.ts b/gcs/electron/main.ts index 54d69995f..f9fec645f 100644 --- a/gcs/electron/main.ts +++ b/gcs/electron/main.ts @@ -57,6 +57,8 @@ if (process.platform === "linux") { let win: BrowserWindow | null let loadingWin: BrowserWindow | null +let isConnectedToDrone = false +let quittingApproved = false // 🚧 Use ['ENV_NAME'] avoid vite:define plugin - Vite@2.x const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"] @@ -138,6 +140,11 @@ ipcMain.handle("settings:save-settings", (_, settings) => { saveUserConfiguration(settings) }) +// Cache connection state from renderer +ipcMain.on("app:connected-state", (_event, connected: boolean) => { + isConnectedToDrone = Boolean(connected) +}) + ipcMain.handle("app:is-mac", () => { return process.platform == "darwin" }) @@ -431,12 +438,46 @@ app.on("window-all-closed", () => { // To ensure that the backend process is killed with Cmd + Q on macOS, // listen to the before-quit event. -app.on("before-quit", () => { - if (process.platform === "darwin" && pythonBackend) { - console.log("Stopping backend") - spawnSync("pkill", ["-f", "fgcs_backend"]) - pythonBackend = null - closeWindows() +app.on("before-quit", (e) => { + if (process.platform !== "darwin") return + + // User already approved, let it proceed without re-prompting + if (quittingApproved) return + + if (isConnectedToDrone && win && !win.isDestroyed()) { + e.preventDefault() + const choice = dialog.showMessageBoxSync(win, { + type: "warning", + buttons: ["Cancel", "Quit"], + defaultId: 0, + title: "Confirm Quit", + message: "Are you sure you want to quit FGCS?", + detail: "You are connected to an aircraft.", + }) + if (choice === 1) { + quittingApproved = true + if (pythonBackend) { + console.log("Stopping backend") + spawnSync("pkill", ["-f", "fgcs_backend"]) + pythonBackend = null + } + // Close all popout windows + closeWindows() + // Destroy main window + if (win && !win.isDestroyed()) { + win.destroy() + } + app.quit() + } + // choice === 0 (Cancel): do nothing, quit is prevented + } else { + // Not connected or no window: stop backend and proceed + if (pythonBackend) { + console.log("Stopping backend") + spawnSync("pkill", ["-f", "fgcs_backend"]) + pythonBackend = null + closeWindows() + } } }) diff --git a/gcs/electron/preload.js b/gcs/electron/preload.js index 1aa9cdf1e..6aec86ad0 100644 --- a/gcs/electron/preload.js +++ b/gcs/electron/preload.js @@ -47,6 +47,8 @@ const ALLOWED_SEND_CHANNELS = [ "window:zoom-in", "window:zoom-out", "window:open-file-in-explorer", + // app state updates + "app:connected-state", ] const ALLOWED_ON_CHANNELS = [ diff --git a/gcs/src/components/mainContent.jsx b/gcs/src/components/mainContent.jsx index 61059dc8e..5a06dc59d 100644 --- a/gcs/src/components/mainContent.jsx +++ b/gcs/src/components/mainContent.jsx @@ -25,18 +25,30 @@ import Navbar from "./navbar" // Redux import { ErrorBoundary } from "react-error-boundary" -import { useDispatch } from "react-redux" +import { useDispatch, useSelector } from "react-redux" import { initSocket } from "../redux/slices/socketSlice" +import { selectConnectedToDrone } from "../redux/slices/droneConnectionSlice" import AlertProvider from "./dashboard/alerts/alertProvider" import ErrorBoundaryFallback from "./error/errorBoundary" export default function AppContent() { // Setup sockets for redux const dispatch = useDispatch() + const connectedToDrone = useSelector(selectConnectedToDrone) useEffect(() => { dispatch(initSocket()) }, []) + // Send connection state changes to main so it can own quit policy on macOS + useEffect(() => { + try { + window.ipcRenderer.send("app:connected-state", connectedToDrone) + } catch { + // Ignore IPC errors if main process isn't ready + console.log("IPC Call Failed: app:connected-state") + } + }, [connectedToDrone]) + return ( diff --git a/gcs/src/components/toolbar/confirmExitModal.jsx b/gcs/src/components/toolbar/confirmExitModal.jsx new file mode 100644 index 000000000..0f813a588 --- /dev/null +++ b/gcs/src/components/toolbar/confirmExitModal.jsx @@ -0,0 +1,62 @@ +// Custom Imports +import { Group, Modal, Button, Text } from "@mantine/core" + +// Redux +import { useDispatch, useSelector } from "react-redux" +import { + selectConfirmExitModalOpen, + setConfirmExitModalOpen, +} from "../../redux/slices/applicationSlice" + +// Tailwind +import resolveConfig from "tailwindcss/resolveConfig" +import tailwindConfig from "../../../tailwind.config" +const tailwindColors = resolveConfig(tailwindConfig).theme.colors + +export default function ConfirmExitModal() { + const dispatch = useDispatch() + const modalOpen = useSelector(selectConfirmExitModalOpen) + + const confirmExit = () => { + window.ipcRenderer.send("window:close") + } + + return ( + dispatch(setConfirmExitModalOpen(false))} + title="Are you sure you want to quit FGCS?" + centered + overlayProps={{ + backgroundOpacity: 0.55, + blur: 3, + }} + styles={{ + content: { + borderRadius: "0.5rem", + }, + }} + withCloseButton={false} + > + + You are connected to an aircraft, are you sure you want to quit FGCS? + + + + + + + ) +} diff --git a/gcs/src/components/toolbar/toolbar.jsx b/gcs/src/components/toolbar/toolbar.jsx index bf5ee0e26..4b373c846 100644 --- a/gcs/src/components/toolbar/toolbar.jsx +++ b/gcs/src/components/toolbar/toolbar.jsx @@ -14,17 +14,34 @@ import { CloseIcon, MaximizeIcon, MinimizeIcon } from "./icons.jsx" import AdvancedMenu from "./menus/advanced.jsx" import FileMenu from "./menus/file.jsx" import ViewMenu from "./menus/view.jsx" +import ConfirmExitModal from "./confirmExitModal.jsx" + +// Redux +import { useDispatch, useSelector } from "react-redux" +import { selectConnectedToDrone } from "../../redux/slices/droneConnectionSlice.js" +import { setConfirmExitModalOpen } from "../../redux/slices/applicationSlice.js" export default function Toolbar() { + const dispatch = useDispatch() const [areMenusActive, setMenusActive] = useState(false) const [isMac, setIsMac] = useState(false) + const connectedToDrone = useSelector(selectConnectedToDrone) + useEffect(() => { window.ipcRenderer.invoke("app:is-mac").then((result) => { setIsMac(result) }) }, []) + const onClose = () => { + if (connectedToDrone) { + dispatch(setConfirmExitModalOpen(true)) + } else { + window.ipcRenderer.send("window:close", []) + } + } + return ( <>
{ - window.ipcRenderer.send("window:close", []) - }} + onClick={() => onClose()} label="Close" > @@ -110,6 +125,8 @@ export default function Toolbar() {
)} + + ) } diff --git a/gcs/src/redux/slices/applicationSlice.js b/gcs/src/redux/slices/applicationSlice.js new file mode 100644 index 000000000..b37033259 --- /dev/null +++ b/gcs/src/redux/slices/applicationSlice.js @@ -0,0 +1,22 @@ +import { createSlice } from "@reduxjs/toolkit" + +const applicationSlice = createSlice({ + name: "application", + initialState: { confirmExitModalOpen: false }, + reducers: { + // Setters + setConfirmExitModalOpen: (state, action) => { + if (action.payload !== state.confirmExitModalOpen) { + state.confirmExitModalOpen = action.payload + } + }, + }, + selectors: { + selectConfirmExitModalOpen: (state) => state.confirmExitModalOpen, + }, +}) + +export const { setConfirmExitModalOpen } = applicationSlice.actions +export const { selectConfirmExitModalOpen } = applicationSlice.selectors + +export default applicationSlice diff --git a/gcs/src/redux/store.js b/gcs/src/redux/store.js index 14b7ca3da..94d1ad779 100644 --- a/gcs/src/redux/store.js +++ b/gcs/src/redux/store.js @@ -2,7 +2,7 @@ import { combineSlices, configureStore } from "@reduxjs/toolkit" import droneInfoSlice, { setGraphValues } from "./slices/droneInfoSlice" import logAnalyserSlice from "./slices/logAnalyserSlice" import socketSlice from "./slices/socketSlice" - +import applicationSlice from "./slices/applicationSlice" import socketMiddleware from "./middleware/socketMiddleware" import configSlice from "./slices/configSlice" import droneConnectionSlice, { @@ -30,6 +30,7 @@ const rootReducer = combineSlices( statusTextSlice, paramsSlice, configSlice, + applicationSlice, ) export const store = configureStore({