diff --git a/src/AdbRouter.jsx b/src/AdbRouter.jsx index 2d369a648..165c18bfb 100644 --- a/src/AdbRouter.jsx +++ b/src/AdbRouter.jsx @@ -39,7 +39,7 @@ import { } from "./features/device/deviceSlice"; import { - selectCheckedMaster, + selectChecked as selectCheckedMaster, selectIsMaster, } from "./features/tabGovernor/tabGovernorSlice"; @@ -57,15 +57,12 @@ export default function AdbRouter() { const [adb, setAdb] = useState(null); const [device, setDevice] = useState(null); const [intervalId, setIntervalId] = useState(null); - const [watcher, setWatcher] = useState(null); - - const [canAutoConnect, setCanAutoConnect] = useState(false); const adbRef = useRef(); const deviceRef = useRef(); + const devicePromiseRef = useRef(); const intervalRef = useRef(); const watcherRef = useRef(); - const devicePromiseRef = useRef(); const connectToDevice = useCallback(async (device) => { if(device && !adb) { @@ -133,13 +130,14 @@ export default function AdbRouter() { * connect to. */ const autoConnect = useCallback(async() => { + const canAutoConnect = (!devicePromiseRef.current && checkedMasterState && isMaster); if(canAutoConnect) { const devices = await AdbWebUsbBackend.getDevices(); if(devices.length > 0) { await connectToDevice(devices[0]); } } - }, [canAutoConnect, connectToDevice]); + }, [connectToDevice, checkedMasterState, devicePromiseRef, isMaster]); // Handle button press for device connection const handleDeviceConnect = useCallback(async() => { @@ -155,15 +153,14 @@ export default function AdbRouter() { } }, [connectToDevice, devicePromiseRef, dispatch]); - // Check if we are able to auto connect to the device - useEffect(() => { - setCanAutoConnect(!devicePromiseRef.current && checkedMasterState && isMaster); - }, [checkedMasterState, devicePromiseRef, isMaster]); - // Set watcher to monitor WebUSB devices popping up or going away useEffect(() => { - if(!watcher && window.navigator.usb) { - const watcher = new AdbWebUsbBackendWatcher(async (id) => { + if(window.navigator.usb) { + if(watcherRef.current) { + watcherRef.current.dispose(); + } + + watcherRef.current = new AdbWebUsbBackendWatcher(async (id) => { if(!id) { setAdb(null); dispatch(resetDevice()); @@ -173,10 +170,8 @@ export default function AdbRouter() { await autoConnect(); } }); - - setWatcher(watcher); } - }, [autoConnect, connectToDevice, dispatch, watcher]); + }, [autoConnect, dispatch, watcherRef]); // Automatically try to connect to device when application starts up useEffect(() => { @@ -212,7 +207,6 @@ export default function AdbRouter() { setAdb(null); setDevice(null); setIntervalId(null); - setWatcher(null); }; }, [dispatch]); @@ -229,10 +223,6 @@ export default function AdbRouter() { intervalRef.current = intervalId; }, [intervalId]); - useEffect(() => { - watcherRef.current = watcher; - }, [watcher]); - return( { dispatch(setMaster(isMaster)); - dispatch(checkedMaster(true)); + dispatch(setChecked()); }, (canClaim) => { dispatch(setCanClaim(canClaim)); }); diff --git a/src/features/overlays/Spinner.jsx b/src/features/overlays/Spinner.jsx new file mode 100644 index 000000000..711a0cfad --- /dev/null +++ b/src/features/overlays/Spinner.jsx @@ -0,0 +1,31 @@ +import PropTypes from "prop-types"; +import React from "react"; + +import Box from "@mui/material/Box"; + +import Spinner from "../loading/Spinner"; + +export default function SpinnerOverlay({ text }) { + return( + + + + ); +} + +SpinnerOverlay.defaultProps = { text: "" }; + +SpinnerOverlay.propTypes = { text: PropTypes.string }; diff --git a/src/features/package/Package.jsx b/src/features/package/Package.jsx index e30313343..68596232f 100644 --- a/src/features/package/Package.jsx +++ b/src/features/package/Package.jsx @@ -2,6 +2,7 @@ import PropTypes from "prop-types"; import React, { useCallback, useEffect, + useState, } from "react"; import { useDispatch, @@ -16,6 +17,12 @@ import Form from "@rjsf/mui"; import Alert from "@mui/material/Alert"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; +import DeleteIcon from "@mui/icons-material/Delete"; +import DownloadIcon from "@mui/icons-material/Download"; +import Grid from "@mui/material/Grid"; +import IconButton from "@mui/material/IconButton"; +import InfoIcon from "@mui/icons-material/Info"; +import Link from "@mui/material/Link"; import Paper from "@mui/material/Paper"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; @@ -26,6 +33,7 @@ import { reset, selectConfig, selectDescription, + selectDetails, selectError, selectFetched, selectInstalled, @@ -36,8 +44,15 @@ import { writeConfig, } from "./packageSlice"; +import { + installPackage, + removePackage, + selectError as selectInstallationError, + selectProcessing, +} from "../packages/packagesSlice"; + import { selectPassed } from "../healthcheck/healthcheckSlice"; -import Spinner from "../loading/Spinner"; +import Spinner from "../overlays/Spinner"; export default function Package({ adb }) { const { t } = useTranslation("package"); @@ -45,11 +60,15 @@ export default function Package({ adb }) { let { packageSlug } = useParams(); + const [installing, setInstalling] = useState(false); + const [removing, setRemoving] = useState(false); + const healthchecksPassed = useSelector(selectPassed); const packageName = useSelector(selectName); const description = useSelector(selectDescription); const installed = useSelector(selectInstalled); + const details = useSelector(selectDetails); const fetched = useSelector(selectFetched); const config = useSelector(selectConfig); @@ -59,6 +78,9 @@ export default function Package({ adb }) { const writing = useSelector(selectWriting); const error = useSelector(selectError); + const isProcessing = useSelector(selectProcessing); + const installationError = useSelector(selectInstallationError); + /** * Fetch package details if healthchecks passed and dtails are not yet * set for the selected package. @@ -78,6 +100,18 @@ export default function Package({ adb }) { } }, [dispatch, packageName, packageSlug]); + useEffect(() => { + if(!isProcessing) { + setInstalling(false); + setRemoving(false); + + dispatch(fetchPackage({ + adb, + name: packageSlug, + })); + } + }, [adb, dispatch, isProcessing, packageSlug, setInstalling, setRemoving]); + // Fetch config and schema if package is installed useEffect(() => { if(installed) { @@ -92,44 +126,191 @@ export default function Package({ adb }) { })); }, [adb, dispatch]); + const removeHandler = useCallback(() => { + setRemoving(true); + dispatch(removePackage({ + adb, + name: packageName, + })); + }, [adb, dispatch, packageName, setRemoving]); + + const installHandler = useCallback(() => { + setInstalling(true); + dispatch(installPackage({ + adb, + name: packageName, + })); + }, [adb, dispatch, packageName, setInstalling]); + + let loadingText = t("loading"); + if(installing) { + loadingText = t("installing"); + } else if(removing) { + loadingText = t("removing"); + } + + const isLoading = loading || installing || removing; + const errorText = installationError.map((line) => { + return ( + + {line} + + ); + }); + return ( - - - - - {t("detailsFor", { name: packageSlug })} - - - {loading && - } - - - {description} - - - {schema && -
+ {installationError.length > 0 && + + {errorText} + } + + + + + - - } - - {error && - - {error} - } - - -
+ + } + + {error && + + {error} + } +
+
+ + {isLoading && + } +
+ ); } diff --git a/src/features/package/packageSlice.js b/src/features/package/packageSlice.js index 88d893861..f8afa0819 100644 --- a/src/features/package/packageSlice.js +++ b/src/features/package/packageSlice.js @@ -10,6 +10,7 @@ const initialState = { name: null, description: null, installed: false, + details: { homePage: null }, schema: null, config: null, @@ -66,6 +67,11 @@ export const packageSlice = createSlice({ state.name = action.payload.name; state.description = action.payload.description; state.installed = action.payload.installed; + + state.details = { + ...state.details, + ...action.payload.details, + }; }).addCase(fetchConfig.pending, (state, action) => { state.config = null; state.schema = null; @@ -91,6 +97,7 @@ export const selectFetched = (state) => state.package.fetched; export const selectName = (state) => state.package.name; export const selectDescription = (state) => state.package.description; export const selectInstalled = (state) => state.package.installed; +export const selectDetails = (state) => state.package.details; export const selectWriting = (state) => state.package.writing; export const selectError = (state) => state.package.error; diff --git a/src/features/packages/packagesSlice.js b/src/features/packages/packagesSlice.js index 24a1224a2..bff14e82e 100644 --- a/src/features/packages/packagesSlice.js +++ b/src/features/packages/packagesSlice.js @@ -24,7 +24,8 @@ export const removePackage = createAsyncThunk( async ({ adb, name, - }) => { + }, { rejectWithValue }) => { + let output = ["Unknown error during removal."]; try { const output = await adb.removePackage(name); @@ -34,6 +35,12 @@ export const removePackage = createAsyncThunk( } catch(e) { console.log(e); } + + if(output.stdout) { + output = output.stdout.split("\n"); + } + + return rejectWithValue(output); } ); @@ -43,7 +50,7 @@ export const installPackage = createAsyncThunk( adb, name, }, { rejectWithValue }) => { - let output = ["ERROR: Unknown error during installation."]; + let output = ["Unknown error during installation."]; try { output = await adb.installPackage(name); @@ -204,6 +211,11 @@ export const packagesSlice = createSlice({ state.fetched = false; state.processing = false; }) + .addCase(removePackage.rejected, (state, action) => { + state.error = action.payload; + state.processing = false; + state.fetched = false; + }) .addCase(installPackage.pending, (state, action) => { state.processing = true; }) diff --git a/src/features/tabGovernor/tabGovernorSlice.js b/src/features/tabGovernor/tabGovernorSlice.js index 530944355..6657a5385 100644 --- a/src/features/tabGovernor/tabGovernorSlice.js +++ b/src/features/tabGovernor/tabGovernorSlice.js @@ -3,7 +3,7 @@ import { createSlice } from "@reduxjs/toolkit"; const initialState = { canClaim: true, claimed: false, - checkedMaster: false, + checked: false, isMaster: true, }; @@ -20,14 +20,14 @@ export const tabGovernorSlice = createSlice({ setClaimed: (state, action) => { state.claimed = action.payload; }, - checkedMaster: (state, action) => { - state.checkedMaster = true; + setChecked: (state, action) => { + state.checked = true; }, }, }); export const { - checkedMaster, + setChecked, setCanClaim, setClaimed, setMaster, @@ -35,7 +35,7 @@ export const { export const selectIsMaster = (state) => state.tabGovernor.isMaster; export const selectCanClaim = (state) => state.tabGovernor.canClaim; -export const selectCheckedMaster = (state) => state.tabGovernor.checkedMaster; +export const selectChecked = (state) => state.tabGovernor.checked; export const selectClaimed = (state) => state.tabGovernor.claimed; export default tabGovernorSlice.reducer; diff --git a/src/translations/en/package.json b/src/translations/en/package.json index 1c49510b4..84720c867 100644 --- a/src/translations/en/package.json +++ b/src/translations/en/package.json @@ -1,5 +1,11 @@ { "detailsFor": "Details for {{name}}", "submit": "Save settings", - "loading": "Loading package details..." + "loading": "Loading package details...", + "visitProjectPage": "Visit project page", + "maintainer": "Maintainer: {{name}}", + "remove": "Remove", + "install": "Install", + "installing": "Installing package...", + "removing": "Removing package..." } \ No newline at end of file diff --git a/src/utils/AdbWrapper.js b/src/utils/AdbWrapper.js index fdad5a133..0624caba0 100644 --- a/src/utils/AdbWrapper.js +++ b/src/utils/AdbWrapper.js @@ -38,7 +38,7 @@ export default class AdbWrapper { healthchesksPath: "/tmp/healthchecks", packageConfigPath: "/opt/etc/package-config", packageConfigFile: "config.json", - packageConfigSchema: "schema.json", + packageConfigSchema: "schemaV2.json", }; }