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;