diff --git a/Cargo.lock b/Cargo.lock index 54cbd11..569bc54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -347,7 +347,7 @@ dependencies = [ [[package]] name = "framely" -version = "0.4.3-preview.5" +version = "0.4.3-preview.6" dependencies = [ "anyhow", "base64", diff --git a/Cargo.toml b/Cargo.toml index 85b4efe..adbfbe5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "framely" -version = "0.4.3-preview.5" +version = "0.4.3-preview.6" edition = "2021" license = "AGPL-3.0-only" diff --git a/README.md b/README.md index 65c74ee..4c0c915 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/i For a Preview release, specify an actual published tag: ```bash -curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- install --version v0.4.3-preview.5 +curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- install --version v0.4.3-preview.6 ``` Replace the example tag with the published version you want to install. Enter the sudo password when prompted. See the [installation guide](docs/user-guide/installation.md) for updates, repair, and uninstallation. diff --git a/README.zh-CN.md b/README.zh-CN.md index 3265c74..54921a3 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -34,7 +34,7 @@ curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/i 安装 Preview 时指定实际已发布的标签: ```bash -curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- install --version v0.4.3-preview.5 +curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- install --version v0.4.3-preview.6 ``` 将示例标签替换为需要安装的已发布版本,并在提示时输入 sudo 密码。更新、修复和卸载步骤见[安装指南](docs/zh-CN/user-guide/installation.md)。 diff --git a/src/desktop.rs b/src/desktop.rs index 82c4808..7cef100 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -1,11 +1,10 @@ //! User-session applications: XDG desktop entries and installed Lepton packages. -use anyhow::{ensure, Context, Result}; +use anyhow::{Context, Result}; use serde::Serialize; use std::{ collections::{BTreeMap, BTreeSet}, fs, path::{Path, PathBuf}, - process::Stdio, }; #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] @@ -256,17 +255,11 @@ fn launch_target(target: Target) -> Result<()> { match target { Target::ManagedLepton(id) => crate::apk::launch_app(&crate::steam::home()?, &id)?, Target::Desktop(path) => { - let output = crate::process::tool("gio") - .arg("launch") - .arg(path) - .stdin(Stdio::null()) - .output() - .context("Cannot launch desktop application")?; - ensure!( - output.status.success(), - "Desktop launch failed: {}", - String::from_utf8_lossy(&output.stderr) - ); + crate::process::launch_handoff( + crate::process::tool("gio").arg("launch").arg(path), + std::time::Duration::from_secs(10), + ) + .map_err(|error| anyhow::anyhow!("Desktop launch failed: {error:#}"))?; } } Ok(()) @@ -323,7 +316,7 @@ mod tests { assert_eq!(apps[0].id, "test/com.example.app"); } #[test] - fn launch_handoffs_use_literal_arguments_and_reuse_live_lepton_context() { + fn desktop_launch_handoff_uses_literal_arguments() { use std::os::unix::fs::PermissionsExt; let dir = tempfile::tempdir().unwrap(); let tools = dir.path().join("tools"); diff --git a/src/process.rs b/src/process.rs index beed925..00296e1 100644 --- a/src/process.rs +++ b/src/process.rs @@ -36,18 +36,100 @@ pub(crate) fn tool(name: &str) -> Command { } Command::new(name) } +/// Wait only for the launcher helper. Applications may inherit its standard +/// streams and keep them open for their entire lifetime, so never use output(). +pub(crate) fn launch_handoff(command: &mut Command, timeout: Duration) -> Result<()> { + let output = command_output_timeout(command, timeout, false)?; + ensure!( + output.status.success(), + "Launch failed ({}): {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ); + Ok(()) +} + +pub(crate) fn command_output_timeout( + command: &mut Command, + timeout: Duration, + capture_stdout: bool, +) -> Result { + use std::io::{Seek, SeekFrom}; + use std::os::unix::fs::OpenOptionsExt; + let path = std::env::temp_dir().join(format!( + "framely-launch-{}", + hex::encode(rand::random::<[u8; 16]>()) + )); + let mut errors = fs::OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .mode(0o600) + .open(&path)?; + // Keep only the open file descriptors; inherited stderr must not become a + // pipe whose EOF depends on a browser or another long-lived application. + fs::remove_file(&path)?; + let mut stdout = tempfile_output()?; + let mut child = command + .stdin(Stdio::null()) + .stdout(if capture_stdout { + Stdio::from(stdout.try_clone()?) + } else { + Stdio::null() + }) + .stderr(errors.try_clone()?) + .spawn()?; + let started = Instant::now(); + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if started.elapsed() < timeout => { + std::thread::sleep(Duration::from_millis(20)) + } + result => { + let _ = child.kill(); + let _ = child.wait(); + if let Err(error) = result { + return Err(error.into()); + } + anyhow::bail!("Launch handoff timed out; check whether the application opened before retrying"); + } + } + }; + errors.seek(SeekFrom::Start(0))?; + let mut stderr = Vec::new(); + errors.take(64 * 1024).read_to_end(&mut stderr)?; + stdout.seek(SeekFrom::Start(0))?; + let mut output = Vec::new(); + if capture_stdout { + stdout.take(64 * 1024).read_to_end(&mut output)?; + } + Ok(std::process::Output { + status, + stdout: output, + stderr, + }) +} +fn tempfile_output() -> Result { + use std::os::unix::fs::OpenOptionsExt; + let path = std::env::temp_dir().join(format!( + "framely-output-{}", + hex::encode(rand::random::<[u8; 16]>()) + )); + let file = fs::OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .mode(0o600) + .open(&path)?; + fs::remove_file(path)?; + Ok(file) +} + pub fn open_web_link(url: &str) -> Result<()> { validate_web_link(url)?; - let result = tool("xdg-open") - .arg(url) - .stdin(Stdio::null()) - .output() - .context("无法启动系统浏览器")?; - ensure!( - result.status.success(), - "无法打开链接:{}", - String::from_utf8_lossy(&result.stderr) - ); + launch_handoff(tool("xdg-open").arg(url), Duration::from_secs(10)) + .map_err(|error| anyhow::anyhow!("无法打开链接:{error:#}"))?; Ok(()) } @@ -463,3 +545,42 @@ pub fn user_home(uid: u32) -> Result { } anyhow::bail!("Steam user not found") } + +#[cfg(test)] +mod launch_tests { + use super::*; + #[test] + fn captured_status_does_not_wait_for_inherited_stdout() { + let output = command_output_timeout( + Command::new("/bin/sh").args(["-c", "printf true; sleep 2 &"]), + Duration::from_secs(1), + true, + ) + .unwrap(); + assert!(output.status.success()); + assert_eq!(output.stdout, b"true"); + } + #[test] + fn helper_exit_does_not_wait_for_application_streams() { + let mut command = Command::new("/bin/sh"); + command.args(["-c", "sleep 2 &"]); + let began = Instant::now(); + launch_handoff(&mut command, Duration::from_secs(1)).unwrap(); + assert!(began.elapsed() < Duration::from_secs(1)); + } + #[test] + fn handoff_failure_keeps_diagnostics_and_stalled_helper_is_bounded() { + let mut failed = Command::new("/bin/sh"); + failed.args(["-c", "printf 'cannot open application' >&2; exit 4"]); + assert!(launch_handoff(&mut failed, Duration::from_secs(1)) + .unwrap_err() + .to_string() + .contains("cannot open application")); + let mut stalled = Command::new("/bin/sleep"); + stalled.arg("3"); + assert!(launch_handoff(&mut stalled, Duration::from_millis(40)) + .unwrap_err() + .to_string() + .contains("timed out")); + } +} diff --git a/src/steam.rs b/src/steam.rs index b383bbe..841466d 100644 --- a/src/steam.rs +++ b/src/steam.rs @@ -5,7 +5,6 @@ use std::{ collections::{BTreeMap, BTreeSet}, fs, path::{Path, PathBuf}, - process::Stdio, }; #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] @@ -312,16 +311,11 @@ fn launch_installed(home: &Path, id: u32) -> Result<()> { apps.iter().any(|a| a.id == id), "Steam app is no longer installed" ); - let output = crate::process::tool("steam") - .arg(format!("steam://rungameid/{id}")) - .stdin(Stdio::null()) - .output() - .context("Cannot contact Steam")?; - ensure!( - output.status.success(), - "Steam launch failed: {}", - String::from_utf8_lossy(&output.stderr) - ); + crate::process::launch_handoff( + crate::process::tool("steam").arg(format!("steam://rungameid/{id}")), + std::time::Duration::from_secs(10), + ) + .map_err(|error| anyhow::anyhow!("Steam launch failed: {error:#}"))?; Ok(()) } #[cfg(test)] diff --git a/tests/launcher_fixture.tsx b/tests/launcher_fixture.tsx index f5d25e7..a3dc6a1 100644 --- a/tests/launcher_fixture.tsx +++ b/tests/launcher_fixture.tsx @@ -1,13 +1,14 @@ import React,{useState} from 'react';import{createRoot}from'react-dom/client'; import{Launcher,LauncherSettings,type LauncherConfig}from'../ui/src/launcher';import{configureLanguage}from'../ui/src/i18n';import type{Plugin}from'../ui/src/main';import'../ui/src/style.css'; const wait=(ms=80)=>new Promise(r=>setTimeout(r,ms));const until=async(f:()=>any)=>{for(let i=0;i<120;i++){if(f())return;await wait(30);}throw Error('Timed out '+document.body.innerText.slice(0,240)+' '+JSON.stringify(calls.slice(-4))+' edit='+!!document.querySelector('.launcher-editing'));}; +let stalledMethod='',releaseStalled:((r:Response)=>void)|undefined; let config:LauncherConfig={primaryTrigger:false,menuAutoClose:true,menuTimeoutSeconds:1};let order:string[]=[];let localApps:any[]=[];let redraw:()=>void;let settings=false;const calls:{method:string;params:any}[]=[]; const plugins:Record={ 'demo.alpha':{manifest:{id:'demo.alpha',icon:'icon.png',name:'Termix',description:'Terminal',author:'Framely',version:'1.0.0',ui:{quickPage:'page.js',windows:{main:{}},launcherActions:[{id:'connect',label:'Connect terminal',target:{type:'frontend',entry:'actions.js'}}]}},enabled:true,favorite:true,order:0}, 'demo.beta':{manifest:{id:'demo.beta',icon:'icon.png',name:'Passthrough color',description:'Color',author:'Framely',version:'1.0.0',ui:{quickPage:'page.js',windows:{}}},enabled:true,favorite:false,order:1}, 'demo.off':{manifest:{id:'demo.off',name:'Disabled app',description:'',author:'Framely',version:'1.0.0',ui:{windows:{}}},enabled:false,favorite:false,order:2}, }; -window.fetch=async(url,init)=>{if(String(url)!=='/api')throw Error('Unexpected fetch');const{method,params}=JSON.parse(String(init?.body));calls.push({method,params});let result:any=true; +window.fetch=async(url,init)=>{if(String(url)!=='/api')throw Error('Unexpected fetch');const{method,params}=JSON.parse(String(init?.body));calls.push({method,params});if(method===stalledMethod)return new Promise(resolve=>{releaseStalled=resolve;});let result:any=true; if(method==='steam.list')result=[{id:620980,name:'Beat Saber'},{id:546560,name:'Half-Life: Alyx'},{id:413150,name:'Stardew Valley'},{id:1245620,name:'Elden Ring'},{id:367520,name:'Hollow Knight'},{id:570,name:'Dota 2'},{id:440,name:'Team Fortress 2'}].map(a=>({...a,icon:`/preview-icons/${a.id}.jpg`,iconFit:'cover'})); else if(method==='desktop.list')result=localApps; else if(method==='status')result={database:{plugins,safeMode:false}}; @@ -85,6 +86,21 @@ function openMenu(){const b=document.querySelector('.launch-i console.log('FRAMELY_SELF_LAUNCH_OK');localApps=[{id:'terminal.desktop',name:'Desktop terminal',kind:'desktop'},{id:'test/com.example.browser',name:'Android browser',kind:'lepton'}];await until(()=>!document.querySelector('.launch-icon:disabled'));window.dispatchEvent(new Event('framely.launcher.open'));await wait(120);await until(()=>!document.querySelector('.launcher-initializing'));document.querySelector('.launcher-filters button[aria-label=All]')!.click();await until(()=>document.querySelector('.launch-icon[aria-label="Desktop terminal"]')); document.querySelector('.launcher-filters button[aria-label="Desktop apps"]')!.click();await until(()=>document.querySelectorAll('.launch-icon').length===1);document.querySelector('.launch-icon')!.click();await until(()=>calls.some(c=>c.method==='desktop.launch'&&c.params.app==='desktop:terminal.desktop')); await until(()=>!document.querySelector('.launch-icon')!.disabled);window.dispatchEvent(new CustomEvent('framely.launcher.navigate',{detail:2}));await until(()=>document.querySelector('.launcher-filters button[aria-label=Lepton]')!.getAttribute('aria-pressed')==='true');document.querySelector('.launch-icon')!.click();await until(()=>calls.some(c=>c.method==='desktop.launch'&&c.params.app==='lepton:test/com.example.browser')); + // A pending response must not lock a reopened launcher, nor mount a stale frontend action after timeout. + await until(()=>!document.querySelector('.launch-icon')!.disabled); + document.querySelector('.launcher-filters button[aria-label=Plugin]')!.click();await until(()=>!!document.querySelector('.launch-icon[aria-label=Termix]')); + const realTimeout=window.setTimeout;window.setTimeout=((f:any,delay?:number,...args:any[])=>realTimeout(f,delay===120000?700:delay,...args)) as typeof window.setTimeout; + stalledMethod='plugin.launcher.action';openMenu();await until(()=>!!button('Connect terminal'));button('Connect terminal').click();await until(()=>!!releaseStalled&&document.querySelector('.launch-icon[aria-label=Termix]')!.disabled); + const actionCount=calls.filter(c=>c.method==='plugin.launcher.action').length;button('Connect terminal').click();if(calls.filter(c=>c.method==='plugin.launcher.action').length!==actionCount)throw Error('Pending app accepted a duplicate action'); + if(document.querySelector('.launcher-search input')!.disabled||[...document.querySelectorAll('.launcher-filters button,.launcher-pages button,.launcher-shortcuts button')].some(b=>b.disabled))throw Error('Pending app locked launcher navigation'); + const other=document.querySelector('.launch-icon[aria-label="Passthrough color"]')!;if(other.disabled)throw Error('Pending app locked another icon');const otherLaunches=calls.filter(c=>c.method==='plugin.launch'&&c.params.plugin==='demo.beta').length;other.click();await until(()=>calls.filter(c=>c.method==='plugin.launch'&&c.params.plugin==='demo.beta').length===otherLaunches+1);await until(()=>!other.disabled);if(!document.querySelector('.launch-icon[aria-label=Termix]')!.disabled)throw Error('Other completion cleared pending app lock'); + window.dispatchEvent(new CustomEvent('framely.launcher.navigate',{detail:-2}));await until(()=>document.querySelector('.launcher-filters button[aria-label=Steam]')!.getAttribute('aria-pressed')==='true');document.querySelector('.launcher-filters button[aria-label=Plugin]')!.click();await until(()=>!!document.querySelector('.launch-icon[aria-label=Termix]'));await until(()=>!!document.querySelector('.launcher-error')&&!document.querySelector('.launch-icon[aria-label=Termix]')!.disabled); + window.dispatchEvent(new Event('framely.launcher.open'));await wait(100); + releaseStalled!(new Response(JSON.stringify({result:{frontend:'actions.js',context:{source:'launcher'}}})));await wait(100); + if(document.querySelector('.launcher-action-frame')||document.querySelector('.launch-icon')!.disabled)throw Error('Late action response locked or mounted in reopened launcher'); + releaseStalled=undefined;stalledMethod='desktop.launch';document.querySelector('.launcher-filters button[aria-label="Desktop apps"]')!.click();await until(()=>document.querySelectorAll('.launch-icon').length===1);document.querySelector('.launch-icon')!.click();await until(()=>!!releaseStalled);await until(()=>!document.querySelector('.launch-icon')!.disabled); + stalledMethod='';releaseStalled!(new Response(JSON.stringify({result:true})));window.setTimeout=realTimeout;await wait(100); + console.log('FRAMELY_LAUNCHER_TIMEOUT_RECOVERY_OK'); const closes=calls.filter(c=>c.method==='host.launcher.close').length;document.querySelector('.launcher-shell')!.dispatchEvent(new MouseEvent('click',{bubbles:true}));window.dispatchEvent(new Event('framely.back'));await until(()=>calls.filter(c=>c.method==='host.launcher.close').length===closes+2); settings=true;redraw();await wait();const switches=document.querySelectorAll('[role=switch]');switches[0].click();await wait();if(!config.primaryTrigger)throw Error('Trigger setting not saved');await wait(180);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_SETTINGS_ZH');configureLanguage('en-US',[]);redraw();await wait(180);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_SETTINGS_EN');console.log('FRAMELY_BRIDGE_PASS'); }catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e);}}; diff --git a/ui/locales/en-US.json b/ui/locales/en-US.json index 5e54936..455b381 100644 --- a/ui/locales/en-US.json +++ b/ui/locales/en-US.json @@ -577,6 +577,7 @@ "日志": "Logs", "切换管理面板": "Switch manager", "插件管理": "Plugin Manager", - "Lepton 侧载管理": "Lepton sideload management" + "Lepton 侧载管理": "Lepton sideload management", + "操作等待超时;请确认应用是否已打开后再重试。": "The operation timed out. Check whether the application opened before retrying." } } diff --git a/ui/locales/zh-CN.json b/ui/locales/zh-CN.json index 16516aa..97d4c91 100644 --- a/ui/locales/zh-CN.json +++ b/ui/locales/zh-CN.json @@ -577,6 +577,7 @@ "日志": "日志", "切换管理面板": "切换管理面板", "插件管理": "插件管理", - "Lepton 侧载管理": "Lepton 侧载管理" + "Lepton 侧载管理": "Lepton 侧载管理", + "操作等待超时;请确认应用是否已打开后再重试。": "操作等待超时;请确认应用是否已打开后再重试。" } } diff --git a/ui/src/api.ts b/ui/src/api.ts index 28526e3..4fc81b7 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -1,7 +1,7 @@ import {t} from './i18n'; import {installKeyboard} from '../../sdk/src/keyboard'; import {installHoverFeedback} from '../../sdk/src/hover'; -export async function api(method:string,params:unknown={}):Promise{const r=await fetch('/api',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({method,params})});if(r.status===401&&r.headers.get('X-Framely-Reauthenticate')==='1')location.replace('/manager');if(!r.ok)throw new Error(t('服务请求失败:{0}',{0:r.status}));const v=await r.json();if(v.error)throw new Error(t(v.error));return v.result;} +export async function api(method:string,params:unknown={},signal?:AbortSignal):Promise{const r=await fetch('/api',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({method,params}),signal});if(r.status===401&&r.headers.get('X-Framely-Reauthenticate')==='1')location.replace('/manager');if(!r.ok)throw new Error(t('服务请求失败:{0}',{0:r.status}));const v=await r.json();if(v.error)throw new Error(t(v.error));return v.result;} export function viewKey(){const p=location.pathname.split('/');return p[1]==='manager'?'framely.manager':p[1]==='window'?`framely.window.${p[2]}.${p[3]}`:p[1]==='notifications'?'notifications':p[1]==='launcher'?'launcher':'menu';} installKeyboard(params=>api('host.keyboard',{view:viewKey(),...params})); diff --git a/ui/src/launcher.tsx b/ui/src/launcher.tsx index 3b033d4..b6513c0 100644 --- a/ui/src/launcher.tsx +++ b/ui/src/launcher.tsx @@ -29,28 +29,35 @@ type LocalApp={id:string;name:string;kind:'desktop'|'lepton';icon?:string}; type LaunchItem={key:string;kind:string;id:string;name:string;icon?:string|null;fit?:string;favorite:boolean}; 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{ + const controller=new AbortController(); + const timer=setTimeout(()=>controller.abort(),60000); + try{return await api(method,params,controller.signal);} + catch(error){if(controller.signal.aborted)throw new Error(t('操作等待超时;请确认应用是否已打开后再重试。'));throw error;} + finally{clearTimeout(timer);} +} export function Launcher({plugins,safeMode,config=defaults,favorites,localFavorites=emptyOrder,order=emptyOrder,refresh}:{plugins:Record;safeMode:boolean;config?:LauncherConfig;favorites:number[];localFavorites?:string[];order?:string[];refresh:()=>Promise}){ const remembered=useRef(null); const[query,setQuery]=useState(''),[indexes,setIndexes]=useState>({}),keyboard=useRef(false),opening=useRef(true),props=useRef({plugins,favorites,localFavorites});props.current={plugins,favorites,localFavorites}; - 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),[busy,setBusy]=useState(false),[uninstall,setUninstall]=useState<(Plugin&{dependents:string[];fingerprint:string})|null>(null),[frontend,setFrontend]=useState<{plugin:string;action:string;context:any}|null>(null),[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}|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),mounted=useRef(true),busyRef=useRef(false),frontTimer=useRef|null>(null),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[]}); + 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()),[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}|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),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]); function selectCategory(key:string){remembered.current=key;opening.current=false;setFilter(key);} function cancelHold(){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=()=>{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([api('steam.list'),api('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;window.removeEventListener('framely.launcher.open',load);window.removeEventListener('framely.launcher.cancelInput',cancel);cancelHold();if(edgeTimer.current)clearTimeout(edgeTimer.current);if(frontTimer.current)clearTimeout(frontTimer.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);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(()=>{if(!drag.current)cancelHold();setMenu(null);setOutside(false);},[filter,page]); - useEffect(()=>{if(!menu||!outside||!config.menuAutoClose||busy)return;const timer=setTimeout(()=>setMenu(null),config.menuTimeoutSeconds*1000);return()=>clearTimeout(timer);},[menu,outside,busy,config.menuAutoClose,config.menuTimeoutSeconds]); + 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(keyboard.current)return;cancelHold();cancelDrag();cancelPan();setMenu(null);if(state.current.editing){setEditing(false);return;}void api('host.launcher.close');} + function close(){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(keyboard.current||drag.current||busyRef.current||state.current.panning)return;const direction=(e as CustomEvent).detail;const s=state.current;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(keyboard.current||drag.current||state.current.panning)return;const direction=(e as CustomEvent).detail;const s=state.current;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);};},[]); 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.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}`)}))]; 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 api>('launcher.search.index',{names}).then(v=>{if(live)setIndexes(v);}).catch(()=>{if(live)setError(t('读音搜索暂不可用,仍可使用原文搜索。'));});return()=>{live=false;};},[indexKey]); + 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]); useEffect(()=>{const changed=(e:Event)=>{keyboard.current=!!(e as CustomEvent).detail;if(keyboard.current){cancelPan();cancelHold();setMenu(null);}};window.addEventListener('framely.keyboard',changed);return()=>window.removeEventListener('framely.keyboard',changed);},[]); const items=useMemo(()=>all.filter(a=>(filter==='favorites'?a.favorite:filter==='all'?(config.allCategories??defaults.allCategories!).includes(a.kind==='framely'?'plugin':a.kind):a.kind===filter||(filter==='plugin'&&a.kind==='framely'))&&matchesSearch(a.name,query,indexes[a.name])),[all,filter,config.allCategories,query,indexes]); const pageSize=13,columns=6,pages=Math.max(1,Math.ceil(items.length/pageSize)),current=Math.min(page,pages-1),selected=menu?all.find(a=>a.key===menu):null,selectedPlugin=selected?.kind==='plugin'?plugins[selected.id]:null,visible=items.slice(current*pageSize,current*pageSize+pageSize); @@ -63,32 +70,41 @@ export function Launcher({plugins,safeMode,config=defaults,favorites,localFavori const positions=useRef(new Map()),layoutPage=useRef(''); useLayoutEffect(()=>{const next=new Map(),pageKey=`${filter}:${current}`;const reduced=window.matchMedia('(prefers-reduced-motion: reduce)').matches;for(const node of Array.from(shell.current?.querySelectorAll('[data-launch-key]')??[])){const key=node.dataset.launchKey!,x=node.offsetLeft,y=node.offsetTop;next.set(key,{x,y});const before=positions.current.get(key);if(editing&&!reduced&&pageKey===layoutPage.current&&before&&key!==dragging&&(before.x!==x||before.y!==y))node.animate([{transform:`translate(${before.x-x}px,${before.y-y}px)`},{transform:'translate(0,0)'}],{duration:180,easing:'cubic-bezier(.2,.75,.2,1)'});}positions.current=next;layoutPage.current=pageKey;},[manual,current,filter,editing,dragging,items.map(a=>a.key).join('|')]); - async function run(f:()=>Promise){if(busyRef.current)return;busyRef.current=true;setBusy(true);setError('');try{await f();await refresh();}catch(e){if(mounted.current)setError(String(e));}finally{busyRef.current=false;if(mounted.current)setBusy(false);}} + async function run(key:string,f:(active:()=>boolean)=>Promise){ + if(operations.current.has(key))return;setBusyKeys(keys=>new Set(keys).add(key));setError(''); + const entry={started:Date.now(),expire:()=>{}};operations.current.set(key,entry); + const active=()=>mounted.current&&operations.current.get(key)===entry; + let timer:ReturnType|undefined; + const deadline=new Promise((_,reject)=>{entry.expire=()=>reject(new Error(t('操作等待超时;请确认应用是否已打开后再重试。')));timer=setTimeout(entry.expire,120000);}); + try{await Promise.race([(async()=>{await f(active);if(active())await refresh();})(),deadline]);} + catch(e){if(active()){done(key,String(e));setError(String(e));}} + finally{if(timer)clearTimeout(timer);if(operations.current.get(key)===entry){operations.current.delete(key);if(mounted.current)setBusyKeys(keys=>{const next=new Set(keys);next.delete(key);return next;});}} + } function startDrag(item:LaunchItem,x:number,y:number){const before=state.current.all.map(a=>a.key);drag.current={key:item.key,before,order:before,moved:false,over:null,edge:0,lastX:x,lastY:y,startedInEdit:state.current.editing};setDragging(item.key);setGhost({item,x,y});setMenu(null);} - function down(e:React.PointerEvent,item:LaunchItem){if(e.button!==0||busy||state.current.panning)return;cancelHold();long.current=false;point.current={x:e.clientX,y:e.clientY};holdTarget.current=item.key;if(!editing)swipe.current={x:e.clientX,y:e.clientY};try{e.currentTarget.setPointerCapture(e.pointerId);}catch{}if(editing){try{shell.current?.setPointerCapture(e.pointerId);}catch{}long.current=true;startDrag(item,e.clientX,e.clientY);return;}hold.current=setTimeout(()=>{long.current=true;setMenu(item.key);setOutside(false);hold.current=null;},400);editHold.current=setTimeout(()=>{long.current=true;setEditing(true);swipe.current=null;try{shell.current?.setPointerCapture(e.pointerId);}catch{}startDrag(item,point.current.x,point.current.y);editHold.current=null;},800);} + function down(e:React.PointerEvent,item:LaunchItem){if(e.button!==0||operations.current.has(item.key)||(state.current.editing&&operations.current.has('order'))||state.current.panning)return;cancelHold();long.current=false;point.current={x:e.clientX,y:e.clientY};holdTarget.current=item.key;if(!editing)swipe.current={x:e.clientX,y:e.clientY};try{e.currentTarget.setPointerCapture(e.pointerId);}catch{}if(editing){try{shell.current?.setPointerCapture(e.pointerId);}catch{}long.current=true;startDrag(item,e.clientX,e.clientY);return;}hold.current=setTimeout(()=>{long.current=true;setMenu(item.key);setOutside(false);hold.current=null;},400);editHold.current=setTimeout(()=>{long.current=true;setEditing(true);swipe.current=null;try{shell.current?.setPointerCapture(e.pointerId);}catch{}startDrag(item,point.current.x,point.current.y);editHold.current=null;},800);} function reorder(target:string){const d=drag.current;if(!d||target===d.key||target===d.over)return;const from=d.order.indexOf(d.key),to=d.order.indexOf(target);if(from<0||to<0)return;const next=[...d.order];next.splice(from,1);next.splice(to,0,d.key);d.order=next;d.over=target;d.moved=true;setManual(next);} function turnEdge(direction:number){const d=drag.current;if(!d||d.edge===direction)return;stopEdge();if(!direction)return;d.edge=direction;setEdge(direction);const tick=()=>{const s=state.current,next=s.current+direction;if(!drag.current||next<0||next>=s.pages){stopEdge();return;}const target=s.items[next*pageSize+(direction>0?0:Math.min(pageSize-1,s.items.length-next*pageSize-1))];if(target)reorder(target.key);setPage(next);edgeTimer.current=setTimeout(tick,900);};edgeTimer.current=setTimeout(tick,650);} useEffect(()=>{const move=(e:PointerEvent)=>{const d=drag.current;if(d){setGhost(g=>g?{...g,x:e.clientX,y:e.clientY}:g);const moved=Math.hypot(e.clientX-point.current.x,e.clientY-point.current.y)>36;if(!d.moved&&!moved)return;d.moved=true;const box=grid.current?.getBoundingClientRect(),categories=shell.current?.querySelector('.launcher-filters')?.getBoundingClientRect();const left=box?Math.max(box.left,(categories?.right??box.left)+24):0;const inHeight=!!box&&e.clientY>=box.top&&e.clientY<=box.bottom;const direction=box&&inHeight?(e.clientX>=left&&e.clientXbox.right-50?1:0):0;turnEdge(direction);if(direction)return;const target=document.elementFromPoint(e.clientX,e.clientY)?.closest('[data-launch-key]');if(target&&Math.hypot(e.clientX-d.lastX,e.clientY-d.lastY)>12){reorder(target.dataset.launchKey!);d.lastX=e.clientX;d.lastY=e.clientY;}else if(!target)d.over=null; }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&&d.startedInEdit)setEditing(false);if(d.moved)void run(async()=>{try{await api('launcher.order.save',{order:d.order});}catch(e){setManual(d.before);throw e;}});}}; + 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&&d.startedInEdit)setEditing(false);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){return a.kind==='framely'?api('host.manager.open'):a.kind==='plugin'?api('plugin.launch',{plugin:a.id,source:'launcher'}):a.key.startsWith('steam:')?api('steam.launch',{app:+a.id}):api('desktop.launch',{app:a.key});} - async function custom(id:string,action:string){await run(async()=>{const v=await api('plugin.launcher.action',{plugin:id,action});if(v.frontend){busyRef.current=true;setBusy(true);await new Promise((resolve,reject)=>{pending.current={resolve,reject};setFrontend({plugin:id,action,context:v.context});frontTimer.current=setTimeout(()=>done('Launcher action timed out'),90000);});}if(v.opened)setMenu(null);});} - const pending=useRef<{resolve:()=>void;reject:(e:Error)=>void}|null>(null); - function done(error?:string){if(frontTimer.current)clearTimeout(frontTimer.current);const p=pending.current;pending.current=null;setFrontend(null);if(error)p?.reject(new Error(error));else p?.resolve();} + function launch(a:LaunchItem){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}):launcherRequest('desktop.launch',{app:a.key});} + 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();} return
{if(e.button===0&&!(e.target as HTMLElement).closest('button,input,.launcher-search,.launch-menu,.modal,.launcher-error')){if(!keyboard.current&&!editing&&!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'))close();}}> -
{cancelPan();cancelHold();setMenu(null);setQuery(e.target.value);setPage(0);}}/>{query&&}
- - - {editing&&
{t('拖动图标排列,移到两侧翻页')}
} +
{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} - {!editing&&} + {a.name} + {!editing&&} {menu===a.key&&
=(columns-2)*4?'align-right':''} ${rows>1&&cells[index].row===rows-1?'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:')&&<>}} - {busy&&{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:')&&<>}} + {busyKeys.has(a.key)&&{t('正在执行…')}}})}{panView&¤t+panView.direction>=0&¤t+panView.direction