diff --git a/.github/workflows/build-linux-arm64.yml b/.github/workflows/build-linux-arm64.yml new file mode 100644 index 000000000..f5cae2994 --- /dev/null +++ b/.github/workflows/build-linux-arm64.yml @@ -0,0 +1,13 @@ +name: Builder ARM64 + +on: + push: + pull_request: + workflow_call: + +jobs: + build: + uses: ./.github/workflows/build-linux.yml + with: + arch: arm64 + artifact_name: PluginLoader-arm64 \ No newline at end of file diff --git a/.github/workflows/build-linux-x86_64.yml b/.github/workflows/build-linux-x86_64.yml new file mode 100644 index 000000000..54085a56d --- /dev/null +++ b/.github/workflows/build-linux-x86_64.yml @@ -0,0 +1,13 @@ +name: Builder + +on: + push: + pull_request: + workflow_call: + +jobs: + build: + uses: ./.github/workflows/build-linux.yml + with: + arch: x86_64 + artifact_name: PluginLoader \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build-linux.yml similarity index 73% rename from .github/workflows/build.yml rename to .github/workflows/build-linux.yml index 46a106a11..441531eac 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build-linux.yml @@ -1,16 +1,21 @@ -name: Builder +name: Builder (Linux) on: - push: - pull_request: workflow_call: - # schedule: - # - cron: '0 13 * * *' # run at 1 PM UTC + inputs: + arch: + description: Architecture to build for (x86_64, arm64) + type: string + default: x86_64 + artifact_name: + description: Name of the uploaded artifact; the binary inside is always PluginLoader + type: string + default: PluginLoader jobs: build: - name: Build PluginLoader - runs-on: ubuntu-22.04 + name: Build ${{ inputs.artifact_name }} (Linux ${{ inputs.arch }}) + runs-on: ${{ inputs.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-22.04' }} steps: - name: Checkout 🧰 @@ -59,7 +64,7 @@ jobs: if: ${{ !env.ACT }} uses: actions/upload-artifact@v4 with: - name: PluginLoader + name: ${{ inputs.artifact_name }} path: ./backend/dist/PluginLoader - name: Download package artifact locally diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ac1637e4e..c727053f1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,6 +3,10 @@ name: Release on: workflow_dispatch: inputs: + preview_tag: + type: string + description: Optional DroidDeck Preview tag (droiddeck-arm64-preview.N) + default: '' release: type: choice description: Release the asset @@ -51,7 +55,17 @@ jobs: - name: Prepare tag ⚙️ id: ready_tag + env: + PREVIEW_TAG: ${{ inputs.preview_tag }} run: | + if [[ -n "$PREVIEW_TAG" ]]; then + if [[ "${{ inputs.release }}" != "prerelease" || ! "$PREVIEW_TAG" =~ ^droiddeck-arm64-preview\.[1-9][0-9]*$ ]]; then + echo "DroidDeck Preview tags require prerelease and the format droiddeck-arm64-preview.N" + exit 1 + fi + echo "tag_name=$PREVIEW_TAG" >> "$GITHUB_OUTPUT" + exit 0 + fi export VERSION=${{ steps.latest_release.outputs.release }} echo "VERS: $VERSION" if [[ ${{github.event.inputs.release}} == "release" ]]; then @@ -123,18 +137,38 @@ jobs: build: name: Build tagged artifact - uses: ./.github/workflows/build.yml + uses: ./.github/workflows/build-linux-x86_64.yml + needs: [create_tag] + + build_arm64: + name: Build tagged artifact (arm64) + uses: ./.github/workflows/build-linux-arm64.yml needs: [create_tag] release: - name: Release tagged artifact + name: Release tagged artifacts runs-on: ubuntu-22.04 - needs: [create_tag, build] + needs: [create_tag, build, build_arm64] steps: - - name: Fetch package artifact ⬇️ + - name: Fetch package artifact (x86_64) ⬇️ uses: actions/download-artifact@v4 with: name: PluginLoader + path: ./artifacts/x86_64 + + - name: Fetch package artifact (arm64) ⬇️ + uses: actions/download-artifact@v4 + with: + name: PluginLoader-arm64 + path: ./artifacts/arm64 + + # Release assets need unique names; the artifacts both contain "PluginLoader" + - name: Name release assets 🛠️ + run: | + mv ./artifacts/x86_64/PluginLoader ./PluginLoader + mv ./artifacts/arm64/PluginLoader ./PluginLoader-arm64 + sha256sum PluginLoader > PluginLoader.sha256 + sha256sum PluginLoader-arm64 > PluginLoader-arm64.sha256 - name: Pre-release 📦 if: github.event.inputs.release == 'prerelease' @@ -142,7 +176,11 @@ jobs: with: name: Prerelease ${{ needs.create_tag.outputs.tag_name }} tag_name: ${{ needs.create_tag.outputs.tag_name }} - files: ./PluginLoader + files: | + ./PluginLoader + ./PluginLoader-arm64 + ./PluginLoader.sha256 + ./PluginLoader-arm64.sha256 prerelease: true generate_release_notes: true - name: Release 📦 @@ -151,6 +189,10 @@ jobs: with: name: Release ${{ needs.create_tag.outputs.tag_name }} tag_name: ${{ needs.create_tag.outputs.tag_name }} - files: ./PluginLoader + files: | + ./PluginLoader + ./PluginLoader-arm64 + ./PluginLoader.sha256 + ./PluginLoader-arm64.sha256 prerelease: false generate_release_notes: true diff --git a/backend/decky_loader/locales/en-US.json b/backend/decky_loader/locales/en-US.json index 1f87fe3bf..6b6553adb 100644 --- a/backend/decky_loader/locales/en-US.json +++ b/backend/decky_loader/locales/en-US.json @@ -217,6 +217,9 @@ "developer_mode": { "label": "Developer mode" }, + "sort_plugins": { + "label": "Sort plugins alphabetically" + }, "notifications": { "decky_updates_label": "Decky update available", "header": "Notifications", diff --git a/backend/decky_loader/localplatform/localplatform.py b/backend/decky_loader/localplatform/localplatform.py index 6cb089824..e77603156 100644 --- a/backend/decky_loader/localplatform/localplatform.py +++ b/backend/decky_loader/localplatform/localplatform.py @@ -4,6 +4,9 @@ ON_MAC = platform.system() == "Darwin" ON_LINUX = not ON_WINDOWS and not ON_MAC +# macOS reports arm64, Linux reports aarch64 +ON_ARM64 = platform.machine() == "arm64" or platform.machine() == "aarch64" + if ON_WINDOWS: from .localplatformwin import * from . import localplatformwin as localplatform diff --git a/backend/decky_loader/updater.py b/backend/decky_loader/updater.py index 3fdfcc048..dd179a9e9 100644 --- a/backend/decky_loader/updater.py +++ b/backend/decky_loader/updater.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, List, TypedDict if TYPE_CHECKING: from .main import PluginManager -from .localplatform.localplatform import chmod, service_restart, service_stop, ON_LINUX, ON_WINDOWS, get_keep_systemd_service, get_selinux +from .localplatform.localplatform import chmod, service_restart, service_stop, ON_LINUX, ON_WINDOWS, ON_ARM64, get_keep_systemd_service, get_selinux import shutil from typing import List, TYPE_CHECKING, TypedDict import zipfile @@ -92,15 +92,24 @@ def get_service_url(self): return str(url) async def get_version_info(self): + managed_externally = ON_LINUX and ON_ARM64 return { "current": self.localVer, "remote": self.remoteVer, - "all": self.allRemoteVers, - "updatable": self.localVer != "unknown" and self.localVer != "dev" + "all": None if managed_externally else self.allRemoteVers, + "updatable": self.localVer != "unknown" and self.localVer != "dev" and not managed_externally, + "managed_externally": managed_externally, } async def check_for_updates(self): logger.debug("checking for updates") + # DroidDeck installs and updates the ARM64 binary from its compatible release + # assets. The upstream updater only knows the regular Linux binary and could + # replace the working ARM64 loader with an incompatible executable. + if ON_LINUX and ON_ARM64: + self.remoteVer = None + self.allRemoteVers = [] + return await self.get_version_info() selectedBranch = self.get_branch(self.context.settings) async with ClientSession() as web: async with web.request("GET", "https://api.github.com/repos/SteamDeckHomebrew/decky-loader/releases", headers={'X-GitHub-Api-Version': '2022-11-28'}, ssl=helpers.get_ssl_context()) as res: @@ -138,8 +147,35 @@ async def version_reloader(self): pass await sleep(60 * 60 * 6) # 6 hours + def get_remote_binary_name(self): + """ + The binaries on GitHub contain an extra part in their name to denote their architecture + + For example: + Windows: PluginLoader.exe + Linux (x86_64): PluginLoader + Linux (arm64): PluginLoader-arm64 + """ + binary_name = "PluginLoader" + + if ON_ARM64 and ON_LINUX: + binary_name += "-arm64" + + if ON_WINDOWS: + binary_name += ".exe" + + return binary_name + + def get_local_binary_name(self): + binary_name = "PluginLoader" + + if ON_WINDOWS: + binary_name += ".exe" + + return binary_name + async def download_decky_binary(self, download_url: str, version: str, is_zip: bool = False, size_in_bytes: int | None = None): - download_filename = "PluginLoader" if ON_LINUX else "PluginLoader.exe" + download_filename = self.get_local_binary_name() download_temp_filename = download_filename + ".new" if size_in_bytes == None: @@ -187,6 +223,9 @@ async def download_decky_binary(self, download_url: str, version: str, is_zip: b async def do_update(self): logger.debug("Starting update.") + if ON_LINUX and ON_ARM64: + logger.warning("ARM64 Decky updates are managed by DroidDeck.") + return try: assert self.remoteVer except AssertionError: @@ -196,10 +235,10 @@ async def do_update(self): version = self.remoteVer["tag_name"] download_url = None size_in_bytes = None - download_filename = "PluginLoader" if ON_LINUX else "PluginLoader.exe" + remote_binary_name = self.get_remote_binary_name() for x in self.remoteVer["assets"]: - if x["name"] == download_filename: + if x["name"] == remote_binary_name: download_url = x["browser_download_url"] size_in_bytes = x["size"] break @@ -247,6 +286,8 @@ async def do_shutdown(self): await service_stop("plugin_loader") async def get_testing_versions(self) -> List[TestingVersion]: + if ON_LINUX and ON_ARM64: + return [] result: List[TestingVersion] = [] async with ClientSession() as web: async with web.request("GET", "https://api.github.com/repos/SteamDeckHomebrew/decky-loader/pulls", @@ -262,6 +303,9 @@ async def get_testing_versions(self) -> List[TestingVersion]: return result async def download_testing_version(self, pr_id: int, sha_id: str): + if ON_LINUX and ON_ARM64: + logger.warning("ARM64 testing builds are managed by DroidDeck.") + return down_id = '' #Get all the associated workflow run for the given sha_id code hash async with ClientSession() as web: @@ -273,7 +317,10 @@ async def download_testing_version(self, pr_id: int, sha_id: str): if ON_WINDOWS and work['name'] == 'Builder Win': down_id=work['id'] break - elif ON_LINUX and work['name'] == 'Builder': + elif ON_LINUX and ON_ARM64 and work['name'] == 'Builder ARM64': + down_id=work['id'] + break + elif ON_LINUX and not ON_ARM64 and work['name'] == 'Builder': down_id=work['id'] break if down_id != '': diff --git a/backend/decky_loader/utilities.py b/backend/decky_loader/utilities.py index c74ac5d14..99a1b0197 100644 --- a/backend/decky_loader/utilities.py +++ b/backend/decky_loader/utilities.py @@ -333,20 +333,24 @@ async def filepicker_ls(self, #Resolving all files/folders in the requested directory for file in path_obj.iterdir(): - if file.exists(): - filest = file.stat() - is_hidden = file.name.startswith('.') - if ON_WINDOWS and not is_hidden: - is_hidden = bool(filest.st_file_attributes & FILE_ATTRIBUTE_HIDDEN) # type: ignore - if include_folders and file.is_dir(): - if (is_hidden and include_hidden) or not is_hidden: - folders.append({"file": file, "filest": filest, "is_dir": True}) - elif include_files: - # Handle requested extensions if present - if include_ext == None or len(include_ext) == 0 or 'all_files' in include_ext \ - or splitext(file.name)[1].lstrip('.').upper() in (ext.upper() for ext in include_ext): + try: + if file.exists(): + filest = file.stat() + is_hidden = file.name.startswith('.') + if ON_WINDOWS and not is_hidden: + is_hidden = bool(filest.st_file_attributes & FILE_ATTRIBUTE_HIDDEN) # type: ignore + if include_folders and file.is_dir(): if (is_hidden and include_hidden) or not is_hidden: - files.append({"file": file, "filest": filest, "is_dir": False}) + folders.append({"file": file, "filest": filest, "is_dir": True}) + elif include_files: + # Handle requested extensions if present + if include_ext == None or len(include_ext) == 0 or 'all_files' in include_ext \ + or splitext(file.name)[1].lstrip('.').upper() in (ext.upper() for ext in include_ext): + if (is_hidden and include_hidden) or not is_hidden: + files.append({"file": file, "filest": filest, "is_dir": False}) + except (PermissionError, OSError): + self.logger.debug(f"Skipping inaccessible file picker entry: {file}") + continue # Filter logic if filter_for is not None: try: diff --git a/backend/pyinstaller.spec b/backend/pyinstaller.spec index 91ff4e5b3..13ac66a07 100644 --- a/backend/pyinstaller.spec +++ b/backend/pyinstaller.spec @@ -9,7 +9,7 @@ a = Analysis( ('decky_loader/locales', 'decky_loader/locales'), ('decky_loader/static', 'decky_loader/static'), ] + copy_metadata('decky_loader'), - hiddenimports=['logging.handlers', 'sqlite3', 'decky_plugin', 'decky'], + hiddenimports=['logging.handlers', 'sqlite3', 'http.server', 'socketserver', 'configparser', 'decky_plugin', 'decky'], ) pyz = PYZ(a.pure, a.zipped_data) diff --git a/frontend/src/components/DeckyState.tsx b/frontend/src/components/DeckyState.tsx index d1b558c15..db938a26b 100644 --- a/frontend/src/components/DeckyState.tsx +++ b/frontend/src/components/DeckyState.tsx @@ -10,6 +10,7 @@ interface PublicDeckyState { disabledPlugins: DisabledPlugin[]; installedPlugins: (Plugin | DisabledPlugin)[]; pluginOrder: string[]; + sortPlugins: boolean; frozenPlugins: string[]; hiddenPlugins: string[]; activePlugin: Plugin | null; @@ -31,6 +32,7 @@ export class DeckyState { private _disabledPlugins: DisabledPlugin[] = []; private _installedPlugins: (Plugin | DisabledPlugin)[] = []; private _pluginOrder: string[] = []; + private _sortPlugins: boolean = false; private _frozenPlugins: string[] = []; private _hiddenPlugins: string[] = []; private _activePlugin: Plugin | null = null; @@ -49,6 +51,7 @@ export class DeckyState { disabledPlugins: this._disabledPlugins, installedPlugins: this._installedPlugins, pluginOrder: this._pluginOrder, + sortPlugins: this._sortPlugins, frozenPlugins: this._frozenPlugins, hiddenPlugins: this._hiddenPlugins, activePlugin: this._activePlugin, @@ -83,6 +86,11 @@ export class DeckyState { this.notifyUpdate(); } + setSortPlugins(sortPlugins: boolean) { + this._sortPlugins = sortPlugins; + this.notifyUpdate(); + } + setFrozenPlugins(frozenPlugins: string[]) { this._frozenPlugins = frozenPlugins; this.notifyUpdate(); @@ -139,6 +147,7 @@ interface DeckyStateContext extends PublicDeckyState { setActivePlugin(name: string): void; setPluginOrder(pluginOrder: string[]): void; setDisabledPlugins(disabled: DisabledPlugin[]): void; + setSortPlugins(sortPlugins: boolean): void; closeActivePlugin(): void; } @@ -178,6 +187,7 @@ export const DeckyStateContextProvider: FC = ({ children, deckyState }) = const closeActivePlugin = deckyState.closeActivePlugin.bind(deckyState); const setPluginOrder = deckyState.setPluginOrder.bind(deckyState); const setDisabledPlugins = deckyState.setDisabledPlugins.bind(deckyState); + const setSortPlugins = deckyState.setSortPlugins.bind(deckyState); return ( = ({ children, deckyState }) = closeActivePlugin, setPluginOrder, setDisabledPlugins, + setSortPlugins, }} > {children} diff --git a/frontend/src/components/PluginView.tsx b/frontend/src/components/PluginView.tsx index ffaa176a8..fbafaf051 100644 --- a/frontend/src/components/PluginView.tsx +++ b/frontend/src/components/PluginView.tsx @@ -16,6 +16,7 @@ const PluginView: FC = () => { updates, activePlugin, pluginOrder, + sortPlugins, setActivePlugin, closeActivePlugin, } = useDeckyState(); @@ -26,10 +27,14 @@ const PluginView: FC = () => { console.log('updating PluginView after changes'); return [...plugins] - .sort((a, b) => pluginOrder.indexOf(a.name) - pluginOrder.indexOf(b.name)) + .sort((a, b) => + sortPlugins + ? a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) + : pluginOrder.indexOf(a.name) - pluginOrder.indexOf(b.name), + ) .filter((p) => p.content) .filter(({ name }) => !hiddenPlugins.includes(name)); - }, [plugins, pluginOrder, hiddenPlugins]); + }, [plugins, pluginOrder, sortPlugins, hiddenPlugins]); const numberOfHidden = hiddenPlugins.filter((name) => !!plugins.find((p) => p.name === name)).length; diff --git a/frontend/src/components/settings/pages/general/Updater.tsx b/frontend/src/components/settings/pages/general/Updater.tsx index 37aceb722..475698aac 100644 --- a/frontend/src/components/settings/pages/general/Updater.tsx +++ b/frontend/src/components/settings/pages/general/Updater.tsx @@ -125,7 +125,9 @@ export default function UpdaterSettings() { onOptionsButton={versionInfo?.all ? showPatchNotes : undefined} label={t('Updater.updates.label')} description={ - checkingForUpdates || versionInfo?.remote?.tag_name != versionInfo?.current || !versionInfo?.remote ? ( + versionInfo?.managed_externally ? ( + Updates are managed in DroidDeck's Steam session settings. + ) : checkingForUpdates || versionInfo?.remote?.tag_name != versionInfo?.current || !versionInfo?.remote ? ( '' ) : ( {t('Updater.updates.lat_version', { ver: versionInfo?.current })} @@ -139,7 +141,7 @@ export default function UpdaterSettings() { } childrenContainerWidth={'fixed'} > - {updateProgress == -1 && !isLoaderUpdating ? ( + {versionInfo?.managed_externally ? null : updateProgress == -1 && !isLoaderUpdating ? ( void; }) { - const { versionInfo } = useDeckyState(); + const { versionInfo, sortPlugins, setSortPlugins } = useDeckyState(); + const [_, setSortPluginsSetting] = useSetting('sortPlugins', false); const { t } = useTranslation(); return ( @@ -42,6 +44,15 @@ export default function GeneralSettings({ }} /> + + { + setSortPlugins(toggleValue); + setSortPluginsSetting(toggleValue); + }} + /> + {t('SettingsGeneralIndex.about.header')} diff --git a/frontend/src/components/settings/pages/plugin_list/index.tsx b/frontend/src/components/settings/pages/plugin_list/index.tsx index 75365ba5b..71a7df6c4 100644 --- a/frontend/src/components/settings/pages/plugin_list/index.tsx +++ b/frontend/src/components/settings/pages/plugin_list/index.tsx @@ -11,7 +11,7 @@ import { ReorderableList, showContextMenu, } from '@decky/ui'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { FaDownload, FaEllipsisH, FaRecycle } from 'react-icons/fa'; @@ -171,9 +171,16 @@ type PluginData = { }; export default function PluginList({ isDeveloper }: { isDeveloper: boolean }) { - const { installedPlugins, disabledPlugins, updates, pluginOrder, setPluginOrder, frozenPlugins, hiddenPlugins } = - useDeckyState(); - + const { + installedPlugins, + disabledPlugins, + updates, + pluginOrder, + setPluginOrder, + frozenPlugins, + hiddenPlugins, + sortPlugins, + } = useDeckyState(); const [_, setPluginOrderSetting] = useSetting( 'pluginOrder', installedPlugins.map((plugin) => plugin.name), @@ -188,6 +195,12 @@ export default function PluginList({ isDeveloper }: { isDeveloper: boolean }) { const hiddenPluginsService = DeckyPluginLoader.hiddenPluginsService; const frozenPluginsService = DeckyPluginLoader.frozenPluginsService; + const sortedPlugins = useMemo(() => { + return sortPlugins + ? [...installedPlugins].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })) + : installedPlugins; + }, [installedPlugins, sortPlugins]); + useEffect(() => { setPluginEntries( installedPlugins.map(({ name, version }) => { @@ -204,7 +217,7 @@ export default function PluginList({ isDeveloper }: { isDeveloper: boolean }) { disabled={disabledPlugins.find((p) => p.name == name) !== undefined} /> ), - position: pluginOrder.indexOf(name), + position: sortPlugins ? sortedPlugins.findIndex((p) => p.name === name) : pluginOrder.indexOf(name), data: { name, disabled: disabledPlugins.some((disabledPlugin) => disabledPlugin.name === name), @@ -221,7 +234,7 @@ export default function PluginList({ isDeveloper }: { isDeveloper: boolean }) { }; }), ); - }, [installedPlugins, updates, hiddenPlugins, disabledPlugins]); + }, [installedPlugins, updates, hiddenPlugins, disabledPlugins, sortPlugins, sortedPlugins]); if (installedPlugins.length === 0) { return ( @@ -232,6 +245,9 @@ export default function PluginList({ isDeveloper }: { isDeveloper: boolean }) { } function onSave(entries: ReorderableEntry[]) { + if (sortPlugins) { + return; + } const newOrder = entries.map((entry) => entry.data!.name); console.log(newOrder); setPluginOrder(newOrder); @@ -265,7 +281,12 @@ export default function PluginList({ isDeveloper }: { isDeveloper: boolean }) { )} - entries={pluginEntries} onSave={onSave} interactables={PluginInteractables} /> + + entries={pluginEntries} + onSave={onSave} + interactables={PluginInteractables} + disableReordering={sortPlugins} + /> ); diff --git a/frontend/src/plugin-loader.tsx b/frontend/src/plugin-loader.tsx index f0a358ef6..31f4ec7e3 100644 --- a/frontend/src/plugin-loader.tsx +++ b/frontend/src/plugin-loader.tsx @@ -360,6 +360,12 @@ class PluginLoader extends Logger { this.deckyState.setPluginOrder(pluginOrder); }); + // Grab and set sort plugins setting + getSetting('sortPlugins', false).then((sortPlugins) => { + this.debug('sortPlugins: ', sortPlugins); + this.deckyState.setSortPlugins(sortPlugins); + }); + this.frozenPluginsService.init(); this.hiddenPluginsService.init(); this.notificationService.init(); diff --git a/frontend/src/updater.ts b/frontend/src/updater.ts index a8853b18c..205dc27cc 100644 --- a/frontend/src/updater.ts +++ b/frontend/src/updater.ts @@ -21,6 +21,7 @@ export interface VerInfo { remote: RemoteVerInfo | null; all: RemoteVerInfo[] | null; updatable: boolean; + managed_externally: boolean; } export const doUpdate = DeckyBackend.callable('updater/do_update');