Compare commits

..

No commits in common. "master" and "v1.0.0" have entirely different histories.

19 changed files with 844 additions and 1421 deletions

21
.gitignore vendored
View file

@ -1,3 +1,5 @@
.vscode
lib-cov lib-cov
*.seed *.seed
*.log *.log
@ -13,6 +15,9 @@ logs
results results
tmp tmp
# Build
public/css/main.css
# Coverage reports # Coverage reports
coverage coverage
@ -22,7 +27,6 @@ coverage
# Dependency directory # Dependency directory
node_modules node_modules
bower_components bower_components
.pnpm-store
# Editors # Editors
.idea .idea
@ -35,16 +39,5 @@ Thumbs.db
# Ignore built ts files # Ignore built ts files
dist/ dist/
__pycache__/ # ignore yarn.lock
yarn.lock
/.yalc
yalc.lock
.vscode/settings.json
# Ignore output folder
backend/out
# Make sure to ignore any instance of the loader's decky_plugin.py
decky_plugin.py

12
.vscode/config.sh vendored
View file

@ -1,12 +0,0 @@
#!/usr/bin/env bash
SCRIPT_DIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]:-$0}"; )" &> /dev/null && pwd 2> /dev/null; )";
# printf "${SCRIPT_DIR}\n"
# printf "$(dirname $0)\n"
if ! [[ -e "${SCRIPT_DIR}/settings.json" ]]; then
printf '.vscode/settings.json does not exist. Creating it with default settings. Exiting afterwards. Run your task again.\n\n'
cp "${SCRIPT_DIR}/defsettings.json" "${SCRIPT_DIR}/settings.json"
exit 1
else
printf '.vscode/settings.json does exist. Congrats.\n'
printf 'Make sure to change settings.json to match your deck.\n'
fi

View file

@ -1,10 +0,0 @@
{
"deckip" : "0.0.0.0",
"deckport" : "22",
"deckpass" : "ssap",
"deckkey" : "-i ${env:HOME}/.ssh/id_rsa",
"deckdir" : "/home/deck",
"python.analysis.extraPaths": [
"./py_modules"
]
}

116
.vscode/tasks.json vendored
View file

@ -1,116 +0,0 @@
{
"version": "2.0.0",
"tasks": [
// OTHER
{
"label": "checkforsettings",
"type": "shell",
"group": "none",
"detail": "Check that settings.json has been created",
"command": "bash -c ${workspaceFolder}/.vscode/config.sh",
"problemMatcher": []
},
// BUILD
{
"label": "pnpmsetup",
"type": "shell",
"group": "build",
"detail": "Setup pnpm",
"command": "pnpm i",
"problemMatcher": []
},
{
"label": "updatefrontendlib",
"type": "shell",
"group": "build",
"detail": "Update deck-frontend-lib",
"command": "pnpm update decky-frontend-lib --latest",
"problemMatcher": []
},
{
"label": "build",
"type": "npm",
"group": "build",
"detail": "rollup -c",
"script": "build",
"path": "",
"problemMatcher": []
},
{
"label": "buildall",
"group": "build",
"detail": "Build decky-plugin-template",
"dependsOrder": "sequence",
"dependsOn": [
"pnpmsetup",
"build"
],
"problemMatcher": []
},
// DEPLOY
{
"label": "createfolders",
"detail": "Create plugins folder in expected directory",
"type": "shell",
"group": "none",
"dependsOn": [
"checkforsettings"
],
"command": "ssh deck@${config:deckip} -p ${config:deckport} ${config:deckkey} 'mkdir -p ${config:deckdir}/homebrew/pluginloader && mkdir -p ${config:deckdir}/homebrew/plugins'",
"problemMatcher": []
},
{
"label": "deploy",
"detail": "Deploy dev plugin to deck",
"type": "shell",
"group": "none",
"dependsOn": [
"createfolders",
"chmodfolders"
],
"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}",
"problemMatcher": []
},
{
"label": "restartloader",
"detail": "Restart the plugin loader (to load updated python code)",
"type": "shell",
"group": "none",
"dependsOn": [],
"command": "ssh deck@${config:deckip} -p ${config:deckport} ${config:deckkey} 'echo '${config:deckpass}' | sudo -S systemctl restart plugin_loader'",
"problemMatcher": []
},
{
"label": "chmodfolders",
"detail": "chmods folders to prevent perms issues",
"type": "shell",
"group": "none",
"command": "ssh deck@${config:deckip} -p ${config:deckport} ${config:deckkey} 'echo '${config:deckpass}' | sudo -S chmod -R ug+rw ${config:deckdir}/homebrew/'",
"problemMatcher": []
},
{
"label": "deployall",
"dependsOrder": "sequence",
"group": "none",
"dependsOn": [
"deploy",
"chmodfolders"
],
"problemMatcher": []
},
// ALL-IN-ONE
{
"label": "allinone",
"detail": "Build and deploy",
"dependsOrder": "sequence",
"group": "test",
"dependsOn": [
"buildall",
"deployall"
// Uncomment this line if you'd like your python code reloaded after deployment (this will restart Steam)
// ,"restartloader"
],
"problemMatcher": []
}
]
}

