From fbd3f9cfa30c392e89f7faecafcc5b337ea2f903 Mon Sep 17 00:00:00 2001 From: toorux Date: Wed, 7 Oct 2026 11:46:11 +0800 Subject: [PATCH] feat(launcher): offer local and Remote Play launch choices --- docs/launcher.md | 4 + docs/zh-CN/launcher.md | 4 + src/session.rs | 16 ++- src/steam.rs | 224 ++++++++++++++++++++++++++++------------- src/steam/remote.js | 22 ++++ src/steam/remote.rs | 77 ++++++++++++++ tests/steam_remote.mjs | 19 ++++ ui/src/launcher.css | 5 + ui/src/launcher.tsx | 30 +++--- 9 files changed, 318 insertions(+), 83 deletions(-) create mode 100644 src/steam/remote.js create mode 100644 src/steam/remote.rs create mode 100644 tests/steam_remote.mjs diff --git a/docs/launcher.md b/docs/launcher.md index 1574dce..f06de73 100644 --- a/docs/launcher.md +++ b/docs/launcher.md @@ -51,3 +51,7 @@ The result shows RMS target bias and the worst target bias in degrees. Each targ After substantial changes to headset fit or the window distance, run calibration again. Press feedback appears only after holding for 200 ms. The Dock entry uses a blue border indicator and opens the quick panel at 600 ms. Launcher icons show a translucent white circular progress border: the halfway tick opens the menu at 400 ms; continuing to 800 ms enters icon editing. Release or cancellation removes the indicator. + +### Remote Steam games + +The Steam category also shows apps installed on a connected Remote Play computer. A small Remote Play badge appears at the bottom right of their icons. The list refreshes in the background; disconnected hosts disappear, and each App ID has one icon. If both local and remote installations are available, clicking it opens a choice between launching locally and Remote Play. Favorites and manual ordering remain shared for that game. Clicking a remote icon asks Steam to stream from its current host and checks availability again before launching. Artwork uses the local Steam cache, with a placeholder when unavailable. The integration uses the running Steam client UI; if that interface is unavailable, local games remain usable. diff --git a/docs/zh-CN/launcher.md b/docs/zh-CN/launcher.md index a5b7269..e4f6060 100644 --- a/docs/zh-CN/launcher.md +++ b/docs/zh-CN/launcher.md @@ -53,3 +53,7 @@ Steam 启动成功表示请求已交给 Steam,并不代表游戏加载完成 明显调整头显佩戴方式或窗口距离后,请重新校准。 按住 200 毫秒后才显示进度反馈。Dock 入口使用蓝色边框进度,600 毫秒打开快捷面板。启动台图标使用半透明白色圆形边框进度:中点刻度对应 400 毫秒打开菜单,继续按至 800 毫秒进入图标编辑;松手或取消按压后移除进度。 + +### Steam 远程畅玩 + +Steam 分类也显示已连接远程畅玩电脑上安装的应用,图标右下角标有远程图标。列表后台刷新,断连的电脑不再展示;相同 App ID 只显示一个图标;本机和远程都可用时,点击弹窗选择本机启动或远程畅玩,收藏和手动排序保持同一份。点击远程图标会先重新检查可用状态,再让 Steam 从对应电脑串流。图片使用本机 Steam 缓存,缺图显示占位图。此功能读取正在运行的 Steam 客户端界面;该接口不可用时,本机游戏仍可使用。 diff --git a/src/session.rs b/src/session.rs index e0ca96d..a6c2691 100644 --- a/src/session.rs +++ b/src/session.rs @@ -953,14 +953,26 @@ impl Agent { } Ok(json!(true)) } - "steam.list" => Ok(json!(crate::steam::discover(&crate::steam::home()?))), + "steam.list" => { + if p["remoteOnly"] == true { + Ok(crate::steam::remote_snapshot()) + } else { + Ok(json!(crate::steam::discover_launcher( + &crate::steam::home()? + ))) + } + } "steam.launch" => { let launcher_session = p["launcherSession"].as_u64(); let id = p["app"] .as_u64() .filter(|v| *v > 0 && *v <= u32::MAX as u64) .context("Invalid Steam app")? as u32; - crate::steam::launch(id)?; + if let Some(client) = p["remoteClient"].as_str() { + crate::steam::launch_remote(id, client)?; + } else { + crate::steam::launch(id)?; + } push( &self.commands, json!({"kind":"launcher.close","handoff":true,"launcherSession":launcher_session}), diff --git a/src/steam.rs b/src/steam.rs index 841466d..773351a 100644 --- a/src/steam.rs +++ b/src/steam.rs @@ -1,4 +1,5 @@ -//! Local Steam library discovery. No account API or network access is needed. +//! Local Steam libraries and connected Remote Play hosts. +mod remote; use anyhow::{ensure, Context, Result}; use serde::Serialize; use std::{ @@ -9,6 +10,9 @@ use std::{ #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct App { + pub remote: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub remote_client: Option, pub id: u32, pub name: String, pub kind: &'static str, @@ -202,77 +206,13 @@ pub fn discover(home: &Path) -> Vec { { continue; } - let dir = steam.join("appcache/librarycache").join(id.to_string()); - let mut candidates: Vec<_> = fs::read_dir(dir) - .ok() - .into_iter() - .flatten() - .flatten() - .map(|e| e.path()) - .collect(); - // Newer Steam clients keep named artwork one level below a content hash. - let nested: Vec<_> = candidates - .iter() - .filter(|p| p.is_dir()) - .take(16) - .cloned() - .collect(); - for path in nested { - candidates.extend( - fs::read_dir(path) - .ok() - .into_iter() - .flatten() - .flatten() - .take(32) - .map(|e| e.path()), - ); - } - for suffix in ["icon.jpg", "icon.png", "library_600x900.jpg", "header.jpg"] { - candidates.push( - steam - .join("appcache/librarycache") - .join(format!("{id}_{suffix}")), - ); - } - let artwork_fit = |p: &Path| { - let name = p.file_name().and_then(|n| n.to_str()).unwrap_or_default(); - if name.contains("600x900") || name == "library_capsule.jpg" { - (0, "cover") - } else if name.contains("header") { - (1, "cover") - } else if name == "logo.png" { - (2, "contain") - } else { - (3, "contain") - } - }; - candidates.sort_by_key(|p| (artwork_fit(p).0, p.clone())); - let icon = candidates.into_iter().take(32).find_map(|p| { - if fs::metadata(&p).ok()?.len() > 1024 * 1024 { - return None; - } - let b = fs::read(&p).ok()?; - let mime = if b.starts_with(b"\x89PNG\r\n\x1a\n") { - "image/png" - } else if b.starts_with(&[0xff, 0xd8, 0xff]) { - "image/jpeg" - } else { - return None; - }; - use base64::Engine; - Some(( - format!( - "data:{mime};base64,{}", - base64::engine::general_purpose::STANDARD.encode(b) - ), - artwork_fit(&p).1, - )) - }); + let icon = cached_artwork(&steam, id); apps.insert( id, App { id, + remote: false, + remote_client: None, name: name.into(), kind: if text(v, "installdir").is_some_and(|dir| { fs::read_dir(library.join("steamapps/common").join(dir)).is_ok_and( @@ -295,6 +235,117 @@ pub fn discover(home: &Path) -> Vec { } apps.into_values().collect() } +fn cached_artwork(steam: &Path, id: u32) -> Option<(String, &'static str)> { + let dir = steam.join("appcache/librarycache").join(id.to_string()); + let mut candidates: Vec<_> = fs::read_dir(dir) + .ok() + .into_iter() + .flatten() + .flatten() + .map(|e| e.path()) + .collect(); + // Newer Steam clients keep named artwork one level below a content hash. + let nested: Vec<_> = candidates + .iter() + .filter(|p| p.is_dir()) + .take(16) + .cloned() + .collect(); + for path in nested { + candidates.extend( + fs::read_dir(path) + .ok() + .into_iter() + .flatten() + .flatten() + .take(32) + .map(|e| e.path()), + ); + } + for suffix in ["icon.jpg", "icon.png", "library_600x900.jpg", "header.jpg"] { + candidates.push( + steam + .join("appcache/librarycache") + .join(format!("{id}_{suffix}")), + ); + } + let artwork_fit = |p: &Path| { + let name = p.file_name().and_then(|n| n.to_str()).unwrap_or_default(); + if name.contains("600x900") || name == "library_capsule.jpg" { + (0, "cover") + } else if name.contains("header") { + (1, "cover") + } else if name == "logo.png" { + (2, "contain") + } else { + (3, "contain") + } + }; + candidates.sort_by_key(|p| (artwork_fit(p).0, p.clone())); + candidates.into_iter().take(32).find_map(|p| { + if fs::metadata(&p).ok()?.len() > 1024 * 1024 { + return None; + } + let b = fs::read(&p).ok()?; + let mime = if b.starts_with(b"\x89PNG\r\n\x1a\n") { + "image/png" + } else if b.starts_with(&[0xff, 0xd8, 0xff]) { + "image/jpeg" + } else { + return None; + }; + use base64::Engine; + Some(( + format!( + "data:{mime};base64,{}", + base64::engine::general_purpose::STANDARD.encode(b) + ), + artwork_fit(&p).1, + )) + }) +} + +pub fn remote_snapshot() -> serde_json::Value { + serde_json::json!(remote::cached()) +} +pub fn discover_launcher(home: &Path) -> Vec { + merge_remote(home, discover(home), remote::cached()) +} +fn merge_remote(home: &Path, local: Vec, remote: Vec) -> Vec { + let mut apps: BTreeMap<_, _> = local.into_iter().map(|a| (a.id, a)).collect(); + let steam = home.join(".local/share/Steam"); + let steam = if steam.exists() { + steam + } else { + 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 icon = cached_artwork(&steam, a.id); + App { + id: a.id, + name: a.name, + kind: "steam", + 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), + } + }); + } + apps.into_values().collect() +} +pub fn launch_remote(id: u32, client: &str) -> Result<()> { + remote::launch(id, client) +} + pub fn home() -> Result { ensure!( unsafe { libc::geteuid() } != 0, @@ -322,6 +373,43 @@ fn launch_installed(home: &Path, id: u32) -> Result<()> { mod tests { use super::*; #[test] + fn remote_inventory_merges_launch_choices_without_duplicate_icons() { + let home = tempfile::tempdir().unwrap(); + let local = vec![App { + id: 42, + name: "Local".into(), + kind: "steam", + icon: None, + icon_fit: "contain", + remote: false, + remote_client: None, + }]; + let rows = vec![ + remote::RemoteApp { + id: 42, + name: "Same remote".into(), + client: "12".into(), + }, + remote::RemoteApp { + id: 43, + name: "Remote".into(), + client: "12".into(), + }, + remote::RemoteApp { + id: 43, + name: "Other host".into(), + client: "13".into(), + }, + ]; + let apps = merge_remote(home.path(), local, rows); + assert_eq!(apps.len(), 2); + assert!(!apps[0].remote); + assert_eq!(apps[0].name, "Local"); + assert_eq!(apps[0].remote_client.as_deref(), Some("12")); + assert!(apps[1].remote); + assert_eq!(apps[1].remote_client.as_deref(), Some("12")); + } + #[test] fn nested_escaped_and_invalid() { let t = tokens("\"libraryfolders\" { \"0\" { \"path\" \"/a\\\\b\" } }").unwrap(); let v = object(&t, &mut 0, 0, false).unwrap(); diff --git a/src/steam/remote.js b/src/steam/remote.js new file mode 100644 index 0000000..2268502 --- /dev/null +++ b/src/steam/remote.js @@ -0,0 +1,22 @@ +async function(request) { + const store=window.appStore, remote=window.RemotePlayStore_SteamUI; + if(!store||!remote||!Array.isArray(store.allApps)||!Array.isArray(remote.devices)) { + 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 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}); + } + } + if(request.mode==='list')return rows.slice(0,10000); + if(request.mode!=='launch'||!rows.some(r=>r.id===request.id&&r.client===request.client))return false; + if(typeof SteamClient.Apps.StreamGame!=='function')throw Error('Steam streaming is unavailable'); + await SteamClient.Apps.StreamGame(request.id,request.client,-1); + return true; +} diff --git a/src/steam/remote.rs b/src/steam/remote.rs new file mode 100644 index 0000000..16d102f --- /dev/null +++ b/src/steam/remote.rs @@ -0,0 +1,77 @@ +//! Steam's current connected-host inventory; no account scraping or media reads. +use anyhow::{ensure, Result}; +use serde::{Deserialize, Serialize}; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Mutex, OnceLock, +}; +use std::time::{Duration, Instant}; +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) struct RemoteApp { + pub id: u32, + pub name: String, + pub client: String, +} +fn query(request: serde_json::Value) -> Result { + crate::apk::steam_ui::evaluate(&format!("({})({request})", include_str!("remote.js"))) +} +fn inventory(value: serde_json::Value) -> Result> { + let rows: Vec = serde_json::from_value(value)?; + ensure!(rows.len() <= 10000, "Remote library is too large"); + Ok(rows + .into_iter() + .filter(|a| { + a.id > 0 && !a.name.trim().is_empty() && a.name.len() <= 1024 && valid_client(&a.client) + }) + .collect()) +} +fn valid_client(client: &str) -> bool { + !client.is_empty() + && client.len() <= 20 + && client.bytes().all(|b| b.is_ascii_digit()) + && client != "0" +} +static CACHE: OnceLock)>>> = OnceLock::new(); +static REFRESHING: AtomicBool = AtomicBool::new(false); +pub(super) fn cached() -> Vec { + let cache = CACHE.get_or_init(|| Mutex::new(None)); + let snapshot = cache.lock().unwrap().clone(); + if snapshot + .as_ref() + .is_none_or(|(at, _)| at.elapsed() >= Duration::from_secs(3)) + && REFRESHING + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + std::thread::spawn(|| { + let rows = query(serde_json::json!({"mode":"list"})) + .and_then(inventory) + .unwrap_or_default(); + *CACHE.get().unwrap().lock().unwrap() = Some((Instant::now(), rows)); + REFRESHING.store(false, Ordering::Release); + }); + } + snapshot + .filter(|(at, _)| at.elapsed() < Duration::from_secs(15)) + .map(|(_, v)| v) + .unwrap_or_default() +} +pub(super) fn launch(id: u32, client: &str) -> Result<()> { + ensure!(id > 0 && valid_client(client), "Invalid remote Steam game"); + ensure!( + query(serde_json::json!({"mode":"launch","id":id,"client":client}))? == true, + "Remote game or host is no longer available" + ); + Ok(()) +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn invalid_remote_entries_and_clients_are_rejected() { + let rows=inventory(serde_json::json!([{ "id":1,"name":"Game","client":"123"},{"id":0,"name":"Invalid","client":"12"},{"id":2,"name":"Unsafe","client":"1);evil()"}])).unwrap(); + assert_eq!(rows.len(), 1); + assert!(launch(42, "0").is_err()); + assert!(launch(42, r#"1"2"#).is_err()); + } +} diff --git a/tests/steam_remote.mjs b/tests/steam_remote.mjs new file mode 100644 index 0000000..f097d1e --- /dev/null +++ b/tests/steam_remote.mjs @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import vm from 'node:vm'; +const calls=[]; +const app=(id,remote,extra={})=>({appid:id,display_name:`Game ${id}`,app_type:1,visible_in_game_list:true,remote_per_client_data:remote,BIsPerClientDataLocal:r=>r.clientid==='local',...extra}); +const context={window:{appStore:{allApps:[app(1,[{clientid:'12',installed:true}]),app(2,[{clientid:'13',installed:true}]),app(3,[{clientid:'12',installed:false}]),app(4,[{clientid:'local',installed:true}]),app(5,[{clientid:'12',installed:true}],{app_type:8192}),app(6,[{clientid:'12',installed:true}],{visible_in_game_list:false})]},RemotePlayStore_SteamUI:{devices:[{clientId:'12',status:'Connected'},{clientId:'13',status:'Disconnected'},{clientId:'local',status:'Connected'}]}},SteamClient:{Apps:{StreamGame:(...args)=>calls.push(args)}}}; +const query=vm.runInNewContext(`(${fs.readFileSync('src/steam/remote.js','utf8')})`,context); +assert.deepEqual(JSON.parse(JSON.stringify(await query({mode:'list'}))),[{id:1,name:'Game 1',client:'12'}]); +assert.equal(await query({mode:'launch',id:1,client:'13'}),false); +assert.equal(await query({mode:'launch',id:999,client:'12'}),false); +assert.equal(calls.length,0); +assert.equal(await query({mode:'launch',id:1,client:'12'}),true); +assert.deepEqual(calls,[[1,'12',-1]]); +context.window.RemotePlayStore_SteamUI.devices=[]; +assert.equal(await query({mode:'launch',id:1,client:'12'}),false); +assert.equal((await query({mode:'list'})).length,0); +context.window.appStore=null; +assert.equal((await query({mode:'list'})).length,0); +console.log('Steam remote inventory and validated launch: passed'); diff --git a/ui/src/launcher.css b/ui/src/launcher.css index 7f1762c..b12451e 100644 --- a/ui/src/launcher.css +++ b/ui/src/launcher.css @@ -195,3 +195,8 @@ @keyframes launch-hold-fill{to{stroke-dashoffset:0}} @media(max-width:900px){.launch-hold-progress{width:94px;height:94px}} + +.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} diff --git a/ui/src/launcher.tsx b/ui/src/launcher.tsx index 47adebc..4564739 100644 --- a/ui/src/launcher.tsx +++ b/ui/src/launcher.tsx @@ -2,7 +2,7 @@ import {SwitchRow} from './switch'; import React,{useEffect,useLayoutEffect,useMemo,useRef,useState} from 'react'; import {api} from './api'; import {t} from './i18n'; -import {IconLayoutGrid,IconPuzzle,IconBrandSteam,IconStar,IconAdjustments,IconTrash,IconDeviceDesktop,IconBrandAndroid,IconGridDots,IconCheck,IconSearch,IconX,IconApps,IconSettings,IconPackageImport,IconFocus2,IconBan} from '@tabler/icons-react'; +import {IconLayoutGrid,IconPuzzle,IconBrandSteam,IconStar,IconAdjustments,IconTrash,IconDeviceDesktop,IconBrandAndroid,IconGridDots,IconCheck,IconSearch,IconX,IconApps,IconSettings,IconPackageImport,IconFocus2,IconBan,IconDeviceDesktopShare} from '@tabler/icons-react'; import {installedIcon} from './store'; import {PluginFrame} from './plugin-frame'; import type {Plugin} from './main'; @@ -12,7 +12,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={id:number;name:string;icon?:string;iconFit?:string;kind?:string}; +type SteamApp={remote?:boolean;remoteClient?: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 +32,7 @@ export function LauncherSettings({config=defaults,refresh}:{config?:LauncherConf return <>

