diff --git a/index.html b/index.html index 17074b6..75915b9 100644 --- a/index.html +++ b/index.html @@ -8,6 +8,26 @@
+ + + + + + + + diff --git a/package.json b/package.json index 58c4b7f..3d0a194 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "lodash": "^4.17.21", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hot-toast": "^2.4.1", "react-router-dom": "^6.22.0", "rehype-highlight": "^7.0.0", "rehype-sanitize": "^6.0.0", diff --git a/src/App.tsx b/src/App.tsx index cefd644..e7e7f54 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import { } from "react-router-dom"; import App from './views/Apps'; import { SWRConfig } from 'swr'; +import { GlobalProgressToaster } from './services/GlobalProgress'; const router = createHashRouter( createRoutesFromElements( @@ -32,11 +33,11 @@ const router = createHashRouter( ) ); - function Entry() { return ( + ) } diff --git a/src/components/AppListItemDetailView/AppDetailView.tsx b/src/components/AppListItemDetailView/AppDetailView.tsx index c952a80..4c9ed68 100644 --- a/src/components/AppListItemDetailView/AppDetailView.tsx +++ b/src/components/AppListItemDetailView/AppDetailView.tsx @@ -5,7 +5,7 @@ import { AppDetailViewProps } from "./interface"; import { Readme } from "./Readme"; import { AppStorageController } from "./AppStorageController"; -export const AppDetailView = ({ app, className }: AppDetailViewProps) => { +export const AppDetailView = ({ app, apps, className }: AppDetailViewProps) => { return (
{ flex-direction: column; `} > - - + +

{app.description}

- - + +
         {JSON.stringify(app, null, 2)}
       
diff --git a/src/components/AppListItemDetailView/AppListItemDetailView.tsx b/src/components/AppListItemDetailView/AppListItemDetailView.tsx index cdee60d..9d07ae1 100644 --- a/src/components/AppListItemDetailView/AppListItemDetailView.tsx +++ b/src/components/AppListItemDetailView/AppListItemDetailView.tsx @@ -4,7 +4,8 @@ import { Modal } from "./Modal"; import { AppDetailView } from "./AppDetailView"; export interface AppListItemDetailViewProps { - data: AppItem | null; + apps: AppItem[]; + app: AppItem | null; isLoading: boolean; error: Error | null; } @@ -24,13 +25,13 @@ export const AppListItemDetailView = (p: AppListItemDetailViewProps) => { ) } - if (p.data === null) { + if (p.app === null) { return (
404 not found
) } - return + return })(); const navigate = useNavigate() diff --git a/src/components/AppListItemDetailView/AppStorageController.tsx b/src/components/AppListItemDetailView/AppStorageController.tsx index f865b8b..4c560f4 100644 --- a/src/components/AppListItemDetailView/AppStorageController.tsx +++ b/src/components/AppListItemDetailView/AppStorageController.tsx @@ -2,7 +2,10 @@ import { css } from "@emotion/react"; import { AppDetailViewProps } from "./interface"; import { UiButton } from "../Buttons/UiButton"; import { ButtonIconContainer } from "../Buttons/ButtonIconContainer"; -import { faGear, faDownload } from "@fortawesome/free-solid-svg-icons"; +import { faGear, faDownload, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { EspruinoComms } from "../../services/Espruino/Comms"; +import { useApps } from "../../api/banglejs/methods"; +import { useEffect, useState } from "react"; interface ControlButtonProps extends AppDetailViewProps { hasConfiguration: boolean; @@ -24,7 +27,51 @@ const InstallControlButton = (props: ControlButtonProps) => { } return ( - + { + try { + const deviceInfo = await EspruinoComms.getDeviceInfo(); + const device = { + ...deviceInfo, + appsInstalled: deviceInfo.apps, + } + + if (props.app.dependencies) { + Object.entries(props.app.dependencies) + .map(async ([dependencyAppId]) => { + const hasInstalled = device.apps.find(app => app.id === dependencyAppId); + if (hasInstalled) return; + + const dependencyApp = props.apps.find(app => app.id === dependencyAppId); + if (!dependencyApp) { + const errorMessage = `dependency required "${dependencyAppId}" is not found`; + alert(errorMessage); + throw new Error(errorMessage) + } + + await EspruinoComms.uploadApp(dependencyApp, { device }); + // TODO: Implement dependency clash with the usage of provide_modules + // see AppInfo.checkDependencies + }) + } + + await EspruinoComms.uploadApp(props.app, { device }); + if (props.app.type === "clock") { + const settings = await EspruinoComms.readFile("setting.json"); + await EspruinoComms.writeFile("setting.json", JSON.stringify({ + ...JSON.parse(settings), + clock: `${props.app.id}.app.js`, + })) + EspruinoComms.resetDevice() + } + + } catch (error) { + alert((error as Error).message); + throw error; + } + }} + > @@ -34,28 +81,69 @@ const InstallControlButton = (props: ControlButtonProps) => { ) }; const ConfigureControlButton = (props: ControlButtonProps) => { - if (props.hasConfiguration) { - return ( - - - Configure - - - ); - } - - return null - }; + if (props.hasConfiguration) { + return ( + + + Configure + + + ); + } + + return null +}; + +const UninstallControlButton = (props: ControlButtonProps) => { + if (props.hasInstalled) { + return ( + { + try { + await EspruinoComms.getDeviceInfo(); + await EspruinoComms.removeApp(props.app) + } catch (error) { + alert((error as Error).message); + throw error; + } + }} + > + + Remove + + + ); + } + + return null +}; export const AppStorageController = (props: AppDetailViewProps) => { + const [ hasInstalled, setHasInstalled ] = useState(false); + const [ hasUpdate, setHasUpdate ] = useState(false); + useEffect(() => { + if (EspruinoComms.isConnected()) { + EspruinoComms.getDeviceInfo() + .then(({ apps }) => { + const found = apps.find(app => app.id === props.app.id); + + setHasInstalled(!!found); + setHasUpdate(!!(found && found.version === props.app.version)); + }) + } + }, [props.app]); + const controlButtonProps: ControlButtonProps = { ...props, hasConfiguration: !!(props.app.custom ?? props.app.interface), - hasInstalled: false, - hasUpdate: false, + hasInstalled, + hasUpdate, }; return (
{ `} > +
) diff --git a/src/components/AppListItemDetailView/interface.ts b/src/components/AppListItemDetailView/interface.ts index c9b6870..c510fa2 100644 --- a/src/components/AppListItemDetailView/interface.ts +++ b/src/components/AppListItemDetailView/interface.ts @@ -1,6 +1,7 @@ import { AppItem } from "../../api/banglejs/interface"; export interface AppDetailViewProps { + apps: AppItem[]; app: AppItem; className?: string; } diff --git a/src/main.tsx b/src/main.tsx index 3d7150d..e869891 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -8,3 +8,16 @@ ReactDOM.createRoot(document.getElementById('root')!).render( , ) + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +const espruinoOriginalHttpGet = httpGet; +console.log(espruinoOriginalHttpGet); +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +window.httpGet = (url: string) => { + console.log('fetching', url); + const updatedUrl = new URL(url, "https://banglejs.com/apps/"); + console.log('fetching', url, updatedUrl); + return espruinoOriginalHttpGet(updatedUrl.href); +} diff --git a/src/services/Espruino/Comms.ts b/src/services/Espruino/Comms.ts new file mode 100644 index 0000000..63becb2 --- /dev/null +++ b/src/services/Espruino/Comms.ts @@ -0,0 +1,184 @@ +import { AppItem } from "../../api/banglejs/interface"; + +export const EspruinoComms: typeof Comms = { + // write: () => { + // throw new Error("not implemented"); + // }, + // showMessage: () => { + // throw new Error("not implemented"); + // }, + // showUploadFinished: () => { + // throw new Error("not implemented"); + // }, + // getProgressCmd: () => { + // throw new Error("not implemented"); + // }, + // reset: () => { + // throw new Error("not implemented"); + // }, + // uploadCommandList: () => { + // throw new Error("not implemented"); + // }, + uploadApp: (...args) => { + return Comms.uploadApp(...args) + }, + getDeviceInfo: (...args) => { + return Comms.getDeviceInfo(...args) + }, + // getAppInfo: () => { + // throw new Error("not implemented"); + // }, + removeApp: (...args) => { + return Comms.removeApp(...args) + }, + // removeAllApps: () => { + // throw new Error("not implemented"); + // }, + // setTime: () => { + // throw new Error("not implemented"); + // }, + resetDevice: (...args) => { + return Comms.resetDevice(...args) + }, + isConnected: (...args) => { + return Comms.isConnected(...args) + }, + // disconnectDevice: () => { + // throw new Error("not implemented"); + // }, + // watchConnectionChange: () => { + // throw new Error("not implemented"); + // }, + // listFiles: () => { + // throw new Error("not implemented"); + // }, + // readTextBlock: () => { + // throw new Error("not implemented"); + // }, + readFile: (...args) => { + return Comms.readFile(...args) + }, + // readStorageFile: () => { + // throw new Error("not implemented"); + // }, + writeFile: (...args) => { + return Comms.writeFile(...args) + }, + // handlers: () => { + // throw new Error("not implemented"); + // }, + // on: () => { + // throw new Error("not implemented"); + // }, +}; + + +declare const Comms: { + uploadApp: (app: AppItem, options: { + /** + * object of translations, eg 'lang/de_DE.json' + */ + language?: { + /** + * Translations that apply for all apps + */ + GLOBAL: undefined | { + [key: string]: string + } + /** + * App-specific overrides + */ + [appId: string]: undefined | { + [key: string]: string + } + }; + /** + * { id : ..., version : ... } info about the currently connected device + */ + device: { + id: string; + appsInstalled: InstalledApp[]; + version: string; + exptr: number; + }; + /** + * if true, showUploadFinished isn't called (displaying the reboot message) + * @default false + */ + noFinish?: boolean; + /** + * if true, don't reset the device before + * + * @default false + * + * reset to ensure we have enough memory to upload what we need to + */ + noReset?: boolean; + }) => Promise; + removeApp: (app: AppItem, options?: { + /** + * if true, don't get data from watch + * @default false + */ + containsFileList: boolean; + /** + * if true, showUploadFinished isn't called (displaying the reboot message) + * @default false + */ + noFinish?: boolean; + /** + * if true, don't reset the device before + * + * @default false + * + * reset to ensure we have enough memory to upload what we need to + */ + noReset?: boolean; + }) => Promise + getDeviceInfo: (noReset?: boolean) => Promise<{ + apps: InstalledApp[]; + /** + * @example 1708008913000 + */ + currentTime: number; + /** + * @example 495324 + */ + exptr: number; + /** + * @example "BANGLEJS2" + */ + id: string; + /** + * @example 3533326687 + */ + uid: number; + /** + * @example "2v21" + */ + version: string; + }> + isConnected: () => boolean; + resetDevice: () => Promise; + readFile: (filename: string) => Promise; + writeFile: (filename: string, data: string) => Promise; +}; + +interface InstalledApp { + /** + * @example "synthwave.info,synthwave.app.js,synthwave.img" + */ + files: string; + /** + * @example "synthwave" + */ + id: string; + /** + * @example "clock" + */ + type: string; + /** + * @example "0.01" + */ + version: string; +} \ No newline at end of file diff --git a/src/services/GlobalProgress.tsx b/src/services/GlobalProgress.tsx new file mode 100644 index 0000000..cfc748d --- /dev/null +++ b/src/services/GlobalProgress.tsx @@ -0,0 +1,49 @@ +import toast, { Toaster } from 'react-hot-toast'; + +interface Progress { + show: ( + options: { + title: string; + domElement?: never; + sticky?: boolean; + interval?: number; + percent?: number; + min?: number; + max?: number; + } + ) => void; + hide: ( + options: { + sticky?: boolean, + } + ) => void; +} + +const augmentedWindow = window as unknown as { Progress: Progress }; +const GLOBAL_PROGRESS_ID = "GLOBAL_PROGRESS_ID"; + +augmentedWindow.Progress = Object.defineProperties(Object.create(null), { + show: { + get: (): Progress["show"] => (options) => { + console.log("showing toast"); + toast(options.title, { + id: GLOBAL_PROGRESS_ID, + position: "bottom-left", + }) + }, + configurable: false, + }, + hide: { + get: (): Progress["hide"] => (options = {}) => { + console.log("hiding toast"); + if (options.sticky) { + toast.dismiss(GLOBAL_PROGRESS_ID); + } + }, + configurable: false, + }, +}); + +export const GlobalProgressToaster = () => { + return +}; diff --git a/src/views/Apps/index.tsx b/src/views/Apps/index.tsx index 1660b79..886fdc1 100644 --- a/src/views/Apps/index.tsx +++ b/src/views/Apps/index.tsx @@ -32,7 +32,8 @@ function App() { <> { appId && ( app.id === appId) || null} + apps={data.apps} + app={data.apps.find(app => app.id === appId) || null} error={error} isLoading={isLoading} /> diff --git a/yarn.lock b/yarn.lock index 3bbaacd..4ff7630 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1421,6 +1421,11 @@ globby@^11.1.0: merge2 "^1.4.1" slash "^3.0.0" +goober@^2.1.10: + version "2.1.14" + resolved "https://registry.yarnpkg.com/goober/-/goober-2.1.14.tgz#4a5c94fc34dc086a8e6035360ae1800005135acd" + integrity sha512-4UpC0NdGyAFqLNPnhCT2iHpza2q+RAY3GV85a/mRPdzyPQMsj0KmMMuetdIkzWRbJ+Hgau1EZztq8ImmiMGhsg== + graphemer@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" @@ -2385,6 +2390,13 @@ react-dom@^18.2.0: loose-envify "^1.1.0" scheduler "^0.23.0" +react-hot-toast@^2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/react-hot-toast/-/react-hot-toast-2.4.1.tgz#df04295eda8a7b12c4f968e54a61c8d36f4c0994" + integrity sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ== + dependencies: + goober "^2.1.10" + react-is@^16.13.1, react-is@^16.7.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"