Compare commits

...
13 changed files with 283 additions and 87 deletions

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"
+17 -3
View File
@@ -7,13 +7,25 @@
[简体中文](README.zh-CN.md)
> Framely is still under rapid development. Preview releases may change frequently; features and interactions are still being refined.
A React plugin manager for Steam Frame, with a Rust core service, a separate CEF/OpenVR host, a network management panel, and a desktop installer. Manage plugins, sources, windows, notifications, and updates.
![Framely spatial launcher](docs/images/launcher.png)
![Framely plugin library](docs/images/plugin-library.png)
## Installation
The target device is a Linux ARM64 Steam Frame. Download the `Framely Installer` for your computer from [Releases](https://github.com/SteamFramelyHomebrew/framely/releases), connect to the device, and install Framely. Alternatively, run this in the Frame SSH terminal to install the latest stable release:
The target device is a Linux ARM64 Steam Frame. Enable developer mode and SSH on Frame, connect your computer and Frame to the same network, and prepare the `steamos` account password.
### Desktop installer
Download the package for your computer from the [Framely Installer release](https://github.com/SteamFramelyHomebrew/framely/releases/tag/installer-v0.4.1-preview.9). Linux packages are `.tar.gz`; Windows and macOS packages are `.zip`. Extract and launch the installer, enter the Frame IP and SSH port, verify the SSH fingerprint, and connect with the `steamos` account. Select the Framely version, then confirm installation. Enable testing releases to install a Preview version. Installer and device versions are independent.
### Command installation
Run the following command **in Frame's SSH terminal** to install the latest stable release:
```bash
curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- install
@@ -22,10 +34,12 @@ 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.2-preview.3
curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- install --version v0.4.3-preview.6
```
First installation uses the complete offline package with CEF; updates use the smaller core package and reuse CEF; the device needs neither Node.js nor Rust. Installation requests sudo and does not automatically disable SteamOS read-only protection. Open the Framely Dock icon in the SteamVR Dashboard for the quick menu, then use Settings → Manage plugins to open the manager.
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.
First installation uses the complete offline package with CEF; updates use the smaller core package and reuse CEF; the device needs neither Node.js nor Rust. Installation requests sudo and does not automatically disable SteamOS read-only protection. Press the main trigger on the Framely Dock icon in the SteamVR Dashboard to open the launcher, or the upper trigger to open the quick menu. Open the manager from the launcher’s Settings button. The trigger actions can be swapped in Launcher settings.
The network panel uses port `15915` by default. Visit `http://DEVICE_IP:15915` on your phone or computer. When no password is configured, the first visit requires setting and confirming a password before login. The device password and panel password are independent.
+17 -3
View File
@@ -7,13 +7,25 @@
[English](README.md)
> Framely 目前仍在快速迭代中,Preview 版本可能频繁更新,功能与交互仍在持续完善。
Steam Frame 的 React 插件管理器:Rust 核心服务、独立 CEF/OpenVR 宿主、网络管理面板和电脑端安装器。可管理插件、来源、窗口、通知和更新。
![Framely 空间启动台](docs/images/launcher.png)
![Framely 插件库](docs/images/plugin-library.png)
## 安装
目标为 Linux ARM64 Steam Frame。电脑端从 [Releases](https://github.com/SteamFramelyHomebrew/framely/releases) 下载对应平台的 `Framely Installer`,连接设备后安装本体。也可在 Frame 的 SSH 终端安装最新正式版:
目标设备为 Linux ARM64 Steam Frame。先在 Frame 上启用开发者模式与 SSH,确保电脑和 Frame 在同一网络,并准备好 `steamos` 账户密码。
### 安装器安装
从 [Framely Installer 发行下载页](https://github.com/SteamFramelyHomebrew/framely/releases/tag/installer-v0.4.1-preview.9) 下载适合电脑平台的安装器。Linux 提供 `.tar.gz`,Windows 和 macOS 提供 `.zip`。解压并启动安装器,填写 Frame 的 IP 和 SSH 端口,核对 SSH 指纹后使用 `steamos` 账户连接。选择 Framely 版本,再确认安装。安装 Preview 版本时需启用测试版本;安装器与 Frame 本体的版本号相互独立。
### 命令安装
**在 Frame 的 SSH 终端中**执行以下命令,安装最新正式版:
```bash
curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- install
@@ -22,10 +34,12 @@ 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.2-preview.3
curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- install --version v0.4.3-preview.6
```
首次安装使用包含 CEF 的完整离线包,更新使用不含 CEF 的本体包并复用运行库;同时提供独立 CEF 在线下载。设备无需 Node.js 或 Rust。安装会请求 sudo;不会自动解除 SteamOS 只读保护。打开 SteamVR Dashboard 的 Framely Dock 图标进入快捷菜单,从“设置 → 管理插件”打开管理窗口。
将示例标签替换为需要安装的已发布版本,并在提示时输入 sudo 密码。更新、修复和卸载步骤见[安装指南](docs/zh-CN/user-guide/installation.md)。
首次安装使用包含 CEF 的完整离线包,更新使用不含 CEF 的本体包并复用运行库;同时提供独立 CEF 在线下载。设备无需 Node.js 或 Rust。安装会请求 sudo;不会自动解除 SteamOS 只读保护。在 SteamVR Dashboard 中指向 Framely Dock 图标,按主扳机打开启动台,按上扳机打开快捷面板;点击启动台右侧的设置按钮打开管理面板。可在“启动台”设置中互换扳机行为。
网络面板默认端口 `15915`,手机/电脑访问 `http://设备IP:15915`。未配置密码时首次访问必须设置并确认密码,保存后登录。设备密码与面板密码相互独立。
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

+49 -30
View File
@@ -313,17 +313,11 @@ pub fn launch(home: &Path, id: &str) -> Result<()> {
fn launch_target(target: Target) -> Result<()> {
match target {
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:#}"))?;
}
Target::Lepton {
runner,
@@ -332,18 +326,19 @@ fn launch_target(target: Target) -> Result<()> {
} => {
// Use the same session-owned context and Android launcher intent; never
// reinstall the APK or accept an arbitrary shell command from the UI.
let running = crate::process::tool("podman")
.args([
let running = crate::process::command_output_timeout(
crate::process::tool("podman").args([
"inspect",
"--format",
"{{.State.Running}}",
&format!("lepton-{context}"),
])
.stdin(Stdio::null())
.output()
.is_ok_and(|o| {
o.status.success() && String::from_utf8_lossy(&o.stdout).trim() == "true"
});
]),
std::time::Duration::from_secs(5),
true,
)
.context("Cannot inspect Lepton context")?;
let running = running.status.success()
&& String::from_utf8_lossy(&running.stdout).trim() == "true";
let child = if running {
None
} else {
@@ -357,11 +352,16 @@ fn launch_target(target: Target) -> Result<()> {
.context("Cannot start Lepton context")?,
)
};
if let Some(mut child) = child {
std::thread::spawn(move || {
let _ = child.wait();
});
}
let start = std::time::Instant::now();
let mut ready = false;
while start.elapsed() < std::time::Duration::from_secs(35) {
let output = crate::process::tool("podman")
.args([
let output = crate::process::command_output_timeout(
crate::process::tool("podman").args([
"exec",
&format!("lepton-{context}"),
"monkey",
@@ -370,20 +370,19 @@ fn launch_target(target: Target) -> Result<()> {
"-c",
"android.intent.category.LAUNCHER",
"1",
])
.stdin(Stdio::null())
.output();
if output.is_ok_and(|o| o.status.success()) {
]),
std::time::Duration::from_secs(35)
.saturating_sub(start.elapsed())
.min(std::time::Duration::from_secs(5)),
false,
)
.context("Lepton launch handoff failed")?;
if output.status.success() {
ready = true;
break;
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
if let Some(mut child) = child {
std::thread::spawn(move || {
let _ = child.wait();
});
}
ensure!(
ready,
"Lepton application did not become ready; retry after the context starts"
@@ -469,6 +468,26 @@ mod tests {
crate::process::TEST_TOOLS.with(|p| *p.borrow_mut() = None);
}
#[test]
fn stalled_lepton_inspect_and_launch_are_bounded() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let podman = dir.path().join("podman");
for inspect in [true, false] {
fs::write(&podman, if inspect { "#!/bin/sh\nexec sleep 30\n" } else { "#!/bin/sh\nif [ \"$1\" = inspect ]; then echo true; exit 0; fi\nexec sleep 30\n" }).unwrap();
fs::set_permissions(&podman, fs::Permissions::from_mode(0o755)).unwrap();
crate::process::TEST_TOOLS.with(|p| *p.borrow_mut() = Some(dir.path().into()));
let started = std::time::Instant::now();
let result = launch_target(Target::Lepton {
runner: dir.path().join("must-not-start"),
context: "existing".into(),
package: "com.example.app".into(),
});
crate::process::TEST_TOOLS.with(|p| *p.borrow_mut() = None);
assert!(format!("{:#}", result.unwrap_err()).contains("timed out"));
assert!(started.elapsed() < std::time::Duration::from_secs(7));
}
}
#[test]
fn unknown_id_never_launches() {
let dir = tempfile::tempdir().unwrap();
assert!(launch(dir.path(), "desktop:../../arbitrary").is_err());
+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")]
@@ -302,16 +301,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
@@ -482,6 +482,7 @@
"读音搜索暂不可用,仍可使用原文搜索。": "Phonetic search is unavailable. You can still search original names.",
"启动方式与菜单": "Launch controls and menus",
"当前已是此版本,无需重复更新。": "This version is already installed. No update is needed.",
"快捷操作": "Quick actions"
"快捷操作": "Quick actions",
"操作等待超时;请确认应用是否已打开后再重试。": "The operation timed out. Check whether the application opened before retrying."
}
}
+2 -1
View File
@@ -482,6 +482,7 @@
"读音搜索暂不可用,仍可使用原文搜索。": "读音搜索暂不可用,仍可使用原文搜索。",
"启动方式与菜单": "启动方式与菜单",
"当前已是此版本,无需重复更新。": "当前已是此版本,无需重复更新。",
"快捷操作": "快捷操作"
"快捷操作": "快捷操作",
"操作等待超时;请确认应用是否已打开后再重试。": "操作等待超时;请确认应用是否已打开后再重试。"
}
}
+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><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><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>;
}