feat(launcher): offer local and Remote Play launch choices

This commit is contained in:
toorux committed 2026-10-07 11:46:11 +08:00
1 parent 6a50b4a74b
commit fbd3f9cfa3
9 files changed
+318 -83

No files matched your search

+4
View File
@@ -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.
+4
View File
@@ -53,3 +53,7 @@ Steam 启动成功表示请求已交给 Steam,并不代表游戏加载完成
明显调整头显佩戴方式或窗口距离后,请重新校准。
按住 200 毫秒后才显示进度反馈。Dock 入口使用蓝色边框进度,600 毫秒打开快捷面板。启动台图标使用半透明白色圆形边框进度:中点刻度对应 400 毫秒打开菜单,继续按至 800 毫秒进入图标编辑;松手或取消按压后移除进度。
### Steam 远程畅玩
Steam 分类也显示已连接远程畅玩电脑上安装的应用,图标右下角标有远程图标。列表后台刷新,断连的电脑不再展示;相同 App ID 只显示一个图标;本机和远程都可用时,点击弹窗选择本机启动或远程畅玩,收藏和手动排序保持同一份。点击远程图标会先重新检查可用状态,再让 Steam 从对应电脑串流。图片使用本机 Steam 缓存,缺图显示占位图。此功能读取正在运行的 Steam 客户端界面;该接口不可用时,本机游戏仍可使用。
+14 -2
View File
@@ -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}),
+156 -68
View File
@@ -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<String>,
pub id: u32,
pub name: String,
pub kind: &'static str,
@@ -202,77 +206,13 @@ pub fn discover(home: &Path) -> Vec<App> {
{
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<App> {
}
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<App> {
merge_remote(home, discover(home), remote::cached())
}
fn merge_remote(home: &Path, local: Vec<App>, remote: Vec<remote::RemoteApp>) -> Vec<App> {
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<PathBuf> {
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();
+22
View File
@@ -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;
}
+77
View File
@@ -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<serde_json::Value> {
crate::apk::steam_ui::evaluate(&format!("({})({request})", include_str!("remote.js")))
}
fn inventory(value: serde_json::Value) -> Result<Vec<RemoteApp>> {
let rows: Vec<RemoteApp> = 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<Mutex<Option<(Instant, Vec<RemoteApp>)>>> = OnceLock::new();
static REFRESHING: AtomicBool = AtomicBool::new(false);
pub(super) fn cached() -> Vec<RemoteApp> {
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());
}
}
+19
View File
@@ -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');
+5
View File
@@ -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}
+17 -13
View File
@@ -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 <><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={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<T=any>(method:string,params:unknown={}):Promise<T>{
@@ -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<LaunchItem|null>(null),choiceRef=useRef<LaunchItem|null>(null);choiceRef.current=launchChoice;
const[steam,setSteam]=useState<SteamApp[]>([]),[local,setLocal]=useState<LocalApp[]>([]),[loading,setLoading]=useState(true),[error,setError]=useState(''),[filter,setFilter]=useState('all'),[page,setPage]=useState(0),[menu,setMenu]=useState<string|null>(null),[busyKeys,setBusyKeys]=useState<Set<string>>(new Set()),[launchingKeys,setLaunchingKeys]=useState<Set<string>>(new Set()),[uninstall,setUninstall]=useState<(Plugin&{dependents:string[];fingerprint:string})|null>(null),[frontends,setFrontends]=useState<Record<string,{plugin:string;action:string;context:any;ticket:number}>>({}),[outside,setOutside]=useState(false),[editing,setEditing]=useState(false),[manual,setManual]=useState(order),[dragging,setDragging]=useState<string|null>(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<ReturnType<typeof setTimeout>|null>(null),skipPageAnimation=useRef(false),grid=useRef<HTMLDivElement>(null),pageContent=useRef<HTMLDivElement>(null),swipe=useRef<{x:number;y:number;active?:boolean;dx?:number}|null>(null),shell=useRef<HTMLDivElement>(null),hold=useRef<ReturnType<typeof setTimeout>|null>(null),editHold=useRef<ReturnType<typeof setTimeout>|null>(null),edgeTimer=useRef<ReturnType<typeof setTimeout>|null>(null),long=useRef(false),point=useRef({x:0,y:0}),holdTarget=useRef<string|null>(null),holdVisual=useRef<SVGSVGElement|null>(null),mounted=useRef(true),operations=useRef(new Map<string,{started:number;expire:()=>void}>()),actionSequence=useRef(0),menuRef=useRef<HTMLDivElement>(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<SteamApp[]>('steam.list'),launcherRequest<LocalApp[]>('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<SteamApp[]>('steam.list'),launcherRequest<LocalApp[]>('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<unknown[]>('steam.list',{remoteOnly:true}).then(async rows=>{const next=JSON.stringify(rows);if(!enabled||epoch!==generation||next===signature)return;const apps=await launcherRequest<SteamApp[]>('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<s.pages,advance=valid&&Math.abs(dx)>=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<number>).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<number>).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<Record<string,SearchIndex>>('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&&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 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<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}>());
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 <div ref={shell} onPointerDown={e=>{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);}}>
<div className="launcher-search"><IconSearch size={22} stroke={1.65}/><input type="search" aria-label={t('搜索应用')} placeholder={t('搜索应用')} value={query} disabled={editing} onChange={e=>{cancelPan();cancelHold();setMenu(null);setQuery(e.target.value);setPage(0);}}/>{query&&<button aria-label={t('清除搜索')} onClick={()=>{setQuery('');setPage(0);}}><IconX size={20}/></button>}</div>
<nav className="launcher-tools launcher-filters" aria-label={t('启动台')}><span className="launcher-category-active" aria-hidden="true" style={{transform:`translateY(${Math.max(0,categories.findIndex(c=>c.key===filter))*61}px)`}}/>{categories.map(({key,label,icon:CategoryIcon})=><button key={key} aria-label={t(label)} title={t(label)} aria-pressed={filter===key} disabled={!!dragging||!!panView} className={filter===key?'active':''} onClick={()=>{if(editing){close();return;}selectCategory(key);setPage(0);}}><CategoryIcon size={26} stroke={1.65}/><span className="launcher-category-label">{t(label)}</span></button>)}</nav>
<nav className="launcher-tools launcher-shortcuts" aria-label={t('快捷操作')}>{[{page:'apk',label:'安装APK',icon:IconPackageImport},{page:'catalog',label:'插件库',icon:IconApps},{page:'calibration',label:'眼追校准',icon:IconFocus2,IconBan},{page:'settings',label:'设置',icon:IconSettings}].map(({page,label,icon:ActionIcon})=><button key={page} aria-label={t(label)} title={t(label)} disabled={busyKeys.has('framely')||editing||!!dragging||!!panView} onClick={()=>{cancelHold();setMenu(null);void run('framely',()=>page==='calibration'?launcherRequest('host.launcher.calibrate',{active:true}):launcherRequest('host.manager.open',{page}));}}><ActionIcon size={26} stroke={1.65}/><span className="launcher-category-label">{t(label)}</span></button>)}</nav>
<nav className="launcher-tools launcher-shortcuts" aria-label={t('快捷操作')}>{[{page:'apk',label:'安装APK',icon:IconPackageImport},{page:'catalog',label:'插件库',icon:IconApps},{page:'calibration',label:'眼追校准',icon:IconFocus2,IconBan,IconDeviceDesktopShare},{page:'settings',label:'设置',icon:IconSettings}].map(({page,label,icon:ActionIcon})=><button key={page} aria-label={t(label)} title={t(label)} disabled={busyKeys.has('framely')||editing||!!dragging||!!panView} onClick={()=>{cancelHold();setMenu(null);void run('framely',()=>page==='calibration'?launcherRequest('host.launcher.calibrate',{active:true}):launcherRequest('host.manager.open',{page}));}}><ActionIcon size={26} stroke={1.65}/><span className="launcher-category-label">{t(label)}</span></button>)}</nav>
{editing&&<div className="launcher-edit-toolbar"><span><IconGridDots size={22}/>{t('拖动图标排列,移到两侧翻页')}</span><button disabled={busyKeys.has('order')||!!dragging} onClick={()=>setEditing(false)}><IconCheck size={22}/>{t('完成')}</button></div>}
{safeMode&&<p className="banner">{t('安全模式:第三方插件暂停运行。')}</p>}{error&&<div className="launcher-error" role="alert">{error}<button onClick={()=>setError('')}>{t('关闭')}</button></div>}
<div ref={grid} className={`launcher-grid ${panView?'launcher-panning':''}`}><div ref={pageContent} className="launcher-page" style={panView?{translate:`${panView.offset}px 0`,transition:panView.settling?'translate 240ms cubic-bezier(.2,.75,.2,1)':'none'}:undefined}>{visible.map((a,index)=><div key={a.key} data-launch-key={a.key} style={{gridColumn:`${cells[index].column} / span 4`,gridRow:`${1+cells[index].row*2} / span 2`}} className={`launch-item ${menu===a.key?'launch-item-expanded':''} ${dragging===a.key?'launch-item-dragged':''}`} onPointerEnter={()=>{if(menu===a.key)setOutside(false);}} onPointerLeave={()=>{if(menu===a.key)setOutside(true);}}>
<button className={`launch-icon ${a.launchUnavailable?'launch-unavailable':''}`} title={a.launchUnavailable?t(a.launchUnavailable):undefined} aria-disabled={!!a.launchUnavailable&&!editing} aria-busy={busyKeys.has(a.key)} disabled={busyKeys.has(a.key)||(editing&&busyKeys.has('order'))||(a.kind==='plugin'&&safeMode&&!editing)} aria-label={a.name} aria-haspopup="menu" aria-expanded={menu===a.key} onPointerDown={e=>down(e,a)} onClick={()=>{if(long.current){long.current=false;return;}if(editing)return;if(a.launchUnavailable){setError(t(a.launchUnavailable));return;}setMenu(null);void run(a.key,()=>launch(a),true);}} onContextMenu={e=>{e.preventDefault();setMenu(a.key);setOutside(false);}}><LauncherArtwork src={a.icon} name={a.name} kind={a.kind} fit={a.fit}/>{a.launchUnavailable&&<span className="launch-unavailable-mark" aria-hidden="true"><IconBan size={23}/></span>}{launchingKeys.has(a.key)&&<span className="launch-starting" role="status" aria-label={t("正在启动")}><span className="launch-spinner" aria-hidden="true"/></span>}</button><span className="launch-name" title={a.name}>{a.name}</span>
<button className={`launch-icon ${a.launchUnavailable?'launch-unavailable':''}`} title={a.launchUnavailable?t(a.launchUnavailable):undefined} aria-disabled={!!a.launchUnavailable&&!editing} aria-busy={busyKeys.has(a.key)} disabled={busyKeys.has(a.key)||(editing&&busyKeys.has('order'))||(a.kind==='plugin'&&safeMode&&!editing)} aria-label={a.name} aria-haspopup="menu" aria-expanded={menu===a.key} onPointerDown={e=>down(e,a)} onClick={()=>{if(long.current){long.current=false;return;}if(editing)return;if(a.launchUnavailable){setError(t(a.launchUnavailable));return;}openItem(a);}} onContextMenu={e=>{e.preventDefault();setMenu(a.key);setOutside(false);}}><LauncherArtwork src={a.icon} name={a.name} kind={a.kind} fit={a.fit}/>{a.remoteClient&&<span className="launch-remote-mark" title={t("远程畅玩")} aria-label={t("远程畅玩")}><IconDeviceDesktopShare size={19} stroke={1.7}/></span>}{a.launchUnavailable&&<span className="launch-unavailable-mark" aria-hidden="true"><IconBan size={23}/></span>}{launchingKeys.has(a.key)&&<span className="launch-starting" role="status" aria-label={t("正在启动")}><span className="launch-spinner" aria-hidden="true"/></span>}</button><span className="launch-name" title={a.name}>{a.name}</span>
{!editing&&<button className={`launch-favorite ${a.favorite?'selected':''}`} aria-label={t(a.favorite?'取消收藏':'收藏')} aria-pressed={a.favorite} title={t(a.favorite?'取消收藏':'收藏')} disabled={busyKeys.has(a.key)} onClick={()=>void run(a.key,()=>launcherRequest('launcher.favorite',{key:a.key,favorite:!a.favorite}))}><IconStar size={16} stroke={1.7} fill={a.favorite?'currentColor':'none'}/></button>}
{menu===a.key&&<div ref={menuRef} role="menu" className={`launch-menu ${cells[index].column>=(columns-2)*4?'align-right':''} ${cells[index].row===2?'above':''}`} onPointerEnter={()=>setOutside(false)} onPointerLeave={()=>setOutside(true)}>
{selectedPlugin?<>{(selectedPlugin.manifest.ui.launcherActions??[]).map(action=><button role="menuitem" key={action.id} disabled={busyKeys.has(a.key)||safeMode} onClick={()=>void custom(a.id,action.id)}>{action.label}</button>)}{!!selectedPlugin.manifest.ui.launcherActions?.length&&<hr/>}<button role="menuitem" disabled={busyKeys.has(a.key)||safeMode||!selectedPlugin.manifest.ui.quickPage} title={!selectedPlugin.manifest.ui.quickPage?t('插件没有快捷页面'):undefined} onClick={()=>void run(a.key,()=>launcherRequest('plugin.launch',{plugin:a.id,source:'launcher',quickPage:true}))}><IconLayoutGrid size={22} stroke={1.7}/>{t('快捷面板')}</button><button role="menuitem" disabled={busyKeys.has(a.key)} onClick={()=>void run(a.key,()=>launcherRequest('host.plugin.manage',{plugin:a.id}))}><IconAdjustments size={22} stroke={1.7}/>{t('管理插件')}</button><button role="menuitem" disabled={busyKeys.has(a.key)} className="danger" onClick={()=>void run(a.key,async()=>{const plan=await launcherRequest('plugin.disable.preview',{plugin:a.id});setUninstall({...selectedPlugin,dependents:plan.dependents,fingerprint:plan.fingerprint});})}><IconTrash size={22} stroke={1.7}/>{t('卸载插件')}</button></>:<><button role="menuitem" title={a.launchUnavailable?t(a.launchUnavailable):undefined} disabled={busyKeys.has(a.key)||!!a.launchUnavailable} onClick={()=>void run(a.key,()=>launch(a),true)}><IconLayoutGrid size={22}/>{t('打开')}</button>{a.kind==='lepton'&&!a.key.startsWith('steam:')&&<><button role="menuitem" disabled={busyKeys.has(a.key)} onClick={()=>void run(a.key,()=>launcherRequest('host.manager.open',{page:'apk'}).then(()=>launcherRequest('apk.selection',{app:a.id})))}><IconAdjustments size={22}/>{t('管理应用')}</button><button role="menuitem" className="danger" disabled={busyKeys.has(a.key)} onClick={()=>void run(a.key,()=>launcherRequest('host.manager.open',{page:'apk'}).then(()=>launcherRequest('apk.selection',{app:a.id,uninstall:true})))}><IconTrash size={22}/>{t('卸载应用')}</button></>}<button role="menuitem" disabled={busyKeys.has(a.key)} onClick={()=>{setMenu(null);setEditing(true);}}><IconGridDots size={22}/>{t('整理图标')}</button></>}
{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}/></span><span className="launch-name">{a.name}</span></div>;})}</div>}</div>
{selectedPlugin?<>{(selectedPlugin.manifest.ui.launcherActions??[]).map(action=><button role="menuitem" key={action.id} disabled={busyKeys.has(a.key)||safeMode} onClick={()=>void custom(a.id,action.id)}>{action.label}</button>)}{!!selectedPlugin.manifest.ui.launcherActions?.length&&<hr/>}<button role="menuitem" disabled={busyKeys.has(a.key)||safeMode||!selectedPlugin.manifest.ui.quickPage} title={!selectedPlugin.manifest.ui.quickPage?t('插件没有快捷页面'):undefined} onClick={()=>void run(a.key,()=>launcherRequest('plugin.launch',{plugin:a.id,source:'launcher',quickPage:true}))}><IconLayoutGrid size={22} stroke={1.7}/>{t('快捷面板')}</button><button role="menuitem" disabled={busyKeys.has(a.key)} onClick={()=>void run(a.key,()=>launcherRequest('host.plugin.manage',{plugin:a.id}))}><IconAdjustments size={22} stroke={1.7}/>{t('管理插件')}</button><button role="menuitem" disabled={busyKeys.has(a.key)} className="danger" onClick={()=>void run(a.key,async()=>{const plan=await launcherRequest('plugin.disable.preview',{plugin:a.id});setUninstall({...selectedPlugin,dependents:plan.dependents,fingerprint:plan.fingerprint});})}><IconTrash size={22} stroke={1.7}/>{t('卸载插件')}</button></>:<><button role="menuitem" title={a.launchUnavailable?t(a.launchUnavailable):undefined} disabled={busyKeys.has(a.key)||!!a.launchUnavailable} onClick={()=>openItem(a)}><IconLayoutGrid size={22}/>{t('打开')}</button>{a.kind==='lepton'&&!a.key.startsWith('steam:')&&<><button role="menuitem" disabled={busyKeys.has(a.key)} onClick={()=>void run(a.key,()=>launcherRequest('host.manager.open',{page:'apk'}).then(()=>launcherRequest('apk.selection',{app:a.id})))}><IconAdjustments size={22}/>{t('管理应用')}</button><button role="menuitem" className="danger" disabled={busyKeys.has(a.key)} onClick={()=>void run(a.key,()=>launcherRequest('host.manager.open',{page:'apk'}).then(()=>launcherRequest('apk.selection',{app:a.id,uninstall:true})))}><IconTrash size={22}/>{t('卸载应用')}</button></>}<button role="menuitem" disabled={busyKeys.has(a.key)} onClick={()=>{setMenu(null);setEditing(true);}}><IconGridDots size={22}/>{t('整理图标')}</button></>}
{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>}
{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&&<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>;
}