{
80 | if (isRunning) {
81 | const res = await PluginController.closeShortcut(shortcut);
82 | if (!res) {
83 | PyInterop.toast("Error", "Failed to close shortcut.");
84 | } else {
85 | setIsRunning(shortcut.id, false);
86 | }
87 | } else {
88 | const res = await PluginController.launchShortcut(shortcut, async () => {
89 | if (PluginController.checkIfRunning(shortcut.id) && shortcut.isApp) {
90 | setIsRunning(shortcut.id, false);
91 | const killRes = await PluginController.killShortcut(shortcut);
92 | if (killRes) {
93 | Navigation.Navigate("/library/home");
94 | Navigation.CloseSideMenus();
95 | } else {
96 | PyInterop.toast("Error", "Failed to kill shortcut.");
97 | }
98 | }
99 | });
100 | if (!res) {
101 | PyInterop.toast("Error", "Shortcut failed. Check the command.");
102 | } else {
103 | if (!shortcut.isApp) {
104 | PyInterop.log(`Registering for WebSocket messages of type: ${shortcut.id}...`);
105 |
106 | PluginController.onWebSocketEvent(shortcut.id, (data: any) => {
107 | if (data.type == "end") {
108 | if (data.status == 0) {
109 | PyInterop.toast(shortcut.name, "Shortcut execution finished.");
110 | } else {
111 | PyInterop.toast(shortcut.name, "Shortcut execution was canceled.");
112 | }
113 |
114 | setIsRunning(shortcut.id, false);
115 | }
116 | });
117 | }
118 |
119 | setIsRunning(shortcut.id, true);
120 | }
121 | }
122 | }
123 |
124 | return (
125 | <>
126 |
139 |
140 | }>
141 |
142 | onAction(props.shortcut)} style={{
143 | minWidth: "30px",
144 | maxWidth: "60px",
145 | display: "flex",
146 | justifyContent: "center",
147 | alignItems: "center"
148 | }}>
149 | { (isRunning) ? : }
150 |
151 |
152 |
153 |
154 | >
155 | );
156 | }
--------------------------------------------------------------------------------
/src/components/plugin-config-ui/AddShortcut.tsx:
--------------------------------------------------------------------------------
1 | import { Field, PanelSection, PanelSectionRow, TextField, ButtonItem, quickAccessControlsClasses, ToggleField, DropdownOption } from "decky-frontend-lib"
2 | import { Fragment, useState, useEffect, VFC } from "react"
3 | import { PyInterop } from "../../PyInterop";
4 | import { Shortcut } from "../../lib/data-structures/Shortcut";
5 |
6 | import { v4 as uuidv4 } from "uuid";
7 | import { useShortcutsState } from "../../state/ShortcutsState";
8 | import { Hook, hookAsOptions } from "../../lib/controllers/HookController";
9 | import { MultiSelect } from "./utils/MultiSelect";
10 | import { PluginController } from "../../lib/controllers/PluginController";
11 |
12 | /**
13 | * Component for adding a shortcut to the plugin.
14 | * @returns An AddShortcut component.
15 | */
16 | export const AddShortcut: VFC = () => {
17 | const { shortcuts, setShortcuts, shortcutsList } = useShortcutsState();
18 | const [ ableToSave, setAbleToSave ] = useState(false);
19 | const [ name, setName ] = useState("");
20 | const [ cmd, setCmd ] = useState("");
21 | const [ isApp, setIsApp ] = useState(true);
22 | const [ passFlags, setPassFlags ] = useState(false);
23 | const [ hooks, setHooks ] = useState([]);
24 |
25 | function saveShortcut() {
26 | const newShort = new Shortcut(uuidv4(), name, cmd, shortcutsList.length + 1, isApp, passFlags, hooks);
27 | PyInterop.addShortcut(newShort);
28 | PluginController.updateHooks(newShort);
29 | setName("");
30 | setCmd("");
31 | PyInterop.toast("Success", "Shortcut Saved!");
32 |
33 | const ref = { ...shortcuts };
34 | ref[newShort.id] = newShort;
35 | setShortcuts(ref);
36 | }
37 |
38 | useEffect(() => {
39 | setAbleToSave(name != "" && cmd != "");
40 | }, [name, cmd])
41 |
42 | return (
43 | <>
44 |
51 |
52 |
53 |
54 | { setName(e?.target.value); }}
61 | />
62 | }
63 | />
64 |
65 |
66 | { setCmd(e?.target.value); }}
73 | />
74 | }
75 | />
76 |
77 |
78 | {
81 | setIsApp(e);
82 | if (e) setPassFlags(false);
83 | }}
84 | checked={isApp}
85 | />
86 |
87 |
88 | { setPassFlags(e); }}
91 | checked={passFlags}
92 | disabled={isApp}
93 | />
94 |
95 |
96 | { setHooks(selected.map((s) => s.label as Hook)); }}
105 | />
106 | }
107 | />
108 |
109 |
110 |
111 | Save
112 |
113 |
114 |
115 |
116 | >
117 | );
118 | }
--------------------------------------------------------------------------------
/src/components/plugin-config-ui/EditModal.tsx:
--------------------------------------------------------------------------------
1 | import { Field, ConfirmModal, PanelSection, PanelSectionRow, TextField, ToggleField, DropdownOption } from "decky-frontend-lib"
2 | import { VFC, Fragment, useState } from "react"
3 | import { Shortcut } from "../../lib/data-structures/Shortcut"
4 | import { MultiSelect } from "./utils/MultiSelect"
5 | import { Hook, hookAsOptions } from "../../lib/controllers/HookController"
6 |
7 | type EditModalProps = {
8 | closeModal: () => void,
9 | onConfirm?(shortcut: Shortcut): any,
10 | title?: string,
11 | shortcut: Shortcut,
12 | }
13 |
14 | /**
15 | * Component for the edit shortcut modal.
16 | * @param props The EditModalProps for this component.
17 | * @returns An EditModal component.
18 | */
19 | export const EditModal: VFC = ({
20 | closeModal,
21 | onConfirm = () => { },
22 | shortcut,
23 | title = `Modifying: ${shortcut.name}`,
24 | }) => {
25 | const [ name, setName ] = useState(shortcut.name);
26 | const [ cmd, setCmd ] = useState(shortcut.cmd);
27 | const [ isApp, setIsApp ] = useState(shortcut.isApp);
28 | const [ passFlags, setPassFlags ] = useState(shortcut.passFlags);
29 | const [ hooks, setHooks ] = useState(shortcut.hooks);
30 |
31 | return (
32 | <>
33 | {
39 | const updated = new Shortcut(shortcut.id, name, cmd, shortcut.position, isApp, passFlags, hooks);
40 | onConfirm(updated);
41 | closeModal();
42 | }}>
43 |
44 |
45 | { setName(e?.target.value); }}
51 | />}
52 | />
53 |
54 |
55 | { setCmd(e?.target.value); }}
61 | />}
62 | />
63 |
64 |
65 | {
68 | setIsApp(e);
69 | if (e) setPassFlags(false);
70 | }}
71 | checked={isApp}
72 | />
73 |
74 |
75 | { setPassFlags(e); }}
78 | checked={passFlags}
79 | disabled={isApp}
80 | />
81 |
82 |
83 | hooks.includes(hookOpt.label))}
91 | onChange={(selected:DropdownOption[]) => { setHooks(selected.map((s) => s.label as Hook)); }}
92 | />
93 | }
94 | />
95 |
96 |
97 |
98 | >
99 | )
100 | }
--------------------------------------------------------------------------------
/src/components/plugin-config-ui/ManageShortcuts.tsx:
--------------------------------------------------------------------------------
1 | import { ButtonItem, ConfirmModal, DialogButton, ReorderableEntry, ReorderableList, showModal } from "decky-frontend-lib";
2 | import { Fragment, VFC, useRef } from "react";
3 | import { PyInterop } from "../../PyInterop";
4 | import { Shortcut } from "../../lib/data-structures/Shortcut";
5 |
6 | import { EditModal } from "./EditModal";
7 | import { useShortcutsState } from "../../state/ShortcutsState";
8 | import { Menu, MenuItem, showContextMenu } from "./utils/MenuProxy";
9 | import { FaEllipsisH } from "react-icons/fa"
10 | import { PluginController } from "../../lib/controllers/PluginController";
11 |
12 | type ActionButtonProps = {
13 | entry: ReorderableEntry
14 | }
15 |
16 | /**
17 | * Component for reorderable list actions.
18 | * @param props The props for this ActionButton.
19 | * @returns An ActionButton component.
20 | */
21 | const ActionButtion: VFC> = (props:ActionButtonProps) => {
22 | const { shortcuts, setShortcuts } = useShortcutsState();
23 |
24 | function onAction(entryReference: ReorderableEntry): void {
25 | const shortcut = entryReference.data as Shortcut;
26 | showContextMenu(
27 | ,
58 | window
59 | );
60 | }
61 |
62 | return (
63 | onAction(props.entry)} onOKButton={() => onAction(props.entry)}>
64 |
65 |
66 | );
67 | }
68 |
69 | type InteractablesProps = {
70 | entry: ReorderableEntry
71 | }
72 |
73 | const Interactables: VFC> = (props:InteractablesProps) => {
74 | return (
75 | <>
76 |
77 | >
78 | );
79 | }
80 |
81 | /**
82 | * Component for managing plugin shortcuts.
83 | * @returns A ManageShortcuts component.
84 | */
85 | export const ManageShortcuts: VFC = () => {
86 | const { setShortcuts, shortcutsList, reorderableShortcuts } = useShortcutsState();
87 | const tries = useRef(0);
88 |
89 | async function reload() {
90 | await PyInterop.getShortcuts().then((res) => {
91 | setShortcuts(res.result as ShortcutsDictionary);
92 | });
93 | }
94 |
95 | function onSave(entries: ReorderableEntry[]) {
96 | const data = {};
97 |
98 | for (const entry of entries) {
99 | data[entry.data!.id] = {...entry.data, "position": entry.position}
100 | }
101 |
102 | setShortcuts(data);
103 |
104 | PyInterop.log("Reordered shortcuts.");
105 | PyInterop.setShortcuts(data as ShortcutsDictionary);
106 | }
107 |
108 | if (shortcutsList.length === 0 && tries.current < 10) {
109 | reload();
110 | tries.current++;
111 | }
112 |
113 | return (
114 | <>
115 | Here you can re-order or remove existing shortcuts
118 | {shortcutsList.length > 0 ? (
119 | <>
120 | entries={reorderableShortcuts} onSave={onSave} interactables={Interactables} />
121 |
122 | Reload Shortcuts
123 |
124 | >
125 | ) : (
126 |
127 | Loading...
128 |
129 | )
130 | }
131 | >
132 | );
133 | }
--------------------------------------------------------------------------------
/src/components/plugin-config-ui/Settings.tsx:
--------------------------------------------------------------------------------
1 | import { Field, PanelSection, PanelSectionRow, quickAccessControlsClasses, TextField } from "decky-frontend-lib";
2 | import { VFC, Fragment, useState, useEffect } from "react";
3 | import { PyInterop } from "../../PyInterop";
4 | import { useSetting } from "./utils/hooks/useSetting";
5 |
6 | type SettingField = {
7 | title: string,
8 | shortTitle: string,
9 | settingsKey: string,
10 | default: string,
11 | description: string,
12 | validator: (newVal: string) => boolean,
13 | mustBeNumeric?: boolean
14 | }
15 |
16 | type SettingsFieldProps = {
17 | field: SettingField
18 | }
19 |
20 | const SettingsField: VFC = ({ field }) => {
21 | const [ setting, setSetting ] = useSetting(field.settingsKey, field.default);
22 | const [ fieldVal, setFieldVal ] = useState(setting);
23 |
24 | useEffect(() => {
25 | setFieldVal(setting);
26 | }, [setting]);
27 |
28 | const onChange = (e: any) => {
29 | const newVal = e.target.value;
30 | setFieldVal(newVal);
31 |
32 | PyInterop.log(`Checking newVal for ${field.settingsKey}. Result was: ${field.validator(newVal)} for value ${newVal}`);
33 | if (field.validator(newVal)) {
34 | setSetting(newVal).then(() => PyInterop.log(`Set value of setting ${field.settingsKey} to ${newVal}`));
35 | }
36 | }
37 |
38 | return (
39 |
40 | );
41 | }
42 |
43 | export const Settings: VFC<{}> = ({}) => {
44 | const fields = [
45 | {
46 | title: "WebSocket Port",
47 | shortTitle: "Port",
48 | settingsKey: "webSocketPort",
49 | default: "",
50 | description: "Set the port the WebSocket uses. Change requires a restart to take effect.",
51 | validator: (newVal: string) => parseInt(newVal) <= 65535,
52 | mustBeNumeric: true
53 | }
54 | ];
55 |
56 | return (
57 | <>
58 |
65 |
66 |
67 | {fields.map((field) => (
68 |
69 | } />
70 |
71 | ))}
72 |
73 |
74 | >
75 | )
76 | }
--------------------------------------------------------------------------------
/src/components/plugin-config-ui/guides/GuidePage.tsx:
--------------------------------------------------------------------------------
1 | import { VFC, Fragment } from "react";
2 |
3 | import MarkDownIt from "markdown-it";
4 | import { ScrollArea, Scrollable, scrollableRef } from "../utils/Scrollable";
5 |
6 | const mdIt = new MarkDownIt({ //try "commonmark"
7 | html: true
8 | })
9 |
10 | export const GuidePage: VFC<{ content: string }> = ({ content }) => {
11 | const ref = scrollableRef();
12 | return (
13 | <>
14 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 | >
42 | );
43 | }
--------------------------------------------------------------------------------
/src/components/plugin-config-ui/utils/MenuProxy.ts:
--------------------------------------------------------------------------------
1 | import { FooterLegendProps, findModuleChild } from "decky-frontend-lib";
2 | import { FC, ReactNode } from "react";
3 |
4 | export const showContextMenu: (children: ReactNode, parent?: EventTarget) => void = findModuleChild((m) => {
5 | if (typeof m !== 'object') return undefined;
6 | for (let prop in m) {
7 | if (typeof m[prop] === 'function' && m[prop].toString().includes('stopPropagation))')) {
8 | return m[prop];
9 | }
10 | }
11 | });
12 |
13 | export interface MenuProps extends FooterLegendProps {
14 | label: string;
15 | onCancel?(): void;
16 | cancelText?: string;
17 | children?: ReactNode;
18 | }
19 |
20 | export const Menu: FC = findModuleChild((m) => {
21 | if (typeof m !== 'object') return undefined;
22 |
23 | for (let prop in m) {
24 | if (m[prop]?.prototype?.HideIfSubmenu && m[prop]?.prototype?.HideMenu) {
25 | return m[prop];
26 | }
27 | }
28 | });
29 |
30 | export interface MenuItemProps extends FooterLegendProps {
31 | bInteractableItem?: boolean;
32 | onClick?(evt: Event): void;
33 | onSelected?(evt: Event): void;
34 | onMouseEnter?(evt: MouseEvent): void;
35 | onMoveRight?(): void;
36 | selected?: boolean;
37 | disabled?: boolean;
38 | bPlayAudio?: boolean;
39 | tone?: 'positive' | 'emphasis' | 'destructive';
40 | children?: ReactNode;
41 | }
42 |
43 | export const MenuItem: FC = findModuleChild((m) => {
44 | if (typeof m !== 'object') return undefined;
45 |
46 | for (let prop in m) {
47 | if (
48 | m[prop]?.render?.toString()?.includes('bPlayAudio:') ||
49 | (m[prop]?.prototype?.OnOKButton && m[prop]?.prototype?.OnMouseEnter)
50 | ) {
51 | return m[prop];
52 | }
53 | }
54 | });
--------------------------------------------------------------------------------
/src/components/plugin-config-ui/utils/MultiSelect.tsx:
--------------------------------------------------------------------------------
1 | import { DialogButton, Dropdown, DropdownOption, Field, FieldProps, Focusable } from "decky-frontend-lib";
2 | import { useState, VFC, useEffect } from "react";
3 | import { FaTimes } from "react-icons/fa";
4 |
5 | /**
6 | * The properties for the MultiSelectedOption component.
7 | * @param option This entry's option.
8 | * @param onRemove The function to run when the user deselects this option.
9 | * @param fieldProps Optional fieldProps for this entry.
10 | */
11 | type MultiSelectedOptionProps = {
12 | option: DropdownOption,
13 | fieldProps?: FieldProps,
14 | onRemove: (option: DropdownOption) => void
15 | }
16 |
17 | /**
18 | * A component for multi select dropdown options.
19 | * @param props The MultiSelectedOptionProps for this component.
20 | * @returns A MultiSelectedOption component.
21 | */
22 | const MultiSelectedOption:VFC = ({ option, fieldProps, onRemove }) => {
23 | return (
24 |
25 |
26 | onRemove(option)} onOKButton={() => onRemove(option)} onOKActionDescription={`Remove ${option.label}`}>
27 |
28 |
29 |
30 |
31 | );
32 | }
33 |
34 | /**
35 | * The properties for the MultiSelect component.
36 | * @param options The list of all possible options for the component.
37 | * @param selected The list of currently selected options.
38 | * @param label The label of the dropdown.
39 | * @param onChange Optional callback function to run when selected values change.
40 | * @param maxOptions Optional prop to limit the amount of selectable options.
41 | * @param fieldProps Optional fieldProps for the MultiSelect entries.
42 | */
43 | export type MultiSelectProps = {
44 | options: DropdownOption[],
45 | selected: DropdownOption[],
46 | label: string,
47 | onChange?: (selected:DropdownOption[]) => void,
48 | maxOptions?: number,
49 | fieldProps?: FieldProps,
50 | }
51 |
52 | /**
53 | * A component for multi select dropdown menus.
54 | * @param props The MultiSelectProps for this component.
55 | * @returns A MultiSelect component.
56 | */
57 | export const MultiSelect:VFC = ({ options, selected, label, onChange = () => {}, maxOptions, fieldProps }) => {
58 | const [ sel, setSel ] = useState(selected);
59 | const [ available, setAvailable ] = useState(options.filter((opt) => !selected.includes(opt)));
60 | const [ dropLabel, setDropLabel ] = useState(label);
61 |
62 | useEffect(() => {
63 | const avail = options.filter((opt) => !sel.includes(opt));
64 | setAvailable(avail);
65 | setDropLabel(avail.length == 0 ? "All selected" : (!!maxOptions && sel.length == maxOptions ? "Max selected" : label));
66 | onChange(sel);
67 | }, [sel]);
68 |
69 | const onRemove = (option: DropdownOption) => {
70 | const ref = [...sel];
71 | ref.splice(sel.indexOf(option), 1);
72 | selected = ref;
73 | setSel(selected);
74 | }
75 |
76 | const onSelectedChange = (option: DropdownOption) => {
77 | selected = [...sel, option];
78 | setSel(selected);
79 | }
80 |
81 | return (
82 |
83 |
84 | {sel.map((option) => )}
85 |
86 |
87 |
88 | );
89 | }
--------------------------------------------------------------------------------
/src/components/plugin-config-ui/utils/Scrollable.tsx:
--------------------------------------------------------------------------------
1 | import { FC, ForwardRefExoticComponent } from "react"
2 | import {
3 | Focusable,
4 | FocusableProps,
5 | GamepadEvent,
6 | GamepadButton,
7 | ServerAPI,
8 | } from "decky-frontend-lib"
9 | import React, { useRef } from "react"
10 |
11 | const DEFAULTSCROLLSPEED = 50
12 |
13 | export interface ScrollableElement extends HTMLDivElement {}
14 |
15 | export function scrollableRef() {
16 | return useRef(null)
17 | }
18 |
19 | export const Scrollable: ForwardRefExoticComponent = React.forwardRef(
20 | (props, ref) => {
21 | if (!props.style) {
22 | props.style = {}
23 | }
24 | // props.style.minHeight = '100%';
25 | // props.style.maxHeight = '80%';
26 | props.style.height = "95vh"
27 | props.style.overflowY = "scroll"
28 |
29 | return (
30 |
31 |
32 |
33 | )
34 | }
35 | )
36 |
37 | interface ScrollAreaProps extends FocusableProps {
38 | scrollable: React.RefObject
39 | scrollSpeed?: number
40 | serverApi?: ServerAPI
41 | }
42 |
43 | // const writeLog = async (serverApi: ServerAPI, content: any) => {
44 | // let text = `${content}`
45 | // serverApi.callPluginMethod<{ content: string }>("log", { content: text })
46 | // }
47 |
48 | const scrollOnDirection = (
49 | e: GamepadEvent,
50 | ref: React.RefObject,
51 | amt: number,
52 | prev: React.RefObject,
53 | next: React.RefObject
54 | ) => {
55 | let childNodes = ref.current?.childNodes
56 | let currentIndex = null
57 | childNodes?.forEach((node, i) => {
58 | if (node == e.currentTarget) {
59 | currentIndex = i
60 | }
61 | })
62 |
63 | // @ts-ignore
64 | let pos = e.currentTarget?.getBoundingClientRect()
65 | let out = ref.current?.getBoundingClientRect()
66 |
67 | if (e.detail.button == GamepadButton.DIR_DOWN) {
68 | if (
69 | out?.bottom != undefined &&
70 | pos.bottom <= out.bottom &&
71 | currentIndex != null &&
72 | childNodes != undefined &&
73 | currentIndex + 1 < childNodes.length
74 | ) {
75 | next.current?.focus()
76 | } else {
77 | ref.current?.scrollBy({ top: amt, behavior: "smooth" })
78 | }
79 | } else if (e.detail.button == GamepadButton.DIR_UP) {
80 | if (
81 | out?.top != undefined &&
82 | pos.top >= out.top &&
83 | currentIndex != null &&
84 | childNodes != undefined &&
85 | currentIndex - 1 >= 0
86 | ) {
87 | prev.current?.focus()
88 | } else {
89 | ref.current?.scrollBy({ top: -amt, behavior: "smooth" })
90 | }
91 | }
92 | }
93 |
94 | export const ScrollArea: FC = (props) => {
95 | let scrollSpeed = DEFAULTSCROLLSPEED
96 | if (props.scrollSpeed) {
97 | scrollSpeed = props.scrollSpeed
98 | }
99 |
100 | const prevFocus = useRef(null)
101 | const nextFocus = useRef(null)
102 |
103 | props.onActivate = (e) => {
104 | const ele = e.currentTarget as HTMLElement
105 | ele.focus()
106 | }
107 | props.onGamepadDirection = (e) => {
108 | scrollOnDirection(
109 | e,
110 | props.scrollable,
111 | scrollSpeed,
112 | prevFocus,
113 | nextFocus
114 | )
115 | }
116 |
117 | return (
118 |
119 | {}} />
120 |
121 | {}} />
122 |
123 | )
124 | }
--------------------------------------------------------------------------------
/src/components/plugin-config-ui/utils/hooks/useSetting.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { PyInterop } from "../../../../PyInterop";
3 |
4 | /**
5 | * Returns a React state for a plugin's setting.
6 | * @param key The key of the setting to use.
7 | * @param def The default value of the setting.
8 | * @returns A React state for the setting.
9 | */
10 | export function useSetting(key: string, def: T): [value: T, setValue: (value: T) => Promise] {
11 | const [value, setValue] = useState(def);
12 |
13 | useEffect(() => {
14 | (async () => {
15 | const res = await PyInterop.getSetting(key, def);
16 | setValue(res);
17 | })();
18 | }, []);
19 |
20 | return [
21 | value,
22 | async (val: T) => {
23 | setValue(val);
24 | await PyInterop.setSetting(key, val);
25 | },
26 | ];
27 | }
--------------------------------------------------------------------------------
/src/global.d.ts:
--------------------------------------------------------------------------------
1 | type Unregisterer = {
2 | unregister: () => void;
3 | }
4 |
5 | type ShortcutsDictionary = {
6 | [key: string]: Shortcut
7 | }
8 |
9 | type GuidePages = {
10 | [key: string]: string
11 | }
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | ButtonItem,
3 | definePlugin,
4 | gamepadDialogClasses,
5 | Navigation,
6 | PanelSection,
7 | PanelSectionRow,
8 | quickAccessControlsClasses,
9 | ServerAPI,
10 | ServerResponse,
11 | SidebarNavigation,
12 | staticClasses,
13 | } from "decky-frontend-lib";
14 | import { VFC, Fragment, useRef } from "react";
15 | import { IoApps, IoSettingsSharp } from "react-icons/io5";
16 | import { HiViewGridAdd } from "react-icons/hi";
17 | import { FaEdit } from "react-icons/fa";
18 | import { MdNumbers } from "react-icons/md";
19 | import { AddShortcut } from "./components/plugin-config-ui/AddShortcut";
20 | import { ShortcutLauncher } from "./components/ShortcutLauncher";
21 | import { ManageShortcuts } from "./components/plugin-config-ui/ManageShortcuts";
22 |
23 | import { PyInterop } from "./PyInterop";
24 | import { Shortcut } from "./lib/data-structures/Shortcut";
25 | import { ShortcutsContextProvider, ShortcutsState, useShortcutsState } from "./state/ShortcutsState";
26 | import { PluginController } from "./lib/controllers/PluginController";
27 | import { Settings } from "./components/plugin-config-ui/Settings";
28 | import { GuidePage } from "./components/plugin-config-ui/guides/GuidePage";
29 |
30 | declare global {
31 | var SteamClient: SteamClient;
32 | var collectionStore: CollectionStore;
33 | var appStore: AppStore;
34 | var loginStore: LoginStore;
35 | }
36 |
37 | const Content: VFC<{ serverAPI: ServerAPI }> = ({ }) => {
38 | const { shortcuts, setShortcuts, shortcutsList } = useShortcutsState();
39 | const tries = useRef(0);
40 |
41 | async function reload(): Promise {
42 | await PyInterop.getShortcuts().then((res) => {
43 | setShortcuts(res.result as ShortcutsDictionary);
44 | });
45 | }
46 |
47 | if (Object.values(shortcuts as ShortcutsDictionary).length === 0 && tries.current < 10) {
48 | reload();
49 | tries.current++;
50 | }
51 |
52 | return (
53 | <>
54 |
78 |
79 |
80 |
81 | { Navigation.CloseSideMenus(); Navigation.Navigate("/bash-shortcuts-config"); }} >
82 | Plugin Config
83 |
84 |
85 | {
86 | (shortcutsList.length == 0) ? (
87 | No shortcuts found
88 | ) : (
89 | <>
90 | {
91 | shortcutsList.map((itm: Shortcut) => (
92 |
93 | ))
94 | }
95 |
96 |
97 | Reload
98 |
99 |
100 | >
101 | )
102 | }
103 |
104 |
105 | >
106 | );
107 | };
108 |
109 | const ShortcutsManagerRouter: VFC<{ guides: GuidePages }> = ({ guides }) => {
110 | const guidePages = {}
111 | Object.entries(guides).map(([ guideName, guide ]) => {
112 | guidePages[guideName] = {
113 | title: guideName,
114 | content: ,
115 | route: `/bash-shortcuts-config/guides-${guideName.toLowerCase().replace(/ /g, "-")}`,
116 | icon: ,
117 | hideTitle: true
118 | }
119 | });
120 |
121 | return (
122 | ,
129 | route: "/bash-shortcuts-config/add",
130 | icon:
131 | },
132 | {
133 | title: "Manage Shortcuts",
134 | content: ,
135 | route: "/bash-shortcuts-config/manage",
136 | icon:
137 | },
138 | {
139 | title: "Settings",
140 | content: ,
141 | route: "/bash-shortcuts-config/settings",
142 | icon:
143 | },
144 | "separator",
145 | guidePages["Overview"],
146 | guidePages["Managing Shortcuts"],
147 | guidePages["Custom Scripts"],
148 | guidePages["Using Hooks"]
149 | ]}
150 | />
151 | );
152 | };
153 |
154 | export default definePlugin((serverApi: ServerAPI) => {
155 | PyInterop.setServer(serverApi);
156 |
157 | const state = new ShortcutsState();
158 | PluginController.setup(serverApi, state);
159 |
160 | const loginHook = PluginController.initOnLogin();
161 |
162 | PyInterop.getGuides().then((res: ServerResponse) => {
163 | const guides = res.result as GuidePages;
164 | console.log("Guides:", guides);
165 |
166 | serverApi.routerHook.addRoute("/bash-shortcuts-config", () => (
167 |
168 |
169 |
170 | ));
171 | });
172 |
173 | return {
174 | title: Bash Shortcuts
,
175 | content: (
176 |
177 |
178 |
179 | ),
180 | icon: ,
181 | onDismount() {
182 | loginHook.unregister();
183 | serverApi.routerHook.removeRoute("/bash-shortcuts-config");
184 | PluginController.dismount();
185 | },
186 | alwaysRender: true
187 | };
188 | });
189 |
--------------------------------------------------------------------------------
/src/lib/Utils.ts:
--------------------------------------------------------------------------------
1 | import { findModuleChild, sleep } from 'decky-frontend-lib';
2 |
3 | /**
4 | * Waits for a condition to be true.
5 | * @param retries The number of times to retry the condition.
6 | * @param delay The time (in ms) between retries.
7 | * @param check The condition to check.
8 | * @returns A promise resolving to true if the check was true on any attempt, or false if it failed each time.
9 | */
10 | export async function waitForCondition(retries: number, delay: number, check: () => (boolean | Promise)): Promise {
11 | const waitImpl = async (): Promise => {
12 | try {
13 | let tries = retries + 1;
14 | while (tries-- !== 0) {
15 | if (await check()) {
16 | return true;
17 | }
18 |
19 | if (tries > 0) {
20 | await sleep(delay);
21 | }
22 | }
23 | } catch (error) {
24 | console.error(error);
25 | }
26 |
27 | return false;
28 | };
29 |
30 | return await waitImpl();
31 | }
32 |
33 | /**
34 | * The react History object.
35 | */
36 | export const History = findModuleChild((m) => {
37 | if (typeof m !== "object") return undefined;
38 | for (let prop in m) {
39 | if (m[prop]?.m_history) return m[prop].m_history
40 | }
41 | });
42 |
43 | /**
44 | * Provides a debounced version of a function.
45 | * @param func The function to debounce.
46 | * @param wait How long before function gets run.
47 | * @param immediate Wether the function should run immediately.
48 | * @returns A debounced version of the function.
49 | */
50 | export function debounce(func:Function, wait:number, immediate?:boolean) {
51 | let timeout:NodeJS.Timeout|null;
52 | return function (this:any) {
53 | const context = this, args = arguments;
54 | const later = function () {
55 | timeout = null;
56 | if (!immediate) func.apply(context, args);
57 | };
58 | const callNow = immediate && !timeout;
59 | clearTimeout(timeout as NodeJS.Timeout);
60 | timeout = setTimeout(later, wait);
61 | if (callNow) func.apply(context, args);
62 | };
63 | };
--------------------------------------------------------------------------------
/src/lib/controllers/HookController.ts:
--------------------------------------------------------------------------------
1 | import { Navigation } from "decky-frontend-lib";
2 | import { PyInterop } from "../../PyInterop";
3 | import { WebSocketClient } from "../../WebsocketClient";
4 | import { ShortcutsState } from "../../state/ShortcutsState";
5 | import { Shortcut } from "../data-structures/Shortcut";
6 | import { InstancesController } from "./InstancesController";
7 | import { SteamController } from "./SteamController";
8 |
9 | /**
10 | * Enum for the different hook events.
11 | */
12 | export enum Hook {
13 | LOG_IN = "Log In",
14 | LOG_OUT = "Log Out",
15 | GAME_START = "Game Start",
16 | GAME_END = "Game End",
17 | GAME_INSTALL = "Game Install",
18 | GAME_UPDATE = "Game Update",
19 | GAME_UNINSTALL = "Game Uninstall",
20 | GAME_ACHIEVEMENT_UNLOCKED = "Game Achievement Unlocked",
21 | SCREENSHOT_TAKEN = "Screenshot Taken",
22 | DECK_SHUTDOWN = "Deck Shutdown",
23 | DECK_SLEEP = "Deck Sleep"
24 | }
25 |
26 | export const hookAsOptions = Object.values(Hook).map((entry) => { return { label: entry, data: entry } });
27 |
28 | type HooksDict = { [key in Hook]: Set }
29 | type RegisteredDict = { [key in Hook]: Unregisterer }
30 |
31 | /**
32 | * Controller for handling hook events.
33 | */
34 | export class HookController {
35 | private state: ShortcutsState;
36 | private steamController: SteamController;
37 | private instancesController: InstancesController;
38 | private webSocketClient: WebSocketClient;
39 |
40 | // @ts-ignore
41 | shortcutHooks: HooksDict = {};
42 | // @ts-ignore
43 | registeredHooks: RegisteredDict = {};
44 |
45 | /**
46 | * Creates a new HooksController.
47 | * @param steamController The SteamController to use.
48 | * @param instancesController The InstanceController to use.
49 | * @param webSocketClient The WebSocketClient to use.
50 | * @param state The plugin state.
51 | */
52 | constructor(steamController: SteamController, instancesController: InstancesController, webSocketClient: WebSocketClient, state: ShortcutsState) {
53 | this.state = state;
54 | this.steamController = steamController;
55 | this.instancesController = instancesController;
56 | this.webSocketClient = webSocketClient;
57 |
58 | for (const hook of Object.values(Hook)) {
59 | this.shortcutHooks[hook] = new Set();
60 | }
61 | }
62 |
63 | /**
64 | * Initializes the hooks for all shortcuts.
65 | * @param shortcuts The shortcuts to initialize the hooks of.
66 | */
67 | init(shortcuts: ShortcutsDictionary): void {
68 | this.liten();
69 |
70 | for (const shortcut of Object.values(shortcuts)) {
71 | this.updateHooks(shortcut);
72 | }
73 | }
74 |
75 | /**
76 | * Gets a shortcut by its id.
77 | * @param shortcutId The id of the shortcut to get.
78 | * @returns The shortcut.
79 | */
80 | private getShortcutById(shortcutId: string): Shortcut {
81 | return this.state.getPublicState().shortcuts[shortcutId];
82 | }
83 |
84 | /**
85 | * Sets wether a shortcut is running or not.
86 | * @param shortcutId The id of the shortcut to set.
87 | * @param value The new value.
88 | */
89 | private setIsRunning(shortcutId: string, value: boolean): void {
90 | this.state.setIsRunning(shortcutId, value);
91 | }
92 |
93 | /**
94 | * Checks if a shortcut is running.
95 | * @param shorcutId The id of the shortcut to check for.
96 | * @returns True if the shortcut is running.
97 | */
98 | private checkIfRunning(shorcutId: string): boolean {
99 | return Object.keys(this.instancesController.instances).includes(shorcutId);
100 | }
101 |
102 | /**
103 | * Updates the hooks for a shortcut.
104 | * @param shortcut The shortcut to update the hooks of.
105 | */
106 | updateHooks(shortcut: Shortcut) {
107 | const shortcutHooks = shortcut.hooks;
108 |
109 | for (const h of Object.keys(this.shortcutHooks)) {
110 | const hook = h as Hook;
111 | const registeredHooks = this.shortcutHooks[hook];
112 |
113 | if (shortcutHooks.includes(hook)) {
114 | this.registerHook(shortcut, hook);
115 | } else if (Object.keys(registeredHooks).includes(shortcut.id)) {
116 | this.unregisterHook(shortcut, hook);
117 | }
118 | }
119 | }
120 |
121 | /**
122 | * Registers a hook for a shortcut.
123 | * @param shortcut The shortcut to register the hook for.
124 | * @param hook The hook to register.
125 | */
126 | private registerHook(shortcut: Shortcut, hook: Hook): void {
127 | this.shortcutHooks[hook].add(shortcut.id);
128 | PyInterop.log(`Registered hook: ${hook} for shortcut: ${shortcut.name} Id: ${shortcut.id}`);
129 | }
130 |
131 | /**
132 | * Unregisters all hooks for a given shortcut.
133 | * @param shortcut The shortcut to unregister the hooks from.
134 | */
135 | unregisterAllHooks(shortcut: Shortcut) {
136 | const shortcutHooks = shortcut.hooks;
137 |
138 | for (const hook of shortcutHooks) {
139 | this.unregisterHook(shortcut, hook);
140 | }
141 | }
142 |
143 | /**
144 | * Unregisters a registered hook for a shortcut.
145 | * @param shortcut The shortcut to remove the hook from.
146 | * @param hook The hook to remove.
147 | */
148 | private unregisterHook(shortcut: Shortcut, hook: Hook): void {
149 | this.shortcutHooks[hook].delete(shortcut.id);
150 | PyInterop.log(`Unregistered hook: ${hook} for shortcut: ${shortcut.name} Id: ${shortcut.id}`);
151 | }
152 |
153 | private async runShortcuts(hook: Hook, flags: { [flag: string ]: string }): Promise {
154 | flags["h"] = hook;
155 |
156 | for (const shortcutId of this.shortcutHooks[hook].values()) {
157 | if (!this.checkIfRunning(shortcutId)) {
158 | const shortcut = this.getShortcutById(shortcutId);
159 | const createdInstance = await this.instancesController.createInstance(shortcut);
160 |
161 | if (createdInstance) {
162 | PyInterop.log(`Created Instance for shortcut { Id: ${shortcut.id}, Name: ${shortcut.name} } on hook: ${hook}`);
163 | const didLaunch = await this.instancesController.launchInstance(shortcut.id, async () => {
164 | if (this.checkIfRunning(shortcut.id) && shortcut.isApp) {
165 | this.setIsRunning(shortcut.id, false);
166 | const killRes = await this.instancesController.killInstance(shortcut.id);
167 | if (killRes) {
168 | Navigation.Navigate("/library/home");
169 | Navigation.CloseSideMenus();
170 | } else {
171 | PyInterop.toast("Error", "Failed to kill shortcut.");
172 | }
173 | }
174 | }, flags);
175 |
176 | if (!didLaunch) {
177 | PyInterop.log(`Failed to launch instance for shortcut { Id: ${shortcut.id}, Name: ${shortcut.name} } on hook: ${hook}`);
178 | } else {
179 | if (!shortcut.isApp) {
180 | PyInterop.log(`Registering for WebSocket messages of type: ${shortcut.id} on hook: ${hook}...`);
181 |
182 | this.webSocketClient.on(shortcut.id, (data: any) => {
183 | if (data.type == "end") {
184 | if (data.status == 0) {
185 | PyInterop.toast(shortcut.name, "Shortcut execution finished.");
186 | } else {
187 | PyInterop.toast(shortcut.name, "Shortcut execution was canceled.");
188 | }
189 |
190 | this.setIsRunning(shortcut.id, false);
191 | }
192 | });
193 | }
194 |
195 | this.setIsRunning(shortcut.id, true);
196 | }
197 | } else {
198 | PyInterop.toast("Error", "Shortcut failed. Check the command.");
199 | PyInterop.log(`Failed to create instance for shortcut { Id: ${shortcut.id}, Name: ${shortcut.name} } on hook: ${hook}`);
200 | }
201 | } else {
202 | PyInterop.log(`Skipping hook: ${hook} for shortcut: ${shortcutId} because it was already running.`);
203 | }
204 | }
205 | }
206 |
207 | /**
208 | * Sets up all of the hooks for the plugin.
209 | */
210 | liten(): void {
211 | this.registeredHooks[Hook.LOG_IN] = this.steamController.registerForAuthStateChange(async (username: string) => {
212 | this.runShortcuts(Hook.LOG_IN, { "u": username});
213 | }, null, false);
214 |
215 | this.registeredHooks[Hook.LOG_OUT] = this.steamController.registerForAuthStateChange(null, async (username: string) => {
216 | this.runShortcuts(Hook.LOG_IN, { "u": username });
217 | }, false);
218 |
219 | this.registeredHooks[Hook.GAME_START] = this.steamController.registerForAllAppLifetimeNotifications((appId: number, data: LifetimeNotification) => {
220 | if (data.bRunning && (collectionStore.allAppsCollection.apps.has(appId) || collectionStore.deckDesktopApps.apps.has(appId))) {
221 | const app = collectionStore.allAppsCollection.apps.get(appId) ?? collectionStore.deckDesktopApps.apps.get(appId);
222 | if (app) {
223 | this.runShortcuts(Hook.GAME_START, { "i": appId.toString(), "n": app.display_name });
224 | }
225 | }
226 | });
227 |
228 | this.registeredHooks[Hook.GAME_END] = this.steamController.registerForAllAppLifetimeNotifications((appId: number, data: LifetimeNotification) => {
229 | if (!data.bRunning && (collectionStore.allAppsCollection.apps.has(appId) || collectionStore.deckDesktopApps.apps.has(appId))) {
230 | const app = collectionStore.allAppsCollection.apps.get(appId) ?? collectionStore.deckDesktopApps.apps.get(appId);
231 | if (app) {
232 | this.runShortcuts(Hook.GAME_END, { "i": appId.toString(), "n": app.display_name });
233 | }
234 | }
235 | });
236 |
237 | this.registeredHooks[Hook.GAME_INSTALL] = this.steamController.registerForGameInstall((appData: SteamAppOverview) => {
238 | this.runShortcuts(Hook.GAME_INSTALL, { "i": appData.appid.toString(), "n": appData.display_name });
239 | });
240 |
241 | this.registeredHooks[Hook.GAME_UPDATE] = this.steamController.registerForGameUpdate((appData: SteamAppOverview) => {
242 | this.runShortcuts(Hook.GAME_UPDATE, { "i": appData.appid.toString(), "n": appData.display_name });
243 | });
244 |
245 | this.registeredHooks[Hook.GAME_UNINSTALL] = this.steamController.registerForGameUninstall((appData: SteamAppOverview) => {
246 | this.runShortcuts(Hook.GAME_UNINSTALL, { "i": appData.appid.toString(), "n": appData.display_name });
247 | });
248 |
249 | this.registeredHooks[Hook.GAME_ACHIEVEMENT_UNLOCKED] = this.steamController.registerForGameAchievementNotification((data: AchievementNotification) => {
250 | const appId = data.unAppID;
251 | const app = collectionStore.localGamesCollection.apps.get(appId);
252 | if (app) {
253 | this.runShortcuts(Hook.GAME_ACHIEVEMENT_UNLOCKED, { "i": appId.toString(), "n": app.display_name, "a": data.achievement.strName });
254 | }
255 | });
256 |
257 | this.registeredHooks[Hook.SCREENSHOT_TAKEN] = this.steamController.registerForScreenshotNotification((data: ScreenshotNotification) => {
258 | const appId = data.unAppID;
259 | const app = collectionStore.localGamesCollection.apps.get(appId);
260 | if (app) {
261 | this.runShortcuts(Hook.GAME_ACHIEVEMENT_UNLOCKED, { "i": appId.toString(), "n": app.display_name, "p": data.details.strUrl });
262 | }
263 | });
264 |
265 | this.registeredHooks[Hook.DECK_SLEEP] = this.steamController.registerForSleepStart(() => {
266 | this.runShortcuts(Hook.DECK_SLEEP, {});
267 | });
268 |
269 | this.registeredHooks[Hook.DECK_SHUTDOWN] = this.steamController.registerForShutdownStart(() => {
270 | this.runShortcuts(Hook.DECK_SHUTDOWN, {});
271 | });
272 | }
273 |
274 | /**
275 | * Dismounts the HooksController.
276 | */
277 | dismount(): void {
278 | for (const hook of Object.keys(this.registeredHooks)) {
279 | this.registeredHooks[hook].unregister();
280 | PyInterop.log(`Unregistered hook: ${hook}`);
281 | }
282 | }
283 | }
--------------------------------------------------------------------------------
/src/lib/controllers/InstancesController.ts:
--------------------------------------------------------------------------------
1 | import { Instance } from "../data-structures/Instance";
2 | import { Shortcut } from "../data-structures/Shortcut";
3 | import { ShortcutsController } from "./ShortcutsController";
4 | import { PyInterop } from "../../PyInterop";
5 | import { Navigation } from "decky-frontend-lib";
6 | import { WebSocketClient } from "../../WebsocketClient";
7 | import { ShortcutsState } from "../../state/ShortcutsState";
8 |
9 | /**
10 | * Controller for managing plugin instances.
11 | */
12 | export class InstancesController {
13 | private baseName = "Bash Shortcuts";
14 | private runnerPath = "/home/deck/homebrew/plugins/bash-shortcuts/shortcutsRunner.sh";
15 | private startDir = "\"/home/deck/homebrew/plugins/bash-shortcuts/\"";
16 | private shorcutsController:ShortcutsController;
17 | private webSocketClient: WebSocketClient;
18 | private state: ShortcutsState;
19 |
20 | numInstances: number;
21 | instances: { [uuid:string]: Instance };
22 |
23 | /**
24 | * Creates a new InstancesController.
25 | * @param shortcutsController The shortcuts controller used by this class.
26 | * @param webSocketClient The WebSocketClient used by this class.
27 | * @param state The plugin state.
28 | */
29 | constructor(shortcutsController: ShortcutsController, webSocketClient: WebSocketClient, state: ShortcutsState) {
30 | this.shorcutsController = shortcutsController;
31 | this.webSocketClient = webSocketClient;
32 | this.state = state;
33 |
34 | PyInterop.getHomeDir().then((res) => {
35 | this.runnerPath = `/home/${res.result}/homebrew/plugins/bash-shortcuts/shortcutsRunner.sh`;
36 | this.startDir = `\"/home/${res.result}/homebrew/plugins/bash-shortcuts/\"`;
37 | });
38 |
39 | this.numInstances = 0;
40 | this.instances = {};
41 | }
42 |
43 | /**
44 | * Gets the current date and time.
45 | * @returns A tuple containing [date, time] in US standard format.
46 | */
47 | private getDatetime(): [string, string] {
48 | const date = new Date();
49 |
50 | const day = date.getDate();
51 | const month = date.getMonth() + 1;
52 | const year = date.getFullYear();
53 |
54 | const hours = date.getHours();
55 | const minutes = date.getMinutes();
56 | const seconds = date.getSeconds();
57 |
58 | return [
59 | `${month}-${day}-${year}`,
60 | `${hours}:${minutes}:${seconds}`
61 | ];
62 | }
63 |
64 | /**
65 | * Creates a new instance for a shortcut.
66 | * @param shortcut The shortcut to make an instance for.
67 | * @returns A promise resolving to true if all the steamClient calls were successful.
68 | */
69 | async createInstance(shortcut: Shortcut): Promise {
70 | this.numInstances++;
71 | const shortcutName = `${this.baseName} - Instance ${this.numInstances}`;
72 |
73 | if (shortcut.isApp) {
74 | let appId = null;
75 |
76 | //* check if instance exists. if so, grab it and modify it
77 | if (await this.shorcutsController.checkShortcutExist(shortcutName)) {
78 | const shortcut = await this.shorcutsController.getShortcut(shortcutName);
79 | appId = shortcut?.unAppID;
80 | } else {
81 | appId = await this.shorcutsController.addShortcut(shortcutName, this.runnerPath, this.startDir, shortcut.cmd);
82 | }
83 |
84 | if (appId) {
85 | this.instances[shortcut.id] = new Instance(appId, shortcutName, shortcut.id, shortcut.isApp);
86 |
87 | const exeRes = await this.shorcutsController.setShortcutExe(appId, this.runnerPath);
88 | if (!exeRes) {
89 | PyInterop.toast("Error", "Failed to set the shortcutsRunner path");
90 | return false;
91 | }
92 |
93 | const nameRes = await this.shorcutsController.setShortcutName(appId, shortcutName);
94 | if (!nameRes) {
95 | PyInterop.toast("Error", "Failed to set the name of the instance");
96 | return false;
97 | }
98 |
99 | const startDirRes = await this.shorcutsController.setShortcutStartDir(appId, this.startDir);
100 | if (!startDirRes) {
101 | PyInterop.toast("Error", "Failed to set the start dir");
102 | return false;
103 | }
104 |
105 | const launchOptsRes = await this.shorcutsController.setShortcutLaunchOptions(appId, shortcut.cmd);
106 | if (!launchOptsRes) {
107 | PyInterop.toast("Error", "Failed to set the launch options");
108 | return false;
109 | }
110 |
111 | return true;
112 | } else {
113 | this.numInstances--;
114 | PyInterop.log(`Failed to start instance. Id: ${shortcut.id} Name: ${shortcut.name}`);
115 | return false;
116 | }
117 | } else {
118 | PyInterop.log(`Shortcut is not an app. Skipping instance shortcut creation. ShortcutId: ${shortcut.id} ShortcutName: ${shortcut.name}`);
119 | this.instances[shortcut.id] = new Instance(null, shortcutName, shortcut.id, shortcut.isApp);
120 |
121 | PyInterop.log(`Adding websocket listener for message type ${shortcut.id}`);
122 | this.webSocketClient.on(shortcut.id, (data: any) => {
123 | if (data.type === "end") {
124 | delete this.instances[shortcut.id];
125 | PyInterop.log(`Removed non app instance for shortcut with Id: ${shortcut.id} because end was detected.`);
126 | setTimeout(() => {
127 | PyInterop.log(`Removing websocket listener for message type ${shortcut.id}`);
128 | this.webSocketClient.deleteListeners(shortcut.id);
129 | }, 2000);
130 | }
131 | });
132 |
133 | return true;
134 | }
135 | }
136 |
137 | /**
138 | * Kills a live shortcut instance.
139 | * @param shortcutId The id of the shortcut whose instance should be killed.
140 | * @returns A promise resolving to true if the instance was successfully killed.
141 | */
142 | async killInstance(shortcutId: string): Promise {
143 | const instance = this.instances[shortcutId];
144 |
145 | if (instance.shortcutIsApp) {
146 | const appId = instance.unAppID as number;
147 | const success = await this.shorcutsController.removeShortcutById(appId);
148 |
149 | if (success) {
150 | PyInterop.log(`Killed instance. Id: ${shortcutId} InstanceName: ${instance.steamShortcutName}`);
151 | delete this.instances[shortcutId];
152 | this.numInstances--;
153 |
154 | return true;
155 | } else {
156 | PyInterop.log(`Failed to kill instance. Could not delete shortcut. Id: ${shortcutId} InstanceName: ${instance.steamShortcutName}`);
157 | return false;
158 | }
159 | } else {
160 | delete this.instances[shortcutId];
161 | const res = await PyInterop.killNonAppShortcut(shortcutId);
162 | console.log(res);
163 |
164 | this.webSocketClient.on(shortcutId, (data:any) => {
165 | if (data.type == "end") {
166 | setTimeout(() => {
167 | PyInterop.log(`Removing websocket listener for message type ${shortcutId}`);
168 | this.webSocketClient.deleteListeners(shortcutId);
169 | }, 2000);
170 | }
171 | });
172 | return true;
173 | }
174 | }
175 |
176 | /**
177 | * Launches an instance.
178 | * @param shortcutId The id of the shortcut associated with the instance to launch.
179 | * @param onExit The function to run when the shortcut closes.
180 | * @param flags Optional flags to pass to the shortcut.
181 | * @returns A promise resolving to true if the instance is launched.
182 | */
183 | async launchInstance(shortcutId: string, onExit: (data?: LifetimeNotification) => void, flags: { [flag: string]: string } = {}): Promise {
184 | const instance = this.instances[shortcutId];
185 |
186 | if (instance.shortcutIsApp) {
187 | const appId = instance.unAppID as number;
188 | const res = await this.shorcutsController.launchShortcut(appId);
189 |
190 | if (!res) {
191 | PyInterop.log(`Failed to launch instance. InstanceName: ${instance.steamShortcutName} ShortcutId: ${shortcutId}`);
192 | } else {
193 | const { unregister } = this.shorcutsController.registerForShortcutExit(appId, (data: LifetimeNotification) => {
194 | onExit(data);
195 | unregister();
196 | });
197 | }
198 |
199 | return res;
200 | } else {
201 | const [ date, time ] = this.getDatetime();
202 | const currentGameOverview = this.state.getPublicState().currentGame;
203 |
204 | flags["d"] = date;
205 | flags["t"] = time;
206 |
207 | if (!Object.keys(flags).includes("u")) flags["u"] = loginStore.m_strAccountName;
208 | if (!Object.keys(flags).includes("i") && currentGameOverview != null) flags["i"] = currentGameOverview.appid.toString();
209 | if (!Object.keys(flags).includes("n") && currentGameOverview != null) flags["n"] = currentGameOverview.display_name;
210 |
211 | const res = await PyInterop.runNonAppShortcut(shortcutId, Object.entries(flags));
212 | console.log(res);
213 | return true;
214 | }
215 | }
216 |
217 | /**
218 | * Stops an instance.
219 | * @param shortcutId The id of the shortcut associated with the instance to stop.
220 | * @returns A promise resolving to true if the instance is stopped.
221 | */
222 | async stopInstance(shortcutId: string): Promise {
223 | const instance = this.instances[shortcutId];
224 |
225 | if (instance.shortcutIsApp) {
226 | const appId = instance.unAppID as number;
227 | const res = await this.shorcutsController.closeShortcut(appId);
228 |
229 | Navigation.Navigate("/library/home");
230 | Navigation.CloseSideMenus();
231 |
232 | if (!res) {
233 | PyInterop.log(`Failed to stop instance. Could not close shortcut. Id: ${shortcutId} InstanceName: ${instance.steamShortcutName}`);
234 | return false;
235 | }
236 |
237 | return true;
238 | } else {
239 | //* atm nothing needed here
240 | // const res = await PyInterop.killNonAppShortcut(shortcutId);
241 | // console.log(res);
242 | return true;
243 | }
244 | }
245 | }
--------------------------------------------------------------------------------
/src/lib/controllers/PluginController.ts:
--------------------------------------------------------------------------------
1 | import { ServerAPI } from "decky-frontend-lib";
2 | import { ShortcutsController } from "./ShortcutsController";
3 | import { InstancesController } from "./InstancesController";
4 | import { PyInterop } from "../../PyInterop";
5 | import { SteamController } from "./SteamController";
6 | import { Shortcut } from "../data-structures/Shortcut";
7 | import { WebSocketClient } from "../../WebsocketClient";
8 | import { HookController } from "./HookController";
9 | import { ShortcutsState } from "../../state/ShortcutsState";
10 | import { History, debounce } from "../Utils";
11 |
12 | /**
13 | * Main controller class for the plugin.
14 | */
15 | export class PluginController {
16 | // @ts-ignore
17 | private static server: ServerAPI;
18 | private static state: ShortcutsState;
19 |
20 | private static steamController: SteamController;
21 | private static shortcutsController: ShortcutsController;
22 | private static instancesController: InstancesController;
23 | private static hooksController: HookController;
24 | private static webSocketClient: WebSocketClient;
25 |
26 | private static gameLifetimeRegister: Unregisterer;
27 | private static historyListener: () => void;
28 |
29 | /**
30 | * Sets the plugin's serverAPI.
31 | * @param server The serverAPI to use.
32 | * @param state The plugin state.
33 | */
34 | static setup(server: ServerAPI, state: ShortcutsState): void {
35 | this.server = server;
36 | this.state = state;
37 | this.steamController = new SteamController();
38 | this.shortcutsController = new ShortcutsController(this.steamController);
39 | this.webSocketClient = new WebSocketClient("localhost", "5000", 1000);
40 | this.instancesController = new InstancesController(this.shortcutsController, this.webSocketClient, this.state);
41 | this.hooksController = new HookController(this.steamController, this.instancesController, this.webSocketClient, this.state);
42 |
43 | this.gameLifetimeRegister = this.steamController.registerForAllAppLifetimeNotifications((appId: number, data: LifetimeNotification) => {
44 | const currGame = this.state.getPublicState().currentGame;
45 |
46 | if (data.bRunning) {
47 | if (currGame == null || currGame.appid != appId) {
48 | this.state.setGameRunning(true);
49 | const overview = appStore.GetAppOverviewByAppID(appId);
50 | this.state.setCurrentGame(overview);
51 |
52 | PyInterop.log(`Set currentGame to ${overview?.display_name} appId: ${appId}`);
53 | }
54 | } else {
55 | this.state.setGameRunning(false);
56 | }
57 | });
58 |
59 | this.historyListener = History.listen(debounce((info: any) => {
60 | const currGame = this.state.getPublicState().currentGame;
61 | const pathStart = "/library/app/";
62 |
63 | if (!this.state.getPublicState().gameRunning) {
64 | if (info.pathname.startsWith(pathStart)) {
65 | const appId = parseInt(info.pathname.substring(info.pathname.indexOf(pathStart) + pathStart.length));
66 |
67 | if (currGame == null || currGame.appid != appId) {
68 | const overview = appStore.GetAppOverviewByAppID(appId);
69 | this.state.setCurrentGame(overview);
70 |
71 | PyInterop.log(`Set currentGame to ${overview?.display_name} appId: ${appId}.`);
72 | }
73 | } else if (currGame != null) {
74 | this.state.setCurrentGame(null);
75 | PyInterop.log(`Set currentGame to null.`);
76 | }
77 | }
78 | }, 200));
79 | }
80 |
81 | /**
82 | * Sets the plugin to initialize once the user logs in.
83 | * @returns The unregister function for the login hook.
84 | */
85 | static initOnLogin(): Unregisterer {
86 | return this.steamController.registerForAuthStateChange(async (username) => {
87 | PyInterop.log(`user logged in. [DEBUG INFO] username: ${username};`);
88 | if (await this.steamController.waitForServicesToInitialize()) {
89 | PluginController.init();
90 | } else {
91 | PyInterop.toast("Error", "Failed to initialize, try restarting.");
92 | }
93 | }, null, true);
94 | }
95 |
96 | /**
97 | * Initializes the Plugin.
98 | */
99 | static async init(): Promise {
100 | PyInterop.log("PluginController initializing...");
101 |
102 | //* clean out all shortcuts with names that start with "Bash Shortcuts - Instance"
103 | const oldInstances = (await this.shortcutsController.getShortcuts()).filter((shortcut:SteamAppDetails) => shortcut.strDisplayName.startsWith("Bash Shortcuts - Instance"));
104 |
105 | if (oldInstances.length > 0) {
106 | for (const instance of oldInstances) {
107 | await this.shortcutsController.removeShortcutById(instance.unAppID);
108 | }
109 | }
110 |
111 | this.webSocketClient.connect();
112 |
113 | const shortcuts = (await PyInterop.getShortcuts()).result;
114 | if (typeof shortcuts === "string") {
115 | PyInterop.log(`Failed to get shortcuts for hooks. Error: ${shortcuts}`);
116 | } else {
117 | this.hooksController.init(shortcuts);
118 | }
119 |
120 | PyInterop.log("PluginController initialized.");
121 | }
122 |
123 | /**
124 | * Checks if a shortcut is running.
125 | * @param shorcutId The id of the shortcut to check for.
126 | * @returns True if the shortcut is running.
127 | */
128 | static checkIfRunning(shorcutId: string): boolean {
129 | return Object.keys(PluginController.instancesController.instances).includes(shorcutId);
130 | }
131 |
132 | /**
133 | * Launches a steam shortcut.
134 | * @param shortcutName The name of the steam shortcut to launch.
135 | * @param shortcut The shortcut to launch.
136 | * @param runnerPath The runner path for the shortcut.
137 | * @param onExit An optional function to run when the instance closes.
138 | * @returns A promise resolving to true if the shortcut was successfully launched.
139 | */
140 | static async launchShortcut(shortcut: Shortcut, onExit: (data?: LifetimeNotification) => void = () => {}): Promise {
141 | const createdInstance = await this.instancesController.createInstance(shortcut);
142 | if (createdInstance) {
143 | PyInterop.log(`Created Instance for shortcut ${shortcut.name}`);
144 | return await this.instancesController.launchInstance(shortcut.id, onExit, {});
145 | } else {
146 | return false;
147 | }
148 | }
149 |
150 | /**
151 | * Closes a running shortcut.
152 | * @param shortcut The shortcut to close.
153 | * @returns A promise resolving to true if the shortcut was successfully closed.
154 | */
155 | static async closeShortcut(shortcut:Shortcut): Promise {
156 | const stoppedInstance = await this.instancesController.stopInstance(shortcut.id);
157 | if (stoppedInstance) {
158 | PyInterop.log(`Stopped Instance for shortcut ${shortcut.name}`);
159 | return await this.instancesController.killInstance(shortcut.id);
160 | } else {
161 | PyInterop.log(`Failed to stop instance for shortcut ${shortcut.name}. Id: ${shortcut.id}`);
162 | return false;
163 | }
164 | }
165 |
166 | /**
167 | * Kills a shortcut's instance.
168 | * @param shortcut The shortcut to kill.
169 | * @returns A promise resolving to true if the shortcut's instance was successfully killed.
170 | */
171 | static async killShortcut(shortcut: Shortcut): Promise {
172 | return await this.instancesController.killInstance(shortcut.id);
173 | }
174 |
175 | /**
176 | * Updates the hooks for a specific shortcut.
177 | * @param shortcut The shortcut to update the hooks for.
178 | */
179 | static updateHooks(shortcut: Shortcut): void {
180 | this.hooksController.updateHooks(shortcut);
181 | }
182 |
183 | /**
184 | * Removes the hooks for a specific shortcut.
185 | * @param shortcut The shortcut to remove the hooks for.
186 | */
187 | static removeHooks(shortcut: Shortcut): void {
188 | this.hooksController.unregisterAllHooks(shortcut);
189 | }
190 |
191 | /**
192 | * Registers a callback to run when WebSocket messages of a given type are recieved.
193 | * @param type The type of message to register for.
194 | * @param callback The callback to run.
195 | */
196 | static onWebSocketEvent(type: string, callback: (data: any) => void) {
197 | this.webSocketClient.on(type, callback);
198 | }
199 |
200 | /**
201 | * Function to run when the plugin dismounts.
202 | */
203 | static dismount(): void {
204 | PyInterop.log("PluginController dismounting...");
205 |
206 | this.shortcutsController.onDismount();
207 | this.webSocketClient.disconnect();
208 | this.hooksController.dismount();
209 | this.gameLifetimeRegister.unregister();
210 | this.historyListener();
211 |
212 | PyInterop.log("PluginController dismounted.");
213 | }
214 | }
--------------------------------------------------------------------------------
/src/lib/controllers/ShortcutsController.ts:
--------------------------------------------------------------------------------
1 | import { PyInterop } from "../../PyInterop";
2 | import { SteamController } from "./SteamController";
3 |
4 | /**
5 | * Controller class for shortcuts.
6 | */
7 | export class ShortcutsController {
8 | private steamController: SteamController;
9 |
10 | /**
11 | * Creates a new ShortcutsController.
12 | * @param steamController The SteamController used by this class.
13 | */
14 | constructor(steamController:SteamController) {
15 | this.steamController = steamController;
16 | }
17 |
18 | /**
19 | * Function to run when the plugin dismounts.
20 | */
21 | onDismount(): void {
22 | PyInterop.log("Dismounting...");
23 | }
24 |
25 | /**
26 | * Gets all of the current user's steam shortcuts.
27 | * @returns A promise resolving to a collection of the current user's steam shortcuts.
28 | */
29 | async getShortcuts(): Promise {
30 | const res = await this.steamController.getShortcuts();
31 | return res;
32 | }
33 |
34 | /**
35 | * Gets the current user's steam shortcut with the given name.
36 | * @param name The name of the shortcut to get.
37 | * @returns A promise resolving to the shortcut with the provided name, or null.
38 | */
39 | async getShortcut(name:string): Promise {
40 | const res = await this.steamController.getShortcut(name);
41 |
42 | if (res) {
43 | return res[0];
44 | } else {
45 | return null;
46 | }
47 | }
48 |
49 | /**
50 | * Checks if a shortcut exists.
51 | * @param name The name of the shortcut to check for.
52 | * @returns A promise resolving to true if the shortcut was found.
53 | */
54 | async checkShortcutExist(name: string): Promise {
55 | const shortcutsArr = await this.steamController.getShortcut(name) as SteamAppDetails[];
56 | return shortcutsArr.length > 0;
57 | }
58 |
59 | /**
60 | * Checks if a shortcut exists.
61 | * @param appId The id of the shortcut to check for.
62 | * @returns A promise resolving to true if the shortcut was found.
63 | */
64 | async checkShortcutExistById(appId: number): Promise {
65 | const shortcutsArr = await this.steamController.getShortcutById(appId) as SteamAppDetails[];
66 | return shortcutsArr[0]?.unAppID != 0;
67 | }
68 |
69 | /**
70 | * Sets the exe of a steam shortcut.
71 | * @param appId The id of the app to set.
72 | * @param exec The new value for the exe.
73 | * @returns A promise resolving to true if the exe was set successfully.
74 | */
75 | async setShortcutExe(appId: number, exec: string): Promise {
76 | return await this.steamController.setShortcutExe(appId, exec);
77 | }
78 |
79 | /**
80 | * Sets the start dir of a steam shortcut.
81 | * @param appId The id of the app to set.
82 | * @param startDir The new value for the start dir.
83 | * @returns A promise resolving to true if the start dir was set successfully.
84 | */
85 | async setShortcutStartDir(appId: number, startDir: string): Promise {
86 | return await this.steamController.setShortcutStartDir(appId, startDir);
87 | }
88 |
89 | /**
90 | * Sets the launch options of a steam shortcut.
91 | * @param appId The id of the app to set.
92 | * @param launchOpts The new value for the launch options.
93 | * @returns A promise resolving to true if the launch options was set successfully.
94 | */
95 | async setShortcutLaunchOptions(appId: number, launchOpts: string): Promise {
96 | return await this.steamController.setAppLaunchOptions(appId, launchOpts);
97 | }
98 |
99 | /**
100 | * Sets the name of a steam shortcut.
101 | * @param appId The id of the app to set.
102 | * @param newName The new name for the shortcut.
103 | * @returns A promise resolving to true if the name was set successfully.
104 | */
105 | async setShortcutName(appId: number, newName: string): Promise {
106 | return await this.steamController.setShortcutName(appId, newName);
107 | }
108 |
109 | /**
110 | * Launches a steam shortcut.
111 | * @param appId The id of the steam shortcut to launch.
112 | * @returns A promise resolving to true if the shortcut was successfully launched.
113 | */
114 | async launchShortcut(appId: number): Promise {
115 | return await this.steamController.runGame(appId, false);
116 | }
117 |
118 | /**
119 | * Closes a running shortcut.
120 | * @param appId The id of the shortcut to close.
121 | * @returns A promise resolving to true if the shortcut was successfully closed.
122 | */
123 | async closeShortcut(appId: number): Promise {
124 | return await this.steamController.terminateGame(appId);
125 | }
126 |
127 | /**
128 | * Creates a new steam shortcut.
129 | * @param name The name of the shortcut to create.
130 | * @param exec The executable file for the shortcut.
131 | * @param startDir The start directory of the shortcut.
132 | * @param launchArgs The launch args of the shortcut.
133 | * @returns A promise resolving to true if the shortcut was successfully created.
134 | */
135 | async addShortcut(name: string, exec: string, startDir: string, launchArgs: string): Promise {
136 | const appId = await this.steamController.addShortcut(name, exec, startDir, launchArgs);
137 | if (appId) {
138 | return appId;
139 | } else {
140 | PyInterop.log(`Failed to add shortcut. Name: ${name}`);
141 | PyInterop.toast("Error", "Failed to add shortcut");
142 | return null;
143 | }
144 | }
145 |
146 | /**
147 | * Deletes a shortcut from steam.
148 | * @param name Name of the shortcut to delete.
149 | * @returns A promise resolving to true if the shortcut was successfully deleted.
150 | */
151 | async removeShortcut(name: string): Promise {
152 | const shortcut = await this.steamController.getShortcut(name)[0] as SteamAppDetails;
153 | if (shortcut) {
154 | return await this.steamController.removeShortcut(shortcut.unAppID);
155 | } else {
156 | PyInterop.log(`Didn't find shortcut to remove. Name: ${name}`);
157 | PyInterop.toast("Error", "Didn't find shortcut to remove.");
158 | return false;
159 | }
160 | }
161 |
162 | /**
163 | * Deletes a shortcut from steam by id.
164 | * @param appId The id of the shortcut to delete.
165 | * @returns A promise resolving to true if the shortcut was successfully deleted.
166 | */
167 | async removeShortcutById(appId: number): Promise {
168 | const res = await this.steamController.removeShortcut(appId);
169 | if (res) {
170 | return true;
171 | } else {
172 | PyInterop.log(`Failed to remove shortcut. AppId: ${appId}`);
173 | PyInterop.toast("Error", "Failed to remove shortcut");
174 | return false;
175 | }
176 | }
177 |
178 | /**
179 | * Registers for lifetime updates for a shortcut.
180 | * @param appId The id of the shortcut to register for.
181 | * @param onExit The function to run when the shortcut closes.
182 | * @returns An Unregisterer function to call to unregister from updates.
183 | */
184 | registerForShortcutExit(appId: number, onExit: (data: LifetimeNotification) => void): Unregisterer {
185 | return this.steamController.registerForAppLifetimeNotifications(appId, (data: LifetimeNotification) => {
186 | if (data.bRunning) return;
187 |
188 | onExit(data);
189 | });
190 | }
191 | }
--------------------------------------------------------------------------------
/src/lib/data-structures/Instance.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Class representing an Instance of Bash Shortcuts.
3 | */
4 | export class Instance {
5 | unAppID: number | null; // null if the instance is not an app.
6 | steamShortcutName: string;
7 | shortcutId: string;
8 | shortcutIsApp: boolean;
9 |
10 | /**
11 | * Creates a new Instance.
12 | * @param unAppID The id of the app to create an instance for.
13 | * @param steamShortcutName The name of this instance.
14 | * @param shortcutId The id of the shortcut associated with this instance.
15 | * @param shortcutIsApp Whether the shortcut is an app.
16 | */
17 | constructor(unAppID: number | null, steamShortcutName: string, shortcutId: string, shortcutIsApp: boolean) {
18 | this.unAppID = unAppID;
19 | this.steamShortcutName = steamShortcutName;
20 | this.shortcutId = shortcutId;
21 | this.shortcutIsApp = shortcutIsApp;
22 | }
23 | }
--------------------------------------------------------------------------------
/src/lib/data-structures/Shortcut.ts:
--------------------------------------------------------------------------------
1 | import { Hook } from "../controllers/HookController";
2 |
3 | /**
4 | * Contains all of the nessesary information on each shortcut.
5 | */
6 | export class Shortcut {
7 | id: string;
8 | name: string;
9 | cmd: string;
10 | position: number;
11 | isApp: boolean;
12 | passFlags: boolean;
13 | hooks: Hook[];
14 |
15 | /**
16 | * Creates a new Shortcut.
17 | * @param id The id of the shortcut.
18 | * @param name The name/lable of the shortcut.
19 | * @param cmd The command the shortcut runs.
20 | * @param position The position of the shortcut in the list of shortcuts.
21 | * @param isApp Whether the shortcut is an app or not.
22 | * @param passFlags Whether the shortcut takes flags or not.
23 | * @param hooks The list of hooks for this shortcut.
24 | */
25 | constructor(id: string, name: string, cmd: string, position: number, isApp: boolean, passFlags: boolean, hooks: Hook[]) {
26 | this.id = id;
27 | this.name = name;
28 | this.cmd = cmd;
29 | this.position = position;
30 | this.isApp = isApp;
31 | this.passFlags = passFlags;
32 | this.hooks = hooks;
33 | }
34 |
35 | /**
36 | * Creates a new Shortcut from the provided json data.
37 | * @param json The json data to use for the shortcut.
38 | * @returns A new Shortcut.
39 | */
40 | static fromJSON(json: any): Shortcut {
41 | return new Shortcut(json.id, json.name, json.cmd, json.position, json.isApp, json.passFlags, json.hooks);
42 | }
43 | }
--------------------------------------------------------------------------------
/src/state/ShortcutsState.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, FC, useContext, useEffect, useState } from "react";
2 | import { Shortcut } from "../lib/data-structures/Shortcut"
3 | import { ReorderableEntry } from "decky-frontend-lib";
4 |
5 | type ShortcutsDictionary = {
6 | [key: string]: Shortcut
7 | }
8 |
9 | interface PublicShortcutsState {
10 | shortcuts: ShortcutsDictionary;
11 | shortcutsList: Shortcut[];
12 | runningShortcuts: Set;
13 | reorderableShortcuts: ReorderableEntry[];
14 | currentGame: SteamAppOverview | null;
15 | gameRunning: boolean;
16 | }
17 |
18 | interface PublicShortcutsContext extends PublicShortcutsState {
19 | setShortcuts(shortcuts: ShortcutsDictionary): void;
20 | setIsRunning(shortcutId: string, value: boolean): void;
21 | setCurrentGame(overview: SteamAppOverview | null): void;
22 | setGameRunning(isRunning: boolean): void;
23 | }
24 |
25 | export class ShortcutsState {
26 | private shortcuts: ShortcutsDictionary = {};
27 | private shortcutsList: Shortcut[] = [];
28 | private runningShortcuts = new Set();
29 | private reorderableShortcuts: ReorderableEntry[] = [];
30 | private currentGame: SteamAppOverview | null = null;
31 | private gameRunning: boolean = false;
32 |
33 | public eventBus = new EventTarget();
34 |
35 | getPublicState() {
36 | return {
37 | "shortcuts": this.shortcuts,
38 | "shortcutsList": this.shortcutsList,
39 | "runningShortcuts": this.runningShortcuts,
40 | "reorderableShortcuts": this.reorderableShortcuts,
41 | "currentGame": this.currentGame,
42 | "gameRunning": this.gameRunning
43 | }
44 | }
45 |
46 | setIsRunning(shortcutId: string, value: boolean): void {
47 | if (value) {
48 | this.runningShortcuts.add(shortcutId);
49 | } else {
50 | this.runningShortcuts.delete(shortcutId);
51 | }
52 |
53 | this.runningShortcuts = new Set(this.runningShortcuts.values());
54 |
55 | this.forceUpdate();
56 | }
57 |
58 | setCurrentGame(overview: SteamAppOverview | null): void {
59 | this.currentGame = overview;
60 |
61 | this.forceUpdate();
62 | }
63 |
64 | setGameRunning(isRunning: boolean): void {
65 | this.gameRunning = isRunning;
66 |
67 | this.forceUpdate();
68 | }
69 |
70 | setShortcuts(shortcuts: ShortcutsDictionary): void {
71 | this.shortcuts = shortcuts;
72 | this.shortcutsList = Object.values(this.shortcuts).sort((a, b) => a.position - b.position);
73 | this.reorderableShortcuts = [];
74 |
75 | for (let i = 0; i < this.shortcutsList.length; i++) {
76 | const shortcut = this.shortcutsList[i];
77 | this.reorderableShortcuts[i] = {
78 | "label": shortcut.name,
79 | "data": shortcut,
80 | "position": shortcut.position
81 | }
82 | }
83 |
84 | this.reorderableShortcuts.sort((a, b) => a.position - b.position);
85 |
86 | this.forceUpdate();
87 | }
88 |
89 | private forceUpdate(): void {
90 | this.eventBus.dispatchEvent(new Event("stateUpdate"));
91 | }
92 | }
93 |
94 | const ShortcutsContext = createContext(null as any);
95 | export const useShortcutsState = () => useContext(ShortcutsContext);
96 |
97 | interface ProviderProps {
98 | shortcutsStateClass: ShortcutsState
99 | }
100 |
101 | export const ShortcutsContextProvider: FC = ({
102 | children,
103 | shortcutsStateClass
104 | }) => {
105 | const [publicState, setPublicState] = useState({
106 | ...shortcutsStateClass.getPublicState()
107 | });
108 |
109 | useEffect(() => {
110 | function onUpdate() {
111 | setPublicState({ ...shortcutsStateClass.getPublicState() });
112 | }
113 |
114 | shortcutsStateClass.eventBus
115 | .addEventListener("stateUpdate", onUpdate);
116 |
117 | return () => {
118 | shortcutsStateClass.eventBus
119 | .removeEventListener("stateUpdate", onUpdate);
120 | }
121 | }, []);
122 |
123 | const setShortcuts = (shortcuts: ShortcutsDictionary) => {
124 | shortcutsStateClass.setShortcuts(shortcuts);
125 | }
126 |
127 | const setIsRunning = (shortcutId: string, value: boolean) => {
128 | shortcutsStateClass.setIsRunning(shortcutId, value);
129 | }
130 |
131 | const setCurrentGame = (overview: SteamAppOverview | null) => {
132 | shortcutsStateClass.setCurrentGame(overview);
133 | }
134 |
135 | const setGameRunning = (isRunning: boolean) => {
136 | shortcutsStateClass.setGameRunning(isRunning);
137 | }
138 |
139 | return (
140 |
149 | {children}
150 |
151 | )
152 | }
--------------------------------------------------------------------------------
/src/types/SteamTypes.d.ts:
--------------------------------------------------------------------------------
1 | interface SteamClient {
2 | Apps: Apps,
3 | Browser: any,
4 | BrowserView: any,
5 | ClientNotifications: any,
6 | Cloud: any,
7 | Console: any,
8 | Downloads: Downloads,
9 | FamilySharing: any,
10 | FriendSettings: any,
11 | Friends: any,
12 | GameSessions: GameSession,
13 | Input: any,
14 | InstallFolder: any,
15 | Installs: Installs,
16 | MachineStorage: any,
17 | Messaging: Messaging,
18 | Notifications: Notifications,
19 | OpenVR: any,
20 | Overlay: any,
21 | Parental: any,
22 | RegisterIFrameNavigatedCallback: any,
23 | RemotePlay: any,
24 | RoamingStorage: any,
25 | Screenshots: Screenshots,
26 | Settings: any,
27 | SharedConnection: any,
28 | Stats: any,
29 | Storage: any,
30 | Streaming: any,
31 | System: System,
32 | UI: any,
33 | URL: any,
34 | Updates: Updates,
35 | User: User,
36 | WebChat: any,
37 | Window: Window
38 | }
39 |
40 | type SteamAppAchievements = {
41 | nAchieved:number
42 | nTotal:number
43 | vecAchievedHidden:any[]
44 | vecHighlight:any[]
45 | vecUnachieved:any[]
46 | }
47 |
48 | type SteamAppLanguages = {
49 | strDisplayName:string,
50 | strShortName:string
51 | }
52 |
53 | type SteamGameClientData = {
54 | bytes_downloaded: string,
55 | bytes_total: string,
56 | client_name: string,
57 | clientid: string,
58 | cloud_status: number,
59 | display_status: number,
60 | is_available_on_current_platform: boolean,
61 | status_percentage: number
62 | }
63 |
64 | type SteamTab = {
65 | title: string,
66 | id: string,
67 | content: ReactElement,
68 | footer: {
69 | onOptrionActionsDescription: string,
70 | onOptionsButtion: () => any,
71 | onSecondaryActionDescription: ReactElement,
72 | onSecondaryButton: () => any
73 | }
74 | }
--------------------------------------------------------------------------------
/src/types/appStore.d.ts:
--------------------------------------------------------------------------------
1 | // Types for the global appStore
2 |
3 | type AppStore = {
4 | GetAppOverviewByAppID: (appId: number) => SteamAppOverview | null;
5 | }
--------------------------------------------------------------------------------
/src/types/collectionStore.d.ts:
--------------------------------------------------------------------------------
1 | // Types for the collectionStore global
2 |
3 | type CollectionStore = {
4 | deckDesktopApps: Collection,
5 | userCollections: Collection[],
6 | localGamesCollection: Collection,
7 | allAppsCollection: Collection,
8 | BIsHidden: (appId: number) => boolean,
9 | SetAppsAsHidden: (appIds: number[], hide: boolean) => void,
10 | }
11 |
12 | type SteamCollection = {
13 | AsDeletableCollection: ()=>null
14 | AsDragDropCollection: ()=>null
15 | AsEditableCollection: ()=>null
16 | GetAppCountWithToolsFilter: (t:any) => any
17 | allApps: SteamAppOverview[]
18 | apps: Map
19 | bAllowsDragAndDrop: boolean
20 | bIsDeletable: boolean
21 | bIsDynamic: boolean
22 | bIsEditable: boolean
23 | displayName: string
24 | id: string,
25 | visibleApps: SteamAppOverview[]
26 | }
27 |
28 | type Collection = {
29 | AsDeletableCollection: () => null,
30 | AsDragDropCollection: () => null,
31 | AsEditableCollection: () => null,
32 | GetAppCountWithToolsFilter: (t) => any,
33 | allApps: SteamAppOverview[],
34 | apps: Map,
35 | bAllowsDragAndDrop: boolean,
36 | bIsDeletable: boolean,
37 | bIsDynamic: boolean,
38 | bIsEditable: boolean,
39 | displayName: string,
40 | id: string,
41 | visibleApps: SteamAppOverview[]
42 | }
--------------------------------------------------------------------------------
/src/types/loginStore.d.ts:
--------------------------------------------------------------------------------
1 | // Types for the global loginStore
2 |
3 | type LoginStore = {
4 | m_strAccountName: string
5 | }
--------------------------------------------------------------------------------
/src/types/steam-client/apps.d.ts:
--------------------------------------------------------------------------------
1 | // Types for SteamClient.Apps
2 |
3 | type Apps = {
4 | RunGame: (gameId: string, unk1: string, unk2: number, unk3: number) => void,
5 | TerminateApp: (gameId: string, unk1: boolean) => void,
6 | SetAppLaunchOptions: (appId: number, options: string) => void,
7 |
8 | AddShortcut: (appName: string, exePath: string, startDir: string, launchArgs: string) => number,
9 | RemoveShortcut: (appId: number) => void,
10 | GetShortcutData: any,
11 |
12 | SetShortcutLaunchOptions: any, //(appId: number, options: string) => void,
13 | SetShortcutName: (appId: number, newName: string) => void,
14 | SetShortcutStartDir: (appId: number, startDir: string) => void,
15 | SetShortcutExe: (appId: number, exePath: string) => void,
16 |
17 | RegisterForAchievementChanges: (callback: () => void) => Unregisterer,
18 | RegisterForAppDetails: (appId: number, callback: (details: SteamAppDetails) => void) => Unregisterer,
19 | RegisterForGameActionEnd: (callback: (unk1: number) => void) => Unregisterer,
20 | RegisterForGameActionStart: (callback: (unk1: number, appId: string, action: string) => void) => Unregisterer,
21 | RegisterForGameActionTaskChange: (callback: (data: any) => void) => Unregisterer,
22 | RegisterForGameActionUserRequest: (callback: (unk1: number, appId: string, action: string, requestedAction: string, appId_2: string) => void) => Unregisterer,
23 | }
24 |
25 | type SteamAppDetails = {
26 | achievements: SteamAppAchievements,
27 | bCanMoveInstallFolder:boolean,
28 | bCloudAvailable:boolean,
29 | bCloudEnabledForAccount:boolean,
30 | bCloudEnabledForApp:boolean,
31 | bCloudSyncOnSuspendAvailable:boolean,
32 | bCloudSyncOnSuspendEnabled:boolean,
33 | bCommunityMarketPresence:boolean,
34 | bEnableAllowDesktopConfiguration:boolean,
35 | bFreeRemovableLicense:boolean,
36 | bHasAllLegacyCDKeys:boolean,
37 | bHasAnyLocalContent:boolean,
38 | bHasLockedPrivateBetas:boolean,
39 | bIsExcludedFromSharing:boolean,
40 | bIsSubscribedTo:boolean,
41 | bOverlayEnabled:boolean,
42 | bOverrideInternalResolution:boolean,
43 | bRequiresLegacyCDKey:boolean,
44 | bShortcutIsVR:boolean,
45 | bShowCDKeyInMenus:boolean,
46 | bShowControllerConfig:boolean,
47 | bSupportsCDKeyCopyToClipboard:boolean,
48 | bVRGameTheatreEnabled:boolean,
49 | bWorkshopVisible:boolean,
50 | eAppOwnershipFlags:number,
51 | eAutoUpdateValue:number,
52 | eBackgroundDownloads:number,
53 | eCloudSync:number,
54 | eControllerRumblePreference:number,
55 | eDisplayStatus:number,
56 | eEnableThirdPartyControllerConfiguration:number,
57 | eSteamInputControllerMask:number,
58 | iInstallFolder:number,
59 | lDiskUsageBytes:number,
60 | lDlcUsageBytes:number,
61 | nBuildID:number,
62 | nCompatToolPriority:number,
63 | nPlaytimeForever:number,
64 | nScreenshots:number,
65 | rtLastTimePlayed:number,
66 | rtLastUpdated:number,
67 | rtPurchased:number,
68 | selectedLanguage:{
69 | strDisplayName:string,
70 | strShortName:string
71 | }
72 | strCloudBytesAvailable:string,
73 | strCloudBytesUsed:string,
74 | strCompatToolDisplayName:string,
75 | strCompatToolName:string,
76 | strDeveloperName:string,
77 | strDeveloperURL:string,
78 | strDisplayName:string,
79 | strExternalSubscriptionURL:string,
80 | strFlatpakAppID:string,
81 | strHomepageURL:string,
82 | strLaunchOptions: string,
83 | strManualURL:string,
84 | strOwnerSteamID:string,
85 | strResolutionOverride:string,
86 | strSelectedBeta:string,
87 | strShortcutExe:string,
88 | strShortcutLaunchOptions:string,
89 | strShortcutStartDir:string,
90 | strSteamDeckBlogURL:string,
91 | unAppID:number,
92 | vecBetas:any[],
93 | vecDLC:any[],
94 | vecDeckCompatTestResults:any[],
95 | vecLanguages:SteamAppLanguages[],
96 | vecLegacyCDKeys:any[],
97 | vecMusicAlbums:any[],
98 | vecPlatforms:string[],
99 | vecScreenShots:any[],
100 | }
101 |
102 | type SteamAppOverview = {
103 | app_type: number,
104 | gameid: string,
105 | appid: number,
106 | display_name: string,
107 | steam_deck_compat_category: number,
108 | size_on_disk: string | undefined, // can use the type of this to determine if an app is installed!
109 | association: { type: number, name: string }[],
110 | canonicalAppType: number,
111 | controller_support: number,
112 | header_filename: string | undefined,
113 | icon_data: string | undefined,
114 | icon_data_format: string | undefined,
115 | icon_hash: string,
116 | library_capsule_filename: string | undefined,
117 | library_id: number | string | undefined,
118 | local_per_client_data: SteamGameClientData,
119 | m_gameid: number | string | undefined,
120 | m_setStoreCategories: Set,
121 | m_setStoreTags: Set,
122 | mastersub_appid: number | string | undefined,
123 | mastersub_includedwith_logo: string | undefined,
124 | metacritic_score: number,
125 | minutes_playtime_forever: number,
126 | minutes_playtime_last_two_weeks: number,
127 | most_available_clientid: string,
128 | most_available_per_client_data: SteamGameClientData,
129 | mru_index: number | undefined,
130 | optional_parent_app_id: number | string | undefined,
131 | owner_account_id: number | string | undefined,
132 | per_client_data: SteamGameClientData[],
133 | review_percentage_with_bombs: number,
134 | review_percentage_without_bombs: number,
135 | review_score_with_bombs: number,
136 | review_score_without_bombs: number,
137 | rt_custom_image_mtime: string | undefined,
138 | rt_last_time_locally_played: number | undefined,
139 | rt_last_time_played: number,
140 | rt_last_time_played_or_installed: number,
141 | rt_original_release_date: number,
142 | rt_purchased_time: number,
143 | rt_recent_activity_time: number,
144 | rt_steam_release_date: number,
145 | rt_store_asset_mtime: number,
146 | selected_clientid: string,
147 | selected_per_client_data: SteamGameClientData,
148 | shortcut_override_appid: undefined,
149 | site_license_site_name: string | undefined,
150 | sort_as: string,
151 | third_party_mod: number | string | undefined,
152 | visible_in_game_list: boolean,
153 | vr_only: boolean | undefined,
154 | vr_supported: boolean | undefined,
155 | BHasStoreTag: () => any,
156 | active_beta: number | string | undefined,
157 | display_status: number,
158 | installed: boolean,
159 | is_available_on_current_platform: boolean,
160 | is_invalid_os_type: boolean | undefined,
161 | review_percentage: number,
162 | review_score: number,
163 | status_percentage: number,
164 | store_category: number[],
165 | store_tag: number[],
166 | }
167 |
168 | type SteamShortcut = {
169 | appid: number,
170 | data: {
171 | bIsApplication:boolean,
172 | strAppName: string,
173 | strExePath: string,
174 | strArguments:string,
175 | strShortcutPath:string,
176 | strSortAs:string
177 | }
178 | }
179 |
180 | type SteamAchievement = {
181 | bAchieved: boolean,
182 | bHidden: boolean,
183 | flAchieved: number, //percent of players who have gotten it
184 | flCurrentProgress: number,
185 | flMaxProgress: number,
186 | flMinProgress: number,
187 | rtUnlocked: number,
188 | strDescription: string,
189 | strID: string,
190 | strImage: string,
191 | strName: string,
192 | }
--------------------------------------------------------------------------------
/src/types/steam-client/downloads.d.ts:
--------------------------------------------------------------------------------
1 | // Types for SteamClient.Downloads
2 |
3 | type Downloads = {
4 | RegisterForDownloadItems: (callback: (isDownloading: boolean, downloadItems: DownloadItem[]) => void) => Unregisterer,
5 | RegisterForDownloadOverview: (callback: (data: DownloadOverview) => void) => Unregisterer,
6 | }
7 |
8 | type DownloadItem = {
9 | active: boolean,
10 | appid: number,
11 | buildid: number,
12 | completed: boolean,
13 | completed_time: number,
14 | deferred_time: number,
15 | downloaded_bytes: number,
16 | launch_on_completion: boolean,
17 | paused: boolean,
18 | queue_index: number,
19 | target_buildid: number,
20 | total_bytes: number,
21 | update_error: string,
22 | update_result: number,
23 | update_type_info: UpdateTypeInfo[]
24 | }
25 |
26 | type UpdateTypeInfo = {
27 | completed_update: boolean,
28 | downloaded_bytes: number,
29 | has_update: boolean,
30 | total_bytes: number
31 | }
32 |
33 | type DownloadOverview = {
34 | lan_peer_hostname: string,
35 | paused: boolean,
36 | throttling_suspended: boolean,
37 | update_appid: number,
38 | update_bytes_downloaded: number,
39 | update_bytes_processed: number,
40 | update_bytes_staged: number,
41 | update_bytes_to_download: number,
42 | update_bytes_to_process: number,
43 | update_bytes_to_stage: number,
44 | update_disc_bytes_per_second: number,
45 | update_is_install: boolean,
46 | update_is_prefetch_estimate: boolean,
47 | update_is_shader: boolean,
48 | update_is_upload: boolean,
49 | update_is_workshop: boolean,
50 | update_network_bytes_per_second: number,
51 | update_peak_network_bytes_per_second: number,
52 | update_seconds_remaining: number,
53 | update_start_time: number,
54 | update_state: "None" | "Starting" | "Updating" | "Stopping"
55 | }
56 |
57 |
--------------------------------------------------------------------------------
/src/types/steam-client/gameSession.d.ts:
--------------------------------------------------------------------------------
1 | // Types for SteamClient.GameSession
2 |
3 | type GameSession = {
4 | RegisterForAchievementNotification: (callback: (data: AchievementNotification) => void) => Unregisterer,
5 | RegisterForAppLifetimeNotifications: (callback: (data: LifetimeNotification) => void) => Unregisterer,
6 | RegisterForScreenshotNotification: (callback: (data: ScreenshotNotification) => void) => Unregisterer,
7 | }
8 |
9 | type AchievementNotification = {
10 | achievement: SteamAchievement,
11 | nCurrentProgress: number,
12 | nMaxProgress: number,
13 | unAppID: number
14 | }
15 |
16 | type LifetimeNotification = {
17 | unAppID: number;
18 | nInstanceID: number;
19 | bRunning: boolean;
20 | }
21 |
22 | type ScreenshotNotification = {
23 | details: Screenshot,
24 | hScreenshot: number,
25 | strOperation: string,
26 | unAppID: number,
27 | }
--------------------------------------------------------------------------------
/src/types/steam-client/installs.d.ts:
--------------------------------------------------------------------------------
1 | // Types for SteamClient.Installs
2 |
3 | type Installs = {
4 | RegisterForShowInstallWizard: (callback: (data: InstallWizardInfo) => void) => Unregisterer,
5 | }
6 |
7 | type InstallWizardInfo = {
8 | bCanChangeInstallFolder: boolean,
9 | bIsRetailInstall: boolean,
10 | currentAppID: number,
11 | eAppError: number,
12 | eInstallState: number, //probably a LUT for install status
13 | errorDetail: string,
14 | iInstallFolder: number, //LUT for install folders
15 | iUnmountedFolder: number,
16 | nDiskSpaceAvailable: number,
17 | nDiskSpaceRequired: number,
18 | rgAppIDs: number[],
19 | }
--------------------------------------------------------------------------------
/src/types/steam-client/messaging.d.ts:
--------------------------------------------------------------------------------
1 | // Types for SteamClient.Messaging
2 |
3 | type Messaging = {
4 | PostMessage: () => void,
5 | RegisterForMessages: (accountName: string, callback: (data: any) => void) => Unregisterer
6 | }
--------------------------------------------------------------------------------
/src/types/steam-client/notification.d.ts:
--------------------------------------------------------------------------------
1 | // Types for SteamClient.Notifications
2 |
3 | type Notifications = {
4 | RegisterForNotifications: (callback: (unk1: number, unk2: number, unk3: ArrayBuffer) => void) => Unregisterer
5 | }
--------------------------------------------------------------------------------
/src/types/steam-client/screenshots.d.ts:
--------------------------------------------------------------------------------
1 | // Types for SteamClient.Screenshots
2 |
3 | type Screenshots = {
4 | GetLastScreenshotTake: () => Promise,
5 | GetAllLocalScreenshots: () => Promise,
6 | GetAllAppsLocalScreenshots: () => Promise
7 | }
8 |
9 | type Screenshot = {
10 | bSpoilers: boolean,
11 | bUploaded: boolean,
12 | ePrivacy: number,
13 | hHandle: number,
14 | nAppID: number,
15 | nCreated: number,
16 | nHeight: number,
17 | nWidth: number,
18 | strCaption: "",
19 | strUrl: string,
20 | ugcHandle: string
21 | };
--------------------------------------------------------------------------------
/src/types/steam-client/system.d.ts:
--------------------------------------------------------------------------------
1 | // Types for SteamClient.System
2 |
3 | type System = {
4 | RegisterForOnSuspendRequest: (callback: (data: any) => void) => Unregisterer,
5 | }
--------------------------------------------------------------------------------
/src/types/steam-client/updates.d.ts:
--------------------------------------------------------------------------------
1 | // Types for SteamClient.Updates
2 |
3 | type Updates = {
4 | RegisterForUpdateStateChanges: (callback: (data: any) => void) => Unregisterer
5 | GetCurrentOSBranch: () => any
6 | }
--------------------------------------------------------------------------------
/src/types/steam-client/user.d.ts:
--------------------------------------------------------------------------------
1 | // Types for SteamClient.User
2 |
3 | type User = {
4 | RegisterForCurrentUserChanges: (callback: (data: any) => void) => Unregisterer,
5 | RegisterForLoginStateChange: (callback: (username: string) => void) => Unregisterer,
6 | RegisterForPrepareForSystemSuspendProgress: (callback: (data: any) => void) => Unregisterer,
7 | RegisterForShutdownStart: (callback: () => void) => Unregisterer,
8 | RegisterForShutdownDone: (callback: () => void) => Unregisterer,
9 | StartRestart: () => void
10 | }
--------------------------------------------------------------------------------
/src/types/types.d.ts:
--------------------------------------------------------------------------------
1 | declare module "*.svg" {
2 | const content: string;
3 | export default content;
4 | }
5 |
6 | declare module "*.png" {
7 | const content: string;
8 | export default content;
9 | }
10 |
11 | declare module "*.jpg" {
12 | const content: string;
13 | export default content;
14 | }
15 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "outDir": "dist",
4 | "module": "ESNext",
5 | "target": "ES2020",
6 | "jsx": "react",
7 | "jsxFactory": "window.SP_REACT.createElement",
8 | "declaration": false,
9 | "moduleResolution": "node",
10 | "noUnusedLocals": true,
11 | "noUnusedParameters": true,
12 | "esModuleInterop": true,
13 | "noImplicitReturns": true,
14 | "noImplicitThis": true,
15 | "noImplicitAny": true,
16 | "strict": true,
17 | "suppressImplicitAnyIndexErrors": true,
18 | "allowSyntheticDefaultImports": true,
19 | "skipLibCheck": true,
20 | "jsxFragmentFactory": "Fragment"
21 | },
22 | "include": ["src"],
23 | "exclude": ["node_modules"]
24 | }
25 |
--------------------------------------------------------------------------------