{t('启动方式与菜单')}

void save({prioritizeUi})}/>

{t('点击入口图标打开启动台;长按 600 毫秒打开快捷面板。')}

void save({menuAutoClose})}/>{error&&

{error}

}

{t('全部包含的分类')}

{t('只影响全部分类,其他分类和收藏仍可访问。')}

{categories.filter(c=>!['all','favorites'].includes(c.key)).map(c=>{const selected=new Set(config.allCategories??defaults.allCategories);if(checked)selected.add(c.key);else selected.delete(c.key);void save({allCategories:[...selected]});}}/>)}

{t('眼追校准')}

{t('在 Frame 内打开模拟启动台,依次注视高亮目标。')}

; } type LocalApp={steamAppId?:number|null;id:string;name:string;kind:'desktop'|'lepton';icon?:string;launchUnavailable?:string|null}; -type LaunchItem={key:string;kind:string;id:string;name:string;icon?:string|null;fit?:string;favorite:boolean;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}; 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(method:string,params:unknown={}):Promise{ @@ -48,6 +48,7 @@ export function Launcher({plugins,safeMode,config=defaults,favorites,localFavori const [calibrating,setCalibrating]=useState(location.hash==='#calibrate'),calibrationRef=useRef(calibrating);calibrationRef.current=calibrating; function endCalibration(){void api('host.launcher.calibrate',{active:false}).catch(e=>setError(String(e)));location.hash='';setCalibrating(false);} useEffect(()=>{const changed=()=>setCalibrating(location.hash==='#calibrate');window.addEventListener('hashchange',changed);return()=>window.removeEventListener('hashchange',changed);},[]); + const[launchChoice,setLaunchChoice]=useState(null),choiceRef=useRef(null);choiceRef.current=launchChoice; const[steam,setSteam]=useState([]),[local,setLocal]=useState([]),[loading,setLoading]=useState(true),[error,setError]=useState(''),[filter,setFilter]=useState('all'),[page,setPage]=useState(0),[menu,setMenu]=useState(null),[busyKeys,setBusyKeys]=useState>(new Set()),[launchingKeys,setLaunchingKeys]=useState>(new Set()),[uninstall,setUninstall]=useState<(Plugin&{dependents:string[];fingerprint:string})|null>(null),[frontends,setFrontends]=useState>({}),[outside,setOutside]=useState(false),[editing,setEditing]=useState(false),[manual,setManual]=useState(order),[dragging,setDragging]=useState(null),[ghost,setGhost]=useState<{item:LaunchItem;x:number;y:number;left:number;right:number;top:number;bottom:number}|null>(null),[edge,setEdge]=useState(0),[panView,setPanView]=useState<{offset:number;direction:number;width:number;settling:boolean}|null>(null); const panTimer=useRef|null>(null),skipPageAnimation=useRef(false),grid=useRef(null),pageContent=useRef(null),swipe=useRef<{x:number;y:number;active?:boolean;dx?:number}|null>(null),shell=useRef(null),hold=useRef|null>(null),editHold=useRef|null>(null),edgeTimer=useRef|null>(null),long=useRef(false),point=useRef({x:0,y:0}),holdTarget=useRef(null),holdVisual=useRef(null),mounted=useRef(true),operations=useRef(new Mapvoid}>()),actionSequence=useRef(0),menuRef=useRef(null),drag=useRef<{key:string;before:string[];order:string[];moved:boolean;over:string|null;edge:number;lastX:number;lastY:number;startedInEdit:boolean}|null>(null),state=useRef({editing:false,current:0,pages:1,filter:'all',panning:false,items:[] as LaunchItem[],all:[] as LaunchItem[]}); useEffect(()=>{if(!drag.current)setManual(order);},[order]); @@ -56,19 +57,20 @@ export function Launcher({plugins,safeMode,config=defaults,favorites,localFavori function cancelHold(){holdVisual.current?.remove();holdVisual.current=null;if(hold.current)clearTimeout(hold.current);if(editHold.current)clearTimeout(editHold.current);hold.current=null;editHold.current=null;holdTarget.current=null;} function stopEdge(){if(edgeTimer.current)clearTimeout(edgeTimer.current);edgeTimer.current=null;setEdge(0);if(drag.current)drag.current.edge=0;} function cancelDrag(){if(drag.current)setManual(drag.current.before);drag.current=null;setDragging(null);setGhost(null);stopEdge();} - useEffect(()=>{mounted.current=true;let live=true,sequence=0;const load=()=>{for(const operation of operations.current.values())if(Date.now()-operation.started>=120000)operation.expire();cancelHold();cancelDrag();cancelPan();setEditing(false);long.current=false;const current=++sequence;opening.current=remembered.current===null;setQuery('');setPage(0);setLoading(true);setMenu(null);setOutside(false);void Promise.allSettled([launcherRequest('steam.list'),launcherRequest('desktop.list')]).then(results=>{if(!live||current!==sequence)return;const [steamResult,localResult]=results;if(steamResult.status==='fulfilled')setSteam(steamResult.value);else setError(String(steamResult.reason));if(localResult.status==='fulfilled')setLocal(localResult.value);else setError(String(localResult.reason));if(opening.current){const known=props.current;const hasFavorite=Object.values(known.plugins).some(p=>p.enabled&&p.favorite)||(steamResult.status==='fulfilled'&&steamResult.value.some(a=>known.favorites.includes(a.id)))||known.localFavorites.includes('framely')||(localResult.status==='fulfilled'&&localResult.value.some(a=>known.localFavorites.includes(`${a.kind}:${a.id}`)));selectCategory(hasFavorite?'favorites':'all');}setLoading(false);});};load();const cancel=()=>{cancelPan();cancelHold();cancelDrag();long.current=true;setMenu(null);setEditing(false);};window.addEventListener('framely.launcher.cancelInput',cancel);window.addEventListener('framely.launcher.open',load);return()=>{live=false;mounted.current=false;for(const operation of operations.current.values())operation.expire();for(const key of pending.current.keys())done(key,'Launcher closed');window.removeEventListener('framely.launcher.open',load);window.removeEventListener('framely.launcher.cancelInput',cancel);cancelHold();if(edgeTimer.current)clearTimeout(edgeTimer.current);if(panTimer.current)clearTimeout(panTimer.current);};},[]); + useEffect(()=>{mounted.current=true;let live=true,sequence=0;const load=()=>{for(const operation of operations.current.values())if(Date.now()-operation.started>=120000)operation.expire();cancelHold();cancelDrag();cancelPan();setEditing(false);setLaunchChoice(null);long.current=false;const current=++sequence;opening.current=remembered.current===null;setQuery('');setPage(0);setLoading(true);setMenu(null);setOutside(false);void Promise.allSettled([launcherRequest('steam.list'),launcherRequest('desktop.list')]).then(results=>{if(!live||current!==sequence)return;const [steamResult,localResult]=results;if(steamResult.status==='fulfilled')setSteam(steamResult.value);else setError(String(steamResult.reason));if(localResult.status==='fulfilled')setLocal(localResult.value);else setError(String(localResult.reason));if(opening.current){const known=props.current;const hasFavorite=Object.values(known.plugins).some(p=>p.enabled&&p.favorite)||(steamResult.status==='fulfilled'&&steamResult.value.some(a=>known.favorites.includes(a.id)))||known.localFavorites.includes('framely')||(localResult.status==='fulfilled'&&localResult.value.some(a=>known.localFavorites.includes(`${a.kind}:${a.id}`)));selectCategory(hasFavorite?'favorites':'all');}setLoading(false);});};load();const cancel=()=>{cancelPan();cancelHold();cancelDrag();long.current=true;setMenu(null);setLaunchChoice(null);setEditing(false);};window.addEventListener('framely.launcher.cancelInput',cancel);window.addEventListener('framely.launcher.open',load);return()=>{live=false;mounted.current=false;for(const operation of operations.current.values())operation.expire();for(const key of pending.current.keys())done(key,'Launcher closed');window.removeEventListener('framely.launcher.open',load);window.removeEventListener('framely.launcher.cancelInput',cancel);cancelHold();if(edgeTimer.current)clearTimeout(edgeTimer.current);if(panTimer.current)clearTimeout(panTimer.current);};},[]); + useEffect(()=>{let enabled=true,pending=false,generation=0,signature:string|null=null;const timer=setInterval(()=>{if(!enabled||pending)return;pending=true;const epoch=generation;void launcherRequest('steam.list',{remoteOnly:true}).then(async rows=>{const next=JSON.stringify(rows);if(!enabled||epoch!==generation||next===signature)return;const apps=await launcherRequest('steam.list');if(enabled&&epoch===generation){signature=next;setSteam(apps);}}).catch(()=>{}).finally(()=>{pending=false;});},4000);const open=()=>{generation++;enabled=true;signature=null;};const close=()=>{generation++;enabled=false;};window.addEventListener('framely.launcher.open',open);window.addEventListener('framely.launcher.closing',close);window.addEventListener('framely.launcher.cancelInput',close);return()=>{enabled=false;clearInterval(timer);window.removeEventListener('framely.launcher.open',open);window.removeEventListener('framely.launcher.closing',close);window.removeEventListener('framely.launcher.cancelInput',close);};},[]); useEffect(()=>{if(!drag.current)cancelHold();setMenu(null);setOutside(false);},[filter,page]); useEffect(()=>{if(!menu||!outside||!config.menuAutoClose||!!(menu&&busyKeys.has(menu)))return;const timer=setTimeout(()=>setMenu(null),config.menuTimeoutSeconds*1000);return()=>clearTimeout(timer);},[menu,outside,busyKeys,config.menuAutoClose,config.menuTimeoutSeconds]); function cancelPan(){if(panTimer.current)clearTimeout(panTimer.current);panTimer.current=null;swipe.current=null;setPanView(null);} - function close(){if(calibrationRef.current){endCalibration();return;}if(keyboard.current)return;cancelHold();cancelDrag();cancelPan();setMenu(null);if(state.current.editing){setEditing(false);return;}void launcherRequest('host.launcher.close');} + function close(){if(choiceRef.current){setLaunchChoice(null);return;}if(calibrationRef.current){endCalibration();return;}if(keyboard.current)return;cancelHold();cancelDrag();cancelPan();setMenu(null);if(state.current.editing){setEditing(false);return;}void launcherRequest('host.launcher.close');} function settleSwipe(gesture:{dx?:number;active?:boolean}){if(!gesture.active)return;const s=state.current,width=grid.current?.clientWidth??1300,dx=gesture.dx??0,direction=dx<0?1:-1,next=s.current+direction,valid=next>=0&&next=70;setPanView({offset:advance?-direction*width:0,direction,width,settling:true});long.current=true;panTimer.current=setTimeout(()=>{panTimer.current=null;if(advance){skipPageAnimation.current=true;setPage(next);}setPanView(null);},240);} - useEffect(()=>{const back=()=>close();const key=(e:KeyboardEvent)=>{if(e.key==='Escape')close();};window.addEventListener('framely.back',back);window.addEventListener('keydown',key);const navigate=(e:Event)=>{if(calibrationRef.current||keyboard.current||state.current.panning)return;const direction=(e as CustomEvent).detail;const s=state.current;if(drag.current){if(Math.abs(direction)===1){stopEdge();moveDraggedPage(direction);}return;}setMenu(null);if(Math.abs(direction)===1)setPage(Math.max(0,Math.min(s.pages-1,s.current+direction)));else if(Math.abs(direction)===2){const index=categories.findIndex(c=>c.key===s.filter);selectCategory(categories[Math.max(0,Math.min(categories.length-1,index+(direction>0?-1:1)))].key);setPage(0);}};window.addEventListener('framely.launcher.navigate',navigate);return()=>{window.removeEventListener('framely.back',back);window.removeEventListener('keydown',key);window.removeEventListener('framely.launcher.navigate',navigate);};},[]); + useEffect(()=>{const back=()=>close();const key=(e:KeyboardEvent)=>{if(e.key==='Escape')close();};window.addEventListener('framely.back',back);window.addEventListener('keydown',key);const navigate=(e:Event)=>{if(choiceRef.current||calibrationRef.current||keyboard.current||state.current.panning)return;const direction=(e as CustomEvent).detail;const s=state.current;if(drag.current){if(Math.abs(direction)===1){stopEdge();moveDraggedPage(direction);}return;}setMenu(null);if(Math.abs(direction)===1)setPage(Math.max(0,Math.min(s.pages-1,s.current+direction)));else if(Math.abs(direction)===2){const index=categories.findIndex(c=>c.key===s.filter);selectCategory(categories[Math.max(0,Math.min(categories.length-1,index+(direction>0?-1:1)))].key);setPage(0);}};window.addEventListener('framely.launcher.navigate',navigate);return()=>{window.removeEventListener('framely.back',back);window.removeEventListener('keydown',key);window.removeEventListener('framely.launcher.navigate',navigate);};},[]); useEffect(()=>{const preference=window.matchMedia('(prefers-reduced-motion: reduce)'),report=()=>console.log(`FRAMELY_LAUNCHER_REDUCED_MOTION=${preference.matches?1:0}`);report();preference.addEventListener('change',report);return()=>preference.removeEventListener('change',report);},[]); // 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,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,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>('launcher.search.index',{names}).then(v=>{if(live)setIndexes(v);}).catch(()=>{if(live)setError(t('读音搜索暂不可用,仍可使用原文搜索。'));});return()=>{live=false;};},[indexKey]); @@ -105,7 +107,9 @@ 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&&next36){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 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,launcherSession}):launcherRequest('desktop.launch',{app:a.key,launcherSession});} + 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 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((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 Mapvoid;reject:(e:Error)=>void;timer:ReturnType;ticket:number}>()); function done(key:string,error?:string,ticket?:number){const p=pending.current.get(key);if(!p||(ticket!==undefined&&p.ticket!==ticket))return;clearTimeout(p.timer);pending.current.delete(key);if(mounted.current)setFrontends(items=>{const next={...items};delete next[key];return next;});if(error)p.reject(new Error(error));else p.resolve();} @@ -113,16 +117,16 @@ export function Launcher({plugins,safeMode,config=defaults,favorites,localFavori return
{if(e.button===0&&!(e.target as HTMLElement).closest('button,input,.launcher-search,.launch-menu,.modal,.launcher-error')){if(!keyboard.current&&!state.current.panning)swipe.current={x:e.clientX,y:e.clientY};long.current=false;try{e.currentTarget.setPointerCapture(e.pointerId);}catch{}}}} className={`launcher-shell ${editing?'launcher-editing':''} ${loading&&opening.current?'launcher-initializing':''}`} onClick={e=>{if(long.current){long.current=false;return;}if(!(e.target as HTMLElement).closest('button,input,.launcher-search,.launch-menu,.modal,.launcher-error'))setMenu(null);}}>
{cancelPan();cancelHold();setMenu(null);setQuery(e.target.value);setPage(0);}}/>{query&&}
- + {editing&&
{t('拖动图标排列,移到两侧翻页')}
} {safeMode&&

{t('安全模式:第三方插件暂停运行。')}

}{error&&
{error}
}
{visible.map((a,index)=>
{if(menu===a.key)setOutside(false);}} onPointerLeave={()=>{if(menu===a.key)setOutside(true);}}> - {a.name} + {a.name} {!editing&&} {menu===a.key&&
=(columns-2)*4?'align-right':''} ${cells[index].row===2?'above':''}`} onPointerEnter={()=>setOutside(false)} onPointerLeave={()=>setOutside(true)}> - {selectedPlugin?<>{(selectedPlugin.manifest.ui.launcherActions??[]).map(action=>)}{!!selectedPlugin.manifest.ui.launcherActions?.length&&
}:<>{a.kind==='lepton'&&!a.key.startsWith('steam:')&&<>}} - {a.launchUnavailable&&{t(a.launchUnavailable)}}{busyKeys.has(a.key)&&{t('正在执行…')}}
}
)}
{panView&¤t+panView.direction>=0&¤t+panView.direction
}
+ {selectedPlugin?<>{(selectedPlugin.manifest.ui.launcherActions??[]).map(action=>)}{!!selectedPlugin.manifest.ui.launcherActions?.length&&
}:<>{a.kind==='lepton'&&!a.key.startsWith('steam:')&&<>}} + {a.launchUnavailable&&{t(a.launchUnavailable)}}{busyKeys.has(a.key)&&{t('正在执行…')}}})}{panView&¤t+panView.direction>=0&¤t+panView.direction