List named Remote Play hosts in redesigned localized launch chooser

This commit is contained in:
toorux committed 2026-10-07 14:32:50 +08:00
1 parent e8c406ffca
commit 6589f85ec6
10 files changed
+112 -20

No files matched your search

+30 -9
View File
@@ -13,12 +13,18 @@ pub struct App {
pub remote: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub remote_client: Option<String>,
pub remote_targets: Vec<RemoteTarget>,
pub id: u32,
pub name: String,
pub kind: &'static str,
pub icon: Option<String>,
pub icon_fit: &'static str,
}
#[derive(Clone, Debug, Serialize)]
pub struct RemoteTarget {
pub client: String,
pub name: String,
}
#[derive(Debug)]
enum Value {
Text(String),
@@ -213,6 +219,7 @@ pub fn discover(home: &Path) -> Vec<App> {
id,
remote: false,
remote_client: None,
remote_targets: Vec::new(),
name: name.into(),
kind: if text(v, "installdir").is_some_and(|dir| {
fs::read_dir(library.join("steamapps/common").join(dir)).is_ok_and(
@@ -320,14 +327,7 @@ fn merge_remote(home: &Path, local: Vec<App>, remote: Vec<remote::RemoteApp>) ->
home.join(".steam/steam")
};
for a in remote {
if let Some(existing) = apps.get_mut(&a.id) {
// Keep local metadata and a single stable icon, but expose both launch targets.
if existing.remote_client.is_none() {
existing.remote_client = Some(a.client);
}
continue;
}
apps.entry(a.id).or_insert_with(|| {
let existing = apps.entry(a.id).or_insert_with(|| {
let icon = cached_artwork(&steam, a.id);
App {
id: a.id,
@@ -336,9 +336,23 @@ fn merge_remote(home: &Path, local: Vec<App>, remote: Vec<remote::RemoteApp>) ->
icon_fit: icon.as_ref().map(|v| v.1).unwrap_or("contain"),
icon: icon.map(|v| v.0),
remote: true,
remote_client: Some(a.client),
remote_client: None,
remote_targets: Vec::new(),
}
});
if existing.remote_targets.iter().any(|t| t.client == a.client) {
continue;
}
if existing.remote_client.is_none() {
existing.remote_client = Some(a.client.clone());
}
existing.remote_targets.push(RemoteTarget {
client: a.client,
name: a.device_name,
});
existing
.remote_targets
.sort_by(|a, b| a.name.cmp(&b.name).then(a.client.cmp(&b.client)));
}
apps.into_values().collect()
}
@@ -383,22 +397,26 @@ mod tests {
icon_fit: "contain",
remote: false,
remote_client: None,
remote_targets: Vec::new(),
}];
let rows = vec![
remote::RemoteApp {
id: 42,
name: "Same remote".into(),
client: "12".into(),
device_name: "Gaming PC".into(),
},
remote::RemoteApp {
id: 43,
name: "Remote".into(),
client: "12".into(),
device_name: "Gaming PC".into(),
},
remote::RemoteApp {
id: 43,
name: "Other host".into(),
client: "13".into(),
device_name: "Laptop".into(),
},
];
let apps = merge_remote(home.path(), local, rows);
@@ -406,6 +424,9 @@ mod tests {
assert!(!apps[0].remote);
assert_eq!(apps[0].name, "Local");
assert_eq!(apps[0].remote_client.as_deref(), Some("12"));
assert_eq!(apps[0].remote_targets[0].name, "Gaming PC");
assert_eq!(apps[1].remote_targets.len(), 2);
assert_eq!(apps[1].remote_targets[1].name, "Laptop");
assert!(apps[1].remote);
assert_eq!(apps[1].remote_client.as_deref(), Some("12"));
}
+2 -2
View File
@@ -4,14 +4,14 @@ async function(request) {
if(request.mode==='list')return [];
throw Error('Steam remote library is unavailable');
}
const connected=new Set(remote.devices.filter(d=>d.status==='Connected').map(d=>String(d.clientId)));
const connected=new Map(remote.devices.filter(d=>d.status==='Connected').map(d=>[String(d.clientId),String(d.clientName||'').trim().slice(0,256)]));
const rows=[];
for(const a of store.allApps) {
if(!Number.isInteger(a.appid)||a.appid<=0||a.appid>4294967295||a.visible_in_game_list===false||!(a.app_type&(1|2|8)))continue;
for(const r of a.remote_per_client_data||[]) {
const client=String(r.clientid);
if(r.installed===true&&/^\d{1,20}$/.test(client)&&connected.has(client)&&a.BIsPerClientDataLocal?.(r)===false)
rows.push({id:a.appid,name:String(a.display_name||''),client});
rows.push({id:a.appid,name:String(a.display_name||''),client,deviceName:connected.get(client)});
}
}
if(request.mode==='list')return rows.slice(0,10000);
+2
View File
@@ -11,6 +11,8 @@ pub(super) struct RemoteApp {
pub id: u32,
pub name: String,
pub client: String,
#[serde(default, rename = "deviceName")]
pub device_name: String,
}
fn query(request: serde_json::Value) -> Result<serde_json::Value> {
crate::apk::steam_ui::evaluate(&format!("({})({request})", include_str!("remote.js")))
+22
View File
@@ -0,0 +1,22 @@
import React from 'react';
import {createRoot} from 'react-dom/client';
import {SteamLaunchChoice} from '../ui/src/steam-launch-choice';
import {configureLanguage,t} from '../ui/src/i18n';
import '../ui/src/style.css';
import '../ui/src/launcher.css';
const root=createRoot(document.getElementById('root')!);
const wait=()=>new Promise(r=>setTimeout(r,450));
let chosen:string|undefined='unset',closed=false;
async function show(locale:string){configureLanguage(locale,[]);root.render(<div className="app launcher-shell" style={{height:'100%',background:'#161618'}}><SteamLaunchChoice name="Hades" local targets={[{client:'12',name:'TOORU-PC'},{client:'13',name:'Living Room PC'}]} onChoose={client=>chosen=client} onClose={()=>closed=true}/></div>);await wait();return document.querySelector('.launch-choice')!;}
(window as any).runInstallReviewChecks=async()=>{try{
let dialog=await show('zh-CN');
if(!dialog.textContent?.includes('选择启动方式')||!dialog.textContent.includes('本机启动')||!dialog.textContent.includes('TOORU-PC')||!dialog.textContent.includes('Living Room PC'))throw Error('Missing localized labels or device names');
if(dialog.scrollWidth>dialog.clientWidth+1)throw Error('Horizontal overflow');
let buttons=dialog.querySelectorAll<HTMLButtonElement>('.launch-choice-target');buttons[2].click();if(chosen!=='13')throw Error('Wrong remote host selected');buttons[0].click();if(chosen!==undefined)throw Error('Local launch still passes remote host');
console.log('FRAMELY_PREVIEW_INSTALL_CHINESE');await wait();
dialog=await show('en-US');if(!dialog.textContent?.includes('Choose how to launch'))throw Error('English missing');
console.log('FRAMELY_PREVIEW_INSTALL_ENGLISH');await wait();
dialog.querySelector<HTMLButtonElement>('.icon-button')!.click();if(!closed)throw Error('Close action broken');
console.log('FRAMELY_BRIDGE_PASS');
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e)}};
show('zh-CN').then(()=>console.log('FRAMELY_VIEW_READY'));
+14
View File
@@ -0,0 +1,14 @@
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
const evaluate=eval(`(${await readFile('src/steam/remote.js','utf8')})`);
const launched=[];
globalThis.window={RemotePlayStore_SteamUI:{devices:[{clientId:'12',clientName:'Gaming PC',status:'Connected'},{clientId:'13',clientName:'Laptop',status:'Connected'},{clientId:'14',clientName:'Offline',status:'Disconnected'}]},appStore:{allApps:[{appid:42,display_name:'Game',app_type:1,remote_per_client_data:['12','13','14'].map(clientid=>({clientid,installed:true})),BIsPerClientDataLocal:()=>false}]}};
globalThis.SteamClient={Apps:{StreamGame:async(...args)=>launched.push(args)}};
const rows=await evaluate({mode:'list'});
assert.deepEqual(rows.map(r=>[r.client,r.deviceName]),[['12','Gaming PC'],['13','Laptop']]);
assert.equal(await evaluate({mode:'launch',id:42,client:'13'}),true);
assert.deepEqual(launched,[[42,'13',-1]]);
window.RemotePlayStore_SteamUI.devices[1].status='Disconnected';
assert.equal(await evaluate({mode:'launch',id:42,client:'13'}),false);
assert.equal(launched.length,1);
console.log('Remote device names, selection and disconnected-host validation passed.');
+4 -1
View File
@@ -974,6 +974,9 @@
"Android user did not unlock; export logs and retry.": "Android user did not unlock; export logs and retry.",
"Android external storage did not mount; export logs and retry.": "Android external storage did not mount; export logs and retry.",
"Android container stopped before becoming ready; export logs and retry.": "Android container stopped before becoming ready; export logs and retry.",
"Android readiness probe did not respond; export logs and retry.": "Android readiness probe did not respond; export logs and retry."
"Android readiness probe did not respond; export logs and retry.": "Android readiness probe did not respond; export logs and retry.",
"在此 Frame 上运行": "Run on this Frame",
"远程设备 {0}": "Remote device {0}",
"架构": "Architecture"
}
}
+14 -1
View File
@@ -964,6 +964,19 @@
"Android user did not unlock; export logs and retry.": "Android 用户未解锁,请导出日志后重试。",
"Android external storage did not mount; export logs and retry.": "Android 外部存储未挂载,请导出日志后重试。",
"Android container stopped before becoming ready; export logs and retry.": "Android 容器在就绪前退出,请导出日志后重试。",
"Android readiness probe did not respond; export logs and retry.": "Android 就绪状态查询无响应,请导出日志后重试。"
"Android readiness probe did not respond; export logs and retry.": "Android 就绪状态查询无响应,请导出日志后重试。",
"选择启动方式": "选择启动方式",
"远程畅玩": "远程畅玩",
"保存 Steam 入口设置": "保存 Steam 入口设置",
"保留安装的 APK 到 Steam": "保留安装的 APK 到 Steam",
"Steam UI operation failed": "Steam 界面操作失败,请保持 Steam 唤醒后重试。",
"Remote game or host is no longer available": "远程游戏或设备已不可用,请检查电脑连接后重试。",
"本机启动": "本机启动",
"通过 Steam 启动,可由 Steam 原生关闭窗口。入口是否常驻遵循 APK 设置页的选项。": "通过 Steam 启动,可由 Steam 原生关闭窗口。入口是否常驻遵循 APK 设置页的选项。",
"空容器": "空容器",
"默认开启,在 Steam 库中保留已安装应用。关闭后只在启动时注册 Steam 入口,退出后删除入口,应用数据保留。": "默认开启,在 Steam 库中保留已安装应用。关闭后只在启动时注册 Steam 入口,退出后删除入口,应用数据保留。",
"在此 Frame 上运行": "在此 Frame 上运行",
"远程设备 {0}": "远程设备 {0}",
"架构": "架构"
}
}
+4 -1
View File
@@ -199,4 +199,7 @@
.launch-remote-mark{position:absolute;right:18px;bottom:11px;width:29px;height:29px;display:flex;align-items:center;justify-content:center;border-radius:50%;background:#252525e8;color:#f0f0f0;box-shadow:0 2px 6px #0005;pointer-events:none;z-index:2}
@media(max-width:900px){.launch-remote-mark{right:1px;bottom:1px}}
.launch-choice-actions{display:flex;gap:14px;margin:24px 0}.launch-choice-actions button{flex:1;display:flex;align-items:center;justify-content:center;gap:12px;min-height:64px}
.launcher-shell .launch-choice{width:600px;padding:28px;border:1px solid #ffffff20;background:rgba(24,24,26,.96);box-shadow:inset 0 1px 0 #ffffff0c,0 24px 72px #00000055;max-height:90%;overflow:auto}
.launch-choice-heading{display:flex;align-items:center;gap:16px;margin-bottom:26px}.launch-choice-heading>div{flex:1;min-width:0}.launch-choice-heading h2{margin:0 0 4px;font-size:25px;overflow-wrap:anywhere}.launch-choice-heading p{margin:0;color:var(--muted);font-size:18px}.launch-choice-heading .icon-button{align-self:flex-start;min-width:40px;min-height:40px;padding:8px}
.launch-choice-targets{display:flex;flex-direction:column;gap:10px}.launch-choice-target{width:100%;min-height:86px;padding:16px 18px;text-align:left;gap:18px;border:1px solid #ffffff12;border-radius:13px;background:#ffffff05;transition:background 140ms,border-color 140ms}.launch-choice-target:hover:not(:disabled){background:#ffffff10;border-color:#ffffff28}.launch-choice-symbol{display:flex;align-items:center;justify-content:center;width:44px;height:44px;flex:none;color:#d6d9de}.launch-choice-copy{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px}.launch-choice-copy b{font-size:21px;font-weight:550;overflow-wrap:anywhere}.launch-choice-copy small{font-size:16px}.launch-choice-arrow{color:#b7bbc2}
@media(prefers-reduced-motion:reduce){.launch-choice-target{transition:none}}
+7 -6
View File
@@ -1,3 +1,4 @@
import {SteamLaunchChoice} from './steam-launch-choice';
import {AppPlaceholder} from './app-placeholder';
import {SwitchRow} from './switch';
import React,{useEffect,useLayoutEffect,useMemo,useRef,useState} from 'react';
@@ -13,7 +14,7 @@ import {LauncherCalibration} from './launcher-calibration';
import {matchesSearch,type SearchIndex} from './launcher-search';
export type LauncherConfig={prioritizeUi?:boolean;primaryTrigger:boolean;menuAutoClose:boolean;menuTimeoutSeconds:number;allCategories?:string[]};
const defaults:LauncherConfig={prioritizeUi:true,primaryTrigger:true,menuAutoClose:true,menuTimeoutSeconds:10,allCategories:['plugin','steam','lepton','desktop']};
type SteamApp={remote?:boolean;remoteClient?:string;id:number;name:string;icon?:string;iconFit?:string;kind?:string};
type SteamApp={remote?:boolean;remoteClient?:string;remoteTargets?:{client:string;name:string}[];id:number;name:string;icon?:string;iconFit?:string;kind?:string};
function launcherCells(total:number){
const counts=[Math.min(Math.max(total-5,0),4),Math.min(total,5),Math.max(total-9,0)];
return counts.flatMap((count,row)=>Array.from({length:count},(_,col)=>({row,column:(6-(total<=5?count:row===1?5:4))*2+1+col*4})));
@@ -32,7 +33,7 @@ export function LauncherSettings({config=defaults,refresh}:{config?:LauncherConf
return <><section className="settings-card launcher-settings"><h2>{t('启动方式与菜单')}</h2><SwitchRow label={t('启动台资源优先')} description={t('启动台显示时提高界面 CPU 调度优先级,关闭后恢复。')} checked={config.prioritizeUi??true} disabled={busy} onChange={prioritizeUi=>void save({prioritizeUi})}/><p className="sub">{t('点击入口图标打开启动台;长按 600 毫秒打开快捷面板。')}</p><SwitchRow label={t('长按菜单自动关闭')} description={t('指针离开后开始计时,移入后取消。')} checked={config.menuAutoClose} disabled={busy} onChange={menuAutoClose=>void save({menuAutoClose})}/><label><span>{t('自动关闭时间(秒)')}</span><input type="number" min={1} max={300} value={seconds} disabled={busy||!config.menuAutoClose} onChange={e=>setSeconds(+e.target.value)} onBlur={()=>{if(seconds!==config.menuTimeoutSeconds&&seconds>=1&&seconds<=300)void save({menuTimeoutSeconds:seconds});}}/></label>{error&&<p role="alert">{error}</p>}</section><section className="settings-card launcher-settings"><h2>{t('全部包含的分类')}</h2><p className="sub">{t('只影响全部分类,其他分类和收藏仍可访问。')}</p>{categories.filter(c=>!['all','favorites'].includes(c.key)).map(c=><SwitchRow key={c.key} label={t(c.label)} description={c.key==='plugin'?t('包含 Framely'):undefined} checked={(config.allCategories??defaults.allCategories!).includes(c.key)} disabled={busy} onChange={checked=>{const selected=new Set(config.allCategories??defaults.allCategories);if(checked)selected.add(c.key);else selected.delete(c.key);void save({allCategories:[...selected]});}}/>)}</section><section className="settings-card launcher-settings"><h2>{t('眼追校准')}</h2><p className="sub">{t('在 Frame 内打开模拟启动台,依次注视高亮目标。')}</p><div className="actions"><button disabled={busy} onClick={()=>void api('host.launcher.calibrate',{active:true}).catch(e=>setError(String(e)))}>{t('开始校准')}</button><button disabled={busy} onClick={()=>{setBusy(true);void api('launcher.gaze.calibration.save',null).then(()=>{window.dispatchEvent(new Event('framely.gaze.calibration.changed'));return refresh();}).catch(e=>setError(String(e))).finally(()=>setBusy(false));}}>{t('清除校准')}</button></div></section></>;
}
type LocalApp={steamAppId?:number|null;id:string;name:string;kind:'desktop'|'lepton';icon?:string;launchUnavailable?:string|null};
type LaunchItem={remote?:boolean;remoteClient?:string;key:string;kind:string;id:string;name:string;icon?:string|null;fit?:string;favorite:boolean;launchUnavailable?:string|null};
type LaunchItem={remote?:boolean;remoteClient?:string;remoteTargets?:{client:string;name:string}[];key:string;kind:string;id:string;name:string;icon?:string|null;fit?:string;favorite:boolean;launchUnavailable?:string|null};
const emptyOrder:string[]=[];
const categories=[{key:'favorites',label:'收藏分类',icon:IconStar},{key:'all',label:'全部',icon:IconLayoutGrid},{key:'plugin',label:'插件',icon:IconPuzzle},{key:'steam',label:'Steam',icon:IconBrandSteam},{key:'lepton',label:'Lepton',icon:IconBrandAndroid},{key:'desktop',label:'桌面程序',icon:IconDeviceDesktop}];
async function launcherRequest<T=any>(method:string,params:unknown={}):Promise<T>{
@@ -70,7 +71,7 @@ export function Launcher({plugins,safeMode,config=defaults,favorites,localFavori
// The native compositor owns surface fades; animating this high-resolution
// page too would multiply alpha and force a full CEF readback every frame.
useEffect(()=>{const open=()=>{if(shell.current)shell.current.style.pointerEvents='';};const closing=()=>{if(calibrationRef.current){location.hash='';setCalibrating(false);}if(shell.current)shell.current.style.pointerEvents='none';};window.addEventListener('framely.launcher.open',open);window.addEventListener('framely.launcher.closing',closing);return()=>{window.removeEventListener('framely.launcher.open',open);window.removeEventListener('framely.launcher.closing',closing);};},[]);
const all=useMemo(()=>{const entries:LaunchItem[]=[{key:'framely',kind:'framely',id:'framely',name:'Framely',icon:'/assets/branding/framely-app-icon.svg',fit:'contain',favorite:localFavorites.includes('framely')},...Object.values(plugins).filter(p=>p.enabled).map(p=>({key:`plugin:${p.manifest.id}`,kind:'plugin',id:p.manifest.id,name:p.manifest.name,icon:installedIcon(p),fit:'contain',favorite:p.favorite})),...steam.filter(a=>!local.some(l=>l.steamAppId===a.id)).map(a=>({key:`steam:${a.id}`,kind:a.kind??'steam',id:String(a.id),name:a.name,icon:a.icon,fit:a.iconFit,remote:a.remote,remoteClient:a.remoteClient,favorite:favorites.includes(a.id)})),...local.map(a=>({key:`${a.kind}:${a.id}`,kind:a.kind,id:a.id,name:a.name,icon:a.icon,fit:'contain',favorite:localFavorites.includes(`${a.kind}:${a.id}`),launchUnavailable:a.launchUnavailable}))];
const all=useMemo(()=>{const entries:LaunchItem[]=[{key:'framely',kind:'framely',id:'framely',name:'Framely',icon:'/assets/branding/framely-app-icon.svg',fit:'contain',favorite:localFavorites.includes('framely')},...Object.values(plugins).filter(p=>p.enabled).map(p=>({key:`plugin:${p.manifest.id}`,kind:'plugin',id:p.manifest.id,name:p.manifest.name,icon:installedIcon(p),fit:'contain',favorite:p.favorite})),...steam.filter(a=>!local.some(l=>l.steamAppId===a.id)).map(a=>({key:`steam:${a.id}`,kind:a.kind??'steam',id:String(a.id),name:a.name,icon:a.icon,fit:a.iconFit,remote:a.remote,remoteClient:a.remoteClient,remoteTargets:a.remoteTargets,favorite:favorites.includes(a.id)})),...local.map(a=>({key:`${a.kind}:${a.id}`,kind:a.kind,id:a.id,name:a.name,icon:a.icon,fit:'contain',favorite:localFavorites.includes(`${a.kind}:${a.id}`),launchUnavailable:a.launchUnavailable}))];
const rank=new Map(manual.map((key,index)=>[key,index]));entries.sort((a,b)=>(rank.get(a.key)??Infinity)-(rank.get(b.key)??Infinity)||a.name.localeCompare(b.name)||a.key.localeCompare(b.key));return entries;},[plugins,steam,local,favorites,localFavorites,manual]);
const indexKey=useMemo(()=>JSON.stringify(all.map(a=>a.name).sort()),[all]);
useEffect(()=>{let live=true;const names=Array.from(new Set(all.map(a=>a.name)));if(names.length)void launcherRequest<Record<string,SearchIndex>>('launcher.search.index',{names}).then(v=>{if(live)setIndexes(v);}).catch(()=>{if(live)setError(t('读音搜索暂不可用,仍可使用原文搜索。'));});return()=>{live=false;};},[indexKey]);
@@ -107,8 +108,8 @@ export function Launcher({plugins,safeMode,config=defaults,favorites,localFavori
}else if(swipe.current){const gesture=swipe.current,dx=e.clientX-gesture.x,dy=e.clientY-gesture.y;if(gesture.active||(Math.abs(dx)>36&&Math.abs(dx)>Math.abs(dy)*1.25)){gesture.active=true;gesture.dx=dx;cancelHold();setMenu(null);long.current=true;pageAnimation.current?.cancel();const width=grid.current?.clientWidth??1300,direction=dx<0?1:-1,next=state.current.current+direction,valid=next>=0&&next<state.current.pages;setPanView({offset:valid?Math.max(-width,Math.min(width,dx)):dx*.22,direction,width,settling:false});}else if(holdTarget.current&&Math.hypot(dx,dy)>36){cancelHold();long.current=true;}}};
const up=(e:PointerEvent)=>{if(swipe.current){const gesture=swipe.current;swipe.current=null;settleSwipe(gesture);}cancelHold();stopEdge();const d=drag.current;drag.current=null;setDragging(null);setGhost(null);if(d){long.current=true;if(d.moved)void run('order',async()=>{try{await launcherRequest('launcher.order.save',{order:d.order});}catch(e){setManual(d.before);throw e;}});}};
const cancel=()=>{cancelPan();cancelHold();cancelDrag();long.current=true;};window.addEventListener('pointermove',move);window.addEventListener('pointerup',up);window.addEventListener('pointercancel',cancel);return()=>{window.removeEventListener('pointermove',move);window.removeEventListener('pointerup',up);window.removeEventListener('pointercancel',cancel);};},[]);
function openItem(a:LaunchItem){setMenu(null);if(a.key.startsWith('steam:')&&!a.remote&&a.remoteClient){setLaunchChoice(a);return;}void run(a.key,()=>launch(a),true);}
function chooseLaunch(remote:boolean){if(!launchChoice)return;const item={...launchChoice,remoteClient:remote?launchChoice.remoteClient:undefined};setLaunchChoice(null);void run(item.key,()=>launch(item),true);}
function openItem(a:LaunchItem){setMenu(null);if(a.key.startsWith('steam:')&&a.remoteClient&&(!a.remote||(a.remoteTargets?.length??0)>1)){setLaunchChoice(a);return;}void run(a.key,()=>launch(a),true);}
function chooseLaunch(client?:string){if(!launchChoice)return;const item={...launchChoice,remoteClient:client};setLaunchChoice(null);void run(item.key,()=>launch(item),true);}
function launch(a:LaunchItem){const launcherSession=(window as Window & {framelyLauncherSession?:number}).framelyLauncherSession;if(a.launchUnavailable)return Promise.reject(new Error(t(a.launchUnavailable)));return a.kind==='framely'?launcherRequest('host.manager.open'):a.kind==='plugin'?launcherRequest('plugin.launch',{plugin:a.id,source:'launcher'}):a.key.startsWith('steam:')?launcherRequest('steam.launch',{app:+a.id,remoteClient:a.remoteClient,launcherSession}):launcherRequest('desktop.launch',{app:a.key,launcherSession});}
async function custom(id:string,action:string){const key=`plugin:${id}`;await run(key,async active=>{const v=await launcherRequest('plugin.launcher.action',{plugin:id,action});if(!active())return;if(v.frontend){await new Promise<void>((resolve,reject)=>{const ticket=++actionSequence.current;const timer=setTimeout(()=>done(key,'Launcher action timed out',ticket),90000);pending.current.set(key,{resolve,reject,timer,ticket});setFrontends(items=>({...items,[key]:{plugin:id,action,context:v.context,ticket}}));});}if(active()&&v.opened)setMenu(current=>current===key?null:current);});}
const pending=useRef(new Map<string,{resolve:()=>void;reject:(e:Error)=>void;timer:ReturnType<typeof setTimeout>;ticket:number}>());
@@ -128,5 +129,5 @@ export function Launcher({plugins,safeMode,config=defaults,favorites,localFavori
{a.launchUnavailable&&<small role="status">{t(a.launchUnavailable)}</small>}{busyKeys.has(a.key)&&<small role="status">{t('正在执行…')}</small>}</div>}</div>)}</div>{panView&&current+panView.direction>=0&&current+panView.direction<pages&&<div className="launcher-adjacent-page" aria-hidden="true" style={{translate:`${panView.offset+panView.direction*panView.width}px 0`,transition:panView.settling?'translate 240ms cubic-bezier(.2,.75,.2,1)':'none'}}>{items.slice((current+panView.direction)*pageSize,(current+panView.direction+1)*pageSize).map((a,index)=>{const count=Math.min(pageSize,items.length-(current+panView.direction)*pageSize),{row,column:slot}=launcherCells(count)[index];return <div key={a.key} className="launch-item" style={{gridColumn:`${slot} / span 4`,gridRow:`${1+row*2} / span 2`}}><span className="launch-icon"><LauncherArtwork src={a.icon} name={a.name} kind={a.kind} fit={a.fit}/>{a.remoteClient&&<span className="launch-remote-mark"><IconDeviceDesktopShare size={19} stroke={1.7}/></span>}</span><span className="launch-name">{a.name}</span></div>;})}</div>}</div>
{ghost&&<div className="launcher-drag-ghost" style={{left:ghost.x,top:ghost.y}}><LauncherArtwork src={ghost.item.icon} name={ghost.item.name} kind={ghost.item.kind} fit={ghost.item.fit}/></div>}{ghost&&[-1,1].map(direction=><div key={direction} className={`launcher-drag-edge ${direction<0?'left':'right'} ${edge===direction?'active':''}`} style={{left:(direction<0?ghost.left:ghost.right)-4,top:ghost.top,height:ghost.bottom-ghost.top}} aria-hidden="true"/>)}
{!loading&&!items.length&&<p className="launcher-empty">{t(query?'没有匹配的应用':filter==='favorites'?'还没有收藏的应用':'没有可启动的应用')}</p>}<nav className="launcher-pages" aria-label={t('分页')}>{Array.from({length:pages},(_,n)=><button key={n} aria-label={t('第 {0} 页',{0:n+1})} aria-current={current===n?'page':undefined} disabled={!!dragging||!!panView} onClick={()=>setPage(n)}><span/></button>)}</nav>{loading&&<span className="launcher-loading" role="status">{t('正在读取本机应用…')}</span>}
{launchChoice&&<div className="modal-backdrop" onPointerDown={e=>e.stopPropagation()}><div className="modal launch-choice" role="dialog" aria-modal="true" aria-labelledby="launch-choice-title"><h2 id="launch-choice-title">{launchChoice.name}</h2><p>{t('选择启动方式')}</p><div className="launch-choice-actions"><button onClick={()=>chooseLaunch(false)}><IconDeviceDesktop size={24}/>{t('本机启动')}</button><button onClick={()=>chooseLaunch(true)}><IconDeviceDesktopShare size={24}/>{t('远程畅玩')}</button></div><button onClick={()=>setLaunchChoice(null)}>{t('取消')}</button></div></div>}{uninstall&&<div className="modal-backdrop"><div className="modal" role="dialog" aria-modal="true"><h2>{t('卸载 {0}?',{0:uninstall.manifest.name})}</h2><p>{t('依赖此插件的应用也会停用;插件数据会保留。')}</p>{!!uninstall.dependents.length&&<p>{t('停用:')}{uninstall.dependents.map(id=>plugins[id]?.manifest.name??id).join(', ')}</p>}<button disabled={busyKeys.has(`plugin:${uninstall.manifest.id}`)} onClick={()=>setUninstall(null)}>{t('取消')}</button><button disabled={busyKeys.has(`plugin:${uninstall.manifest.id}`)} className="danger" onClick={()=>void run(`plugin:${uninstall.manifest.id}`,async()=>{await launcherRequest('plugin.uninstall',{plugin:uninstall.manifest.id,purge:false,approveDependents:true,fingerprint:uninstall.fingerprint});setUninstall(null);setMenu(null);})}>{t('确认卸载')}</button></div></div>}{Object.entries(frontends).map(([key,frontend])=><div key={`${key}:${frontend.ticket}`} className="launcher-action-frame" aria-hidden="true"><PluginFrame plugin={frontend.plugin} entry={`action-${frontend.action}`} actionContext={frontend.context} onActionDone={error=>done(key,error,frontend.ticket)}/></div>)}</div>;
{launchChoice&&<SteamLaunchChoice name={launchChoice.name} icon={launchChoice.icon} local={!launchChoice.remote} targets={launchChoice.remoteTargets?.length?launchChoice.remoteTargets:[{client:launchChoice.remoteClient!,name:''}]} onChoose={chooseLaunch} onClose={()=>setLaunchChoice(null)}/>}{uninstall&&<div className="modal-backdrop"><div className="modal" role="dialog" aria-modal="true"><h2>{t('卸载 {0}?',{0:uninstall.manifest.name})}</h2><p>{t('依赖此插件的应用也会停用;插件数据会保留。')}</p>{!!uninstall.dependents.length&&<p>{t('停用:')}{uninstall.dependents.map(id=>plugins[id]?.manifest.name??id).join(', ')}</p>}<button disabled={busyKeys.has(`plugin:${uninstall.manifest.id}`)} onClick={()=>setUninstall(null)}>{t('取消')}</button><button disabled={busyKeys.has(`plugin:${uninstall.manifest.id}`)} className="danger" onClick={()=>void run(`plugin:${uninstall.manifest.id}`,async()=>{await launcherRequest('plugin.uninstall',{plugin:uninstall.manifest.id,purge:false,approveDependents:true,fingerprint:uninstall.fingerprint});setUninstall(null);setMenu(null);})}>{t('确认卸载')}</button></div></div>}{Object.entries(frontends).map(([key,frontend])=><div key={`${key}:${frontend.ticket}`} className="launcher-action-frame" aria-hidden="true"><PluginFrame plugin={frontend.plugin} entry={`action-${frontend.action}`} actionContext={frontend.context} onActionDone={error=>done(key,error,frontend.ticket)}/></div>)}</div>;
}
+13
View File
@@ -0,0 +1,13 @@
import React,{useEffect,useRef} from 'react';
import {IconArrowRight,IconDeviceDesktop,IconDeviceDesktopShare,IconX} from '@tabler/icons-react';
import {PluginImage} from './store';
import {t} from './i18n';
export type RemoteLaunchTarget={client:string;name:string};
export function SteamLaunchChoice({name,icon,local,targets,onChoose,onClose}:{name:string;icon?:string|null;local:boolean;targets:RemoteLaunchTarget[];onChoose:(client?:string)=>void;onClose:()=>void}){
const dialog=useRef<HTMLDivElement>(null);
useEffect(()=>{const previous=document.activeElement as HTMLElement|null;dialog.current?.querySelector<HTMLButtonElement>('.launch-choice-target')?.focus();return()=>previous?.focus();},[]);
return <div className="modal-backdrop" onPointerDown={e=>e.stopPropagation()} onClick={e=>e.stopPropagation()}><div ref={dialog} className="modal launch-choice" role="dialog" aria-modal="true" aria-labelledby="launch-choice-title" onKeyDown={e=>{if(e.key!=='Tab')return;const buttons=Array.from(dialog.current?.querySelectorAll<HTMLButtonElement>('button')??[]);const first=buttons[0],last=buttons.at(-1);if(e.shiftKey&&document.activeElement===first){e.preventDefault();last?.focus();}else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first?.focus();}}}>
<header className="launch-choice-heading"><PluginImage kind="steam" src={icon} name={name} size={56}/><div><h2 id="launch-choice-title">{name}</h2><p>{t('选择启动方式')}</p></div><button className="icon-button" aria-label={t('取消')} onClick={onClose}><IconX size={22}/></button></header>
<div className="launch-choice-targets">{local&&<button className="launch-choice-target" onClick={()=>onChoose()}><span className="launch-choice-symbol"><IconDeviceDesktop size={26} stroke={1.6}/></span><span className="launch-choice-copy"><b>{t('本机启动')}</b><small>{t('在此 Frame 上运行')}</small></span><IconArrowRight className="launch-choice-arrow" size={22} stroke={1.6}/></button>}{targets.map(target=><button key={target.client} className="launch-choice-target" onClick={()=>onChoose(target.client)}><span className="launch-choice-symbol"><IconDeviceDesktopShare size={26} stroke={1.6}/></span><span className="launch-choice-copy"><b>{target.name||t('远程设备 {0}',{0:target.client})}</b><small>{t('远程畅玩')}</small></span><IconArrowRight className="launch-choice-arrow" size={22} stroke={1.6}/></button>)}</div>
</div></div>;
}