mirror of
https://github.com/SteamDeckHomebrew/decky-loader.git
synced 2026-06-13 04:05:04 +03:00
handle crashloops and disable decky for the user
This commit is contained in:
+57
-43
@@ -11,48 +11,62 @@ import { visualizer } from 'rollup-plugin-visualizer';
|
||||
|
||||
const hiddenWarnings = ['THIS_IS_UNDEFINED', 'EVAL'];
|
||||
|
||||
export default defineConfig({
|
||||
input: 'src/index.ts',
|
||||
plugins: [
|
||||
del({ targets: '../backend/decky_loader/static/*', force: true }),
|
||||
commonjs(),
|
||||
nodeResolve({
|
||||
browser: true,
|
||||
}),
|
||||
externalGlobals({
|
||||
react: 'SP_REACT',
|
||||
'react-dom': 'SP_REACTDOM',
|
||||
// hack to shut up react-markdown
|
||||
process: '{cwd: () => {}}',
|
||||
path: '{dirname: () => {}, join: () => {}, basename: () => {}, extname: () => {}}',
|
||||
url: '{fileURLToPath: (f) => f}',
|
||||
}),
|
||||
typescript(),
|
||||
json(),
|
||||
replace({
|
||||
preventAssignment: false,
|
||||
'process.env.NODE_ENV': JSON.stringify('production'),
|
||||
}),
|
||||
image(),
|
||||
visualizer(),
|
||||
],
|
||||
preserveEntrySignatures: false,
|
||||
treeshake: {
|
||||
// Assume all external modules have imports with side effects (the default) while allowing decky libraries to treeshake
|
||||
pureExternalImports: true,
|
||||
preset: 'smallest'
|
||||
},
|
||||
output: {
|
||||
dir: '../backend/decky_loader/static',
|
||||
format: 'esm',
|
||||
chunkFileNames: (chunkInfo) => {
|
||||
return 'chunk-[hash].js';
|
||||
export default defineConfig([
|
||||
// Main bundle
|
||||
{
|
||||
input: 'src/index.ts',
|
||||
plugins: [
|
||||
del({ targets: ['../backend/decky_loader/static/*', '!../backend/decky_loader/static/fallback.js'], force: true }),
|
||||
commonjs(),
|
||||
nodeResolve({
|
||||
browser: true,
|
||||
}),
|
||||
externalGlobals({
|
||||
react: 'SP_REACT',
|
||||
'react-dom': 'SP_REACTDOM',
|
||||
// hack to shut up react-markdown
|
||||
process: '{cwd: () => {}}',
|
||||
path: '{dirname: () => {}, join: () => {}, basename: () => {}, extname: () => {}}',
|
||||
url: '{fileURLToPath: (f) => f}',
|
||||
}),
|
||||
typescript(),
|
||||
json(),
|
||||
replace({
|
||||
preventAssignment: false,
|
||||
'process.env.NODE_ENV': JSON.stringify('production'),
|
||||
}),
|
||||
image(),
|
||||
visualizer(),
|
||||
],
|
||||
preserveEntrySignatures: false,
|
||||
treeshake: {
|
||||
// Assume all external modules have imports with side effects (the default) while allowing decky libraries to treeshake
|
||||
pureExternalImports: true,
|
||||
preset: 'smallest'
|
||||
},
|
||||
output: {
|
||||
dir: '../backend/decky_loader/static',
|
||||
format: 'esm',
|
||||
chunkFileNames: (chunkInfo) => {
|
||||
return 'chunk-[hash].js';
|
||||
},
|
||||
sourcemap: true,
|
||||
sourcemapPathTransform: (relativeSourcePath) => relativeSourcePath.replace(/^\.\.\//, `decky://decky/loader/`),
|
||||
},
|
||||
onwarn: function (message, handleWarning) {
|
||||
if (hiddenWarnings.some((warning) => message.code === warning)) return;
|
||||
handleWarning(message);
|
||||
},
|
||||
sourcemap: true,
|
||||
sourcemapPathTransform: (relativeSourcePath) => relativeSourcePath.replace(/^\.\.\//, `decky://decky/loader/`),
|
||||
},
|
||||
onwarn: function (message, handleWarning) {
|
||||
if (hiddenWarnings.some((warning) => message.code === warning)) return;
|
||||
handleWarning(message);
|
||||
},
|
||||
});
|
||||
// Fallback
|
||||
{
|
||||
input: 'src/fallback.ts',
|
||||
plugins: [
|
||||
typescript()
|
||||
],
|
||||
output: {
|
||||
file: '../backend/decky_loader/static/fallback.js',
|
||||
format: 'esm',
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
@@ -94,7 +94,7 @@ const DeckyErrorBoundary: FunctionComponent<DeckyErrorBoundaryProps> = ({ error,
|
||||
style={{ marginRight: '5px', padding: '5px' }}
|
||||
onClick={() => {
|
||||
addLogLine('Restarting Steam...');
|
||||
SteamClient.User.StartRestart();
|
||||
SteamClient.User.StartRestart(false);
|
||||
}}
|
||||
>
|
||||
Restart Steam
|
||||
@@ -121,7 +121,7 @@ const DeckyErrorBoundary: FunctionComponent<DeckyErrorBoundaryProps> = ({ error,
|
||||
doShutdown();
|
||||
await sleep(5000);
|
||||
addLogLine('Restarting Steam...');
|
||||
SteamClient.User.StartRestart();
|
||||
SteamClient.User.StartRestart(false);
|
||||
}}
|
||||
>
|
||||
Disable Decky until next boot
|
||||
@@ -166,7 +166,7 @@ const DeckyErrorBoundary: FunctionComponent<DeckyErrorBoundaryProps> = ({ error,
|
||||
await sleep(2000);
|
||||
addLogLine('Restarting Steam...');
|
||||
await sleep(500);
|
||||
SteamClient.User.StartRestart();
|
||||
SteamClient.User.StartRestart(false);
|
||||
}}
|
||||
>
|
||||
Uninstall {errorSource} and restart Decky
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// THIS FILE MUST BE ENTIRELY SELF-CONTAINED! DO NOT USE PACKAGES!
|
||||
interface Window {
|
||||
FocusNavController: any;
|
||||
GamepadNavTree: any;
|
||||
deckyFallbackLoaded?: boolean;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
if (window.deckyFallbackLoaded) return;
|
||||
window.deckyFallbackLoaded = true;
|
||||
|
||||
// #region utils
|
||||
function sleep(ms: number) {
|
||||
return new Promise((res) => setTimeout(res, ms));
|
||||
}
|
||||
// #endregion
|
||||
|
||||
// #region DeckyIcon
|
||||
const fallbackIcon = `
|
||||
<svg class="fallbackDeckyIcon" xmlns="http://www.w3.org/2000/svg" height="100%" width="100%" viewBox="0 0 512 456">
|
||||
<g>
|
||||
<path
|
||||
style="fill: none;"
|
||||
d="M154.33,72.51v49.79c11.78-0.17,23.48,2,34.42,6.39c10.93,4.39,20.89,10.91,29.28,19.18
|
||||
c8.39,8.27,15.06,18.13,19.61,29c4.55,10.87,6.89,22.54,6.89,34.32c0,11.78-2.34,23.45-6.89,34.32
|
||||
c-4.55,10.87-11.21,20.73-19.61,29c-8.39,8.27-18.35,14.79-29.28,19.18c-10.94,4.39-22.63,6.56-34.42,6.39v49.77
|
||||
c36.78,0,72.05-14.61,98.05-40.62c26-26.01,40.61-61.28,40.61-98.05c0-36.78-14.61-72.05-40.61-98.05
|
||||
C226.38,87.12,191.11,72.51,154.33,72.51z"
|
||||
/>
|
||||
|
||||
<ellipse
|
||||
transform="matrix(0.982 -0.1891 0.1891 0.982 -37.1795 32.9988)"
|
||||
style="fill: none;"
|
||||
cx="154.33"
|
||||
cy="211.33"
|
||||
rx="69.33"
|
||||
ry="69.33"
|
||||
/>
|
||||
<path style="fill: none;" d="M430,97h-52v187h52c7.18,0,13-5.82,13-13V110C443,102.82,437.18,97,430,97z" />
|
||||
<path
|
||||
style="fill: currentColor;"
|
||||
d="M432,27h-54V0H0v361c0,52.47,42.53,95,95,95h188c52.47,0,95-42.53,95-95v-7h54c44.18,0,80-35.82,80-80V107
|
||||
C512,62.82,476.18,27,432,27z M85,211.33c0-38.29,31.04-69.33,69.33-69.33c38.29,0,69.33,31.04,69.33,69.33
|
||||
c0,38.29-31.04,69.33-69.33,69.33C116.04,280.67,85,249.62,85,211.33z M252.39,309.23c-26.01,26-61.28,40.62-98.05,40.62v-49.77
|
||||
c11.78,0.17,23.48-2,34.42-6.39c10.93-4.39,20.89-10.91,29.28-19.18c8.39-8.27,15.06-18.13,19.61-29
|
||||
c4.55-10.87,6.89-22.53,6.89-34.32c0-11.78-2.34-23.45-6.89-34.32c-4.55-10.87-11.21-20.73-19.61-29
|
||||
c-8.39-8.27-18.35-14.79-29.28-19.18c-10.94-4.39-22.63-6.56-34.42-6.39V72.51c36.78,0,72.05,14.61,98.05,40.61
|
||||
c26,26.01,40.61,61.28,40.61,98.05C293,247.96,278.39,283.23,252.39,309.23z M443,271c0,7.18-5.82,13-13,13h-52V97h52
|
||||
c7.18,0,13,5.82,13,13V271z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
`;
|
||||
// #endregion
|
||||
|
||||
// #region findSP
|
||||
// from @decky/ui
|
||||
function getFocusNavController(): any {
|
||||
return window.GamepadNavTree?.m_context?.m_controller || window.FocusNavController;
|
||||
}
|
||||
|
||||
function getGamepadNavigationTrees(): any {
|
||||
const focusNav = getFocusNavController();
|
||||
const context = focusNav.m_ActiveContext || focusNav.m_LastActiveContext;
|
||||
return context?.m_rgGamepadNavigationTrees;
|
||||
}
|
||||
|
||||
function findSP(): Window {
|
||||
// old (SP as host)
|
||||
if (document.title == 'SP') return window;
|
||||
// new (SP as popup)
|
||||
const navTrees = getGamepadNavigationTrees();
|
||||
return navTrees?.find((x: any) => x.m_ID == 'root_1_').Root.Element.ownerDocument.defaultView;
|
||||
}
|
||||
// #endregion
|
||||
|
||||
const fallbackCSS = `
|
||||
.fallbackContainer {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
flex-direction: column;
|
||||
z-index: 99999999;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
backdrop-filter: blur(8px) brightness(40%);
|
||||
}
|
||||
.fallbackDeckyIcon {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
`;
|
||||
|
||||
const fallbackHTML = `
|
||||
<style>${fallbackCSS}</style>
|
||||
${fallbackIcon}
|
||||
<span class="fallbackText">
|
||||
<b>A crash loop has been detected and Decky has been disabled for this boot.</b>
|
||||
<br>
|
||||
<i>Steam will restart in 10 seconds...</i>
|
||||
</span>
|
||||
`;
|
||||
|
||||
await sleep(4000);
|
||||
|
||||
const win = findSP() || window;
|
||||
|
||||
const container = Object.assign(document.createElement('div'), {
|
||||
innerHTML: fallbackHTML,
|
||||
});
|
||||
container.classList.add('fallbackContainer');
|
||||
|
||||
win.document.body.appendChild(container);
|
||||
|
||||
await sleep(10000);
|
||||
|
||||
SteamClient.User.StartShutdown(false);
|
||||
} catch (e) {
|
||||
console.error('Error showing fallback!', e);
|
||||
}
|
||||
})();
|
||||
@@ -172,11 +172,32 @@ class PluginLoader extends Logger {
|
||||
.then(() => this.log('Initialized'));
|
||||
}
|
||||
|
||||
private checkForSP(): boolean {
|
||||
try {
|
||||
return !!findSP();
|
||||
} catch (e) {
|
||||
this.warn('Error checking for SP tab', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async runCrashChecker() {
|
||||
const spExists = this.checkForSP();
|
||||
await sleep(5000);
|
||||
if (spExists && !this.checkForSP()) {
|
||||
// SP died after plugin loaded. Give up and let the loader's crash loop detection handle it.
|
||||
this.error('SP died during startup. Restarting webhelper.');
|
||||
await this.restartWebhelper();
|
||||
}
|
||||
}
|
||||
|
||||
private getPluginsFromBackend = DeckyBackend.callable<
|
||||
[],
|
||||
{ name: string; version: string; load_type: PluginLoadType }[]
|
||||
>('loader/get_plugins');
|
||||
|
||||
private restartWebhelper = DeckyBackend.callable<[], void>('utilities/restart_webhelper');
|
||||
|
||||
private async loadPlugins() {
|
||||
let registration: any;
|
||||
const uiMode = await new Promise(
|
||||
@@ -192,6 +213,7 @@ class PluginLoader extends Logger {
|
||||
await sleep(100);
|
||||
}
|
||||
}
|
||||
this.runCrashChecker();
|
||||
const plugins = await this.getPluginsFromBackend();
|
||||
const pluginLoadPromises = [];
|
||||
const loadStart = performance.now();
|
||||
@@ -395,6 +417,7 @@ class PluginLoader extends Logger {
|
||||
version?: string,
|
||||
loadType: PluginLoadType = PluginLoadType.ESMODULE_V1,
|
||||
) {
|
||||
let spExists = this.checkForSP();
|
||||
try {
|
||||
switch (loadType) {
|
||||
case PluginLoadType.ESMODULE_V1:
|
||||
@@ -442,7 +465,7 @@ class PluginLoader extends Logger {
|
||||
</PanelSectionRow>
|
||||
<PanelSectionRow>
|
||||
<pre style={{ overflowX: 'scroll' }}>
|
||||
<code>{e instanceof Error ? e.stack : JSON.stringify(e)}</code>
|
||||
<code>{e instanceof Error ? '' + e.stack : JSON.stringify(e)}</code>
|
||||
</pre>
|
||||
</PanelSectionRow>
|
||||
<PanelSectionRow>
|
||||
@@ -474,6 +497,12 @@ class PluginLoader extends Logger {
|
||||
icon: <FaExclamationCircle />,
|
||||
});
|
||||
}
|
||||
|
||||
if (spExists && !this.checkForSP()) {
|
||||
// SP died after plugin loaded. Give up and let the loader's crash loop detection handle it.
|
||||
this.error('SP died after loading plugin. Restarting webhelper.');
|
||||
await this.restartWebhelper();
|
||||
}
|
||||
}
|
||||
|
||||
async callServerMethod(methodName: string, args = {}) {
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import type { ToastData, ToastNotification } from '@decky/api';
|
||||
import { Patch, callOriginal, findModuleExport, injectFCTrampoline, replacePatch } from '@decky/ui';
|
||||
import {
|
||||
ErrorBoundary,
|
||||
Patch,
|
||||
callOriginal,
|
||||
findModuleExport,
|
||||
injectFCTrampoline,
|
||||
replacePatch,
|
||||
sleep,
|
||||
} from '@decky/ui';
|
||||
|
||||
import Toast from './components/Toast';
|
||||
import Logger from './logger';
|
||||
@@ -21,6 +29,8 @@ declare global {
|
||||
|
||||
class Toaster extends Logger {
|
||||
private toastPatch?: Patch;
|
||||
private markReady!: () => void;
|
||||
private ready = new Promise<void>((r) => (this.markReady = r));
|
||||
|
||||
constructor() {
|
||||
super('Toaster');
|
||||
@@ -34,13 +44,16 @@ class Toaster extends Logger {
|
||||
this.toastPatch = replacePatch(patchedRenderer, 'component', (args: any[]) => {
|
||||
if (args?.[0]?.group?.decky || args?.[0]?.group?.notifications?.[0]?.decky) {
|
||||
return args[0].group.notifications.map((notification: any) => (
|
||||
<Toast toast={notification.data} newIndicator={notification.bNewIndicator} location={args?.[0]?.location} />
|
||||
<ErrorBoundary>
|
||||
<Toast toast={notification.data} newIndicator={notification.bNewIndicator} location={args?.[0]?.location} />
|
||||
</ErrorBoundary>
|
||||
));
|
||||
}
|
||||
return callOriginal;
|
||||
});
|
||||
|
||||
this.log('Initialized');
|
||||
sleep(4000).then(this.markReady);
|
||||
}
|
||||
|
||||
toast(toast: ToastData): ToastNotification {
|
||||
@@ -107,7 +120,7 @@ class Toaster extends Logger {
|
||||
}
|
||||
}, toast.expiration);
|
||||
}
|
||||
window.NotificationStore.ProcessNotification(info, toastData, ToastType.New);
|
||||
this.ready.then(() => window.NotificationStore.ProcessNotification(info, toastData, ToastType.New));
|
||||
return toastResult;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user