Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8f809c50e | ||
|
|
c3e625a8f6 | ||
|
|
7c15707e3b | ||
|
|
dbd3756793 | ||
|
|
85fe5a910a | ||
|
|
d78088e6af | ||
|
|
bf77d9b2a0 | ||
|
|
f4ca7bc412 | ||
|
|
d0ea377ead | ||
|
|
9a829f72e3 | ||
|
|
482a56c9b9 | ||
|
|
32409983b2 | ||
|
|
fe0667919e | ||
|
|
0037a892cd | ||
|
|
ad22dd3215 | ||
|
|
619179c3c8 | ||
|
|
7e5e669b09 | ||
|
|
437c4ab1c5 |
+16
-14
@@ -27,20 +27,22 @@ If you are running Linux with a graphical user interface (Wayland or X11), you s
|
||||
| Raspberry Pi 5 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | [Official 7" Touch Display 2 (720x1280)](https://www.raspberrypi.com/products/touch-display-2/) | 🟩 |
|
||||
|
||||
### 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 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 | Generic Non-Touch | 🟨 |
|
||||
| Generic PC (x64) | Debian KDE (64-bit), 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 | 🟥 |
|
||||
| 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), 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 | 🟥 |
|
||||
|
||||
## Contributions
|
||||
In case your hardware is not listed above don't worry, give it a try.
|
||||
|
||||
@@ -109,16 +109,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 +140,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.
|
||||
|
||||
### 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 back 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).
|
||||
|
||||
@@ -226,8 +249,6 @@ This way, the first click only turns the screen on and focuses the window, allow
|
||||
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
|
||||
@@ -238,13 +259,19 @@ Please have a look into the [hardware](https://github.com/leukipp/touchkio/blob/
|
||||
|
||||
**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.
|
||||
- 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.
|
||||
|
||||
|
||||
+59
-6
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 71 KiB |
@@ -59,7 +59,7 @@ app.whenReady().then(async () => {
|
||||
global.ARGS = args;
|
||||
|
||||
// Show used arguments
|
||||
console.log(`Arguments: ${JSON.stringify(global.ARGS, null, 2)}`);
|
||||
console.log(`Arguments: ${JSON.stringify(global.ARGS, null, 2)}\n`);
|
||||
|
||||
// Chained init functions
|
||||
const chained = [webview.init, hardware.init, integration.init];
|
||||
|
||||
+21
-10
@@ -63,23 +63,34 @@ 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; }
|
||||
|
||||
bash -c "cat << EOF > \"$SERVICE_FILE\"
|
||||
[Unit]
|
||||
SERVICE_CONTENT="[Unit]
|
||||
Description=TouchKio
|
||||
After=graphical.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/bin/touchkio
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
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"
|
||||
SERVICE_CREATE=true
|
||||
if [ -f "$SERVICE_FILE" ]; then
|
||||
read -p "Service $SERVICE_FILE exists, overwrite? (y/N) " overwrite
|
||||
if [[ ! ${overwrite:-n} == [Yy]* ]]; then
|
||||
SERVICE_CREATE=false
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$SERVICE_CREATE" = true ]; 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..."
|
||||
@@ -99,8 +110,8 @@ 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
|
||||
|
||||
+137
-57
@@ -3,10 +3,11 @@ const fs = require("fs");
|
||||
const path = require("path");
|
||||
const fsp = require("fs/promises");
|
||||
const cpr = require("child_process");
|
||||
const Events = require("events");
|
||||
|
||||
global.HARDWARE = global.HARDWARE || {
|
||||
initialized: false,
|
||||
status: null,
|
||||
events: new Events(),
|
||||
session: {},
|
||||
support: {},
|
||||
battery: {
|
||||
@@ -16,19 +17,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,24 +38,26 @@ 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.warn("Operating system is not supported\n");
|
||||
return false;
|
||||
}
|
||||
if (!sessionRights()) {
|
||||
console.warn(`User "${sessionUser()}" is missing password-less sudo rights\n`);
|
||||
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
|
||||
console.log(`Supported: ${JSON.stringify(HARDWARE.support, null, 2)}`);
|
||||
@@ -63,6 +65,7 @@ const init = async () => {
|
||||
// Show session infos
|
||||
console.log("\nUser:", HARDWARE.session.user);
|
||||
console.log("Session:", HARDWARE.session.type);
|
||||
console.log("Desktop:", HARDWARE.session.desktop);
|
||||
|
||||
// Show device infos
|
||||
console.log("\nModel:", getModel());
|
||||
@@ -78,9 +81,23 @@ const init = async () => {
|
||||
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");
|
||||
const unsupported = "unsupported";
|
||||
console.log(
|
||||
`\nBattery 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}\n`,
|
||||
);
|
||||
|
||||
// Check for keyboard visibility
|
||||
setKeyboardVisibility("OFF", (reply, error) => {
|
||||
@@ -92,9 +109,7 @@ const init = async () => {
|
||||
return;
|
||||
}
|
||||
HARDWARE.keyboard.visibility = property.Visible === "true";
|
||||
HARDWARE.keyboard.notifiers.forEach((notifier) => {
|
||||
notifier();
|
||||
});
|
||||
HARDWARE.events.emit("updateKeyboard");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
HARDWARE.events.emit("updateDisplay");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,26 +143,37 @@ 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();
|
||||
});
|
||||
HARDWARE.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));
|
||||
};
|
||||
|
||||
/**
|
||||
* Verifies user rights to run sudo commands without a password.
|
||||
*
|
||||
* @returns {bool} Returns true if password-less sudo rights exists.
|
||||
*/
|
||||
const sessionRights = () => {
|
||||
if (!commandExists("sudo")) {
|
||||
return false;
|
||||
}
|
||||
const result = execSyncCommand("sudo", ["-n", "true"]);
|
||||
return result !== null && !result.includes("password is required");
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the session user name using `os.userInfo()`.
|
||||
*
|
||||
@@ -179,12 +203,32 @@ 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 global properties.
|
||||
*
|
||||
* @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");
|
||||
return {
|
||||
batteryLevel: battery.path !== null,
|
||||
displayStatus: status.path !== null && status.command !== null,
|
||||
displayBrightness: brightness.path !== null && brightness.max !== null,
|
||||
keyboardVisibility: keyboard === true,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -371,7 +415,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 +447,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();
|
||||
}
|
||||
} 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";
|
||||
}
|
||||
switch (HARDWARE.display.status.command) {
|
||||
case "wlopm":
|
||||
const wlopm = execSyncCommand("wlopm", []);
|
||||
if (wlopm !== null) {
|
||||
const out = wlopm.split("\n")[0].split(" ");
|
||||
return out.pop().toUpperCase();
|
||||
}
|
||||
break;
|
||||
case "kscreen-doctor":
|
||||
const kdoc = execSyncCommand("kscreen-doctor", ["--dpms", "show"]);
|
||||
if (kdoc !== null) {
|
||||
const out = kdoc.split("\n")[0].split(" ");
|
||||
return out.pop().toUpperCase();
|
||||
}
|
||||
break;
|
||||
case "xset":
|
||||
const xset = execSyncCommand("xset", ["-q"]);
|
||||
if (xset !== null) {
|
||||
const on = /Monitor is On/.test(xset);
|
||||
return on ? "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 +492,16 @@ const setDisplayStatus = (status, callback = null) => {
|
||||
if (typeof callback === "function") callback(null, "Invalid status");
|
||||
return;
|
||||
}
|
||||
if (session("wayland") && commandExists("wlopm")) {
|
||||
execAsyncCommand("wlopm", [`--${status.toLowerCase()}`, "*"], callback);
|
||||
} else if (session("x11") && commandExists("xset")) {
|
||||
execAsyncCommand("xset", ["dpms", "force", status.toLowerCase()], callback);
|
||||
switch (HARDWARE.display.status.command) {
|
||||
case "wlopm":
|
||||
execAsyncCommand("wlopm", [`--${status.toLowerCase()}`, "*"], callback);
|
||||
break;
|
||||
case "kscreen-doctor":
|
||||
execAsyncCommand("kscreen-doctor", ["--dpms", status.toLowerCase()], callback);
|
||||
break;
|
||||
case "xset":
|
||||
execAsyncCommand("xset", ["dpms", "force", status.toLowerCase()], callback);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -444,7 +528,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 +574,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());
|
||||
@@ -670,9 +754,9 @@ 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" });
|
||||
const output = cpr.execSync([cmd, ...args].join(" ").trim(), { encoding: "utf8" });
|
||||
if (typeof callback === "function") {
|
||||
callback(output.trim().replace(/\0/g, ""), null);
|
||||
callback(output.trim().replace(/\0/g, "").split("\n").slice(1).join("\n").trim(), null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Call D-Bus:", error.message);
|
||||
@@ -761,13 +845,9 @@ module.exports = {
|
||||
getMemoryUsage,
|
||||
getProcessorUsage,
|
||||
getProcessorTemperature,
|
||||
getBatteryLevelPath,
|
||||
getBatteryLevel,
|
||||
getDisplayStatusPath,
|
||||
getDisplayStatus,
|
||||
setDisplayStatus,
|
||||
getDisplayBrightnessPath,
|
||||
getDisplayBrightnessMax,
|
||||
getDisplayBrightness,
|
||||
setDisplayBrightness,
|
||||
getKeyboardVisibility,
|
||||
|
||||
+69
-22
@@ -1,10 +1,11 @@
|
||||
const mqtt = require("mqtt");
|
||||
const hardware = require("./hardware");
|
||||
const { app } = require("electron");
|
||||
const Events = require("events");
|
||||
|
||||
global.INTEGRATION = global.INTEGRATION || {
|
||||
initialized: false,
|
||||
status: null,
|
||||
events: new Events(),
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -56,7 +57,7 @@ const init = async () => {
|
||||
|
||||
// Client connected
|
||||
INTEGRATION.client
|
||||
.on("connect", () => {
|
||||
.once("connect", () => {
|
||||
console.log(`MQTT Connected: ${url.toString()}\n`);
|
||||
|
||||
// Init client controls
|
||||
@@ -67,6 +68,7 @@ const init = async () => {
|
||||
initDisplay();
|
||||
initKeyboard();
|
||||
initPageNumber();
|
||||
initPageZoom();
|
||||
initPageUrl();
|
||||
|
||||
// Init client sensors
|
||||
@@ -85,20 +87,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
|
||||
HARDWARE.events.on("updateDisplay", updateDisplay);
|
||||
HARDWARE.events.on("updateKeyboard", updateKeyboard);
|
||||
|
||||
// Update time sensors periodically (30s)
|
||||
setInterval(() => {
|
||||
@@ -130,6 +126,7 @@ const update = async () => {
|
||||
// Update client sensors
|
||||
updateKiosk();
|
||||
updatePageNumber();
|
||||
updatePageZoom();
|
||||
updatePageUrl();
|
||||
updateUpTime();
|
||||
updateLastActive();
|
||||
@@ -248,7 +245,7 @@ const initRefresh = () => {
|
||||
if (topic === config.command_topic) {
|
||||
console.log("Refreshing webview...");
|
||||
hardware.setDisplayStatus("ON");
|
||||
WEBVIEW.views[WEBVIEW.viewActive].webContents.reloadIgnoringCache();
|
||||
WEBVIEW.events.emit("reloadView");
|
||||
}
|
||||
})
|
||||
.subscribe(config.command_topic);
|
||||
@@ -340,13 +337,23 @@ const initDisplay = () => {
|
||||
if (topic === config.command_topic) {
|
||||
const status = message.toString();
|
||||
console.log("Set Display Status:", status);
|
||||
hardware.setDisplayStatus(status);
|
||||
hardware.update();
|
||||
hardware.setDisplayStatus(status, (reply, error) => {
|
||||
if (error) {
|
||||
console.warn("Failed:", error);
|
||||
} else {
|
||||
hardware.update();
|
||||
}
|
||||
});
|
||||
} else if (topic === config.brightness_command_topic) {
|
||||
const brightness = parseInt(message, 10);
|
||||
console.log("Set Display Brightness:", brightness);
|
||||
hardware.setDisplayBrightness(brightness);
|
||||
hardware.update();
|
||||
hardware.setDisplayBrightness(brightness, (reply, error) => {
|
||||
if (error) {
|
||||
console.warn("Failed:", error);
|
||||
} else {
|
||||
hardware.update();
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.subscribe(config.command_topic)
|
||||
@@ -438,7 +445,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;
|
||||
WEBVIEW.events.emit("updateView");
|
||||
}
|
||||
})
|
||||
.subscribe(config.command_topic);
|
||||
@@ -449,10 +457,49 @@ const initPageNumber = () => {
|
||||
* Updates the page number via the mqtt connection.
|
||||
*/
|
||||
const updatePageNumber = () => {
|
||||
const pageNumber = WEBVIEW.viewActive;
|
||||
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);
|
||||
WEBVIEW.events.emit("updateView");
|
||||
}
|
||||
})
|
||||
.subscribe(config.command_topic);
|
||||
updatePageZoom();
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the page zoom via the mqtt connection.
|
||||
*/
|
||||
const updatePageZoom = () => {
|
||||
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 +520,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);
|
||||
@@ -484,8 +531,8 @@ 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 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);
|
||||
};
|
||||
|
||||
+261
-58
@@ -1,14 +1,16 @@
|
||||
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");
|
||||
const Events = require("events");
|
||||
|
||||
global.WEBVIEW = global.WEBVIEW || {
|
||||
initialized: false,
|
||||
events: new Events(),
|
||||
status: null,
|
||||
locked: false,
|
||||
pointer: {
|
||||
position: {},
|
||||
time: new Date(),
|
||||
},
|
||||
};
|
||||
@@ -37,23 +39,18 @@ 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();
|
||||
},
|
||||
});
|
||||
WEBVIEW.events.on("reloadView", reloadView);
|
||||
WEBVIEW.events.on("updateView", updateView);
|
||||
|
||||
// Init global root window
|
||||
WEBVIEW.window = new BaseWindow({
|
||||
@@ -78,10 +75,24 @@ 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.getAppPath(), "html", "pager.html"));
|
||||
|
||||
// Init global widget
|
||||
WEBVIEW.widget = new WebContentsView({
|
||||
transparent: true,
|
||||
@@ -110,7 +121,7 @@ const init = async () => {
|
||||
windowEvents();
|
||||
widgetEvents();
|
||||
navigationEvents();
|
||||
domEvents();
|
||||
viewEvents();
|
||||
appEvents();
|
||||
|
||||
return true;
|
||||
@@ -150,8 +161,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 = `TouchKio - ${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 +204,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 +267,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 +316,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 +345,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 +377,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 +388,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,
|
||||
@@ -332,7 +441,7 @@ const windowEvents = () => {
|
||||
}
|
||||
WEBVIEW.locked = false;
|
||||
});
|
||||
HARDWARE.display.notifiers.push(() => {
|
||||
HARDWARE.events.on("updateDisplay", () => {
|
||||
WEBVIEW.locked = hardware.getDisplayStatus() === "OFF";
|
||||
if (WEBVIEW.locked) {
|
||||
WEBVIEW.window.blur();
|
||||
@@ -347,32 +456,39 @@ 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();
|
||||
});
|
||||
|
||||
// Handle window touch events
|
||||
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) {
|
||||
// Auto-hide navigation
|
||||
if (delta > 60) {
|
||||
const navigation = WEBVIEW.navigation.getBounds();
|
||||
|
||||
// Auto-hide navigation
|
||||
if (navigation.height > 0) {
|
||||
toggleNavigation();
|
||||
}
|
||||
}
|
||||
WEBVIEW.pointer.position = posNew;
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
@@ -406,7 +522,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 +535,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 +547,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 +561,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();
|
||||
@@ -519,13 +639,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 +678,11 @@ const navigationEvents = () => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Register dom events and handler.
|
||||
* Register view events and handler.
|
||||
*/
|
||||
const domEvents = () => {
|
||||
WEBVIEW.views.forEach((view) => {
|
||||
const viewEvents = () => {
|
||||
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 +696,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 +720,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 +730,37 @@ 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.pointer.time;
|
||||
const delta = Math.abs(now - then) / 1000;
|
||||
WEBVIEW.pointer.time = now;
|
||||
switch (mouse.type) {
|
||||
case "mouseMove":
|
||||
if (delta > 30) {
|
||||
console.log("Update Last Active");
|
||||
integration.update();
|
||||
}
|
||||
break;
|
||||
case "mouseDown":
|
||||
switch (mouse.button) {
|
||||
case "back":
|
||||
historyBackward();
|
||||
break;
|
||||
case "forward":
|
||||
historyForward();
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -616,6 +783,42 @@ const appEvents = () => {
|
||||
}
|
||||
WEBVIEW.window.focus();
|
||||
});
|
||||
|
||||
// Webview initialized
|
||||
WEBVIEW.initialized = true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+9
-8
@@ -3,25 +3,26 @@
|
||||
"description": "Home Assistant Touch Kiosk",
|
||||
"homepage": "https://github.com/leukipp/touchkio",
|
||||
"author": "leukipp",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.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": "^37.4.0",
|
||||
"prettier": "^3.6.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"mqtt": "^5.13.2"
|
||||
"axios": "^1.11.0",
|
||||
"mqtt": "^5.14.0"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user