Merge main into feat/apk-manager

This commit is contained in:
toorux committed 2026-10-06 00:11:29 +08:00
commit dd990dd401
12 files changed
+209 -67

No files matched your search

Generated
+1 -1
View File
@@ -347,7 +347,7 @@ dependencies = [
[[package]]
name = "framely"
version = "0.4.3-preview.5"
version = "0.4.3-preview.6"
dependencies = [
"anyhow",
"base64",
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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)。
+7 -14
View File
@@ -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");
+131 -10
View File
@@ -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<std::process::Output> {
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<fs::File> {
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<String> {
}
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"));
}
}
+5 -11
View File
@@ -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)]
+17 -1
View File
@@ -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<string,Plugin>={
'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<Response>(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<HTMLButtonElement>('.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<HTMLButtonElement>('.launcher-filters button[aria-label=All]')!.click();await until(()=>document.querySelector('.launch-icon[aria-label="Desktop terminal"]'));
document.querySelector<HTMLButtonElement>('.launcher-filters button[aria-label="Desktop apps"]')!.click();await until(()=>document.querySelectorAll('.launch-icon').length===1);document.querySelector<HTMLButtonElement>('.launch-icon')!.click();await until(()=>calls.some(c=>c.method==='desktop.launch'&&c.params.app==='desktop:terminal.desktop'));
await until(()=>!document.querySelector<HTMLButtonElement>('.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<HTMLButtonElement>('.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<HTMLButtonElement>('.launch-icon')!.disabled);
document.querySelector<HTMLButtonElement>('.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<HTMLButtonElement>('.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<HTMLInputElement>('.launcher-search input')!.disabled||[...document.querySelectorAll<HTMLButtonElement>('.launcher-filters button,.launcher-pages button,.launcher-shortcuts button')].some(b=>b.disabled))throw Error('Pending app locked launcher navigation');
const other=document.querySelector<HTMLButtonElement>('.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<HTMLButtonElement>('.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<HTMLButtonElement>('.launcher-filters button[aria-label=Plugin]')!.click();await until(()=>!!document.querySelector('.launch-icon[aria-label=Termix]'));await until(()=>!!document.querySelector('.launcher-error')&&!document.querySelector<HTMLButtonElement>('.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<HTMLButtonElement>('.launch-icon')!.disabled)throw Error('Late action response locked or mounted in reopened launcher');
releaseStalled=undefined;stalledMethod='desktop.launch';document.querySelector<HTMLButtonElement>('.launcher-filters button[aria-label="Desktop apps"]')!.click();await until(()=>document.querySelectorAll('.launch-icon').length===1);document.querySelector<HTMLButtonElement>('.launch-icon')!.click();await until(()=>!!releaseStalled);await until(()=>!document.querySelector<HTMLButtonElement>('.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<HTMLInputElement>('[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);}};
+2 -1
View File
@@ -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."
}
}
+2 -1
View File
@@ -577,6 +577,7 @@
"日志": "日志",
"切换管理面板": "切换管理面板",
"插件管理": "插件管理",
"Lepton 侧载管理": "Lepton 侧载管理"
"Lepton 侧载管理": "Lepton 侧载管理",
"操作等待超时;请确认应用是否已打开后再重试。": "操作等待超时;请确认应用是否已打开后再重试。"
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
import {t} from './i18n';
import {installKeyboard} from '../../sdk/src/keyboard';
import {installHoverFeedback} from '../../sdk/src/hover';
export async function api<T=any>(method:string,params:unknown={}):Promise<T>{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<T=any>(method:string,params:unknown={},signal?:AbortSignal):Promise<T>{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}));
+40 -24
View File
@@ -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<T=any>(method:string,params:unknown={}):Promise<T>{
const controller=new AbortController();
const timer=setTimeout(()=>controller.abort(),60000);
try{return await api<T>(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<string,Plugin>;safeMode:boolean;config?:LauncherConfig;favorites:number[];localFavorites?:string[];order?:string[];refresh:()=>Promise<void>}){
const remembered=useRef<string|null>(null);
const[query,setQuery]=useState(''),[indexes,setIndexes]=useState<Record<string,SearchIndex>>({}),keyboard=useRef(false),opening=useRef(true),props=useRef({plugins,favorites,localFavorites});props.current={plugins,favorites,localFavorites};
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),[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<string|null>(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<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),mounted=useRef(true),busyRef=useRef(false),frontTimer=useRef<ReturnType<typeof setTimeout>|null>(null),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[]});
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()),[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}|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),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]);
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<SteamApp[]>('steam.list'),api<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;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<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(()=>{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<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(keyboard.current||drag.current||busyRef.current||state.current.panning)return;const direction=(e as CustomEvent<number>).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<number>).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<Record<string,SearchIndex>>('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<Record<string,SearchIndex>>('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<boolean>).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<string,{x:number;y:number}>()),layoutPage=useRef('');
useLayoutEffect(()=>{const next=new Map<string,{x:number;y:number}>(),pageKey=`${filter}:${current}`;const reduced=window.matchMedia('(prefers-reduced-motion: reduce)').matches;for(const node of Array.from(shell.current?.querySelectorAll<HTMLElement>('[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<unknown>){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<unknown>){
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<typeof setTimeout>|undefined;
const deadline=new Promise<never>((_,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.clientX<left+50?-1:e.clientX<=box.right&&e.clientX>box.right-50?1:0):0;turnEdge(direction);if(direction)return;const target=document.elementFromPoint(e.clientX,e.clientY)?.closest<HTMLElement>('[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&&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&&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<void>((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<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();}
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&&!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();}}>
<div className="launcher-search"><IconSearch size={22} stroke={1.65}/><input type="search" aria-label={t('搜索应用')} placeholder={t('搜索应用')} value={query} disabled={editing||busy} 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={busy||!!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:'catalog',label:'插件库',icon:IconApps},{page:'settings',label:'设置',icon:IconSettings}].map(({page,label,icon:ActionIcon})=><button key={page} aria-label={t(label)} title={t(label)} disabled={busy||editing||!!dragging||!!panView} onClick={()=>{cancelHold();setMenu(null);void run(()=>api('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={busy||!!dragging} onClick={()=>setEditing(false)}><IconCheck size={22}/>{t('完成')}</button></div>}
<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:'catalog',label:'插件库',icon:IconApps},{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',()=>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" disabled={busy||(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;setMenu(null);void run(()=>launch(a));}} onContextMenu={e=>{e.preventDefault();setMenu(a.key);setOutside(false);}}><LauncherArtwork src={a.icon} name={a.name} kind={a.kind} fit={a.fit}/></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={busy} onClick={()=>void run(()=>api('launcher.favorite',{key:a.key,favorite:!a.favorite}))}><IconStar size={16} stroke={1.7} fill={a.favorite?'currentColor':'none'}/></button>}
<button className="launch-icon" 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;setMenu(null);void run(a.key,()=>launch(a));}} onContextMenu={e=>{e.preventDefault();setMenu(a.key);setOutside(false);}}><LauncherArtwork src={a.icon} name={a.name} kind={a.kind} fit={a.fit}/></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':''} ${rows>1&&cells[index].row===rows-1?'above':''}`} onPointerEnter={()=>setOutside(false)} onPointerLeave={()=>setOutside(true)}>
{selectedPlugin?<>{(selectedPlugin.manifest.ui.launcherActions??[]).map(action=><button role="menuitem" key={action.id} disabled={busy||safeMode} onClick={()=>void custom(a.id,action.id)}>{action.label}</button>)}{!!selectedPlugin.manifest.ui.launcherActions?.length&&<hr/>}<button role="menuitem" disabled={busy||safeMode||!selectedPlugin.manifest.ui.quickPage} title={!selectedPlugin.manifest.ui.quickPage?t('插件没有快捷页面'):undefined} onClick={()=>void run(()=>api('plugin.launch',{plugin:a.id,source:'launcher',quickPage:true}))}><IconLayoutGrid size={22} stroke={1.7}/>{t('快捷面板')}</button><button role="menuitem" disabled={busy} onClick={()=>void run(()=>api('host.plugin.manage',{plugin:a.id}))}><IconAdjustments size={22} stroke={1.7}/>{t('管理插件')}</button><button role="menuitem" disabled={busy} className="danger" onClick={()=>void run(async()=>{const plan=await api('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" disabled={busy} onClick={()=>void run(()=>launch(a))}><IconLayoutGrid size={22}/>{t('打开')}</button>{a.kind==='lepton'&&!a.key.startsWith('steam:')&&<><button role="menuitem" disabled={busy} onClick={()=>void run(()=>api('host.manager.open',{page:'apk'}).then(()=>api('apk.selection',{app:a.id})))}><IconAdjustments size={22}/>{t('管理应用')}</button><button role="menuitem" className="danger" disabled={busy} onClick={()=>void run(()=>api('host.manager.open',{page:'apk'}).then(()=>api('apk.selection',{app:a.id,uninstall:true})))}><IconTrash size={22}/>{t('卸载应用')}</button></>}<button role="menuitem" disabled={busy} onClick={()=>{setMenu(null);setEditing(true);}}><IconGridDots size={22}/>{t('整理图标')}</button></>}
{busy&&<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=index<4?0:index<9?1:2,column=index-(row===0?0:row===1?4:9),slot=(columns-(count<=4?count:row===1?5:4))*2+1+column*4;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" disabled={busyKeys.has(a.key)} onClick={()=>void run(a.key,()=>launch(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></>}
{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=index<4?0:index<9?1:2,column=index-(row===0?0:row===1?4:9),slot=(columns-(count<=4?count:row===1?5:4))*2+1+column*4;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>
{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>}{!!edge&&<div className={`launcher-drag-edge ${edge<0?'left':'right'}`} 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={busy||!!dragging||!!panView} onClick={()=>{if(editing){close();return;}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={busy} onClick={()=>setUninstall(null)}>{t('取消')}</button><button disabled={busy} className="danger" onClick={()=>void run(async()=>{await api('plugin.uninstall',{plugin:uninstall.manifest.id,purge:false,approveDependents:true,fingerprint:uninstall.fingerprint});setUninstall(null);setMenu(null);})}>{t('确认卸载')}</button></div></div>}{frontend&&<div className="launcher-action-frame" aria-hidden="true"><PluginFrame plugin={frontend.plugin} entry={`action-${frontend.action}`} actionContext={frontend.context} onActionDone={done}/></div>}</div>;
{!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={()=>{if(editing){close();return;}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>;
}