diff --git a/door-thing-app/.eslintrc.json b/door-thing-app/.eslintrc.json index 2d7aa60..6e52bc9 100644 --- a/door-thing-app/.eslintrc.json +++ b/door-thing-app/.eslintrc.json @@ -12,5 +12,11 @@ "plugin:import/electron", "plugin:import/typescript" ], + "settings": { + "import/resolver": { + "typescript": {} + } + }, + "parser": "@typescript-eslint/parser" } diff --git a/door-thing-app/package-lock.json b/door-thing-app/package-lock.json index 76bacbd..beee208 100644 --- a/door-thing-app/package-lock.json +++ b/door-thing-app/package-lock.json @@ -9,7 +9,8 @@ "version": "0.0.1", "license": "MIT", "dependencies": { - "electron-squirrel-startup": "^1.0.0" + "electron-squirrel-startup": "^1.0.0", + "lowdb": "^3.0.0" }, "devDependencies": { "@electron-forge/cli": "^6.0.0-beta.63", @@ -25,6 +26,7 @@ "css-loader": "^6.7.1", "electron": "18.0.4", "eslint": "^8.13.0", + "eslint-import-resolver-typescript": "^2.7.1", "eslint-plugin-import": "^2.26.0", "fork-ts-checker-webpack-plugin": "^6.5.1", "node-loader": "^2.0.0", @@ -4368,6 +4370,26 @@ "ms": "^2.1.1" } }, + "node_modules/eslint-import-resolver-typescript": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-2.7.1.tgz", + "integrity": "sha512-00UbgGwV8bSgUv34igBDbTOtKhqoRMy9bFjNehT40bXg6585PNIct8HhXZ0SybqB9rWtXj9crcku8ndDn/gIqQ==", + "dev": true, + "dependencies": { + "debug": "^4.3.4", + "glob": "^7.2.0", + "is-glob": "^4.0.3", + "resolve": "^1.22.0", + "tsconfig-paths": "^3.14.1" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*" + } + }, "node_modules/eslint-module-utils": { "version": "2.7.3", "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.7.3.tgz", @@ -7000,6 +7022,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lowdb": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lowdb/-/lowdb-3.0.0.tgz", + "integrity": "sha512-9KZRulmIcU8fZuWiaM0d5e2/nPnrFyXkeXVpqT+MJS+vgbgOf1EbtvgQmba8HwUFgDl1oeZR6XqEJnkJmQdKmg==", + "dependencies": { + "steno": "^2.1.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/lower-case": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", @@ -9642,6 +9678,17 @@ "node": ">= 0.6" } }, + "node_modules/steno": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/steno/-/steno-2.1.0.tgz", + "integrity": "sha512-mauOsiaqTNGFkWqIfwcm3y/fq+qKKaIWf1vf3ocOuTdco9XoHCO2AGF1gFYXuZFSWuP38Q8LBHBGJv2KnJSXyA==", + "engines": { + "node": "^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -14547,6 +14594,19 @@ } } }, + "eslint-import-resolver-typescript": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-2.7.1.tgz", + "integrity": "sha512-00UbgGwV8bSgUv34igBDbTOtKhqoRMy9bFjNehT40bXg6585PNIct8HhXZ0SybqB9rWtXj9crcku8ndDn/gIqQ==", + "dev": true, + "requires": { + "debug": "^4.3.4", + "glob": "^7.2.0", + "is-glob": "^4.0.3", + "resolve": "^1.22.0", + "tsconfig-paths": "^3.14.1" + } + }, "eslint-module-utils": { "version": "2.7.3", "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.7.3.tgz", @@ -16564,6 +16624,14 @@ "is-unicode-supported": "^0.1.0" } }, + "lowdb": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lowdb/-/lowdb-3.0.0.tgz", + "integrity": "sha512-9KZRulmIcU8fZuWiaM0d5e2/nPnrFyXkeXVpqT+MJS+vgbgOf1EbtvgQmba8HwUFgDl1oeZR6XqEJnkJmQdKmg==", + "requires": { + "steno": "^2.1.0" + } + }, "lower-case": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", @@ -18565,6 +18633,11 @@ "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=", "dev": true }, + "steno": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/steno/-/steno-2.1.0.tgz", + "integrity": "sha512-mauOsiaqTNGFkWqIfwcm3y/fq+qKKaIWf1vf3ocOuTdco9XoHCO2AGF1gFYXuZFSWuP38Q8LBHBGJv2KnJSXyA==" + }, "string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", diff --git a/door-thing-app/package.json b/door-thing-app/package.json index 598c115..b4cbeec 100644 --- a/door-thing-app/package.json +++ b/door-thing-app/package.json @@ -53,7 +53,10 @@ { "html": "./src/index.html", "js": "./src/renderer.tsx", - "name": "main_window" + "name": "main_window", + "preload": { + "js": "./src/preload.ts" + } } ] } @@ -76,6 +79,7 @@ "css-loader": "^6.7.1", "electron": "18.0.4", "eslint": "^8.13.0", + "eslint-import-resolver-typescript": "^2.7.1", "eslint-plugin-import": "^2.26.0", "fork-ts-checker-webpack-plugin": "^6.5.1", "node-loader": "^2.0.0", @@ -85,6 +89,7 @@ "typescript": "~4.5.4" }, "dependencies": { - "electron-squirrel-startup": "^1.0.0" + "electron-squirrel-startup": "^1.0.0", + "lowdb": "^3.0.0" } } diff --git a/door-thing-app/src/App.tsx b/door-thing-app/src/App.tsx index daf8cd3..43165a6 100644 --- a/door-thing-app/src/App.tsx +++ b/door-thing-app/src/App.tsx @@ -1,5 +1,6 @@ -import { useState } from "preact/hooks"; +import { useEffect, useState } from "preact/hooks"; +const doorServiceUUIDString = "0x1811"; const doorServiceUUID: BluetoothServiceUUID = 0x1811; const doorSwitchUUID: BluetoothCharacteristicUUID = 0x2ae2; @@ -11,49 +12,93 @@ export function App() { characteristic: BluetoothRemoteGATTCharacteristic; } | null>(null); const [doorOpen, setDoorOpen] = useState(null); + const [autoConnect, setAutoConnect] = useState(false); - const onConnectClick = async () => { - try { - setConnecting(true); - const foundDevice = await navigator.bluetooth.requestDevice({ + useEffect(() => { + window.api.send("read-setting", "autoConnect"); + window.api.receive( + "read-setting-success", + (res: { key: string; value: any }) => { + if (res.key == "autoConnect") { + setAutoConnect(res.value); + } + }, + ); + window.api.receive( + "user-gesture-reply", + async (res: { type: string; promise: Promise }) => { + if (res.type == "find-device") { + try { + const foundDevice = await window.newDevice; + console.log("Found device:"); + console.log(foundDevice); + + await foundDevice.gatt.connect(); + foundDevice.ongattserverdisconnected = onDisconnect; + + const doorService = await foundDevice.gatt.getPrimaryService( + doorServiceUUID, + ); + const doorSwitch = await doorService.getCharacteristic( + doorSwitchUUID, + ); + setDevice({ + device: foundDevice, + service: doorService, + characteristic: doorSwitch, + }); + + const data = await (await doorSwitch.readValue()).getUint8(0); + setDoorOpen(Boolean(data)); + + doorSwitch.oncharacteristicvaluechanged = onDoorUpdate; + doorSwitch.startNotifications(); + } catch (error) { + console.log("Error connecting:"); + console.log(error); + } finally { + setConnecting(false); + window.newDevice = null; + } + } + }, + ); + }, []); + + useEffect(() => { + if (autoConnect && !device && !connecting) { + tryConnect(); + } + }, [autoConnect, device, connecting]); + + function onAutoConnectChange(e: Event) { + const newVal = (e.currentTarget as any).checked; + setAutoConnect(newVal); + window.api.send("write-setting", { key: "autoConnect", value: newVal }); + } + + const tryConnect = async () => { + setConnecting(true); + //we actually need all this, because otherwise it gives "Must be handling a user gesture..." error + //and there doesn't seem to be a way to circumvent this yet + //todo: make this at least somewhat less ugly? + //similar issue: https://github.com/electron/electron/issues/27625 + window.api.send("exec-user-gesture", { + type: "find-device", + function: ` + window.newDevice = navigator.bluetooth.requestDevice({ filters: [ { namePrefix: "Nano", }, ], - optionalServices: [doorServiceUUID], - }); - console.log("Found device:"); - console.log(foundDevice); - - await foundDevice.gatt.connect(); - foundDevice.ongattserverdisconnected = onDisconnect; - - const doorService = await foundDevice.gatt.getPrimaryService( - doorServiceUUID, - ); - const doorSwitch = await doorService.getCharacteristic(doorSwitchUUID); - setDevice({ - device: foundDevice, - service: doorService, - characteristic: doorSwitch, - }); - - const data = await (await doorSwitch.readValue()).getUint8(0); - setDoorOpen(Boolean(data)); - - doorSwitch.oncharacteristicvaluechanged = onDoorUpdate; - doorSwitch.startNotifications(); - } catch (error) { - console.log("Error connecting:"); - console.log(error); - } finally { - setConnecting(false); - } + optionalServices: [${doorServiceUUIDString}], + })`, + }); }; - const onDoorUpdate = async (ev: Event) => { - const characteristic = ev.target as BluetoothRemoteGATTCharacteristic; + const onDoorUpdate = async (e: Event) => { + const characteristic = e.target as BluetoothRemoteGATTCharacteristic; const parsed = Boolean(await characteristic.value.getUint8(0)); setDoorOpen(parsed); new Notification("Door update", { body: parsed ? "Open" : "Closed" }); @@ -76,9 +121,14 @@ export function App() { return (
+ {connecting && !device && Trying to connect} {device == null && !connecting && ( - + )} {device && ( <> diff --git a/door-thing-app/src/index.ts b/door-thing-app/src/index.ts index cf77f36..2588beb 100644 --- a/door-thing-app/src/index.ts +++ b/door-thing-app/src/index.ts @@ -1,8 +1,59 @@ -import { app, BrowserWindow } from "electron"; +import { app, BrowserWindow, ipcMain } from "electron"; +import * as path from "path"; +import { JSONFile, Low } from "lowdb"; + +interface ISettings { + autoConnect: boolean; +} + +const settingsPath = path.join(app.getPath("appData"), "settings.json"); +const settings = new Low(new JSONFile(settingsPath)); +const settingsRead = false; + +async function initSettings() { + if (!settingsRead) { + await settings.read(); + } + + settings.data ||= { + autoConnect: false, + }; +} + +ipcMain.on("read-setting", async (event, arg: string) => { + await initSettings(); + if (Object.keys(settings.data).includes(arg)) { + event.reply("read-setting-success", { + key: arg, + value: settings.data[arg as keyof ISettings], + }); + } else { + event.reply("read-setting-error", { + key: arg, + error: "bad key", + }); + } +}); + +ipcMain.on( + "write-setting", + async (event, arg: { key: keyof ISettings; value: any }) => { + await initSettings(); + settings.data[arg.key] = arg.value; + try { + await settings.write(); + event.reply("write-setting-success", arg.key); + } catch (e) { + event.reply("write-setting-fail", { key: arg.key, error: e }); + } + }, +); + // This allows TypeScript to pick up the magic constant that's auto-generated by Forge's Webpack // plugin that tells the Electron app where to look for the Webpack-bundled app code (depending on // whether you're running in development or production). declare const MAIN_WINDOW_WEBPACK_ENTRY: string; +declare const MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY: any; // Handle creating/removing shortcuts on Windows when installing/uninstalling. if (require("electron-squirrel-startup")) { @@ -15,6 +66,9 @@ const createWindow = (): void => { const mainWindow = new BrowserWindow({ height: 600, width: 800, + webPreferences: { + preload: MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY, + }, }); mainWindow.webContents.on( @@ -27,6 +81,17 @@ const createWindow = (): void => { }, ); + ipcMain.on( + "exec-user-gesture", + async (event, arg: { type: string; function: string }) => { + await mainWindow.webContents.executeJavaScript(arg.function, true); + event.reply("user-gesture-reply", { + type: arg.type, + promise: "aaaah", + }); + }, + ); + // and load the index.html of the app. mainWindow.loadURL(MAIN_WINDOW_WEBPACK_ENTRY); diff --git a/door-thing-app/src/preload.ts b/door-thing-app/src/preload.ts new file mode 100644 index 0000000..7982181 --- /dev/null +++ b/door-thing-app/src/preload.ts @@ -0,0 +1,29 @@ +import { contextBridge, ipcRenderer } from "electron"; + +//todo: renderer.d.ts +contextBridge.exposeInMainWorld("api", { + send: (channel: string, data: any) => { + // whitelist channels + const validChannels = [ + "read-setting", + "write-setting", + "exec-user-gesture", + ]; + if (validChannels.includes(channel)) { + ipcRenderer.send(channel, data); + } + }, + receive: (channel: string, func: any) => { + const validChannels = [ + "read-setting-success", + "read-setting-error", + "write-setting-success", + "write-setting-fail", + "user-gesture-reply", + ]; + if (validChannels.includes(channel)) { + // Deliberately strip event as it includes `sender` + ipcRenderer.on(channel, (event, ...args) => func(...args)); + } + }, +}); diff --git a/door-thing-app/src/renderer.d.ts b/door-thing-app/src/renderer.d.ts new file mode 100644 index 0000000..4e12786 --- /dev/null +++ b/door-thing-app/src/renderer.d.ts @@ -0,0 +1,11 @@ +export interface IElectronAPI { + send: (channel: string, data: any) => Promise; + receive: (channel: string, func: any) => void; +} + +declare global { + interface Window { + api: IElectronAPI; + newDevice: Promise | null; + } +} diff --git a/door-thing-app/tsconfig.json b/door-thing-app/tsconfig.json index c65c4ef..71f63cc 100644 --- a/door-thing-app/tsconfig.json +++ b/door-thing-app/tsconfig.json @@ -16,5 +16,6 @@ "jsx": "react-jsx", "jsxImportSource": "preact" }, - "include": ["src/**/*"] + "include": ["src/**/*"], + "files": ["src/renderer.d.ts"] }