diff --git a/dylib/feats/webpatch.c b/dylib/feats/webpatch.c index f2284a20..c6c90a46 100644 --- a/dylib/feats/webpatch.c +++ b/dylib/feats/webpatch.c @@ -1038,7 +1038,7 @@ static int out_expand(np_out_t *o, const char *replace, const np_cap_t *caps) { "as=w=>/^[A-Za-z_]\\w*=/.test(o.slice(w.start,w.end))," \ "b=G.length>1&&ev(G[0])&&(as(G[1])||m(G[1]))?1:0," \ "n=G.slice(b).findIndex(w=>m(w)||!as(w));" \ - "return{p:G[b].start,e:G.slice(b,n<0?G.length:b+n),ev:1===b}})()," + "return{p:G[b].start,e:G.slice(b,n<0?G.length:b+n),ev:1===b,c:G.find(m)}})()," // Rewrites KEY=value entries before %command% and leaves the rest of the text byte for byte. #define NP_CX_LAUNCH \ @@ -1055,7 +1055,24 @@ static int out_expand(np_out_t *o, const char *replace, const np_cap_t *caps) { "E.e.forEach(w=>{if(ps.some(p=>o.startsWith(p[0]+\"=\",w.start))){" \ "r+=o.slice(at,w.start);at=w.end;while(o[at]===\" \"||o[at]===\"\\t\")at++}});r+=o.slice(at)}" \ "SteamClient.Apps.SetAppLaunchOptions(id,r.trim()===\"%command%\"?\"\":r)};" \ - "return{g,s,k:E.e.map(w=>o.slice(w.start,w.end).split(\"=\")[0])}}" + "return{g,s,k:E.e.map(w=>o.slice(w.start,w.end).split(\"=\")[0]),c:E.c}}" + +// The chosen program replaces the argument after waitforexitandrun and starts in its own folder, which Mod Engine 2's .bat needs. Other verbs run unchanged. +#define NP_CX_TARGET \ + "(o,id,home)=>{" \ + "const q=v=>\"'\"+v.replace(/'/g,\"'\\\\''\").replace(/%(?=command%)/gi,\"%'\\\"\\\"'\")+\"'\"," \ + "h=\"sh -c \"+q('exe=$1 dir=$2 found= prev=; shift 2; for arg in \"$@\"; do shift; '+" \ + "'if [ -z \"$found\" ] && [ \"$prev\" = waitforexitandrun ]; then arg=$exe found=1; fi; prev=$arg; set -- \"$@\" \"$arg\"; done; '+" \ + "'if [ \"$found\" ]; then cd \"${dir:-${exe%/*}/}\" || exit; fi; exec \"$@\"')+\" sh \"," \ + "at=o.indexOf(h)," \ + "m=at<0?null:/^('(?:[^']|'\\\\''|'\"\"')*') ('(?:[^']|'\\\\''|'\"\"')*') %command%/.exec(o.slice(at+h.length))," \ + "rd=v=>v.slice(1,-1).replace(/'\\\\''|'\"\"'/g,x=>x[1]===\"\\\\\"?\"'\":\"\")," \ + "b=m?o.slice(0,at)+\"%command%\"+o.slice(at+h.length+m[0].length):o;" \ + "return{home,ex:m?rd(m[1]):\"\",sd:m?rd(m[2]):\"\",write:(e,d)=>{" \ + "const B=/%command%/i.test(b)?b:\"%command%\"+(b.trim()?\" \"+b:\"\"),c=MSCXLaunch(B,id).c;" \ + "if(!c||!/^%command%$/i.test(B.slice(c.start,c.end)))return;" \ + "const r=B.slice(0,c.start)+(e?h+q(e)+\" \"+q(d)+\" %command%\":\"%command%\")+B.slice(c.end);" \ + "SteamClient.Apps.SetAppLaunchOptions(id,r.trim()===\"%command%\"?\"\":r)}}}" #define NP_CX_PICK \ "(t,n)=>{const o=t.strLaunchOptions||\"\";" \ @@ -1208,7 +1225,7 @@ static int out_expand(np_out_t *o, const char *replace, const np_cap_t *caps) { // FEX runs arm64 Wine, which has no D3DMetal and never runs under Rosetta. #define NP_CX_PANEL(RT, BARREL) \ - "({fex,get:G,saved:readSaved,set:s,clearUnused,head,dflt,dev,tool,ours=1})=>{" \ + "({fex,get:G,saved:readSaved,set:s,clearUnused,head,dflt,dev,tool,ours=1,target:X})=>{" \ "const npZh=" NP_WEB_IS_ZH ",npIt=" NP_WEB_IS_IT ",npL=(en,zh,it)=>npZh?zh:npIt?it:en," \ /* A plugin may name the tools it is for. Filtered here rather than at render, so the dependency chain and the indent depth see what the panel shows and nothing else. */ \ @@ -1252,20 +1269,20 @@ static int out_expand(np_out_t *o, const char *replace, const np_cap_t *caps) { ".concat((f.ev||[]).map(e=>[e.slice(0,e.indexOf(\"=\")),v?e.slice(e.indexOf(\"=\")+1):\"\"])),1)," \ "TP=(f,d,lv)=>{const rd=MSCXReady[f.k]!==0;" \ "return(0," RT ".jsx)(" BARREL ".y4," \ - "{label:rd?f.l:f.l+npL(\" (not installed)\",\" (未安装)\",\" (not installed)\"),disabled:!rd," \ + "{label:rd?f.l:f.l+npL(\" (Not Installed)\",\" (未安装)\",\" (Not Installed)\"),disabled:!rd," \ "description:d||undefined,checked:rd&&npHas(f)," \ "indentLevel:lv||undefined," \ "onChange:v=>npSet(f,v)},f.k)}," \ "TD=(k,l,on,off,d,lv)=>{const rd=MSCXReady[k]!==0;" \ "return(0," RT ".jsx)(" BARREL ".y4," \ - "{label:rd?l:l+npL(\" (not installed)\",\" (未安装)\",\" (not installed)\"),disabled:!rd," \ + "{label:rd?l:l+npL(\" (Not Installed)\",\" (未安装)\",\" (Not Installed)\"),disabled:!rd," \ "description:d||undefined,checked:rd&&G(k)===on," \ "indentLevel:lv||undefined," \ "onChange:v=>s([[k,v?on:(off||\"\")]])},k)}," \ /* A row that holds one of its declared values, with Default for none of them. */ \ "TC=(k,l,d,lv,ch,pe)=>{const rd=MSCXReady[k]!==0,v=G(k);" \ "return(0," RT ".jsx)(" BARREL ".m," \ - "{label:rd?l:l+npL(\" (not installed)\",\" (未安装)\",\" (not installed)\"),disabled:!rd," \ + "{label:rd?l:l+npL(\" (Not Installed)\",\" (未安装)\",\" (Not Installed)\"),disabled:!rd," \ "description:d||undefined,layout:\"inline\"," \ "indentLevel:lv||undefined," \ "rgOptions:[{data:\"\",label:npL(\"Default\",\"默认\",\"Default\")}]" \ @@ -1276,7 +1293,7 @@ static int out_expand(np_out_t *o, const char *replace, const np_cap_t *caps) { "TS=(k,l,d,lv,mn,mx,st,df,sf,pe)=>{const rd=MSCXReady[k]!==0,v=G(k)," \ "n=!rd||\"\"===v.trim()||!isFinite(+v)?(undefined===df?mn:df):+v;" \ "return(0," RT ".jsx)(" BARREL ".d3," \ - "{label:rd?l:l+npL(\" (not installed)\",\" (未安装)\",\" (not installed)\"),disabled:!rd," \ + "{label:rd?l:l+npL(\" (Not Installed)\",\" (未安装)\",\" (Not Installed)\"),disabled:!rd," \ "description:d||undefined," \ "indentLevel:lv||undefined," \ "value:n,min:mn,max:mx,step:st||undefined,showValue:true," \ @@ -1289,7 +1306,7 @@ static int out_expand(np_out_t *o, const char *replace, const np_cap_t *caps) { "TL=(k,l,d,lv,ch,df,pe)=>{const rd=MSCXReady[k]!==0,V=ch.map(c=>c.v),v=G(k)," \ "n=rd&&V.indexOf(v)>=0?v:(undefined===df?V[0]:df);" \ "return(0," RT ".jsx)(" BARREL ".gz," \ - "{label:rd?l:l+npL(\" (not installed)\",\" (未安装)\",\" (not installed)\"),disabled:!rd," \ + "{label:rd?l:l+npL(\" (Not Installed)\",\" (未安装)\",\" (Not Installed)\"),disabled:!rd," \ "description:d||undefined," \ "indentLevel:lv||undefined," \ "rValues:V,value:n,showValue:true," \ @@ -1302,6 +1319,25 @@ static int out_expand(np_out_t *o, const char *replace, const np_cap_t *caps) { "checked:\"1\"===G(k),onChange:v=>s([[k,w(k,v?\"1\":\"0\",\"0\")]])},k)," \ "D=(k,l)=>(0," RT ".jsx)(" BARREL ".Yh,{className:\"MSCXRow\",label:l," \ "checked:\"0\"!==G(k),onChange:v=>s([[k,w(k,v?\"1\":\"0\",\"1\")]])},k)," \ + "SL=(k,en)=>globalThis.LocalizationManager?.LocalizeString?.(k,!0)??en," \ + "ex=X?X.ex:\"\",exd=ex.replace(/\\/[^\\/]*$/,\"\")||\"/\",sd=X&&X.sd||exd," \ + "pk=(q,f)=>SteamClient.System.OpenFileDialog(q).then(p=>p&&f(p),()=>{})," \ + "bt=(l,f,k)=>(0," RT ".jsx)(" BARREL ".$n,{className:\"MSCXSetBtn\",onClick:f,children:l},k)," \ + "br=SL(\"#AppProperties_Shortcut_BrowseFiles\",\"Browse...\")," \ + "XF=(k,l,d,c)=>(0," RT ".jsx)(" BARREL ".D0,{label:l,description:d,childrenContainerWidth:\"min\"," \ + "children:(0," RT ".jsx)(\"div\",{className:\"MSCXBtns\",children:c})},k)," \ + "exe=X&&(ours||ex)&&(0," RT ".jsxs)(" BARREL ".XY,{label:npL(\"Launch Executable\",\"Launch Executable\",\"Launch Executable\"),children:[" \ + "XF(\"target\",npL(\"Executable\",\"Executable\",\"Executable\"),ex||undefined,[" \ + "ex&&bt(SL(\"#Button_Remove\",\"Remove\"),()=>X.write(\"\",\"\"),\"remove\")," \ + "ours&&bt(br,()=>pk({strTitle:npL(\"Choose a Program to Launch\",\"选择要启动的程序\",\"Scegli il programma da avviare\")," \ + "rgFilters:[{strFileTypeName:npL(\"Windows Programs (*.exe, *.bat)\",\"Windows 程序 (*.exe, *.bat)\"," \ + "\"Programmi Windows (*.exe, *.bat)\"),rFilePatterns:[\"*.exe\",\"*.bat\"],bUseAsDefault:!0}]," \ + "strInitialFile:ex||X.home},p=>X.write(p,\"\")),\"pick\")])," \ + "ours&&ex&&XF(\"dir\",npL(\"Start Executable From\",\"Start Executable From\",\"Start Executable From\"),sd,[" \ + "sd!==exd&&bt(SL(\"#AppProperties_Customization_Reset\",\"Reset\"),()=>X.write(ex,\"\"),\"reset\")," \ + "bt(br,()=>pk({strTitle:SL(\"#AppProperties_Shortcut_ChooseStartDirectoryDialogTitle\",\"Choose Start Directory\")," \ + "strInitialFile:sd,bChooseDirectory:!0},p=>{const n=p.replace(/\\/+$/,\"\")||\"/\";X.write(ex,n===exd?\"\":n)}),\"pick\")])" \ + "]},\"exe\")," \ "bv=G(\"CX_GRAPHICS_BACKEND\"),b=\"auto\"===bv||fex&&\"d3dmetal\"===bv?\"\":bv," \ "dm=!fex&&(\"\"===b||\"d3dmetal\"===b)," \ "dx=\"\"===b||\"dxmt\"===b," \ @@ -1357,14 +1393,14 @@ static int out_expand(np_out_t *o, const char *replace, const np_cap_t *caps) { "?[[\"DXMT_METALFX_SPATIAL_SWAPCHAIN\",\"\"],[\"DXMT_CONFIG\",fx(\"\")]]" \ ":[[\"DXMT_METALFX_SPATIAL_SWAPCHAIN\",v.data?\"1\":\"0\"],[\"DXMT_CONFIG\",fx(v.data)]])})},\"usf\")," \ "ours&&(0," RT ".jsxs)(" BARREL ".XY,{label:npL(\"Controllers\",\"控制器\",\"Controller\"),children:[" \ - "T(\"NOTPROTON_RAW_CONTROLLERS\",npL(\"Let games read controllers directly (May break Steam Input. Not recommended)\"," \ + "T(\"NOTPROTON_RAW_CONTROLLERS\",npL(\"Let Games Read Controllers Directly (May break Steam Input. Not recommended)\"," \ "\"允许游戏直接读取控制器(可能导致 Steam Input 失效,不推荐)\"," \ "\"Lascia che i giochi leggano direttamente i controller (pu\\u00f2 interferire con Steam Input, sconsigliato)\"))," \ "T(\"NOTPROTON_DISABLE_HIDRAW\",npL(\"Disable hidraw\",\"停用 hidraw\",\"Disattiva hidraw\"))" \ "]},\"ctl\")," \ "fex&&(0," RT ".jsx)(" BARREL ".XY,{label:\"FEX\"," \ "children:D(\"FEX_X87REDUCEDPRECISION\"," \ - "\"Reduced x87 Precision (Reducing precision improves game performance, is enabled by default in CrossOver Preview)\")},\"fex\")," \ + "\"Reduced x87 Precision (Reducing precision improves game performance, is enabled by default in CrossOver Preview)\")},\"fex\"),exe," \ "dev&&(npRows.length||vids.length)?(0," RT ".jsx)(" BARREL ".J1,{children:(0," RT ".jsx)(" BARREL ".XY," \ "{label:npL(\"Developer\",\"开发者\",\"Developer\")," \ "children:vids.map(V).concat(npRows.filter(f=>!f.dep||on1(f.dep))" \ @@ -1399,7 +1435,7 @@ static int out_expand(np_out_t *o, const char *replace, const np_cap_t *caps) { "&&!t.strCompatToolName)return null;" \ "return MSCXPanel({fex,get:G,saved:g,set:L.s,clearUnused:1,dev:1,tool:tn," \ "ours:tn===\"" NP_FALLBACK_TOOL "\"||" NP_OUR_TOOLS ".indexOf(tn)>=0," \ - "dflt:lost?null:k=>DF[k],head:" \ + "dflt:lost?null:k=>DF[k],target:(" NP_CX_TARGET ")(o,t.unAppID,t.strInstallFolder||\"\"),head:" \ "typeof MSCXPrefixRow==\"function\"&&(0," RT ".jsx)(\"div\",{className:\"MSCXRow\",ref:e=>e&&MSCXPrefixRow(e,t.unAppID)},\"npstate\")" \ "})}" diff --git a/dylib/tests/panel-behavior/behavior.js b/dylib/tests/panel-behavior/behavior.js index a682d9bd..e369df58 100644 --- a/dylib/tests/panel-behavior/behavior.js +++ b/dylib/tests/panel-behavior/behavior.js @@ -87,7 +87,7 @@ for (const form of Object.keys(FORMS)) { ty: 'choice', ch: [{ v: 'x', l: 'Ex' }] }, ]).replace(/"(\w+)":/g, '$1:'); { - const { render: PV, written: vwritten } = panel(emit, form, undefined, { feats: PVALUES }); + const { render: PV, written: vwritten, files: vfiles } = panel(emit, form, undefined, { feats: PVALUES }); const nodesFor = (tool, opts) => walk(PV({ details: details(opts, { strCompatToolName: tool }) })); const labelsFor = (tool, opts) => nodesFor(tool, opts) @@ -112,10 +112,23 @@ for (const form of Object.keys(FORMS)) { t.ok(nodesFor('notproton-26.3', 'NOTPROTON_HUD=1 NOTPROTON_ENV_HUD_POS=tl %command%') .find(x => x.props.label === 'HUD position').props.selectedOption === 'tl', 'while under one of ours the key keeps its prefix'); + t.ok(!labelsFor('fexmac', on).includes('Launch Executable') && + labelsFor('notproton-26.3', '').includes('Launch Executable'), + 'Launch Executable is offered only for NotProton tools, since its wrapper needs the shell the launch hook runs'); + vfiles.push('/g/a.exe'); + nodesFor('notproton-26.3', '').find(x => x.props.children === 'Browse...').props.onClick(); + const picked = vwritten.at(-1).opts; + const other = nodesFor('fexmac', picked); + t.ok(JSON.stringify(other.filter(x => x.type === 'Button').map(x => x.props.children)) === '["Remove"]' && + other.some(x => x.props.label === 'Executable' && x.props.description === '/g/a.exe') && + !other.some(x => x.props.label === 'Start Executable From'), + 'a pick made under one of ours still shows on another tool, with only Remove'); + other.find(x => x.props.children === 'Remove').props.onClick(); + t.ok(vwritten.at(-1).opts === '', 'and Remove there takes the wrapper out'); } let r = nodes(''); - t.ok(toggles(r).length === 7 && sections(r).length === 3, 'automatic shows seven toggles in three sections'); + t.ok(toggles(r).length === 7 && sections(r).length === 4, 'automatic shows seven toggles in four sections'); t.ok(r.filter(x => x.type === 'Dropdown').length === 2, 'two dropdowns'); t.ok(!('label' in r.find(x => x.type === 'Dropdown').props), 'the backend dropdown carries no label column'); @@ -174,7 +187,7 @@ for (const form of Object.keys(FORMS)) { t.ok(root.props.className === 'MSCXPanel', 'the panel root carries its own class'); const classes = nodes('').filter(x => x.type === 'Toggle').map(x => x.props.className); t.ok(classes.length === 7 && classes.every(c => c === 'MSCXRow'), 'every checkbox carries the row class'); - const raw = o => nodes(o).find(x => x.props.label === 'Let games read controllers directly (May break Steam Input. Not recommended)'); + const raw = o => nodes(o).find(x => x.props.label === 'Let Games Read Controllers Directly (May break Steam Input. Not recommended)'); t.ok(sections(nodes('')).includes('Controllers') && raw('').props.checked === false, 'controller hiding is on unless the game says otherwise'); written.length = 0; @@ -349,7 +362,7 @@ for (const form of Object.keys(FORMS)) { 'a parent at its off value does not count as on'); t.ok(alpha('') && alpha('').props.disabled === true, 'a plugin whose assets are missing renders disabled'); - t.ok(alpha('').props.label.endsWith('(not installed)'), + t.ok(alpha('').props.label.endsWith('(Not Installed)'), 'it says why it cannot be used'); t.ok(alpha('NOTPROTON_ALPHA=1 %command%').props.checked === false, 'a stale launch option does not show it as on'); diff --git a/dylib/tests/panel-behavior/defaults.js b/dylib/tests/panel-behavior/defaults.js index da7b6dfc..2c9017e1 100644 --- a/dylib/tests/panel-behavior/defaults.js +++ b/dylib/tests/panel-behavior/defaults.js @@ -310,7 +310,8 @@ for (const form of Object.keys(FORMS)) { t.ok(row(r, 'Metal HUD').props.checked === true, 'an unset row shows the default'); t.ok(row(r, AVX).props.checked === false, 'the AVX2 default of 0 shows off'); t.ok(dropdowns(r)[1].props.selectedOption === '1.5', 'an unset upscaler shows the default factor'); - t.ok(!r.some(x => x.type === 'Button' || x.type === 'button'), 'a game has no Reset to defaults'); + t.ok(!r.some(x => (x.type === 'Button' || x.type === 'button') && x.props.children === 'Reset to defaults'), + 'a game has no Reset to defaults'); r = nodes('MTL_HUD_ENABLED=0 CX_GRAPHICS_BACKEND=auto %command%'); t.ok(row(r, 'Metal HUD').props.checked === false, "the game's 0 wins over the default"); @@ -410,6 +411,7 @@ for (const form of Object.keys(FORMS)) { 'Rosetta shows its saved defaults'); t.ok(!p.some(x => x.props && x.type === 'Button' && x.props.children === 'Reset to defaults'), 'the page has no Reset to defaults'); + t.ok(!p.some(x => x.type === 'Section' && x.props.label === 'Launch Executable'), 'the page has no Launch Executable section'); const runnerLines = i => sent[i].text.split('\n').sort().join(';'); row(p, 'MSync').props.onChange(true); diff --git a/dylib/tests/panel-behavior/harness.js b/dylib/tests/panel-behavior/harness.js index 7a6da84b..fad0785f 100644 --- a/dylib/tests/panel-behavior/harness.js +++ b/dylib/tests/panel-behavior/harness.js @@ -56,8 +56,15 @@ function panel(emit, form, globals = { document: { documentElement: { lang: 'en' const body = expand(raw, { [f.react]: names[0], [f.barrel]: names[1] }); const src = form === 'component' ? 'var ' + body.replace(/,$/, '') : body.replace(/;$/, ''); const written = []; + // A file dialog answers synchronously with the next entry in `files`, and rejects like Steam's when there is none. + const dialogs = [], files = []; const SteamClient = { Apps: { SetAppLaunchOptions: (appid, opts) => written.push({ appid, opts }) }, + System: { OpenFileDialog: q => { + dialogs.push(q); + const p = files.shift(); + return { then: (f, r) => p === undefined ? r && r({ result: 2 }) : f(p) }; + } }, }; const build = new Function(names[0], names[1], 'SteamClient', 'globalThis', src + '\nreturn { MSCXOpts, MSCXPick: typeof MSCXPick === "function" ? MSCXPick : null,' + @@ -66,7 +73,7 @@ function panel(emit, form, globals = { document: { documentElement: { lang: 'en' const { MSCXOpts, MSCXPick, MSCXToolQuery, MSCXDefaultsPage } = build(react, barrel, SteamClient, globals); return { render: MSCXOpts, pick: MSCXPick, toolQuery: MSCXToolQuery, - defaultsPage: MSCXDefaultsPage, written, globals }; + defaultsPage: MSCXDefaultsPage, written, globals, dialogs, files }; } // Function components are resolved rather than recorded, so a check sees the nodes the diff --git a/dylib/tests/panel-behavior/localization.js b/dylib/tests/panel-behavior/localization.js index 6c752caf..f3d2ed48 100644 --- a/dylib/tests/panel-behavior/localization.js +++ b/dylib/tests/panel-behavior/localization.js @@ -19,7 +19,7 @@ for (const form of Object.keys(FORMS)) { for (const [language, cases] of [['zh', zhCases], ['it', itCases], ['en', enCases]]) { const t = (...labels) => labels[lang[language]]; for (const globals of cases) { - const { render, written } = panel(emit, form, globals); + const { render, files, dialogs, written } = panel(emit, form, globals); const nodes = walk(render({ details: details('CX_GRAPHICS_BACKEND=dxmt %command%') })); const dropdowns = nodes.filter(n => n.type === 'Dropdown'); assert.equal(dropdowns[0].props.rgOptions[0].label, t('Automatic', '自动', 'Automatico')); @@ -29,15 +29,37 @@ for (const form of Object.keys(FORMS)) { assert(nodes.some(n => n.props.label === t('Graphics', '图形', 'Grafica'))); assert(nodes.some(n => n.props.label === t('Controllers', '控制器', 'Controller'))); const raw = nodes.find(n => n.props.label === t( - 'Let games read controllers directly (May break Steam Input. Not recommended)', + 'Let Games Read Controllers Directly (May break Steam Input. Not recommended)', '允许游戏直接读取控制器(可能导致 Steam Input 失效,不推荐)', 'Lascia che i giochi leggano direttamente i controller (può interferire con Steam Input, sconsigliato)')); assert(nodes.some(n => n.props.label === t('Disable hidraw', '停用 hidraw', 'Disattiva hidraw'))); + assert(nodes.some(n => n.props.label === 'Launch Executable')); + const browse = nodes.find(n => n.type === 'Button'); + assert.equal(browse.props.children, 'Browse...'); + files.push('/g/a.exe'); + browse.props.onClick(); + assert.equal(dialogs.at(-1).strTitle, t('Choose a Program to Launch', '选择要启动的程序', 'Scegli il programma da avviare')); + const target = walk(render({ details: details(written.at(-1).opts) })); + assert.deepEqual(target.filter(n => n.type === 'Button').map(n => n.props.children), ['Remove', 'Browse...', 'Browse...']); + assert.deepEqual(target.filter(n => n.type === 'Field').map(n => n.props.label), ['Executable', 'Start Executable From']); + target.filter(n => n.type === 'Button')[2].props.onClick(); + assert.equal(dialogs.at(-1).bChooseDirectory, true); + assert.equal(dialogs.at(-1).strTitle, 'Choose Start Directory'); + // Steam's own strings, when its localizer is there. + const steam = { ...globals, LocalizationManager: { LocalizeString: k => ({ + '#AppProperties_Shortcut_BrowseFiles': 'B', '#Button_Remove': 'R', + '#AppProperties_Shortcut_ChooseStartDirectoryDialogTitle': 'C' })[k] } }; + const S = panel(emit, form, steam); + const sn = walk(S.render({ details: details(written.at(-1).opts) })); + assert.deepEqual(sn.filter(n => n.type === 'Button').map(n => n.props.children), ['R', 'B', 'B']); + assert.deepEqual(sn.filter(n => n.type === 'Field').map(n => n.props.label), ['Executable', 'Start Executable From']); + sn.filter(n => n.type === 'Button')[2].props.onClick(); + assert.equal(S.dialogs.at(-1).strTitle, 'C'); const feats = panel(emit, form, globals, { ready: '{"NOTPROTON_X":0}', feats: '[{k:"NOTPROTON_X",l:"X",on:"1",off:"",dep:"",d:""}]' }); const fnodes = walk(feats.render({ details: details('') })); assert(fnodes.some(n => n.props.label === t('Developer', '开发者', 'Developer'))); - assert(fnodes.some(n => n.props.label === 'X' + t(' (not installed)', ' (未安装)', ' (not installed)'))); + assert(fnodes.some(n => n.props.label === 'X' + t(' (Not Installed)', ' (未安装)', ' (Not Installed)'))); raw.props.onChange(true); assert.equal(written.at(-1).opts, 'NOTPROTON_RAW_CONTROLLERS=1 CX_GRAPHICS_BACKEND=dxmt %command%'); const hud = nodes.find(n => n.props.label === t('Metal HUD', 'Metal 性能监视器', 'Metal HUD')); diff --git a/dylib/tests/panel-behavior/shell.js b/dylib/tests/panel-behavior/shell.js index 97d5bd71..29e438be 100644 --- a/dylib/tests/panel-behavior/shell.js +++ b/dylib/tests/panel-behavior/shell.js @@ -13,39 +13,40 @@ const TOGGLES = { 'MSync': 'WINEMSYNC', 'High Resolution': 'NOTPROTON_RETINA', 'Advertise AVX2 to Rosetta': 'ROSETTA_ADVERTISE_AVX', - 'Let games read controllers directly (May break Steam Input. Not recommended)': 'NOTPROTON_RAW_CONTROLLERS', + 'Let Games Read Controllers Directly (May break Steam Input. Not recommended)': 'NOTPROTON_RAW_CONTROLLERS', 'Disable hidraw': 'NOTPROTON_DISABLE_HIDRAW', }; const ON_UNLESS_0 = { ROSETTA_ADVERTISE_AVX: true }; const PANEL_KEYS = ['CX_GRAPHICS_BACKEND', 'MTL_HUD_ENABLED', 'D3DM_ENABLE_METALFX', 'DXMT_ENABLE_NVEXT', 'DXMT_METALFX_SPATIAL_SWAPCHAIN', 'DXMT_CONFIG', 'ROSETTA_ADVERTISE_AVX', 'WINEMSYNC', 'NOTPROTON_RETINA', 'NOTPROTON_RAW_CONTROLLERS', 'NOTPROTON_DISABLE_HIDRAW']; -const OTHER_KEYS = ['WINEDEBUG', 'DXVK_HUD', 'FOO']; +const OTHER_KEYS = ['WINEDEBUG', 'DXVK_HUD', 'FOO', 'SteamAppId', 'STEAM_COMPAT_DATA_PATH']; -const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'np-shell-')); +const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'np-shell-'))); const probe = path.join(dir, 'probe'); fs.writeFileSync(probe, '#!' + process.execPath + '\n' + `const k=${JSON.stringify(PANEL_KEYS.concat(OTHER_KEYS))};const e={};` + 'k.forEach(n=>{if(n in process.env)e[n]=process.env[n]});' + 'require("fs").appendFileSync(process.env.NP_PROBE_OUT,' + - 'JSON.stringify({argv:process.argv.slice(2),env:e})+"\\n");\n', { mode: 0o755 }); + 'JSON.stringify({argv:process.argv.slice(2),env:e,cwd:process.cwd()})+"\\n");\n', { mode: 0o755 }); fs.writeFileSync(path.join(dir, 'gamemoderun'), '#!/bin/sh\nexec "$@"\n', { mode: 0o755 }); const quote = s => "'" + s.replace(/'/g, "'\\''") + "'"; -const COMMAND = quote(probe) + " 'game.exe'"; +// Steam expands %command% to the tool, its verb and the game. +const COMMAND = quote(probe) + " waitforexitandrun 'game.exe'"; -function steamLine(options) { +function steamLine(options, command = COMMAND) { const o = options.trim(); - return /%command%/i.test(o) ? o.replace(/%command%/gi, () => COMMAND) : COMMAND + (o ? ' ' + o : ''); + return /%command%/i.test(o) ? o.replace(/%command%/gi, () => command) : command + (o ? ' ' + o : ''); } const shells = ['/bin/sh', '/bin/dash'].filter(s => fs.existsSync(s)); -function launch(shell, options) { +function launch(shell, options, command) { const out = path.join(dir, 'runs'); fs.rmSync(out, { force: true }); - spawnSync(shell, ['-c', steamLine(options)], { - env: { PATH: dir + ':/usr/bin:/bin', HOME: process.env.HOME, NP_PROBE_OUT: out }, + spawnSync(shell, ['-c', steamLine(options, command)], { + env: { PATH: dir + ':/usr/bin:/bin', HOME: process.env.HOME, NP_PROBE_OUT: out, SteamAppId: '287700', STEAM_COMPAT_DATA_PATH: '/steam/prefix/287700' }, cwd: dir, timeout: 10000, stdio: 'ignore', }); return fs.existsSync(out) ? fs.readFileSync(out, 'utf8').trim().split('\n').map(JSON.parse) : []; @@ -53,13 +54,17 @@ function launch(shell, options) { function view(form, options) { const P = panel(emit, form); - const nodes = walk(P.render({ details: details(options) })); + const nodes = walk(P.render({ details: details(options, { strInstallFolder: dir }) })); const toggles = {}; nodes.filter(n => n.type === 'Toggle' && n.props.label in TOGGLES) .forEach(n => { toggles[TOGGLES[n.props.label]] = n; }); const backend = nodes.find(n => n.type === 'Dropdown' && n.props.rgOptions.some(o => o.data === 'dxvk')); const upscale = nodes.find(n => n.type === 'Dropdown' && n.props.rgOptions.some(o => o.data === '1.5')); - return { toggles, backend, upscale, written: P.written }; + return { toggles, backend, upscale, written: P.written, files: P.files, dialogs: P.dialogs, + buttons: nodes.filter(n => n.type === 'Button'), + section: nodes.find(n => n.type === 'Section' && n.props.label === 'Launch Executable'), + target: nodes.find(n => n.type === 'Field' && n.props.label === 'Executable'), + start: nodes.find(n => n.type === 'Field' && n.props.label === 'Start Executable From') }; } function dxmt(conf) { @@ -209,6 +214,127 @@ for (const shell of shells) { failed += t.failed; } } + +// The Launch Executable section, driven through Steam's file dialog and read back from what the game gets. +const TARGETS = ['Mod Engine 2/launchmod_eldenring.bat', + `Jeux/Café "d" l'été/ゲーム.EXE`, 'AssassinsCreed_Dx10.exe', '$HOME `id` \\x.bat', + 'braces{a,b}[*]?/%command%\n.exe'].map(p => path.join(dir, p)); +for (const exe of TARGETS) fs.mkdirSync(path.dirname(exe), { recursive: true }); +const startDir = path.join(dir, 'start in/ü'); +fs.mkdirSync(startDir, { recursive: true }); +const otherExe = path.join(dir, 'other.exe'); +const STARTS = ['', '-windowed', 'MTL_HUD_ENABLED=1 %command% --offline', + 'WINEDEBUG="a b" gamemoderun %command% -t er', + '"/Users/test/Games/Mod Engine 2/launchmod_eldenring.bat"', + `bash -c 'exec "\${@/game.exe/launchmod_eldenring.bat}"' -- %command%`]; +for (const shell of shells) { + for (const form of Object.keys(FORMS)) { + const t = runner(`${path.basename(shell)} ${form} executable`); + const click = (v, n, file) => { + if (file !== undefined) v.files.push(file); + v.buttons[n].props.onClick(); + return v.written.length ? v.written[v.written.length - 1].opts : null; + }; + for (const start of STARTS) { + const before = launch(shell, start); + let v = view(form, start); + t.ok(v.section && v.target && !v.start && v.target.props.description === undefined && + JSON.stringify(v.buttons.map(b => b.props.children)) === '["Browse..."]', + `${JSON.stringify(start)}: only Executable and its Browse show without a pick`); + t.ok(click(v, 0, '') === null, `${JSON.stringify(start)}: an empty answer writes nothing`); + v = view(form, start); + t.ok(click(v, 0, undefined) === null, `${JSON.stringify(start)}: a cancelled dialog writes nothing`); + for (const exe of TARGETS) { + const what = `${JSON.stringify(start)} + ${JSON.stringify(exe)}`; + const folder = path.dirname(exe); + v = view(form, start); + const picked = click(v, 0, exe); + t.ok(JSON.stringify(v.dialogs[0].rgFilters[0].rFilePatterns) === '["*.exe","*.bat"]' && + v.dialogs[0].strInitialFile === dir, `${what}: the dialog offers .exe and .bat from the game folder`); + const run = launch(shell, picked); + t.ok(run.length === before.length && run.length > 0, `${what}: the game runs once -> ${JSON.stringify(picked)}`); + if (!run.length || run.length !== before.length) continue; + t.ok(run[0].argv[1] === exe && run[0].cwd === folder, `${what}: tool receives the pick and starts in its folder`); + t.ok(JSON.stringify(run[0].argv) === JSON.stringify(before[0].argv.map((a, i) => i === 1 ? exe : a)), + `${what}: game arguments kept ${JSON.stringify(run[0].argv)}`); + t.ok(['WINEDEBUG', 'MTL_HUD_ENABLED', 'SteamAppId', 'STEAM_COMPAT_DATA_PATH'].every(k => run[0].env[k] === before[0].env[k]), + `${what}: other settings kept`); + + if (start === STARTS[0] && exe === TARGETS[0]) { + const native = launch(shell, picked, quote(probe) + " run 'game.exe'"); + t.ok(native.length === 1 && JSON.stringify(native[0].argv) === '["run","game.exe"]' && native[0].cwd === dir, + `${what}: without waitforexitandrun the command runs unchanged`); + v = view(form, picked); + v.toggles.MTL_HUD_ENABLED.props.onChange(true); + const toggled = v.written.at(-1).opts; + const toggleRun = launch(shell, toggled); + t.ok(toggleRun.length === 1 && toggleRun[0].env.MTL_HUD_ENABLED === '1' && + toggleRun[0].argv[1] === exe, `${what}: settings edits retain the generated wrapper`); + const F = panel(emit, form, undefined, { fexTools: ['fex'] }); + F.pick(details('CX_GRAPHICS_BACKEND=d3dmetal ' + toggled), 'fex'); + const fexRun = launch(shell, F.written.at(-1).opts); + t.ok(fexRun.length === 1 && !fexRun[0].env.CX_GRAPHICS_BACKEND && + fexRun[0].argv[1] === exe, `${what}: FEX cleanup retains the generated wrapper`); + v = view(form, picked); + t.ok(launch(shell, click(v, 2, path.join(dir, 'missing'))).length === 0, + `${what}: a start folder that is gone does not launch`); + v = view(form, picked); + t.ok(click(v, 2, folder) === picked, `${what}: choosing the program's own folder keeps the default`); + v = view(form, picked); + t.ok(click(v, 2, folder + '/') === picked, `${what}: and so does the same folder with a trailing slash`); + } + v = view(form, picked); + t.ok(JSON.stringify(v.buttons.map(b => b.props.children)) === '["Remove","Browse...","Browse..."]' && + v.target.props.description === exe && v.start.props.description === folder, + `${what}: the pick and the folder it starts in show as full paths`); + const withDir = click(v, 2, startDir); + t.ok(v.dialogs[0].bChooseDirectory === true && v.dialogs[0].strInitialFile === folder, + `${what}: the start folder dialog opens on the program's folder`); + const dirRun = launch(shell, withDir); + t.ok(dirRun.length && dirRun[0].cwd === startDir && dirRun[0].argv[1] === exe, `${what}: the start folder is used and the pick kept`); + v = view(form, withDir); + t.ok(v.target.props.description === exe && v.start.props.description === startDir && + JSON.stringify(v.buttons.map(b => b.props.children)) === '["Remove","Browse...","Reset","Browse..."]', + `${what}: the chosen start folder shows, with Reset beside its Browse`); + const dirReset = launch(shell, click(v, 2)); + t.ok(dirReset.length && dirReset[0].cwd === folder && dirReset[0].argv[1] === exe, + `${what}: Reset starts the program in its own folder again`); + v = view(form, withDir); + + const again = launch(shell, click(v, 1, otherExe)); + t.ok(again.length && again[0].argv[1] === otherExe && again[0].cwd === dir, + `${what}: picking another program starts it in its own folder`); + + v = view(form, withDir); + const removed = click(v, 0); + const removedRun = launch(shell, removed); + t.ok(removedRun.length === before.length && removedRun.every((r, n) => + r.cwd === before[n].cwd && JSON.stringify(r.argv) === JSON.stringify(before[n].argv)), + `${what}: Remove launches the game as before -> ${JSON.stringify(removed)}`); + t.ok(removed === (/%command%/.test(start) || !start ? start : '%command% ' + start), + `${what}: Remove restores the original text`); + } + } + failed += t.failed; + } +} + +// A %command% the wrapper cannot stand in for is left alone rather than rewritten. +for (const form of Object.keys(FORMS)) { + const t = runner(`${form} executable, unsupported options`); + for (const start of ['MTL_HUD_ENABLED=1 "%command%"', 'MTL_HUD_ENABLED=1 \\%command%', + "bash -c 'run %command%'", '# %command%']) { + const v = view(form, start); + v.files.push(otherExe); + v.buttons[0].props.onClick(); + t.ok(v.written.length === 0, `${JSON.stringify(start)}: Browse writes nothing`); + } + const lookalike = "sh -c 'exec \"$@\"' sh '/g/a.exe' '' %command%"; + const v = view(form, lookalike); + t.ok(v.buttons.length === 1, 'a hand-written wrapper is not read as a chosen executable'); + failed += t.failed; +} + fs.rmSync(dir, { recursive: true, force: true }); console.log(failed ? `\n${failed} failure(s)` : `\npanel agrees with ${shells.join(' and ')}`); process.exit(failed ? 1 : 0);