View file

@ -1,14 +1,10 @@
> [!TIP]
> This is a fork of [Quick Launch by Fisch03](https://github.com/Fisch03/SDH-QuickLaunch) that has been modified to work on [Anatase](https://anatase.org/). Since Anatase runs Steam in a Flatpak, launching other Flatpaks and apps doesn't normally work. The purpose of this fork is to replace calls to /usr/bin/flatpak to /app/bin/flatpak, which is a wrapper that allows launching other Flatpaks within Anatase's Steam package.
# SDH-QuickLaunch # SDH-QuickLaunch
[Decky Loader](https://github.com/SteamDeckHomebrew/PluginLoader) Plugin to quickly launch Apps from the Quick Access Menu without adding them as shortcuts, and add new shortcuts entirely [Decky Loader](https://github.com/SteamDeckHomebrew/PluginLoader) Plugin to Quickly Launch Apps from the Steam Deck Quick Access Menu without adding them as Shortcuts
![plugin_demo](./ui.png) ![plugin_demo](./ui.png)
## Tips ## Tips
- Feel free to hide the QuickLaunch Shortcut this creates from your library, it wont affect the functionality Feel free to hide the QuickLaunch Shortcut this creates from your library, it wont affect the functionality
- To obtain a SteamGridDB API Key, login/register at [SteamGridDB](https://www.steamgriddb.com/). You can then generate an API Key [here](https://www.steamgriddb.com/profile/preferences/api)
## Caveats ## Caveats
You can only have one application open at at time - You can only have one application open at at time

125
main.py
View file

@ -1,7 +1,4 @@
import os, json, base64, ssl, certifi import os, json, base64, ssl, certifi
from pathlib import Path
from json import dumps as jsonDumps
from itertools import chain
import decky_plugin import decky_plugin
@ -10,129 +7,57 @@ from subprocess import Popen, PIPE
from urllib.error import HTTPError from urllib.error import HTTPError
from urllib.request import urlopen, Request from urllib.request import urlopen, Request
FLATPAK_RUNNER = "flatpak" # or explicitly: /app/bin/flatpak
HOST_RUNNER = "hrun" # or explicitly: /app/bin/hrun
confdir = os.environ["DECKY_PLUGIN_SETTINGS_DIR"] confdir = os.environ['DECKY_PLUGIN_SETTINGS_DIR']
send_buffer = [] send_buffer = []
def split_string(string): def split_string(string):
return [ return [string[i:i+1024*60] for i in range(0, len(string), 1024*60)] # every 60KB
string[i : i + 1024 * 60] for i in range(0, len(string), 1024 * 60)
] # every 60KB
class Plugin: class Plugin:
async def get_flatpaks(self): async def get_flatpaks(self):
def list_flatpaks(cmd): proc = Popen('flatpak list -d --app | awk \'BEGIN {FS="\\t"} {print "{\\"name\\":\\""$1"\\",\\"exec\\":\\"/usr/bin/flatpak run "$3"\\"},"}\\\'', stdout=PIPE, stderr=None, shell=True)
flatpaks = [] packages = proc.communicate()[0]
packages = packages.decode("utf-8")
clean_env = os.environ.copy() packages = packages[:-2]
clean_env["LD_LIBRARY_PATH"] = "" return "["+packages+"]"
with Popen(cmd, stdout=PIPE, stderr=None, env=clean_env, text=True) as p:
for line in p.stdout:
if '\t' not in line:
continue
name, app_id = line.strip().split('\t', 1)
name = name.strip()
app_id = app_id.strip()
if not name or not app_id:
continue
flatpaks.append({
"id": app_id,
"name": name,
"exec": f"{FLATPAK_RUNNER} run {app_id}"
})
return flatpaks
global_flatpaks = list_flatpaks([
'flatpak', 'list', '--app', '--columns=name,application'
])
local_flatpaks = list_flatpaks([
'runuser', '-l', decky_plugin.DECKY_USER, '-c',
'flatpak list --app --columns=name,application'
])
seen = set()
unique_flatpaks = []
for flatpak in global_flatpaks + local_flatpaks:
if flatpak["id"] not in seen:
seen.add(flatpak["id"])
unique_flatpaks.append(flatpak)
sorted_items = sorted(unique_flatpaks, key=lambda x: x.get("name", "").lower())
return jsonDumps(sorted_items)
async def get_desktops(self): async def get_desktops(self):
packages = [] #Really REALLY scuffed way of reading desktop files...
for desktopFile in chain( proc = Popen('ls -l /usr/share/applications | gawk \'{path="/usr/share/applications/"$9; while(( getline line<path) > 0) {if(line ~ /^Exec=/ && exec=="") { exec=substr(line,6);}; if(line ~ /^Name=/ && name=="") { name=substr(line,6); }; if(exec!="" && name!="") { break }};gsub(/"/,"\\\\\\"",exec);print("{\\"name\\":\\""name"\\",\\"exec\\":\\""exec"\\"},");exec="";name=""}\'', stdout=PIPE, stderr=None, shell=True)
Path("/usr/share/applications").glob("*.desktop"),
Path(f"{decky_plugin.DECKY_USER_HOME}/.local/share/applications/").glob("*.desktop"),
Path("/var/usrlocal/share/applications").glob("*.desktop"),
):
if not desktopFile.is_file():
continue
with open(desktopFile) as f:
package = {}
foundName = foundExec = False
for line in f:
line = line.strip()
if line.startswith("Name="):
foundName = True
package["name"] = line[5:]
elif line.startswith("Exec="):
foundExec = True
execTarget = line[5:].split(" ", 1)
decky_plugin.logger.info("- Exec path: " + str(execTarget))
runner = FLATPAK_RUNNER if execTarget[0].endswith("flatpak") else HOST_RUNNER
package["exec"] = f"{runner} {execTarget[1]}"
if foundName and foundExec:
decky_plugin.logger.info("+ Desktop app: " + package["name"])
packages.append(package)
break
return jsonDumps(packages) packages = proc.communicate()[0]
packages = packages.decode("utf-8")
async def get_DECKY_USER_HOME(self): packages = packages[:-2]
return decky_plugin.DECKY_USER_HOME return "["+packages+"]"
async def get_config(self): async def get_config(self):
with open(os.path.join(confdir, "config.json"), "r") as f: with open(os.path.join(confdir,"config.json"), "r") as f:
return json.load(f) return json.load(f)
async def set_config_value(self, key, value): async def set_config_value(self, key, value):
config = json.load(open(os.path.join(confdir, "config.json"))) config = json.load(open(os.path.join(confdir,"config.json")))
config[key] = value config[key] = value
with open(os.path.join(confdir, "config.json"), "w") as f: with open(os.path.join(confdir,"config.json"), "w") as f:
json.dump(config, f) json.dump(config, f)
return config return config
async def get_id(self): async def get_id(self):
with open(os.path.join(confdir, "scid.txt"), "r") as sc: with open(os.path.join(confdir,"scid.txt"), "r") as sc:
id = sc.read() id = sc.read()
try: try:
id = int(id) id = int(id)
return id return id
except ValueError: except ValueError:
return -1 return -1
async def set_id(self, id): async def set_id(self, id):
with open(os.path.join(confdir, "scid.txt"), "w") as sc: with open(os.path.join(confdir,"scid.txt"), "w") as sc:
sc.write(str(id)) sc.write(str(id))
async def get_req_imgb64(self, url): async def get_req_imgb64(self, url):
global send_buffer global send_buffer
if len(send_buffer) != 0: if(len(send_buffer) != 0):
return return
req = Request(url) req = Request(url)
@ -140,15 +65,13 @@ class Plugin:
req.add_header("User-Agent", "SDH-QuickLaunch") req.add_header("User-Agent", "SDH-QuickLaunch")
try: try:
content = urlopen( content = urlopen(req, context=ssl.create_default_context(cafile=certifi.where())).read()
req, context=ssl.create_default_context(cafile=certifi.where()) img = base64.b64encode(content).decode('ascii')
).read()
img = base64.b64encode(content).decode("ascii")
send_buffer = split_string(img) send_buffer = split_string(img)
new_chunk = send_buffer.pop(0) new_chunk = send_buffer.pop(0)
return {"data": new_chunk, "is_last": len(send_buffer) == 0} return {"data": new_chunk, "is_last": len(send_buffer) == 0}
except HTTPError: except HTTPError:
decky_plugin.logger.error("HTTPError while requesting " + url) decky_plugin.logger.error("HTTPError while requesting "+url)
pass pass
async def receive_next_chunk(self): async def receive_next_chunk(self):
@ -165,12 +88,12 @@ class Plugin:
pass pass
try: try:
sc = open(os.path.join(confdir, "scid.txt"), "x") sc = open(os.path.join(confdir,"scid.txt"), "x")
sc.close() sc.close()
except FileExistsError: except FileExistsError:
pass pass
try: try:
sc = open(os.path.join(confdir, "config.json"), "x") sc = open(os.path.join(confdir,"config.json"), "x")
sc.write("{}") sc.write("{}")
sc.close() sc.close()
except FileExistsError: except FileExistsError:

View file

@ -1,7 +1,7 @@
{ {
"name": "sdh-quicklaunch-anatase", "name": "sdh-quicklaunch",
"version": "1.2.1", "version": "1.0.0",
"description": "Quickly launch apps from the Steam Deck Quick Access Menu without adding them as shortcuts", "description": "Quickly Launch Apps from the Steam Deck Quick Access Menu without adding them as Shortcuts",
"scripts": { "scripts": {
"build": "shx rm -rf dist && rollup -c", "build": "shx rm -rf dist && rollup -c",
"watch": "rollup -c -w", "watch": "rollup -c -w",
@ -9,7 +9,7 @@
}, },
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://gitdab.com/dj/SDH-QuickLaunch-Anatase.git" "url": "git+https://github.com/Fisch03/SDH-QuickLaunch.git"
}, },
"keywords": [ "keywords": [
"decky", "decky",
@ -17,12 +17,12 @@
"steam-deck", "steam-deck",
"deck" "deck"
], ],
"author": "Fisch03 & djsime1", "author": "Fisch03",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"bugs": { "bugs": {
"url": "https://gitdab.com/dj/SDH-QuickLaunch-Anatase/issues" "url": "https://github.com/Fisch03/SDH-QuickLaunch/issues"
}, },
"homepage": "https://gitdab.com/dj/SDH-QuickLaunch-Anatase", "homepage": "https://github.com/Fisch03/SDH-QuickLaunch#readme",
"devDependencies": { "devDependencies": {
"@rollup/plugin-commonjs": "^21.1.0", "@rollup/plugin-commonjs": "^21.1.0",
"@rollup/plugin-json": "^4.1.0", "@rollup/plugin-json": "^4.1.0",
@ -31,7 +31,6 @@
"@rollup/plugin-typescript": "^8.3.3", "@rollup/plugin-typescript": "^8.3.3",
"@types/react": "16.14.0", "@types/react": "16.14.0",
"@types/webpack": "^5.28.0", "@types/webpack": "^5.28.0",
"decky-frontend-lib": "^3.24.5",
"rollup": "^2.77.1", "rollup": "^2.77.1",
"rollup-plugin-import-assets": "^1.1.1", "rollup-plugin-import-assets": "^1.1.1",
"shx": "^0.3.4", "shx": "^0.3.4",
@ -39,14 +38,14 @@
"typescript": "^4.7.4" "typescript": "^4.7.4"
}, },
"dependencies": { "dependencies": {
"decky-frontend-lib": "^3.19.2",
"react-icons": "^4.8.0" "react-icons": "^4.8.0"
}, },
"pnpm": { "pnpm": {
"peerDependencyRules": { "peerDependencyRules": {
"ignoreMissing": [ "ignoreMissing": [
"react", "react",
"react-dom", "react-dom"
"decky-frontend-lib"
] ]
} }
} }

View file

@ -1,10 +1,11 @@
{ {
"name": "Quick Launch", "name": "Quick Launch",
"author": "Fisch03 & djsime1", "author": "Fisch03",
"flags": ["root"], "flags": ["root"],
"publish": { "publish": {
"discord_id": "431374517462499328",
"description": "Quickly Launch Non-Steam-Apps from the Quick Access menu without adding them as Shortcuts, or add them to the Steam Library.", "description": "Quickly Launch Non-Steam-Apps from the Quick Access menu without adding them as Shortcuts, or add them to the Steam Library.",
"tags": [ "utility" ], "tags": [ "utility" ],
"image": "https://gitdab.com/dj/SDH-QuickLaunch-Anatase/raw/branch/master/ui_full.png" "image": "https://raw.githubusercontent.com/Fisch03/SDH-QuickLaunch/master/ui.png"
} }
} }

1492
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -24,13 +24,12 @@ export default defineConfig({
}) })
], ],
context: 'window', context: 'window',
external: ['react', 'react-dom', 'decky-frontend-lib'], external: ['react', 'react-dom'],
output: { output: {
file: 'dist/index.js', file: 'dist/index.js',
globals: { globals: {
react: 'SP_REACT', react: 'SP_REACT',
'react-dom': 'SP_REACTDOM', 'react-dom': 'SP_REACTDOM',
'decky-frontend-lib': 'DFL',
}, },
format: 'iife', format: 'iife',
exports: 'default', exports: 'default',

View file

@ -1,47 +0,0 @@
import { ServerAPI } from "decky-frontend-lib";
import { App, getLaunchOptions, getTarget } from "./apptypes";
import { getImagesForGame } from "./steamgriddb";
import { gameIDFromAppID, getShortcutID, createShortcut } from "./utils";
import { Settings } from "./settings";
export async function launchApp(sAPI: ServerAPI, app: App) {
let id: number = await getShortcutID(sAPI);
SteamClient.Apps.SetShortcutName(id, `[QL] ${app.name}`)
SteamClient.Apps.SetShortcutLaunchOptions(id, getLaunchOptions(app))
SteamClient.Apps.SetShortcutExe(id, `"${getTarget(app)}"`)
SteamClient.Apps.SetShortcutStartDir(id, "/app/bin")
SteamClient.Apps.SpecifyCompatTool(id, app.compatTool === undefined ? "" : app.compatTool)
setTimeout(() => {
let gid = gameIDFromAppID(id);
SteamClient.Apps.RunGame(gid,"",-1,100);
}, 500)
}
export function createAppShortcut(sAPI: ServerAPI, app: App, settings: Settings, launchOptions?: string, target?: string) {
let shortcutLaunchOptions = launchOptions === undefined ? getLaunchOptions(app) : launchOptions
let shortcutTarget = target === undefined ? getTarget(app) : target
createShortcut(app.name, shortcutLaunchOptions, shortcutTarget).then((id:number) => {
if(settings.get("useGridDB")) {
getImagesForGame(sAPI, settings.get("gridDBKey") ,app.name)
.then(images => {
if(images.Grid !== null) SteamClient.Apps.SetCustomArtworkForApp(id, images.Grid, "png", 0);
if(images.Hero !== null) SteamClient.Apps.SetCustomArtworkForApp(id, images.Hero, "png", 1);
if(images.Logo !== null) SteamClient.Apps.SetCustomArtworkForApp(id, images.Logo, "png", 2);
//if(images.Grid !== null) SteamClient.Apps.SetCustomArtworkForApp(id, images.GridH, "png", 3);
})
.catch(() => {}); //Maybe display error to the user in the future?
}
//This should theoretically not be needed with the new SteamClient.Apps.AddShortcut params but they seem to be pretty broken rn. It's not like it hurts either.
setTimeout(() => {
SteamClient.Apps.SetShortcutName(id, app.name);
SteamClient.Apps.SetShortcutLaunchOptions(id, shortcutLaunchOptions);
SteamClient.Apps.SetShortcutExe(id, `"${shortcutTarget}"`);
SteamClient.Apps.SetShortcutStartDir(id, "/app/bin")
SteamClient.Apps.SpecifyCompatTool(id, app.compatTool === undefined ? "" : app.compatTool);
}, 500)
})
}

View file

@ -1,7 +1,6 @@
export interface App { export interface App {
name: string; name: string;
exec: string; exec: string;
compatTool?: string;
} }
export function getLaunchOptions(app: App) { export function getLaunchOptions(app: App) {

View file

@ -1,101 +0,0 @@
import {
ServerAPI,
ConfirmModal,
ToastData,
TextField,
FilePickerRes,
ToggleField
} from "decky-frontend-lib"
import { useEffect, useState } from "react"
import { App } from "./apptypes"
import { createAppShortcut, launchApp } from "./appoperations"
import { Settings } from "./settings"
export const FileOptionsModal = (props: {closeModal?: CallableFunction, settings: Settings, starApp: CallableFunction, filepath: FilePickerRes, serverAPI: ServerAPI}) => {
const closeModal = () => { if (props.closeModal) {props.closeModal()} }
const {settings, starApp, filepath, serverAPI} = props
const [appName, setAppName] = useState<string>(filepath.path)
const [compatTool, setCompatTool] = useState<string|undefined>()
const [addToFavorites, setAddToFavorites] = useState<boolean>(false)
const [addAsShortcut, setAddAsShortcut] = useState<boolean>(false)
useEffect(()=>{
let filenameWithExtension = filepath.realpath.split(/[\\\/]/).pop() // potentially has an extension
let filename = filenameWithExtension
let fileext = ''
if (filenameWithExtension) {
filename = filenameWithExtension.slice(0, filenameWithExtension.lastIndexOf('.'))
fileext = filenameWithExtension.slice(filenameWithExtension.lastIndexOf('.')+1, filenameWithExtension.length).toLowerCase()
}
let path = filepath.realpath.substring(0, filepath.realpath.length - (filenameWithExtension ? filenameWithExtension.length : 0))
localStorage.setItem('decky-addtosteam', path)
let appName = filename || filenameWithExtension || 'MissingAppName'
setAppName(appName)
if (['exe', 'bat'].includes(fileext)) setCompatTool('proton-experimental')
},[])
const onOK = () => {
let app: App = {
name: appName,
exec: filepath.realpath,
compatTool: compatTool
}
if (addAsShortcut) {
createAppShortcut(serverAPI, app, settings, "", app.exec)
let toastData: ToastData = {
title: 'Added Shortcut',
body: appName,
playSound: true,
showToast: true
}
serverAPI.toaster.toast(toastData)
} else if (addToFavorites) {
//TODO: Doing this looses information about the compat tool. An app should also be able to store data about compat tools.
starApp(app);
} else {
launchApp(serverAPI, app);
}
}
const nameField = <TextField
label='Name'
focusOnMount={true}
value={appName}
onChange={(e) => setAppName(e.currentTarget.value)}
/>
return (
<ConfirmModal
strTitle='File Options'
strOKButtonText={addAsShortcut ? 'Add Shortcut' : addToFavorites ? 'Add to starred Apps' : 'Launch'}
closeModal={closeModal}
onOK={onOK}
onCancel={closeModal}
onEscKeypress={closeModal}
>
<ToggleField
label='Add to favorites'
checked={addToFavorites}
onChange={addToFavorites => {
setAddToFavorites(addToFavorites);
if(addToFavorites) setAddAsShortcut(false);
}}
/>
<ToggleField
label='Add as shortcut'
checked={addAsShortcut}
onChange={addAsShortcut => {
setAddAsShortcut(addAsShortcut);
if(addAsShortcut) setAddToFavorites(false);
}}
/>
{ (addToFavorites || addAsShortcut) && nameField }
</ConfirmModal>
)
}

View file

@ -6,29 +6,21 @@ import {
MultiDropdownOption, MultiDropdownOption,
PanelSection, PanelSection,
PanelSectionRow, PanelSectionRow,
ButtonItem,
ToggleField, ToggleField,
showModal, showModal,
ModalRoot, ModalRoot,
SingleDropdownOption, SingleDropdownOption,
DropdownOption, DropdownOption
DialogButton,
Focusable,
FileSelectionType
} from "decky-frontend-lib"; } from "decky-frontend-lib";
import { Fragment, useEffect } from "react"; import { Fragment, useEffect } from "react";
import { VFC, useState } from "react"; import { VFC, useState } from "react";
import { FaRocket, FaStar, FaRegStar } from "react-icons/fa"; import { FaRocket } from "react-icons/fa";
import { App } from "./apptypes"; import { App, getLaunchOptions, getTarget } from "./apptypes";
import { Settings } from "./settings"; import { Settings } from "./settings";
import { GridDBPanel } from "./steamgriddb"; import { GridDBPanel, getImagesForGame } from "./steamgriddb";
import { fetchApps } from "./utils"; import { fetchApps, launchApp, createShortcut } from "./utils";
import { FileOptionsModal } from "./fileoptions";
import { launchApp, createAppShortcut } from "./appoperations";
enum SpecialSelections {
FileShortcut = -1,
}
let appList: App[] = []; let appList: App[] = [];
@ -38,61 +30,42 @@ const Content: VFC<{ serverAPI: ServerAPI }> = ({ serverAPI }) => {
const [settings] = useState<Settings>(new Settings(serverAPI)) const [settings] = useState<Settings>(new Settings(serverAPI))
const [showKeyInput, setShowKeyInput] = useState<boolean>(false); const [buttonText, setButtonText] = useState<string>("Launch!");
const updateButtonText = () => settings.get("createNewShortcut")? setButtonText("Create!") : setButtonText("Launch!");
const [isStarred, setIsStarred] = useState<boolean>(false); const [showKeyInput, setShowKeyInput] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
settings.readSettings().then(() => { settings.readSettings().then(() => {
if(dropdownOptions.length === 0 || appList.length === 0) if(dropdownOptions.length === 0 || appList.length === 0)
buildAppList(); buildAppList();
updateButtonText();
setShowKeyInput(settings.get("useGridDB")); setShowKeyInput(settings.get("useGridDB"));
//setKeyInputValue(settings.get("gridDBKey"));
}); });
}, []); }, []);
function buildAppList() { function buildAppList() {
return new Promise<void>((resolve) => {
let newDropdownOptions: DropdownOption[] = [];
appList = []; appList = [];
if(settings.get("enableAll")) {
let newDropdownOptions: MultiDropdownOption[] = [];
let starredApps = settings.get("starredApps"); fetchApps(serverAPI, "flatpaks")
.then(list => {
if(starredApps.length > 0) newDropdownOptions.push(createSubcategory("Flatpaks", list));
newDropdownOptions.push(...createSubcategory("Starred Apps", settings.get("starredApps")).options); return fetchApps(serverAPI, "desktops");
})
fetchApps(serverAPI, "flatpaks") .then(list => {
.then(list => {
list = list.filter(app => {
for(let starredApp of starredApps) {
if(starredApp.name === app.name) return false;
}
return true;
});
newDropdownOptions.push(createSubcategory("Flatpaks", list));
if(settings.get("enableAll")) {
return fetchApps(serverAPI, "desktops")
} else {
return Promise.resolve([]);
}
})
.then(list => {
if(list.length > 0) {
list = list.filter(app => {
for(let starredApp of starredApps) {
if(starredApp.name === app.name) return false;
}
return true;
});
newDropdownOptions.push(createSubcategory(".desktop files", list)); newDropdownOptions.push(createSubcategory(".desktop files", list));
} setDropdownOptions(newDropdownOptions);
}) });
.finally(() => { } else {
if(settings.get("enableAll")) newDropdownOptions.push({label: "Choose a file...", data: SpecialSelections.FileShortcut} as SingleDropdownOption); fetchApps(serverAPI, "flatpaks")
setDropdownOptions(newDropdownOptions); .then(list => {
resolve(); setDropdownOptions(createSubcategory("Flatpaks", list).options)
}) appList = list
}) })
}
} }
function createSubcategory(categoryName: string, list: App[]) { function createSubcategory(categoryName: string, list: App[]) {
@ -105,45 +78,30 @@ const Content: VFC<{ serverAPI: ServerAPI }> = ({ serverAPI }) => {
} }
async function createFileShortcut() { function doButtonAction() {
let lastUsedPath = localStorage.getItem('decky-addtosteam') if(selectedApp === null) return;
let deckyUserHome = (await serverAPI.callPluginMethod('get_DECKY_USER_HOME', {})).result
let path: string let app = appList[selectedApp];
if (lastUsedPath != null) { if(settings.get("createNewShortcut")) {
path = lastUsedPath createShortcut(app.name).then((id:number) => {
} else if (typeof deckyUserHome === 'string') { if(settings.get("useGridDB")) {
path = deckyUserHome getImagesForGame(serverAPI, settings.get("gridDBKey"),app.name)
.then(images => {
if(images.Grid !== null) SteamClient.Apps.SetCustomArtworkForApp(id, images.Grid, "png", 0);
if(images.Hero !== null) SteamClient.Apps.SetCustomArtworkForApp(id, images.Hero, "png", 1);
if(images.Logo !== null) SteamClient.Apps.SetCustomArtworkForApp(id, images.Logo, "png", 2);
//if(images.Grid !== null) SteamClient.Apps.SetCustomArtworkForApp(id, images.GridH, "png", 3);
})
.catch(() => {}); //Maybe display error to the user in the future?
}
setTimeout(() => {
SteamClient.Apps.SetShortcutLaunchOptions(id, getLaunchOptions(app));
SteamClient.Apps.SetShortcutExe(id, `"${getTarget(app)}"`);
}, 500)
})
} else { } else {
return launchApp(serverAPI, app);
}
let filepath = await serverAPI.openFilePickerV2(
FileSelectionType.FILE,
path,
true,
undefined,
undefined,
undefined,
false,
true
)
if (!filepath) { return }
const starApp = async (app: App) => {
let starredApps = settings.get("starredApps");
if(!starredApps.find((a: App) => a.name === app.name && a.exec === app.exec))
starredApps.push(app);
settings.set("starredApps", starredApps);
await buildAppList();
setSelectedApp(appList.findIndex((a: App) => a.name === app.name && a.exec === app.exec));
}
showModal(<FileOptionsModal filepath={filepath} starApp={starApp} settings={settings} serverAPI={serverAPI}/>)
}
function newShortcut() {
if(selectedApp != null){
createAppShortcut(serverAPI, appList[selectedApp], settings)
} }
} }
@ -151,73 +109,27 @@ const Content: VFC<{ serverAPI: ServerAPI }> = ({ serverAPI }) => {
<Fragment> <Fragment>
<PanelSection> <PanelSection>
<PanelSectionRow> <PanelSectionRow>
<Focusable flow-children="horizontal" style={{display: "flex", justifyContent: "space-between", padding: 0, gap: "8px"}}> <Dropdown
<div style={{flexGrow: 1}}> strDefaultLabel="Select App..."
<Dropdown rgOptions={dropdownOptions}
strDefaultLabel="Select App..." selectedOption={selectedApp}
rgOptions={dropdownOptions} onChange={(e: SingleDropdownOption) => {setSelectedApp(e.data);}}
selectedOption={selectedApp} />
onChange={(e: SingleDropdownOption) => {
setIsStarred(e.data < settings.get("starredApps").length);
if(e.data === SpecialSelections.FileShortcut) {
createFileShortcut();
setSelectedApp(null);
}
else setSelectedApp(e.data);
}}
/>
</div>
<DialogButton style={{minWidth: 0, width: "15%", padding: 0}} onClick={() => {
if(selectedApp === null) return;
let toBeStarred = appList[selectedApp];
setIsStarred(!isStarred);
let starredApps = settings.get("starredApps");
if(isStarred) {
starredApps.splice(starredApps.indexOf(appList[selectedApp]), 1);
} else {
starredApps.push(appList[selectedApp]);
}
settings.set("starredApps", starredApps);
buildAppList()
.then(() => {
if(isStarred) {
let foundApp = 0;
for(let app of appList) {
if(app.name === toBeStarred.name) {
foundApp = appList.indexOf(app);
break;
}
}
setSelectedApp(foundApp);
} else {
setSelectedApp(starredApps.length-1);
}
})
}}>
{isStarred ? <FaStar /> : <FaRegStar />}
</DialogButton>
</Focusable>
</PanelSectionRow> </PanelSectionRow>
<PanelSectionRow> <PanelSectionRow>
<DialogButton style={{ marginTop: "8px" }} onClick={() => { <ButtonItem layout="below" onClick={() => doButtonAction()}>
if(selectedApp === null) return; {buttonText}
launchApp(serverAPI, appList[selectedApp]) </ButtonItem>
}}>
Launch!
</DialogButton>
</PanelSectionRow>
<PanelSectionRow>
<DialogButton style={{ marginTop: "8px" }} onClick={() => {newShortcut()}}>
Create Shortcut
</DialogButton>
</PanelSectionRow> </PanelSectionRow>
</PanelSection> </PanelSection>
<PanelSection title="Settings"> <PanelSection title="Settings">
<PanelSectionRow>
<ToggleField
label="Add as a separate Shortcut"
checked={settings.get("createNewShortcut")}
onChange={(e) => {settings.set("createNewShortcut", e); updateButtonText()}}
/>
</PanelSectionRow>
<PanelSectionRow> <PanelSectionRow>
<ToggleField <ToggleField
label="Automatically download Artworks from SteamGridDB" label="Automatically download Artworks from SteamGridDB"

View file

@ -11,8 +11,6 @@ export class Settings {
private gridDBKey: string = ""; private gridDBKey: string = "";
//@ts-ignore //@ts-ignore
private enableAll: boolean = false; private enableAll: boolean = false;
//@ts-ignore
private starredApps: App[] = [];
constructor(sAPI: ServerAPI, startingSettings: Settings = {} as Settings) { constructor(sAPI: ServerAPI, startingSettings: Settings = {} as Settings) {
this.sAPI = sAPI; this.sAPI = sAPI;

View file

@ -129,9 +129,10 @@ const searchGame = (sAPI: ServerAPI, key: string, gameName: string) => apiReques
const getGrids = (sAPI: ServerAPI, key: string, gameID: number) => apiRequest(sAPI, key, "/grids/game", gameID) as Promise<ImageAPIResponse> const getGrids = (sAPI: ServerAPI, key: string, gameID: number) => apiRequest(sAPI, key, "/grids/game", gameID) as Promise<ImageAPIResponse>
const getHeroes = (sAPI: ServerAPI, key: string, gameID: number) => apiRequest(sAPI, key, "/heroes/game", gameID) as Promise<ImageAPIResponse> const getHeroes = (sAPI: ServerAPI, key: string, gameID: number) => apiRequest(sAPI, key, "/heroes/game", gameID) as Promise<ImageAPIResponse>
const getLogos = (sAPI: ServerAPI, key: string, gameID: number) => apiRequest(sAPI, key, "/logos/game", gameID) as Promise<ImageAPIResponse> const getLogos = (sAPI: ServerAPI, key: string, gameID: number) => apiRequest(sAPI, key, "/logos/game", gameID) as Promise<ImageAPIResponse>
//const getIcons = (sAPI: ServerAPI, key: string, gameID: number) => apiRequest(sAPI, key, "/icons/game", gameID) as Promise<ImageAPIResponse>
//const getGridH = (sAPI: ServerAPI, key: string, gameID: number) => apiRequest(sAPI, key, "/grids/game", gameID) as Promise<ImageAPIResponse> //const getGridH = (sAPI: ServerAPI, key: string, gameID: number) => apiRequest(sAPI, key, "/grids/game", gameID) as Promise<ImageAPIResponse>
export function getImagesForGame(sAPI: ServerAPI, key: string, gameName: string): Promise<ImageCollection> { export function getImagesForGame(sAPI: ServerAPI, key: string, gameName: string): Promise<ImageCollection> {
return new Promise<ImageCollection>((resolve, reject) => { return new Promise<ImageCollection>((resolve, reject) => {
searchGame(sAPI, key, gameName) searchGame(sAPI, key, gameName)

View file

@ -1,9 +1,10 @@
import { ServerAPI } from "decky-frontend-lib"; import { ServerAPI } from "decky-frontend-lib";
import { App } from "./apptypes"; import { App, getLaunchOptions, getTarget } from "./apptypes";
export const createShortcut = (name: string, launchOptions: string = "", target:string = "") => { export const createShortcut = (name: string) => {
return SteamClient.Apps.AddShortcut(name,"/usr/bin/ifyouseethisyoufoundabug", target, launchOptions); //The Part after the last Slash does not matter because it should always be replaced when launching an app //@ts-ignore
return SteamClient.Apps.AddShortcut(name,"/usr/bin/ifyouseethisyoufoundabug") //The Part after the last Slash does not matter because it should always be replaced when launching an app
} }
export const gameIDFromAppID = (appid: number) => { export const gameIDFromAppID = (appid: number) => {
@ -20,21 +21,48 @@ export const gameIDFromAppID = (appid: number) => {
export async function fetchApps(sAPI: ServerAPI, type: string): Promise<App[]> { export async function fetchApps(sAPI: ServerAPI, type: string): Promise<App[]> {
const result = await sAPI.callPluginMethod<any, string>(`get_${type}`, {}); const result = await sAPI.callPluginMethod<any, string>(`get_${type}`, {});
let apps: App[] = [] let apps: App[] = []
if (result.success) { if(result.success) {
let appsDict = new Map<string, App>(); //...i guess it works
for (let app of JSON.parse(result.result)) { let apps_withDuplicates: App[] = JSON.parse(result.result);
if (app.name !== "" && !appsDict.has(app.name)) {
appsDict.set(app.name, app); let names: String[] = []
for(let app of apps_withDuplicates) {
if(!names.includes(app.name) && app.name !== "") {
names.push(app.name)
}
}
for(let name of names) {
let app = apps_withDuplicates.find(app => app.name === name);
if(app !== undefined) {
apps.push(app);
} }
} }
// map values to list apps.sort((a, b) => {
apps = Array.from(appsDict.values()).sort((a, b) => a.name.localeCompare(b.name)); if(a.name < b.name) { return -1; }
if(a.name > b.name) { return 1; }
return 0;
})
} }
return apps return apps
} }
export const launchApp = async (sAPI: ServerAPI, app: App) => {
let id: number = await getShortcutID(sAPI);
//@ts-ignore
SteamClient.Apps.SetShortcutLaunchOptions(id, getLaunchOptions(app))
//@ts-ignore
SteamClient.Apps.SetShortcutExe(id, `"${getTarget(app)}"`)
setTimeout(() => {
let gid = gameIDFromAppID(id);
//@ts-ignore
SteamClient.Apps.RunGame(gid,"",-1,100);
}, 500)
}
export const getShortcutID = async (sAPI: ServerAPI) => { export const getShortcutID = async (sAPI: ServerAPI) => {
const result = await sAPI.callPluginMethod<any, number>("get_id", {}) const result = await sAPI.callPluginMethod<any, number>("get_id", {})

BIN
ui.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 307 KiB