diff --git a/src/locales/en.json b/src/locales/en.json index f9a382f1..86d63c15 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -457,11 +457,11 @@ "string": "Zip, combine assets into single file (not recommended)" }, "application-win32": { - "string": "{type} Windows application (32-bit or 64-bit)", + "string": "{type} Windows application (32-bit)", "context": "type will become something like 'NW.js' or 'Electron'. Do not translate 'Windows'." }, "application-win64": { - "string": "{type} Windows application (64-bit only, not recommended)", + "string": "{type} Windows application (64-bit)", "context": "type will become something like 'NW.js' or 'Electron'. Do not translate 'Windows'." }, "application-win-arm": { @@ -473,7 +473,7 @@ "context": "type will become something like 'NW.js' or 'Electron'. Do not translate 'macOS'." }, "application-linux64": { - "string": "{type} Linux application (64-bit only)", + "string": "{type} Linux application (64-bit)", "context": "type will become something like 'NW.js' or 'Electron'. Do not translate 'Linux'." }, "application-linux-arm32": { @@ -521,6 +521,39 @@ "maxTextureDimension": { "string": "Increase max vector costume resolution to make large costumes look better. May increase memory use and cause crashes." }, + "steamworksExtension": { + "string": "Steamworks Extension", + "context": "Title of section that appears when using the Steamworks extension" + }, + "steamworksMacWarning": { + "string": "Warning: macOS games published on Steam need to be notarized by Apple, which the packager doesn't support. You can still test your game on a Mac, but you won't be able to publish it for macOS yet.", + "developer_comment": "When using the Steamworks extension, warning that appears whenever macOS support is used or mentioned." + }, + "steamworksDocumentation": { + "string": "See the extension's documentation for more details.", + "context": "Link to more documentation about the Steamworks extension" + }, + "steamworksUnavailable": { + "string": "To enable the Steamworks extension, you must use one of these environments:" + }, + "steamworksAvailable": { + "string": "This project is using the Steamworks extension. You can find your game's App ID in Steamworks, or use {n} for testing with the Steamworks demo game." + }, + "steamworksAppId": { + "string": "App ID" + }, + "steamworksOnError": { + "string": "If there is an error initializing Steamworks" + }, + "steamworksIgnore": { + "string": "Do nothing" + }, + "steamworksWarning": { + "string": "Show a warning but continue" + }, + "steamworksError": { + "string": "Show an error and exit" + }, "package": { "string": "Package", "context": "Button to package the project" @@ -620,4 +653,4 @@ "description": "Button to cancel import" } } -} \ No newline at end of file +} diff --git a/src/p4/PackagerOptions.svelte b/src/p4/PackagerOptions.svelte index b476c73b..cd816f2a 100644 --- a/src/p4/PackagerOptions.svelte +++ b/src/p4/PackagerOptions.svelte @@ -151,6 +151,8 @@ thing = 'Electron'; } else if (detail.asset === 'webview-mac') { thing = 'WKWebView'; + } else if (detail.asset === 'steamworks.js') { + thing = 'Steamworks.js'; } if (thing) { task.setProgressText($_('progress.loadingLargeAsset').replace('{thing}', thing)); @@ -1022,6 +1024,54 @@ {/if} +{#if projectData.project.analysis.usesSteamworks} +
{ + resetOptions([ + 'steamworks' + ]); + }} + > +

{$_('options.steamworksExtension')}

+ {#if ['electron-win64', 'electron-linux64', 'electron-mac'].includes($options.target)} +

{$_('options.steamworksAvailable').replace('{n}', '480')}

+ + + + {#if $options.target === 'electron-mac'} +

+ {$_('options.steamworksMacWarning')} +

+ {/if} + {:else} +

{$_('options.steamworksUnavailable')}

+ + {/if} +
+{/if} + +

+ {$_('options.steamworksDocumentation')} +

importOptionsFromDataTransfer(e.detail)}>
diff --git a/src/packager/download-project.js b/src/packager/download-project.js index 18282ca1..2716e46d 100644 --- a/src/packager/download-project.js +++ b/src/packager/download-project.js @@ -5,6 +5,7 @@ const unknownAnalysis = () => ({ stageVariables: [], stageComments: [], usesMusic: true, + usesSteamworks: false, extensions: [] }); @@ -40,6 +41,7 @@ const analyzeScratch3 = (projectData) => { .map((i) => i.text); // TODO: usesMusic has possible false negatives const usesMusic = projectData.extensions.includes('music'); + const usesSteamworks = projectData.extensions.includes('steamworks'); const extensions = projectData.extensionURLs ? Object.values(projectData.extensionURLs) : []; return { ...unknownAnalysis(), diff --git a/src/packager/packager.js b/src/packager/packager.js index 2246d6cf..6047e208 100644 --- a/src/packager/packager.js +++ b/src/packager/packager.js @@ -526,6 +526,7 @@ cd "$(dirname "$0")" const contentsPrefix = isMac ? `${rootPrefix}${packageName}.app/Contents/` : rootPrefix; const resourcesPrefix = isMac ? `${contentsPrefix}Resources/app/` : `${contentsPrefix}resources/app/`; const electronMainName = 'electron-main.js'; + const electronPreloadName = 'electron-preload.js'; const iconName = 'icon.png'; const icon = await Adapter.getAppIcon(this.options.app.icon); @@ -538,8 +539,8 @@ cd "$(dirname "$0")" }; zip.file(`${resourcesPrefix}package.json`, JSON.stringify(manifest, null, 4)); - const mainJS = `'use strict'; -const {app, BrowserWindow, Menu, shell, screen, dialog} = require('electron'); + let mainJS = `'use strict'; +const {app, BrowserWindow, Menu, shell, screen, dialog, ipcMain} = require('electron'); const path = require('path'); const isWindows = process.platform === 'win32'; @@ -572,6 +573,7 @@ const createWindow = (windowOptions) => { sandbox: true, contextIsolation: true, nodeIntegration: false, + preload: path.resolve(__dirname, ${JSON.stringify(electronPreloadName)}), }, show: true, width: 480, @@ -661,12 +663,23 @@ const openLink = (url) => { } }; +const createProcessCrashMessage = (details) => { + let message = details.type ? details.type + ' child process' : 'Renderer process'; + message += ' crashed: ' + details.reason + ' (' + details.exitCode + ')\\n\\n'; + if (process.arch === 'ia32') { + message += 'Usually this means the project was too big for the 32-bit Electron environment or your computer is out of memory. Ask the creator to use the 64-bit environment instead.'; + } else { + message += 'Usually this means your computer is out of memory.'; + } + return message; +}; + app.on('render-process-gone', (event, webContents, details) => { const window = BrowserWindow.fromWebContents(webContents); dialog.showMessageBoxSync(window, { type: 'error', title: 'Error', - message: 'Renderer process crashed: ' + details.reason + ' (' + details.exitCode + ')' + message: createProcessCrashMessage(details) }); }); @@ -674,7 +687,7 @@ app.on('child-process-gone', (event, details) => { dialog.showMessageBoxSync({ type: 'error', title: 'Error', - message: details.type + ' child process crashed: ' + details.reason + ' (' + details.exitCode + ')' + message: createProcessCrashMessage(details) }); }); @@ -720,7 +733,176 @@ app.whenReady().then(() => { createProjectWindow(defaultProjectURL); }); `; + + let preloadJS = `'use strict'; +const {contextBridge, ipcRenderer} = require('electron'); +`; + + if ( + this.project.analysis.usesSteamworks && + ['electron-win64', 'electron-linux64', 'electron-mac'].includes(this.options.target) + ) { + mainJS += ` + const enableSteamworks = () => { + const APP_ID = +${JSON.stringify(this.options.steamworks.appId)}; + const steamworks = require('./steamworks.js/'); + const client = steamworks.init(APP_ID); + const async = (event, callback) => ipcMain.handle(event, (e, ...args) => { + return callback(...args); + }); + const sync = (event, callback) => ipcMain.on(event, (e, ...args) => { + e.returnValue = callback(...args); + }); + async('Steamworks.achievement.activate', (achievement) => client.achievement.activate(achievement)); + async('Steamworks.achievement.clear', (achievement) => client.achievement.clear(achievement)); + sync('Steamworks.achievement.isActivated', (achievement) => client.achievement.isActivated(achievement)); + sync('Steamworks.apps.isDlcInstalled', (dlc) => client.apps.isDlcInstalled(dlc)); + sync('Steamworks.localplayer.getName', () => client.localplayer.getName()); + sync('Steamworks.localplayer.getLevel', () => client.localplayer.getLevel()); + sync('Steamworks.localplayer.getIpCountry', () => client.localplayer.getIpCountry()); + sync('Steamworks.localplayer.getSteamId', () => client.localplayer.getSteamId()); + async('Steamworks.overlay.activateToWebPage', (url) => client.overlay.activateToWebPage(url)); + + //-----NEW PENGUINMOD STUFF----- + sync('Steamworks.init', (appId) => client.init(appId)); + sync('Steamworks.restartAppIfNecessary', (appId) => client.restartAppIfNecessary(appId)); + sync('Steamworks.runCallbacks', () => client.runCallbacks()); + + async('Steamworks.auth.getSessionTicketWithSteamId', (steamId64, timeoutSeconds) => client.auth.getSessionTicketWithSteamId(steamId64, timeoutSeconds)); + async('Steamworks.auth.getSessionTicketWithIp', (ip, timeoutSeconds) => client.auth.getSessionTicketWithIp(ip, timeoutSeconds)); + async('Steamworks.auth.getAuthTicketForWebApi', (identity, timeoutSeconds) => client.auth.getAuthTicketForWebApi(identity, timeoutSeconds)); + + sync('Steamworks.apps.isSubscribedApp', (appId) => client.apps.isSubscribedApp(appId)); + sync('Steamworks.apps.isAppInstalled', (appId) => client.apps.isAppInstalled(appId)); + sync('Steamworks.apps.isSubscribedFromFreeWeekend', () => client.apps.isSubscribedFromFreeWeekend()); + sync('Steamworks.apps.isVacBanned', () => client.apps.isVacBanned()); + sync('Steamworks.apps.isCybercafe', () => client.apps.isCybercafe()); + sync('Steamworks.apps.isLowViolence', () => client.apps.isLowViolence()); + sync('Steamworks.apps.isSubscribed', () => client.apps.isSubscribed()); + sync('Steamworks.apps.appBuildId', () => client.apps.appBuildId()); + sync('Steamworks.apps.appInstallDir', (appId) => client.apps.appInstallDir(appId)); + sync('Steamworks.apps.appOwner', () => client.apps.appOwner()); + sync('Steamworks.apps.availableGameLanguages', () => client.apps.availableGameLanguages()); + sync('Steamworks.apps.currentGameLanguage', () => client.apps.currentGameLanguage()); + sync('Steamworks.apps.currentBetaName', () => client.apps.currentBetaName()); + + sync('Steamworks.cloud.isEnabledForAccount', () => client.cloud.isEnabledForAccount()); + sync('Steamworks.cloud.isEnabledForApp', () => client.cloud.isEnabledForApp()); + sync('Steamworks.cloud.listFiles', () => client.cloud.listFiles()); + + sync('Steamworks.input.init', () => client.input.init()); + sync('Steamworks.input.getControllers', () => client.input.getControllers()); + sync('Steamworks.input.getActionSet', (actionSetName) => client.input.getActionSet(actionSetName)); + sync('Steamworks.input.getDigitalAction', (actionName) => client.input.getDigitalAction(actionName)); + sync('Steamworks.input.getAnalogAction', (actionName) => client.input.getAnalogAction(actionName)); + sync('Steamworks.input.shutdown', () => client.input.shutdown()); + + async('Steamworks.matchmaking.createLobby', (lobbyType, maxMembers) => client.matchmaking.createLobby(lobbyType, maxMembers)); + async('Steamworks.matchmaking.joinLobby', (lobbyId) => client.matchmaking.joinLobby(lobbyId)); + async('Steamworks.matchmaking.getLobbies', () => client.matchmaking.getLobbies()); + + async('Steamworks.networking.sendP2PPacket', (steamId64, sendType, data) => client.networking.sendP2PPacket(steamId64, sendType, data)); + sync('Steamworks.networking.acceptP2PSession', (steamId64) => client.networking.acceptP2PSession(steamId64)); + + sync('Steamworks.overlay.activateDialog', (dialog) => client.overlay.activateDialog(dialog)); + sync('Steamworks.overlay.activateDialogToUser', (dialog, steamId64) => client.overlay.activateDialogToUser(dialog, steamId64)); + sync('Steamworks.overlay.activateInviteDialog', (lobbyId) => client.overlay.activateInviteDialog(lobbyId)); + sync('Steamworks.overlay.activateToStore', (appId, flag) => client.overlay.activateToStore(appId, flag)); + + sync('Steamworks.stats.getInt', (name) => client.stats.getInt(name)); + sync('Steamworks.stats.setInt', (name, value) => client.stats.setInt(name, value)); + sync('Steamworks.stats.store', () => client.stats.store()); + sync('Steamworks.stats.resetAll', (achievementsToo) => client.stats.resetAll(achievementsToo)); + + sync('Steamworks.utils.getAppId', () => client.utils.getAppId()); + sync('Steamworks.utils.getServerRealTime', () => client.utils.getServerRealTime()); + sync('Steamworks.utils.isSteamRunningOnSteamDeck', () => client.utils.isSteamRunningOnSteamDeck()); + async('Steamworks.utils.showGamepadTextInput', (inputMode, inputLineMode, description, maxCharacters, existingText) => client.utils.showGamepadTextInput(inputMode, inputLineMode, description, maxCharacters, existingText)); + async('Steamworks.utils.showFloatingGamepadTextInput', (keyboardMode, x, y, width, height) => client.utils.showFloatingGamepadTextInput(keyboardMode, x, y, width, height)); + + async('Steamworks.workshop.createItem', (appId) => client.workshop.createItem(appId)); + async('Steamworks.workshop.updateItem', (itemId, updateDetails, appId) => client.workshop.updateItem(itemId, updateDetails, appId)); + async('Steamworks.workshop.updateItemWithCallback', (itemId, updateDetails, appId, successCallback, errorCallback, progressCallback, progressCallbackIntervalMs) => client.workshop.updateItemWithCallback(itemId, updateDetails, appId, successCallback, errorCallback, progressCallback, progressCallbackIntervalMs)); + async('Steamworks.workshop.subscribe', (itemId) => client.workshop.subscribe(itemId)); + async('Steamworks.workshop.unsubscribe', (itemId) => client.workshop.unsubscribe(itemId)); + sync('Steamworks.workshop.state', (itemId) => client.workshop.state(itemId)); + sync('Steamworks.workshop.installInfo', (itemId) => client.workshop.installInfo(itemId)); + sync('Steamworks.workshop.downloadInfo', (itemId) => client.workshop.downloadInfo(itemId)); + sync('Steamworks.workshop.download', (itemId, highPriority) => client.workshop.download(itemId, highPriority)); + sync('Steamworks.workshop.getSubscribedItems', () => client.workshop.getSubscribedItems()); + async('Steamworks.workshop.getItem', (item, queryConfig) => client.workshop.getItem(item, queryConfig)); + async('Steamworks.workshop.getItems', (items, queryConfig) => client.workshop.getItems(items, queryConfig)); + async('Steamworks.workshop.getAllItems', (page, queryType, itemType, creatorAppId, consumerAppId, queryConfig) => client.workshop.getAllItems(page, queryType, itemType, creatorAppId, consumerAppId, queryConfig)); + async('Steamworks.workshop.getUserItems', (page, accountId, listType, itemType, sortOrder, creatorAppId, consumerAppId, queryConfig) => client.workshop.getUserItems(page, accountId, listType, itemType, sortOrder, creatorAppId, consumerAppId, queryConfig)); + + steamworks.electronEnableSteamOverlay(); + sync('Steamworks.ok', () => true); + }; + try { + enableSteamworks(); + } catch (e) { + console.error(e); + ipcMain.on('Steamworks.ok', (e) => { + e.returnValue = false; + }); + app.whenReady().then(() => { + const ON_ERROR = ${JSON.stringify(this.options.steamworks.onError)}; + const window = BrowserWindow.getAllWindows()[0]; + if (ON_ERROR === 'warning') { + dialog.showMessageBox(window, { + type: 'error', + message: 'Error initializing Steamworks: ' + e, + }); + } else if (ON_ERROR === 'error') { + dialog.showMessageBoxSync(window, { + type: 'error', + message: 'Error initializing Steamworks: ' + e, + }); + app.quit(); + } + }); + }`; + + preloadJS += ` + const enableSteamworks = () => { + const sync = (event) => (...args) => ipcRenderer.sendSync(event, ...args); + const async = (event) => (...args) => ipcRenderer.invoke(event, ...args); + contextBridge.exposeInMainWorld('Steamworks', { + ok: sync('Steamworks.ok'), + achievement: { + activate: async('Steamworks.achievement.activate'), + clear: async('Steamworks.achievement.clear'), + isActivated: sync('Steamworks.achievement.isActivated'), + }, + apps: { + isDlcInstalled: async('Steamworks.apps.isDlcInstalled'), + }, + leaderboard: { + uploadScore: async('Steamworks.leaderboard.uploadScore'), + }, + localplayer: { + getName: sync('Steamworks.localplayer.getName'), + getLevel: sync('Steamworks.localplayer.getLevel'), + getIpCountry: sync('Steamworks.localplayer.getIpCountry'), + getSteamId: sync('Steamworks.localplayer.getSteamId'), + }, + overlay: { + activateToWebPage: async('Steamworks.overlay.activateToWebPage'), + }, + }); + }; + enableSteamworks();`; + + const steamworksBuffer = await this.fetchLargeAsset('steamworks.js', 'arraybuffer'); + const steamworksZip = await (await getJSZip()).loadAsync(steamworksBuffer); + for (const [path, file] of Object.entries(steamworksZip.files)) { + const newPath = path.replace(/^package\//, 'steamworks.js/'); + setFileFast(zip, `${resourcesPrefix}${newPath}`, file); + } + } + zip.file(`${resourcesPrefix}${electronMainName}`, mainJS); + zip.file(`${resourcesPrefix}${electronPreloadName}`, preloadJS); for (const [path, data] of Object.entries(projectZip.files)) { setFileFast(zip, `${resourcesPrefix}${path}`, data); @@ -1672,6 +1854,12 @@ Packager.DEFAULT_OPTIONS = () => ({ y: 0 } }, + steamworks: { + // 480 is Spacewar, the Steamworks demo game + appId: '480', + // 'ignore' (no alert), 'warning' (alert and continue), or 'error' (alert and exit) + onError: 'warning' + }, extensions: [], bakeExtensions: true, maxTextureDimension: 2048