From 0758d202a0d2d62379ce4c2b7243979b91701b19 Mon Sep 17 00:00:00 2001 From: Mohammed Salem Date: Wed, 19 Aug 2026 03:20:57 +0100 Subject: [PATCH] Add larger-client layouts, cmd arguments, and new action types The fixed 100px 5x3 grid only fills part of the screen on DeskThing clients bigger than the Car Thing's 800x480 (tablets, Echo Show). - Add three layout options: the existing 5x3, a 5x3 scaled to fill the display, and a 7x5 grid of 35 buttons (the original 15 stay centred and unchanged; the new 20 fill the outer ring clockwise from the top-left), selectable from settings - Support arguments in cmd actions, so a wrapper script is no longer needed to pass parameters - Add three action types alongside cmd: key for a global keystroke, url to open a link, and dt to fire a DeskThing action such as dt,server,play or dt,discord,mute - Run commands on Windows as well as Linux, and add windows to the manifest platforms --- deskthing/manifest.json | 2 +- server/index.ts | 435 +++++++++++++++++++--------------------- src/App.tsx | 327 ++++++++++++++++++++---------- 3 files changed, 435 insertions(+), 329 deletions(-) diff --git a/deskthing/manifest.json b/deskthing/manifest.json index 87421ef..4616ca1 100644 --- a/deskthing/manifest.json +++ b/deskthing/manifest.json @@ -1 +1 @@ -{"id":"streamthingy","requires":[],"label":"streamthingy","version":"0.11.3","description":"Stream Thingy","author":"drowster","platforms":["linux"],"homepage":"https://github.com/JustAHippo/StreamThingy","repository":"https://github.com/JustAHippo/StreamThingy","template":"base","tags":["webappOnly"],"requiredVersions":{"server":">=0.11.13","client":">=0.11.2"},"updateUrl":"https://github.com/JustAHippo/StreamThingy"} \ No newline at end of file +{"id":"streamthingy","requires":[],"label":"streamthingy","version":"0.11.3","description":"Stream Thingy","author":"drowster","platforms":["windows","linux"],"homepage":"https://github.com/JustAHippo/StreamThingy","repository":"https://github.com/JustAHippo/StreamThingy","template":"base","tags":["webappOnly"],"requiredVersions":{"server":">=0.11.13","client":">=0.11.2"},"updateUrl":"https://github.com/JustAHippo/StreamThingy"} \ No newline at end of file diff --git a/server/index.ts b/server/index.ts index 7bb9f2b..24b5d8d 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,251 +1,234 @@ import { DeskThing } from "@deskthing/server"; import { AppSettings, DESKTHING_EVENTS, SETTING_TYPES } from "@deskthing/types"; -import { spawn } from 'child_process'; -const start = async () => { - console.log('Server Started!') - // TODO: Implement settings groups once DeskThing receives functionality +import { spawn } from "child_process"; + +/* ------------------------------------------------------------------ */ +/* Settings */ +/* ------------------------------------------------------------------ */ + +const MODULES = 35; + +const buildSettings = (): AppSettings => { const settings: AppSettings = { color: { - label: 'App Color', - id: 'color', + label: "App Color", + id: "color", type: SETTING_TYPES.COLOR, - value: '#000000' + value: "#000000", }, buttonColor: { - label: 'Button Color', - id: 'btnColor', + label: "Button Color", + id: "btnColor", type: SETTING_TYPES.COLOR, - value: '#A7A6BA' + value: "#A7A6BA", }, - icon1: { - label: "Module 1 Icon", - id: "icon1", - type: SETTING_TYPES.STRING, - value: '' - }, - action1: { - label: 'Module 1 Action', - id: "act1", - type: SETTING_TYPES.STRING, - value:'' - }, - icon2: { - label: "Module 2 Icon", - id: "icon2", - type: SETTING_TYPES.STRING, - value: '' - }, - action2: { - label: 'Module 2 Action', - id: "act2", - type: SETTING_TYPES.STRING, - value:'' - }, - icon3: { - label: "Module 3 Icon", - id: "icon3", - type: SETTING_TYPES.STRING, - value: '' - }, - action3: { - label: 'Module 3 Action', - id: "act3", - type: SETTING_TYPES.STRING, - value:'' - }, - icon4: { - label: "Module 4 Icon", - id: "icon4", - type: SETTING_TYPES.STRING, - value: '' - }, - action4: { - label: 'Module 4 Action', - id: "act4", - type: SETTING_TYPES.STRING, - value:'' - }, - icon5: { - label: "Module 5 Icon", - id: "icon5", - type: SETTING_TYPES.STRING, - value: '' - }, - action5: { - label: 'Module 5 Action', - id: "act5", - type: SETTING_TYPES.STRING, - value:'' - }, - icon6: { - label: "Module 6 Icon", - id: "icon6", - type: SETTING_TYPES.STRING, - value: '' - }, - action6: { - label: 'Module 6 Action', - id: "act6", - type: SETTING_TYPES.STRING, - value:'' - }, - icon7: { - label: "Module 7 Icon", - id: "icon7", - type: SETTING_TYPES.STRING, - value: '' - }, - action7: { - label: 'Module 7 Action', - id: "act7", - type: SETTING_TYPES.STRING, - value:'' - }, - icon8: { - label: "Module 8 Icon", - id: "icon8", - type: SETTING_TYPES.STRING, - value: '' - }, - action8: { - label: 'Module 8 Action', - id: "act8", - type: SETTING_TYPES.STRING, - value:'' - }, - icon9: { - label: "Module 9 Icon", - id: "icon9", - type: SETTING_TYPES.STRING, - value: '' - }, - action9: { - label: 'Module 9 Action', - id: "act9", - type: SETTING_TYPES.STRING, - value:'' - }, - icon10: { - label: "Module 10 Icon", - id: "icon10", - type: SETTING_TYPES.STRING, - value: '' - }, - action10: { - label: 'Module 10 Action', - id: "act10", - type: SETTING_TYPES.STRING, - value:'' - }, - icon11: { - label: "Module 11 Icon", - id: "icon11", - type: SETTING_TYPES.STRING, - value: '' - }, - action11: { - label: 'Module 11 Action', - id: "act11", - type: SETTING_TYPES.STRING, - value:'' - }, - icon12: { - label: "Module 12 Icon", - id: "icon12", - type: SETTING_TYPES.STRING, - value: '' - }, - action12: { - label: 'Module 12 Action', - id: "act12", - type: SETTING_TYPES.STRING, - value:'' - }, - icon13: { - label: "Module 13 Icon", - id: "icon13", - type: SETTING_TYPES.STRING, - value: '' - }, - action13: { - label: 'Module 13 Action', - id: "act13", - type: SETTING_TYPES.STRING, - value:'' - }, - icon14: { - label: "Module 14 Icon", - id: "icon14", - type: SETTING_TYPES.STRING, - value: '' - }, - action14: { - label: 'Module 14 Action', - id: "act14", - type: SETTING_TYPES.STRING, - value:'' - }, - icon15: { - label: "Module 15 Icon", - id: "icon15", - type: SETTING_TYPES.STRING, - value: '' - }, - action15: { - label: 'Module 15 Action', - id: "act15", - type: SETTING_TYPES.STRING, - value:'' - } + }; + settings["layout"] = { + label: "Layout", + id: "layout", + type: SETTING_TYPES.SELECT, + value: "default", + options: [ + { label: "Default (5x3, fixed size)", value: "default" }, + { label: "Scaled (5x3, fills screen)", value: "scaled" }, + { label: "Extended (7x5, 35 buttons)", value: "extended" }, + ], + }; - + for (let i = 1; i <= MODULES; i++) { + settings[`icon${i}`] = { + label: `Module ${i} Icon`, + id: `icon${i}`, + type: SETTING_TYPES.STRING, + value: "", + }; + settings[`action${i}`] = { + label: `Module ${i} Action`, + id: `act${i}`, + type: SETTING_TYPES.STRING, + value: "", + }; } - DeskThing.initSettings(settings) + return settings; +}; + +const start = async () => { + console.log("Server Started!"); + DeskThing.initSettings(buildSettings()); }; const stop = async () => { - // Function called when the server is stopped - console.log('Server Stopped'); + console.log("Server Stopped"); }; -// Main Entrypoint of the server DeskThing.on(DESKTHING_EVENTS.START, start); - -// Main exit point of the server DeskThing.on(DESKTHING_EVENTS.STOP, stop); -// Listener for requested commands -DeskThing.on('action', (data) => { - console.log(data.payload) // the requested desktop action - const instruction = "" + data.payload - const index = instruction.indexOf(','); - const type = instruction.slice(0,index) - const command = instruction.slice(index+1) - switch (type) { - case "cmd": { - spawn(command, [], { detached: true, stdio: 'ignore', env: { - ...process.env, - WAYLAND_DISPLAY: 'wayland-0', - XDG_RUNTIME_DIR: '/run/user/1000', - } - }).unref(); - - break; - } - - case "key": { - const chain = command.split("+") - console.log("TODO: Find good global implementation for keystrokes") - break; +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +/** + * Logging must NEVER be able to break a button press. + * DeskThing.sendLog does not exist on all server SDK versions, and an + * exception here aborts the whole action handler before the spawn runs. + */ +const log = (msg: string) => { + try { + const dt = DeskThing as unknown as Record; + if (typeof dt.sendLog === "function") { + (dt.sendLog as (m: string) => void)(msg); + return; + } + if (typeof dt.log === "function") { + (dt.log as (lvl: string, m: string) => void)("log", msg); + return; + } + } catch { + /* ignore */ + } + console.log(msg); +}; + +const IS_WIN = process.platform === "win32"; +const IS_MAC = process.platform === "darwin"; +const IS_LINUX = process.platform === "linux"; + +/** + * Linux GUI apps launched from a background service often have no display + * context. These are only injected on Linux so Windows/macOS are unaffected. + */ +const guiEnv = () => + IS_LINUX + ? { + ...process.env, + WAYLAND_DISPLAY: process.env.WAYLAND_DISPLAY ?? "wayland-0", + XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR ?? "/run/user/1000", + DISPLAY: process.env.DISPLAY ?? ":0", } - - default: - console.log("Unhandled type"); - break; + : process.env; +/** Fire and forget. shell:true is what makes arguments work. */ +const run = (command: string) => { + try { + const child = spawn(command, [], { + shell: true, + detached: !IS_WIN, + stdio: "ignore", + windowsHide: true, + env: guiEnv(), + }); + child.on("error", (err) => log(`spawn failed: ${err.message}`)); + child.unref(); + } catch (err) { + log(`spawn threw: ${String(err)}`); + } +}; - } -}) +/** control+shift+p -> ^+p (Windows SendKeys syntax) */ +const toSendKeys = (combo: string): string => { + const parts = combo.split("+").map((p) => p.trim().toLowerCase()); + const key = parts.pop() ?? ""; + const named: Record = { + enter: "{ENTER}", tab: "{TAB}", esc: "{ESC}", escape: "{ESC}", + space: " ", backspace: "{BACKSPACE}", delete: "{DELETE}", + up: "{UP}", down: "{DOWN}", left: "{LEFT}", right: "{RIGHT}", + home: "{HOME}", end: "{END}", pgup: "{PGUP}", pgdn: "{PGDN}", + }; + const body = named[key] ?? (/^f\d{1,2}$/.test(key) ? `{${key.toUpperCase()}}` : key); + const mods = parts + .map((m) => + m === "ctrl" || m === "control" ? "^" : + m === "alt" ? "%" : + m === "shift" ? "+" : "" + ) + .join(""); + return mods + body; +}; + +/** control+shift+p -> keystroke "p" using {command down, shift down} */ +const toAppleScript = (combo: string): string => { + const parts = combo.split("+").map((p) => p.trim().toLowerCase()); + const key = parts.pop() ?? ""; + const mods = parts + .map((m) => + m === "cmd" || m === "command" ? "command down" : + m === "ctrl" || m === "control" ? "control down" : + m === "alt" || m === "option" ? "option down" : + m === "shift" ? "shift down" : "" + ) + .filter(Boolean); + const using = mods.length ? ` using {${mods.join(", ")}}` : ""; + return `keystroke "${key}"${using}`; +}; + +/** + * Sends a global keystroke to whatever window currently has focus. + * Linux/X11 needs xdotool; Linux/Wayland needs ydotool or wtype + * (swap the linux branch below if you are on Wayland). + */ +const sendKeys = (combo: string) => { + if (IS_WIN) { + const keys = toSendKeys(combo).replace(/'/g, "''"); + run( + `powershell -NoProfile -WindowStyle Hidden -Command ` + + `"Add-Type -AssemblyName System.Windows.Forms; ` + + `[System.Windows.Forms.SendKeys]::SendWait('${keys}')"` + ); + } else if (IS_MAC) { + run(`osascript -e 'tell application "System Events" to ${toAppleScript(combo)}'`); + } else { + // X11: + run(`xdotool key ${combo.split("+").map((p) => p.trim()).join("+")}`); + // Wayland alternative: + // run(`wtype -M ctrl -M shift p -m ctrl -m shift`) + } +}; +/** Open a URL or file with the OS default handler. */ +const openTarget = (target: string) => { + if (IS_WIN) run(`start "" "${target}"`); + else if (IS_MAC) run(`open "${target}"`); + else run(`xdg-open "${target}"`); +}; + +/* ------------------------------------------------------------------ */ +/* Action dispatch */ +/* ------------------------------------------------------------------ */ + +DeskThing.on("action", (data) => { + const instruction = String(data.payload ?? "").trim(); + if (!instruction) return; + + const index = instruction.indexOf(","); + if (index === -1) { + log(`Malformed action (missing comma): "${instruction}"`); + return; + } + + const type = instruction.slice(0, index).trim().toLowerCase(); + const command = instruction.slice(index + 1).trim(); + + log(`StreamThingy action: [${type}] ${command}`); + + switch (type) { + case "cmd": + run(command); + break; + + case "key": + sendKeys(command); + break; + + case "url": + case "open": + openTarget(command); + break; + + default: + log(`Unhandled action type: "${type}"`); + break; + } +}); diff --git a/src/App.tsx b/src/App.tsx index 36fa5a0..99f9f38 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,102 +1,225 @@ -import React, { useEffect, useState } from "react"; -import { DeskThing } from "@deskthing/client"; -import { AppSettings, DEVICE_CLIENT } from "@deskthing/types"; - -const App: React.FC = () => { - const [settings, setSettings] = useState(null); - const [icons, setIcons] = useState([]); - const [actions, setActions] = useState([]); - const [backgroundColor, setBackgroundColor] = useState(); - const [iconColor, setIconColor] = useState(); - - - // Load settings on mount and subscribe to changes - useEffect(() => { - async function initialize() { - const loadedSettings = await DeskThing.getSettings(); - console.log("Initial settings loaded:", loadedSettings); - if (loadedSettings) { - setSettings(loadedSettings); - } - DeskThing.send({ type: "get", request: "sampleData" }); - } - initialize(); - - const removeListener = DeskThing.on( - DEVICE_CLIENT.SETTINGS, - (data) => { - console.log("Settings update received:", data.payload); - if (data.payload) { - setSettings(data.payload); - } - } - ); - - return () => { - removeListener(); - }; - }, []); - - // Derive icons and actions when settings changes - useEffect(() => { - if (!settings) return; - - console.log("Processing settings to extract icons and actions..."); - - const iconList = Object.values(settings) - .filter( - (setting) => - setting.id.startsWith("icon") - ) - .map((setting) => setting.value as string); - - const actionList = Object.values(settings) - .filter( - (setting) => - setting.id.startsWith("act") && typeof setting.value === "string" - ) - .map((setting) => setting.value as string); - - console.log("Extracted icons:", iconList); - console.log("Extracted actions:", actionList); - - setIcons(iconList); - setActions(actionList); - setBackgroundColor((settings.color?.value as string) ?? "#000000"); - setIconColor((settings.buttonColor?.value as string) ?? "#A7A6BA"); - - }, [settings]); - // Send corresponding action to server - function requestAction(index: number) { - const action = actions[index - 1]; - console.log("Button clicked:", index, "-> action:", action); - if (action) { - DeskThing.send({ type: "action", payload: action }); - } - } - - const buttons = Array.from({ length: 15 }, (_, i) => i + 1); - - if (!settings) { - return

