mirror of
https://github.com/SteamFramelyHomebrew/framely.git
synced 2026-10-17 19:01:26 +02:00
feat(launcher): offer local and Remote Play launch choices
This commit is contained in:
1 parent
6a50b4a74b
commit
fbd3f9cfa3
9 files changed
+318
-83
No files matched your search
@@ -51,3 +51,7 @@ The result shows RMS target bias and the worst target bias in degrees. Each targ
|
||||
After substantial changes to headset fit or the window distance, run calibration again.
|
||||
|
||||
Press feedback appears only after holding for 200 ms. The Dock entry uses a blue border indicator and opens the quick panel at 600 ms. Launcher icons show a translucent white circular progress border: the halfway tick opens the menu at 400 ms; continuing to 800 ms enters icon editing. Release or cancellation removes the indicator.
|
||||
|
||||
### Remote Steam games
|
||||
|
||||
The Steam category also shows apps installed on a connected Remote Play computer. A small Remote Play badge appears at the bottom right of their icons. The list refreshes in the background; disconnected hosts disappear, and each App ID has one icon. If both local and remote installations are available, clicking it opens a choice between launching locally and Remote Play. Favorites and manual ordering remain shared for that game. Clicking a remote icon asks Steam to stream from its current host and checks availability again before launching. Artwork uses the local Steam cache, with a placeholder when unavailable. The integration uses the running Steam client UI; if that interface is unavailable, local games remain usable.
|
||||
@@ -53,3 +53,7 @@ Steam 启动成功表示请求已交给 Steam,并不代表游戏加载完成
|
||||
明显调整头显佩戴方式或窗口距离后,请重新校准。
|
||||
|
||||
按住 200 毫秒后才显示进度反馈。Dock 入口使用蓝色边框进度,600 毫秒打开快捷面板。启动台图标使用半透明白色圆形边框进度:中点刻度对应 400 毫秒打开菜单,继续按至 800 毫秒进入图标编辑;松手或取消按压后移除进度。
|
||||
|
||||
### Steam 远程畅玩
|
||||
|
||||
Steam 分类也显示已连接远程畅玩电脑上安装的应用,图标右下角标有远程图标。列表后台刷新,断连的电脑不再展示;相同 App ID 只显示一个图标;本机和远程都可用时,点击弹窗选择本机启动或远程畅玩,收藏和手动排序保持同一份。点击远程图标会先重新检查可用状态,再让 Steam 从对应电脑串流。图片使用本机 Steam 缓存,缺图显示占位图。此功能读取正在运行的 Steam 客户端界面;该接口不可用时,本机游戏仍可使用。
|
||||
+14
-2
@@ -953,14 +953,26 @@ impl Agent {
|
||||
}
|
||||
Ok(json!(true))
|
||||
}
|
||||
"steam.list" => Ok(json!(crate::steam::discover(&crate::steam::home()?))),
|
||||
"steam.list" => {
|
||||
if p["remoteOnly"] == true {
|
||||
Ok(crate::steam::remote_snapshot())
|
||||
} else {
|
||||
Ok(json!(crate::steam::discover_launcher(
|
||||
&crate::steam::home()?
|
||||
)))
|
||||
}
|
||||
}
|
||||
"steam.launch" => {
|
||||
let launcher_session = p["launcherSession"].as_u64();
|
||||
let id = p["app"]
|
||||
.as_u64()
|
||||
.filter(|v| *v > 0 && *v <= u32::MAX as u64)
|
||||
.context("Invalid Steam app")? as u32;
|
||||
crate::steam::launch(id)?;
|
||||
if let Some(client) = p["remoteClient"].as_str() {
|
||||
crate::steam::launch_remote(id, client)?;
|
||||
} else {
|
||||
crate::steam::launch(id)?;
|
||||
}
|
||||
push(
|
||||
&self.commands,
|
||||
json!({"kind":"launcher.close","handoff":true,"launcherSession":launcher_session}),
|
||||
|
||||
+156
-68
@@ -1,4 +1,5 @@
|
||||
//! Local Steam library discovery. No account API or network access is needed.
|
||||
//! Local Steam libraries and connected Remote Play hosts.
|
||||
mod remote;
|
||||
use anyhow::{ensure, Context, Result};
|
||||
use serde::Serialize;
|
||||
use std::{
|
||||
@@ -9,6 +10,9 @@ use std::{
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct App {
|
||||
pub remote: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub remote_client: Option<String>,
|
||||
pub id: u32,
|
||||
pub name: String,
|
||||
pub kind: &'static str,
|
||||
@@ -202,77 +206,13 @@ pub fn discover(home: &Path) -> Vec<App> {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let dir = steam.join("appcache/librarycache").join(id.to_string());
|
||||
let mut candidates: Vec<_> = fs::read_dir(dir)
|
||||
.ok()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.collect();
|
||||
// Newer Steam clients keep named artwork one level below a content hash.
|
||||
let nested: Vec<_> = candidates
|
||||
.iter()
|
||||
.filter(|p| p.is_dir())
|
||||
.take(16)
|
||||
.cloned()
|
||||
.collect();
|
||||
for path in nested {
|
||||
candidates.extend(
|
||||
fs::read_dir(path)
|
||||
.ok()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.take(32)
|
||||
.map(|e| e.path()),
|
||||
);
|
||||
}
|
||||
for suffix in ["icon.jpg", "icon.png", "library_600x900.jpg", "header.jpg"] {
|
||||
candidates.push(
|
||||
steam
|
||||
.join("appcache/librarycache")
|
||||
.join(format!("{id}_{suffix}")),
|
||||
);
|
||||
}
|
||||
let artwork_fit = |p: &Path| {
|
||||
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or_default();
|
||||
if name.contains("600x900") || name == "library_capsule.jpg" {
|
||||
(0, "cover")
|
||||
} else if name.contains("header") {
|
||||
(1, "cover")
|
||||
} else if name == "logo.png" {
|
||||
(2, "contain")
|
||||
} else {
|
||||
(3, "contain")
|
||||
}
|
||||
};
|
||||
candidates.sort_by_key(|p| (artwork_fit(p).0, p.clone()));
|
||||
let icon = candidates.into_iter().take(32).find_map(|p| {
|
||||
if fs::metadata(&p).ok()?.len() > 1024 * 1024 {
|
||||
return None;
|
||||
}
|
||||
let b = fs::read(&p).ok()?;
|
||||
let mime = if b.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||
"image/png"
|
||||
} else if b.starts_with(&[0xff, 0xd8, 0xff]) {
|
||||
"image/jpeg"
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
use base64::Engine;
|
||||
Some((
|
||||
format!(
|
||||
"data:{mime};base64,{}",
|
||||
base64::engine::general_purpose::STANDARD.encode(b)
|
||||
),
|
||||
artwork_fit(&p).1,
|
||||
))
|
||||
});
|
||||
let icon = cached_artwork(&steam, id);
|
||||
apps.insert(
|
||||
id,
|
||||
App {
|
||||
id,
|
||||
remote: false,
|
||||
remote_client: None,
|
||||
name: name.into(),
|
||||
kind: if text(v, "installdir").is_some_and(|dir| {
|
||||
fs::read_dir(library.join("steamapps/common").join(dir)).is_ok_and(
|
||||
@@ -295,6 +235,117 @@ pub fn discover(home: &Path) -> Vec<App> {
|
||||
}
|
||||
apps.into_values().collect()
|
||||
}
|
||||
fn cached_artwork(steam: &Path, id: u32) -> Option<(String, &'static str)> {
|
||||
let dir = steam.join("appcache/librarycache").join(id.to_string());
|
||||
let mut candidates: Vec<_> = fs::read_dir(dir)
|
||||
.ok()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.collect();
|
||||
// Newer Steam clients keep named artwork one level below a content hash.
|
||||
let nested: Vec<_> = candidates
|
||||
.iter()
|
||||
.filter(|p| p.is_dir())
|
||||
.take(16)
|
||||
.cloned()
|
||||
.collect();
|
||||
for path in nested {
|
||||
candidates.extend(
|
||||
fs::read_dir(path)
|
||||
.ok()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.take(32)
|
||||
.map(|e| e.path()),
|
||||
);
|
||||
}
|
||||
for suffix in ["icon.jpg", "icon.png", "library_600x900.jpg", "header.jpg"] {
|
||||
candidates.push(
|
||||
steam
|
||||
.join("appcache/librarycache")
|
||||
.join(format!("{id}_{suffix}")),
|
||||
);
|
||||
}
|
||||
let artwork_fit = |p: &Path| {
|
||||
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or_default();
|
||||
if name.contains("600x900") || name == "library_capsule.jpg" {
|
||||
(0, "cover")
|
||||
} else if name.contains("header") {
|
||||
(1, "cover")
|
||||
} else if name == "logo.png" {
|
||||
(2, "contain")
|
||||
} else {
|
||||
(3, "contain")
|
||||
}
|
||||
};
|
||||
candidates.sort_by_key(|p| (artwork_fit(p).0, p.clone()));
|
||||
candidates.into_iter().take(32).find_map(|p| {
|
||||
if fs::metadata(&p).ok()?.len() > 1024 * 1024 {
|
||||
return None;
|
||||
}
|
||||
let b = fs::read(&p).ok()?;
|
||||
let mime = if b.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||
"image/png"
|
||||
} else if b.starts_with(&[0xff, 0xd8, 0xff]) {
|
||||
"image/jpeg"
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
use base64::Engine;
|
||||
Some((
|
||||
format!(
|
||||
"data:{mime};base64,{}",
|
||||
base64::engine::general_purpose::STANDARD.encode(b)
|
||||
),
|
||||
artwork_fit(&p).1,
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn remote_snapshot() -> serde_json::Value {
|
||||
serde_json::json!(remote::cached())
|
||||
}
|
||||
pub fn discover_launcher(home: &Path) -> Vec<App> {
|
||||
merge_remote(home, discover(home), remote::cached())
|
||||
}
|
||||
fn merge_remote(home: &Path, local: Vec<App>, remote: Vec<remote::RemoteApp>) -> Vec<App> {
|
||||
let mut apps: BTreeMap<_, _> = local.into_iter().map(|a| (a.id, a)).collect();
|
||||
let steam = home.join(".local/share/Steam");
|
||||
let steam = if steam.exists() {
|
||||
steam
|
||||
} else {
|
||||
home.join(".steam/steam")
|
||||
};
|
||||
for a in remote {
|
||||
if let Some(existing) = apps.get_mut(&a.id) {
|
||||
// Keep local metadata and a single stable icon, but expose both launch targets.
|
||||
if existing.remote_client.is_none() {
|
||||
existing.remote_client = Some(a.client);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
apps.entry(a.id).or_insert_with(|| {
|
||||
let icon = cached_artwork(&steam, a.id);
|
||||
App {
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
kind: "steam",
|
||||
icon_fit: icon.as_ref().map(|v| v.1).unwrap_or("contain"),
|
||||
icon: icon.map(|v| v.0),
|
||||
remote: true,
|
||||
remote_client: Some(a.client),
|
||||
}
|
||||
});
|
||||
}
|
||||
apps.into_values().collect()
|
||||
}
|
||||
pub fn launch_remote(id: u32, client: &str) -> Result<()> {
|
||||
remote::launch(id, client)
|
||||
}
|
||||
|
||||
pub fn home() -> Result<PathBuf> {
|
||||
ensure!(
|
||||
unsafe { libc::geteuid() } != 0,
|
||||
@@ -322,6 +373,43 @@ fn launch_installed(home: &Path, id: u32) -> Result<()> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn remote_inventory_merges_launch_choices_without_duplicate_icons() {
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let local = vec![App {
|
||||
id: 42,
|
||||
name: "Local".into(),
|
||||
kind: "steam",
|
||||
icon: None,
|
||||
icon_fit: "contain",
|
||||
remote: false,
|
||||
remote_client: None,
|
||||
}];
|
||||
let rows = vec![
|
||||
remote::RemoteApp {
|
||||
id: 42,
|
||||
name: "Same remote".into(),
|
||||
client: "12".into(),
|
||||
},
|
||||
remote::RemoteApp {
|
||||
id: 43,
|
||||
name: "Remote".into(),
|
||||
client: "12".into(),
|
||||
},
|
||||
remote::RemoteApp {
|
||||
id: 43,
|
||||
name: "Other host".into(),
|
||||
client: "13".into(),
|
||||
},
|
||||
];
|
||||
let apps = merge_remote(home.path(), local, rows);
|
||||
assert_eq!(apps.len(), 2);
|
||||
assert!(!apps[0].remote);
|
||||
assert_eq!(apps[0].name, "Local");
|
||||
assert_eq!(apps[0].remote_client.as_deref(), Some("12"));
|
||||
assert!(apps[1].remote);
|
||||
assert_eq!(apps[1].remote_client.as_deref(), Some("12"));
|
||||
}
|
||||
#[test]
|
||||
fn nested_escaped_and_invalid() {
|
||||
let t = tokens("\"libraryfolders\" { \"0\" { \"path\" \"/a\\\\b\" } }").unwrap();
|
||||
let v = object(&t, &mut 0, 0, false).unwrap();
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
async function(request) {
|
||||
const store=window.appStore, remote=window.RemotePlayStore_SteamUI;
|
||||
if(!store||!remote||!Array.isArray(store.allApps)||!Array.isArray(remote.devices)) {
|
||||
if(request.mode==='list')return [];
|
||||
throw Error('Steam remote library is unavailable');
|
||||
}
|
||||
const connected=new Set(remote.devices.filter(d=>d.status==='Connected').map(d=>String(d.clientId)));
|
||||
const rows=[];
|
||||
for(const a of store.allApps) {
|
||||
if(!Number.isInteger(a.appid)||a.appid<=0||a.appid>4294967295||a.visible_in_game_list===false||!(a.app_type&(1|2|8)))continue;
|
||||
for(const r of a.remote_per_client_data||[]) {
|
||||
const client=String(r.clientid);
|
||||
if(r.installed===true&&/^\d{1,20}$/.test(client)&&connected.has(client)&&a.BIsPerClientDataLocal?.(r)===false)
|
||||
rows.push({id:a.appid,name:String(a.display_name||''),client});
|
||||
}
|
||||
}
|
||||
if(request.mode==='list')return rows.slice(0,10000);
|
||||
if(request.mode!=='launch'||!rows.some(r=>r.id===request.id&&r.client===request.client))return false;
|
||||
if(typeof SteamClient.Apps.StreamGame!=='function')throw Error('Steam streaming is unavailable');
|
||||
await SteamClient.Apps.StreamGame(request.id,request.client,-1);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Steam's current connected-host inventory; no account scraping or media reads.
|
||||
use anyhow::{ensure, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Mutex, OnceLock,
|
||||
};
|
||||
use std::time::{Duration, Instant};
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub(super) struct RemoteApp {
|
||||
pub id: u32,
|
||||
pub name: String,
|
||||
pub client: String,
|
||||
}
|
||||
fn query(request: serde_json::Value) -> Result<serde_json::Value> {
|
||||
crate::apk::steam_ui::evaluate(&format!("({})({request})", include_str!("remote.js")))
|
||||
}
|
||||
fn inventory(value: serde_json::Value) -> Result<Vec<RemoteApp>> {
|
||||
let rows: Vec<RemoteApp> = serde_json::from_value(value)?;
|
||||
ensure!(rows.len() <= 10000, "Remote library is too large");
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.filter(|a| {
|
||||
a.id > 0 && !a.name.trim().is_empty() && a.name.len() <= 1024 && valid_client(&a.client)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
fn valid_client(client: &str) -> bool {
|
||||
!client.is_empty()
|
||||
&& client.len() <= 20
|
||||
&& client.bytes().all(|b| b.is_ascii_digit())
|
||||
&& client != "0"
|
||||
}
|
||||
static CACHE: OnceLock<Mutex<Option<(Instant, Vec<RemoteApp>)>>> = OnceLock::new();
|
||||
static REFRESHING: AtomicBool = AtomicBool::new(false);
|
||||
pub(super) fn cached() -> Vec<RemoteApp> {
|
||||
let cache = CACHE.get_or_init(|| Mutex::new(None));
|
||||
let snapshot = cache.lock().unwrap().clone();
|
||||
if snapshot
|
||||
.as_ref()
|
||||
.is_none_or(|(at, _)| at.elapsed() >= Duration::from_secs(3))
|
||||
&& REFRESHING
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_ok()
|
||||
{
|
||||
std::thread::spawn(|| {
|
||||
let rows = query(serde_json::json!({"mode":"list"}))
|
||||
.and_then(inventory)
|
||||
.unwrap_or_default();
|
||||
*CACHE.get().unwrap().lock().unwrap() = Some((Instant::now(), rows));
|
||||
REFRESHING.store(false, Ordering::Release);
|
||||
});
|
||||
}
|
||||
snapshot
|
||||
.filter(|(at, _)| at.elapsed() < Duration::from_secs(15))
|
||||
.map(|(_, v)| v)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
pub(super) fn launch(id: u32, client: &str) -> Result<()> {
|
||||
ensure!(id > 0 && valid_client(client), "Invalid remote Steam game");
|
||||
ensure!(
|
||||
query(serde_json::json!({"mode":"launch","id":id,"client":client}))? == true,
|
||||
"Remote game or host is no longer available"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn invalid_remote_entries_and_clients_are_rejected() {
|
||||
let rows=inventory(serde_json::json!([{ "id":1,"name":"Game","client":"123"},{"id":0,"name":"Invalid","client":"12"},{"id":2,"name":"Unsafe","client":"1);evil()"}])).unwrap();
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert!(launch(42, "0").is_err());
|
||||
assert!(launch(42, r#"1"2"#).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
const calls=[];
|
||||
const app=(id,remote,extra={})=>({appid:id,display_name:`Game ${id}`,app_type:1,visible_in_game_list:true,remote_per_client_data:remote,BIsPerClientDataLocal:r=>r.clientid==='local',...extra});
|
||||
const context={window:{appStore:{allApps:[app(1,[{clientid:'12',installed:true}]),app(2,[{clientid:'13',installed:true}]),app(3,[{clientid:'12',installed:false}]),app(4,[{clientid:'local',installed:true}]),app(5,[{clientid:'12',installed:true}],{app_type:8192}),app(6,[{clientid:'12',installed:true}],{visible_in_game_list:false})]},RemotePlayStore_SteamUI:{devices:[{clientId:'12',status:'Connected'},{clientId:'13',status:'Disconnected'},{clientId:'local',status:'Connected'}]}},SteamClient:{Apps:{StreamGame:(...args)=>calls.push(args)}}};
|
||||
const query=vm.runInNewContext(`(${fs.readFileSync('src/steam/remote.js','utf8')})`,context);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(await query({mode:'list'}))),[{id:1,name:'Game 1',client:'12'}]);
|
||||
assert.equal(await query({mode:'launch',id:1,client:'13'}),false);
|
||||
assert.equal(await query({mode:'launch',id:999,client:'12'}),false);
|
||||
assert.equal(calls.length,0);
|
||||
assert.equal(await query({mode:'launch',id:1,client:'12'}),true);
|
||||
assert.deepEqual(calls,[[1,'12',-1]]);
|
||||
context.window.RemotePlayStore_SteamUI.devices=[];
|
||||
assert.equal(await query({mode:'launch',id:1,client:'12'}),false);
|
||||
assert.equal((await query({mode:'list'})).length,0);
|
||||
context.window.appStore=null;
|
||||
assert.equal((await query({mode:'list'})).length,0);
|
||||
console.log('Steam remote inventory and validated launch: passed');
|
||||
@@ -195,3 +195,8 @@
|
||||
@keyframes launch-hold-fill{to{stroke-dashoffset:0}}
|
||||
|
||||
@media(max-width:900px){.launch-hold-progress{width:94px;height:94px}}
|
||||
|
||||
.launch-remote-mark{position:absolute;right:18px;bottom:11px;width:29px;height:29px;display:flex;align-items:center;justify-content:center;border-radius:50%;background:#252525e8;color:#f0f0f0;box-shadow:0 2px 6px #0005;pointer-events:none;z-index:2}
|
||||
@media(max-width:900px){.launch-remote-mark{right:1px;bottom:1px}}
|
||||
|
||||
.launch-choice-actions{display:flex;gap:14px;margin:24px 0}.launch-choice-actions button{flex:1;display:flex;align-items:center;justify-content:center;gap:12px;min-height:64px}
|
||||
+17
-13
@@ -2,7 +2,7 @@ import {SwitchRow} from './switch';
|
||||
import React,{useEffect,useLayoutEffect,useMemo,useRef,useState} from 'react';
|
||||
import {api} from './api';
|
||||
import {t} from './i18n';
|
||||
import {IconLayoutGrid,IconPuzzle,IconBrandSteam,IconStar,IconAdjustments,IconTrash,IconDeviceDesktop,IconBrandAndroid,IconGridDots,IconCheck,IconSearch,IconX,IconApps,IconSettings,IconPackageImport,IconFocus2,IconBan} from '@tabler/icons-react';
|
||||
import {IconLayoutGrid,IconPuzzle,IconBrandSteam,IconStar,IconAdjustments,IconTrash,IconDeviceDesktop,IconBrandAndroid,IconGridDots,IconCheck,IconSearch,IconX,IconApps,IconSettings,IconPackageImport,IconFocus2,IconBan,IconDeviceDesktopShare} from '@tabler/icons-react';
|
||||
import {installedIcon} from './store';
|
||||
import {PluginFrame} from './plugin-frame';
|
||||
import type {Plugin} from './main';
|
||||
@@ -12,7 +12,7 @@ import {LauncherCalibration} from './launcher-calibration';
|
||||
import {matchesSearch,type SearchIndex} from './launcher-search';
|
||||
export type LauncherConfig={prioritizeUi?:boolean;primaryTrigger:boolean;menuAutoClose:boolean;menuTimeoutSeconds:number;allCategories?:string[]};
|
||||
const defaults:LauncherConfig={prioritizeUi:true,primaryTrigger:true,menuAutoClose:true,menuTimeoutSeconds:10,allCategories:['plugin','steam','lepton','desktop']};
|
||||
type SteamApp={id:number;name:string;icon?:string;iconFit?:string;kind?:string};
|
||||
type SteamApp={remote?:boolean;remoteClient?:string;id:number;name:string;icon?:string;iconFit?:string;kind?:string};
|
||||
function launcherCells(total:number){
|
||||
const counts=[Math.min(Math.max(total-5,0),4),Math.min(total,5),Math.max(total-9,0)];
|
||||
return counts.flatMap((count,row)=>Array.from({length:count},(_,col)=>({row,column:(6-(total<=5?count:row===1?5:4))*2+1+col*4})));
|
||||
@@ -32,7 +32,7 @@ export function LauncherSettings({config=defaults,refresh}:{config?:LauncherConf
|
||||
return <><section className="settings-card launcher-settings"><h2>{t('启动方式与菜单')}</h2><SwitchRow label={t('启动台资源优先')} description={t('启动台显示时提高界面 CPU 调度优先级,关闭后恢复。')} checked={config.prioritizeUi??true} disabled={busy} onChange={prioritizeUi=>void save({prioritizeUi})}/><p className="sub">{t('点击入口图标打开启动台;长按 600 毫秒打开快捷面板。')}</p><SwitchRow label={t('长按菜单自动关闭')} description={t('指针离开后开始计时,移入后取消。')} checked={config.menuAutoClose} disabled={busy} onChange={menuAutoClose=>void save({menuAutoClose})}/><label><span>{t('自动关闭时间(秒)')}</span><input type="number" min={1} max={300} value={seconds} disabled={busy||!config.menuAutoClose} onChange={e=>setSeconds(+e.target.value)} onBlur={()=>{if(seconds!==config.menuTimeoutSeconds&&seconds>=1&&seconds<=300)void save({menuTimeoutSeconds:seconds});}}/></label>{error&&<p role="alert">{error}</p>}</section><section className="settings-card launcher-settings"><h2>{t('全部包含的分类')}</h2><p className="sub">{t('只影响全部分类,其他分类和收藏仍可访问。')}</p>{categories.filter(c=>!['all','favorites'].includes(c.key)).map(c=><SwitchRow key={c.key} label={t(c.label)} description={c.key==='plugin'?t('包含 Framely'):undefined} checked={(config.allCategories??defaults.allCategories!).includes(c.key)} disabled={busy} onChange={checked=>{const selected=new Set(config.allCategories??defaults.allCategories);if(checked)selected.add(c.key);else selected.delete(c.key);void save({allCategories:[...selected]});}}/>)}</section><section className="settings-card launcher-settings"><h2>{t('眼追校准')}</h2><p className="sub">{t('在 Frame 内打开模拟启动台,依次注视高亮目标。')}</p><div className="actions"><button disabled={busy} onClick={()=>void api('host.launcher.calibrate',{active:true}).catch(e=>setError(String(e)))}>{t('开始校准')}</button><button disabled={busy} onClick={()=>{setBusy(true);void api('launcher.gaze.calibration.save',null).then(()=>{window.dispatchEvent(new Event('framely.gaze.calibration.changed'));return refresh();}).catch(e=>setError(String(e))).finally(()=>setBusy(false));}}>{t('清除校准')}</button></div></section></>;
|
||||
}
|
||||
type LocalApp={steamAppId?:number|null;id:string;name:string;kind:'desktop'|'lepton';icon?:string;launchUnavailable?:string|null};
|
||||
type LaunchItem={key:string;kind:string;id:string;name:string;icon?:string|null;fit?:string;favorite:boolean;launchUnavailable?:string|null};
|
||||
type LaunchItem={remote?:boolean;remoteClient?:string;key:string;kind:string;id:string;name:string;icon?:string|null;fit?:string;favorite:boolean;launchUnavailable?:string|null};
|
||||
const emptyOrder:string[]=[];
|
||||
const categories=[{key:'favorites',label:'收藏分类',icon:IconStar},{key:'all',label:'全部',icon:IconLayoutGrid},{key:'plugin',label:'插件',icon:IconPuzzle},{key:'steam',label:'Steam',icon:IconBrandSteam},{key:'lepton',label:'Lepton',icon:IconBrandAndroid},{key:'desktop',label:'桌面程序',icon:IconDeviceDesktop}];
|
||||
async function launcherRequest<T=any>(method:string,params:unknown={}):Promise<T>{
|
||||
@@ -48,6 +48,7 @@ export function Launcher({plugins,safeMode,config=defaults,favorites,localFavori
|
||||
const [calibrating,setCalibrating]=useState(location.hash==='#calibrate'),calibrationRef=useRef(calibrating);calibrationRef.current=calibrating;
|
||||
function endCalibration(){void api('host.launcher.calibrate',{active:false}).catch(e=>setError(String(e)));location.hash='';setCalibrating(false);}
|
||||
useEffect(()=>{const changed=()=>setCalibrating(location.hash==='#calibrate');window.addEventListener('hashchange',changed);return()=>window.removeEventListener('hashchange',changed);},[]);
|
||||
const[launchChoice,setLaunchChoice]=useState<LaunchItem|null>(null),choiceRef=useRef<LaunchItem|null>(null);choiceRef.current=launchChoice;
|
||||
const[steam,setSteam]=useState<SteamApp[]>([]),[local,setLocal]=useState<LocalApp[]>([]),[loading,setLoading]=useState(true),[error,setError]=useState(''),[filter,setFilter]=useState('all'),[page,setPage]=useState(0),[menu,setMenu]=useState<string|null>(null),[busyKeys,setBusyKeys]=useState<Set<string>>(new Set()),[launchingKeys,setLaunchingKeys]=useState<Set<string>>(new Set()),[uninstall,setUninstall]=useState<(Plugin&{dependents:string[];fingerprint:string})|null>(null),[frontends,setFrontends]=useState<Record<string,{plugin:string;action:string;context:any;ticket:number}>>({}),[outside,setOutside]=useState(false),[editing,setEditing]=useState(false),[manual,setManual]=useState(order),[dragging,setDragging]=useState<string|null>(null),[ghost,setGhost]=useState<{item:LaunchItem;x:number;y:number;left:number;right:number;top:number;bottom:number}|null>(null),[edge,setEdge]=useState(0),[panView,setPanView]=useState<{offset:number;direction:number;width:number;settling:boolean}|null>(null);
|
||||
const panTimer=useRef<ReturnType<typeof setTimeout>|null>(null),skipPageAnimation=useRef(false),grid=useRef<HTMLDivElement>(null),pageContent=useRef<HTMLDivElement>(null),swipe=useRef<{x:number;y:number;active?:boolean;dx?:number}|null>(null),shell=useRef<HTMLDivElement>(null),hold=useRef<ReturnType<typeof setTimeout>|null>(null),editHold=useRef<ReturnType<typeof setTimeout>|null>(null),edgeTimer=useRef<ReturnType<typeof setTimeout>|null>(null),long=useRef(false),point=useRef({x:0,y:0}),holdTarget=useRef<string|null>(null),holdVisual=useRef<SVGSVGElement|null>(null),mounted=useRef(true),operations=useRef(new Map<string,{started:number;expire:()=>void}>()),actionSequence=useRef(0),menuRef=useRef<HTMLDivElement>(null),drag=useRef<{key:string;before:string[];order:string[];moved:boolean;over:string|null;edge:number;lastX:number;lastY:number;startedInEdit:boolean}|null>(null),state=useRef({editing:false,current:0,pages:1,filter:'all',panning:false,items:[] as LaunchItem[],all:[] as LaunchItem[]});
|
||||
useEffect(()=>{if(!drag.current)setManual(order);},[order]);
|
||||
@@ -56,19 +57,20 @@ export function Launcher({plugins,safeMode,config=defaults,favorites,localFavori
|
||||
function cancelHold(){holdVisual.current?.remove();holdVisual.current=null;if(hold.current)clearTimeout(hold.current);if(editHold.current)clearTimeout(editHold.current);hold.current=null;editHold.current=null;holdTarget.current=null;}
|
||||
function stopEdge(){if(edgeTimer.current)clearTimeout(edgeTimer.current);edgeTimer.current=null;setEdge(0);if(drag.current)drag.current.edge=0;}
|
||||
function cancelDrag(){if(drag.current)setManual(drag.current.before);drag.current=null;setDragging(null);setGhost(null);stopEdge();}
|
||||
useEffect(()=>{mounted.current=true;let live=true,sequence=0;const load=()=>{for(const operation of operations.current.values())if(Date.now()-operation.started>=120000)operation.expire();cancelHold();cancelDrag();cancelPan();setEditing(false);long.current=false;const current=++sequence;opening.current=remembered.current===null;setQuery('');setPage(0);setLoading(true);setMenu(null);setOutside(false);void Promise.allSettled([launcherRequest<SteamApp[]>('steam.list'),launcherRequest<LocalApp[]>('desktop.list')]).then(results=>{if(!live||current!==sequence)return;const [steamResult,localResult]=results;if(steamResult.status==='fulfilled')setSteam(steamResult.value);else setError(String(steamResult.reason));if(localResult.status==='fulfilled')setLocal(localResult.value);else setError(String(localResult.reason));if(opening.current){const known=props.current;const hasFavorite=Object.values(known.plugins).some(p=>p.enabled&&p.favorite)||(steamResult.status==='fulfilled'&&steamResult.value.some(a=>known.favorites.includes(a.id)))||known.localFavorites.includes('framely')||(localResult.status==='fulfilled'&&localResult.value.some(a=>known.localFavorites.includes(`${a.kind}:${a.id}`)));selectCategory(hasFavorite?'favorites':'all');}setLoading(false);});};load();const cancel=()=>{cancelPan();cancelHold();cancelDrag();long.current=true;setMenu(null);setEditing(false);};window.addEventListener('framely.launcher.cancelInput',cancel);window.addEventListener('framely.launcher.open',load);return()=>{live=false;mounted.current=false;for(const operation of operations.current.values())operation.expire();for(const key of pending.current.keys())done(key,'Launcher closed');window.removeEventListener('framely.launcher.open',load);window.removeEventListener('framely.launcher.cancelInput',cancel);cancelHold();if(edgeTimer.current)clearTimeout(edgeTimer.current);if(panTimer.current)clearTimeout(panTimer.current);};},[]);
|
||||
useEffect(()=>{mounted.current=true;let live=true,sequence=0;const load=()=>{for(const operation of operations.current.values())if(Date.now()-operation.started>=120000)operation.expire();cancelHold();cancelDrag();cancelPan();setEditing(false);setLaunchChoice(null);long.current=false;const current=++sequence;opening.current=remembered.current===null;setQuery('');setPage(0);setLoading(true);setMenu(null);setOutside(false);void Promise.allSettled([launcherRequest<SteamApp[]>('steam.list'),launcherRequest<LocalApp[]>('desktop.list')]).then(results=>{if(!live||current!==sequence)return;const [steamResult,localResult]=results;if(steamResult.status==='fulfilled')setSteam(steamResult.value);else setError(String(steamResult.reason));if(localResult.status==='fulfilled')setLocal(localResult.value);else setError(String(localResult.reason));if(opening.current){const known=props.current;const hasFavorite=Object.values(known.plugins).some(p=>p.enabled&&p.favorite)||(steamResult.status==='fulfilled'&&steamResult.value.some(a=>known.favorites.includes(a.id)))||known.localFavorites.includes('framely')||(localResult.status==='fulfilled'&&localResult.value.some(a=>known.localFavorites.includes(`${a.kind}:${a.id}`)));selectCategory(hasFavorite?'favorites':'all');}setLoading(false);});};load();const cancel=()=>{cancelPan();cancelHold();cancelDrag();long.current=true;setMenu(null);setLaunchChoice(null);setEditing(false);};window.addEventListener('framely.launcher.cancelInput',cancel);window.addEventListener('framely.launcher.open',load);return()=>{live=false;mounted.current=false;for(const operation of operations.current.values())operation.expire();for(const key of pending.current.keys())done(key,'Launcher closed');window.removeEventListener('framely.launcher.open',load);window.removeEventListener('framely.launcher.cancelInput',cancel);cancelHold();if(edgeTimer.current)clearTimeout(edgeTimer.current);if(panTimer.current)clearTimeout(panTimer.current);};},[]);
|
||||
useEffect(()=>{let enabled=true,pending=false,generation=0,signature:string|null=null;const timer=setInterval(()=>{if(!enabled||pending)return;pending=true;const epoch=generation;void launcherRequest<unknown[]>('steam.list',{remoteOnly:true}).then(async rows=>{const next=JSON.stringify(rows);if(!enabled||epoch!==generation||next===signature)return;const apps=await launcherRequest<SteamApp[]>('steam.list');if(enabled&&epoch===generation){signature=next;setSteam(apps);}}).catch(()=>{}).finally(()=>{pending=false;});},4000);const open=()=>{generation++;enabled=true;signature=null;};const close=()=>{generation++;enabled=false;};window.addEventListener('framely.launcher.open',open);window.addEventListener('framely.launcher.closing',close);window.addEventListener('framely.launcher.cancelInput',close);return()=>{enabled=false;clearInterval(timer);window.removeEventListener('framely.launcher.open',open);window.removeEventListener('framely.launcher.closing',close);window.removeEventListener('framely.launcher.cancelInput',close);};},[]);
|
||||
useEffect(()=>{if(!drag.current)cancelHold();setMenu(null);setOutside(false);},[filter,page]);
|
||||
useEffect(()=>{if(!menu||!outside||!config.menuAutoClose||!!(menu&&busyKeys.has(menu)))return;const timer=setTimeout(()=>setMenu(null),config.menuTimeoutSeconds*1000);return()=>clearTimeout(timer);},[menu,outside,busyKeys,config.menuAutoClose,config.menuTimeoutSeconds]);
|
||||
function cancelPan(){if(panTimer.current)clearTimeout(panTimer.current);panTimer.current=null;swipe.current=null;setPanView(null);}
|
||||
function close(){if(calibrationRef.current){endCalibration();return;}if(keyboard.current)return;cancelHold();cancelDrag();cancelPan();setMenu(null);if(state.current.editing){setEditing(false);return;}void launcherRequest('host.launcher.close');}
|
||||
function close(){if(choiceRef.current){setLaunchChoice(null);return;}if(calibrationRef.current){endCalibration();return;}if(keyboard.current)return;cancelHold();cancelDrag();cancelPan();setMenu(null);if(state.current.editing){setEditing(false);return;}void launcherRequest('host.launcher.close');}
|
||||
function settleSwipe(gesture:{dx?:number;active?:boolean}){if(!gesture.active)return;const s=state.current,width=grid.current?.clientWidth??1300,dx=gesture.dx??0,direction=dx<0?1:-1,next=s.current+direction,valid=next>=0&&next<s.pages,advance=valid&&Math.abs(dx)>=70;setPanView({offset:advance?-direction*width:0,direction,width,settling:true});long.current=true;panTimer.current=setTimeout(()=>{panTimer.current=null;if(advance){skipPageAnimation.current=true;setPage(next);}setPanView(null);},240);}
|
||||
|
||||
useEffect(()=>{const back=()=>close();const key=(e:KeyboardEvent)=>{if(e.key==='Escape')close();};window.addEventListener('framely.back',back);window.addEventListener('keydown',key);const navigate=(e:Event)=>{if(calibrationRef.current||keyboard.current||state.current.panning)return;const direction=(e as CustomEvent<number>).detail;const s=state.current;if(drag.current){if(Math.abs(direction)===1){stopEdge();moveDraggedPage(direction);}return;}setMenu(null);if(Math.abs(direction)===1)setPage(Math.max(0,Math.min(s.pages-1,s.current+direction)));else if(Math.abs(direction)===2){const index=categories.findIndex(c=>c.key===s.filter);selectCategory(categories[Math.max(0,Math.min(categories.length-1,index+(direction>0?-1:1)))].key);setPage(0);}};window.addEventListener('framely.launcher.navigate',navigate);return()=>{window.removeEventListener('framely.back',back);window.removeEventListener('keydown',key);window.removeEventListener('framely.launcher.navigate',navigate);};},[]);
|
||||
useEffect(()=>{const back=()=>close();const key=(e:KeyboardEvent)=>{if(e.key==='Escape')close();};window.addEventListener('framely.back',back);window.addEventListener('keydown',key);const navigate=(e:Event)=>{if(choiceRef.current||calibrationRef.current||keyboard.current||state.current.panning)return;const direction=(e as CustomEvent<number>).detail;const s=state.current;if(drag.current){if(Math.abs(direction)===1){stopEdge();moveDraggedPage(direction);}return;}setMenu(null);if(Math.abs(direction)===1)setPage(Math.max(0,Math.min(s.pages-1,s.current+direction)));else if(Math.abs(direction)===2){const index=categories.findIndex(c=>c.key===s.filter);selectCategory(categories[Math.max(0,Math.min(categories.length-1,index+(direction>0?-1:1)))].key);setPage(0);}};window.addEventListener('framely.launcher.navigate',navigate);return()=>{window.removeEventListener('framely.back',back);window.removeEventListener('keydown',key);window.removeEventListener('framely.launcher.navigate',navigate);};},[]);
|
||||
useEffect(()=>{const preference=window.matchMedia('(prefers-reduced-motion: reduce)'),report=()=>console.log(`FRAMELY_LAUNCHER_REDUCED_MOTION=${preference.matches?1:0}`);report();preference.addEventListener('change',report);return()=>preference.removeEventListener('change',report);},[]);
|
||||
// The native compositor owns surface fades; animating this high-resolution
|
||||
// page too would multiply alpha and force a full CEF readback every frame.
|
||||
useEffect(()=>{const open=()=>{if(shell.current)shell.current.style.pointerEvents='';};const closing=()=>{if(calibrationRef.current){location.hash='';setCalibrating(false);}if(shell.current)shell.current.style.pointerEvents='none';};window.addEventListener('framely.launcher.open',open);window.addEventListener('framely.launcher.closing',closing);return()=>{window.removeEventListener('framely.launcher.open',open);window.removeEventListener('framely.launcher.closing',closing);};},[]);
|
||||
const all=useMemo(()=>{const entries:LaunchItem[]=[{key:'framely',kind:'framely',id:'framely',name:'Framely',icon:'/assets/branding/framely-app-icon.svg',fit:'contain',favorite:localFavorites.includes('framely')},...Object.values(plugins).filter(p=>p.enabled).map(p=>({key:`plugin:${p.manifest.id}`,kind:'plugin',id:p.manifest.id,name:p.manifest.name,icon:installedIcon(p),fit:'contain',favorite:p.favorite})),...steam.filter(a=>!local.some(l=>l.steamAppId===a.id)).map(a=>({key:`steam:${a.id}`,kind:a.kind??'steam',id:String(a.id),name:a.name,icon:a.icon,fit:a.iconFit,favorite:favorites.includes(a.id)})),...local.map(a=>({key:`${a.kind}:${a.id}`,kind:a.kind,id:a.id,name:a.name,icon:a.icon,fit:'contain',favorite:localFavorites.includes(`${a.kind}:${a.id}`),launchUnavailable:a.launchUnavailable}))];
|
||||
const all=useMemo(()=>{const entries:LaunchItem[]=[{key:'framely',kind:'framely',id:'framely',name:'Framely',icon:'/assets/branding/framely-app-icon.svg',fit:'contain',favorite:localFavorites.includes('framely')},...Object.values(plugins).filter(p=>p.enabled).map(p=>({key:`plugin:${p.manifest.id}`,kind:'plugin',id:p.manifest.id,name:p.manifest.name,icon:installedIcon(p),fit:'contain',favorite:p.favorite})),...steam.filter(a=>!local.some(l=>l.steamAppId===a.id)).map(a=>({key:`steam:${a.id}`,kind:a.kind??'steam',id:String(a.id),name:a.name,icon:a.icon,fit:a.iconFit,remote:a.remote,remoteClient:a.remoteClient,favorite:favorites.includes(a.id)})),...local.map(a=>({key:`${a.kind}:${a.id}`,kind:a.kind,id:a.id,name:a.name,icon:a.icon,fit:'contain',favorite:localFavorites.includes(`${a.kind}:${a.id}`),launchUnavailable:a.launchUnavailable}))];
|
||||
const rank=new Map(manual.map((key,index)=>[key,index]));entries.sort((a,b)=>(rank.get(a.key)??Infinity)-(rank.get(b.key)??Infinity)||a.name.localeCompare(b.name)||a.key.localeCompare(b.key));return entries;},[plugins,steam,local,favorites,localFavorites,manual]);
|
||||
const indexKey=useMemo(()=>JSON.stringify(all.map(a=>a.name).sort()),[all]);
|
||||
useEffect(()=>{let live=true;const names=Array.from(new Set(all.map(a=>a.name)));if(names.length)void launcherRequest<Record<string,SearchIndex>>('launcher.search.index',{names}).then(v=>{if(live)setIndexes(v);}).catch(()=>{if(live)setError(t('读音搜索暂不可用,仍可使用原文搜索。'));});return()=>{live=false;};},[indexKey]);
|
||||
@@ -105,7 +107,9 @@ export function Launcher({plugins,safeMode,config=defaults,favorites,localFavori
|
||||
}else if(swipe.current){const gesture=swipe.current,dx=e.clientX-gesture.x,dy=e.clientY-gesture.y;if(gesture.active||(Math.abs(dx)>36&&Math.abs(dx)>Math.abs(dy)*1.25)){gesture.active=true;gesture.dx=dx;cancelHold();setMenu(null);long.current=true;pageAnimation.current?.cancel();const width=grid.current?.clientWidth??1300,direction=dx<0?1:-1,next=state.current.current+direction,valid=next>=0&&next<state.current.pages;setPanView({offset:valid?Math.max(-width,Math.min(width,dx)):dx*.22,direction,width,settling:false});}else if(holdTarget.current&&Math.hypot(dx,dy)>36){cancelHold();long.current=true;}}};
|
||||
const up=(e:PointerEvent)=>{if(swipe.current){const gesture=swipe.current;swipe.current=null;settleSwipe(gesture);}cancelHold();stopEdge();const d=drag.current;drag.current=null;setDragging(null);setGhost(null);if(d){long.current=true;if(d.moved)void run('order',async()=>{try{await launcherRequest('launcher.order.save',{order:d.order});}catch(e){setManual(d.before);throw e;}});}};
|
||||
const cancel=()=>{cancelPan();cancelHold();cancelDrag();long.current=true;};window.addEventListener('pointermove',move);window.addEventListener('pointerup',up);window.addEventListener('pointercancel',cancel);return()=>{window.removeEventListener('pointermove',move);window.removeEventListener('pointerup',up);window.removeEventListener('pointercancel',cancel);};},[]);
|
||||
function launch(a:LaunchItem){const launcherSession=(window as Window & {framelyLauncherSession?:number}).framelyLauncherSession;if(a.launchUnavailable)return Promise.reject(new Error(t(a.launchUnavailable)));return a.kind==='framely'?launcherRequest('host.manager.open'):a.kind==='plugin'?launcherRequest('plugin.launch',{plugin:a.id,source:'launcher'}):a.key.startsWith('steam:')?launcherRequest('steam.launch',{app:+a.id,launcherSession}):launcherRequest('desktop.launch',{app:a.key,launcherSession});}
|
||||
function openItem(a:LaunchItem){setMenu(null);if(a.key.startsWith('steam:')&&!a.remote&&a.remoteClient){setLaunchChoice(a);return;}void run(a.key,()=>launch(a),true);}
|
||||
function chooseLaunch(remote:boolean){if(!launchChoice)return;const item={...launchChoice,remoteClient:remote?launchChoice.remoteClient:undefined};setLaunchChoice(null);void run(item.key,()=>launch(item),true);}
|
||||
function launch(a:LaunchItem){const launcherSession=(window as Window & {framelyLauncherSession?:number}).framelyLauncherSession;if(a.launchUnavailable)return Promise.reject(new Error(t(a.launchUnavailable)));return a.kind==='framely'?launcherRequest('host.manager.open'):a.kind==='plugin'?launcherRequest('plugin.launch',{plugin:a.id,source:'launcher'}):a.key.startsWith('steam:')?launcherRequest('steam.launch',{app:+a.id,remoteClient:a.remoteClient,launcherSession}):launcherRequest('desktop.launch',{app:a.key,launcherSession});}
|
||||
async function custom(id:string,action:string){const key=`plugin:${id}`;await run(key,async active=>{const v=await launcherRequest('plugin.launcher.action',{plugin:id,action});if(!active())return;if(v.frontend){await new Promise<void>((resolve,reject)=>{const ticket=++actionSequence.current;const timer=setTimeout(()=>done(key,'Launcher action timed out',ticket),90000);pending.current.set(key,{resolve,reject,timer,ticket});setFrontends(items=>({...items,[key]:{plugin:id,action,context:v.context,ticket}}));});}if(active()&&v.opened)setMenu(current=>current===key?null:current);});}
|
||||
const pending=useRef(new Map<string,{resolve:()=>void;reject:(e:Error)=>void;timer:ReturnType<typeof setTimeout>;ticket:number}>());
|
||||
function done(key:string,error?:string,ticket?:number){const p=pending.current.get(key);if(!p||(ticket!==undefined&&p.ticket!==ticket))return;clearTimeout(p.timer);pending.current.delete(key);if(mounted.current)setFrontends(items=>{const next={...items};delete next[key];return next;});if(error)p.reject(new Error(error));else p.resolve();}
|
||||
@@ -113,16 +117,16 @@ export function Launcher({plugins,safeMode,config=defaults,favorites,localFavori
|
||||
return <div ref={shell} onPointerDown={e=>{if(e.button===0&&!(e.target as HTMLElement).closest('button,input,.launcher-search,.launch-menu,.modal,.launcher-error')){if(!keyboard.current&&!state.current.panning)swipe.current={x:e.clientX,y:e.clientY};long.current=false;try{e.currentTarget.setPointerCapture(e.pointerId);}catch{}}}} className={`launcher-shell ${editing?'launcher-editing':''} ${loading&&opening.current?'launcher-initializing':''}`} onClick={e=>{if(long.current){long.current=false;return;}if(!(e.target as HTMLElement).closest('button,input,.launcher-search,.launch-menu,.modal,.launcher-error'))setMenu(null);}}>
|
||||
<div className="launcher-search"><IconSearch size={22} stroke={1.65}/><input type="search" aria-label={t('搜索应用')} placeholder={t('搜索应用')} value={query} disabled={editing} onChange={e=>{cancelPan();cancelHold();setMenu(null);setQuery(e.target.value);setPage(0);}}/>{query&&<button aria-label={t('清除搜索')} onClick={()=>{setQuery('');setPage(0);}}><IconX size={20}/></button>}</div>
|
||||
<nav className="launcher-tools launcher-filters" aria-label={t('启动台')}><span className="launcher-category-active" aria-hidden="true" style={{transform:`translateY(${Math.max(0,categories.findIndex(c=>c.key===filter))*61}px)`}}/>{categories.map(({key,label,icon:CategoryIcon})=><button key={key} aria-label={t(label)} title={t(label)} aria-pressed={filter===key} disabled={!!dragging||!!panView} className={filter===key?'active':''} onClick={()=>{if(editing){close();return;}selectCategory(key);setPage(0);}}><CategoryIcon size={26} stroke={1.65}/><span className="launcher-category-label">{t(label)}</span></button>)}</nav>
|
||||
<nav className="launcher-tools launcher-shortcuts" aria-label={t('快捷操作')}>{[{page:'apk',label:'安装APK',icon:IconPackageImport},{page:'catalog',label:'插件库',icon:IconApps},{page:'calibration',label:'眼追校准',icon:IconFocus2,IconBan},{page:'settings',label:'设置',icon:IconSettings}].map(({page,label,icon:ActionIcon})=><button key={page} aria-label={t(label)} title={t(label)} disabled={busyKeys.has('framely')||editing||!!dragging||!!panView} onClick={()=>{cancelHold();setMenu(null);void run('framely',()=>page==='calibration'?launcherRequest('host.launcher.calibrate',{active:true}):launcherRequest('host.manager.open',{page}));}}><ActionIcon size={26} stroke={1.65}/><span className="launcher-category-label">{t(label)}</span></button>)}</nav>
|
||||
<nav className="launcher-tools launcher-shortcuts" aria-label={t('快捷操作')}>{[{page:'apk',label:'安装APK',icon:IconPackageImport},{page:'catalog',label:'插件库',icon:IconApps},{page:'calibration',label:'眼追校准',icon:IconFocus2,IconBan,IconDeviceDesktopShare},{page:'settings',label:'设置',icon:IconSettings}].map(({page,label,icon:ActionIcon})=><button key={page} aria-label={t(label)} title={t(label)} disabled={busyKeys.has('framely')||editing||!!dragging||!!panView} onClick={()=>{cancelHold();setMenu(null);void run('framely',()=>page==='calibration'?launcherRequest('host.launcher.calibrate',{active:true}):launcherRequest('host.manager.open',{page}));}}><ActionIcon size={26} stroke={1.65}/><span className="launcher-category-label">{t(label)}</span></button>)}</nav>
|
||||
{editing&&<div className="launcher-edit-toolbar"><span><IconGridDots size={22}/>{t('拖动图标排列,移到两侧翻页')}</span><button disabled={busyKeys.has('order')||!!dragging} onClick={()=>setEditing(false)}><IconCheck size={22}/>{t('完成')}</button></div>}
|
||||
{safeMode&&<p className="banner">{t('安全模式:第三方插件暂停运行。')}</p>}{error&&<div className="launcher-error" role="alert">{error}<button onClick={()=>setError('')}>{t('关闭')}</button></div>}
|
||||
<div ref={grid} className={`launcher-grid ${panView?'launcher-panning':''}`}><div ref={pageContent} className="launcher-page" style={panView?{translate:`${panView.offset}px 0`,transition:panView.settling?'translate 240ms cubic-bezier(.2,.75,.2,1)':'none'}:undefined}>{visible.map((a,index)=><div key={a.key} data-launch-key={a.key} style={{gridColumn:`${cells[index].column} / span 4`,gridRow:`${1+cells[index].row*2} / span 2`}} className={`launch-item ${menu===a.key?'launch-item-expanded':''} ${dragging===a.key?'launch-item-dragged':''}`} onPointerEnter={()=>{if(menu===a.key)setOutside(false);}} onPointerLeave={()=>{if(menu===a.key)setOutside(true);}}>
|
||||
<button className={`launch-icon ${a.launchUnavailable?'launch-unavailable':''}`} title={a.launchUnavailable?t(a.launchUnavailable):undefined} aria-disabled={!!a.launchUnavailable&&!editing} aria-busy={busyKeys.has(a.key)} disabled={busyKeys.has(a.key)||(editing&&busyKeys.has('order'))||(a.kind==='plugin'&&safeMode&&!editing)} aria-label={a.name} aria-haspopup="menu" aria-expanded={menu===a.key} onPointerDown={e=>down(e,a)} onClick={()=>{if(long.current){long.current=false;return;}if(editing)return;if(a.launchUnavailable){setError(t(a.launchUnavailable));return;}setMenu(null);void run(a.key,()=>launch(a),true);}} onContextMenu={e=>{e.preventDefault();setMenu(a.key);setOutside(false);}}><LauncherArtwork src={a.icon} name={a.name} kind={a.kind} fit={a.fit}/>{a.launchUnavailable&&<span className="launch-unavailable-mark" aria-hidden="true"><IconBan size={23}/></span>}{launchingKeys.has(a.key)&&<span className="launch-starting" role="status" aria-label={t("正在启动")}><span className="launch-spinner" aria-hidden="true"/></span>}</button><span className="launch-name" title={a.name}>{a.name}</span>
|
||||
<button className={`launch-icon ${a.launchUnavailable?'launch-unavailable':''}`} title={a.launchUnavailable?t(a.launchUnavailable):undefined} aria-disabled={!!a.launchUnavailable&&!editing} aria-busy={busyKeys.has(a.key)} disabled={busyKeys.has(a.key)||(editing&&busyKeys.has('order'))||(a.kind==='plugin'&&safeMode&&!editing)} aria-label={a.name} aria-haspopup="menu" aria-expanded={menu===a.key} onPointerDown={e=>down(e,a)} onClick={()=>{if(long.current){long.current=false;return;}if(editing)return;if(a.launchUnavailable){setError(t(a.launchUnavailable));return;}openItem(a);}} onContextMenu={e=>{e.preventDefault();setMenu(a.key);setOutside(false);}}><LauncherArtwork src={a.icon} name={a.name} kind={a.kind} fit={a.fit}/>{a.remoteClient&&<span className="launch-remote-mark" title={t("远程畅玩")} aria-label={t("远程畅玩")}><IconDeviceDesktopShare size={19} stroke={1.7}/></span>}{a.launchUnavailable&&<span className="launch-unavailable-mark" aria-hidden="true"><IconBan size={23}/></span>}{launchingKeys.has(a.key)&&<span className="launch-starting" role="status" aria-label={t("正在启动")}><span className="launch-spinner" aria-hidden="true"/></span>}</button><span className="launch-name" title={a.name}>{a.name}</span>
|
||||
{!editing&&<button className={`launch-favorite ${a.favorite?'selected':''}`} aria-label={t(a.favorite?'取消收藏':'收藏')} aria-pressed={a.favorite} title={t(a.favorite?'取消收藏':'收藏')} disabled={busyKeys.has(a.key)} onClick={()=>void run(a.key,()=>launcherRequest('launcher.favorite',{key:a.key,favorite:!a.favorite}))}><IconStar size={16} stroke={1.7} fill={a.favorite?'currentColor':'none'}/></button>}
|
||||
{menu===a.key&&<div ref={menuRef} role="menu" className={`launch-menu ${cells[index].column>=(columns-2)*4?'align-right':''} ${cells[index].row===2?'above':''}`} onPointerEnter={()=>setOutside(false)} onPointerLeave={()=>setOutside(true)}>
|
||||
{selectedPlugin?<>{(selectedPlugin.manifest.ui.launcherActions??[]).map(action=><button role="menuitem" key={action.id} disabled={busyKeys.has(a.key)||safeMode} onClick={()=>void custom(a.id,action.id)}>{action.label}</button>)}{!!selectedPlugin.manifest.ui.launcherActions?.length&&<hr/>}<button role="menuitem" disabled={busyKeys.has(a.key)||safeMode||!selectedPlugin.manifest.ui.quickPage} title={!selectedPlugin.manifest.ui.quickPage?t('插件没有快捷页面'):undefined} onClick={()=>void run(a.key,()=>launcherRequest('plugin.launch',{plugin:a.id,source:'launcher',quickPage:true}))}><IconLayoutGrid size={22} stroke={1.7}/>{t('快捷面板')}</button><button role="menuitem" disabled={busyKeys.has(a.key)} onClick={()=>void run(a.key,()=>launcherRequest('host.plugin.manage',{plugin:a.id}))}><IconAdjustments size={22} stroke={1.7}/>{t('管理插件')}</button><button role="menuitem" disabled={busyKeys.has(a.key)} className="danger" onClick={()=>void run(a.key,async()=>{const plan=await launcherRequest('plugin.disable.preview',{plugin:a.id});setUninstall({...selectedPlugin,dependents:plan.dependents,fingerprint:plan.fingerprint});})}><IconTrash size={22} stroke={1.7}/>{t('卸载插件')}</button></>:<><button role="menuitem" title={a.launchUnavailable?t(a.launchUnavailable):undefined} disabled={busyKeys.has(a.key)||!!a.launchUnavailable} onClick={()=>void run(a.key,()=>launch(a),true)}><IconLayoutGrid size={22}/>{t('打开')}</button>{a.kind==='lepton'&&!a.key.startsWith('steam:')&&<><button role="menuitem" disabled={busyKeys.has(a.key)} onClick={()=>void run(a.key,()=>launcherRequest('host.manager.open',{page:'apk'}).then(()=>launcherRequest('apk.selection',{app:a.id})))}><IconAdjustments size={22}/>{t('管理应用')}</button><button role="menuitem" className="danger" disabled={busyKeys.has(a.key)} onClick={()=>void run(a.key,()=>launcherRequest('host.manager.open',{page:'apk'}).then(()=>launcherRequest('apk.selection',{app:a.id,uninstall:true})))}><IconTrash size={22}/>{t('卸载应用')}</button></>}<button role="menuitem" disabled={busyKeys.has(a.key)} onClick={()=>{setMenu(null);setEditing(true);}}><IconGridDots size={22}/>{t('整理图标')}</button></>}
|
||||
{a.launchUnavailable&&<small role="status">{t(a.launchUnavailable)}</small>}{busyKeys.has(a.key)&&<small role="status">{t('正在执行…')}</small>}</div>}</div>)}</div>{panView&¤t+panView.direction>=0&¤t+panView.direction<pages&&<div className="launcher-adjacent-page" aria-hidden="true" style={{translate:`${panView.offset+panView.direction*panView.width}px 0`,transition:panView.settling?'translate 240ms cubic-bezier(.2,.75,.2,1)':'none'}}>{items.slice((current+panView.direction)*pageSize,(current+panView.direction+1)*pageSize).map((a,index)=>{const count=Math.min(pageSize,items.length-(current+panView.direction)*pageSize),{row,column:slot}=launcherCells(count)[index];return <div key={a.key} className="launch-item" style={{gridColumn:`${slot} / span 4`,gridRow:`${1+row*2} / span 2`}}><span className="launch-icon"><LauncherArtwork src={a.icon} name={a.name} kind={a.kind} fit={a.fit}/></span><span className="launch-name">{a.name}</span></div>;})}</div>}</div>
|
||||
{selectedPlugin?<>{(selectedPlugin.manifest.ui.launcherActions??[]).map(action=><button role="menuitem" key={action.id} disabled={busyKeys.has(a.key)||safeMode} onClick={()=>void custom(a.id,action.id)}>{action.label}</button>)}{!!selectedPlugin.manifest.ui.launcherActions?.length&&<hr/>}<button role="menuitem" disabled={busyKeys.has(a.key)||safeMode||!selectedPlugin.manifest.ui.quickPage} title={!selectedPlugin.manifest.ui.quickPage?t('插件没有快捷页面'):undefined} onClick={()=>void run(a.key,()=>launcherRequest('plugin.launch',{plugin:a.id,source:'launcher',quickPage:true}))}><IconLayoutGrid size={22} stroke={1.7}/>{t('快捷面板')}</button><button role="menuitem" disabled={busyKeys.has(a.key)} onClick={()=>void run(a.key,()=>launcherRequest('host.plugin.manage',{plugin:a.id}))}><IconAdjustments size={22} stroke={1.7}/>{t('管理插件')}</button><button role="menuitem" disabled={busyKeys.has(a.key)} className="danger" onClick={()=>void run(a.key,async()=>{const plan=await launcherRequest('plugin.disable.preview',{plugin:a.id});setUninstall({...selectedPlugin,dependents:plan.dependents,fingerprint:plan.fingerprint});})}><IconTrash size={22} stroke={1.7}/>{t('卸载插件')}</button></>:<><button role="menuitem" title={a.launchUnavailable?t(a.launchUnavailable):undefined} disabled={busyKeys.has(a.key)||!!a.launchUnavailable} onClick={()=>openItem(a)}><IconLayoutGrid size={22}/>{t('打开')}</button>{a.kind==='lepton'&&!a.key.startsWith('steam:')&&<><button role="menuitem" disabled={busyKeys.has(a.key)} onClick={()=>void run(a.key,()=>launcherRequest('host.manager.open',{page:'apk'}).then(()=>launcherRequest('apk.selection',{app:a.id})))}><IconAdjustments size={22}/>{t('管理应用')}</button><button role="menuitem" className="danger" disabled={busyKeys.has(a.key)} onClick={()=>void run(a.key,()=>launcherRequest('host.manager.open',{page:'apk'}).then(()=>launcherRequest('apk.selection',{app:a.id,uninstall:true})))}><IconTrash size={22}/>{t('卸载应用')}</button></>}<button role="menuitem" disabled={busyKeys.has(a.key)} onClick={()=>{setMenu(null);setEditing(true);}}><IconGridDots size={22}/>{t('整理图标')}</button></>}
|
||||
{a.launchUnavailable&&<small role="status">{t(a.launchUnavailable)}</small>}{busyKeys.has(a.key)&&<small role="status">{t('正在执行…')}</small>}</div>}</div>)}</div>{panView&¤t+panView.direction>=0&¤t+panView.direction<pages&&<div className="launcher-adjacent-page" aria-hidden="true" style={{translate:`${panView.offset+panView.direction*panView.width}px 0`,transition:panView.settling?'translate 240ms cubic-bezier(.2,.75,.2,1)':'none'}}>{items.slice((current+panView.direction)*pageSize,(current+panView.direction+1)*pageSize).map((a,index)=>{const count=Math.min(pageSize,items.length-(current+panView.direction)*pageSize),{row,column:slot}=launcherCells(count)[index];return <div key={a.key} className="launch-item" style={{gridColumn:`${slot} / span 4`,gridRow:`${1+row*2} / span 2`}}><span className="launch-icon"><LauncherArtwork src={a.icon} name={a.name} kind={a.kind} fit={a.fit}/>{a.remoteClient&&<span className="launch-remote-mark"><IconDeviceDesktopShare size={19} stroke={1.7}/></span>}</span><span className="launch-name">{a.name}</span></div>;})}</div>}</div>
|
||||
{ghost&&<div className="launcher-drag-ghost" style={{left:ghost.x,top:ghost.y}}><LauncherArtwork src={ghost.item.icon} name={ghost.item.name} kind={ghost.item.kind} fit={ghost.item.fit}/></div>}{ghost&&[-1,1].map(direction=><div key={direction} className={`launcher-drag-edge ${direction<0?'left':'right'} ${edge===direction?'active':''}`} style={{left:(direction<0?ghost.left:ghost.right)-4,top:ghost.top,height:ghost.bottom-ghost.top}} aria-hidden="true"/>)}
|
||||
{!loading&&!items.length&&<p className="launcher-empty">{t(query?'没有匹配的应用':filter==='favorites'?'还没有收藏的应用':'没有可启动的应用')}</p>}<nav className="launcher-pages" aria-label={t('分页')}>{Array.from({length:pages},(_,n)=><button key={n} aria-label={t('第 {0} 页',{0:n+1})} aria-current={current===n?'page':undefined} disabled={!!dragging||!!panView} onClick={()=>setPage(n)}><span/></button>)}</nav>{loading&&<span className="launcher-loading" role="status">{t('正在读取本机应用…')}</span>}
|
||||
{uninstall&&<div className="modal-backdrop"><div className="modal" role="dialog" aria-modal="true"><h2>{t('卸载 {0}?',{0:uninstall.manifest.name})}</h2><p>{t('依赖此插件的应用也会停用;插件数据会保留。')}</p>{!!uninstall.dependents.length&&<p>{t('停用:')}{uninstall.dependents.map(id=>plugins[id]?.manifest.name??id).join(', ')}</p>}<button disabled={busyKeys.has(`plugin:${uninstall.manifest.id}`)} onClick={()=>setUninstall(null)}>{t('取消')}</button><button disabled={busyKeys.has(`plugin:${uninstall.manifest.id}`)} className="danger" onClick={()=>void run(`plugin:${uninstall.manifest.id}`,async()=>{await launcherRequest('plugin.uninstall',{plugin:uninstall.manifest.id,purge:false,approveDependents:true,fingerprint:uninstall.fingerprint});setUninstall(null);setMenu(null);})}>{t('确认卸载')}</button></div></div>}{Object.entries(frontends).map(([key,frontend])=><div key={`${key}:${frontend.ticket}`} className="launcher-action-frame" aria-hidden="true"><PluginFrame plugin={frontend.plugin} entry={`action-${frontend.action}`} actionContext={frontend.context} onActionDone={error=>done(key,error,frontend.ticket)}/></div>)}</div>;
|
||||
{launchChoice&&<div className="modal-backdrop" onPointerDown={e=>e.stopPropagation()}><div className="modal launch-choice" role="dialog" aria-modal="true" aria-labelledby="launch-choice-title"><h2 id="launch-choice-title">{launchChoice.name}</h2><p>{t('选择启动方式')}</p><div className="launch-choice-actions"><button onClick={()=>chooseLaunch(false)}><IconDeviceDesktop size={24}/>{t('本机启动')}</button><button onClick={()=>chooseLaunch(true)}><IconDeviceDesktopShare size={24}/>{t('远程畅玩')}</button></div><button onClick={()=>setLaunchChoice(null)}>{t('取消')}</button></div></div>}{uninstall&&<div className="modal-backdrop"><div className="modal" role="dialog" aria-modal="true"><h2>{t('卸载 {0}?',{0:uninstall.manifest.name})}</h2><p>{t('依赖此插件的应用也会停用;插件数据会保留。')}</p>{!!uninstall.dependents.length&&<p>{t('停用:')}{uninstall.dependents.map(id=>plugins[id]?.manifest.name??id).join(', ')}</p>}<button disabled={busyKeys.has(`plugin:${uninstall.manifest.id}`)} onClick={()=>setUninstall(null)}>{t('取消')}</button><button disabled={busyKeys.has(`plugin:${uninstall.manifest.id}`)} className="danger" onClick={()=>void run(`plugin:${uninstall.manifest.id}`,async()=>{await launcherRequest('plugin.uninstall',{plugin:uninstall.manifest.id,purge:false,approveDependents:true,fingerprint:uninstall.fingerprint});setUninstall(null);setMenu(null);})}>{t('确认卸载')}</button></div></div>}{Object.entries(frontends).map(([key,frontend])=><div key={`${key}:${frontend.ticket}`} className="launcher-action-frame" aria-hidden="true"><PluginFrame plugin={frontend.plugin} entry={`action-${frontend.action}`} actionContext={frontend.context} onActionDone={error=>done(key,error,frontend.ticket)}/></div>)}</div>;
|
||||
}
|
||||
Reference in new issue
Block a user