diff --git a/js/hardware.js b/js/hardware.js index 0d8da43..1fe05d5 100644 --- a/js/hardware.js +++ b/js/hardware.js @@ -198,7 +198,7 @@ const sessionDesktop = () => { }; /** - * Checks supported features based on global properties. + * Checks supported features based on hardware and software. * * @returns {Object} Returns support object with boolean values. */ @@ -207,13 +207,16 @@ const checkSupport = () => { const status = HARDWARE.display.status; const brightness = HARDWARE.display.brightness; const keyboard = processRuns("squeekboard"); + const service = serviceRuns(APP.name); const sudo = sudoRights(); + const deb = APP.build.maker === "deb"; return { - batteryLevel: battery.path !== null, - displayStatus: status.path !== null && status.command !== null, - displayBrightness: sudo && brightness.path !== null && brightness.max !== null, + batteryLevel: !!battery.path, + displayStatus: !!status.path && !!status.command, + displayBrightness: sudo && !!brightness.path && !!brightness.max, keyboardVisibility: keyboard, sudoRights: sudo, + appUpdate: sudo && service && deb, }; }; @@ -689,6 +692,19 @@ const sudoRights = () => { return false; }; +/** + * Checks if a service is running using `systemctl`. + * + * @param {string} name - The service name to check. + * @returns {bool} Returns true if the service runs. + */ +const serviceRuns = (name) => { + try { + return !!cpr.execSync(`systemctl --user is-active ${name}`, { encoding: "utf8" }); + } catch {} + return false; +}; + /** * Checks if a process is running using `pidof`. * @@ -765,14 +781,61 @@ const execAsyncCommand = (cmd, args = [], callback = null) => { } } catch (error) { console.error("Execute Async:", error.message); - if (typeof callback === "function") { - callback(null, error.message); - } + if (typeof callback === "function") callback(null, error.message); } }); return proc; }; +/** + * Executes a script command asynchronously. + * + * @param {string} cmd - The script to execute. + * @param {Array} args - The arguments for the command. + * @param {Function} callback - A callback function that receives the progress or error. + * @returns {Object} The spawned process object. + */ +const execScriptCommand = (cmd, args = [], callback = null) => { + let progress = 10; + let proc = cpr.spawn(cmd, args); + if (typeof callback === "function") callback(progress, null); + proc.stdout.on("data", (data) => { + if (data) { + const lines = data.toString().trim(); + const output = lines.replace(/\n+/g, "\n").replace(/^\n+|\n+$|\0/g, ""); + if (output) { + console.log(output); + } + } + }); + proc.stderr.on("data", (data) => { + if (data) { + const lines = data.toString().trim(); + const output = lines.replace(/\n+/g, "\n").replace(/^\n+|\n+$|\0/g, ""); + if (output) { + const matches = output.match(/(\d{1,3})%/g); + if (matches) { + const match = Math.max(...matches.map((p) => parseInt(p))); + const percent = Math.floor(match / 10) * 10; + if (percent > 10 && percent > progress) { + progress = percent; + if (typeof callback === "function") callback(progress - 10, null); + } + } + } + } + }); + proc.on("close", (code) => { + if (code !== 0) { + console.log(`Script exited with error code ${code}.`); + if (typeof callback === "function") callback(null, code); + } else { + console.log("Script exited successfully."); + if (typeof callback === "function") callback(100, null); + } + }); +}; + /** * Executes a D-Bus method call synchronously using `dbus-send`. * @@ -788,14 +851,10 @@ const dbusCall = (path, method, values, callback = null) => { const args = ["--print-reply", "--type=method_call", `--dest=${dest}`]; try { const output = cpr.execSync([cmd, ...args].join(" ").trim(), { encoding: "utf8" }); - if (typeof callback === "function") { - callback(output.trim().replace(/\0/g, ""), null); - } + if (typeof callback === "function") callback(output.trim().replace(/\0/g, ""), null); } catch (error) { console.error("Call D-Bus:", error.message); - if (typeof callback === "function") { - callback(null, error.message); - } + if (typeof callback === "function") callback(null, error.message); } }; @@ -829,17 +888,13 @@ const dbusMonitor = (path, callback) => { } } catch (error) { console.error("Monitor D-Bus:", error.message); - if (typeof callback === "function") { - callback(null, error.message); - } + if (typeof callback === "function") callback(null, error.message); } }); proc.stderr.on("data", (data) => { if (data) { console.error("Monitor D-Bus:", data.toString()); - if (typeof callback === "function") { - callback(null, data.toString()); - } + if (typeof callback === "function") callback(null, data.toString()); } }); return proc; @@ -889,4 +944,7 @@ module.exports = { checkPackageUpgrades, shutdownSystem, rebootSystem, + execSyncCommand, + execAsyncCommand, + execScriptCommand, }; diff --git a/js/integration.js b/js/integration.js index aa16079..c38449b 100644 --- a/js/integration.js +++ b/js/integration.js @@ -39,15 +39,15 @@ const init = async () => { // Init globals INTEGRATION.discovery = discovery; INTEGRATION.node = `rpi_${deviceId}`; - INTEGRATION.root = `${app.getName()}/${INTEGRATION.node}`; + INTEGRATION.root = `${APP.name}/${INTEGRATION.node}`; INTEGRATION.device = { - name: `TouchKio ${deviceName}`, + name: `${APP.title} ${deviceName}`, model: model, manufacturer: vendor, serial_number: serialNumber, identifiers: [INTEGRATION.node], - sw_version: `${app.getName()}-v${app.getVersion()}`, - configuration_url: "https://github.com/leukipp/touchkio", + sw_version: `${APP.name}-v${APP.version}`, + configuration_url: APP.homepage, }; // Connection settings @@ -62,6 +62,7 @@ const init = async () => { console.log(`MQTT Connected: ${url.toString()}\n`); // Init client controls + initApp(); initShutdown(); initReboot(); initRefresh(); @@ -109,8 +110,9 @@ const init = async () => { update(); }, 60 * 1000); - // Update package sensors periodically (60min) + // Update upgrade sensors periodically (1h) setInterval(() => { + updateApp(); updatePackageUpgrades(); }, 3600 * 1000); @@ -185,6 +187,57 @@ const publishState = (path, state) => { return INTEGRATION.client.publish(root, `${state}`, { qos: 1, retain: true }); }; +/** + * Initializes the app update entity and handles the execute logic. + */ +const initApp = () => { + if (!HARDWARE.support.appUpdate) { + return; + } + const root = `${INTEGRATION.root}/app`; + const config = { + name: "App", + unique_id: `${INTEGRATION.node}_app`, + command_topic: `${root}/install`, + state_topic: `${root}/version/state`, + payload_install: "update", + device: INTEGRATION.device, + }; + publishConfig("update", config) + .on("message", (topic, message) => { + if (topic === config.command_topic) { + console.log("Update App..."); + hardware.setDisplayStatus("ON"); + const args = ["-c", `bash <(wget -qO- ${APP.scripts.install}) update`]; + hardware.execScriptCommand("bash", args, (progress, error) => { + updateApp(progress); + }); + } + }) + .subscribe(config.command_topic); + updateApp(); +}; + +/** + * Updates the app update entity via the mqtt connection. + */ +const updateApp = async (progress = 0) => { + const latest = APP.releases.latest; + if (!latest) { + return; + } + const version = { + title: latest.title, + latest_version: latest.version, + installed_version: APP.version, + release_summary: latest.summary.slice(0, 250) + "...", + release_url: latest.url, + update_percentage: progress || null, + in_progress: progress && progress > 0 && progress < 100, + }; + publishState("app/version", JSON.stringify(version)); +}; + /** * Initializes the shutdown button and handles the execute logic. */ @@ -314,7 +367,7 @@ const initKiosk = () => { /** * Updates the kiosk status via the mqtt connection. */ -const updateKiosk = () => { +const updateKiosk = async () => { const kiosk = WEBVIEW.status; publishState("kiosk", kiosk); }; @@ -373,7 +426,7 @@ const initDisplay = () => { /** * Updates the display status, brightness via the mqtt connection. */ -const updateDisplay = () => { +const updateDisplay = async () => { const status = hardware.getDisplayStatus(); const brightness = hardware.getDisplayBrightness(); publishState("display/power", status); @@ -423,7 +476,7 @@ const initKeyboard = () => { /** * Updates the keyboard visibility via the mqtt connection. */ -const updateKeyboard = () => { +const updateKeyboard = async () => { const visibility = hardware.getKeyboardVisibility(); publishState("keyboard", visibility); }; @@ -432,7 +485,7 @@ const updateKeyboard = () => { * Initializes the page number and handles the execute logic. */ const initPageNumber = () => { - if (WEBVIEW.viewUrls.length < 3) { + if (WEBVIEW.viewUrls.length <= 2) { return; } const root = `${INTEGRATION.root}/page_number`; @@ -465,7 +518,7 @@ const initPageNumber = () => { /** * Updates the page number via the mqtt connection. */ -const updatePageNumber = () => { +const updatePageNumber = async () => { const pageNumber = WEBVIEW.viewActive || 1; publishState("page_number", pageNumber); }; @@ -504,7 +557,7 @@ const initPageZoom = () => { /** * Updates the page zoom via the mqtt connection. */ -const updatePageZoom = () => { +const updatePageZoom = async () => { const pageZoom = Math.round(WEBVIEW.views[WEBVIEW.viewActive || 1].webContents.getZoomFactor() * 100.0); publishState("page_zoom", pageZoom); }; @@ -539,7 +592,7 @@ const initPageUrl = () => { /** * Updates the page url via the mqtt connection. */ -const updatePageUrl = () => { +const updatePageUrl = async () => { const defaultUrl = WEBVIEW.viewUrls[WEBVIEW.viewActive || 1]; const currentUrl = WEBVIEW.views[WEBVIEW.viewActive || 1].webContents.getURL(); const pageUrl = !currentUrl || currentUrl.startsWith("data:") ? defaultUrl : currentUrl; @@ -555,6 +608,7 @@ const initModel = () => { name: "Model", unique_id: `${INTEGRATION.node}_model`, state_topic: `${root}/state`, + json_attributes_topic: `${root}/attributes`, value_template: "{{ value }}", icon: "mdi:raspberry-pi", device: INTEGRATION.device, @@ -566,9 +620,10 @@ const initModel = () => { /** * Updates the model sensor via the mqtt connection. */ -const updateModel = () => { +const updateModel = async () => { const model = hardware.getModel(); publishState("model", model); + publishAttributes("model", HARDWARE.support); }; /** @@ -591,7 +646,7 @@ const initSerialNumber = () => { /** * Updates the serial number sensor via the mqtt connection. */ -const updateSerialNumber = () => { +const updateSerialNumber = async () => { const serialNumber = hardware.getSerialNumber(); publishState("serial_number", serialNumber); }; @@ -617,7 +672,7 @@ const initNetworkAddress = () => { /** * Updates the network address sensor via the mqtt connection. */ -const updateNetworkAddress = () => { +const updateNetworkAddress = async () => { const networkAddresses = hardware.getNetworkAddresses(); const [name] = Object.keys(networkAddresses); const [family] = name ? Object.keys(networkAddresses[name]) : []; @@ -646,7 +701,7 @@ const initHostName = () => { /** * Updates the host name sensor via the mqtt connection. */ -const updateHostName = () => { +const updateHostName = async () => { const hostName = hardware.getHostName(); publishState("host_name", hostName); }; @@ -672,7 +727,7 @@ const initUpTime = () => { /** * Updates the up time sensor via the mqtt connection. */ -const updateUpTime = () => { +const updateUpTime = async () => { const upTime = hardware.getUpTime(); publishState("up_time", upTime); }; @@ -698,7 +753,7 @@ const initMemorySize = () => { /** * Updates the memory size sensor via the mqtt connection. */ -const updateMemorySize = () => { +const updateMemorySize = async () => { const memorySize = hardware.getMemorySize(); publishState("memory_size", memorySize); }; @@ -724,7 +779,7 @@ const initMemoryUsage = () => { /** * Updates the memory usage sensor via the mqtt connection. */ -const updateMemoryUsage = () => { +const updateMemoryUsage = async () => { const memoryUsage = hardware.getMemoryUsage(); publishState("memory_usage", memoryUsage); }; @@ -750,7 +805,7 @@ const initProcessorUsage = () => { /** * Updates the processor usage sensor via the mqtt connection. */ -const updateProcessorUsage = () => { +const updateProcessorUsage = async () => { const processorUsage = hardware.getProcessorUsage(); publishState("processor_usage", processorUsage); }; @@ -776,7 +831,7 @@ const initProcessorTemperature = () => { /** * Updates the processor temperature sensor via the mqtt connection. */ -const updateProcessorTemperature = () => { +const updateProcessorTemperature = async () => { const processorTemperature = hardware.getProcessorTemperature(); publishState("processor_temperature", processorTemperature); }; @@ -805,7 +860,7 @@ const initBatteryLevel = () => { /** * Updates the battery level sensor via the mqtt connection. */ -const updateBatteryLevel = () => { +const updateBatteryLevel = async () => { const batteryLevel = hardware.getBatteryLevel(); publishState("battery_level", batteryLevel); }; @@ -831,7 +886,7 @@ const initPackageUpgrades = () => { /** * Updates the package upgrades sensor via the mqtt connection. */ -const updatePackageUpgrades = () => { +const updatePackageUpgrades = async () => { const packages = hardware.checkPackageUpgrades(); const attributes = { total: packages.length, @@ -861,7 +916,7 @@ const initHeartbeat = () => { /** * Updates the heartbeat sensor via the mqtt connection. */ -const updateHeartbeat = () => { +const updateHeartbeat = async () => { const now = new Date(); const local = new Date(now.getTime() - now.getTimezoneOffset() * 60 * 1000); const heartbeat = local.toISOString().replace(/\.\d{3}Z$/, ""); @@ -889,7 +944,7 @@ const initLastActive = () => { /** * Updates the last active sensor via the mqtt connection. */ -const updateLastActive = () => { +const updateLastActive = async () => { const now = new Date(); const then = WEBVIEW.pointer.time; const lastActive = Math.abs(now - then) / (1000 * 60); diff --git a/js/webview.js b/js/webview.js index a64d4f4..3895a23 100644 --- a/js/webview.js +++ b/js/webview.js @@ -54,8 +54,8 @@ const init = async () => { // Init global root window WEBVIEW.window = new BaseWindow({ - title: "TouchKio", - icon: path.join(__dirname, "..", "img", "icon.png"), + title: APP.title, + icon: path.join(APP.path, "img", "icon.png"), }); WEBVIEW.window.setMenuBarVisibility(false); WEBVIEW.window.setFullScreen(fullscreen); @@ -91,7 +91,7 @@ const init = async () => { }); WEBVIEW.pager.setBackgroundColor("#00000000"); WEBVIEW.window.contentView.addChildView(WEBVIEW.pager); - WEBVIEW.pager.webContents.loadFile(path.join(app.getAppPath(), "html", "pager.html")); + WEBVIEW.pager.webContents.loadFile(path.join(APP.path, "html", "pager.html")); // Init global widget WEBVIEW.widget = new WebContentsView({ @@ -103,7 +103,7 @@ const init = async () => { }); WEBVIEW.widget.setBackgroundColor("#00000000"); WEBVIEW.window.contentView.addChildView(WEBVIEW.widget); - WEBVIEW.widget.webContents.loadFile(path.join(app.getAppPath(), "html", "widget.html")); + WEBVIEW.widget.webContents.loadFile(path.join(APP.path, "html", "widget.html")); // Init global navigation WEBVIEW.navigation = new WebContentsView({ @@ -115,14 +115,14 @@ const init = async () => { }); WEBVIEW.navigation.setBackgroundColor("#00000000"); WEBVIEW.window.contentView.addChildView(WEBVIEW.navigation); - WEBVIEW.navigation.webContents.loadFile(path.join(app.getAppPath(), "html", "navigation.html")); + WEBVIEW.navigation.webContents.loadFile(path.join(APP.path, "html", "navigation.html")); // Register events - windowEvents(); - widgetEvents(); - navigationEvents(); - viewEvents(); - appEvents(); + await windowEvents(); + await widgetEvents(); + await navigationEvents(); + await viewEvents(); + await appEvents(); return true; }; @@ -166,7 +166,7 @@ const updateView = () => { } const url = WEBVIEW.views[WEBVIEW.viewActive].webContents.getURL(); const host = url.startsWith("data:") ? "whoopsie" : new URL(url).host; - const title = `TouchKio - ${host} (${WEBVIEW.viewActive})`; + const title = `${APP.title} - ${host} (${WEBVIEW.viewActive})`; // Update window title console.log(`Update View: ${title}`); @@ -427,7 +427,7 @@ const resizeView = () => { /** * Register window events and handler. */ -const windowEvents = () => { +const windowEvents = async () => { // Handle window resize events WEBVIEW.window.on("ready-to-show", resizeView); WEBVIEW.window.on("resize", resizeView); @@ -476,7 +476,7 @@ const windowEvents = () => { historyForward(); }); - // Handle window touch events + // Check for window touch events (1s) setInterval(() => { const now = new Date(); const then = WEBVIEW.pointer.time; @@ -495,7 +495,7 @@ const windowEvents = () => { /** * Register widget events and handler. */ -const widgetEvents = () => { +const widgetEvents = async () => { if (!WEBVIEW.widgetEnabled) { return; } @@ -581,7 +581,7 @@ const widgetEvents = () => { /** * Register navigation events and handler. */ -const navigationEvents = () => { +const navigationEvents = async () => { if (!WEBVIEW.navigationEnabled) { return; } @@ -680,7 +680,7 @@ const navigationEvents = () => { /** * Register view events and handler. */ -const viewEvents = () => { +const viewEvents = async () => { const ready = []; WEBVIEW.views.forEach((view, i) => { // Enable webview touch emulation @@ -768,7 +768,7 @@ const viewEvents = () => { /** * Register app events and handler. */ -const appEvents = () => { +const appEvents = async () => { // Handle signal and exit events process.on("SIGINT", app.quit); app.on("before-quit", () => { @@ -784,10 +784,37 @@ const appEvents = () => { WEBVIEW.window.focus(); }); + // Check for latest release infos (2h) + setInterval(() => { + latestRelease(); + }, 7200 * 1000); + await latestRelease(); + // Webview initialized WEBVIEW.initialized = true; }; +/** + * Fetches the latest app release infos from github. + */ +const latestRelease = async () => { + try { + const response = await axios.get(`${APP.releases.url}/latest`); + const data = response ? response.data : null; + if (!data || data.draft || data.prerelease) { + return; + } + APP.releases.latest = { + title: APP.title, + version: (data.tag_name || data.name || "").replace(/^v/i, ""), + summary: data.body || "", + url: data.html_url || "", + }; + } catch (error) { + console.warn("Github Error:", error.message); + } +}; + /** * Checks for network connectivity by requesting a known url. *