Loading settings...

; - } - - return ( -
-
- {buttons.map((num) => ( - - ))} -
-
- ); -}; - -export default App; +import React, { useEffect, useState } from "react"; +import { DeskThing } from "@deskthing/client"; +import { AppSettings, DEVICE_CLIENT } from "@deskthing/types"; + +const MODULES = 35; +const BUILD = "0.11.3"; + +/** + * Settings have arrived in different shapes across DeskThing versions: + * keyed by the object key ("action1"), keyed by the setting id ("act1"), + * or as a plain array. Resolve against all of them rather than assuming. + */ +const readSetting = (settings: unknown, keys: string[]): string => { + if (!settings) return ""; + + if (!Array.isArray(settings)) { + const obj = settings as Record; + for (const k of keys) { + const v = obj[k]?.value; + if (typeof v === "string" && v.length) return v; + } + } + + const values = Array.isArray(settings) + ? (settings as { id?: string; value?: unknown }[]) + : Object.values(settings as Record); + + for (const entry of values) { + if (entry && typeof entry.id === "string" && keys.includes(entry.id)) { + const v = entry.value; + if (typeof v === "string" && v.length) return v; + } + } + + return ""; +}; + +/** + * Loads the URL directly first (the display has its own network access), + * falling back to DeskThing's /proxy/v1 only if that fails. A tile that + * failed both shows a "?" so it is distinguishable from an unconfigured one. + */ +const IconTile: React.FC<{ url: string }> = ({ url }) => { + const [src, setSrc] = useState(url); + const [stage, setStage] = useState<"direct" | "proxy" | "failed">("direct"); + + useEffect(() => { + setSrc(url); + setStage("direct"); + }, [url]); + + if (!url) return
; + + if (stage === "failed") { + return ?; + } + + return ( + { + if (stage === "direct") { + setStage("proxy"); + setSrc(DeskThing.useProxy(url)); + } else { + console.warn("Icon failed direct AND proxy:", url); + setStage("failed"); + } + }} + /> + ); +}; + +/** + * Grid placement. + * + * In "extended" (7x5) the original 15 modules keep their 5x3 arrangement, + * centred, so an existing deck looks identical after switching layouts. + * Modules 16-35 fill the outer ring clockwise from the top-left corner. + */ +const ringCells = (): [number, number][] => { + const cells: [number, number][] = []; + for (let c = 1; c <= 7; c++) cells.push([1, c]); // top row, left to right + for (let r = 2; r <= 5; r++) cells.push([r, 7]); // right column, down + for (let c = 6; c >= 1; c--) cells.push([5, c]); // bottom row, right to left + for (let r = 4; r >= 2; r--) cells.push([r, 1]); // left column, up + return cells; +}; + +const placeModule = (n: number, extended: boolean): { row: number; col: number } => { + if (!extended) { + return { row: Math.floor((n - 1) / 5) + 1, col: ((n - 1) % 5) + 1 }; + } + if (n <= 15) { + return { row: Math.floor((n - 1) / 5) + 2, col: ((n - 1) % 5) + 2 }; + } + const [row, col] = ringCells()[n - 16]; + return { row, col }; +}; + +const App: React.FC = () => { + + const [settings, setSettings] = useState(null); + const [icons, setIcons] = useState([]); + const [actions, setActions] = useState([]); + const [backgroundColor, setBackgroundColor] = useState("#000000"); + const [iconColor, setIconColor] = useState("#A7A6BA"); + + useEffect(() => { + async function initialize() { + const loaded = await DeskThing.getSettings(); + console.log("StreamThingy settings payload:", loaded); + if (loaded) setSettings(loaded); + } + initialize(); + + const removeListener = DeskThing.on(DEVICE_CLIENT.SETTINGS, (data) => { + if (data.payload) setSettings(data.payload as AppSettings); + }); + + return () => removeListener(); + }, []); + + useEffect(() => { + if (!settings) return; + + const iconList: string[] = []; + const actionList: string[] = []; + + for (let i = 1; i <= MODULES; i++) { + iconList.push(readSetting(settings, [`icon${i}`])); + actionList.push(readSetting(settings, [`action${i}`, `act${i}`])); + } + + console.log("StreamThingy icons resolved:", iconList); + console.log("StreamThingy actions resolved:", actionList); + + setIcons(iconList); + setActions(actionList); + setBackgroundColor(readSetting(settings, ["color"]) || "#000000"); + setIconColor(readSetting(settings, ["buttonColor", "btnColor"]) || "#A7A6BA"); + }, [settings]); + + function requestAction(index: number) { + const action = (actions[index - 1] ?? "").trim(); + if (!action) return; + + if (action.toLowerCase().startsWith("dt,")) { + const [, source, id, ...rest] = action.split(",").map((p) => p.trim()); + if (!source || !id) return; + DeskThing.triggerAction({ id, source, value: rest.length ? rest.join(",") : undefined }); + return; + } + + DeskThing.send({ type: "action", payload: action }); + } + + // Layout: default = original fixed 5x3; scaled = 5x3 filling the screen; + // extended = 7x5 at the original tile size. + const layout = readSetting(settings ?? {}, ["layout"]) || "default"; + + let cols = 5; + let rows = 3; + let visible = 15; + let cell = "100px"; + + if (layout === "scaled") { + cell = "min(calc((100vw - 80px) / 5), calc((100vh - 60px) / 3))"; + } else if (layout === "extended") { + cols = 7; + rows = 5; + visible = 35; + } + + const buttons = Array.from({ length: visible }, (_, i) => i + 1); + + if (!settings) { + return

Loading settings... (build {BUILD})

; + } + + return ( +
+
+ {buttons.map((num) => ( + + ))} +
+ + v{BUILD} + +
+ ); +}; + +export default App;