├── .gitattributes ├── .gitignore ├── .vscode ├── config.sh ├── defsettings.json └── tasks.json ├── LICENSE ├── README.md ├── assets ├── bash-flags-ref-qrcode.png ├── thumbnail.png ├── v2.0_QAM.png ├── v2.0_add-shortcut.png ├── v2.0_hooks.png ├── v2.0_modify-shortcut.png └── v2.0_reordering.png ├── defaults ├── guides │ ├── Custom_Scripts.md │ ├── Managing_Shortcuts.md │ ├── Overview.md │ └── Using_Hooks.md ├── py_backend │ ├── __init__.py │ ├── instanceManager.py │ ├── jsInterop.py │ ├── logger.py │ ├── server.py │ ├── serverTest.py │ ├── webSocketClient │ │ ├── LICENSE │ │ ├── __init__.py │ │ ├── _abnf.py │ │ ├── _app.py │ │ ├── _cookiejar.py │ │ ├── _core.py │ │ ├── _exceptions.py │ │ ├── _handshake.py │ │ ├── _http.py │ │ ├── _logging.py │ │ ├── _socket.py │ │ ├── _ssl_compat.py │ │ ├── _url.py │ │ ├── _utils.py │ │ └── _wsdump.py │ └── webSocketServer │ │ └── __init__.py └── shortcutsRunner.sh ├── deploy.sh ├── main.py ├── package-lock.json ├── package.json ├── plugin.json ├── pnpm-lock.yaml ├── rollup.config.js ├── setup.sh ├── src ├── PyInterop.ts ├── WebsocketClient.ts ├── components │ ├── ShortcutLauncher.tsx │ └── plugin-config-ui │ │ ├── AddShortcut.tsx │ │ ├── EditModal.tsx │ │ ├── ManageShortcuts.tsx │ │ ├── Settings.tsx │ │ ├── guides │ │ └── GuidePage.tsx │ │ └── utils │ │ ├── MenuProxy.ts │ │ ├── MultiSelect.tsx │ │ ├── Scrollable.tsx │ │ └── hooks │ │ └── useSetting.ts ├── global.d.ts ├── index.tsx ├── lib │ ├── Utils.ts │ ├── controllers │ │ ├── HookController.ts │ │ ├── InstancesController.ts │ │ ├── PluginController.ts │ │ ├── ShortcutsController.ts │ │ └── SteamController.ts │ └── data-structures │ │ ├── Instance.ts │ │ └── Shortcut.ts ├── state │ └── ShortcutsState.tsx └── types │ ├── SteamTypes.d.ts │ ├── appStore.d.ts │ ├── collectionStore.d.ts │ ├── loginStore.d.ts │ ├── steam-client │ ├── apps.d.ts │ ├── downloads.d.ts │ ├── gameSession.d.ts │ ├── installs.d.ts │ ├── messaging.d.ts │ ├── notification.d.ts │ ├── screenshots.d.ts │ ├── system.d.ts │ ├── updates.d.ts │ └── user.d.ts │ └── types.d.ts └── tsconfig.json /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set the default behavior, in case people don't have core.autocrlf set. 2 | * text=auto 3 | 4 | # Declare files that will always have LF line endings on checkout. 5 | *.sh text eol=lf 6 | 7 | # Denote all files that are truly binary and should not be modified. 8 | *.png binary 9 | *.jpg binary -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib-cov 2 | *.seed 3 | *.log 4 | *.csv 5 | *.dat 6 | *.out 7 | *.pid 8 | *.gz 9 | *.swp 10 | 11 | pids 12 | logs 13 | results 14 | tmp 15 | 16 | # Coverage reports 17 | coverage 18 | 19 | # API keys and secrets 20 | .env 21 | 22 | # Dependency directory 23 | node_modules 24 | bower_components 25 | 26 | # Editors 27 | .idea 28 | *.iml 29 | 30 | # OS metadata 31 | .DS_Store 32 | Thumbs.db 33 | 34 | # Ignore built ts files 35 | dist/ 36 | 37 | __pycache__/ 38 | 39 | /.yalc 40 | yalc.lock 41 | 42 | .vscode/settings.json 43 | 44 | # Ignore output folder 45 | backend/out 46 | 47 | # Custom ignores 48 | /py_backend -------------------------------------------------------------------------------- /.vscode/config.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | SCRIPT_DIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]:-$0}"; )" &> /dev/null && pwd 2> /dev/null; )"; 3 | # printf "${SCRIPT_DIR}\n" 4 | # printf "$(dirname $0)\n" 5 | if ! [[ -e "${SCRIPT_DIR}/settings.json" ]]; then 6 | printf '.vscode/settings.json does not exist. Creating it with default settings. Exiting afterwards. Run your task again.\n\n' 7 | cp "${SCRIPT_DIR}/defsettings.json" "${SCRIPT_DIR}/settings.json" 8 | exit 1 9 | else 10 | printf '.vscode/settings.json does exist. Congrats.\n' 11 | printf 'Make sure to change settings.json to match your deck.\n' 12 | fi -------------------------------------------------------------------------------- /.vscode/defsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deckip" : "0.0.0.0", 3 | "deckport" : "22", 4 | "deckpass" : "ssap", 5 | "deckkey" : "-i ${env:HOME}/.ssh/id_rsa", 6 | "deckdir" : "/home/deck" 7 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | // OTHER 5 | { 6 | "label": "checkforsettings", 7 | "type": "shell", 8 | "group": "none", 9 | "detail": "Check that settings.json has been created", 10 | "command": "bash -c ${workspaceFolder}/.vscode/config.sh", 11 | "problemMatcher": [] 12 | }, 13 | // BUILD 14 | { 15 | "label": "pnpmsetup", 16 | "type": "shell", 17 | "group": "build", 18 | "detail": "Setup pnpm", 19 | "command": "pnpm i", 20 | "problemMatcher": [] 21 | }, 22 | { 23 | "label": "updatefrontendlib", 24 | "type": "shell", 25 | "group": "build", 26 | "detail": "Update deck-frontend-lib", 27 | "command": "pnpm update decky-frontend-lib --latest", 28 | "problemMatcher": [] 29 | }, 30 | { 31 | "label": "build", 32 | "type": "npm", 33 | "group": "build", 34 | "detail": "rollup -c", 35 | "script": "build", 36 | "path": "", 37 | "problemMatcher": [] 38 | }, 39 | { 40 | "label": "buildall", 41 | "group": "build", 42 | "detail": "Build decky-plugin-template", 43 | "dependsOrder": "sequence", 44 | "dependsOn": [ 45 | "pnpmsetup", 46 | "build" 47 | ], 48 | "problemMatcher": [] 49 | }, 50 | // DEPLOY 51 | { 52 | "label": "createfolders", 53 | "detail": "Create plugins folder in expected directory", 54 | "type": "shell", 55 | "group": "none", 56 | "dependsOn": [ 57 | "checkforsettings" 58 | ], 59 | "command": "ssh deck@${config:deckip} -p ${config:deckport} ${config:deckkey} 'mkdir -p ${config:deckdir}/homebrew/pluginloader && mkdir -p ${config:deckdir}/homebrew/plugins'", 60 | "problemMatcher": [] 61 | }, 62 | { 63 | "label": "deploy", 64 | "detail": "Deploy dev plugin to deck", 65 | "type": "shell", 66 | "group": "none", 67 | "dependsOn": [ 68 | "createfolders", 69 | "chmodfolders" 70 | ], 71 | "command": "rsync -azp --delete --chmod=D0755,F0755 --rsh='ssh -p ${config:deckport} ${config:deckkey}' --exclude='.git/' --exclude='.github/' --exclude='.vscode/' --exclude='node_modules/' --exclude='src/' --exclude='*.log' --exclude='.gitignore' . deck@${config:deckip}:${config:deckdir}/homebrew/plugins/${workspaceFolderBasename}", 72 | "problemMatcher": [] 73 | }, 74 | { 75 | "label": "chmodfolders", 76 | "detail": "chmods folders to prevent perms issues", 77 | "type": "shell", 78 | "group": "none", 79 | "command": "ssh deck@${config:deckip} -p ${config:deckport} ${config:deckkey} 'echo '${config:deckpass}' | sudo -S chmod -R ug+rw ${config:deckdir}/homebrew/'", 80 | "problemMatcher": [] 81 | }, 82 | { 83 | "label": "deployall", 84 | "dependsOrder": "sequence", 85 | "group": "none", 86 | "dependsOn": [ 87 | "deploy", 88 | "chmodfolders" 89 | ], 90 | "problemMatcher": [] 91 | }, 92 | // ALL-IN-ONE 93 | { 94 | "label": "allinone", 95 | "detail": "Build and deploy", 96 | "dependsOrder": "sequence", 97 | "group": "test", 98 | "dependsOn": [ 99 | "buildall", 100 | "deployall" 101 | ], 102 | "problemMatcher": [] 103 | } 104 | ] 105 | } 106 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2022, Tormak 4 | Original Copyright (c) 2022, SteamDeckHomebrew 5 | All rights reserved. 6 | 7 | Redistribution and use in source and binary forms, with or without 8 | modification, are permitted provided that the following conditions are met: 9 | 10 | 1. Redistributions of source code must retain the above copyright notice, this 11 | list of conditions and the following disclaimer. 12 | 13 | 2. Redistributions in binary form must reproduce the above copyright notice, 14 | this list of conditions and the following disclaimer in the documentation 15 | and/or other materials provided with the distribution. 16 | 17 | 3. Neither the name of the copyright holder nor the names of its 18 | contributors may be used to endorse or promote products derived from 19 | this software without specific prior written permission. 20 | 21 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 22 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 23 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 24 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 25 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 26 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 27 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 28 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 29 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **IMPORTANT**: Bash Shortcuts has a new home with SDH Stewardship. You can find the repo [here](https://github.com/SDH-Stewardship/bash-shortcuts) 2 | 3 | # Bash Shortcuts Plugin 4 | 5 | A plugin for creating and managing shortcuts that can be launched from the Quick Access Menu! Uses bash under the hood to run commands, hence the name. 6 | 7 | 8 | ![Main View](./assets/thumbnail.png) 9 | 10 | 11 | 12 | # Overview 13 | This plugin allows you to create shortcuts that can be run from the Quick Access Menu. 14 | 15 | The plugin has a default shortcut to launch Konsole (the default teminal installed on the steamdeck). Feel free to remove it. 16 | 17 | 18 | # Using the plugin 19 | If you have questions about using the plugin and/or its features, there is a built in guide in the config menu, which is also available [here](defaults/guides) 20 | 21 | # Installation 22 | 1. [Install the Decky plugin loader](https://github.com/SteamDeckHomebrew/decky-loader#installation) 23 | 2. Use the built in plugin store to download the Bash Shortcuts Plugin 24 | -------------------------------------------------------------------------------- /assets/bash-flags-ref-qrcode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tormak9970/bash-shortcuts/50d6449238c88bf9ff98faa97e17b145d17cd703/assets/bash-flags-ref-qrcode.png -------------------------------------------------------------------------------- /assets/thumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tormak9970/bash-shortcuts/50d6449238c88bf9ff98faa97e17b145d17cd703/assets/thumbnail.png -------------------------------------------------------------------------------- /assets/v2.0_QAM.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tormak9970/bash-shortcuts/50d6449238c88bf9ff98faa97e17b145d17cd703/assets/v2.0_QAM.png -------------------------------------------------------------------------------- /assets/v2.0_add-shortcut.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tormak9970/bash-shortcuts/50d6449238c88bf9ff98faa97e17b145d17cd703/assets/v2.0_add-shortcut.png -------------------------------------------------------------------------------- /assets/v2.0_hooks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tormak9970/bash-shortcuts/50d6449238c88bf9ff98faa97e17b145d17cd703/assets/v2.0_hooks.png -------------------------------------------------------------------------------- /assets/v2.0_modify-shortcut.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tormak9970/bash-shortcuts/50d6449238c88bf9ff98faa97e17b145d17cd703/assets/v2.0_modify-shortcut.png -------------------------------------------------------------------------------- /assets/v2.0_reordering.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tormak9970/bash-shortcuts/50d6449238c88bf9ff98faa97e17b145d17cd703/assets/v2.0_reordering.png -------------------------------------------------------------------------------- /defaults/guides/Custom_Scripts.md: -------------------------------------------------------------------------------- 1 | ## Custom Scripts 2 | 3 | ### Table of Contents 4 | - Overview 5 | - Scripting in Bash 6 | - Bash Tips 7 | - Testing and Debugging 8 | - Common Issues 9 | 10 |
11 | 12 | ### Overview 13 | This guide serves to provide tips for writing in bash, and help on some common issues you might run into. 14 | 15 |
16 | 17 | ### Scripting in Bash 18 | Bash is a common scripting language available natively on linux. It is extremely powerful, and allows you to do pretty much anything you can think of, especially with community packages. There are also loads of resources on how to do various things in bash. Odds are if you have a question, there's either an article or stack overflow post about it. 19 | 20 |
21 | 22 | ### Bash Tips 23 | There are a couple of tricks I have found are very useful when scripting in bash. 24 | 25 | - You can store the output of commands in variables using `var=$(YOUR_COMMANDS)`. 26 | - You can pass the output of one command to another using `COMMAND | COMMAND_THAT_TAKES_INPUT`. 27 | - You can read flags passed to the script. (**Using Hooks** has more info on that) 28 | - You can make arrays with `var=(item1 item2 item3 item4 etc)` 29 | - You can make functions by doing `function myFunction() {}` 30 | 31 | For debugging, I recommend testing in desktop mode, and in game mode, change your command to: 32 | 33 | - scripts: `LD_PRELOAD= QT_SCALE_FACTOR=1.25 konsole -e "YOUR_SCRIPT_HERE"`. 34 | - commands: `LD_PRELOAD= QT_SCALE_FACTOR=1.25 konsole -e /bin/bash --rcfile <(echo "YOUR_COMMAND(s)_HERE")`. 35 | 36 | This will launch konsole and then run your shortcut, allowing you to see any error output. 37 |
38 | 39 | ### Common Issues: 40 | - Need sudo permissions - The plugin can't use sudo, so you'll need to find a different solution or remove your sudo password (NOT RECOMMENDED) 41 | - Shortcut fails immediately - Odds are this is related to your command. check if it might be misspelled or crashing 42 | 43 |
44 | 45 | ###### © Travis Lane (Tormak) -------------------------------------------------------------------------------- /defaults/guides/Managing_Shortcuts.md: -------------------------------------------------------------------------------- 1 | ## Managing Shortcuts 2 | 3 | ### Table of Contents 4 | - Overview 5 | - Adding Shortcuts 6 | - New Shortcut Options 7 | - Editing Shortcuts 8 | - Changing Existing Shortcuts 9 | - Changing the Order of Shortcuts 10 | - Removing Shortcuts 11 | 12 |
13 | 14 | ### Overview 15 | This guide serves as a reference for everything you can do with shortcuts, including: adding shortcuts, editing shortcuts, reordering shortcuts, and deleting shortcuts 16 | 17 |
18 | 19 | ### Adding Shortcuts 20 | 21 |
22 | This is where you can add new shortcuts to the plugin. You can find the various options explained below. 23 | 24 | #### Options: 25 | 26 | **Name** - The name of the shortcut. (This will show up in the Quick Access Menu) 27 | 28 | **Command** - The command to run when the shortcut is run. 29 | 30 | **Is an App** - Toggle this off if your shortcut does not launch an app. 31 | 32 | **Takes Flags** - Toggle this on if you intend to use any of the flags mentioned in the **Using Hooks** guide. This can only be enabled for non app shortcuts. 33 | 34 | **Hooks** - The list of hooks that should run the shortcut. (You can check the dedicated guide to learn more) 35 | 36 |
37 | 38 | ### Editing Shortcuts 39 | This where you can modify existing shortcuts. You can reorder, modify, and delete shortcuts from here. 40 | 41 | #### Changing Existing Shortcuts 42 | 43 | The modification window allows you to edit the values of existing shortcuts. It shares the same options as the **Add Shortcuts** section. 44 | 45 |
46 | 47 | #### Changing the Order of Shortcuts 48 | 49 | To reorder your shortcuts, focus the shortcut you want to reorder, then click **X** (or Square) and move the left stick stick to change its position. Click the button again to save your changes. 50 | 51 |
52 | 53 | ### Removing Shortcuts 54 | In order to remove a shortcut, simply go to the **Manage Shortcuts** section, click the button on the shortcut entry, and select delete. 55 | 56 |
57 | 58 | ###### © Travis Lane (Tormak) -------------------------------------------------------------------------------- /defaults/guides/Overview.md: -------------------------------------------------------------------------------- 1 | ## Overview 2 | 3 | ### About 4 | The purpose of Bash Shortcuts is to provide an easy way to run scripts and apps from Game Mode on the Steam Deck. The potential is only limited by your understanding of Bash and Linux! 5 | 6 |
7 | 8 | ### Guides 9 | These guides serve as a reference for questions you may have, and a resource for utilizing Bash Shortcut's full potential. Below you will find the table of contents listing each guide and providing a short description of each. Simply select the guide from the menu to the left to view it. 10 | 11 |
12 | 13 | ### Table of Contents 14 | * Overview 15 | * General overview of the plugin and guides. **You are here** 16 | * Managing Shortcuts 17 | * Guide on the process of adding, editing, and removing shortcuts. 18 | * Custom Scripts 19 | * Guide and tips for writing scripts to run. 20 | * Using Hooks 21 | * Guide on using hooks to run shortcuts when specific events occur. 22 | 23 |
24 | 25 | ###### © Travis Lane (Tormak) -------------------------------------------------------------------------------- /defaults/guides/Using_Hooks.md: -------------------------------------------------------------------------------- 1 | ## Using Hooks 2 | 3 | ### Table of Contents 4 | - Overview 5 | - Hookable Events 6 | - Log In 7 | - Log Out 8 | - Game Start 9 | - Game End 10 | - Game Install 11 | - Game Uninstall 12 | - Game Achievement Unlock 13 | - Screenshot Taken 14 | - Message Recieved 15 | - SteamOS Update Available 16 | - Deck Shutdown 17 | - Deck Sleep 18 | 19 |
20 | 21 | ### Overview 22 | Hooks are a more complex way to run your shortcuts, and allow you to automate running them. By adding a hook to a shortcut, it will be run each time the hook's associated event occurs, and will be passed the flags for the hook event (**IMPORTANT:** You need to turn on pass flags in the shortcut's settings, otherwise it will be given no flags). 23 | 24 |
25 | 26 | ### Global Flags 27 | There are a few flags that are passed to all non app shortcuts wthat take flags. These are: 28 | | Flag | Value | 29 | | :----: | :-------- | 30 | | -t | The time the shortcut was run at | 31 | | -d | The date the shortcut was run on | 32 | | -u | The username of the current user | 33 | 34 |
35 | 36 | ### Current Game Flags 37 | These flags are passed when you are on a game's library page or it is running.
38 | **Note:** that these are not passed for game related hooks since those hooks set the same flags 39 | | Flag | Value | 40 | | :----: | :-------- | 41 | | -i | The current game's appId | 42 | | -n | The current game's name | 43 | 44 |
45 | 46 | ### Hookable Events 47 | Listed below are all the different hookable events. Each hook has a description of when it occurs, and what flag(s) it will provide your shortcut with (in addition to the global flags). These flags can be accessed by checking for them in your script, as well as a flag containing the hook name. Scan the QR code below or click [here](https://linuxconfig.org/bash-script-flags-usage-with-arguments-examples) to learn more: 48 | 49 | 50 | 51 |
52 | 53 | #### Log In 54 | Run whenever a user logs into the steamdeck. 55 | 56 | | Flag | Value | 57 | | :----: | :-------- | 58 | | -h | "Log In" | 59 | 60 | #### Log Out 61 | Run whenever a user logs out of the steamdeck. 62 | 63 | | Flag | Value | 64 | | :----: | :-------- | 65 | | -h | "Log Out" | 66 | 67 | #### Game Start 68 | Run whenever a game is started. 69 | 70 | | Flag | Value | 71 | | :----: | :-------- | 72 | | -h | "Game Start" | 73 | | -i | The id of the game that was started | 74 | | -n | The name of the game that was started | 75 | 76 | #### Game End 77 | Run whenever a game ends. 78 | 79 | | Flag | Value | 80 | | :----: | :-------- | 81 | | -h | "Game End" | 82 | | -i | The id of the game that was ended | 83 | | -n | The name of the game that was ended | 84 | 85 | #### Game Install 86 | Run whenever a game is installed. 87 | 88 | | Flag | Value | 89 | | :----: | :-------- | 90 | | -h | "Game Install" | 91 | | -i | The id of the game that was installed | 92 | | -v | The version of the game that was installed | 93 | | -n | The name of the game that was installed | 94 | 95 | #### Game Update 96 | Run whenever a game is updated. 97 | 98 | | Flag | Value | 99 | | :----: | :-------- | 100 | | -h | "Game Update" | 101 | | -i | The id of the game that was updated | 102 | | -n | The name of the game that was updated | 103 | | -v | The version the game that was updared to | 104 | 105 | #### Game Uninstall 106 | Run whenever a game is uninstalled. 107 | 108 | | Flag | Value | 109 | | :----: | :-------- | 110 | | -h | "Game Uninstall" | 111 | | -i | The id of the game that was uninstalled | 112 | | -n | The name of the game that was uninstalled | 113 | 114 | #### Game Achievement Unlock 115 | Run whenever an achievement is unlocked in a game. 116 | 117 | | Flag | Value | 118 | | :----: | :-------- | 119 | | -h | "Game Achievement Unlock" | 120 | | -i | The id of the current game | 121 | | -n | The name of the current game | 122 | | -a | The name of the unlocked achievement | 123 | 124 | #### Screenshot Taken 125 | Run whenever a screenshot is taken. 126 | 127 | | Flag | Value | 128 | | :----: | :-------- | 129 | | -h | "Screenshot Taken" | 130 | | -i | The id of the current game | 131 | | -n | The name of the current game | 132 | | -p | The path to the screenshot | 133 | 134 | #### Deck Sleep 135 | Run before the Deck goes to sleep. 136 | 137 | | Flag | Value | 138 | | :----: | :-------- | 139 | | -h | "Deck Sleep" | 140 | 141 | #### Deck Shutdown 142 | Run before the Deck shuts down. 143 | 144 | | Flag | Value | 145 | | :----: | :-------- | 146 | | -h | "Deck Shutdown" | 147 | 148 |
149 | 150 | ###### © Travis Lane (Tormak) -------------------------------------------------------------------------------- /defaults/py_backend/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | py_backend 3 | ~~~~~~~ 4 | 5 | The python lib for bash shortcuts. 6 | """ -------------------------------------------------------------------------------- /defaults/py_backend/instanceManager.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import subprocess 3 | from threading import Thread 4 | from time import sleep 5 | from copy import deepcopy 6 | import json 7 | 8 | from .logger import log 9 | 10 | instancesShouldRun = {} 11 | 12 | class Instance: 13 | def __init__(self, clonedShortcut, flags, checkInterval, jsInteropManager): 14 | self.shortcut = clonedShortcut 15 | self.flags = flags 16 | self.shortcutProcess = None 17 | self.checkInterval = checkInterval 18 | self.jsInteropManager = jsInteropManager 19 | 20 | def createInstance(self): 21 | log(f"Created instance for shortcut {self.shortcut['name']} Id: {self.shortcut['id']}") 22 | command = [self.shortcut["cmd"]] 23 | 24 | for _, flag in enumerate(self.flags): 25 | command.append(f"-{flag[0]}") 26 | command.append(flag[1]) 27 | 28 | self.shortcutProcess = subprocess.Popen(' '.join(command), shell=True) # , stdout=subprocess.PIPE 29 | log(f"Ran process for shortcut {self.shortcut['name']} Id: {self.shortcut['id']}. Attempting to fetch status") 30 | status = self._getProcessStatus() 31 | log(f"Status for command was {status}. Name: {self.shortcut['name']} Id: {self.shortcut['id']}") 32 | self._onUpdate(status, None) 33 | return status 34 | 35 | def killInstance(self): 36 | log(f"Killing instance for shortcut {self.shortcut['name']} Id: {self.shortcut['id']}") 37 | status = self._getProcessStatus() 38 | 39 | if (status == 2): 40 | self.shortcutProcess.kill() 41 | return 3 42 | else: 43 | return status 44 | 45 | def _getProcessStatus(self): 46 | log(f"Getting process status for instance. Name: {self.shortcut['name']} Id: {self.shortcut['id']}") 47 | status = self.shortcutProcess.poll() 48 | 49 | if (status == None): 50 | return 2 51 | elif (status < 0): 52 | return 4 53 | elif (status == 0): 54 | return 0 55 | elif (status > 0): 56 | return 3 57 | 58 | def listenForStatus(self): 59 | while True: 60 | log(f"Checking status for shortcut {self.shortcut['name']}. shouldRun: {instancesShouldRun[self.shortcut['id']]}") 61 | 62 | if (instancesShouldRun[self.shortcut["id"]]): 63 | status = self._getProcessStatus() 64 | 65 | if (status != 2): 66 | self._onTerminate(status) 67 | break 68 | else: 69 | sleep(self.checkInterval) 70 | else: 71 | status = self.killInstance() 72 | self._onTerminate(status) 73 | break 74 | 75 | def _onUpdate(self, status, data): 76 | log(f"Recieved update event for instance. Name {self.shortcut['name']} Id: {self.shortcut['id']}") 77 | log(f"Notifying frontend. Status: {status}") 78 | self.jsInteropManager.sendMessage(f"{self.shortcut['id']}", { "type": "update", "data": data, "started": True, "ended": False, "status": status }) 79 | pass 80 | 81 | def _onTerminate(self, status): 82 | log(f"Recieved terminate event for instance. Name {self.shortcut['name']} Id: {self.shortcut['id']}") 83 | log(f"Notifying frontend. Status: {status}") 84 | self.jsInteropManager.sendMessage(f"{self.shortcut['id']}", { "type": "end", "data": None, "started": True, "ended": True, "status": status }) 85 | 86 | if (self.shortcut["id"] not in instancesShouldRun): 87 | log(f"Missing instanceShouldRun for shortcut {self.shortcut['name']} with id {self.shortcut['id']}") 88 | else: 89 | del instancesShouldRun[self.shortcut["id"]] 90 | 91 | pass 92 | 93 | def cloneObject(object): 94 | return deepcopy(object) 95 | 96 | class InstanceManager: 97 | def __init__(self, checkInterval, jsInteropManager): 98 | self.checkInterval = checkInterval 99 | self.jsInteropManager = jsInteropManager 100 | 101 | def createInstance(self, shortcut, flags): 102 | log(f"Creating instance for {shortcut['name']} Id: {shortcut['id']}") 103 | instancesShouldRun[shortcut["id"]] = True 104 | cmdThread = Thread(target=self.runInstanceInThread, args=[cloneObject(shortcut), flags, self.checkInterval, self.jsInteropManager]) 105 | cmdThread.start() 106 | pass 107 | 108 | def killInstance(self, shortcut): 109 | log(f"Killing instance for {shortcut['name']} Id: {shortcut['id']}") 110 | instancesShouldRun[shortcut["id"]] = False 111 | pass 112 | 113 | def runInstanceInThread(self, clonedShortcut, flags, checkInterval, jsInteropManager): 114 | log(f"Running instance in thread for {clonedShortcut['name']} Id: {clonedShortcut['id']}") 115 | instance = Instance(clonedShortcut, flags, checkInterval, jsInteropManager) 116 | instance.createInstance() 117 | instance.listenForStatus() 118 | pass 119 | -------------------------------------------------------------------------------- /defaults/py_backend/jsInterop.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from subprocess import Popen 3 | import json 4 | import os 5 | 6 | from .webSocketClient import WebSocket as WebSocketClient 7 | from .logger import log 8 | 9 | class JsInteropManager: 10 | def __init__(self, hostName, port): 11 | self.hostName = hostName 12 | self.port = port 13 | pass 14 | 15 | def startServer(self): 16 | log(f"Starting Websocket server on port {self.port}") 17 | # self.serverProcess = Popen(["python", "server.py", self.hostName, self.port, os.environ["DECKY_PLUGIN_LOG_DIR"]], shell=True) 18 | self.serverProcess = Popen(f"python {os.path.join(os.path.dirname(__file__), 'server.py')} \"{self.hostName}\" \"{self.port}\" \"{os.environ['DECKY_PLUGIN_LOG_DIR']}\"", shell=True) 19 | pass 20 | 21 | def sendMessage(self, type: str, data: dict): 22 | ws = WebSocketClient() 23 | log(f"Connecting to websocket {self.hostName}:{self.port}...") 24 | ws.connect(f"ws://{self.hostName}:{self.port}") 25 | log(f"Connected") 26 | 27 | log(f"Sending message to frontend. Type: {data}") 28 | ws.send(json.dumps({ "type": type, "data": data })) 29 | log(f"Message sent.") 30 | 31 | log(f"Closing websocket...") 32 | ws.close() 33 | log(f"Closed.") 34 | pass 35 | 36 | def stopServer(self): 37 | log(f"Killing Websocket server on port {self.port}") 38 | self.serverProcess.kill() 39 | log("Waiting for Websocket server to die") 40 | self.serverProcess.wait() 41 | log("Websocket server died") 42 | pass 43 | 44 | -------------------------------------------------------------------------------- /defaults/py_backend/logger.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import logging 3 | import os 4 | 5 | logging.basicConfig(filename=os.path.join(os.environ["DECKY_PLUGIN_LOG_DIR"], "bash-shortcuts.log"), format="[Bash Shortcuts] %(asctime)s %(levelname)s %(message)s", filemode="w+", force=True) 6 | logger=logging.getLogger() 7 | logger.setLevel(logging.INFO) # can be changed to logging.DEBUG for debugging issues 8 | 9 | def log(txt): 10 | logger.info(txt) -------------------------------------------------------------------------------- /defaults/py_backend/server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from webSocketServer import WebSocketServer, WebSocket 3 | import sys 4 | 5 | import logging 6 | import os 7 | 8 | args = sys.argv 9 | numArgs = len(args) 10 | 11 | logging.basicConfig(filename=os.path.join(args[3], "server.log"), format="[Server] %(asctime)s %(levelname)s %(message)s", filemode="w+", force=True) 12 | logger=logging.getLogger() 13 | logger.setLevel(logging.INFO) 14 | 15 | clients = [] 16 | 17 | def log(txt): 18 | print(txt) 19 | logger.info(txt) 20 | 21 | class InteropServer(WebSocket): 22 | def handle(self): 23 | log("Handling Message") 24 | log(f"Data: {self.data}") 25 | 26 | for client in clients: 27 | if client != self: 28 | client.send_message(self.data) 29 | 30 | def connected(self): 31 | log(f"{self.address} connected") 32 | 33 | for client in clients: 34 | client.send_message(self.address[0] + u' - connected') 35 | 36 | clients.append(self) 37 | 38 | def handle_close(self): 39 | clients.remove(self) 40 | log(f"{self.address} closed") 41 | 42 | for client in clients: 43 | client.send_message(self.address[0] + u' - disconnected') 44 | 45 | if (len(args) >= 4): 46 | server = WebSocketServer(args[1], args[2], InteropServer) 47 | log(f"Starting server on {args[1]}:{args[2]}") 48 | server.serve_forever() 49 | else: 50 | Exception(f"Expected three arguments but only found {len(sys.argv)}") 51 | -------------------------------------------------------------------------------- /defaults/py_backend/serverTest.py: -------------------------------------------------------------------------------- 1 | from subprocess import Popen 2 | import webSocketClient as websocket 3 | 4 | def log(txt): 5 | print(txt) 6 | 7 | 8 | Popen(["python", "server.py", "localhost", "5000", "/home/deck/homebrew/logs/bash-shortcuts"], shell=True) 9 | 10 | websocket.enableTrace(True) 11 | ws = websocket.WebSocket() 12 | ws.connect("ws://localhost:5000") 13 | ws.send("Hello, Server") 14 | # print(ws.recv()) 15 | ws.close() 16 | -------------------------------------------------------------------------------- /defaults/py_backend/webSocketClient/LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2022 engn33r 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | 204 | -------------------------------------------------------------------------------- /defaults/py_backend/webSocketClient/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | __init__.py 3 | websocket - WebSocket client library for Python 4 | 5 | Copyright 2022 engn33r 6 | 7 | Licensed under the Apache License, Version 2.0 (the "License"); 8 | you may not use this file except in compliance with the License. 9 | You may obtain a copy of the License at 10 | 11 | http://www.apache.org/licenses/LICENSE-2.0 12 | 13 | Unless required by applicable law or agreed to in writing, software 14 | distributed under the License is distributed on an "AS IS" BASIS, 15 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | See the License for the specific language governing permissions and 17 | limitations under the License. 18 | """ 19 | from ._abnf import * 20 | from ._app import WebSocketApp, setReconnect 21 | from ._core import * 22 | from ._exceptions import * 23 | from ._logging import * 24 | from ._socket import * 25 | 26 | __version__ = "1.5.1" 27 | -------------------------------------------------------------------------------- /defaults/py_backend/webSocketClient/_cookiejar.py: -------------------------------------------------------------------------------- 1 | import http.cookies 2 | 3 | """ 4 | _cookiejar.py 5 | websocket - WebSocket client library for Python 6 | 7 | Copyright 2022 engn33r 8 | 9 | Licensed under the Apache License, Version 2.0 (the "License"); 10 | you may not use this file except in compliance with the License. 11 | You may obtain a copy of the License at 12 | 13 | http://www.apache.org/licenses/LICENSE-2.0 14 | 15 | Unless required by applicable law or agreed to in writing, software 16 | distributed under the License is distributed on an "AS IS" BASIS, 17 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 18 | See the License for the specific language governing permissions and 19 | limitations under the License. 20 | """ 21 | 22 | 23 | class SimpleCookieJar: 24 | def __init__(self): 25 | self.jar = dict() 26 | 27 | def add(self, set_cookie): 28 | if set_cookie: 29 | simpleCookie = http.cookies.SimpleCookie(set_cookie) 30 | 31 | for k, v in simpleCookie.items(): 32 | domain = v.get("domain") 33 | if domain: 34 | if not domain.startswith("."): 35 | domain = "." + domain 36 | cookie = self.jar.get(domain) if self.jar.get(domain) else http.cookies.SimpleCookie() 37 | cookie.update(simpleCookie) 38 | self.jar[domain.lower()] = cookie 39 | 40 | def set(self, set_cookie): 41 | if set_cookie: 42 | simpleCookie = http.cookies.SimpleCookie(set_cookie) 43 | 44 | for k, v in simpleCookie.items(): 45 | domain = v.get("domain") 46 | if domain: 47 | if not domain.startswith("."): 48 | domain = "." + domain 49 | self.jar[domain.lower()] = simpleCookie 50 | 51 | def get(self, host): 52 | if not host: 53 | return "" 54 | 55 | cookies = [] 56 | for domain, simpleCookie in self.jar.items(): 57 | host = host.lower() 58 | if host.endswith(domain) or host == domain[1:]: 59 | cookies.append(self.jar.get(domain)) 60 | 61 | return "; ".join(filter( 62 | None, sorted( 63 | ["%s=%s" % (k, v.value) for cookie in filter(None, cookies) for k, v in cookie.items()] 64 | ))) 65 | -------------------------------------------------------------------------------- /defaults/py_backend/webSocketClient/_exceptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | _exceptions.py 3 | websocket - WebSocket client library for Python 4 | 5 | Copyright 2022 engn33r 6 | 7 | Licensed under the Apache License, Version 2.0 (the "License"); 8 | you may not use this file except in compliance with the License. 9 | You may obtain a copy of the License at 10 | 11 | http://www.apache.org/licenses/LICENSE-2.0 12 | 13 | Unless required by applicable law or agreed to in writing, software 14 | distributed under the License is distributed on an "AS IS" BASIS, 15 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | See the License for the specific language governing permissions and 17 | limitations under the License. 18 | """ 19 | 20 | 21 | class WebSocketException(Exception): 22 | """ 23 | WebSocket exception class. 24 | """ 25 | pass 26 | 27 | 28 | class WebSocketProtocolException(WebSocketException): 29 | """ 30 | If the WebSocket protocol is invalid, this exception will be raised. 31 | """ 32 | pass 33 | 34 | 35 | class WebSocketPayloadException(WebSocketException): 36 | """ 37 | If the WebSocket payload is invalid, this exception will be raised. 38 | """ 39 | pass 40 | 41 | 42 | class WebSocketConnectionClosedException(WebSocketException): 43 | """ 44 | If remote host closed the connection or some network error happened, 45 | this exception will be raised. 46 | """ 47 | pass 48 | 49 | 50 | class WebSocketTimeoutException(WebSocketException): 51 | """ 52 | WebSocketTimeoutException will be raised at socket timeout during read/write data. 53 | """ 54 | pass 55 | 56 | 57 | class WebSocketProxyException(WebSocketException): 58 | """ 59 | WebSocketProxyException will be raised when proxy error occurred. 60 | """ 61 | pass 62 | 63 | 64 | class WebSocketBadStatusException(WebSocketException): 65 | """ 66 | WebSocketBadStatusException will be raised when we get bad handshake status code. 67 | """ 68 | 69 | def __init__(self, message, status_code, status_message=None, resp_headers=None): 70 | msg = message % (status_code, status_message) 71 | super().__init__(msg) 72 | self.status_code = status_code 73 | self.resp_headers = resp_headers 74 | 75 | 76 | class WebSocketAddressException(WebSocketException): 77 | """ 78 | If the websocket address info cannot be found, this exception will be raised. 79 | """ 80 | pass 81 | -------------------------------------------------------------------------------- /defaults/py_backend/webSocketClient/_handshake.py: -------------------------------------------------------------------------------- 1 | """ 2 | _handshake.py 3 | websocket - WebSocket client library for Python 4 | 5 | Copyright 2022 engn33r 6 | 7 | Licensed under the Apache License, Version 2.0 (the "License"); 8 | you may not use this file except in compliance with the License. 9 | You may obtain a copy of the License at 10 | 11 | http://www.apache.org/licenses/LICENSE-2.0 12 | 13 | Unless required by applicable law or agreed to in writing, software 14 | distributed under the License is distributed on an "AS IS" BASIS, 15 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | See the License for the specific language governing permissions and 17 | limitations under the License. 18 | """ 19 | import hashlib 20 | import hmac 21 | import os 22 | from base64 import encodebytes as base64encode 23 | from http import client as HTTPStatus 24 | from ._cookiejar import SimpleCookieJar 25 | from ._exceptions import * 26 | from ._http import * 27 | from ._logging import * 28 | from ._socket import * 29 | 30 | __all__ = ["handshake_response", "handshake", "SUPPORTED_REDIRECT_STATUSES"] 31 | 32 | # websocket supported version. 33 | VERSION = 13 34 | 35 | SUPPORTED_REDIRECT_STATUSES = (HTTPStatus.MOVED_PERMANENTLY, HTTPStatus.FOUND, HTTPStatus.SEE_OTHER,) 36 | SUCCESS_STATUSES = SUPPORTED_REDIRECT_STATUSES + (HTTPStatus.SWITCHING_PROTOCOLS,) 37 | 38 | CookieJar = SimpleCookieJar() 39 | 40 | 41 | class handshake_response: 42 | 43 | def __init__(self, status, headers, subprotocol): 44 | self.status = status 45 | self.headers = headers 46 | self.subprotocol = subprotocol 47 | CookieJar.add(headers.get("set-cookie")) 48 | 49 | 50 | def handshake(sock, url, hostname, port, resource, **options): 51 | headers, key = _get_handshake_headers(resource, url, hostname, port, options) 52 | 53 | header_str = "\r\n".join(headers) 54 | send(sock, header_str) 55 | dump("request header", header_str) 56 | 57 | status, resp = _get_resp_headers(sock) 58 | if status in SUPPORTED_REDIRECT_STATUSES: 59 | return handshake_response(status, resp, None) 60 | success, subproto = _validate(resp, key, options.get("subprotocols")) 61 | if not success: 62 | raise WebSocketException("Invalid WebSocket Header") 63 | 64 | return handshake_response(status, resp, subproto) 65 | 66 | 67 | def _pack_hostname(hostname): 68 | # IPv6 address 69 | if ':' in hostname: 70 | return '[' + hostname + ']' 71 | 72 | return hostname 73 | 74 | 75 | def _get_handshake_headers(resource, url, host, port, options): 76 | headers = [ 77 | "GET %s HTTP/1.1" % resource, 78 | "Upgrade: websocket" 79 | ] 80 | if port == 80 or port == 443: 81 | hostport = _pack_hostname(host) 82 | else: 83 | hostport = "%s:%d" % (_pack_hostname(host), port) 84 | if options.get("host"): 85 | headers.append("Host: %s" % options["host"]) 86 | else: 87 | headers.append("Host: %s" % hostport) 88 | 89 | # scheme indicates whether http or https is used in Origin 90 | # The same approach is used in parse_url of _url.py to set default port 91 | scheme, url = url.split(":", 1) 92 | if not options.get("suppress_origin"): 93 | if "origin" in options and options["origin"] is not None: 94 | headers.append("Origin: %s" % options["origin"]) 95 | elif scheme == "wss": 96 | headers.append("Origin: https://%s" % hostport) 97 | else: 98 | headers.append("Origin: http://%s" % hostport) 99 | 100 | key = _create_sec_websocket_key() 101 | 102 | # Append Sec-WebSocket-Key & Sec-WebSocket-Version if not manually specified 103 | if not options.get('header') or 'Sec-WebSocket-Key' not in options['header']: 104 | headers.append("Sec-WebSocket-Key: %s" % key) 105 | else: 106 | key = options['header']['Sec-WebSocket-Key'] 107 | 108 | if not options.get('header') or 'Sec-WebSocket-Version' not in options['header']: 109 | headers.append("Sec-WebSocket-Version: %s" % VERSION) 110 | 111 | if not options.get('connection'): 112 | headers.append('Connection: Upgrade') 113 | else: 114 | headers.append(options['connection']) 115 | 116 | subprotocols = options.get("subprotocols") 117 | if subprotocols: 118 | headers.append("Sec-WebSocket-Protocol: %s" % ",".join(subprotocols)) 119 | 120 | header = options.get("header") 121 | if header: 122 | if isinstance(header, dict): 123 | header = [ 124 | ": ".join([k, v]) 125 | for k, v in header.items() 126 | if v is not None 127 | ] 128 | headers.extend(header) 129 | 130 | server_cookie = CookieJar.get(host) 131 | client_cookie = options.get("cookie", None) 132 | 133 | cookie = "; ".join(filter(None, [server_cookie, client_cookie])) 134 | 135 | if cookie: 136 | headers.append("Cookie: %s" % cookie) 137 | 138 | headers.append("") 139 | headers.append("") 140 | 141 | return headers, key 142 | 143 | 144 | def _get_resp_headers(sock, success_statuses=SUCCESS_STATUSES): 145 | status, resp_headers, status_message = read_headers(sock) 146 | if status not in success_statuses: 147 | raise WebSocketBadStatusException("Handshake status %d %s", status, status_message, resp_headers) 148 | return status, resp_headers 149 | 150 | 151 | _HEADERS_TO_CHECK = { 152 | "upgrade": "websocket", 153 | "connection": "upgrade", 154 | } 155 | 156 | 157 | def _validate(headers, key, subprotocols): 158 | subproto = None 159 | for k, v in _HEADERS_TO_CHECK.items(): 160 | r = headers.get(k, None) 161 | if not r: 162 | return False, None 163 | r = [x.strip().lower() for x in r.split(',')] 164 | if v not in r: 165 | return False, None 166 | 167 | if subprotocols: 168 | subproto = headers.get("sec-websocket-protocol", None) 169 | if not subproto or subproto.lower() not in [s.lower() for s in subprotocols]: 170 | error("Invalid subprotocol: " + str(subprotocols)) 171 | return False, None 172 | subproto = subproto.lower() 173 | 174 | result = headers.get("sec-websocket-accept", None) 175 | if not result: 176 | return False, None 177 | result = result.lower() 178 | 179 | if isinstance(result, str): 180 | result = result.encode('utf-8') 181 | 182 | value = (key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode('utf-8') 183 | hashed = base64encode(hashlib.sha1(value).digest()).strip().lower() 184 | success = hmac.compare_digest(hashed, result) 185 | 186 | if success: 187 | return True, subproto 188 | else: 189 | return False, None 190 | 191 | 192 | def _create_sec_websocket_key(): 193 | randomness = os.urandom(16) 194 | return base64encode(randomness).decode('utf-8').strip() 195 | -------------------------------------------------------------------------------- /defaults/py_backend/webSocketClient/_http.py: -------------------------------------------------------------------------------- 1 | """ 2 | _http.py 3 | websocket - WebSocket client library for Python 4 | 5 | Copyright 2022 engn33r 6 | 7 | Licensed under the Apache License, Version 2.0 (the "License"); 8 | you may not use this file except in compliance with the License. 9 | You may obtain a copy of the License at 10 | 11 | http://www.apache.org/licenses/LICENSE-2.0 12 | 13 | Unless required by applicable law or agreed to in writing, software 14 | distributed under the License is distributed on an "AS IS" BASIS, 15 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | See the License for the specific language governing permissions and 17 | limitations under the License. 18 | """ 19 | import errno 20 | import os 21 | import socket 22 | import sys 23 | 24 | from ._exceptions import * 25 | from ._logging import * 26 | from ._socket import * 27 | from ._ssl_compat import * 28 | from ._url import * 29 | 30 | from base64 import encodebytes as base64encode 31 | 32 | __all__ = ["proxy_info", "connect", "read_headers"] 33 | 34 | try: 35 | from python_socks.sync import Proxy 36 | from python_socks._errors import * 37 | from python_socks._types import ProxyType 38 | HAVE_PYTHON_SOCKS = True 39 | except: 40 | HAVE_PYTHON_SOCKS = False 41 | 42 | class ProxyError(Exception): 43 | pass 44 | 45 | class ProxyTimeoutError(Exception): 46 | pass 47 | 48 | class ProxyConnectionError(Exception): 49 | pass 50 | 51 | 52 | class proxy_info: 53 | 54 | def __init__(self, **options): 55 | self.proxy_host = options.get("http_proxy_host", None) 56 | if self.proxy_host: 57 | self.proxy_port = options.get("http_proxy_port", 0) 58 | self.auth = options.get("http_proxy_auth", None) 59 | self.no_proxy = options.get("http_no_proxy", None) 60 | self.proxy_protocol = options.get("proxy_type", "http") 61 | # Note: If timeout not specified, default python-socks timeout is 60 seconds 62 | self.proxy_timeout = options.get("http_proxy_timeout", None) 63 | if self.proxy_protocol not in ['http', 'socks4', 'socks4a', 'socks5', 'socks5h']: 64 | raise ProxyError("Only http, socks4, socks5 proxy protocols are supported") 65 | else: 66 | self.proxy_port = 0 67 | self.auth = None 68 | self.no_proxy = None 69 | self.proxy_protocol = "http" 70 | 71 | 72 | def _start_proxied_socket(url, options, proxy): 73 | if not HAVE_PYTHON_SOCKS: 74 | raise WebSocketException("Python Socks is needed for SOCKS proxying but is not available") 75 | 76 | hostname, port, resource, is_secure = parse_url(url) 77 | 78 | if proxy.proxy_protocol == "socks5": 79 | rdns = False 80 | proxy_type = ProxyType.SOCKS5 81 | if proxy.proxy_protocol == "socks4": 82 | rdns = False 83 | proxy_type = ProxyType.SOCKS4 84 | # socks5h and socks4a send DNS through proxy 85 | if proxy.proxy_protocol == "socks5h": 86 | rdns = True 87 | proxy_type = ProxyType.SOCKS5 88 | if proxy.proxy_protocol == "socks4a": 89 | rdns = True 90 | proxy_type = ProxyType.SOCKS4 91 | 92 | ws_proxy = Proxy.create( 93 | proxy_type=proxy_type, 94 | host=proxy.proxy_host, 95 | port=int(proxy.proxy_port), 96 | username=proxy.auth[0] if proxy.auth else None, 97 | password=proxy.auth[1] if proxy.auth else None, 98 | rdns=rdns) 99 | 100 | sock = ws_proxy.connect(hostname, port, timeout=proxy.proxy_timeout) 101 | 102 | if is_secure and HAVE_SSL: 103 | sock = _ssl_socket(sock, options.sslopt, hostname) 104 | elif is_secure: 105 | raise WebSocketException("SSL not available.") 106 | 107 | return sock, (hostname, port, resource) 108 | 109 | 110 | def connect(url, options, proxy, socket): 111 | # Use _start_proxied_socket() only for socks4 or socks5 proxy 112 | # Use _tunnel() for http proxy 113 | # TODO: Use python-socks for http protocol also, to standardize flow 114 | if proxy.proxy_host and not socket and not (proxy.proxy_protocol == "http"): 115 | return _start_proxied_socket(url, options, proxy) 116 | 117 | hostname, port_from_url, resource, is_secure = parse_url(url) 118 | 119 | if socket: 120 | return socket, (hostname, port_from_url, resource) 121 | 122 | addrinfo_list, need_tunnel, auth = _get_addrinfo_list( 123 | hostname, port_from_url, is_secure, proxy) 124 | if not addrinfo_list: 125 | raise WebSocketException( 126 | "Host not found.: " + hostname + ":" + str(port_from_url)) 127 | 128 | sock = None 129 | try: 130 | sock = _open_socket(addrinfo_list, options.sockopt, options.timeout) 131 | if need_tunnel: 132 | sock = _tunnel(sock, hostname, port_from_url, auth) 133 | 134 | if is_secure: 135 | if HAVE_SSL: 136 | sock = _ssl_socket(sock, options.sslopt, hostname) 137 | else: 138 | raise WebSocketException("SSL not available.") 139 | 140 | return sock, (hostname, port_from_url, resource) 141 | except: 142 | if sock: 143 | sock.close() 144 | raise 145 | 146 | 147 | def _get_addrinfo_list(hostname, port, is_secure, proxy): 148 | phost, pport, pauth = get_proxy_info( 149 | hostname, is_secure, proxy.proxy_host, proxy.proxy_port, proxy.auth, proxy.no_proxy) 150 | try: 151 | # when running on windows 10, getaddrinfo without socktype returns a socktype 0. 152 | # This generates an error exception: `_on_error: exception Socket type must be stream or datagram, not 0` 153 | # or `OSError: [Errno 22] Invalid argument` when creating socket. Force the socket type to SOCK_STREAM. 154 | if not phost: 155 | addrinfo_list = socket.getaddrinfo( 156 | hostname, port, 0, socket.SOCK_STREAM, socket.SOL_TCP) 157 | return addrinfo_list, False, None 158 | else: 159 | pport = pport and pport or 80 160 | # when running on windows 10, the getaddrinfo used above 161 | # returns a socktype 0. This generates an error exception: 162 | # _on_error: exception Socket type must be stream or datagram, not 0 163 | # Force the socket type to SOCK_STREAM 164 | addrinfo_list = socket.getaddrinfo(phost, pport, 0, socket.SOCK_STREAM, socket.SOL_TCP) 165 | return addrinfo_list, True, pauth 166 | except socket.gaierror as e: 167 | raise WebSocketAddressException(e) 168 | 169 | 170 | def _open_socket(addrinfo_list, sockopt, timeout): 171 | err = None 172 | for addrinfo in addrinfo_list: 173 | family, socktype, proto = addrinfo[:3] 174 | sock = socket.socket(family, socktype, proto) 175 | sock.settimeout(timeout) 176 | for opts in DEFAULT_SOCKET_OPTION: 177 | sock.setsockopt(*opts) 178 | for opts in sockopt: 179 | sock.setsockopt(*opts) 180 | 181 | address = addrinfo[4] 182 | err = None 183 | while not err: 184 | try: 185 | sock.connect(address) 186 | except socket.error as error: 187 | sock.close() 188 | error.remote_ip = str(address[0]) 189 | try: 190 | eConnRefused = (errno.ECONNREFUSED, errno.WSAECONNREFUSED, errno.ENETUNREACH) 191 | except AttributeError: 192 | eConnRefused = (errno.ECONNREFUSED, errno.ENETUNREACH) 193 | if error.errno in eConnRefused: 194 | err = error 195 | continue 196 | else: 197 | raise error 198 | else: 199 | break 200 | else: 201 | continue 202 | break 203 | else: 204 | if err: 205 | raise err 206 | 207 | return sock 208 | 209 | 210 | def _wrap_sni_socket(sock, sslopt, hostname, check_hostname): 211 | context = sslopt.get('context', None) 212 | if not context: 213 | context = ssl.SSLContext(sslopt.get('ssl_version', ssl.PROTOCOL_TLS_CLIENT)) 214 | 215 | if sslopt.get('cert_reqs', ssl.CERT_NONE) != ssl.CERT_NONE: 216 | cafile = sslopt.get('ca_certs', None) 217 | capath = sslopt.get('ca_cert_path', None) 218 | if cafile or capath: 219 | context.load_verify_locations(cafile=cafile, capath=capath) 220 | elif hasattr(context, 'load_default_certs'): 221 | context.load_default_certs(ssl.Purpose.SERVER_AUTH) 222 | if sslopt.get('certfile', None): 223 | context.load_cert_chain( 224 | sslopt['certfile'], 225 | sslopt.get('keyfile', None), 226 | sslopt.get('password', None), 227 | ) 228 | 229 | # Python 3.10 switch to PROTOCOL_TLS_CLIENT defaults to "cert_reqs = ssl.CERT_REQUIRED" and "check_hostname = True" 230 | # If both disabled, set check_hostname before verify_mode 231 | # see https://github.com/liris/websocket-client/commit/b96a2e8fa765753e82eea531adb19716b52ca3ca#commitcomment-10803153 232 | if sslopt.get('cert_reqs', ssl.CERT_NONE) == ssl.CERT_NONE and not sslopt.get('check_hostname', False): 233 | context.check_hostname = False 234 | context.verify_mode = ssl.CERT_NONE 235 | else: 236 | context.check_hostname = sslopt.get('check_hostname', True) 237 | context.verify_mode = sslopt.get('cert_reqs', ssl.CERT_REQUIRED) 238 | 239 | if 'ciphers' in sslopt: 240 | context.set_ciphers(sslopt['ciphers']) 241 | if 'cert_chain' in sslopt: 242 | certfile, keyfile, password = sslopt['cert_chain'] 243 | context.load_cert_chain(certfile, keyfile, password) 244 | if 'ecdh_curve' in sslopt: 245 | context.set_ecdh_curve(sslopt['ecdh_curve']) 246 | 247 | return context.wrap_socket( 248 | sock, 249 | do_handshake_on_connect=sslopt.get('do_handshake_on_connect', True), 250 | suppress_ragged_eofs=sslopt.get('suppress_ragged_eofs', True), 251 | server_hostname=hostname, 252 | ) 253 | 254 | 255 | def _ssl_socket(sock, user_sslopt, hostname): 256 | sslopt = dict(cert_reqs=ssl.CERT_REQUIRED) 257 | sslopt.update(user_sslopt) 258 | 259 | certPath = os.environ.get('WEBSOCKET_CLIENT_CA_BUNDLE') 260 | if certPath and os.path.isfile(certPath) \ 261 | and user_sslopt.get('ca_certs', None) is None: 262 | sslopt['ca_certs'] = certPath 263 | elif certPath and os.path.isdir(certPath) \ 264 | and user_sslopt.get('ca_cert_path', None) is None: 265 | sslopt['ca_cert_path'] = certPath 266 | 267 | if sslopt.get('server_hostname', None): 268 | hostname = sslopt['server_hostname'] 269 | 270 | check_hostname = sslopt.get('check_hostname', True) 271 | sock = _wrap_sni_socket(sock, sslopt, hostname, check_hostname) 272 | 273 | return sock 274 | 275 | 276 | def _tunnel(sock, host, port, auth): 277 | debug("Connecting proxy...") 278 | connect_header = "CONNECT %s:%d HTTP/1.1\r\n" % (host, port) 279 | connect_header += "Host: %s:%d\r\n" % (host, port) 280 | 281 | # TODO: support digest auth. 282 | if auth and auth[0]: 283 | auth_str = auth[0] 284 | if auth[1]: 285 | auth_str += ":" + auth[1] 286 | encoded_str = base64encode(auth_str.encode()).strip().decode().replace('\n', '') 287 | connect_header += "Proxy-Authorization: Basic %s\r\n" % encoded_str 288 | connect_header += "\r\n" 289 | dump("request header", connect_header) 290 | 291 | send(sock, connect_header) 292 | 293 | try: 294 | status, resp_headers, status_message = read_headers(sock) 295 | except Exception as e: 296 | raise WebSocketProxyException(str(e)) 297 | 298 | if status != 200: 299 | raise WebSocketProxyException( 300 | "failed CONNECT via proxy status: %r" % status) 301 | 302 | return sock 303 | 304 | 305 | def read_headers(sock): 306 | status = None 307 | status_message = None 308 | headers = {} 309 | trace("--- response header ---") 310 | 311 | while True: 312 | line = recv_line(sock) 313 | line = line.decode('utf-8').strip() 314 | if not line: 315 | break 316 | trace(line) 317 | if not status: 318 | 319 | status_info = line.split(" ", 2) 320 | status = int(status_info[1]) 321 | if len(status_info) > 2: 322 | status_message = status_info[2] 323 | else: 324 | kv = line.split(":", 1) 325 | if len(kv) == 2: 326 | key, value = kv 327 | if key.lower() == "set-cookie" and headers.get("set-cookie"): 328 | headers["set-cookie"] = headers.get("set-cookie") + "; " + value.strip() 329 | else: 330 | headers[key.lower()] = value.strip() 331 | else: 332 | raise WebSocketException("Invalid header") 333 | 334 | trace("-----------------------") 335 | 336 | return status, headers, status_message 337 | -------------------------------------------------------------------------------- /defaults/py_backend/webSocketClient/_logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | """ 4 | _logging.py 5 | websocket - WebSocket client library for Python 6 | 7 | Copyright 2022 engn33r 8 | 9 | Licensed under the Apache License, Version 2.0 (the "License"); 10 | you may not use this file except in compliance with the License. 11 | You may obtain a copy of the License at 12 | 13 | http://www.apache.org/licenses/LICENSE-2.0 14 | 15 | Unless required by applicable law or agreed to in writing, software 16 | distributed under the License is distributed on an "AS IS" BASIS, 17 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 18 | See the License for the specific language governing permissions and 19 | limitations under the License. 20 | """ 21 | 22 | _logger = logging.getLogger('websocket') 23 | try: 24 | from logging import NullHandler 25 | except ImportError: 26 | class NullHandler(logging.Handler): 27 | def emit(self, record): 28 | pass 29 | 30 | _logger.addHandler(NullHandler()) 31 | 32 | _traceEnabled = False 33 | 34 | __all__ = ["enableTrace", "dump", "error", "warning", "debug", "trace", 35 | "isEnabledForError", "isEnabledForDebug", "isEnabledForTrace"] 36 | 37 | 38 | def enableTrace(traceable, handler=logging.StreamHandler(), level="DEBUG"): 39 | """ 40 | Turn on/off the traceability. 41 | 42 | Parameters 43 | ---------- 44 | traceable: bool 45 | If set to True, traceability is enabled. 46 | """ 47 | global _traceEnabled 48 | _traceEnabled = traceable 49 | if traceable: 50 | _logger.addHandler(handler) 51 | _logger.setLevel(getattr(logging, level)) 52 | 53 | 54 | def dump(title, message): 55 | if _traceEnabled: 56 | _logger.debug("--- " + title + " ---") 57 | _logger.debug(message) 58 | _logger.debug("-----------------------") 59 | 60 | 61 | def error(msg): 62 | _logger.error(msg) 63 | 64 | 65 | def warning(msg): 66 | _logger.warning(msg) 67 | 68 | 69 | def debug(msg): 70 | _logger.debug(msg) 71 | 72 | 73 | def info(msg): 74 | _logger.info(msg) 75 | 76 | 77 | def trace(msg): 78 | if _traceEnabled: 79 | _logger.debug(msg) 80 | 81 | 82 | def isEnabledForError(): 83 | return _logger.isEnabledFor(logging.ERROR) 84 | 85 | 86 | def isEnabledForDebug(): 87 | return _logger.isEnabledFor(logging.DEBUG) 88 | 89 | 90 | def isEnabledForTrace(): 91 | return _traceEnabled 92 | -------------------------------------------------------------------------------- /defaults/py_backend/webSocketClient/_socket.py: -------------------------------------------------------------------------------- 1 | import errno 2 | import selectors 3 | import socket 4 | 5 | from ._exceptions import * 6 | from ._ssl_compat import * 7 | from ._utils import * 8 | 9 | """ 10 | _socket.py 11 | websocket - WebSocket client library for Python 12 | 13 | Copyright 2022 engn33r 14 | 15 | Licensed under the Apache License, Version 2.0 (the "License"); 16 | you may not use this file except in compliance with the License. 17 | You may obtain a copy of the License at 18 | 19 | http://www.apache.org/licenses/LICENSE-2.0 20 | 21 | Unless required by applicable law or agreed to in writing, software 22 | distributed under the License is distributed on an "AS IS" BASIS, 23 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 24 | See the License for the specific language governing permissions and 25 | limitations under the License. 26 | """ 27 | 28 | DEFAULT_SOCKET_OPTION = [(socket.SOL_TCP, socket.TCP_NODELAY, 1)] 29 | if hasattr(socket, "SO_KEEPALIVE"): 30 | DEFAULT_SOCKET_OPTION.append((socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)) 31 | if hasattr(socket, "TCP_KEEPIDLE"): 32 | DEFAULT_SOCKET_OPTION.append((socket.SOL_TCP, socket.TCP_KEEPIDLE, 30)) 33 | if hasattr(socket, "TCP_KEEPINTVL"): 34 | DEFAULT_SOCKET_OPTION.append((socket.SOL_TCP, socket.TCP_KEEPINTVL, 10)) 35 | if hasattr(socket, "TCP_KEEPCNT"): 36 | DEFAULT_SOCKET_OPTION.append((socket.SOL_TCP, socket.TCP_KEEPCNT, 3)) 37 | 38 | _default_timeout = None 39 | 40 | __all__ = ["DEFAULT_SOCKET_OPTION", "sock_opt", "setdefaulttimeout", "getdefaulttimeout", 41 | "recv", "recv_line", "send"] 42 | 43 | 44 | class sock_opt: 45 | 46 | def __init__(self, sockopt, sslopt): 47 | if sockopt is None: 48 | sockopt = [] 49 | if sslopt is None: 50 | sslopt = {} 51 | self.sockopt = sockopt 52 | self.sslopt = sslopt 53 | self.timeout = None 54 | 55 | 56 | def setdefaulttimeout(timeout): 57 | """ 58 | Set the global timeout setting to connect. 59 | 60 | Parameters 61 | ---------- 62 | timeout: int or float 63 | default socket timeout time (in seconds) 64 | """ 65 | global _default_timeout 66 | _default_timeout = timeout 67 | 68 | 69 | def getdefaulttimeout(): 70 | """ 71 | Get default timeout 72 | 73 | Returns 74 | ---------- 75 | _default_timeout: int or float 76 | Return the global timeout setting (in seconds) to connect. 77 | """ 78 | return _default_timeout 79 | 80 | 81 | def recv(sock, bufsize): 82 | if not sock: 83 | raise WebSocketConnectionClosedException("socket is already closed.") 84 | 85 | def _recv(): 86 | try: 87 | return sock.recv(bufsize) 88 | except SSLWantReadError: 89 | pass 90 | except socket.error as exc: 91 | error_code = extract_error_code(exc) 92 | if error_code != errno.EAGAIN and error_code != errno.EWOULDBLOCK: 93 | raise 94 | 95 | sel = selectors.DefaultSelector() 96 | sel.register(sock, selectors.EVENT_READ) 97 | 98 | r = sel.select(sock.gettimeout()) 99 | sel.close() 100 | 101 | if r: 102 | return sock.recv(bufsize) 103 | 104 | try: 105 | if sock.gettimeout() == 0: 106 | bytes_ = sock.recv(bufsize) 107 | else: 108 | bytes_ = _recv() 109 | except TimeoutError: 110 | raise WebSocketTimeoutException("Connection timed out") 111 | except socket.timeout as e: 112 | message = extract_err_message(e) 113 | raise WebSocketTimeoutException(message) 114 | except SSLError as e: 115 | message = extract_err_message(e) 116 | if isinstance(message, str) and 'timed out' in message: 117 | raise WebSocketTimeoutException(message) 118 | else: 119 | raise 120 | 121 | if not bytes_: 122 | raise WebSocketConnectionClosedException( 123 | "Connection to remote host was lost.") 124 | 125 | return bytes_ 126 | 127 | 128 | def recv_line(sock): 129 | line = [] 130 | while True: 131 | c = recv(sock, 1) 132 | line.append(c) 133 | if c == b'\n': 134 | break 135 | return b''.join(line) 136 | 137 | 138 | def send(sock, data): 139 | if isinstance(data, str): 140 | data = data.encode('utf-8') 141 | 142 | if not sock: 143 | raise WebSocketConnectionClosedException("socket is already closed.") 144 | 145 | def _send(): 146 | try: 147 | return sock.send(data) 148 | except SSLWantWriteError: 149 | pass 150 | except socket.error as exc: 151 | error_code = extract_error_code(exc) 152 | if error_code is None: 153 | raise 154 | if error_code != errno.EAGAIN and error_code != errno.EWOULDBLOCK: 155 | raise 156 | 157 | sel = selectors.DefaultSelector() 158 | sel.register(sock, selectors.EVENT_WRITE) 159 | 160 | w = sel.select(sock.gettimeout()) 161 | sel.close() 162 | 163 | if w: 164 | return sock.send(data) 165 | 166 | try: 167 | if sock.gettimeout() == 0: 168 | return sock.send(data) 169 | else: 170 | return _send() 171 | except socket.timeout as e: 172 | message = extract_err_message(e) 173 | raise WebSocketTimeoutException(message) 174 | except Exception as e: 175 | message = extract_err_message(e) 176 | if isinstance(message, str) and "timed out" in message: 177 | raise WebSocketTimeoutException(message) 178 | else: 179 | raise 180 | -------------------------------------------------------------------------------- /defaults/py_backend/webSocketClient/_ssl_compat.py: -------------------------------------------------------------------------------- 1 | """ 2 | _ssl_compat.py 3 | websocket - WebSocket client library for Python 4 | 5 | Copyright 2022 engn33r 6 | 7 | Licensed under the Apache License, Version 2.0 (the "License"); 8 | you may not use this file except in compliance with the License. 9 | You may obtain a copy of the License at 10 | 11 | http://www.apache.org/licenses/LICENSE-2.0 12 | 13 | Unless required by applicable law or agreed to in writing, software 14 | distributed under the License is distributed on an "AS IS" BASIS, 15 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | See the License for the specific language governing permissions and 17 | limitations under the License. 18 | """ 19 | __all__ = ["HAVE_SSL", "ssl", "SSLError", "SSLWantReadError", "SSLWantWriteError"] 20 | 21 | try: 22 | import ssl 23 | from ssl import SSLError 24 | from ssl import SSLWantReadError 25 | from ssl import SSLWantWriteError 26 | HAVE_SSL = True 27 | except ImportError: 28 | # dummy class of SSLError for environment without ssl support 29 | class SSLError(Exception): 30 | pass 31 | 32 | class SSLWantReadError(Exception): 33 | pass 34 | 35 | class SSLWantWriteError(Exception): 36 | pass 37 | 38 | ssl = None 39 | HAVE_SSL = False 40 | -------------------------------------------------------------------------------- /defaults/py_backend/webSocketClient/_url.py: -------------------------------------------------------------------------------- 1 | import os 2 | import socket 3 | import struct 4 | 5 | from urllib.parse import unquote, urlparse 6 | 7 | """ 8 | _url.py 9 | websocket - WebSocket client library for Python 10 | 11 | Copyright 2022 engn33r 12 | 13 | Licensed under the Apache License, Version 2.0 (the "License"); 14 | you may not use this file except in compliance with the License. 15 | You may obtain a copy of the License at 16 | 17 | http://www.apache.org/licenses/LICENSE-2.0 18 | 19 | Unless required by applicable law or agreed to in writing, software 20 | distributed under the License is distributed on an "AS IS" BASIS, 21 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 22 | See the License for the specific language governing permissions and 23 | limitations under the License. 24 | """ 25 | 26 | __all__ = ["parse_url", "get_proxy_info"] 27 | 28 | 29 | def parse_url(url): 30 | """ 31 | parse url and the result is tuple of 32 | (hostname, port, resource path and the flag of secure mode) 33 | 34 | Parameters 35 | ---------- 36 | url: str 37 | url string. 38 | """ 39 | if ":" not in url: 40 | raise ValueError("url is invalid") 41 | 42 | scheme, url = url.split(":", 1) 43 | 44 | parsed = urlparse(url, scheme="http") 45 | if parsed.hostname: 46 | hostname = parsed.hostname 47 | else: 48 | raise ValueError("hostname is invalid") 49 | port = 0 50 | if parsed.port: 51 | port = parsed.port 52 | 53 | is_secure = False 54 | if scheme == "ws": 55 | if not port: 56 | port = 80 57 | elif scheme == "wss": 58 | is_secure = True 59 | if not port: 60 | port = 443 61 | else: 62 | raise ValueError("scheme %s is invalid" % scheme) 63 | 64 | if parsed.path: 65 | resource = parsed.path 66 | else: 67 | resource = "/" 68 | 69 | if parsed.query: 70 | resource += "?" + parsed.query 71 | 72 | return hostname, port, resource, is_secure 73 | 74 | 75 | DEFAULT_NO_PROXY_HOST = ["localhost", "127.0.0.1"] 76 | 77 | 78 | def _is_ip_address(addr): 79 | try: 80 | socket.inet_aton(addr) 81 | except socket.error: 82 | return False 83 | else: 84 | return True 85 | 86 | 87 | def _is_subnet_address(hostname): 88 | try: 89 | addr, netmask = hostname.split("/") 90 | return _is_ip_address(addr) and 0 <= int(netmask) < 32 91 | except ValueError: 92 | return False 93 | 94 | 95 | def _is_address_in_network(ip, net): 96 | ipaddr = struct.unpack('!I', socket.inet_aton(ip))[0] 97 | netaddr, netmask = net.split('/') 98 | netaddr = struct.unpack('!I', socket.inet_aton(netaddr))[0] 99 | 100 | netmask = (0xFFFFFFFF << (32 - int(netmask))) & 0xFFFFFFFF 101 | return ipaddr & netmask == netaddr 102 | 103 | 104 | def _is_no_proxy_host(hostname, no_proxy): 105 | if not no_proxy: 106 | v = os.environ.get("no_proxy", os.environ.get("NO_PROXY", "")).replace(" ", "") 107 | if v: 108 | no_proxy = v.split(",") 109 | if not no_proxy: 110 | no_proxy = DEFAULT_NO_PROXY_HOST 111 | 112 | if '*' in no_proxy: 113 | return True 114 | if hostname in no_proxy: 115 | return True 116 | if _is_ip_address(hostname): 117 | return any([_is_address_in_network(hostname, subnet) for subnet in no_proxy if _is_subnet_address(subnet)]) 118 | for domain in [domain for domain in no_proxy if domain.startswith('.')]: 119 | if hostname.endswith(domain): 120 | return True 121 | return False 122 | 123 | 124 | def get_proxy_info( 125 | hostname, is_secure, proxy_host=None, proxy_port=0, proxy_auth=None, 126 | no_proxy=None, proxy_type='http'): 127 | """ 128 | Try to retrieve proxy host and port from environment 129 | if not provided in options. 130 | Result is (proxy_host, proxy_port, proxy_auth). 131 | proxy_auth is tuple of username and password 132 | of proxy authentication information. 133 | 134 | Parameters 135 | ---------- 136 | hostname: str 137 | Websocket server name. 138 | is_secure: bool 139 | Is the connection secure? (wss) looks for "https_proxy" in env 140 | before falling back to "http_proxy" 141 | proxy_host: str 142 | http proxy host name. 143 | http_proxy_port: str or int 144 | http proxy port. 145 | http_no_proxy: list 146 | Whitelisted host names that don't use the proxy. 147 | http_proxy_auth: tuple 148 | HTTP proxy auth information. Tuple of username and password. Default is None. 149 | proxy_type: str 150 | Specify the proxy protocol (http, socks4, socks4a, socks5, socks5h). Default is "http". 151 | Use socks4a or socks5h if you want to send DNS requests through the proxy. 152 | """ 153 | if _is_no_proxy_host(hostname, no_proxy): 154 | return None, 0, None 155 | 156 | if proxy_host: 157 | port = proxy_port 158 | auth = proxy_auth 159 | return proxy_host, port, auth 160 | 161 | env_keys = ["http_proxy"] 162 | if is_secure: 163 | env_keys.insert(0, "https_proxy") 164 | 165 | for key in env_keys: 166 | value = os.environ.get(key, os.environ.get(key.upper(), "")).replace(" ", "") 167 | if value: 168 | proxy = urlparse(value) 169 | auth = (unquote(proxy.username), unquote(proxy.password)) if proxy.username else None 170 | return proxy.hostname, proxy.port, auth 171 | 172 | return None, 0, None 173 | -------------------------------------------------------------------------------- /defaults/py_backend/webSocketClient/_utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | _url.py 3 | websocket - WebSocket client library for Python 4 | 5 | Copyright 2022 engn33r 6 | 7 | Licensed under the Apache License, Version 2.0 (the "License"); 8 | you may not use this file except in compliance with the License. 9 | You may obtain a copy of the License at 10 | 11 | http://www.apache.org/licenses/LICENSE-2.0 12 | 13 | Unless required by applicable law or agreed to in writing, software 14 | distributed under the License is distributed on an "AS IS" BASIS, 15 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | See the License for the specific language governing permissions and 17 | limitations under the License. 18 | """ 19 | __all__ = ["NoLock", "validate_utf8", "extract_err_message", "extract_error_code"] 20 | 21 | 22 | class NoLock: 23 | 24 | def __enter__(self): 25 | pass 26 | 27 | def __exit__(self, exc_type, exc_value, traceback): 28 | pass 29 | 30 | 31 | try: 32 | # If wsaccel is available we use compiled routines to validate UTF-8 33 | # strings. 34 | from wsaccel.utf8validator import Utf8Validator 35 | 36 | def _validate_utf8(utfbytes): 37 | return Utf8Validator().validate(utfbytes)[0] 38 | 39 | except ImportError: 40 | # UTF-8 validator 41 | # python implementation of http://bjoern.hoehrmann.de/utf-8/decoder/dfa/ 42 | 43 | _UTF8_ACCEPT = 0 44 | _UTF8_REJECT = 12 45 | 46 | _UTF8D = [ 47 | # The first part of the table maps bytes to character classes that 48 | # to reduce the size of the transition table and create bitmasks. 49 | 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 50 | 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 51 | 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 52 | 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 53 | 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1, 9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9, 54 | 7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7, 7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7, 55 | 8,8,2,2,2,2,2,2,2,2,2,2,2,2,2,2, 2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2, 56 | 10,3,3,3,3,3,3,3,3,3,3,3,3,4,3,3, 11,6,6,6,5,8,8,8,8,8,8,8,8,8,8,8, 57 | 58 | # The second part is a transition table that maps a combination 59 | # of a state of the automaton and a character class to a state. 60 | 0,12,24,36,60,96,84,12,12,12,48,72, 12,12,12,12,12,12,12,12,12,12,12,12, 61 | 12, 0,12,12,12,12,12, 0,12, 0,12,12, 12,24,12,12,12,12,12,24,12,24,12,12, 62 | 12,12,12,12,12,12,12,24,12,12,12,12, 12,24,12,12,12,12,12,12,12,24,12,12, 63 | 12,12,12,12,12,12,12,36,12,36,12,12, 12,36,12,12,12,12,12,36,12,36,12,12, 64 | 12,36,12,12,12,12,12,12,12,12,12,12, ] 65 | 66 | def _decode(state, codep, ch): 67 | tp = _UTF8D[ch] 68 | 69 | codep = (ch & 0x3f) | (codep << 6) if ( 70 | state != _UTF8_ACCEPT) else (0xff >> tp) & ch 71 | state = _UTF8D[256 + state + tp] 72 | 73 | return state, codep 74 | 75 | def _validate_utf8(utfbytes): 76 | state = _UTF8_ACCEPT 77 | codep = 0 78 | for i in utfbytes: 79 | state, codep = _decode(state, codep, i) 80 | if state == _UTF8_REJECT: 81 | return False 82 | 83 | return True 84 | 85 | 86 | def validate_utf8(utfbytes): 87 | """ 88 | validate utf8 byte string. 89 | utfbytes: utf byte string to check. 90 | return value: if valid utf8 string, return true. Otherwise, return false. 91 | """ 92 | return _validate_utf8(utfbytes) 93 | 94 | 95 | def extract_err_message(exception): 96 | if exception.args: 97 | return exception.args[0] 98 | else: 99 | return None 100 | 101 | 102 | def extract_error_code(exception): 103 | if exception.args and len(exception.args) > 1: 104 | return exception.args[0] if isinstance(exception.args[0], int) else None 105 | -------------------------------------------------------------------------------- /defaults/py_backend/webSocketClient/_wsdump.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | wsdump.py 5 | websocket - WebSocket client library for Python 6 | 7 | Copyright 2022 engn33r 8 | 9 | Licensed under the Apache License, Version 2.0 (the "License"); 10 | you may not use this file except in compliance with the License. 11 | You may obtain a copy of the License at 12 | 13 | http://www.apache.org/licenses/LICENSE-2.0 14 | 15 | Unless required by applicable law or agreed to in writing, software 16 | distributed under the License is distributed on an "AS IS" BASIS, 17 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 18 | See the License for the specific language governing permissions and 19 | limitations under the License. 20 | """ 21 | 22 | import argparse 23 | import code 24 | import sys 25 | import threading 26 | import time 27 | import ssl 28 | import gzip 29 | import zlib 30 | from urllib.parse import urlparse 31 | 32 | import websocket 33 | 34 | try: 35 | import readline 36 | except ImportError: 37 | pass 38 | 39 | 40 | def get_encoding(): 41 | encoding = getattr(sys.stdin, "encoding", "") 42 | if not encoding: 43 | return "utf-8" 44 | else: 45 | return encoding.lower() 46 | 47 | 48 | OPCODE_DATA = (websocket.ABNF.OPCODE_TEXT, websocket.ABNF.OPCODE_BINARY) 49 | ENCODING = get_encoding() 50 | 51 | 52 | class VAction(argparse.Action): 53 | 54 | def __call__(self, parser, args, values, option_string=None): 55 | if values is None: 56 | values = "1" 57 | try: 58 | values = int(values) 59 | except ValueError: 60 | values = values.count("v") + 1 61 | setattr(args, self.dest, values) 62 | 63 | 64 | def parse_args(): 65 | parser = argparse.ArgumentParser(description="WebSocket Simple Dump Tool") 66 | parser.add_argument("url", metavar="ws_url", 67 | help="websocket url. ex. ws://echo.websocket.events/") 68 | parser.add_argument("-p", "--proxy", 69 | help="proxy url. ex. http://127.0.0.1:8080") 70 | parser.add_argument("-v", "--verbose", default=0, nargs='?', action=VAction, 71 | dest="verbose", 72 | help="set verbose mode. If set to 1, show opcode. " 73 | "If set to 2, enable to trace websocket module") 74 | parser.add_argument("-n", "--nocert", action='store_true', 75 | help="Ignore invalid SSL cert") 76 | parser.add_argument("-r", "--raw", action="store_true", 77 | help="raw output") 78 | parser.add_argument("-s", "--subprotocols", nargs='*', 79 | help="Set subprotocols") 80 | parser.add_argument("-o", "--origin", 81 | help="Set origin") 82 | parser.add_argument("--eof-wait", default=0, type=int, 83 | help="wait time(second) after 'EOF' received.") 84 | parser.add_argument("-t", "--text", 85 | help="Send initial text") 86 | parser.add_argument("--timings", action="store_true", 87 | help="Print timings in seconds") 88 | parser.add_argument("--headers", 89 | help="Set custom headers. Use ',' as separator") 90 | 91 | return parser.parse_args() 92 | 93 | 94 | class RawInput: 95 | 96 | def raw_input(self, prompt): 97 | line = input(prompt) 98 | 99 | if ENCODING and ENCODING != "utf-8" and not isinstance(line, str): 100 | line = line.decode(ENCODING).encode("utf-8") 101 | elif isinstance(line, str): 102 | line = line.encode("utf-8") 103 | 104 | return line 105 | 106 | 107 | class InteractiveConsole(RawInput, code.InteractiveConsole): 108 | 109 | def write(self, data): 110 | sys.stdout.write("\033[2K\033[E") 111 | # sys.stdout.write("\n") 112 | sys.stdout.write("\033[34m< " + data + "\033[39m") 113 | sys.stdout.write("\n> ") 114 | sys.stdout.flush() 115 | 116 | def read(self): 117 | return self.raw_input("> ") 118 | 119 | 120 | class NonInteractive(RawInput): 121 | 122 | def write(self, data): 123 | sys.stdout.write(data) 124 | sys.stdout.write("\n") 125 | sys.stdout.flush() 126 | 127 | def read(self): 128 | return self.raw_input("") 129 | 130 | 131 | def main(): 132 | start_time = time.time() 133 | args = parse_args() 134 | if args.verbose > 1: 135 | websocket.enableTrace(True) 136 | options = {} 137 | if args.proxy: 138 | p = urlparse(args.proxy) 139 | options["http_proxy_host"] = p.hostname 140 | options["http_proxy_port"] = p.port 141 | if args.origin: 142 | options["origin"] = args.origin 143 | if args.subprotocols: 144 | options["subprotocols"] = args.subprotocols 145 | opts = {} 146 | if args.nocert: 147 | opts = {"cert_reqs": ssl.CERT_NONE, "check_hostname": False} 148 | if args.headers: 149 | options['header'] = list(map(str.strip, args.headers.split(','))) 150 | ws = websocket.create_connection(args.url, sslopt=opts, **options) 151 | if args.raw: 152 | console = NonInteractive() 153 | else: 154 | console = InteractiveConsole() 155 | print("Press Ctrl+C to quit") 156 | 157 | def recv(): 158 | try: 159 | frame = ws.recv_frame() 160 | except websocket.WebSocketException: 161 | return websocket.ABNF.OPCODE_CLOSE, None 162 | if not frame: 163 | raise websocket.WebSocketException("Not a valid frame %s" % frame) 164 | elif frame.opcode in OPCODE_DATA: 165 | return frame.opcode, frame.data 166 | elif frame.opcode == websocket.ABNF.OPCODE_CLOSE: 167 | ws.send_close() 168 | return frame.opcode, None 169 | elif frame.opcode == websocket.ABNF.OPCODE_PING: 170 | ws.pong(frame.data) 171 | return frame.opcode, frame.data 172 | 173 | return frame.opcode, frame.data 174 | 175 | def recv_ws(): 176 | while True: 177 | opcode, data = recv() 178 | msg = None 179 | if opcode == websocket.ABNF.OPCODE_TEXT and isinstance(data, bytes): 180 | data = str(data, "utf-8") 181 | if isinstance(data, bytes) and len(data) > 2 and data[:2] == b'\037\213': # gzip magick 182 | try: 183 | data = "[gzip] " + str(gzip.decompress(data), "utf-8") 184 | except: 185 | pass 186 | elif isinstance(data, bytes): 187 | try: 188 | data = "[zlib] " + str(zlib.decompress(data, -zlib.MAX_WBITS), "utf-8") 189 | except: 190 | pass 191 | 192 | if isinstance(data, bytes): 193 | data = repr(data) 194 | 195 | if args.verbose: 196 | msg = "%s: %s" % (websocket.ABNF.OPCODE_MAP.get(opcode), data) 197 | else: 198 | msg = data 199 | 200 | if msg is not None: 201 | if args.timings: 202 | console.write(str(time.time() - start_time) + ": " + msg) 203 | else: 204 | console.write(msg) 205 | 206 | if opcode == websocket.ABNF.OPCODE_CLOSE: 207 | break 208 | 209 | thread = threading.Thread(target=recv_ws) 210 | thread.daemon = True 211 | thread.start() 212 | 213 | if args.text: 214 | ws.send(args.text) 215 | 216 | while True: 217 | try: 218 | message = console.read() 219 | ws.send(message) 220 | except KeyboardInterrupt: 221 | return 222 | except EOFError: 223 | time.sleep(args.eof_wait) 224 | return 225 | 226 | 227 | if __name__ == "__main__": 228 | try: 229 | main() 230 | except Exception as e: 231 | print(e) 232 | -------------------------------------------------------------------------------- /defaults/shortcutsRunner.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | eval "$@" 4 | 5 | preppedWindowDimensions=$(xrandr --current | grep -oP '/(?<=(current )).*(?=[\,])/i') 6 | IFS=' ' read -r -a windowDimensions <<< $preppedWindowDimensions 7 | SCREEN_WIDTH="${windowDimensions[0]}" 8 | SCREEN_HEIGHT="${windowDimensions[2]}" 9 | 10 | xdotool windowsize :ACTIVE: $SCREEN_WIDTH $SCREEN_HEIGHT -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "[TASK]: Loading config..." 4 | 5 | unclean_output=$(<.vscode/settings.json) 6 | prepped_output="${unclean_output//[\s\{\}\" ]/""}" 7 | 8 | IFS=',:' read -r -a tmps <<< $prepped_output 9 | 10 | deck_ip="${tmps[1]}" 11 | deck_port="${tmps[3]}" 12 | deck_home_dir="${tmps[5]}/Desktop/dev-plugins/Shortcuts" 13 | 14 | echo "[INFO]: Loaded config" 15 | echo "" 16 | echo "[TASK]: Deploying plugin to deck..." 17 | 18 | function scpDirRecursive() { 19 | # $1 from dir 20 | # $2 to dir 21 | files=($(ls $1)) 22 | 23 | if ssh -q deck@$deck_ip "[ -d $2 ]"; then 24 | for file in "${files[@]}"; do 25 | if [ -d "$1/$file" ]; then 26 | scpDirRecursive "$1/$file" "$2/$file" 27 | else 28 | diff=$(ssh deck@$deck_ip "cat $2/$file" | diff - $1/$file) 29 | 30 | if [ "$diff" != "" ]; then 31 | scp -P $deck_port $1/$file deck@$deck_ip:$2/$file 32 | echo "[INFO]: Copied $1/$file to $2/$file" 33 | else 34 | echo "[INFO]: Skipping $1/$file. No changes detected." 35 | fi 36 | fi 37 | done 38 | else 39 | scp -r -P $deck_port $1 deck@$deck_ip:$2 40 | echo "[INFO]: Copied $1 to $2" 41 | fi 42 | } 43 | 44 | #? Copy general files 45 | echo "[TASK]: Copying general files..." 46 | genFiles=(LICENSE main.py package.json plugin.json README.md) 47 | 48 | for genFile in "${genFiles[@]}"; do 49 | diff=$(ssh deck@$deck_ip "cat $deck_home_dir/$genFile" | diff - $genFile) 50 | 51 | if [ "$diff" != "" ]; then 52 | scp -P $deck_port $genFile deck@$deck_ip:$deck_home_dir/$genFile 53 | echo "[INFO]: Copied ./$genFile to $deck_home_dir/$genFile" 54 | else 55 | echo "[INFO]: Skipping $genFile. No changes detected." 56 | fi 57 | done 58 | 59 | #? Copy frontend 60 | echo "[TASK]: Copying frontend..." 61 | scpDirRecursive "./dist" "$deck_home_dir/dist" 62 | 63 | #? Copy default files 64 | echo "[TASK]: Copying defaults..." 65 | scpDirRecursive "./defaults" "$deck_home_dir" 66 | 67 | echo "[DONE]" -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import json 3 | import os 4 | import sys 5 | 6 | sys.path.append(os.path.dirname(__file__)) 7 | 8 | from py_backend.instanceManager import InstanceManager 9 | from py_backend.jsInterop import JsInteropManager 10 | from settings import SettingsManager 11 | from py_backend.logger import log 12 | 13 | Initialized = False 14 | 15 | class Plugin: 16 | pluginUser = os.environ["DECKY_USER"] 17 | pluginSettingsDir = os.environ["DECKY_PLUGIN_SETTINGS_DIR"] 18 | 19 | oldShortcutsPath = f"/home/{pluginUser}/.config/bash-shortcuts/shortcuts.json" 20 | 21 | shortcutsRunnerPath = f"\"/home/{pluginUser}/homebrew/plugins/bash-shortcuts/shortcutsRunner.sh\"" 22 | guidesDirPath = f"/home/{pluginUser}/homebrew/plugins/bash-shortcuts/guides" 23 | 24 | settingsManager = SettingsManager(name='bash-shortcuts', settings_directory=pluginSettingsDir) 25 | 26 | guides = {} 27 | 28 | # Normal methods: can be called from JavaScript using call_plugin_function("signature", argument) 29 | async def getShortcuts(self): 30 | shortcuts = self.settingsManager.getSetting("shortcuts", {}) 31 | 32 | needToSet = False 33 | 34 | for key in shortcuts.keys(): 35 | if not "hooks" in shortcuts[key]: 36 | shortcuts[key]["hooks"] = [] 37 | needToSet = True 38 | if not "passFlags" in shortcuts[key]: 39 | shortcuts[key]["passFlags"] = False 40 | needToSet = True 41 | 42 | if needToSet: 43 | self.settingsManager.setSetting("shortcuts", shortcuts) 44 | 45 | return shortcuts 46 | 47 | async def getGuides(self): 48 | self._getGuides(self) 49 | return self.guides 50 | 51 | async def getSetting(self, key, defaultVal): 52 | return self.settingsManager.getSetting(key, defaultVal) 53 | 54 | async def setSetting(self, key, newVal): 55 | self.settingsManager.setSetting(key, newVal) 56 | log(f"Set setting {key} to {newVal}") 57 | pass 58 | 59 | async def addShortcut(self, shortcut): 60 | self._addShortcut(self, shortcut) 61 | return self.settingsManager.getSetting("shortcuts", {}) 62 | 63 | async def setShortcuts(self, shortcuts): 64 | self._setShortcuts(self, shortcuts) 65 | return self.settingsManager.getSetting("shortcuts", {}) 66 | 67 | async def modShortcut(self, shortcut): 68 | self._modShortcut(self, shortcut) 69 | return self.settingsManager.getSetting("shortcuts", {}) 70 | 71 | async def remShortcut(self, shortcut): 72 | self._remShortcut(self, shortcut) 73 | return self.settingsManager.getSetting("shortcuts", {}) 74 | 75 | async def runNonAppShortcut(self, shortcutId, flags): 76 | self._runNonAppShortcut(self, shortcutId, flags) 77 | 78 | async def killNonAppShortcut(self, shortcutId): 79 | self._killNonAppShortcut(self, shortcutId) 80 | 81 | async def getHomeDir(self): 82 | return self.pluginUser 83 | 84 | async def logMessage(self, message): 85 | log(message) 86 | 87 | # Asyncio-compatible long-running code, executed in a task when the plugin is loaded 88 | async def _main(self): 89 | global Initialized 90 | if Initialized: 91 | return 92 | 93 | Initialized = True 94 | 95 | log("Initializing Shorcuts Plugin") 96 | 97 | self.settingsManager.read() 98 | 99 | if "shortcuts" not in self.settingsManager.settings: 100 | log("No shortcuts detected in settings.") 101 | if (os.path.exists(self.oldShortcutsPath)): 102 | log("Converting old shortcuts.") 103 | try: 104 | with open(self.oldShortcutsPath, "r") as file: 105 | shortcutsDict = json.load(file) 106 | log(f"Got shortcuts from old shortcuts.json. Shortcuts: {json.dumps(shortcutsDict)}") 107 | self.settingsManager.setSetting("shortcuts", shortcutsDict) 108 | 109 | except Exception as e: 110 | log(f"Exception while parsing shortcuts: {e}") # error reading json 111 | else: 112 | log("Adding default shortcut.") 113 | self.settingsManager.setSetting("shortcuts", { 114 | "fcba1cb4-4601-45d8-b919-515d152c56ef": { 115 | "id": "fcba1cb4-4601-45d8-b919-515d152c56ef", 116 | "name": "Konsole", 117 | "cmd": "LD_PRELOAD= QT_SCALE_FACTOR=1.25 konsole", 118 | "position": 1, 119 | "isApp": True, 120 | "hooks": [] 121 | } 122 | }) 123 | else: 124 | log(f"Shortcuts loaded from settings. Shortcuts: {json.dumps(self.settingsManager.getSetting('shortcuts', {}))}") 125 | 126 | if "webSocketPort" not in self.settingsManager.settings: 127 | log("No WebSocket port detected in settings.") 128 | self.settingsManager.setSetting("webSocketPort", "5000") 129 | log("Set WebSocket port to default; \"5000\"") 130 | else: 131 | log(f"WebSocket port loaded from settings. Port: {self.settingsManager.getSetting('webSocketPort', '')}") 132 | 133 | self.jsInteropManager = JsInteropManager("localhost", self.settingsManager.getSetting("webSocketPort", "5000")) 134 | self.instanceManager = InstanceManager(0.25, self.jsInteropManager) 135 | 136 | #* start websocket server 137 | self.jsInteropManager.startServer() 138 | 139 | async def _unload(self): 140 | self.jsInteropManager.stopServer() 141 | log("Plugin unloaded") 142 | pass 143 | 144 | def _addShortcut(self, shortcut): 145 | if (shortcut["id"] not in self.settingsManager.getSetting("shortcuts", {})): 146 | log(f"Adding shortcut {shortcut['name']}") 147 | shortcutsDict = self.settingsManager.getSetting("shortcuts", {}) 148 | shortcutsDict[shortcut["id"]] = shortcut 149 | 150 | self.settingsManager.setSetting("shortcuts", shortcutsDict) 151 | else: 152 | log(f"Shortcut {shortcut['name']} already exists") 153 | 154 | pass 155 | 156 | def _setShortcuts(self, shortcuts): 157 | log(f"Setting shortcuts...") 158 | self.settingsManager.setSetting("shortcuts", shortcuts) 159 | 160 | pass 161 | 162 | def _modShortcut(self, shortcut): 163 | if (shortcut["id"] in self.settingsManager.getSetting("shortcuts", {})): 164 | log(f"Modifying shortcut {shortcut['name']}") 165 | log(f"JSON: {json.dumps(shortcut)}") 166 | shortcutsDict = self.settingsManager.getSetting("shortcuts", {}) 167 | shortcutsDict[shortcut["id"]] = shortcut 168 | 169 | self.settingsManager.setSetting("shortcuts", shortcutsDict) 170 | else: 171 | log(f"Shortcut {shortcut['name']} does not exist") 172 | 173 | pass 174 | 175 | def _remShortcut(self, shortcut): 176 | if (shortcut["id"] in self.settingsManager.getSetting("shortcuts", {})): 177 | log(f"Removing shortcut {shortcut['name']}") 178 | shortcutsDict = self.settingsManager.getSetting("shortcuts", {}) 179 | del shortcutsDict[shortcut["id"]] 180 | 181 | self.settingsManager.setSetting("shortcuts", shortcutsDict) 182 | else: 183 | log(f"Shortcut {shortcut['name']} does not exist") 184 | 185 | pass 186 | 187 | def _getGuides(self): 188 | for guideFileName in os.listdir(self.guidesDirPath): 189 | with open(os.path.join(self.guidesDirPath, guideFileName), 'r') as guideFile: 190 | guideName = guideFileName.replace("_", " ").replace(".md", "") 191 | self.guides[guideName] = "".join(guideFile.readlines()) 192 | 193 | pass 194 | 195 | def _runNonAppShortcut(self, shortcutId, flags): 196 | shortcut = self.settingsManager.getSetting("shortcuts", {})[shortcutId] 197 | 198 | log(f"Running createInstance for shortcut with Id: {shortcutId} and Flags: {json.dumps(flags)}") 199 | 200 | self.instanceManager.createInstance(shortcut, flags) 201 | 202 | def _killNonAppShortcut(self, shortcutId): 203 | log(f"Running killInstance for shortcut with Id: {shortcutId}") 204 | self.instanceManager.killInstance(self.settingsManager.getSetting("shortcuts", {})[shortcutId]) 205 | 206 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bash-shortcuts", 3 | "version": "2.0.4", 4 | "description": "A plugin for creating and managing shortcuts that can be launched from the Quick Access Menu. Uses bash to run shortcuts, hence the name.", 5 | "scripts": { 6 | "build": "shx rm -rf dist && rollup -c", 7 | "watch": "rollup -c -w", 8 | "test": "echo \"Error: no test specified\" && exit 1", 9 | "createDist": "rsync -r --exclude \"src/\" --exclude \"__pycache__\" --exclude \"node_modules\" /plugin/ /out/" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/Tormak9970/bash-shortcuts.git" 14 | }, 15 | "keywords": [ 16 | "decky", 17 | "plugin", 18 | "steam-deck", 19 | "deck", 20 | "QoL", 21 | "shortcuts" 22 | ], 23 | "author": { 24 | "name": "Tormak", 25 | "email": "Tormak9970@gmail.com" 26 | }, 27 | "contributors": [ 28 | { 29 | "name": "JediRhymeTrix", 30 | "url": "https://github.com/JediRhymeTrix" 31 | } 32 | ], 33 | "license": "GPL-2.0-or-later", 34 | "bugs": { 35 | "url": "https://github.com/Tormak9970/bash-shortcuts/issues" 36 | }, 37 | "homepage": "https://github.com/Tormak9970/bash-shortcuts#readme", 38 | "devDependencies": { 39 | "@rollup/plugin-commonjs": "^21.1.0", 40 | "@rollup/plugin-json": "^4.1.0", 41 | "@rollup/plugin-node-resolve": "^13.3.0", 42 | "@rollup/plugin-replace": "^4.0.0", 43 | "@rollup/plugin-typescript": "^8.5.0", 44 | "@types/markdown-it": "^12.2.3", 45 | "@types/marked": "^4.3.2", 46 | "@types/react": "16.14.0", 47 | "@types/uuid": "^8.3.4", 48 | "@types/webpack": "^5.28.5", 49 | "markdown-it": "^13.0.2", 50 | "rollup": "^2.79.1", 51 | "rollup-plugin-import-assets": "^1.1.1", 52 | "shx": "^0.3.4", 53 | "tslib": "^2.6.2", 54 | "typescript": "^4.9.5" 55 | }, 56 | "dependencies": { 57 | "decky-frontend-lib": "^3.25.0", 58 | "react-icons": "^4.12.0", 59 | "uuid": "^9.0.1" 60 | }, 61 | "pnpm": { 62 | "peerDependencyRules": { 63 | "ignoreMissing": [ 64 | "react", 65 | "react-dom" 66 | ] 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Bash Shortcuts", 3 | "author": "Tormak & JediRhymeTrix", 4 | "flags": [], 5 | "publish": { 6 | "tags": ["shortcuts", "convenience", "scripting"], 7 | "description": "Manager for shortcuts in the Quick Access Menu! Uses Bash under the hood.", 8 | "image": "https://raw.githubusercontent.com/Tormak9970/bash-shortcuts/main/assets/thumbnail.png" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import commonjs from '@rollup/plugin-commonjs'; 2 | import json from '@rollup/plugin-json'; 3 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 4 | import replace from '@rollup/plugin-replace'; 5 | import typescript from '@rollup/plugin-typescript'; 6 | import { defineConfig } from 'rollup'; 7 | import importAssets from 'rollup-plugin-import-assets'; 8 | 9 | import { name } from "./plugin.json"; 10 | 11 | export default defineConfig({ 12 | input: './src/index.tsx', 13 | plugins: [ 14 | commonjs(), 15 | nodeResolve({ preferBuiltins: false }), 16 | typescript(), 17 | json(), 18 | replace({ 19 | preventAssignment: false, 20 | 'process.env.NODE_ENV': JSON.stringify('production'), 21 | }), 22 | importAssets({ 23 | publicPath: `http://127.0.0.1:1337/plugins/${name}/` 24 | }) 25 | ], 26 | context: 'window', 27 | external: ['react', 'react-dom'], 28 | output: { 29 | file: 'dist/index.js', 30 | globals: { 31 | react: 'SP_REACT', 32 | 'react-dom': 'SP_REACTDOM', 33 | }, 34 | format: 'iife', 35 | exports: 'default', 36 | }, 37 | }); 38 | -------------------------------------------------------------------------------- /setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "[INFO]: Running Setup..." 4 | 5 | if [ -d "./py_backend" ]; then 6 | if [ ! -L "./py_backend" ]; then 7 | echo "[INFO]: py_backend is not a symlink!" 8 | echo "" 9 | echo "[TASK]: removing py_backend..." 10 | 11 | rm -r "./py_backend" 12 | 13 | echo "[INFO]: removed py_backend" 14 | echo "" 15 | echo "[TASK]: creating symlink..." 16 | 17 | ln -s ./defaults/py_backend ./py_backend 18 | 19 | echo "[INFO]: py_backend symlink created!" 20 | else 21 | echo "[INFO]: py_backend already a symlink!" 22 | fi 23 | else 24 | echo "[INFO]: py_backend does not exist!" 25 | echo "" 26 | echo "[TASK]: creating symlink..." 27 | 28 | ln -s ./defaults/py_backend ./py_backend 29 | 30 | echo "[INFO]: py_backend symlink created!" 31 | fi 32 | 33 | echo "[DONE]" -------------------------------------------------------------------------------- /src/PyInterop.ts: -------------------------------------------------------------------------------- 1 | import { ServerAPI, ServerResponse } from "decky-frontend-lib"; 2 | import { Shortcut } from "./lib/data-structures/Shortcut"; 3 | 4 | type ShortcutsDictionary = { 5 | [key: string]: Shortcut 6 | } 7 | 8 | /** 9 | * Class for frontend - backend communication. 10 | */ 11 | export class PyInterop { 12 | private static serverAPI: ServerAPI; 13 | 14 | /** 15 | * Sets the interop's severAPI. 16 | * @param serv The ServerAPI for the interop to use. 17 | */ 18 | static setServer(serv: ServerAPI): void { 19 | this.serverAPI = serv; 20 | } 21 | 22 | /** 23 | * Gets the interop's serverAPI. 24 | */ 25 | static get server(): ServerAPI { return this.serverAPI; } 26 | 27 | /** 28 | * Logs a message to bash shortcut's log file and the frontend console. 29 | * @param message The message to log. 30 | */ 31 | static async log(message: String): Promise { 32 | console.log(message); 33 | await this.serverAPI.callPluginMethod<{ message: String }, boolean>("logMessage", { message: `[front-end]: ${message}` }); 34 | } 35 | 36 | /** 37 | * Gets a user's home directory. 38 | * @returns A promise resolving to a server response containing the user's home directory. 39 | */ 40 | static async getHomeDir(): Promise> { 41 | const res = await this.serverAPI.callPluginMethod<{}, string>("getHomeDir", {}); 42 | return res; 43 | } 44 | 45 | /** 46 | * Shows a toast message. 47 | * @param title The title of the toast. 48 | * @param message The message of the toast. 49 | */ 50 | static toast(title: string, message: string): void { 51 | return (() => { 52 | try { 53 | return this.serverAPI.toaster.toast({ 54 | title: title, 55 | body: message, 56 | duration: 8000, 57 | }); 58 | } catch (e) { 59 | console.log("Toaster Error", e); 60 | } 61 | })(); 62 | } 63 | 64 | /** 65 | * Gets the shortcuts from the backend. 66 | * @returns A promise resolving to a server response containing the shortcuts dictionary. 67 | */ 68 | static async getShortcuts(): Promise> { 69 | return await this.serverAPI.callPluginMethod<{}, ShortcutsDictionary>("getShortcuts", {}); 70 | } 71 | 72 | /** 73 | * Gets the plugin's guides. 74 | * @returns The guides. 75 | */ 76 | static async getGuides(): Promise> { 77 | return await this.serverAPI.callPluginMethod<{}, GuidePages>("getGuides", {}); 78 | } 79 | 80 | /** 81 | * Gets the value of a plugin's setting. 82 | * @param key The key of the setting to get. 83 | * @param defaultVal The default value of the setting. 84 | * @returns A promise resolving to the setting's value. 85 | */ 86 | static async getSetting(key: string, defaultVal: T): Promise { 87 | return (await this.serverAPI.callPluginMethod<{ key: string, defaultVal: T }, T>("getSetting", { key: key, defaultVal: defaultVal })).result as T; 88 | } 89 | 90 | /** 91 | * Sets the value of a plugin's setting. 92 | * @param key The key of the setting to set. 93 | * @param newVal The new value for the setting. 94 | * @returns A void promise resolving once the setting is set. 95 | */ 96 | static async setSetting(key: string, newVal: T): Promise> { 97 | return await this.serverAPI.callPluginMethod<{ key: string, newVal : T}, void>("setSetting", { key: key, newVal: newVal }); 98 | } 99 | 100 | /** 101 | * Adds a new shortcut on the backend and returns the updated shortcuts dictionary. 102 | * @param shortcut The shortcut to add. 103 | * @returns A promise resolving to a server response containing the updated shortcuts dictionary. 104 | */ 105 | static async addShortcut(shortcut: Shortcut): Promise> { 106 | return await this.serverAPI.callPluginMethod<{ shortcut: Shortcut }, ShortcutsDictionary>("addShortcut", { shortcut: shortcut }); 107 | } 108 | 109 | /** 110 | * Sets the entire shortcuts dictionary, and returns the updated dictionary. 111 | * @param shortcuts The updated shortcuts dictionary. 112 | * @returns A promise resolving to a server response containing the updated shortcuts dictionary. 113 | */ 114 | static async setShortcuts(shortcuts: ShortcutsDictionary): Promise> { 115 | return await this.serverAPI.callPluginMethod<{ shortcuts: ShortcutsDictionary }, ShortcutsDictionary>("setShortcuts", { shortcuts: shortcuts }); 116 | } 117 | 118 | /** 119 | * Updates/edits a shortcut on the backend, and returns the updated dictionary. 120 | * @param shortcut The shortcut to update. 121 | * @returns A promise resolving to a server response containing the updated shortcuts dictionary. 122 | */ 123 | static async modShortcut(shortcut: Shortcut): Promise> { 124 | return await this.serverAPI.callPluginMethod<{ shortcut: Shortcut }, ShortcutsDictionary>("modShortcut", { shortcut: shortcut }); 125 | } 126 | 127 | /** 128 | * Removes a shortcut on the backend and returns the updated shortcuts dictionary. 129 | * @param shortcut The shortcut to remove. 130 | * @returns A promise resolving to a server response containing the updated shortcuts dictionary. 131 | */ 132 | static async remShortcut(shortcut: Shortcut): Promise> { 133 | return await this.serverAPI.callPluginMethod<{ shortcut: Shortcut }, ShortcutsDictionary>("remShortcut", { shortcut: shortcut }); 134 | } 135 | 136 | /** 137 | * Runs a non app shortcut. 138 | * @param shortcutId The id of the shortcut to run. 139 | * @param flags Optional tuple array of flags to pass to the shortcut. 140 | */ 141 | static async runNonAppShortcut(shortcutId: string, flags: [string, string][]): Promise> { 142 | const successful = await this.serverAPI.callPluginMethod<{ shortcutId: string, flags: [string, string][] }, void>("runNonAppShortcut", { shortcutId: shortcutId, flags: flags }); 143 | return successful; 144 | } 145 | 146 | /** 147 | * Kills a non app shortcut. 148 | * @param shortcutId The id of the shortcut to kill. 149 | */ 150 | static async killNonAppShortcut(shortcutId: string): Promise> { 151 | const successful = await this.serverAPI.callPluginMethod<{ shortcutId: string }, void>("killNonAppShortcut", { shortcutId: shortcutId }); 152 | return successful; 153 | } 154 | } -------------------------------------------------------------------------------- /src/WebsocketClient.ts: -------------------------------------------------------------------------------- 1 | import { PyInterop } from "./PyInterop"; 2 | 3 | type Listener = (data: any) => void 4 | 5 | /** 6 | * Enum for return values from running scripts. 7 | */ 8 | // @ts-ignore 9 | enum ScriptStatus { 10 | UNEXPECTED_RETURN_CODE = -1, 11 | FINISHED = 0, 12 | DOES_NOT_EXIST = 1, 13 | RUNNING = 2, 14 | KILLED = 3, 15 | FAILED = 4 16 | } 17 | 18 | /** 19 | * WebSocketClient class for connecting to a WebSocket. 20 | */ 21 | export class WebSocketClient { 22 | hostName: string; 23 | port: string; 24 | ws: WebSocket|null; 25 | listeners = new Map(); 26 | reconnectInterval: number; 27 | numRetries: number | null; 28 | 29 | /** 30 | * Creates a new WebSocketClient. 31 | * @param hostName The host name of the WebSocket. 32 | * @param port The port of the WebSocket. 33 | * @param reconnectInterval The time between reconnect attempts. 34 | * @param numRetries The number of times to try to reconnect. If null there is no cap. Defaults to null. 35 | */ 36 | constructor(hostName: string, port: string, reconnectInterval: number, numRetries = null) { 37 | this.hostName = hostName; 38 | this.port = port; 39 | this.reconnectInterval = reconnectInterval; 40 | this.numRetries = numRetries; 41 | this.ws = null; 42 | } 43 | 44 | /** 45 | * Connects the client to the WebSocket. 46 | */ 47 | connect(): void { 48 | PyInterop.log(`WebSocket client connecting to ${this.hostName}:${this.port}...`); 49 | 50 | this.ws = new WebSocket(`ws://${this.hostName}:${this.port}`); 51 | this.ws.onopen = this.onOpen.bind(this); 52 | this.ws.onmessage = this.listen.bind(this); 53 | this.ws.onerror = this.onError.bind(this); 54 | this.ws.onclose = this.onClose.bind(this); 55 | 56 | PyInterop.log(`WebSocket client connected to ${this.hostName}:${this.port}.`); 57 | } 58 | 59 | /** 60 | * Disconnects the client from the WebSocket. 61 | */ 62 | disconnect(): void { 63 | PyInterop.log(`WebSocket client disconnecting from ${this.hostName}:${this.port}...`); 64 | 65 | this.ws?.close(); 66 | 67 | PyInterop.log(`WebSocket client disconnected from ${this.hostName}:${this.port}.`); 68 | } 69 | 70 | /** 71 | * Listens to the WebSocket for messages. 72 | * @param e The MessageEvent. 73 | */ 74 | private listen(e: MessageEvent): void { 75 | // PyInterop.log(`Recieved message: ${JSON.stringify(e)}`); 76 | 77 | try { 78 | const info = JSON.parse(e.data); 79 | 80 | // PyInterop.log(`WebSocketClient recieved message containing JSON data. Message: ${JSON.stringify(e)} Data: ${JSON.stringify(info)}`); 81 | 82 | if (this.listeners.has(info.type)) { 83 | const registeredListeners = this.listeners.get(info.type) as Listener[]; 84 | 85 | for (const listener of registeredListeners) { 86 | listener(info.data); 87 | } 88 | } 89 | } catch (err: any) { 90 | // PyInterop.log(`WebSocketClient recieved message containing no JSON data. Message: ${JSON.stringify(e)} Error: ${JSON.stringify(err)}`); 91 | } 92 | } 93 | 94 | /** 95 | * Handler for WebSocket errors. 96 | * @param e The Event. 97 | */ 98 | private onError(e: Event) { 99 | PyInterop.log(`Websocket recieved and error: ${JSON.stringify(e)}`) 100 | } 101 | 102 | /** 103 | * Handler for when WebSocket opens. 104 | * @param e The Event. 105 | */ 106 | private onOpen(e: Event) { 107 | this.ws?.send("Hello server from TS!"); 108 | PyInterop.log(`WebSocket server opened. Event: ${JSON.stringify(e)}`); 109 | } 110 | 111 | /** 112 | * Handler for when WebSocket closes. 113 | * @param e The CloseEvent. 114 | */ 115 | private onClose(e: CloseEvent) { 116 | // const returnCode = e.code; 117 | // const reason = e.reason; 118 | // const wasClean = e.wasClean; 119 | PyInterop.log(`WebSocket onClose triggered: ${JSON.stringify(e)}`); 120 | } 121 | 122 | /** 123 | * Registers a callback to run when an event with the given message is recieved. 124 | * @param type The type of message to register the callback for. 125 | * @param callback The callback to run. 126 | */ 127 | on(type: string, callback: Listener): void { 128 | let existingListeners:Listener[] = [] 129 | if (this.listeners.has(type)) { 130 | existingListeners = this.listeners.get(type) as Listener[]; 131 | } 132 | 133 | existingListeners.push(callback) 134 | 135 | this.listeners.set(type, existingListeners); 136 | PyInterop.log(`Registered listener for message of type: ${type}.`); 137 | } 138 | 139 | /** 140 | * Deletes all listeners for a message type. 141 | * @param type The type of message. 142 | */ 143 | deleteListeners(type: string): void { 144 | this.listeners.delete(type); 145 | PyInterop.log(`Removed listeners for message of type: ${type}.`); 146 | } 147 | 148 | /** 149 | * Sends a message to the WebSocket. 150 | * @param type The type message name to send. 151 | * @param data The data to send. 152 | */ 153 | sendMessage(type: string, data: any) { 154 | this.ws?.send(JSON.stringify({ 155 | "type": type, 156 | "data": data 157 | })); 158 | } 159 | } -------------------------------------------------------------------------------- /src/components/ShortcutLauncher.tsx: -------------------------------------------------------------------------------- 1 | import { DialogButton, Field, Focusable, Navigation, gamepadDialogClasses } from "decky-frontend-lib"; 2 | import { Fragment, VFC, useEffect, useState } from "react"; 3 | import { Shortcut } from "../lib/data-structures/Shortcut"; 4 | 5 | import { IoRocketSharp } from "react-icons/io5"; 6 | import { PyInterop } from "../PyInterop"; 7 | import { FaTrashAlt } from "react-icons/fa"; 8 | import { PluginController } from "../lib/controllers/PluginController"; 9 | import { useShortcutsState } from "../state/ShortcutsState"; 10 | 11 | export type ShortcutLauncherProps = { 12 | shortcut: Shortcut 13 | } 14 | 15 | /** 16 | * A component for the label of a ShortcutLauncher. 17 | * @param props The props for this ShortcutLabel. 18 | * @returns A ShortcutLabel component. 19 | */ 20 | const ShortcutLabel: VFC<{ shortcut: Shortcut, isRunning: boolean}> = (props: { shortcut: Shortcut, isRunning: boolean }) => { 21 | return ( 22 | <> 23 | 36 |
42 |
{props.shortcut.name}
43 |
52 |
53 | 54 | ); 55 | } 56 | /** 57 | * A component for launching shortcuts. 58 | * @param props The ShortcutLauncher's props. 59 | * @returns The ShortcutLauncher component. 60 | */ 61 | export const ShortcutLauncher: VFC = (props: ShortcutLauncherProps) => { 62 | const { runningShortcuts, setIsRunning } = useShortcutsState(); 63 | const [ isRunning, _setIsRunning ] = useState(PluginController.checkIfRunning(props.shortcut.id)); 64 | 65 | useEffect(() => { 66 | if (PluginController.checkIfRunning(props.shortcut.id) && !runningShortcuts.has(props.shortcut.id)) { 67 | setIsRunning(props.shortcut.id, true); 68 | } 69 | }, []); 70 | 71 | useEffect(() => { 72 | _setIsRunning(runningShortcuts.has(props.shortcut.id)); 73 | }, [runningShortcuts]); 74 | 75 | /** 76 | * Determines which action to run when the interactable is selected. 77 | * @param shortcut The shortcut associated with this shortcutLauncher. 78 | */ 79 | async function onAction(shortcut:Shortcut): Promise { 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 | 28 | { 29 | showModal( 30 | // @ts-ignore 31 | { 32 | if (PluginController.checkIfRunning(shortcut.id)) PluginController.closeShortcut(shortcut); 33 | PyInterop.modShortcut(updated); 34 | PluginController.updateHooks(updated); 35 | let shorts = shortcuts; 36 | shorts[shortcut.id] = updated; 37 | setShortcuts(shorts); 38 | PyInterop.toast("Success", `Updated shortcut ${props.entry.data?.name}.`); 39 | }} shortcut={shortcut} /> 40 | ); 41 | }}>Edit 42 | { 43 | showModal( 44 | { 45 | if (PluginController.checkIfRunning(shortcut.id)) PluginController.closeShortcut(shortcut); 46 | PyInterop.remShortcut(shortcut); 47 | PluginController.updateHooks(shortcut); 48 | let shorts = shortcuts; 49 | delete shorts[shortcut.id]; 50 | setShortcuts(shorts); 51 | PyInterop.toast("Success", `Removed shortcut ${props.entry.data?.name}.`); 52 | }} bDestructiveWarning={true}> 53 | Are you sure you want to delete this shortcut? 54 | 55 | ); 56 | }}>Delete 57 | , 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 | --------------------------------------------------------------------------------