41 Commits
Author SHA1 Message Date
leukipp 092697850d build: compress zip 2025-09-09 19:59:30 +02:00
leukipp 422e92dd89 build: update version 2025-09-09 19:41:09 +02:00
leukipp a15c0fc91d docs: update readme 2025-09-09 19:33:33 +02:00
leukipp 358fa3ca9c ref: use display status callback 2025-09-09 17:25:56 +02:00
leukipp 10722fa217 build: update build.json 2025-09-08 21:31:01 +02:00
leukipp 7267fafb97 ref: add console logs 2025-09-08 20:03:23 +02:00
leukipp b5e95936dc ref: obfuscate logged password 2025-09-08 18:58:29 +02:00
leukipp 047b38a41b feat: extended logging #73 2025-09-08 17:31:42 +02:00
leukipp a93fbb2ad3 build: update dependencies 2025-09-08 17:18:16 +02:00
leukipp 0b927a3ef6 fix: touch event handling for inactive screens #61 2025-09-08 16:23:04 +02:00
leukipp d35cf27de9 fix: update last active only on touch #71 2025-09-08 09:56:27 +02:00
leukipp 19348b4b60 build: disable zip update 2025-09-06 20:27:01 +02:00
leukipp ce1eed391d build: update version 2025-09-06 20:08:16 +02:00
leukipp fe6fe92656 docs: update readme 2025-09-06 20:07:41 +02:00
leukipp 3aea938cfb fix: validate arguments file #69 2025-09-06 20:03:49 +02:00
leukipp c39428fa42 build: update build.json 2025-09-06 19:48:02 +02:00
leukipp 7caa5bfc8a ref: update images 2025-09-06 19:22:19 +02:00
leukipp 5f69961a9f feat: mqtt app update #70 2025-09-06 19:21:51 +02:00
leukipp f44d998016 build: improve update logic 2025-09-06 19:07:57 +02:00
leukipp 3ea65a4af8 build: update dependencies 2025-09-06 19:04:16 +02:00
leukipp 2ad49c3167 feat: add network address mqtt sensor #66 2025-09-03 22:13:45 +02:00
leukipp ef2093a25e feat: check sudo rights for mqtt #62 2025-09-03 16:01:47 +02:00
leukipp 2737eabd8a fix: mqtt keyboard state #65 2025-09-01 17:22:26 +02:00
leukipp b8f809c50e build: update version 2025-08-31 22:04:56 +02:00
leukipp c3e625a8f6 docs: update readme 2025-08-31 22:01:18 +02:00
leukipp 7c15707e3b docs: add hardware 2025-08-31 21:06:57 +02:00
leukipp dbd3756793 ref: update images 2025-08-31 20:33:07 +02:00
leukipp 85fe5a910a fix: update service file #62 2025-08-31 20:31:38 +02:00
leukipp d78088e6af feat: side panel pager #46 2025-08-31 19:24:50 +02:00
leukipp bf77d9b2a0 fix: mqtt memory leak #63 2025-08-31 16:09:39 +02:00
leukipp f4ca7bc412 feat: basic keyboard navigation #46 2025-08-31 11:41:12 +02:00
leukipp d0ea377ead feat: add page zoom buttons #48 2025-08-31 09:24:30 +02:00
leukipp 9a829f72e3 build: update dependencies 2025-08-31 08:48:24 +02:00
leukipp 482a56c9b9 build: update dependencies 2025-08-27 20:03:26 +02:00
leukipp 32409983b2 fix: remember zoom level on refresh #52 2025-08-27 20:02:59 +02:00
leukipp fe0667919e feat: check online status #62 2025-08-27 18:30:15 +02:00
leukipp 0037a892cd ref: display status fallback 2025-08-27 18:26:40 +02:00
leukipp ad22dd3215 feat: kde wayland support #57 2025-08-26 17:15:21 +02:00
leukipp 619179c3c8 fix: event based communication #39 #48 #55 2025-08-25 18:52:25 +02:00
afsy-d 7e5e669b09 Added hardware information (#56)
* Update HARDWARE.md

Added CM4 light 2GB information

* Update HARDWARE.md

Updated information regarding CM4
2025-08-25 13:31:51 +02:00
Tom Carpenter 437c4ab1c5 Make Webview Zoom Changeable from Home Assistant (#52)
* Add Page Zoom Control

Adds MQTT discovery for a "Page Zoom" input to control the `WEBVIEW.viewZoom` parameter.

The MQTT value has been configured to be in % which is then converted to a scale factor when assigned or read from `WEBVIEW.viewZoom`.

* Correct Type for page_zoom publishConfig

Accidentally replaced "number" with "zoom" when changing paramters.

* Add updateZoom Functionality to webview

Add a setter function for the viewZoom property that updates the zoom factor for all views when the property is changed.
2025-08-25 13:31:28 +02:00
14 changed files with 1826 additions and 726 deletions
+1
View File
@@ -1,3 +1,4 @@
node_modules/
docs/
out/
build.json
+4 -2
View File
@@ -28,17 +28,19 @@ If you are running Linux with a graphical user interface (Wayland or X11), you s
### HDMI
| Device | System | Display | Status |
| ------------------------ | -------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ------ |
| ------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ------ |
| Raspberry Pi 4 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | [LAFVIN 5" Touch Display (800x480)](https://www.amazon.de/gp/product/B0BWJ8YP7S) | 🟨 |
| Raspberry Pi 4 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | [Waveshare 8" CAPLCD (768x1024)](https://www.waveshare.com/wiki/8inch_768x1024_LCD) | 🟨 |
| Raspberry Pi 4 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | [Waveshare 7" 7EP-CAPLCD (1280x800)](https://www.waveshare.com/7ep-caplcd.htm) | 🟨 |
| Raspberry Pi 4 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | [Hannspree HT225 21.5" (1920x1080)](https://www.hannspree.eu/product/HT-225-HPB) | 🟨 |
| Raspberry Pi 400 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | [UPerfect Vertical Touch 15.6" (1920x1080)](https://uperfect.com/products/uperfect-y-vertical-monitor-15-6) | 🟨 |
| Raspberry Pi CM 4 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | [Lenovo ThinkCentre 23.8" (1920x1080)](https://psref.lenovo.com/Detail/ThinkCentre_Tiny_In_One_24_Gen_5?M=12NBGAT1EU) | 🟨 |
| Raspberry Pi 5 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | [GeeekPi 10.1" Capacitive Touch (1280x800)](https://www.amazon.nl/dp/B0DHV6DZC1) | 🟨 |
| Raspberry Pi 5 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | [Prechen Portable Touch 18.5" FHD (1920x1080)](https://www.amazon.de/dp/B0CT2KLDBQ) | 🟨 |
| Raspberry Pi 5 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | [Akntzcs Portable Touch 16" HD (1920x1200)](https://www.amazon.com/dp/B0CTGW6MQ6) | 🟨 |
| Raspberry Pi 5 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | [ELECROW Portable Monitor 10.1" (1280x800)](https://www.amazon.co.uk/dp/B0BHHQLKPY) | 🟨 |
| Raspberry Pi 5 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | Generic Non-Touch | 🟨 |
| Generic PC (x64) | Debian KDE (64-bit), X11 | Generic Non-Touch | 🟧 |
| Generic PC (x64) | Debian KDE (64-bit), Wayland, X11 | Generic Non-Touch | 🟧 |
| Generic PC (x64) | Ubuntu GNOME (64-bit), X11 | Generic Non-Touch | 🟧 |
| Generic PC (x64) | Ubuntu GNOME (64-bit), Wayland | Generic Non-Touch | 🟥 |
+60 -18
View File
@@ -17,13 +17,14 @@ Moreover, the device running the **kiosk application** also offers several **Hom
- [x] Configurable browser zoom and theme support.
- [x] Extended touch screen wake-up functionality.
- [x] Remote controllable via MQTT.
- [x] Manage kiosk window status.
- [x] Toggle the on-screen keyboard.
- [x] Touch display power and brightness.
- [x] Manage kiosk window status and zoom.
- [x] Show network interfaces and addresses.
- [x] List all available system package upgrades.
- [x] Multi-webpage switching and url navigation.
- [x] Monitor temperature, processor and memory usage.
- [x] Execute system reboot and shutdown commands.
- [x] Show available system package upgrades.
The kiosk application can be executed with command line arguments to load a **Home Assistant dashboard in fullscreen** mode.
Additionally, a **MQTT endpoint** can be defined, allowing the application to provide controls and sensors for the Linux device and the connected Touch Display.
@@ -43,7 +44,7 @@ For a comprehensive guide on setting up MQTT with Home Assistant, please refer t
## Installation
On the first run of the application, you may encounter a **setup procedure** and the Home Assistant **login screen**.
It's recommended to create a **dedicated user** (local access only) for your kiosk device.
It's recommended to create a dedicated Home Assistant user (local access only) for your kiosk device.
You might also need a physical keyboard or remote VNC access to input these credentials once.
If your hardware is [supported](https://github.com/leukipp/touchkio/blob/main/HARDWARE.md) you may be able to activate the on-screen keyboard using the side [widget](https://github.com/leukipp/touchkio/issues/16).
@@ -56,7 +57,6 @@ bash <(wget -qO- https://raw.githubusercontent.com/leukipp/touchkio/main/install
```
Make sure that you run this with your **standard user** and not with root (sudo).
If you are paranoid, or smart, or both, have a look into the [install.sh](https://github.com/leukipp/touchkio/blob/main/install.sh) script before executing external code on your machine.
This command can also be re-run to **update** an existing installation to the latest version.
The systemd **user service** is enabled by default and the kiosk application should start automatically the next time you boot.
If you need manual control use:
@@ -89,6 +89,15 @@ yarn build
</div></details>
#### Update
If you have already installed TouchKio and want to upgrade to the latest version, simply install the newer version over the existing one.
The [install.sh](https://github.com/leukipp/touchkio/blob/main/install.sh) script can also be run to update an existing installation to the **latest version**.
The setup procedure can be skipped to use the existing default arguments from the configuration file:
```bash
bash <(wget -qO- https://raw.githubusercontent.com/leukipp/touchkio/main/install.sh) update
```
## Configuration
Running `touchkio --setup` will prompt you to enter arguments that will be used when the application starts without any specified arguments.
These default arguments are stored in `~/.config/touchkio/Arguments.json`, where they can also be modified.
@@ -109,16 +118,16 @@ For example:
touchkio --web-url=http://192.168.1.42:8123 --web-theme=light --web-zoom=1.0
```
> <a href="#ref1">↩ </a><a id="foot1"></a>**[1]:** Not necessarily a Home Assistant Url.
> You can load multiple pages by separating them with a comma:
> `touchkio --web-url=https://demo.home-assistant.io,https://imvbh.github.io/FlipClock`.
> <a id="foot1"></a><a href="#ref1">[1]</a>: This doesn't necessarily have to be a Home Assistant Url.
> You can configure multiple pages by separating them with a comma:
> `touchkio --web-url=https://demo.home-assistant.io,https://demo.immichkiosk.app`.
In the `~/.config/touchkio/Arguments.json` file:
```json
{
"web_url": [
"https://demo.home-assistant.io",
"https://imvbh.github.io/FlipClock"
"https://demo.immichkiosk.app"
]
}
```
@@ -140,6 +149,29 @@ For example:
touchkio --web-url=http://192.168.1.42:8123 --mqtt-url=mqtt://192.168.1.42:1883 --mqtt-user=kiosk --mqtt-password=password
```
## User Interface
TouchKio provides a simple webview window designed specifically for Touch Displays. Electron apps are known to be resource intensive due to their architecture and the inclusion of a full web browser environment. If you just run the kiosk application without other heavy loads, everything should run smoothly.
### Touch Controls
Additional controls can be found along the right edge of the kiosk application. For more details, you may want to have a closer look here:
| Name | Description |
| ------------ | -------------------------------------------------------- |
| `Widget` | [See #16](https://github.com/leukipp/touchkio/issues/16) |
| `Navigation` | [See #45](https://github.com/leukipp/touchkio/issues/45) |
| `Pager` | [See #64](https://github.com/leukipp/touchkio/issues/64) |
### Keyboard Shortcuts
The application also supports basic shortcuts to enhance navigation and usability for users who prefer or require non-touch input methods:
| Name | Description |
| --------------------------- | ---------------------------- |
| `Control+Left` | Navigate to previous page |
| `Control+Right` | Navigate to next page |
| `Control+Num_Subtract` | Decrease the page zoom |
| `Control+Num_Add` | Increase the page zoom |
| `Alt+Left`/`Mouse+Back` | Navigate backward in history |
| `Alt+Right`/`Mouse+Forward` | Navigate forward in history |
## Development
To create your own local build, you first need to install [Node.js](https://pimylifeup.com/raspberry-pi-nodejs) and [Yarn](https://classic.yarnpkg.com/lang/en/docs/install).
@@ -219,35 +251,45 @@ Implementing stronger **security measures** would complicate the setup process a
When using the kiosk application without initializing the default arguments, you will need to provide them with every command.
This means that the password may be stored as plain text in various files, such as `touchkio.service`, `~/.bash_history`, etc.
To resolve the issue where the first **touch** on a **turned-off screen** triggers a **click event** (which could inadvertently activate Home Assistant actions), a workaround needed to be implemented.
When the screen **turns off** the **focus** is removed from the kiosk window.
This way, the first click only turns the screen on and focuses the window, allowing subsequent clicks to work as expected.
To address the problem where the first **touch** on a **turned-off screen** may trigger a **click event** (which could inadvertently activate Home Assistant actions), a workaround needed to be implemented.
When the screen **turns on/off**, the timestamps of these events are recorded.
If a touch event is detected and the **timestamp** of the **last screen-off** is more recent than the **last screen-on** timestamp, the touch event is discarded and the screen is turned on instead.
From that point onward, subsequent touch interactions will function normally as expected.
Additionally, to address the problem that scrolling only works with the **web browser scrollbar** on the right, the application is configured to **simulate a touch device** using `Emulation.setEmitTouchEventsForMouse`.
This adjustment provides a user experience similar to that of a proper mobile device.
Electron apps are known to be **resource intensive** due to their architecture and the inclusion of a full web browser environment. If you just run the kiosk application without other heavy loads, everything should run smoothly.
</div></details>
## Issues
Please have a look into the [hardware](https://github.com/leukipp/touchkio/blob/main/HARDWARE.md) documentation if you ran into any issues.
Please review the [hardware](https://github.com/leukipp/touchkio/blob/main/HARDWARE.md) documentation first if you encounter any problems.
**Minimal features** are designed to run on any system without issues:
- A webview kiosk window launched in fullscreen mode and loading the specified `--web-url` website should not cause any problems.
**Extended features** become available when the `--mqtt-*` arguments are provided and the hardware is supported:
- If your hardware is not fully compatible there should be no crashes, but you may miss some sensors.
- On some Debian based systems (e.g. Ubuntu), the display status control may only be available when using X11.
- On some Debian based systems (e.g. Ubuntu GNOME), the display status control may only be available when using X11.
- The following commands are currently implemented to modify the display status.
- `wlopm --[on]/[off] *` (Raspberry Pi OS, Wayland)
- `kscreen-doctor --dpms [on]/[off]` (Debian KDE, Wayland)
- `xset dpms force [on]/[off]` (Generic, X11)
**Known Issues** that are by-design or for which there isn't a solution so far:
- You can use Raspberry Pi's build-in screen blanking functionality, however, if the screen is turned on through Home Assistant after being automatically turned off, it will remain on indefinitely.
- You can use Raspberry Pi's build-in [screen blanking](https://www.raspberrypi.com/documentation/computers/configuration.html#screen-blanking-3) functionality, however, if the screen is turned on through Home Assistant after being automatically turned off, it will remain on indefinitely.
- It's recommended to either use the built-in screen blanking feature or implement a Home Assistant automation (e.g. presence detection) to manage the screen status.
- When using a Raspberry Pi, the on-screen keyboard doesn't automatically pop-out when entering a text field inside the webview.
- As a current [workaround](https://github.com/leukipp/touchkio/issues/4) you can use the side [widget](https://github.com/leukipp/touchkio/issues/16) to toggle the keyboard visibility.
- When using a Raspberry Pi, the on-screen keyboard doesn't [automatically pop-out](https://github.com/leukipp/touchkio/issues/4) when entering a text field inside the webview.
- As a current workaround you can use the side [widget](https://github.com/leukipp/touchkio/issues/16) to toggle the keyboard visibility.
- Certain commands from the MQTT integration may require elevated privileges to work correctly.
- Ensure that your local user has the [necessary permissions](https://github.com/leukipp/touchkio/issues/39) to run `sudo` without being prompted for a password.
- On the terminal you may see some *ERROR:gbm_wrapper.cc* messages.
- This appears to be a [known issue](https://github.com/electron/electron/issues/42322) that currently lacks a fix, but the webview still works.
For debugging, stop the service and launch `touchkio` directly on the terminal to monitor the log output in real-time.
This output is also written into `~/.config/touchkio/logs/main.log` for review.
If you encounter any problems, please [create an issue](https://github.com/leukipp/touchkio/issues) and include the output of `touchkio --version`, additional information's about your system (such as operating system, hardware specs, etc.) and any relevant log files.
## Credits
Thanks to Sebastian ([@pdsccode](https://github.com/pdsccode)) for his contributions on issues and [community](https://community.home-assistant.io/t/kiosk-mode-for-raspberry-pi-with-touch-display/821196) discussions.
+35 -2
View File
@@ -1,4 +1,22 @@
const fs = require("fs");
const jszip = require("jszip");
const crypto = require("crypto");
const path = require("path");
const date = new Date().toISOString();
const generateBuildFile = (platform, arch, maker) => {
const package = fs.readFileSync(path.join(__dirname, "package.json"), "utf8");
const hash = crypto.createHash("sha256").update(package);
const data = {
id: hash.digest("hex").slice(-6),
platform: platform,
arch: arch,
maker: maker,
date: date,
};
return JSON.stringify(data, null, 2);
};
module.exports = {
packagerConfig: {
@@ -33,10 +51,25 @@ module.exports = {
},
],
hooks: {
postPackage: async (config, results) => {
for (const outputPath of results.outputPaths) {
const [name, platform, arch] = path.basename(outputPath).split("-");
const buildFile = path.join(outputPath, "resources", "app", "build.json");
fs.writeFileSync(buildFile, generateBuildFile(platform, arch, "deb"), { encoding: "utf8" });
}
},
postMake: async (config, results) => {
for (const result of results) {
const artifacts = [];
result.artifacts.forEach((artifact) => {
for (const artifact of result.artifacts) {
if (artifact.includes(".zip")) {
const [name, platform, arch] = path.basename(artifact).split("-");
const buildFile = path.join(`${name}-${platform}-${arch}`, "resources", "app", "build.json");
const options = { type: "nodebuffer", compression: "DEFLATE", compressionOptions: { level: 9 } };
const zip = await jszip.loadAsync(fs.readFileSync(artifact));
zip.file(buildFile, generateBuildFile(platform, arch, "zip"));
fs.writeFileSync(artifact, await zip.generateAsync(options));
}
if (artifact.includes("amd64")) {
const renamed = artifact.replace("amd64", "x64");
fs.renameSync(artifact, renamed);
@@ -44,7 +77,7 @@ module.exports = {
} else {
artifacts.push(artifact);
}
});
}
result.artifacts = artifacts;
}
},
+59 -6
View File
@@ -46,11 +46,19 @@
li.dynamic {
flex: 1 1 0%;
}
li.separator {
height: 32px;
border: 1px solid var(--border-color);
}
li.separator:has(+ li.hidden) {
display: none;
}
li.hidden {
display: none;
}
li.disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
button {
@@ -62,12 +70,13 @@
input {
width: 100%;
min-width: 0;
margin: 0 10px;
margin: 0 5px;
padding: 6px 10px;
border-radius: 4px;
font-size: 18px;
outline: none;
border: none;
cursor: text;
box-sizing: border-box;
color: var(--primary-color);
background-color: var(--border-color);
@@ -91,7 +100,28 @@
</button>
</li>
<li>
<button id="pager">
<button id="refresh">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<title>refresh-circle</title>
<path
d="M12 2A10 10 0 1 0 22 12A10 10 0 0 0 12 2M18 11H13L14.81 9.19A3.94 3.94 0 0 0 12 8A4 4 0 1 0 15.86 13H17.91A6 6 0 1 1 12 6A5.91 5.91 0 0 1 16.22 7.78L18 6Z"
/>
</svg>
</button>
</li>
<li class="separator"></li>
<li>
<button id="previous">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<title>page-previous</title>
<path
d="M4,21H19A2,2 0 0,0 21,19V13H17V15L13,12L17,9V11H21V5A2,2 0 0,0 19,3H4A2,2 0 0,0 2,5V19A2,2 0 0,0 4,21M4,15H8V17H4V15M4,11H11V13H4V11M4,7H11V9H4V7M21,11H24V13H21V11Z"
/>
</svg>
</button>
</li>
<li>
<button id="next">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<title>page-next</title>
<path
@@ -100,15 +130,17 @@
</svg>
</button>
</li>
<li class="separator"></li>
<li class="dynamic">
<input id="url" type="text" spellcheck="false" />
</li>
<li class="separator"></li>
<li>
<button id="backward">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<title>arrow-left-circle</title>
<title>history</title>
<path
d="M2,12A10,10 0 0,1 12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12M18,11H10L13.5,7.5L12.08,6.08L6.16,12L12.08,17.92L13.5,16.5L10,13H18V11Z"
d="M13.5,8H12V13L16.28,15.54L17,14.33L13.5,12.25V8M13,3A9,9 0 0,0 4,12H1L4.96,16.03L9,12H6A7,7 0 0,1 13,5A7,7 0 0,1 20,12A7,7 0 0,1 13,19C11.07,19 9.32,18.21 8.06,16.94L6.64,18.36C8.27,20 10.5,21 13,21A9,9 0 0,0 22,12A9,9 0 0,0 13,3"
/>
</svg>
</button>
@@ -116,9 +148,30 @@
<li>
<button id="forward">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<title>arrow-right-circle</title>
<title>update</title>
<path
d="M22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2A10,10 0 0,1 22,12M6,13H14L10.5,16.5L11.92,17.92L17.84,12L11.92,6.08L10.5,7.5L14,11H6V13Z"
d="M21,10.12H14.22L16.96,7.3C14.23,4.6 9.81,4.5 7.08,7.2C4.35,9.91 4.35,14.28 7.08,17C9.81,19.7 14.23,19.7 16.96,17C18.32,15.65 19,14.08 19,12.1H21C21,14.08 20.12,16.65 18.36,18.39C14.85,21.87 9.15,21.87 5.64,18.39C2.14,14.92 2.11,9.28 5.62,5.81C9.13,2.34 14.76,2.34 18.27,5.81L21,3V10.12M12.5,8V12.25L16,14.33L15.28,15.54L11,13V8H12.5Z"
/>
</svg>
</button>
</li>
<li class="separator"></li>
<li>
<button id="minus">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<title>magnify-minus</title>
<path
d="M9,2A7,7 0 0,1 16,9C16,10.57 15.5,12 14.61,13.19L15.41,14H16L22,20L20,22L14,16V15.41L13.19,14.61C12,15.5 10.57,16 9,16A7,7 0 0,1 2,9A7,7 0 0,1 9,2M5,8V10H13V8H5Z"
/>
</svg>
</button>
</li>
<li>
<button id="plus">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<title>magnify-plus</title>
<path
d="M9,2A7,7 0 0,1 16,9C16,10.57 15.5,12 14.61,13.19L15.41,14H16L22,20L20,22L14,16V15.41L13.19,14.61C12,15.5 10.57,16 9,16A7,7 0 0,1 2,9A7,7 0 0,1 9,2M8,5V8H5V10H8V13H10V10H13V8H10V5H8Z"
/>
</svg>
</button>
+82
View File
@@ -0,0 +1,82 @@
<!doctype html>
<html>
<head>
<style>
[data-theme="hidden"] {
--primary-color: rgba(0, 0, 0, 0);
--border-color: rgba(0, 0, 0, 0);
--background-color: rgba(0, 0, 0, 0);
}
::-webkit-scrollbar {
display: none;
}
* {
margin: 0;
padding: 0;
cursor: none;
}
body {
display: flex;
align-items: center;
width: 100vw;
height: 100vh;
}
menu {
display: flex;
flex-direction: column;
align-items: center;
width: 100vw;
height: 100vh;
list-style-type: none;
background: var(--background-color);
}
li {
display: flex;
align-items: center;
flex: 0 0 auto;
}
button {
width: 100vw;
height: 50vh;
border: none;
background-color: transparent;
}
#previous {
cursor: w-resize;
}
#next {
cursor: e-resize;
}
</style>
</head>
<body>
<menu>
<li>
<button id="previous"></button>
</li>
<li>
<button id="next"></button>
</li>
</menu>
<script>
const { ipcRenderer } = require("electron");
const registerSendEvents = () => {
document.querySelectorAll("button").forEach((button) => {
button.addEventListener("click", (e) => {
ipcRenderer.send("button-click", { id: e.target.id });
});
});
};
const registerReceiveEvents = () => {
ipcRenderer.on("data-theme", (e, data) => {
document.documentElement.setAttribute("data-theme", data.theme);
});
};
registerSendEvents();
registerReceiveEvents();
</script>
</body>
</html>
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 78 KiB

+127 -17
View File
@@ -5,9 +5,13 @@ const readline = require("readline/promises");
const integration = require("./js/integration");
const hardware = require("./js/hardware");
const webview = require("./js/webview");
const log = require("electron-log");
const { app } = require("electron");
const Events = require("events");
global.APP = global.APP || {};
global.ARGS = global.ARGS || {};
global.EVENTS = global.EVENTS || new Events();
if (!process.env.DISPLAY) {
console.error(`\n$DISPLAY variable not set to run the GUI application, are you connected via SSH?\n`);
@@ -23,20 +27,90 @@ if (!process.env.DISPLAY) {
* initialization tasks.
*/
app.whenReady().then(async () => {
const lock = app.requestSingleInstanceLock();
if (!lock) {
console.error("TouchKio is already running");
if (!(await initApp()) || !(await initArgs()) || !(await initLog())) {
return;
}
// Show used arguments
const args = Object.assign({}, ARGS);
if ("mqtt_password" in args) {
args.mqtt_password = "*".repeat((args.mqtt_password || "").length);
}
console.log(`Arguments: ${JSON.stringify(args, null, 2)}`);
// Chained init functions
const chained = [webview.init, hardware.init, integration.init];
for (const init of chained) {
if (!(await init())) {
break;
}
}
});
/**
* Initializes the global app object.
*
* @returns {bool} Returns true if the initialization was successful.
*/
const initApp = async () => {
const packageJsonPath = path.join(app.getAppPath(), "package.json");
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
const buildJsonPath = path.join(app.getAppPath(), "build.json");
const buildFileExists = fs.existsSync(buildJsonPath);
// Set required app infos
APP.name = app.getName();
APP.title = packageJson.title;
APP.version = app.getVersion();
APP.path = app.getAppPath();
APP.config = app.getPath("userData");
APP.log = path.join(app.getPath("logs"), "main.log");
// Set additional update infos
APP.homepage = `https://github.com/${packageJson.author}/${packageJson.name}`;
APP.releases = {
url: `https://api.github.com/repos/${packageJson.author}/${packageJson.name}/releases`,
latest: null,
};
APP.scripts = {
install: `https://raw.githubusercontent.com/${packageJson.author}/${packageJson.name}/main/install.sh`,
};
// Set additional build infos
APP.build = {};
if (buildFileExists) {
APP.build = JSON.parse(fs.readFileSync(buildJsonPath, "utf8"));
}
// Request single app instance lock
if (!app.requestSingleInstanceLock()) {
console.error(`${APP.title} is already running`);
return app.quit();
}
return true;
};
/**
* Initializes the global args object.
*
* @returns {bool} Returns true if the initialization was successful.
*/
const initArgs = async () => {
let args = parseArgs(process);
let argsProvided = !!Object.keys(args).length;
let argsFilePath = path.join(app.getPath("userData"), "Arguments.json");
let argsFilePath = path.join(APP.config, "Arguments.json");
let argsFileExists = fs.existsSync(argsFilePath);
// Show version and release info
if ("help" in args || "version" in args) {
console.log(`${app.getName()}-v${app.getVersion()}, https://github.com/leukipp/touchkio`);
let build = "";
if (APP.build.id) {
build = ` (${APP.build.id}), built on ${APP.build.date} (${APP.build.platform}-${APP.build.arch}-${APP.build.maker})`;
}
console.log(`${APP.name}-v${APP.version}${build}\n${APP.homepage}`);
return app.quit();
}
@@ -51,24 +125,44 @@ app.whenReady().then(async () => {
args = readArgs(argsFilePath);
}
// Check arguments object
if (!Object.keys(args).length) {
console.error(`No arguments provided`);
return app.quit();
}
// Split url arguments
args.web_url = args.web_url || [];
if (!Array.isArray(args.web_url)) {
args.web_url = args.web_url.split(",").map((url) => url.trim());
}
global.ARGS = args;
ARGS = args;
// Show used arguments
console.log(`Arguments: ${JSON.stringify(global.ARGS, null, 2)}`);
return true;
};
// Chained init functions
const chained = [webview.init, hardware.init, integration.init];
for (const init of chained) {
if (!(await init())) {
break;
/**
* Initializes the global log object.
*
* @returns {bool} Returns true if the initialization was successful.
*/
const initLog = async () => {
try {
if (fs.existsSync(APP.log)) {
fs.unlinkSync(APP.log);
}
} catch (error) {
console.error("Failed to delete log file:", error.message);
}
});
// Overwrite console log settings
log.transports.file.resolvePathFn = () => {
return APP.log;
};
Object.assign(console, log.functions);
return true;
};
/**
* Parses command-line arguments from the given process object.
@@ -155,6 +249,7 @@ const promptArgs = async (proc) => {
// Prompt questions and wait for the answers
let args = {};
let ignore = [];
try {
for (const { key, question, fallback } of prompts) {
if (key === "mqtt") {
const prompt = `${question} (${fallback}): `;
@@ -182,7 +277,13 @@ const promptArgs = async (proc) => {
}
}
}
} catch (error) {
console.error(`\n${error.message}`);
args = {};
app.quit();
} finally {
read.close();
}
return args;
};
@@ -194,11 +295,15 @@ const promptArgs = async (proc) => {
* @param {Object} args - The arguments object.
*/
const writeArgs = (path, args) => {
try {
const argc = Object.assign({}, args);
if ("mqtt_password" in argc) {
argc.mqtt_password = encrypt(argc.mqtt_password);
}
fs.writeFileSync(path, JSON.stringify(argc, null, 2));
} catch (error) {
console.error(`Failed to write ${path}:`, error.message);
}
};
/**
@@ -208,11 +313,16 @@ const writeArgs = (path, args) => {
* @returns {Object} The arguments object.
*/
const readArgs = (path) => {
const args = JSON.parse(fs.readFileSync(path));
try {
const args = JSON.parse(fs.readFileSync(path, "utf8"));
if ("mqtt_password" in args) {
args.mqtt_password = decrypt(args.mqtt_password);
}
return args;
} catch (error) {
console.error(`Failed to parse ${path}:`, error.message);
}
return {};
};
/**
@@ -223,7 +333,7 @@ const readArgs = (path) => {
*/
const encrypt = (value) => {
const iv = crypto.randomBytes(16);
const key = crypto.scryptSync(hardware.getMachineId(), app.getName(), 32);
const key = crypto.scryptSync(hardware.getMachineId(), APP.name, 32);
const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
let encrypted = cipher.update(value, "utf8", "hex");
encrypted += cipher.final("hex");
@@ -239,7 +349,7 @@ const encrypt = (value) => {
const decrypt = (value) => {
const p = Buffer.from(value, "base64").toString("utf8").split(":");
const iv = Buffer.from(p.shift(), "hex");
const key = crypto.scryptSync(hardware.getMachineId(), app.getName(), 32);
const key = crypto.scryptSync(hardware.getMachineId(), APP.name, 32);
const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv);
const buffer = Buffer.from(p.join(":"), "hex");
let decrypted = decipher.update(buffer, "binary", "utf8");
+54 -41
View File
@@ -1,5 +1,13 @@
#!/usr/bin/env bash
# Read arguments
ARG_UPDATE=false
for arg in "$@"; do
if [ "$arg" = "update" ]; then
ARG_UPDATE=true
fi
done
# Determine system architecture
echo -e "Determining system architecture..."
@@ -12,62 +20,43 @@ case "$(uname -m)" in
ARCH="x64"
;;
*)
echo "Architecture $(uname -m) running $BITS-bit operating system is not supported."
exit 1
{ echo "Architecture $(uname -m) running $BITS-bit operating system is not supported."; exit 1; }
;;
esac
if [ "$BITS" -ne 64 ]; then
echo "Architecture $ARCH running $BITS-bit operating system is not supported."
exit 1
fi
[ "$BITS" -eq 64 ] || { echo "Architecture $ARCH running $BITS-bit operating system is not supported."; exit 1; }
echo "Architecture $ARCH running $BITS-bit operating system is supported."
# Download the latest .deb package
echo -e "\nDownloading the latest release..."
TMP_DIR=$(mktemp -d)
DEB_URL=$(wget -qO- https://api.github.com/repos/leukipp/touchkio/releases/latest | \
grep -o "\"browser_download_url\": \"[^\"]*_${ARCH}\.deb\"" | \
sed 's/"browser_download_url": "//;s/"//g')
TMP_DIR=$(mktemp -d)
DEB_PATH="${TMP_DIR}/$(basename "$DEB_URL")"
chmod 755 "$TMP_DIR"
if [ -z "$DEB_URL" ]; then
echo "Download url for .deb file not found."
exit 1
fi
if ! wget --show-progress -q -O "$DEB_PATH" "$DEB_URL"; then
echo "Failed to download the .deb file."
exit 1
fi
[ -z "$DEB_URL" ] && { echo "Download url for .deb file not found."; exit 1; }
wget --show-progress -q -O "$DEB_PATH" "$DEB_URL" || { echo "Failed to download the .deb file."; exit 1; }
# Install the latest .deb package
echo -e "\nInstalling the latest release..."
if ! command -v apt &> /dev/null; then
echo "Package manager apt was not found."
exit 1
fi
if ! sudo apt install "$DEB_PATH"; then
echo "Installation of .deb file failed."
exit 1
fi
command -v apt &> /dev/null || { echo "Package manager apt was not found."; exit 1; }
sudo apt install -y "$DEB_PATH" || { echo "Installation of .deb file failed."; exit 1; }
# Create the systemd user service
echo -e "\nCreating systemd user service..."
SERVICE_FILE="$HOME/.config/systemd/user/touchkio.service"
mkdir -p "$(dirname "$SERVICE_FILE")" || { echo "Failed to create directory for $SERVICE_FILE"; exit 1; }
SERVICE_NAME="touchkio.service"
SERVICE_FILE="$HOME/.config/systemd/user/$SERVICE_NAME"
mkdir -p "$(dirname "$SERVICE_FILE")" || { echo "Failed to create directory for $SERVICE_FILE."; exit 1; }
bash -c "cat << EOF > \"$SERVICE_FILE\"
[Unit]
SERVICE_CONTENT="[Unit]
Description=TouchKio
After=graphical.target
Wants=network-online.target
[Service]
ExecStart=/usr/bin/touchkio
@@ -75,35 +64,59 @@ Restart=on-failure
RestartSec=5s
[Install]
WantedBy=default.target
EOF"
WantedBy=default.target"
systemctl --user enable "$(basename "$SERVICE_FILE")" || { echo "Failed to enable service $SERVICE_FILE"; exit 1; }
echo "Service $SERVICE_FILE enabled"
if $ARG_UPDATE; then
if systemctl --user --quiet is-active "${SERVICE_NAME}"; then
systemctl --user restart "${SERVICE_NAME}"
echo "Existing $SERVICE_NAME restarted."
else
echo "Existing $SERVICE_NAME not running, start touchkio manually."
fi
exit 0
fi
SERVICE_CREATE=true
if [ -f "$SERVICE_FILE" ]; then
read -p "Service $SERVICE_FILE exists, overwrite? (y/N) " overwrite
[[ ${overwrite:-n} == [Yy]* ]] || SERVICE_CREATE=false
fi
if $SERVICE_CREATE; then
echo "$SERVICE_CONTENT" > "$SERVICE_FILE" || { echo "Failed to write to $SERVICE_FILE."; exit 1; }
systemctl --user enable "$(basename "$SERVICE_FILE")" || { echo "Failed to enable service $SERVICE_FILE."; exit 1; }
echo "Service $SERVICE_FILE enabled."
else
echo "Service $SERVICE_FILE not created."
fi
# Export display variables
echo -e "\nExporting display variables..."
if [ -z "$DISPLAY" ]; then
export DISPLAY=":0"
echo "DISPLAY was not set, defaulting to \"$DISPLAY\""
echo "DISPLAY was not set, defaulting to \"$DISPLAY\"."
else
echo "DISPLAY is set to \"$DISPLAY\""
echo "DISPLAY is set to \"$DISPLAY\"."
fi
if [ -z "$WAYLAND_DISPLAY" ]; then
export WAYLAND_DISPLAY="wayland-0"
echo "WAYLAND_DISPLAY was not set, defaulting to \"$WAYLAND_DISPLAY\""
echo "WAYLAND_DISPLAY was not set, defaulting to \"$WAYLAND_DISPLAY\"."
else
echo "WAYLAND_DISPLAY is set to \"$WAYLAND_DISPLAY\""
echo "WAYLAND_DISPLAY is set to \"$WAYLAND_DISPLAY\"."
fi
# Start the setup mode
echo ""
read -p "Start touchkio setup? (Y/n) " answer
if [[ ${answer:-y} == [Yy]* ]]; then
read -p "Start touchkio setup? (Y/n) " setup
if [[ ${setup:-y} == [Yy]* ]]; then
echo "/usr/bin/touchkio --setup"
/usr/bin/touchkio --setup
else
echo "/usr/bin/touchkio"
/usr/bin/touchkio
fi
exit 0
+255 -80
View File
@@ -6,9 +6,8 @@ const cpr = require("child_process");
global.HARDWARE = global.HARDWARE || {
initialized: false,
status: null,
session: {},
support: {},
session: {},
battery: {
level: {
path: null,
@@ -16,19 +15,18 @@ global.HARDWARE = global.HARDWARE || {
},
display: {
status: {
path: null,
value: null,
path: null,
command: null,
},
brightness: {
path: null,
value: null,
path: null,
max: null,
},
notifiers: [],
},
keyboard: {
visible: null,
notifiers: [],
},
};
@@ -38,51 +36,68 @@ global.HARDWARE = global.HARDWARE || {
* @returns {bool} Returns true if the initialization was successful.
*/
const init = async () => {
if (!compatibleDevice()) {
console.warn("Device not supported");
if (!compatibleSystem()) {
console.error("Operating system is not supported");
return false;
}
// Init globals
HARDWARE.session.user = sessionUser();
HARDWARE.session.type = sessionType();
HARDWARE.session.desktop = sessionDesktop();
HARDWARE.battery.level.path = getBatteryLevelPath();
HARDWARE.support.batteryLevel = HARDWARE.battery.level.path !== null;
HARDWARE.display.status.path = getDisplayStatusPath();
HARDWARE.support.displayStatus = HARDWARE.display.status.path !== null;
HARDWARE.display.status.command = getDisplayStatusCommand();
HARDWARE.display.brightness.path = getDisplayBrightnessPath();
HARDWARE.support.displayBrightness = HARDWARE.display.brightness.path !== null;
HARDWARE.display.brightness.max = getDisplayBrightnessMax();
HARDWARE.support.keyboardVisibility = processRuns("squeekboard");
HARDWARE.support = checkSupport();
HARDWARE.initialized = true;
HARDWARE.status = "Supported";
// Show supported features
process.stdout.write("\n");
console.log(`Supported: ${JSON.stringify(HARDWARE.support, null, 2)}`);
// Show session infos
console.log("\nUser:", HARDWARE.session.user);
process.stdout.write("\n");
console.log("User:", HARDWARE.session.user);
console.log("Session:", HARDWARE.session.type);
console.log("Desktop:", HARDWARE.session.desktop);
// Show device infos
console.log("\nModel:", getModel());
process.stdout.write("\n");
console.log("Model:", getModel());
console.log("Vendor:", getVendor());
console.log("Serial Number:", getSerialNumber());
console.log("Network Addresses:", getNetworkAddresses());
console.log("Host Name:", getHostName());
// Show system infos
console.log("\nUp Time:", getUpTime());
process.stdout.write("\n");
console.log("Up Time:", getUpTime());
console.log("Memory Size:", getMemorySize());
console.log("Memory Usage:", getMemoryUsage());
console.log("Processor Usage:", getProcessorUsage());
console.log("Processor Temperature:", getProcessorTemperature());
// Show hardware infos
console.log(`\nBattery Level [${HARDWARE.battery.level.path}]:`, getBatteryLevel());
console.log(`Display Status [${HARDWARE.display.status.path}]:`, getDisplayStatus());
console.log(`Display Brightness [${HARDWARE.display.brightness.path}]:`, getDisplayBrightness(), "\n");
process.stdout.write("\n");
const unsupported = "unsupported";
console.log(`Battery Level [${HARDWARE.battery.level.path || unsupported}]:`, `${getBatteryLevel() || unsupported}`);
console.log(
`Display Status [${HARDWARE.display.status.path || unsupported}]:`,
`${getDisplayStatus() || unsupported} (${HARDWARE.display.status.command || unsupported})`,
);
console.log(
`Display Brightness [${HARDWARE.display.brightness.path || unsupported}]:`,
`${getDisplayBrightness() || unsupported}`,
);
console.log(
`Keyboard Visibility [${HARDWARE.support.keyboardVisibility ? "squeekboard" : unsupported}]:`,
`${getKeyboardVisibility() || unsupported}`,
);
// Check for keyboard visibility
process.stdout.write("\n");
setKeyboardVisibility("OFF", (reply, error) => {
if (!reply || error) {
return;
@@ -92,14 +107,14 @@ const init = async () => {
return;
}
HARDWARE.keyboard.visibility = property.Visible === "true";
HARDWARE.keyboard.notifiers.forEach((notifier) => {
notifier();
});
EVENTS.emit("updateKeyboard");
});
});
// Check for display changes
setDisplayStatus("ON", () => {
interval(update, 1000);
});
return true;
};
@@ -118,9 +133,7 @@ const update = async () => {
if (status !== HARDWARE.display.status.value) {
console.log("Update Display Status:", getDisplayStatus());
HARDWARE.display.status.value = status;
HARDWARE.display.notifiers.forEach((notifier) => {
notifier();
});
EVENTS.emit("updateDisplay");
}
}
@@ -130,23 +143,21 @@ const update = async () => {
if (brightness !== HARDWARE.display.brightness.value) {
console.log("Update Display Brightness:", getDisplayBrightness());
HARDWARE.display.brightness.value = brightness;
HARDWARE.display.notifiers.forEach((notifier) => {
notifier();
});
EVENTS.emit("updateDisplay");
}
}
};
/**
* Verifies device compatibility by checking the presence of necessary sys paths.
* Verifies system compatibility by checking the presence of necessary sys paths.
*
* @returns {bool} Returns true if all paths exists.
*/
const compatibleDevice = () => {
const compatibleSystem = () => {
if (os.platform() !== "linux") {
return false;
}
const paths = ["/sys/class/drm", "/sys/class/backlight", "/sys/class/thermal"];
const paths = ["/sys/class/drm", "/sys/class/backlight", "/sys/class/power_supply", "/sys/class/thermal"];
return paths.every((path) => fs.existsSync(path));
};
@@ -179,12 +190,37 @@ const sessionType = () => {
};
/**
* Checks the session type for the logged in user.
* Gets the desktop environment name by checking environment variables.
*
* @returns {bool} Returns true if the session matches the type.
* @returns {string} Returns desktop environment name or 'unknown' if not detected.
*/
const session = (type) => {
return HARDWARE.session.type === type;
const sessionDesktop = () => {
const envs = ["XDG_CURRENT_DESKTOP", "XDG_DESKTOP_SESSION", "DESKTOP_SESSION"];
const names = envs.map((env) => process.env[env]).filter(Boolean);
return (names.join(":") || "unknown").toLowerCase();
};
/**
* Checks supported features based on hardware and software.
*
* @returns {Object} Returns support object with boolean values.
*/
const checkSupport = () => {
const battery = HARDWARE.battery.level;
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,
displayStatus: !!status.path && !!status.command,
displayBrightness: sudo && !!brightness.path && !!brightness.max,
keyboardVisibility: keyboard,
sudoRights: sudo,
appUpdate: sudo && service && deb,
};
};
/**
@@ -196,7 +232,7 @@ const getModel = () => {
const paths = ["/sys/firmware/devicetree/base/model", "/sys/class/dmi/id/product_name"];
for (const path of paths) {
if (fs.existsSync(path)) {
return execSyncCommand("sudo", ["cat", path]) || "Generic";
return execSyncCommand("cat", [path]) || "Generic";
}
}
return "Generic";
@@ -211,7 +247,7 @@ const getVendor = () => {
const paths = ["/sys/class/dmi/id/board_vendor"];
for (const path of paths) {
if (fs.existsSync(path)) {
return execSyncCommand("sudo", ["cat", path]) || "Generic";
return execSyncCommand("cat", [path]) || "Generic";
}
}
const model = getModel();
@@ -222,22 +258,22 @@ const getVendor = () => {
};
/**
* Gets the serial number using `/sys/firmware/devicetree/base/serial-number` or `/sys/class/dmi/id/product_serial`.
* Gets the serial number using `/sys/firmware/devicetree/base/serial-number`.
*
* @returns {string} The serial number of the device or '123456' if not found.
* @returns {string} The serial number or machine id parts of the device or '123456' if not found.
*/
const getSerialNumber = () => {
const paths = ["/sys/firmware/devicetree/base/serial-number", "/sys/class/dmi/id/product_serial"];
const paths = ["/sys/firmware/devicetree/base/serial-number"];
for (const path of paths) {
if (fs.existsSync(path)) {
return execSyncCommand("sudo", ["cat", path]) || "123456";
return execSyncCommand("cat", [path]) || "123456";
}
}
return "123456";
return getMachineId().slice(-6);
};
/**
* Gets the host machine id using `/etc/machine-id`.
* Gets the machine id using `/etc/machine-id`.
*
* @returns {string} The machine id of the system or '123456' if not found.
*/
@@ -245,12 +281,38 @@ const getMachineId = () => {
const paths = ["/etc/machine-id"];
for (const path of paths) {
if (fs.existsSync(path)) {
return execSyncCommand("sudo", ["cat", path]) || "123456";
return execSyncCommand("cat", [path]) || "123456";
}
}
return "123456";
};
/**
* Gets the network interfaces addresses using `os.networkInterfaces()`.
*
* @returns {Object} The network addresses of all interfaces.
*/
const getNetworkAddresses = () => {
const addresses = {};
for (const [key, interfaces] of Object.entries(os.networkInterfaces())) {
for (const interface of interfaces) {
if (interface.internal || !interface.family || !interface.address) {
continue;
}
const name = key.charAt(0).toUpperCase() + key.slice(1);
if (!addresses[name]) {
addresses[name] = {};
}
const family = interface.family;
if (!addresses[name][family]) {
addresses[name][family] = [];
}
addresses[name][family].push(interface.address);
}
}
return addresses;
};
/**
* Gets the host name of the current system using `os.hostname()`.
*
@@ -371,7 +433,31 @@ const getDisplayStatusPath = () => {
};
/**
* Gets the current display power status using `wlopm` or `xset`.
* Gets the available display status command checking for `wlopm`, `kscreen-doctor` and `xset`.
*
* @returns {string|null} The display status command or null if nothing was found.
*/
const getDisplayStatusCommand = () => {
const type = HARDWARE.session.type;
const desktop = HARDWARE.session.desktop;
const mapping = {
wayland: [
{ command: "wlopm", desktops: ["labwc", "wayfire", "unknown"] },
{ command: "kscreen-doctor", desktops: ["kde", "plasma", "unknown"] },
],
x11: [{ command: "xset", desktops: ["*"] }],
}[type];
for (const map of mapping || []) {
if (commandExists(map.command) && map.desktops.some((d) => d === "*" || desktop.includes(d))) {
return map.command;
}
}
console.warn("No display command available");
return null;
};
/**
* Gets the current display power status using the available command.
*
* @returns {string|null} The display status as 'ON'/'OFF' or null if an error occurs.
*/
@@ -379,24 +465,34 @@ const getDisplayStatus = () => {
if (!HARDWARE.support.displayStatus) {
return null;
}
if (session("wayland") && commandExists("wlopm")) {
const status = execSyncCommand("wlopm", []);
if (status !== null) {
const out = status.split("\n")[0].split(" ");
return out.pop().toUpperCase();
switch (HARDWARE.display.status.command) {
case "wlopm":
const wlopm = execSyncCommand("wlopm", []);
if (wlopm !== null) {
const output = wlopm.split("\n")[0].split(" ");
return output.pop().toUpperCase();
}
} else if (session("x11") && commandExists("xset")) {
const status = execSyncCommand("xset", ["-q"]);
if (status !== null) {
const on = /Monitor is On/.test(status);
return on ? "ON" : "OFF";
break;
case "kscreen-doctor":
const kdoc = execSyncCommand("kscreen-doctor", ["--dpms", "show"]);
if (kdoc !== null) {
const output = kdoc.split("\n")[0].split(" ");
return output.pop().toUpperCase();
}
break;
case "xset":
const xset = execSyncCommand("xset", ["-q"]);
if (xset !== null) {
const output = /Monitor is On/.test(xset);
return output ? "ON" : "OFF";
}
break;
}
return null;
};
/**
* Sets the display power status using `wlopm` or `xset`.
* Sets the display power status using the available command.
*
* This function takes a desired status ('ON' or 'OFF') and executes
* the appropriate command to set the display status.
@@ -414,10 +510,16 @@ const setDisplayStatus = (status, callback = null) => {
if (typeof callback === "function") callback(null, "Invalid status");
return;
}
if (session("wayland") && commandExists("wlopm")) {
switch (HARDWARE.display.status.command) {
case "wlopm":
execAsyncCommand("wlopm", [`--${status.toLowerCase()}`, "*"], callback);
} else if (session("x11") && commandExists("xset")) {
break;
case "kscreen-doctor":
execAsyncCommand("kscreen-doctor", ["--dpms", status.toLowerCase()], callback);
break;
case "xset":
execAsyncCommand("xset", ["dpms", "force", status.toLowerCase()], callback);
break;
}
};
@@ -444,7 +546,7 @@ const getDisplayBrightnessPath = () => {
* @returns {number|null} The brightness maximum value or null if an error occurs.
*/
const getDisplayBrightnessMax = () => {
if (!HARDWARE.support.displayBrightness) {
if (!HARDWARE.display.brightness.path) {
return null;
}
const max = fs.readFileSync(`${HARDWARE.display.brightness.path}/max_brightness`, "utf8").trim();
@@ -490,7 +592,7 @@ const setDisplayBrightness = (brightness, callback = null) => {
if (typeof callback === "function") callback(null, "Invalid brightness");
return;
}
const max = HARDWARE.display.brightness.max;
const max = HARDWARE.display.brightness.max || 1;
const value = Math.max(1, Math.min(Math.round((brightness / 100) * max), max));
const proc = execAsyncCommand("sudo", ["tee", `${HARDWARE.display.brightness.path}/brightness`], callback);
proc.stdin.write(value.toString());
@@ -557,6 +659,10 @@ const checkPackageUpgrades = () => {
* @param {Function} callback - A callback function that receives the output or error.
*/
const shutdownSystem = (callback = null) => {
if (!HARDWARE.support.sudoRights) {
if (typeof callback === "function") callback(null, "Not supported");
return;
}
execAsyncCommand("sudo", ["shutdown", "-h", "now"], callback);
};
@@ -569,9 +675,39 @@ const shutdownSystem = (callback = null) => {
* @param {Function} callback - A callback function that receives the output or error.
*/
const rebootSystem = (callback = null) => {
if (!HARDWARE.support.sudoRights) {
if (typeof callback === "function") callback(null, "Not supported");
return;
}
execAsyncCommand("sudo", ["reboot"], callback);
};
/**
* Checks if sudo commands can run without a password.
*
* @returns {bool} Returns true if password-less sudo rights exists.
*/
const sudoRights = () => {
try {
const output = cpr.execSync(`sudo -n true`, { encoding: "utf8" });
return output !== null && !output.includes("password is required");
} catch {}
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`.
*
@@ -648,14 +784,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<string>} 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 = 1;
let proc = cpr.spawn(cmd, args);
if (typeof callback === "function") callback(progress, null);
proc.stdout.on("data", (data) => {
if (data) {
const output = data.toString().trim();
const lines = output.replace(/\n+/g, "\n").replace(/^\n+|\n+$|\0/g, "");
for (const line of lines.split("\n").filter(Boolean)) {
console.log(line);
}
}
});
proc.stderr.on("data", (data) => {
if (data) {
const output = data.toString().trim();
const lines = output.replace(/\n+/g, "\n").replace(/^\n+|\n+$|\0/g, "");
for (const line of lines.split("\n").filter(Boolean)) {
const matches = line.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`.
*
@@ -670,15 +853,11 @@ const dbusCall = (path, method, values, callback = null) => {
const dest = `${iface} ${path} ${iface}.${method} ${values.join(" ")}`;
const args = ["--print-reply", "--type=method_call", `--dest=${dest}`];
try {
const output = cpr.execSync([cmd, ...args].join(" "), { encoding: "utf8" });
if (typeof callback === "function") {
callback(output.trim().replace(/\0/g, ""), null);
}
const output = cpr.execSync([cmd, ...args].join(" ").trim(), { encoding: "utf8" });
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);
}
};
@@ -712,17 +891,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;
@@ -755,19 +930,16 @@ module.exports = {
getVendor,
getSerialNumber,
getMachineId,
getNetworkAddresses,
getHostName,
getUpTime,
getMemorySize,
getMemoryUsage,
getProcessorUsage,
getProcessorTemperature,
getBatteryLevelPath,
getBatteryLevel,
getDisplayStatusPath,
getDisplayStatus,
setDisplayStatus,
getDisplayBrightnessPath,
getDisplayBrightnessMax,
getDisplayBrightness,
setDisplayBrightness,
getKeyboardVisibility,
@@ -775,4 +947,7 @@ module.exports = {
checkPackageUpgrades,
shutdownSystem,
rebootSystem,
execSyncCommand,
execAsyncCommand,
execScriptCommand,
};
+202 -52
View File
@@ -4,7 +4,6 @@ const { app } = require("electron");
global.INTEGRATION = global.INTEGRATION || {
initialized: false,
status: null,
};
/**
@@ -38,14 +37,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()}`,
sw_version: `${APP.name}-v${APP.version}`,
configuration_url: APP.homepage,
};
// Connection settings
@@ -56,10 +56,12 @@ const init = async () => {
// Client connected
INTEGRATION.client
.on("connect", () => {
console.log(`MQTT Connected: ${url.toString()}\n`);
.once("connect", () => {
console.log(`MQTT Connected: ${url.toString()}`);
process.stdout.write("\n");
// Init client controls
initApp();
initShutdown();
initReboot();
initRefresh();
@@ -67,12 +69,14 @@ const init = async () => {
initDisplay();
initKeyboard();
initPageNumber();
initPageZoom();
initPageUrl();
// Init client sensors
initModel();
initSerialNumber();
initHostName();
initNetworkAddress();
initUpTime();
initMemorySize();
initMemoryUsage();
@@ -85,20 +89,14 @@ const init = async () => {
// Integration initialized
INTEGRATION.initialized = true;
INTEGRATION.status = "Online";
})
.on("error", (error) => {
console.error("MQTT:", error);
INTEGRATION.status = "Offline";
console.error("MQTT", error.message);
});
// Update sensor states from notifiers
HARDWARE.display.notifiers.push(() => {
updateDisplay();
});
HARDWARE.keyboard.notifiers.push(() => {
updateKeyboard();
});
// Update sensor states from events
EVENTS.on("updateDisplay", updateDisplay);
EVENTS.on("updateKeyboard", updateKeyboard);
// Update time sensors periodically (30s)
setInterval(() => {
@@ -111,8 +109,9 @@ const init = async () => {
update();
}, 60 * 1000);
// Update package sensors periodically (60min)
// Update upgrade sensors periodically (1h)
setInterval(() => {
updateApp();
updatePackageUpgrades();
}, 3600 * 1000);
@@ -130,7 +129,9 @@ const update = async () => {
// Update client sensors
updateKiosk();
updatePageNumber();
updatePageZoom();
updatePageUrl();
updateNetworkAddress();
updateUpTime();
updateLastActive();
updateMemoryUsage();
@@ -185,10 +186,68 @@ 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) => {
if (progress) {
console.log(`Progress: ${progress}%`);
}
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.
*/
const initShutdown = () => {
if (!HARDWARE.support.sudoRights) {
return;
}
const root = `${INTEGRATION.root}/shutdown`;
const config = {
name: "Shutdown",
@@ -201,8 +260,9 @@ const initShutdown = () => {
.on("message", (topic, message) => {
if (topic === config.command_topic) {
console.log("Shutdown system...");
hardware.setDisplayStatus("ON");
hardware.setDisplayStatus("ON", () => {
hardware.shutdownSystem();
});
}
})
.subscribe(config.command_topic);
@@ -212,6 +272,9 @@ const initShutdown = () => {
* Initializes the reboot button and handles the execute logic.
*/
const initReboot = () => {
if (!HARDWARE.support.sudoRights) {
return;
}
const root = `${INTEGRATION.root}/reboot`;
const config = {
name: "Reboot",
@@ -224,8 +287,9 @@ const initReboot = () => {
.on("message", (topic, message) => {
if (topic === config.command_topic) {
console.log("Rebooting system...");
hardware.setDisplayStatus("ON");
hardware.setDisplayStatus("ON", () => {
hardware.rebootSystem();
});
}
})
.subscribe(config.command_topic);
@@ -247,8 +311,9 @@ const initRefresh = () => {
.on("message", (topic, message) => {
if (topic === config.command_topic) {
console.log("Refreshing webview...");
hardware.setDisplayStatus("ON");
WEBVIEW.views[WEBVIEW.viewActive].webContents.reloadIgnoringCache();
hardware.setDisplayStatus("ON", () => {
EVENTS.emit("reloadView");
});
}
})
.subscribe(config.command_topic);
@@ -274,7 +339,7 @@ const initKiosk = () => {
if (topic === config.command_topic) {
const status = message.toString();
console.log("Set Kiosk Status:", status);
hardware.setDisplayStatus("ON");
hardware.setDisplayStatus("ON", () => {
switch (status) {
case "Framed":
WEBVIEW.window.restore();
@@ -299,6 +364,7 @@ const initKiosk = () => {
case "Terminated":
app.quit();
}
});
}
})
.subscribe(config.command_topic);
@@ -308,8 +374,8 @@ const initKiosk = () => {
/**
* Updates the kiosk status via the mqtt connection.
*/
const updateKiosk = () => {
const kiosk = WEBVIEW.status;
const updateKiosk = async () => {
const kiosk = WEBVIEW.tracker.status;
publishState("kiosk", kiosk);
};
@@ -340,13 +406,23 @@ const initDisplay = () => {
if (topic === config.command_topic) {
const status = message.toString();
console.log("Set Display Status:", status);
hardware.setDisplayStatus(status);
hardware.setDisplayStatus(status, (reply, error) => {
if (!error) {
hardware.update();
} else {
console.warn("Failed:", error);
}
});
} else if (topic === config.brightness_command_topic) {
const brightness = parseInt(message, 10);
console.log("Set Display Brightness:", brightness);
hardware.setDisplayBrightness(brightness);
hardware.setDisplayBrightness(brightness, (reply, error) => {
if (!error) {
hardware.update();
} else {
console.warn("Failed:", error);
}
});
}
})
.subscribe(config.command_topic)
@@ -357,7 +433,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);
@@ -385,7 +461,7 @@ const initKeyboard = () => {
if (topic === config.command_topic) {
const status = message.toString();
console.log("Set Keyboard Visibility:", status);
hardware.setKeyboardVisibility(status);
hardware.setDisplayStatus("ON", () => {
switch (status) {
case "OFF":
WEBVIEW.window.restore();
@@ -398,6 +474,8 @@ const initKeyboard = () => {
WEBVIEW.window.maximize();
break;
}
hardware.setKeyboardVisibility(status);
});
}
})
.subscribe(config.command_topic);
@@ -407,7 +485,7 @@ const initKeyboard = () => {
/**
* Updates the keyboard visibility via the mqtt connection.
*/
const updateKeyboard = () => {
const updateKeyboard = async () => {
const visibility = hardware.getKeyboardVisibility();
publishState("keyboard", visibility);
};
@@ -416,7 +494,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`;
@@ -438,7 +516,8 @@ const initPageNumber = () => {
if (topic === config.command_topic) {
const number = parseInt(message, 10);
console.log("Set Page Number:", number);
WEBVIEW.viewActive = number;
WEBVIEW.viewActive = number || 1;
EVENTS.emit("updateView");
}
})
.subscribe(config.command_topic);
@@ -448,11 +527,50 @@ const initPageNumber = () => {
/**
* Updates the page number via the mqtt connection.
*/
const updatePageNumber = () => {
const pageNumber = WEBVIEW.viewActive;
const updatePageNumber = async () => {
const pageNumber = WEBVIEW.viewActive || 1;
publishState("page_number", pageNumber);
};
/**
* Initializes the page zoom and handles the execute logic.
*/
const initPageZoom = () => {
const root = `${INTEGRATION.root}/page_zoom`;
const config = {
name: "Page Zoom",
unique_id: `${INTEGRATION.node}_page_zoom`,
command_topic: `${root}/set`,
state_topic: `${root}/state`,
value_template: "{{ value | int }}",
mode: "slider",
min: 25,
max: 400,
unit_of_measurement: "%",
icon: "mdi:magnify-plus",
device: INTEGRATION.device,
};
publishConfig("number", config)
.on("message", (topic, message) => {
if (topic === config.command_topic) {
const zoom = parseInt(message, 10);
console.log("Set Page Zoom:", zoom);
WEBVIEW.views[WEBVIEW.viewActive || 1].webContents.setZoomFactor(zoom / 100.0);
EVENTS.emit("updateView");
}
})
.subscribe(config.command_topic);
updatePageZoom();
};
/**
* Updates the page zoom via the mqtt connection.
*/
const updatePageZoom = async () => {
const pageZoom = Math.round(WEBVIEW.views[WEBVIEW.viewActive || 1].webContents.getZoomFactor() * 100.0);
publishState("page_zoom", pageZoom);
};
/**
* Initializes the page url and handles the execute logic.
*/
@@ -473,7 +591,7 @@ const initPageUrl = () => {
if (topic === config.command_topic) {
const url = message.toString();
console.log("Set Page Url:", url);
WEBVIEW.views[WEBVIEW.viewActive].webContents.loadURL(url);
WEBVIEW.views[WEBVIEW.viewActive || 1].webContents.loadURL(url);
}
})
.subscribe(config.command_topic);
@@ -483,9 +601,9 @@ const initPageUrl = () => {
/**
* Updates the page url via the mqtt connection.
*/
const updatePageUrl = () => {
const defaultUrl = WEBVIEW.viewUrls[WEBVIEW.viewActive];
const currentUrl = WEBVIEW.views[WEBVIEW.viewActive].webContents.getURL();
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;
publishState("page_url", pageUrl);
};
@@ -499,6 +617,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,
@@ -510,9 +629,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);
};
/**
@@ -535,11 +655,41 @@ const initSerialNumber = () => {
/**
* Updates the serial number sensor via the mqtt connection.
*/
const updateSerialNumber = () => {
const updateSerialNumber = async () => {
const serialNumber = hardware.getSerialNumber();
publishState("serial_number", serialNumber);
};
/**
* Initializes the network address sensor.
*/
const initNetworkAddress = () => {
const root = `${INTEGRATION.root}/network_address`;
const config = {
name: "Network Address",
unique_id: `${INTEGRATION.node}_network_address`,
state_topic: `${root}/state`,
json_attributes_topic: `${root}/attributes`,
value_template: "{{ value }}",
icon: "mdi:ip-network",
device: INTEGRATION.device,
};
publishConfig("sensor", config);
updateNetworkAddress();
};
/**
* Updates the network address sensor via the mqtt connection.
*/
const updateNetworkAddress = async () => {
const networkAddresses = hardware.getNetworkAddresses();
const [name] = Object.keys(networkAddresses);
const [family] = name ? Object.keys(networkAddresses[name]) : [];
const networkAddress = networkAddresses[name]?.[family]?.[0] || null;
publishState("network_address", networkAddress);
publishAttributes("network_address", networkAddresses);
};
/**
* Initializes the host name sensor.
*/
@@ -560,7 +710,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);
};
@@ -586,7 +736,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);
};
@@ -612,7 +762,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);
};
@@ -638,7 +788,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);
};
@@ -664,7 +814,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);
};
@@ -690,7 +840,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);
};
@@ -719,7 +869,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);
};
@@ -745,14 +895,14 @@ 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,
packages: packages.map((pkg) => pkg.replace(/\s*\[.*?\]\s*/g, "").trim()),
};
publishAttributes("package_upgrades", attributes);
publishState("package_upgrades", attributes.total);
publishAttributes("package_upgrades", attributes);
};
/**
@@ -775,7 +925,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$/, "");
@@ -803,10 +953,10 @@ 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);
const then = WEBVIEW.tracker.pointer.time;
const lastActive = (now - then) / (1000 * 60);
publishState("last_active", lastActive);
};
+346 -101
View File
@@ -1,16 +1,19 @@
const path = require("path");
const axios = require("axios");
const hardware = require("./hardware");
const integration = require("./integration");
const { app, screen, nativeTheme, ipcMain, session, BaseWindow, WebContentsView } = require("electron");
const { app, nativeTheme, globalShortcut, ipcMain, session, BaseWindow, WebContentsView } = require("electron");
global.WEBVIEW = global.WEBVIEW || {
initialized: false,
status: null,
locked: false,
tracker: {
pointer: {
position: {},
time: new Date(),
},
display: {},
status: null,
},
};
/**
@@ -37,28 +40,21 @@ const init = async () => {
const urls = [loaderHtml(40, 1.0, theme)].concat(ARGS.web_url);
// Init global properties
WEBVIEW.viewActive = 0;
WEBVIEW.viewUrls = urls;
WEBVIEW.viewZoom = zoom;
WEBVIEW.viewTheme = theme;
WEBVIEW.pagerEnabled = widget;
WEBVIEW.widgetTheme = theme;
WEBVIEW.widgetEnabled = widget;
WEBVIEW.navigationTheme = theme;
WEBVIEW.navigationEnabled = widget;
nativeTheme.themeSource = WEBVIEW.viewTheme;
Object.defineProperty(WEBVIEW, "viewActive", {
get() {
return this._viewActive || 0;
},
set(v) {
this._viewActive = Math.max(0, Math.min(v, WEBVIEW.viewUrls.length - 1));
updateView();
},
});
// 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);
@@ -78,9 +74,23 @@ const init = async () => {
view.setVisible(i === 0);
view.setBackgroundColor("#FFFFFFFF");
WEBVIEW.window.contentView.addChildView(view);
view.webContents.loadURL(url);
WEBVIEW.views.push(view);
onlineStatus(url).then(() => {
view.webContents.loadURL(url);
});
});
// Init global pager
WEBVIEW.pager = new WebContentsView({
transparent: true,
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
},
});
WEBVIEW.pager.setBackgroundColor("#00000000");
WEBVIEW.window.contentView.addChildView(WEBVIEW.pager);
WEBVIEW.pager.webContents.loadFile(path.join(APP.path, "html", "pager.html"));
// Init global widget
WEBVIEW.widget = new WebContentsView({
@@ -92,7 +102,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({
@@ -104,14 +114,24 @@ 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();
domEvents();
appEvents();
// Register global events
EVENTS.on("reloadView", reloadView);
EVENTS.on("updateView", updateView);
EVENTS.on("updateDisplay", () => {
const status = hardware.getDisplayStatus();
if (status) {
WEBVIEW.tracker.display[status.toLowerCase()] = new Date();
}
});
// Register local events
await windowEvents();
await widgetEvents();
await navigationEvents();
await viewEvents();
await appEvents();
return true;
};
@@ -124,15 +144,15 @@ const update = async () => {
return;
}
// Update windows status
// Update window status
if (WEBVIEW.window.isFullScreen()) {
WEBVIEW.status = "Fullscreen";
WEBVIEW.tracker.status = "Fullscreen";
} else if (WEBVIEW.window.isMinimized()) {
WEBVIEW.status = "Minimized";
WEBVIEW.tracker.status = "Minimized";
} else if (WEBVIEW.window.isMaximized()) {
WEBVIEW.status = "Maximized";
WEBVIEW.tracker.status = "Maximized";
} else {
WEBVIEW.status = "Framed";
WEBVIEW.tracker.status = "Framed";
}
// Update widget status
@@ -142,7 +162,7 @@ const update = async () => {
updateNavigation();
// Update integration sensor
console.log("Update Kiosk Status:", WEBVIEW.status);
console.log("Update Kiosk Status:", WEBVIEW.tracker.status);
integration.update();
};
@@ -150,8 +170,15 @@ const update = async () => {
* Updates the active view.
*/
const updateView = () => {
const title = `TouchKio - ${new URL(WEBVIEW.viewUrls[WEBVIEW.viewActive]).host}`;
console.log(`Update View: ${title} (${WEBVIEW.viewActive})`);
if (!WEBVIEW.viewActive) {
return;
}
const url = WEBVIEW.views[WEBVIEW.viewActive].webContents.getURL();
const host = url.startsWith("data:") ? "whoopsie" : new URL(url).host;
const title = `${APP.title} - ${host} (${WEBVIEW.viewActive})`;
// Update window title
console.log(`Update View: ${title}`);
WEBVIEW.window.setTitle(title);
// Hide all other webviews and show only the active one
@@ -186,17 +213,41 @@ const updateNavigation = () => {
const defaultUrl = WEBVIEW.viewUrls[WEBVIEW.viewActive];
const currentUrl = view.webContents.getURL();
// Hide pager button
// Hide pager buttons
WEBVIEW.navigation.webContents.send("button-hidden", {
id: "pager",
hidden: WEBVIEW.viewUrls.length < 3,
id: "previous",
hidden: WEBVIEW.viewUrls.length <= 2,
});
WEBVIEW.navigation.webContents.send("button-hidden", {
id: "next",
hidden: WEBVIEW.viewUrls.length <= 2,
});
// Disable pager buttons
WEBVIEW.navigation.webContents.send("button-disabled", {
id: "previous",
disabled: WEBVIEW.viewActive <= 1,
});
WEBVIEW.navigation.webContents.send("button-disabled", {
id: "next",
disabled: WEBVIEW.viewActive >= WEBVIEW.viewUrls.length - 1,
});
// Update url text
WEBVIEW.navigation.webContents.send("input-text", {
id: "url",
text: currentUrl.startsWith("data:") ? "" : currentUrl,
placeholder: defaultUrl,
placeholder: defaultUrl.startsWith("data:") ? "" : defaultUrl,
});
// Disable zoom buttons
WEBVIEW.navigation.webContents.send("button-disabled", {
id: "minus",
disabled: view.webContents.getZoomFactor().toFixed(2) <= 0.25,
});
WEBVIEW.navigation.webContents.send("button-disabled", {
id: "plus",
disabled: view.webContents.getZoomFactor().toFixed(2) >= 4.0,
});
// Disable history buttons
@@ -225,11 +276,34 @@ const toggleNavigation = () => {
width: window.width,
height: height,
});
if (height > 0) {
WEBVIEW.navigation.webContents.focus();
} else {
WEBVIEW.views[WEBVIEW.viewActive].webContents.focus();
}
// Resize webview
resizeView();
};
/**
* Decreases page zoom on the active webview.
*/
const zoomMinus = () => {
const view = WEBVIEW.views[WEBVIEW.viewActive];
view.webContents.setZoomFactor(Math.max(0.25, view.webContents.getZoomFactor() - 0.1));
update();
};
/**
* Increases page zoom on the active webview.
*/
const zoomPlus = () => {
const view = WEBVIEW.views[WEBVIEW.viewActive];
view.webContents.setZoomFactor(Math.min(4.0, view.webContents.getZoomFactor() + 0.1));
update();
};
/**
* Navigates backward in the history of the active webview.
*/
@@ -251,16 +325,25 @@ const historyForward = () => {
};
/**
* Activates the next webview or cycles back to the first.
* Activates the previous webview page.
*/
const nextView = () => {
WEBVIEW.viewActive = (WEBVIEW.viewActive + 1) % WEBVIEW.views.length || 1;
const previousView = () => {
if (WEBVIEW.viewActive > 1) WEBVIEW.viewActive--;
updateView();
};
/**
* Reloads the current or default url on the active webview.
* Activates the next webview page.
*/
const reloadView = () => {
const nextView = () => {
if (WEBVIEW.viewActive < WEBVIEW.views.length - 1) WEBVIEW.viewActive++;
updateView();
};
/**
* Reloads the default url and settings on the active webview.
*/
const homeView = () => {
const view = WEBVIEW.views[WEBVIEW.viewActive];
const defaultUrl = WEBVIEW.viewUrls[WEBVIEW.viewActive];
const currentUrl = view.webContents.getURL();
@@ -271,6 +354,30 @@ const reloadView = () => {
} else {
view.webContents.reloadIgnoringCache();
}
// Reset page zoom and history
view.webContents.setZoomFactor(WEBVIEW.viewZoom);
setTimeout(() => {
view.webContents.navigationHistory.clear();
update();
}, 2000);
};
/**
* Reloads the current url on the active webview.
*/
const reloadView = () => {
const view = WEBVIEW.views[WEBVIEW.viewActive];
const defaultUrl = WEBVIEW.viewUrls[WEBVIEW.viewActive];
const currentUrl = view.webContents.getURL();
// Reload the default url or refresh the page
if (currentUrl.startsWith("data:")) {
view.webContents.loadURL(defaultUrl);
} else {
view.webContents.reloadIgnoringCache();
}
update();
};
/**
@@ -279,6 +386,7 @@ const reloadView = () => {
const resizeView = () => {
const window = WEBVIEW.window.getBounds();
const navigation = WEBVIEW.navigation.getBounds();
const pager = { width: 20, height: window.height };
const widget = { width: 60, height: 200 };
// Update view size
@@ -289,13 +397,23 @@ const resizeView = () => {
width: window.width,
height: window.height - navigation.height,
});
view.webContents.setZoomFactor(WEBVIEW.viewZoom);
});
// Update pager size
if (WEBVIEW.pagerEnabled) {
WEBVIEW.pager.setBounds({
x: window.width - pager.width,
y: 0,
width: pager.width,
height: pager.height,
});
WEBVIEW.pager.webContents.send("data-theme", { theme: "hidden" });
}
// Update widget size
if (WEBVIEW.widgetEnabled) {
WEBVIEW.widget.setBounds({
x: window.width - 15,
x: window.width - 20,
y: parseInt(window.height / 2 - widget.height / 2, 10),
width: widget.width,
height: widget.height,
@@ -318,27 +436,12 @@ 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);
resizeView();
// Handle window focus events
WEBVIEW.window.on("focus", () => {
if (WEBVIEW.locked) {
hardware.setDisplayStatus("ON");
WEBVIEW.window.blur();
}
WEBVIEW.locked = false;
});
HARDWARE.display.notifiers.push(() => {
WEBVIEW.locked = hardware.getDisplayStatus() === "OFF";
if (WEBVIEW.locked) {
WEBVIEW.window.blur();
}
});
// Handle window status updates
WEBVIEW.window.on("minimize", update);
WEBVIEW.window.on("restore", update);
@@ -347,39 +450,46 @@ const windowEvents = () => {
WEBVIEW.window.on("enter-full-screen", update);
WEBVIEW.window.on("leave-full-screen", update);
// Handle window touch events for activity tracking
// Handle global shortcut events
globalShortcut.register("Control+Left", () => {
previousView();
});
globalShortcut.register("Control+Right", () => {
nextView();
});
globalShortcut.register("Control+numsub", () => {
zoomMinus();
});
globalShortcut.register("Control+numadd", () => {
zoomPlus();
});
globalShortcut.register("Alt+Left", () => {
historyBackward();
});
globalShortcut.register("Alt+Right", () => {
historyForward();
});
// Check for window touch events (1s)
setInterval(() => {
const now = new Date();
const then = WEBVIEW.pointer.time;
const delta = Math.abs(now - then) / 1000;
const posNew = screen.getCursorScreenPoint();
const posOld = WEBVIEW.pointer.position;
// Cursor movement detected
if (posOld.x !== posNew.x || posOld.y !== posNew.y) {
WEBVIEW.pointer.time = now;
// Update integration sensor
if (delta > 30) {
console.log("Update Last Active");
integration.update();
}
} else if (delta > 60) {
const navigation = WEBVIEW.navigation.getBounds();
const then = WEBVIEW.tracker.pointer.time;
const delta = (now - then) / 1000;
// Auto-hide navigation
if (delta > 60) {
const navigation = WEBVIEW.navigation.getBounds();
if (navigation.height > 0) {
toggleNavigation();
}
}
WEBVIEW.pointer.position = posNew;
}, 1000);
};
/**
* Register widget events and handler.
*/
const widgetEvents = () => {
const widgetEvents = async () => {
if (!WEBVIEW.widgetEnabled) {
return;
}
@@ -406,7 +516,7 @@ const widgetEvents = () => {
// Hide widget
WEBVIEW.widget.setBounds({
x: window.width - 15,
x: window.width - 20,
y: widget.y,
width: widget.width,
height: widget.height,
@@ -419,7 +529,6 @@ const widgetEvents = () => {
switch (button.id) {
case "keyboard":
const toggle = hardware.getKeyboardVisibility() === "ON" ? "OFF" : "ON";
hardware.setKeyboardVisibility(toggle);
switch (toggle) {
case "OFF":
WEBVIEW.window.restore();
@@ -432,6 +541,9 @@ const widgetEvents = () => {
WEBVIEW.window.maximize();
break;
}
hardware.setKeyboardVisibility(toggle, () => {
WEBVIEW.views[WEBVIEW.viewActive].webContents.focus();
});
break;
case "fullscreen":
if (WEBVIEW.window.isFullScreen()) {
@@ -443,7 +555,9 @@ const widgetEvents = () => {
WEBVIEW.window.unmaximize();
WEBVIEW.window.setFullScreen(true);
}
hardware.setKeyboardVisibility("OFF");
hardware.setKeyboardVisibility("OFF", () => {
WEBVIEW.views[WEBVIEW.viewActive].webContents.focus();
});
break;
case "minimize":
WEBVIEW.window.restore();
@@ -461,7 +575,7 @@ const widgetEvents = () => {
/**
* Register navigation events and handler.
*/
const navigationEvents = () => {
const navigationEvents = async () => {
if (!WEBVIEW.navigationEnabled) {
return;
}
@@ -519,13 +633,30 @@ const navigationEvents = () => {
// Handle navigation button click events
ipcMain.on("button-click", (e, button) => {
const view = WEBVIEW.views[WEBVIEW.viewActive];
switch (button.id) {
case "pager":
case "home":
homeView();
hardware.setKeyboardVisibility("OFF");
break;
case "refresh":
reloadView();
hardware.setKeyboardVisibility("OFF");
break;
case "previous":
previousView();
hardware.setKeyboardVisibility("OFF");
break;
case "next":
nextView();
hardware.setKeyboardVisibility("OFF");
break;
case "home":
reloadView();
case "minus":
zoomMinus();
hardware.setKeyboardVisibility("OFF");
break;
case "plus":
zoomPlus();
hardware.setKeyboardVisibility("OFF");
break;
case "backward":
@@ -541,10 +672,11 @@ const navigationEvents = () => {
};
/**
* Register dom events and handler.
* Register view events and handler.
*/
const domEvents = () => {
WEBVIEW.views.forEach((view) => {
const viewEvents = async () => {
const ready = [];
WEBVIEW.views.forEach((view, i) => {
// Enable webview touch emulation
view.webContents.debugger.attach("1.1");
view.webContents.debugger.sendCommand("Emulation.setEmitTouchEventsForMouse", {
@@ -558,15 +690,18 @@ const domEvents = () => {
return { action: "deny" };
});
// Remove webview scrollbar
// Update webview layout
view.webContents.on("dom-ready", () => {
view.webContents.insertCSS("::-webkit-scrollbar { display: none; }");
if (ready.length < WEBVIEW.views.length) {
view.webContents.setZoomFactor(WEBVIEW.viewZoom);
ready.push(i);
}
});
// Webview fully loaded
view.webContents.on("did-finish-load", () => {
WEBVIEW.initialized = true;
if (WEBVIEW.viewActive === 0) {
if (WEBVIEW.viewActive === 0 && ready.length === WEBVIEW.views.length) {
nextView();
}
if (ARGS.app_debug === "true") {
@@ -579,7 +714,7 @@ const domEvents = () => {
// Webview not loaded
view.webContents.on("did-fail-load", (e, code, text, url, mainframe) => {
if (mainframe) {
if (WEBVIEW.viewActive === 0) {
if (WEBVIEW.viewActive === 0 && ready.length === WEBVIEW.views.length) {
nextView();
}
view.webContents.loadURL(errorHtml(code, text, url));
@@ -589,11 +724,57 @@ const domEvents = () => {
// Webview url changed
view.webContents.on("did-navigate-in-page", (e, url, mainframe) => {
if (mainframe) {
update();
updateView();
}
});
view.webContents.on("did-navigate", () => {
update();
updateView();
});
// Handle webview mouse events
view.webContents.on("before-mouse-event", (e, mouse) => {
const now = new Date();
const then = WEBVIEW.tracker.pointer.time;
const delta = (now - then) / 1000;
switch (mouse.type) {
case "mouseMove":
const posNew = { x: mouse.globalX, y: mouse.globalY };
if (posNew.x < 0 || posNew.y < 0) {
break;
}
// Update last active on pointer position change
const posOld = WEBVIEW.tracker.pointer.position;
if (posOld.x !== posNew.x || posOld.y !== posNew.y) {
WEBVIEW.tracker.pointer.time = now;
WEBVIEW.tracker.pointer.position = posNew;
if (delta > 30) {
console.log("Update Last Active");
integration.update();
}
}
break;
case "mouseDown":
switch (mouse.button) {
case "left":
// Ignore touch event if display was off
if (WEBVIEW.tracker.display.off > WEBVIEW.tracker.display.on) {
console.log("Display Touch Event: Ignored");
e.preventDefault();
}
// Turn display on if it was off
if (hardware.getDisplayStatus() === "OFF") {
hardware.setDisplayStatus("ON");
}
break;
case "back":
historyBackward();
break;
case "forward":
historyForward();
break;
}
break;
}
});
});
};
@@ -601,14 +782,7 @@ const domEvents = () => {
/**
* Register app events and handler.
*/
const appEvents = () => {
// Handle signal and exit events
process.on("SIGINT", app.quit);
app.on("before-quit", () => {
WEBVIEW.status = "Terminated";
integration.update();
});
const appEvents = async () => {
// Handle multiple instances
app.on("second-instance", () => {
if (WEBVIEW.window.isMinimized()) {
@@ -616,6 +790,77 @@ const appEvents = () => {
}
WEBVIEW.window.focus();
});
// Handle signal and exit events
app.on("before-quit", () => {
WEBVIEW.tracker.status = "Terminated";
console.warn(`${APP.title} Terminated`);
integration.update();
});
process.on("SIGINT", app.quit);
// Webview initialized
WEBVIEW.initialized = true;
// Check for latest release infos (2h)
setInterval(() => {
latestRelease();
}, 7200 * 1000);
await latestRelease();
};
/**
* Fetches the latest app release infos from github.
*/
const latestRelease = async () => {
try {
const response = await axios.get(`${APP.releases.url}/latest`, { timeout: 10000 });
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.
*
* @param {string} url - Url to request.
* @param {number} interval - Interval between requests in milliseconds.
* @param {number} timeout - Maximum time to repeat requests in milliseconds.
* @returns {Promise<boolean>} Resolves true if online, false on timeout.
*/
const onlineStatus = (url, interval = 1000, timeout = 60000) => {
return new Promise((resolve) => {
const start = Date.now();
const check = async () => {
const elapsed = Date.now() - start;
try {
if (!url.startsWith("data:")) {
await axios.get(url, { timeout: 10000 });
}
resolve(true);
} catch (error) {
if (elapsed >= interval) {
console.warn(`Checking Connection: ${url}`, error.message);
}
if (elapsed >= timeout) {
resolve(false);
} else {
setTimeout(check, interval);
}
}
};
check();
});
};
/**
+12 -8
View File
@@ -1,27 +1,31 @@
{
"name": "touchkio",
"title": "TouchKio",
"description": "Home Assistant Touch Kiosk",
"homepage": "https://github.com/leukipp/touchkio",
"author": "leukipp",
"version": "1.2.0",
"version": "1.3.1",
"license": "MIT",
"main": "index.js",
"scripts": {
"start": "electron . --web-url=https://demo.home-assistant.io --trace-warnings",
"start": "electron . --web-url=https://demo.home-assistant.io,https://demo.immichkiosk.app --app-debug=false --trace-warnings",
"build": "electron-forge make"
},
"prettier": {
"printWidth": 120
},
"devDependencies": {
"@electron-forge/cli": "^7.8.1",
"@electron-forge/maker-deb": "^7.8.1",
"@electron-forge/maker-zip": "^7.8.1",
"@electron-forge/publisher-github": "^7.8.1",
"electron": "^37.2.0",
"@electron-forge/cli": "^7.8.3",
"@electron-forge/maker-deb": "^7.8.3",
"@electron-forge/maker-zip": "^7.8.3",
"@electron-forge/publisher-github": "^7.8.3",
"electron": "^38.0.0",
"jszip": "^3.10.1",
"prettier": "^3.6.2"
},
"dependencies": {
"mqtt": "^5.13.2"
"axios": "^1.11.0",
"electron-log": "^5.4.3",
"mqtt": "^5.14.1"
}
}
+495 -305
View File
File diff suppressed because it is too large Load Diff