mirror of
https://github.com/SteamFramelyHomebrew/framely.git
synced 2026-10-18 02:01:27 +02:00
Add opt-in Frame controller input for Lepton APKs
This commit is contained in:
1 parent
26643ed11d
commit
651663ad1d
16 files changed
+892
-22
No files matched your search
@@ -88,3 +88,13 @@ The initial implementation supports individual APKs. Split APK bundles, XAPK/APK
|
||||
In **Manage app → Advanced settings → Window orientation**, choose **Automatic**, **Landscape**, or **Portrait**. The choice is saved per app and applied at its next launch. Forced portrait uses a tall window rather than rotating content sideways. Changing orientation may restart the shared container and stop other apps in it. VR apps ignore this setting.
|
||||
|
||||
Moonlight V+ 12.12.12’s connection-creation screen currently crashes on the tested Lepton build because Android’s clipboard service is absent. This is distinct from the window-startup failure. Framely does not modify or re-sign the APK; a compatible Lepton runtime or an app-side fallback is required.
|
||||
|
||||
### Optional Frame gamepad input
|
||||
|
||||
**APK → Apps → Frame gamepad input** is off by default, including after upgrading an existing installation. Enable it to use the two Frame controllers as one standard Android gamepad for APKs opened through Framely. It includes ABXY, the D-pad and diagonals, both sticks and stick clicks, shoulder buttons, analog triggers, Select and Start. Applications must support Android gamepad input; this does not map controls to touchscreen gestures or provide Android VR controller tracking or vibration.
|
||||
|
||||
Enabling applies on the next application launch. Framely may restart its container to attach the input device, stopping other apps in that container. Disabling stops forwarding immediately. Only one Framely-launched APK owns the bridge at a time; switching containers releases the previous pad. Input pauses when another Android activity is foreground, and releases on controller disconnect, application exit, container stop or session-service restart. After a service restart, launch the APK again to reconnect. Keep the intended Android window selected in Dock; the bridge does not change Steam's physical-controller bindings.
|
||||
|
||||
The bridge runs as the Steam session user and requires access to `/dev/uinput`, active SteamVR and the supported Lepton startup hooks. It does not modify Steam's installed Lepton files. Only its virtual event node is mounted into the target container, where Android claims it exclusively. If this claim fails, no input is forwarded and the launch reports an error. Disable the option to use the normal launch path. Bridge errors are recorded in `~/.local/share/framely/apk-manager/logs/gamepad.log`; key presses are not logged. The native helper and Android adapter ship in both offline and core update packages.
|
||||
|
||||
When this option is enabled, launch APKs through the management panel or launcher. A one-shot CLI launch cannot own the persistent input bridge and reports an error instead.
|
||||
@@ -92,3 +92,13 @@ LEPTON_NO_CLEANUP=true ~/.local/share/Steam/steamapps/common/Lepton/lepton start
|
||||
在 **管理应用 → 高级设置 → 窗口方向** 中选择 **自动选择、横屏或竖屏**。每个应用独立保存,下次启动时应用。竖屏使用纵向窗口,而不是让内容侧着显示。切换方向可能重启共享容器,停止其中其他应用;VR 应用忽略此设置。
|
||||
|
||||
Moonlight V+ 12.12.12 的创建连接界面在当前测试的 Lepton 版本上会因缺少 Android 剪贴板服务而崩溃,与此前窗口启动失败是两个问题。Framely 不修改或重新签名 APK,需要兼容的 Lepton 运行环境或应用端容错处理。
|
||||
|
||||
### 可选的 Frame 手柄输入
|
||||
|
||||
**APK → 应用 → Frame 手柄输入**默认关闭,旧配置升级后也保持关闭。开启后,通过 Framely 打开的 APK 可以将两个 Frame 手柄作为一个标准 Android 游戏手柄使用,包括 ABXY、十字键及斜向组合、双摇杆及按下、肩键、模拟扳机、Select 和 Start。应用本身需要支持 Android 游戏手柄;该功能不把按键映射为触屏操作,也不提供 Android VR 手柄追踪或振动。
|
||||
|
||||
开启后在下次打开应用时生效。接入输入设备可能需要重启容器,影响其中其他正在运行的应用。关闭开关立即停止转发。同一时间只向一个由 Framely 打开的 APK 转发;切换容器会释放原来的虚拟手柄。其他 Android 页面进入前台时暂停输入;手柄断连、应用退出、容器停止或用户会话服务重启时释放输入。服务重启后再次打开 APK 即可重新接入。使用时在 Dock 中选中目标 Android 窗口;该功能不修改 Steam 的实体手柄绑定。
|
||||
|
||||
桥接以 Steam 会话用户运行,需要 `/dev/uinput` 访问权限、已运行的 SteamVR 和受支持的 Lepton 启动接口,不修改 Steam 安装目录中的 Lepton 文件。只向目标容器挂载自己的虚拟输入节点,由 Android 独占接收。接管失败时不转发输入,并提示启动错误;可以关闭开关恢复普通启动。错误记录在 `~/.local/share/framely/apk-manager/logs/gamepad.log`,不记录按键内容。原生桥接和 Android 适配库均包含在离线包及核心更新包中。
|
||||
|
||||
开启此选项后,请通过管理面板或启动台打开 APK。一次性命令行进程无法管理持续运行的输入桥接,此时命令行启动会明确报错。
|
||||
@@ -0,0 +1,69 @@
|
||||
// Session-owned OpenVR -> Android gamepad. No CEF dependency or timed test limit.
|
||||
#include "openvr.h"
|
||||
#include <linux/uinput.h>
|
||||
#include <sys/prctl.h>
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include <poll.h>
|
||||
#include <signal.h>
|
||||
#include <filesystem>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <cerrno>
|
||||
#include <cmath>
|
||||
#include <algorithm>
|
||||
#include <string>
|
||||
#include <chrono>
|
||||
#include <thread>
|
||||
static volatile sig_atomic_t stopped=0;
|
||||
static void stop(int){stopped=1;}
|
||||
static const int keys[]={BTN_SOUTH,BTN_EAST,BTN_WEST,BTN_NORTH,BTN_TL,BTN_TR,BTN_SELECT,BTN_START,BTN_THUMBL,BTN_THUMBR};
|
||||
static const int axes[]={ABS_X,ABS_Y,ABS_RX,ABS_RY,ABS_Z,ABS_RZ,ABS_HAT0X,ABS_HAT0Y};
|
||||
static bool event(int fd,int type,int code,int value){input_event e{};e.type=type;e.code=code;e.value=value;return write(fd,&e,sizeof e)==sizeof e;}
|
||||
static bool send(int fd,const int* state){bool ok=true;for(int k=0;k<10;k++)ok=event(fd,EV_KEY,keys[k],state[k])&&ok;for(int k=0;k<8;k++)ok=event(fd,EV_ABS,axes[k],state[10+k])&&ok;return event(fd,EV_SYN,SYN_REPORT,0)&&ok;}
|
||||
int main(int argc,char**argv){
|
||||
if(argc!=2||geteuid()==0){fprintf(stderr,"Run gamepad bridge as the Steam session user with an action manifest.\n");return 1;}
|
||||
auto parent=getppid();prctl(PR_SET_PDEATHSIG,SIGTERM);if(getppid()!=parent)return 1;
|
||||
signal(SIGTERM,stop);signal(SIGINT,stop);signal(SIGPIPE,SIG_IGN);
|
||||
vr::EVRInitError error;vr::VR_Init(&error,vr::VRApplication_Overlay);if(error){fprintf(stderr,"SteamVR gamepad input unavailable: %d\n",error);return 1;}
|
||||
auto*input=vr::VRInput();vr::VRActiveActionSet_t set{};
|
||||
bool valid=input->SetActionManifestPath(argv[1])==vr::VRInputError_None&&input->GetActionSetHandle("/actions/framely_gamepad",&set.ulActionSet)==vr::VRInputError_None;
|
||||
const char*names[]={"right_a","right_b","right_x","right_y","left_bumper","right_bumper","left_menu","right_menu","left_stickclick","right_stickclick","left_thumbstick","right_thumbstick","left_trigger","right_trigger","left_dpad_left","left_dpad_right","left_dpad_up","left_dpad_down"};
|
||||
vr::VRActionHandle_t handles[18]{};
|
||||
for(int k=0;k<18;k++)valid= input->GetActionHandle((std::string("/actions/framely_gamepad/in/")+names[k]).c_str(),&handles[k])==vr::VRInputError_None&&valid;
|
||||
if(!valid){fprintf(stderr,"Invalid gamepad action bindings.\n");vr::VR_Shutdown();return 1;}
|
||||
int fd=open("/dev/uinput",O_WRONLY|O_NONBLOCK|O_CLOEXEC);if(fd<0){perror("Gamepad /dev/uinput");vr::VR_Shutdown();return 1;}
|
||||
bool ok=ioctl(fd,UI_SET_EVBIT,EV_KEY)==0&&ioctl(fd,UI_SET_EVBIT,EV_ABS)==0;
|
||||
for(auto key:keys)ok=ioctl(fd,UI_SET_KEYBIT,key)==0&&ok;
|
||||
for(int k=0;k<8;k++){ok=ioctl(fd,UI_SET_ABSBIT,axes[k])==0&&ok;uinput_abs_setup a{};a.code=axes[k];a.absinfo.minimum=k<4?-32768:(k<6?0:-1);a.absinfo.maximum=k<4?32767:(k<6?255:1);a.absinfo.flat=k<4?1024:0;ok=ioctl(fd,UI_ABS_SETUP,&a)==0&&ok;}
|
||||
uinput_setup device{};device.id={BUS_USB,0x045e,0x028e,0x0114};strcpy(device.name,"Framely Frame gamepad");
|
||||
ok=ioctl(fd,UI_DEV_SETUP,&device)==0&&ioctl(fd,UI_DEV_CREATE)==0&&ok;
|
||||
char sysname[128]{};ok=ioctl(fd,UI_GET_SYSNAME(sizeof sysname),sysname)>=0&&ok;
|
||||
std::string node;
|
||||
for(int tries=0;ok&&tries<100&&node.empty();tries++){std::error_code ec;for(auto&entry:std::filesystem::directory_iterator(std::string("/sys/class/input/")+sysname,ec)){auto name=entry.path().filename().string();if(name.rfind("event",0)==0)node="/dev/input/"+name;}if(node.empty())std::this_thread::sleep_for(std::chrono::milliseconds(20));}
|
||||
if(!ok||node.empty()){fprintf(stderr,"Could not create the virtual gamepad.\n");ioctl(fd,UI_DEV_DESTROY);close(fd);vr::VR_Shutdown();return 1;}
|
||||
int witness=-1;for(int tries=0;tries<100&&witness<0;tries++){witness=open(node.c_str(),O_RDONLY|O_NONBLOCK|O_CLOEXEC);if(witness<0)std::this_thread::sleep_for(std::chrono::milliseconds(20));}
|
||||
if(witness<0){perror("Gamepad event access");ioctl(fd,UI_DEV_DESTROY);close(fd);vr::VR_Shutdown();return 1;}
|
||||
printf("%s\n",node.c_str());fflush(stdout);
|
||||
// Parent owns routing. Start neutral; EOF, disconnect or disabled actions release input.
|
||||
bool enabled=false;std::string commands;int previous[18]{};
|
||||
while(!stopped){pollfd p{STDIN_FILENO,POLLIN,0};if(poll(&p,1,0)>0){char buffer[128];auto count=read(0,buffer,sizeof buffer);if(count<=0)break;commands.append(buffer,count);size_t at;while((at=commands.find('\n'))!=std::string::npos){auto command=commands.substr(0,at);commands.erase(0,at+1);enabled=command=="enable";}if(commands.size()>128)break;}
|
||||
int state[18]{};
|
||||
// If Android releases its exclusive claim (e.g. InputReader restart),
|
||||
// pause until it claims the node again. Do not feed other host consumers.
|
||||
bool claimed=false;if(enabled){if(ioctl(witness,EVIOCGRAB,1)==0)ioctl(witness,EVIOCGRAB,0);else claimed=errno==EBUSY;}
|
||||
if(enabled&&claimed&&input->UpdateActionState(&set,sizeof set,1)==vr::VRInputError_None){
|
||||
const auto leftIndex=vr::VRSystem()->GetTrackedDeviceIndexForControllerRole(vr::TrackedControllerRole_LeftHand);
|
||||
const auto rightIndex=vr::VRSystem()->GetTrackedDeviceIndexForControllerRole(vr::TrackedControllerRole_RightHand);
|
||||
const bool left=leftIndex!=vr::k_unTrackedDeviceIndexInvalid&&vr::VRSystem()->IsTrackedDeviceConnected(leftIndex);
|
||||
const bool right=rightIndex!=vr::k_unTrackedDeviceIndexInvalid&&vr::VRSystem()->IsTrackedDeviceConnected(rightIndex);
|
||||
bool buttons[18]{};for(int k=0;k<18;k++){if(k>=10&&k<=13)continue;vr::InputDigitalActionData_t data{};if(input->GetDigitalActionData(handles[k],&data,sizeof data,vr::k_ulInvalidInputValueHandle)==vr::VRInputError_None)buttons[k]=(k==4||k==6||k==8||k>=14?left:right)&&data.bActive&&data.bState;}
|
||||
for(int k=0;k<10;k++)state[k]=buttons[k];
|
||||
for(int k=10;k<=13;k++){if(!(k==10||k==12?left:right))continue;vr::InputAnalogActionData_t data{};if(input->GetAnalogActionData(handles[k],&data,sizeof data,vr::k_ulInvalidInputValueHandle)!=vr::VRInputError_None||!data.bActive||!std::isfinite(data.x)||!std::isfinite(data.y))continue;if(k<=11){state[10+(k-10)*2]=std::lround(std::clamp(data.x,-1.f,1.f)*32767);state[11+(k-10)*2]=std::lround(std::clamp(-data.y,-1.f,1.f)*32767);}else state[14+k-12]=std::lround(std::clamp(data.x,0.f,1.f)*255);}
|
||||
state[16]=int(buttons[15])-int(buttons[14]);state[17]=int(buttons[17])-int(buttons[16]);
|
||||
}
|
||||
if(memcmp(previous,state,sizeof state)){if(!send(fd,state))break;memcpy(previous,state,sizeof state);}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(8));
|
||||
}
|
||||
int neutral[18]{};send(fd,neutral);close(witness);ioctl(fd,UI_DEV_DESTROY);close(fd);vr::VR_Shutdown();return 0;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Bionic preload: exclusively claim only Framely's mounted virtual event node.
|
||||
// Raw AArch64 syscalls avoid a glibc dependency inside Android.
|
||||
#define _GNU_SOURCE
|
||||
#include <stdarg.h>
|
||||
#include <fcntl.h>
|
||||
#include <linux/input.h>
|
||||
extern int *__errno(void);
|
||||
static long call(long nr,long a,long b,long c,long d){register long x0 __asm__("x0")=a;register long x1 __asm__("x1")=b;register long x2 __asm__("x2")=c;register long x3 __asm__("x3")=d;register long x8 __asm__("x8")=nr;__asm__ volatile("svc 0":"+r"(x0):"r"(x1),"r"(x2),"r"(x3),"r"(x8):"memory");return x0;}
|
||||
static int same(const char*a,const char*b){while(*a&&*a==*b){a++;b++;}return *a==*b;}
|
||||
static int opened(int dir,const char*p,int flags,int mode){long fd=call(56,dir,(long)p,flags,mode);if(fd<0){*__errno()=-fd;return -1;}if(same(p,"/dev/input/event250")){long r=call(29,fd,EVIOCGRAB,1,0);if(r==0){long ready=call(56,-100,(long)"/framely-gamepad-ready/ready",O_WRONLY|O_CREAT|O_TRUNC|O_CLOEXEC,0644);if(ready>=0){call(64,ready,(long)"ready\n",6,0);call(57,ready,0,0,0);}}}return fd;}
|
||||
int open(const char*p,int flags,...){int mode=0;if((flags&O_CREAT)||(flags&O_TMPFILE)==O_TMPFILE){va_list ap;va_start(ap,flags);mode=va_arg(ap,int);va_end(ap);}return opened(-100,p,flags,mode);}
|
||||
int open64(const char*p,int flags,...){int mode=0;if((flags&O_CREAT)||(flags&O_TMPFILE)==O_TMPFILE){va_list ap;va_start(ap,flags);mode=va_arg(ap,int);va_end(ap);}return opened(-100,p,flags,mode);}
|
||||
int openat(int dir,const char*p,int flags,...){int mode=0;if((flags&O_CREAT)||(flags&O_TMPFILE)==O_TMPFILE){va_list ap;va_start(ap,flags);mode=va_arg(ap,int);va_end(ap);}return opened(dir,p,flags,mode);}
|
||||
int __open_2(const char*p,int flags){return opened(-100,p,flags,0);}
|
||||
int __openat_2(int dir,const char*p,int flags){return opened(dir,p,flags,0);}
|
||||
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"action_sets": [
|
||||
{
|
||||
"name": "/actions/framely_gamepad",
|
||||
"usage": "leftright"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"name": "/actions/framely_gamepad/in/left_dpad_up",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"name": "/actions/framely_gamepad/in/left_dpad_down",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"name": "/actions/framely_gamepad/in/left_dpad_left",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"name": "/actions/framely_gamepad/in/left_dpad_right",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"name": "/actions/framely_gamepad/in/left_bumper",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"name": "/actions/framely_gamepad/in/left_menu",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"name": "/actions/framely_gamepad/in/left_thumbstick",
|
||||
"type": "vector2"
|
||||
},
|
||||
{
|
||||
"name": "/actions/framely_gamepad/in/left_trigger",
|
||||
"type": "vector1"
|
||||
},
|
||||
{
|
||||
"name": "/actions/framely_gamepad/in/left_stickclick",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"name": "/actions/framely_gamepad/in/right_a",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"name": "/actions/framely_gamepad/in/right_b",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"name": "/actions/framely_gamepad/in/right_x",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"name": "/actions/framely_gamepad/in/right_y",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"name": "/actions/framely_gamepad/in/right_bumper",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"name": "/actions/framely_gamepad/in/right_menu",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"name": "/actions/framely_gamepad/in/right_thumbstick",
|
||||
"type": "vector2"
|
||||
},
|
||||
{
|
||||
"name": "/actions/framely_gamepad/in/right_trigger",
|
||||
"type": "vector1"
|
||||
},
|
||||
{
|
||||
"name": "/actions/framely_gamepad/in/right_stickclick",
|
||||
"type": "boolean"
|
||||
}
|
||||
],
|
||||
"default_bindings": [
|
||||
{
|
||||
"controller_type": "frame_controller",
|
||||
"binding_url": "bindings.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
{
|
||||
"controller_type": "frame_controller",
|
||||
"name": "Framely Android gamepad",
|
||||
"bindings": {
|
||||
"/actions/framely_gamepad": {
|
||||
"sources": [
|
||||
{
|
||||
"path": "/user/hand/left/input/dpad_up",
|
||||
"mode": "button",
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/framely_gamepad/in/left_dpad_up"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/left/input/dpad_down",
|
||||
"mode": "button",
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/framely_gamepad/in/left_dpad_down"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/left/input/dpad_left",
|
||||
"mode": "button",
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/framely_gamepad/in/left_dpad_left"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/left/input/dpad_right",
|
||||
"mode": "button",
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/framely_gamepad/in/left_dpad_right"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/left/input/bumper",
|
||||
"mode": "button",
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/framely_gamepad/in/left_bumper"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/left/input/view",
|
||||
"mode": "button",
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/framely_gamepad/in/left_menu"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/left/input/thumbstick",
|
||||
"mode": "joystick",
|
||||
"inputs": {
|
||||
"position": {
|
||||
"output": "/actions/framely_gamepad/in/left_thumbstick"
|
||||
},
|
||||
"click": {
|
||||
"output": "/actions/framely_gamepad/in/left_stickclick"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/left/input/trigger",
|
||||
"mode": "trigger",
|
||||
"inputs": {
|
||||
"pull": {
|
||||
"output": "/actions/framely_gamepad/in/left_trigger"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/right/input/a",
|
||||
"mode": "button",
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/framely_gamepad/in/right_a"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/right/input/b",
|
||||
"mode": "button",
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/framely_gamepad/in/right_b"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/right/input/x",
|
||||
"mode": "button",
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/framely_gamepad/in/right_x"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/right/input/y",
|
||||
"mode": "button",
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/framely_gamepad/in/right_y"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/right/input/bumper",
|
||||
"mode": "button",
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/framely_gamepad/in/right_bumper"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/right/input/menu",
|
||||
"mode": "button",
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/framely_gamepad/in/right_menu"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/right/input/thumbstick",
|
||||
"mode": "joystick",
|
||||
"inputs": {
|
||||
"position": {
|
||||
"output": "/actions/framely_gamepad/in/right_thumbstick"
|
||||
},
|
||||
"click": {
|
||||
"output": "/actions/framely_gamepad/in/right_stickclick"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/right/input/trigger",
|
||||
"mode": "trigger",
|
||||
"inputs": {
|
||||
"pull": {
|
||||
"output": "/actions/framely_gamepad/in/right_trigger"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+240
-13
@@ -39,6 +39,8 @@ struct Record {
|
||||
}
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
struct Database {
|
||||
#[serde(default)]
|
||||
gamepad_enabled: bool,
|
||||
#[serde(default)]
|
||||
owned_contexts: Vec<String>,
|
||||
#[serde(default)]
|
||||
@@ -750,7 +752,7 @@ pub fn list(home: &Path) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
Ok(
|
||||
json!({"apps":apps,"containers":cs.iter().map(|c| { let known=!warnings.iter().any(|w|w.starts_with(&format!("{}:",c.name)))&&!apps.iter().any(|a|a.context==c.id&&!a.state_known); let mut v=serde_json::to_value(c).unwrap(); v["acceptsAdditionalApps"]=json!(!c.steam&&known); v }).collect::<Vec<_>>(),"warnings":warnings,"roots":db.roots,"available":runner(home).is_ok()}),
|
||||
json!({"apps":apps,"containers":cs.iter().map(|c| { let known=!warnings.iter().any(|w|w.starts_with(&format!("{}:",c.name)))&&!apps.iter().any(|a|a.context==c.id&&!a.state_known); let mut v=serde_json::to_value(c).unwrap(); v["acceptsAdditionalApps"]=json!(!c.steam&&known); v }).collect::<Vec<_>>(),"warnings":warnings,"roots":db.roots,"available":runner(home).is_ok(),"gamepadEnabled":db.gamepad_enabled}),
|
||||
)
|
||||
}
|
||||
pub fn launcher(home: &Path) -> Vec<Value> {
|
||||
@@ -888,6 +890,26 @@ fn direct_launch_script(source: &str) -> Result<String> {
|
||||
"Unsupported Lepton entry script; cannot safely configure the APK display"
|
||||
);
|
||||
let hooks = r#"
|
||||
# Optional virtual gamepad: mount only its event node, not host input devices.
|
||||
if [[ -n "${FRAMELY_GAMEPAD_EVENT:-}" ]]; then
|
||||
for hook in setup_podman_mounts generate_zygote_launch_rc; do
|
||||
declare -F "$hook" >/dev/null || { echo "Unsupported Lepton gamepad hooks" >&2; exit 64; }
|
||||
done
|
||||
eval "$(declare -f setup_podman_mounts | sed '1s/setup_podman_mounts/framely_original_gamepad_mounts/')"
|
||||
function setup_podman_mounts() {
|
||||
framely_original_gamepad_mounts "$@"
|
||||
podman_mount_entry "${FRAMELY_GAMEPAD_EVENT:?}" /dev/input/event250 rw
|
||||
podman_mount_entry "${FRAMELY_GAMEPAD_GRAB:?}" /vendor/lib64/libframely_gamepad_grab.so ro
|
||||
podman_mount_entry "${FRAMELY_GAMEPAD_READY:?}" /framely-gamepad-ready rw
|
||||
}
|
||||
eval "$(declare -f generate_zygote_launch_rc | sed '1s/generate_zygote_launch_rc/framely_original_gamepad_zygote/')"
|
||||
function generate_zygote_launch_rc() {
|
||||
framely_original_gamepad_zygote "$@"
|
||||
local rc="$(prefix)/init.zygote64.rc"
|
||||
[[ "$(grep -c '^service zygote ' "$rc")" == 1 ]] && ! grep -q 'setenv LD_PRELOAD' "$rc" || { echo "Unsupported Lepton gamepad zygote" >&2; return 64; }
|
||||
sed -i '/^service zygote /a\ setenv LD_PRELOAD /vendor/lib64/libframely_gamepad_grab.so' "$rc"
|
||||
}
|
||||
fi
|
||||
# Framely: configure display visibility and shape, retaining the dev context.
|
||||
# Do not use is_app: Lepton's app bake path can reset existing application data.
|
||||
eval "$(declare -f setup_props | sed '1s/setup_props/framely_original_setup_props/')"
|
||||
@@ -905,6 +927,7 @@ function setup_props() {
|
||||
landscape) printf '\npersist.waydroid.width=1920\npersist.waydroid.height=1080\n' >> "$(props_file)" ;;
|
||||
esac
|
||||
printf '\nframely.window_orientation=%s\n' "${FRAMELY_WINDOW_ORIENTATION:-auto}" >> "$(props_file)"
|
||||
printf '\nframely.gamepad.bridge=%s\n' "${FRAMELY_GAMEPAD_TOKEN:-}" >> "$(props_file)"
|
||||
}
|
||||
"#;
|
||||
Ok(source
|
||||
@@ -921,7 +944,7 @@ fn start_container(
|
||||
direct: bool,
|
||||
log: &Path,
|
||||
) -> Result<()> {
|
||||
start_oriented_container(home, c, show, direct, None, log)
|
||||
start_oriented_container(home, c, show, direct, None, None, log)
|
||||
}
|
||||
fn start_oriented_container(
|
||||
home: &Path,
|
||||
@@ -929,6 +952,7 @@ fn start_oriented_container(
|
||||
show: Option<bool>,
|
||||
direct: bool,
|
||||
orientation: Option<&str>,
|
||||
gamepad: Option<&crate::gamepad::Mount>,
|
||||
log: &Path,
|
||||
) -> Result<()> {
|
||||
ensure!(
|
||||
@@ -940,7 +964,7 @@ fn start_oriented_container(
|
||||
}
|
||||
let runner = runner(home)?;
|
||||
let mut temporary = None;
|
||||
let mut cmd = if direct || orientation.is_some() {
|
||||
let mut cmd = if direct || orientation.is_some() || gamepad.is_some() {
|
||||
let script = direct_launch_script(&fs::read_to_string(&runner)?)?;
|
||||
let path = root(home).join("logs").join(format!(
|
||||
"lepton-launch-{}.sh",
|
||||
@@ -978,6 +1002,12 @@ fn start_oriented_container(
|
||||
"false"
|
||||
},
|
||||
);
|
||||
if let Some(mount) = gamepad {
|
||||
cmd.env("FRAMELY_GAMEPAD_EVENT", &mount.event)
|
||||
.env("FRAMELY_GAMEPAD_GRAB", &mount.grab)
|
||||
.env("FRAMELY_GAMEPAD_READY", &mount.ready)
|
||||
.env("FRAMELY_GAMEPAD_TOKEN", &mount.token);
|
||||
}
|
||||
if c.id.starts_with("external-") {
|
||||
cmd.env(
|
||||
"STEAM_COMPAT_DATA_PATH",
|
||||
@@ -1056,6 +1086,7 @@ fn wrapper_active(name: &str) -> bool {
|
||||
})
|
||||
}
|
||||
fn stop(c: &Container, log: &Path) -> Result<()> {
|
||||
crate::gamepad::stop_context(&c.name);
|
||||
if running(&c.name) {
|
||||
podman(
|
||||
&["stop", "--time", "10", &format!("lepton-{}", c.name)],
|
||||
@@ -1761,6 +1792,17 @@ pub fn operate(
|
||||
progress(json!({"phase":"preparing","operation":kind}));
|
||||
let result = (|| -> Result<Value> {
|
||||
match kind {
|
||||
"gamepad.settings" => {
|
||||
let enabled = p["enabled"].as_bool().context("Invalid gamepad setting")?;
|
||||
cancel.commit(|| {
|
||||
db.gamepad_enabled = enabled;
|
||||
save(home, &db)?;
|
||||
if !enabled {
|
||||
crate::gamepad::stop();
|
||||
}
|
||||
Ok(json!(true))
|
||||
})
|
||||
}
|
||||
"root.add" => {
|
||||
let cs = containers(home, &db);
|
||||
let path = p["path"].as_str().context("Missing data directory")?;
|
||||
@@ -2042,18 +2084,53 @@ progress(json!({"phase":"starting"}));start(home,&c,Some(false),&log)?;
|
||||
stop(&c, &log)?;
|
||||
}
|
||||
}
|
||||
start_oriented_container(
|
||||
home,
|
||||
&c,
|
||||
Some(flat),
|
||||
!flat,
|
||||
if flat { a.orientation.as_deref() } else { None },
|
||||
&log,
|
||||
)?;
|
||||
progress(json!({"phase":"launching"}));
|
||||
launch(&c, &a, &log)?;
|
||||
// A running container cannot acquire a new bind-mounted
|
||||
// input node. Reboot only if its bridge identity differs.
|
||||
if running(&c.name) && db.gamepad_enabled {
|
||||
let token = podman(
|
||||
&[
|
||||
"exec",
|
||||
&format!("lepton-{}", c.name),
|
||||
"getprop",
|
||||
"framely.gamepad.bridge",
|
||||
],
|
||||
Some(&log),
|
||||
)?;
|
||||
if crate::gamepad::current(&c.name)
|
||||
.is_none_or(|m| m.token != token.trim())
|
||||
{
|
||||
stop(&c, &log)?;
|
||||
}
|
||||
}
|
||||
let gamepad = if db.gamepad_enabled {
|
||||
Some(crate::gamepad::prepare(&root(home), &c.name)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let launch_result = (|| -> Result<()> {
|
||||
start_oriented_container(
|
||||
home,
|
||||
&c,
|
||||
Some(flat),
|
||||
!flat,
|
||||
if flat { a.orientation.as_deref() } else { None },
|
||||
gamepad.as_ref(),
|
||||
&log,
|
||||
)?;
|
||||
progress(json!({"phase":"launching"}));
|
||||
launch(&c, &a, &log)?;
|
||||
if gamepad.is_some() {
|
||||
crate::gamepad::activate(&c.name, &a.metadata.package)?;
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
if launch_result.is_err() {
|
||||
crate::gamepad::stop_context(&c.name);
|
||||
}
|
||||
launch_result?;
|
||||
}
|
||||
"close" => {
|
||||
crate::gamepad::stop_app(&c.name, &a.metadata.package);
|
||||
if running(&c.name) {
|
||||
let active = podman(
|
||||
&[
|
||||
@@ -2329,6 +2406,45 @@ progress(json!({"phase":"starting"}));start(home,&c,Some(false),&log)?;
|
||||
fn backup_fn(home: &Path, c: &Container, db: &Database, log: &Path) -> Result<String> {
|
||||
backup(home, c, db, log)
|
||||
}
|
||||
pub(crate) fn gamepad_health(context: &str, package: &str) -> (Option<bool>, bool) {
|
||||
if !safe(context) || !apk_metadata::valid_package(package) {
|
||||
return (Some(false), false);
|
||||
}
|
||||
let mut cmd = crate::process::tool("podman");
|
||||
cmd.args([
|
||||
"exec",
|
||||
&format!("lepton-{context}"),
|
||||
"sh",
|
||||
"-c",
|
||||
"dumpsys activity processes && dumpsys activity activities",
|
||||
"framely-gamepad",
|
||||
package,
|
||||
]);
|
||||
match output(cmd, Duration::from_secs(3), None) {
|
||||
Ok(text) => (
|
||||
Some(gamepad_present(&text, package)),
|
||||
gamepad_foreground(&text, package),
|
||||
),
|
||||
Err(_) => (None, false),
|
||||
}
|
||||
}
|
||||
fn gamepad_present(text: &str, package: &str) -> bool {
|
||||
gamepad_foreground(text, package)
|
||||
|| text.lines().any(|line| {
|
||||
line.split_once("packageList={")
|
||||
.and_then(|(_, names)| names.split_once('}'))
|
||||
.is_some_and(|(names, _)| names.split(',').any(|name| name.trim() == package))
|
||||
})
|
||||
}
|
||||
fn gamepad_foreground(text: &str, package: &str) -> bool {
|
||||
text.lines().any(|line| {
|
||||
(line.contains("mResumedActivity:") || line.contains("topResumedActivity="))
|
||||
&& line.split_whitespace().any(|word| {
|
||||
word.strip_prefix(package)
|
||||
.is_some_and(|rest| rest.starts_with('/'))
|
||||
})
|
||||
})
|
||||
}
|
||||
pub fn launch_app(home: &Path, id: &str) -> Result<()> {
|
||||
operate(
|
||||
home,
|
||||
@@ -2343,6 +2459,117 @@ pub fn launch_app(home: &Path, id: &str) -> Result<()> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn gamepad_setting_defaults_off_and_round_trips() {
|
||||
let _guard = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
assert!(!load(home).unwrap().gamepad_enabled);
|
||||
for enabled in [true, false] {
|
||||
operate(
|
||||
home,
|
||||
"gamepad.settings",
|
||||
&json!({"enabled":enabled}),
|
||||
Cancellation::default(),
|
||||
Arc::new(|_| {}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(load(home).unwrap().gamepad_enabled, enabled);
|
||||
}
|
||||
assert!(operate(
|
||||
home,
|
||||
"gamepad.settings",
|
||||
&json!({"enabled":"true"}),
|
||||
Cancellation::default(),
|
||||
Arc::new(|_| {})
|
||||
)
|
||||
.is_err());
|
||||
assert!(!load(home).unwrap().gamepad_enabled);
|
||||
fs::write(root(home).join("state.json"), "{\"records\":{}}").unwrap();
|
||||
assert!(!load(home).unwrap().gamepad_enabled);
|
||||
}
|
||||
#[test]
|
||||
fn gamepad_foreground_does_not_match_another_package() {
|
||||
assert!(gamepad_foreground(
|
||||
"mResumedActivity: ActivityRecord{abc u0 com.example.game/.Main t12}",
|
||||
"com.example.game"
|
||||
));
|
||||
assert!(gamepad_foreground(
|
||||
"topResumedActivity=ActivityRecord{abc u0 com.example.game/.Main t12}",
|
||||
"com.example.game"
|
||||
));
|
||||
assert!(!gamepad_foreground(
|
||||
"mResumedActivity: ActivityRecord{abc u0 com.example.game.other/.Main t12}",
|
||||
"com.example.game"
|
||||
));
|
||||
assert!(!gamepad_foreground(
|
||||
"mLastPausedActivity: ActivityRecord{abc u0 com.example.game/.Main t12}",
|
||||
"com.example.game"
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn gamepad_tracks_custom_processes_by_package() {
|
||||
assert!(gamepad_present(
|
||||
"packageList={com.example.other, com.example.game}",
|
||||
"com.example.game"
|
||||
));
|
||||
assert!(!gamepad_present(
|
||||
"packageList={com.example.game.other}",
|
||||
"com.example.game"
|
||||
));
|
||||
assert!(!gamepad_present("No running processes", "com.example.game"));
|
||||
}
|
||||
#[test]
|
||||
fn gamepad_boot_mounts_only_its_node_and_injects_android_claim() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let library = dir.path().join("vendor/liblepton");
|
||||
fs::create_dir_all(&library).unwrap();
|
||||
fs::write(library.join("liblepton.sh"),r#"
|
||||
function props_file() { echo "$TEST_PROPS"; }
|
||||
function prefix() { echo "$TEST_PREFIX"; }
|
||||
function app_wants_flatscreen() { return 0; }
|
||||
function setup_props() { echo waydroid.background_start=false > "$(props_file)"; }
|
||||
function setup_podman_mounts() { :; }
|
||||
function podman_mount_entry() { printf '%s|%s|%s\n' "$1" "$2" "$3" >> "$TEST_MOUNTS"; }
|
||||
function generate_zygote_launch_rc() { printf 'service zygote /system/bin/app_process64\n class main\n' > "$(prefix)/init.zygote64.rc"; }
|
||||
"#).unwrap();
|
||||
let script = direct_launch_script(
|
||||
r#"#!/bin/bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
|
||||
source "${SCRIPT_DIR}/liblepton/liblepton.sh"
|
||||
setup_podman_mounts
|
||||
generate_zygote_launch_rc
|
||||
setup_props
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let path = dir.path().join("start.sh");
|
||||
fs::write(&path, script).unwrap();
|
||||
let status = Command::new("bash")
|
||||
.arg(&path)
|
||||
.env("FRAMELY_LEPTON_DIR", library.parent().unwrap())
|
||||
.env("TEST_PROPS", dir.path().join("props"))
|
||||
.env("TEST_PREFIX", dir.path())
|
||||
.env("TEST_MOUNTS", dir.path().join("mounts"))
|
||||
.env("FRAMELY_GAMEPAD_EVENT", "/dev/input/event17")
|
||||
.env("FRAMELY_GAMEPAD_GRAB", "/runtime/grab.so")
|
||||
.env("FRAMELY_GAMEPAD_READY", "/private/gamepad")
|
||||
.env("FRAMELY_GAMEPAD_TOKEN", "token")
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(status.success());
|
||||
let mounts = fs::read_to_string(dir.path().join("mounts")).unwrap();
|
||||
assert_eq!(mounts.lines().count(), 3);
|
||||
assert!(mounts.contains("/dev/input/event17|/dev/input/event250|rw"));
|
||||
assert!(!mounts.contains("/dev/input|"));
|
||||
assert!(fs::read_to_string(dir.path().join("init.zygote64.rc"))
|
||||
.unwrap()
|
||||
.contains("setenv LD_PRELOAD /vendor/lib64/libframely_gamepad_grab.so"));
|
||||
assert!(fs::read_to_string(dir.path().join("props"))
|
||||
.unwrap()
|
||||
.contains("framely.gamepad.bridge=token"));
|
||||
}
|
||||
#[test]
|
||||
fn direct_boot_preserves_context_and_controls_window_visibility() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let library = dir.path().join("vendor with spaces/liblepton");
|
||||
|
||||
+274
@@ -0,0 +1,274 @@
|
||||
//! Optional, session-user-only input bridge. One container owns the virtual pad.
|
||||
use anyhow::{ensure, Context, Result};
|
||||
use std::{
|
||||
fs,
|
||||
io::{BufRead, BufReader, Read, Write},
|
||||
os::unix::fs::{FileTypeExt, OpenOptionsExt, PermissionsExt},
|
||||
path::{Path, PathBuf},
|
||||
process::{Child, ChildStdin, Command, Stdio},
|
||||
sync::{Mutex, OnceLock},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
#[derive(Clone)]
|
||||
pub struct Mount {
|
||||
pub context: String,
|
||||
pub token: String,
|
||||
pub event: PathBuf,
|
||||
pub grab: PathBuf,
|
||||
pub ready: PathBuf,
|
||||
}
|
||||
struct Bridge {
|
||||
mount: Mount,
|
||||
child: Child,
|
||||
input: ChildStdin,
|
||||
package: Option<String>,
|
||||
enabled: bool,
|
||||
}
|
||||
static BRIDGE: Mutex<Option<Bridge>> = Mutex::new(None);
|
||||
static SESSION: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
pub fn session_started() {
|
||||
SESSION.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
static WATCHER: OnceLock<()> = OnceLock::new();
|
||||
impl Drop for Bridge {
|
||||
fn drop(&mut self) {
|
||||
let _ = writeln!(self.input, "disable");
|
||||
unsafe {
|
||||
libc::kill(self.child.id() as i32, libc::SIGTERM);
|
||||
};
|
||||
let began = Instant::now();
|
||||
while began.elapsed() < Duration::from_millis(500) {
|
||||
if self.child.try_wait().ok().flatten().is_some() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
let _ = fs::remove_dir_all(&self.mount.ready);
|
||||
}
|
||||
}
|
||||
pub fn stop() {
|
||||
BRIDGE.lock().unwrap().take();
|
||||
}
|
||||
pub fn stop_context(context: &str) {
|
||||
let mut b = BRIDGE.lock().unwrap();
|
||||
if b.as_ref().is_some_and(|b| b.mount.context == context) {
|
||||
b.take();
|
||||
}
|
||||
}
|
||||
pub fn stop_app(context: &str, package: &str) {
|
||||
let mut b = BRIDGE.lock().unwrap();
|
||||
if b.as_ref()
|
||||
.is_some_and(|b| b.mount.context == context && b.package.as_deref() == Some(package))
|
||||
{
|
||||
b.take();
|
||||
}
|
||||
}
|
||||
pub fn current(context: &str) -> Option<Mount> {
|
||||
let mut b = BRIDGE.lock().unwrap();
|
||||
if let Some(value) = b.as_mut() {
|
||||
if value.child.try_wait().ok().flatten().is_some() {
|
||||
b.take();
|
||||
}
|
||||
}
|
||||
b.as_ref()
|
||||
.filter(|b| b.mount.context == context)
|
||||
.map(|b| b.mount.clone())
|
||||
}
|
||||
pub fn prepare(storage: &Path, context: &str) -> Result<Mount> {
|
||||
ensure!(SESSION.load(std::sync::atomic::Ordering::Relaxed),"Gamepad launch requires the UI session; open the APK from the management panel or launcher");
|
||||
ensure!(
|
||||
unsafe { libc::geteuid() } != 0,
|
||||
"Gamepad input must run as the Steam session user"
|
||||
);
|
||||
if let Some(m) = current(context) {
|
||||
let mut b = BRIDGE.lock().unwrap();
|
||||
if let Some(bridge) = b.as_mut() {
|
||||
writeln!(bridge.input, "disable")?;
|
||||
bridge.package = None;
|
||||
bridge.enabled = false;
|
||||
}
|
||||
return Ok(m);
|
||||
}
|
||||
stop();
|
||||
let exe = std::env::current_exe()?;
|
||||
let distribution = exe
|
||||
.parent()
|
||||
.and_then(Path::parent)
|
||||
.context("Missing Framely runtime directory")?;
|
||||
let helper = distribution.join("lib/openvr/framely-gamepad");
|
||||
let grab = distribution.join("lib/openvr/libframely-gamepad-grab.so");
|
||||
let manifest = distribution.join("share/input/gamepad/actions.json");
|
||||
ensure!(
|
||||
helper.is_file() && grab.is_file() && manifest.is_file(),
|
||||
"Gamepad input runtime is missing; install a complete Framely build"
|
||||
);
|
||||
let token = hex::encode(rand::random::<[u8; 16]>());
|
||||
let ready = storage.join(format!("gamepad-{token}"));
|
||||
fs::create_dir(&ready)?;
|
||||
fs::set_permissions(&ready, fs::Permissions::from_mode(0o733))?;
|
||||
let log = fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.create(true)
|
||||
.mode(0o600)
|
||||
.open(storage.join("logs/gamepad.log"))?;
|
||||
let mut child = Command::new(helper)
|
||||
.arg(manifest)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(log)
|
||||
.spawn()?;
|
||||
let input = child.stdin.take().context("Missing gamepad control pipe")?;
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.context("Missing gamepad readiness pipe")?;
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let mut line = String::new();
|
||||
let result = BufReader::new(stdout).read_line(&mut line).map(|_| line);
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
let event = rx
|
||||
.recv_timeout(Duration::from_secs(8))
|
||||
.context("Gamepad input did not become ready")
|
||||
.and_then(|v| v.map_err(Into::into))
|
||||
.map(|s| PathBuf::from(s.trim()));
|
||||
let mut bridge = Bridge {
|
||||
mount: Mount {
|
||||
context: context.into(),
|
||||
token,
|
||||
event: PathBuf::new(),
|
||||
grab,
|
||||
ready,
|
||||
},
|
||||
child,
|
||||
input,
|
||||
package: None,
|
||||
enabled: false,
|
||||
};
|
||||
let event = event?;
|
||||
ensure!(
|
||||
valid_node(&event) && fs::metadata(&event)?.file_type().is_char_device(),
|
||||
"Invalid gamepad event device"
|
||||
);
|
||||
bridge.mount.event = event;
|
||||
let mount = bridge.mount.clone();
|
||||
*BRIDGE.lock().unwrap() = Some(bridge);
|
||||
WATCHER.get_or_init(|| {
|
||||
std::thread::spawn(|| loop {
|
||||
std::thread::sleep(Duration::from_millis(700));
|
||||
let target = {
|
||||
let b = BRIDGE.lock().unwrap();
|
||||
b.as_ref()
|
||||
.and_then(|b| b.package.as_ref().map(|p| (b.mount.clone(), p.clone())))
|
||||
};
|
||||
let Some((mount, package)) = target else {
|
||||
continue;
|
||||
};
|
||||
let (alive, focused) = crate::apk::gamepad_health(&mount.context, &package);
|
||||
let mut b = BRIDGE.lock().unwrap();
|
||||
if b.as_ref().is_none_or(|b| b.mount.token != mount.token) {
|
||||
continue;
|
||||
}
|
||||
if alive == Some(false)
|
||||
|| b.as_mut()
|
||||
.is_some_and(|b| b.child.try_wait().ok().flatten().is_some())
|
||||
{
|
||||
b.take();
|
||||
continue;
|
||||
}
|
||||
let bridge = b.as_mut().unwrap();
|
||||
let enabled = focused && claimed(&mount.ready);
|
||||
if bridge.enabled != enabled {
|
||||
if writeln!(
|
||||
bridge.input,
|
||||
"{}",
|
||||
if enabled { "enable" } else { "disable" }
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
b.take();
|
||||
continue;
|
||||
}
|
||||
bridge.enabled = enabled;
|
||||
}
|
||||
});
|
||||
});
|
||||
Ok(mount)
|
||||
}
|
||||
pub fn activate(context: &str, package: &str) -> Result<()> {
|
||||
let mount = current(context).context("Gamepad input is no longer running")?;
|
||||
let began = Instant::now();
|
||||
while !claimed(&mount.ready) && began.elapsed() < Duration::from_secs(3) {
|
||||
std::thread::sleep(Duration::from_millis(30));
|
||||
}
|
||||
ensure!(
|
||||
claimed(&mount.ready),
|
||||
"Android did not claim the virtual gamepad; restart the container and retry"
|
||||
);
|
||||
let mut b = BRIDGE.lock().unwrap();
|
||||
let bridge = b
|
||||
.as_mut()
|
||||
.filter(|b| b.mount.token == mount.token)
|
||||
.context("Gamepad input is no longer running")?;
|
||||
bridge.package = Some(package.into());
|
||||
Ok(())
|
||||
}
|
||||
fn claimed(directory: &Path) -> bool {
|
||||
let Ok(file) = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
|
||||
.open(directory.join("ready"))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if !file.metadata().is_ok_and(|m| m.is_file() && m.len() == 6) {
|
||||
return false;
|
||||
}
|
||||
let mut data = String::new();
|
||||
file.take(16).read_to_string(&mut data).is_ok() && data == "ready\n"
|
||||
}
|
||||
fn valid_node(p: &Path) -> bool {
|
||||
p.parent() == Some(Path::new("/dev/input"))
|
||||
&& p.file_name().and_then(|s| s.to_str()).is_some_and(|s| {
|
||||
s.strip_prefix("event")
|
||||
.is_some_and(|n| !n.is_empty() && n.bytes().all(|c| c.is_ascii_digit()))
|
||||
})
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn readiness_requires_a_regular_acknowledgement() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("ready");
|
||||
assert!(!claimed(dir.path()));
|
||||
fs::write(&path, "ready\n").unwrap();
|
||||
assert!(claimed(dir.path()));
|
||||
fs::write(&path, "not ready\n").unwrap();
|
||||
assert!(!claimed(dir.path()));
|
||||
fs::remove_file(&path).unwrap();
|
||||
std::os::unix::fs::symlink("/dev/zero", &path).unwrap();
|
||||
assert!(!claimed(dir.path()));
|
||||
fs::remove_file(&path).unwrap();
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let name = std::ffi::CString::new(path.as_os_str().as_bytes()).unwrap();
|
||||
assert_eq!(unsafe { libc::mkfifo(name.as_ptr(), 0o600) }, 0);
|
||||
assert!(!claimed(dir.path()));
|
||||
}
|
||||
#[test]
|
||||
fn only_event_nodes_are_accepted() {
|
||||
assert!(valid_node(Path::new("/dev/input/event5")));
|
||||
for path in [
|
||||
"/dev/input/event",
|
||||
"/dev/uinput",
|
||||
"/dev/input/../event5",
|
||||
"/dev/input/event5/child",
|
||||
"/tmp/event5",
|
||||
] {
|
||||
assert!(!valid_node(Path::new(path)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ mod auth;
|
||||
mod desktop;
|
||||
mod diagnostics;
|
||||
mod file_browser;
|
||||
mod gamepad;
|
||||
mod http;
|
||||
mod ipc;
|
||||
mod jobs;
|
||||
|
||||
@@ -1619,6 +1619,7 @@ pub fn serve(
|
||||
unsafe { libc::geteuid() } != 0,
|
||||
"UI session must not run as root"
|
||||
);
|
||||
crate::gamepad::session_started();
|
||||
let server = Server::http("127.0.0.1:0").map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
||||
let port = server
|
||||
.server_addr()
|
||||
|
||||
@@ -4,16 +4,16 @@ const wait=(ms=80)=>new Promise(r=>setTimeout(r,ms));const until=async(f:()=>unk
|
||||
const meta=(pkg:string,name:string,launch=true)=>({package:pkg,name,version:'1.2.3',versionCode:123,minSdk:24,activities:launch?[`${pkg}.Main`]:[],declaredActivities:[`${pkg}.Main`],abis:['arm64-v8a'],vr:false});
|
||||
const apps:any[]=[{id:'framely-wolvic/com.igalia.wolvic',context:'framely-wolvic',metadata:meta('com.igalia.wolvic','Wolvic'),installed:true,running:false,managed:true,size:128*1024*1024},{id:'dev/com.example.service',context:'dev',metadata:meta('com.example.service','Background service',false),installed:true,running:true,managed:false,size:20*1024*1024},{id:'old/com.example.notes',context:'old',metadata:meta('com.example.notes','Notes'),installed:false,running:false,managed:true,size:4*1024*1024}];
|
||||
const containers=[{acceptsAdditionalApps:false,id:'framely-wolvic',name:'framely-wolvic',managed:true,running:false,baked:'/home/steamos/.local/share/lepton/contexts/framely-wolvic/baked'},{acceptsAdditionalApps:true,id:'dev',name:'dev',managed:false,running:true,baked:'/home/steamos/.local/share/lepton/contexts/dev/baked'},{id:'old',name:'old',managed:true,running:false,baked:'/home/steamos/.local/share/lepton/contexts/old/baked'}];
|
||||
const calls:{method:string;params:any}[]=[];const jobs=new Map<string,any>();let counter=0,fail=false,cleanupItems:any[]=[{id:'data:old/com.example.notes',kind:'data',name:'Notes',bytes:4*1024*1024,sensitive:true,app:'old/com.example.notes'},{id:'backups:backup1',kind:'backups',name:'20261005-backup',bytes:128*1024*1024,sensitive:true,context:'framely-wolvic',restorable:true},{id:'unknown:dev/com.example.orphan',kind:'unknown',name:'com.example.orphan',bytes:20,sensitive:true,unknown:true}];
|
||||
const calls:{method:string;params:any}[]=[];const jobs=new Map<string,any>();let counter=0,fail=false,gamepadEnabled=false,cleanupItems:any[]=[{id:'data:old/com.example.notes',kind:'data',name:'Notes',bytes:4*1024*1024,sensitive:true,app:'old/com.example.notes'},{id:'backups:backup1',kind:'backups',name:'20261005-backup',bytes:128*1024*1024,sensitive:true,context:'framely-wolvic',restorable:true},{id:'unknown:dev/com.example.orphan',kind:'unknown',name:'com.example.orphan',bytes:20,sensitive:true,unknown:true}];
|
||||
window.fetch=async(url,init)=>{if(String(url).startsWith('/api/upload/')){const offset=+String(url).split('/').at(-1)!;return new Response(JSON.stringify({result:{received:offset+(init?.body as Blob).size}}));}const{method,params}=JSON.parse(String(init?.body));calls.push({method,params});let result:any=true;
|
||||
if(method==='apk.upload.start')result={upload:'fixture-upload',chunkSize:512*1024};
|
||||
else if(method==='apk.cleanup.list')result=cleanupItems;
|
||||
else if(method==='apk.logs')result={name:'apk.log',text:'Installation complete\nAndroid package manager: Success'};
|
||||
else if(method==='apk.list.start'||method==='apk.inspect.start'||method==='apk.operation.start'){
|
||||
const id='job-'+counter++;let value:any=true;
|
||||
if(method==='apk.list.start')value={apps:structuredClone(apps),containers,roots:[],warnings:[],available:true};
|
||||
if(method==='apk.list.start')value={apps:structuredClone(apps),containers,roots:[],warnings:[],available:true,gamepadEnabled};
|
||||
else if(method==='apk.inspect.start')value={ticket:'review-ticket',metadata:meta('com.example.new','New APK'),bytes:4};
|
||||
else{const app=apps.find(a=>a.id===params.app);if(params.operation==='settings')Object.assign(app,{activity:params.activity,showWindow:params.showWindow});if(params.operation==='uninstall')app.installed=false;if(params.operation==='cleanup'){cleanupItems=cleanupItems.filter(i=>!params.items.includes(i.id));value={freedBytes:4*1024*1024,failed:[]};}}
|
||||
else{if(params.operation==='gamepad.settings')gamepadEnabled=params.enabled;const app=apps.find(a=>a.id===params.app);if(params.operation==='settings')Object.assign(app,{activity:params.activity,showWindow:params.showWindow});if(params.operation==='uninstall')app.installed=false;if(params.operation==='cleanup'){cleanupItems=cleanupItems.filter(i=>!params.items.includes(i.id));value={freedBytes:4*1024*1024,failed:[]};}}
|
||||
jobs.set(id,{id,kind:'apk',phase:fail&&method==='apk.operation.start'?'failed':'done',result:value,error:'Fixture signature mismatch',received:0});result={job:id};
|
||||
}else if(method==='job.status')result=jobs.get(params.job);
|
||||
return new Response(JSON.stringify({result}));};
|
||||
@@ -21,7 +21,11 @@ const root=createRoot(document.getElementById('root')!);function render(){root.r
|
||||
const button=(text:string,scope:ParentNode=document)=>Array.from(scope.querySelectorAll<HTMLButtonElement>('button')).find(b=>b.textContent?.trim()===text)??(()=>{throw Error('Missing button '+text)})();
|
||||
async function snapshot(name:string){await wait(220);console.log('FRAMELY_PREVIEW_INSTALL_'+name);await wait(180);}
|
||||
(async()=>{
|
||||
configureLanguage('en-US',[]);render();await until(()=>document.querySelectorAll('.apk-row').length===2);const content=document.querySelector('.content')!.getBoundingClientRect(),manager=document.querySelector('.apk-manager')!.getBoundingClientRect();if(Math.abs(manager.width-(content.width-64))>2&&innerWidth>1000)throw Error('Manager width does not fill content');const search=document.querySelector<HTMLInputElement>('.apk-filters input')!.getBoundingClientRect(),filter=document.querySelector<HTMLButtonElement>('.apk-filters .framely-select>button')!.getBoundingClientRect();if(Math.abs(search.y-filter.y)>1||Math.abs(search.height-filter.height)>1||search.right>=filter.left)throw Error('APK search/filter controls are not aligned');await snapshot('APK_EN');
|
||||
configureLanguage('en-US',[]);render();await until(()=>document.querySelectorAll('.apk-row').length===2);const content=document.querySelector('.content')!.getBoundingClientRect(),manager=document.querySelector('.apk-manager')!.getBoundingClientRect();const padding=getComputedStyle(document.querySelector('.content')!);if(Math.abs(manager.width-(document.querySelector('.content')!.clientWidth-parseFloat(padding.paddingLeft)-parseFloat(padding.paddingRight)))>2)throw Error(`Manager width does not fill content: ${manager.width}/${content.width}, ${padding.paddingLeft}/${padding.paddingRight}`);const search=document.querySelector<HTMLInputElement>('.apk-filters input')!.getBoundingClientRect(),filter=document.querySelector<HTMLButtonElement>('.apk-filters .framely-select>button')!.getBoundingClientRect();if(Math.abs(search.y-filter.y)>1||Math.abs(search.height-filter.height)>1||search.right>=filter.left)throw Error('APK search/filter controls are not aligned');await snapshot('APK_EN');
|
||||
const gamepad=()=>document.querySelector<HTMLButtonElement>('[role=switch][aria-label="Frame gamepad input"]')!;
|
||||
if(gamepad().getAttribute('aria-checked')!=='false')throw Error('Gamepad input does not default off');
|
||||
gamepad().click();await until(()=>gamepad().getAttribute('aria-checked')==='true'&&!gamepad().disabled);if(!gamepadEnabled)throw Error('Gamepad switch was not persisted');
|
||||
gamepad().click();await until(()=>gamepad().getAttribute('aria-checked')==='false'&&!gamepad().disabled);if(gamepadEnabled)throw Error('Gamepad input did not turn off');
|
||||
// Container startup and shutdown are not data deletion operations.
|
||||
button('Containers').click();await until(()=>document.querySelectorAll('.apk-container').length===3);
|
||||
for(const title of ['Start container','Stop container']){button(title).click();await until(()=>!!document.querySelector('.apk-dialog'));if(document.querySelector('.apk-dialog')!.textContent!.includes(t('永久删除的数据无法撤销。')))throw Error('Container operation displayed data deletion warning');if(!button('Confirm',document.querySelector('.apk-dialog')!).classList.contains('primary'))throw Error('Non-destructive container confirmation uses destructive styling');button('Cancel',document.querySelector('.apk-dialog')!).click();await until(()=>!document.querySelector('.apk-dialog'));}
|
||||
|
||||
@@ -7,3 +7,7 @@ mkdir -p "$out"
|
||||
(cd "$base" && node tools/build-scroll-gestures.mjs "$out")
|
||||
openvr="$base/native/vendor/openvr"
|
||||
g++ -std=c++17 -O2 -I"$cef" -I"$openvr" -I"$base/native" -I"$out" "$base/native/host.cpp" -L"$cef/Release" -L"$openvr/lib/linuxarm64" -lcef -lopenvr_api -lGL -lX11 -pthread -Wl,-rpath,'$ORIGIN:$ORIGIN/../openvr' -o "$out/framely-vr"
|
||||
|
||||
# Independent of CEF; both binaries ship in the core/update archive.
|
||||
g++ -std=c++17 -O2 -I"$openvr" "$base/native/gamepad.cpp" -L"$openvr/lib/linuxarm64" -lopenvr_api -pthread -Wl,-rpath,'$ORIGIN' -o "$out/framely-gamepad"
|
||||
gcc -shared -fPIC -nostdlib -fno-stack-protector -Wl,--hash-style=both "$base/native/gamepad_grab.c" -o "$out/libframely-gamepad-grab.so"
|
||||
@@ -23,6 +23,7 @@ rm -f "$stage/lib/cef/framely-probe" "$stage/lib/cef/framely-browser-probe"
|
||||
# Remove distribution debug information; browser functionality is unchanged.
|
||||
strip --strip-debug "$stage/lib/cef/libcef.so"
|
||||
cp native/vendor/openvr/lib/linuxarm64/libopenvr_api.so "$stage/lib/openvr/"
|
||||
cp target/native/framely-gamepad target/native/libframely-gamepad-grab.so "$stage/lib/openvr/"
|
||||
cp -a ui/dist/. "$stage/share/ui/"
|
||||
cp -a assets/search "$stage/share/"
|
||||
cp assets/search/COPYING "$stage/share/licenses/ipadic.txt"
|
||||
|
||||
@@ -904,6 +904,9 @@
|
||||
"调整侧栏宽度": "Resize sidebar",
|
||||
"文件操作": "File actions",
|
||||
"Ctrl+Shift+C 复制 · Ctrl+Shift+V 粘贴 · Ctrl+C 中断程序": "Ctrl+Shift+C copy · Ctrl+Shift+V paste · Ctrl+C interrupt",
|
||||
"无法复制,请使用浏览器的复制功能。": "Unable to copy. Use the browser copy command."
|
||||
"无法复制,请使用浏览器的复制功能。": "Unable to copy. Use the browser copy command.",
|
||||
"保存手柄设置": "Save gamepad settings",
|
||||
"Frame 手柄输入": "Frame gamepad input",
|
||||
"默认关闭。将 Frame 手柄用作标准游戏手柄;开启后下次打开 APK 生效,必要时重启容器。关闭立即停止输入。": "Off by default. Use Frame controllers as a standard gamepad. Enabling takes effect at the next APK launch and may restart its container. Disabling stops input immediately."
|
||||
}
|
||||
}
|
||||
@@ -904,6 +904,9 @@
|
||||
"调整侧栏宽度": "调整侧栏宽度",
|
||||
"文件操作": "文件操作",
|
||||
"Ctrl+Shift+C 复制 · Ctrl+Shift+V 粘贴 · Ctrl+C 中断程序": "Ctrl+Shift+C 复制 · Ctrl+Shift+V 粘贴 · Ctrl+C 中断程序",
|
||||
"无法复制,请使用浏览器的复制功能。": "无法复制,请使用浏览器的复制功能。"
|
||||
"无法复制,请使用浏览器的复制功能。": "无法复制,请使用浏览器的复制功能。",
|
||||
"保存手柄设置": "保存手柄设置",
|
||||
"Frame 手柄输入": "Frame 手柄输入",
|
||||
"默认关闭。将 Frame 手柄用作标准游戏手柄;开启后下次打开 APK 生效,必要时重启容器。关闭立即停止输入。": "默认关闭。将 Frame 手柄用作标准游戏手柄;开启后下次打开 APK 生效,必要时重启容器。关闭立即停止输入。"
|
||||
}
|
||||
}
|
||||
@@ -11,13 +11,13 @@ import './apk-manager.css';
|
||||
type Metadata={package:string;name:string;version:string;versionCode:number;minSdk:number;activities:string[];declaredActivities:string[];icon?:string;abis:string[];vr:boolean};
|
||||
type App={stateKnown?:boolean;id:string;context:string;metadata:Metadata;installed:boolean;running:boolean;managed:boolean;steam:boolean;size:number;pending?:string;activity?:string;showWindow?:boolean|null;orientation?:string|null};
|
||||
type Container={acceptsAdditionalApps?:boolean;id:string;name:string;baked:string;running:boolean;managed:boolean;steam:boolean};
|
||||
type Data={apps:App[];containers:Container[];warnings:string[];roots:string[];available:boolean};
|
||||
type Data={apps:App[];containers:Container[];warnings:string[];roots:string[];available:boolean;gamepadEnabled?:boolean};
|
||||
type Cleanup={id:string;kind:string;name:string;bytes:number;sensitive:boolean;app?:string;context?:string;restorable?:boolean;unknown?:boolean};
|
||||
type Review={ticket:string;metadata:Metadata;bytes:number};
|
||||
type Confirm={operation:string;app?:App;container?:Container;backup?:string;items?:string[];title:string;description:string;purge?:boolean};
|
||||
function bytes(n:number){return n<1024?`${n} B`:n<1024*1024?`${(n/1024).toFixed(1)} KiB`:n<1024**3?`${(n/1024**2).toFixed(1)} MiB`:`${(n/1024**3).toFixed(2)} GiB`;}
|
||||
const phases:Record<string,string>={loading:'正在读取',preparing:'准备中',uploading:'上传 APK',inspecting:'校验 APK',starting:'启动容器',launching:'启动应用',installing:'安装 APK',verifying:'核对安装结果','backing-up':'备份容器数据',uninstalling:'卸载应用',done:'完成',failed:'失败',cancelled:'操作已取消'};
|
||||
const operationLabels:Record<string,string>={install:'安装 APK',launch:'启动应用',close:'关闭应用',uninstall:'卸载应用',purge:'清除保留数据',clear:'清除应用数据',reconcile:'核对应用状态',settings:'保存设置','container.start':'启动容器','container.stop':'停止容器','container.delete':'删除容器',restore:'恢复备份',cleanup:'数据清理','root.add':'添加数据位置','root.remove':'移除数据位置'};
|
||||
const operationLabels:Record<string,string>={'gamepad.settings':'保存手柄设置',install:'安装 APK',launch:'启动应用',close:'关闭应用',uninstall:'卸载应用',purge:'清除保留数据',clear:'清除应用数据',reconcile:'核对应用状态',settings:'保存设置','container.start':'启动容器','container.stop':'停止容器','container.delete':'删除容器',restore:'恢复备份',cleanup:'数据清理','root.add':'添加数据位置','root.remove':'移除数据位置'};
|
||||
export function ApkManager({view:externalView,onViewChange}:{view?:string;onViewChange?:(view:string)=>void}={}){
|
||||
const[data,setData]=useState<Data>({apps:[],containers:[],warnings:[],roots:[],available:true}),[loading,setLoading]=useState(true),[localView,setLocalView]=useState('apps'),[filter,setFilter]=useState('installed'),[query,setQuery]=useState(''),[error,setError]=useState(''),[review,setReview]=useState<Review|null>(null),[target,setTarget]=useState(''),[displayMode,setDisplayMode]=useState('flat'),[updating,setUpdating]=useState<App|null>(null),[details,setDetails]=useState<App|null>(null),[confirm,setConfirm]=useState<Confirm|null>(null),[job,setJob]=useState<(Job&{cancellable?:boolean;operation?:string})|null>(null),[busy,setBusy]=useState(false),[cleanup,setCleanup]=useState<Cleanup[]>([]),[selected,setSelected]=useState<string[]>([]),[log,setLog]=useState<{name:string;text:string}|null>(null),[path,setPath]=useState(''),[contextName,setContextName]=useState(''),[notice,setNotice]=useState('');
|
||||
const view=externalView??localView;const setView=(value:string)=>{setLocalView(value);onViewChange?.(value);};
|
||||
@@ -49,7 +49,7 @@ export function ApkManager({view:externalView,onViewChange}:{view?:string;onView
|
||||
{!data.available&&<p className="banner">{t('未检测到 Lepton,请先通过 Steam 安装。')}</p>}
|
||||
{notice&&<p className="apk-notice" role="status">{notice}</p>}{error&&<div className="error apk-error" role="alert">{error}<button aria-label={t('关闭')} onClick={()=>setError('')}><IconX size={20}/></button></div>}
|
||||
{!externalView&&<div className="apk-tabs" role="tablist">{[['apps','应用'],['containers','容器'],['cleanup','数据清理']].map(([key,label])=><button key={key} role="tab" aria-selected={view===key} className={view===key?'active':''} disabled={busy} onClick={()=>{setView(key);if(key==='cleanup')void loadCleanup();}}>{t(label)}</button>)}</div>}
|
||||
{view==='apps'&&<><div className="apk-filters"><input type="search" aria-label={t('搜索 APK')} placeholder={t('搜索名称、包名或容器')} value={query} onChange={e=>setQuery(e.target.value)}/><Select label={t('应用筛选')} value={filter} onChange={setFilter} options={[{value:'installed',label:t('已安装')},{value:'retained',label:t('已卸载但保留数据')},{value:'all',label:t('全部')}]}/></div><div className="apk-list">{listed.map(a=><article className="apk-row" key={a.id}><PluginImage src={a.metadata.icon} name={a.metadata.name} size={64}/><div className="apk-copy"><h2>{a.metadata.name}<small>{a.metadata.version}</small></h2><code>{a.metadata.package}</code><p>{contextFor(a.context)?.name??a.context} · {bytes(a.size)} · {t(a.stateKnown===false?'安装状态未知':a.installed?(a.running?'容器运行中':'已安装'):'已卸载但保留数据')}</p>{a.pending&&<p className="error">{t(a.pending)}</p>}{a.installed&&!a.metadata.activities.length&&!a.activity&&<p>{t('未发现启动入口')}</p>}</div><div className="apk-actions">{a.installed&&a.stateKnown!==false&&<button disabled={busy||!a.metadata.activities.length&&!a.activity} onClick={()=>void perform('launch',{app:a.id})}><IconPlayerPlay size={20}/>{t('打开')}</button>}<button disabled={busy} onClick={()=>setDetails(a)}>{t('管理')}</button></div></article>)}</div>{!listed.length&&<div className="empty"><IconBrandAndroid size={40}/><p>{loading?t('正在读取'):t('没有匹配的 APK 应用。')}</p></div>}</>}
|
||||
{view==='apps'&&<><SwitchRow label={t('Frame 手柄输入')} description={t('默认关闭。将 Frame 手柄用作标准游戏手柄;开启后下次打开 APK 生效,必要时重启容器。关闭立即停止输入。')} checked={!!data.gamepadEnabled} disabled={busy||loading||!data.available&&!data.gamepadEnabled} onChange={enabled=>void perform('gamepad.settings',{enabled})}/><div className="apk-filters"><input type="search" aria-label={t('搜索 APK')} placeholder={t('搜索名称、包名或容器')} value={query} onChange={e=>setQuery(e.target.value)}/><Select label={t('应用筛选')} value={filter} onChange={setFilter} options={[{value:'installed',label:t('已安装')},{value:'retained',label:t('已卸载但保留数据')},{value:'all',label:t('全部')}]}/></div><div className="apk-list">{listed.map(a=><article className="apk-row" key={a.id}><PluginImage src={a.metadata.icon} name={a.metadata.name} size={64}/><div className="apk-copy"><h2>{a.metadata.name}<small>{a.metadata.version}</small></h2><code>{a.metadata.package}</code><p>{contextFor(a.context)?.name??a.context} · {bytes(a.size)} · {t(a.stateKnown===false?'安装状态未知':a.installed?(a.running?'容器运行中':'已安装'):'已卸载但保留数据')}</p>{a.pending&&<p className="error">{t(a.pending)}</p>}{a.installed&&!a.metadata.activities.length&&!a.activity&&<p>{t('未发现启动入口')}</p>}</div><div className="apk-actions">{a.installed&&a.stateKnown!==false&&<button disabled={busy||!a.metadata.activities.length&&!a.activity} onClick={()=>void perform('launch',{app:a.id})}><IconPlayerPlay size={20}/>{t('打开')}</button>}<button disabled={busy} onClick={()=>setDetails(a)}>{t('管理')}</button></div></article>)}</div>{!listed.length&&<div className="empty"><IconBrandAndroid size={40}/><p>{loading?t('正在读取'):t('没有匹配的 APK 应用。')}</p></div>}</>}
|
||||
{view==='containers'&&<><div className="apk-list">{data.containers.map(c=><article className="apk-container" key={c.id}><div className="apk-copy"><h2><IconFolder size={24}/>{c.name}</h2><code>{c.baked}</code><p>{t(c.steam?'Steam 管理':c.managed?'Framely 管理':'已有侧载容器')} · {t(c.running?'运行中':'已停止')}</p><p>{data.apps.filter(a=>a.context===c.id&&a.installed).map(a=>a.metadata.name).join(', ')||t('未发现已安装的应用,启动容器不会安装 APK。')}</p></div><div className="apk-actions"><button disabled={busy} onClick={()=>void showLog(c.id)}><IconFileText size={20}/>{t('日志')}</button>{!c.steam&&<><button disabled={busy} onClick={()=>containerAsk(c,c.running?'container.stop':'container.start')}>{t(c.running?'停止容器':'启动容器')}</button><button className="danger" disabled={busy} onClick={()=>containerAsk(c,'container.delete')}>{t('删除容器')}</button></>}</div></article>)}</div><section className="apk-locations"><h2>{t('其他数据位置')}</h2><p>{t('添加其他工具使用的 baked 数据目录,不移动原有应用。')}</p><div className="apk-filters"><input value={path} placeholder="/path/to/compatdata/baked" aria-label={t('Lepton 数据目录')} onChange={e=>setPath(e.target.value)}/><input value={contextName} placeholder={t('原容器名称(可选)')} aria-label={t('原容器名称(可选)')} onChange={e=>setContextName(e.target.value)}/><button disabled={busy||!path.trim()} onClick={()=>void perform('root.add',{path,context:contextName}).then(()=>setPath(''))}>{t('添加')}</button></div>{data.roots.map(p=><div className="apk-location" key={p}><code>{p}</code><button disabled={busy} onClick={()=>void perform('root.remove',{path:p})}>{t('移除扫描位置')}</button></div>)}</section></>}
|
||||
{view==='cleanup'&&<><div className="apk-cleanup-heading"><div><h2>{t('数据清理')}</h2><p>{t('存档、设置与恢复备份默认不选中。未知归属的数据不会自动删除。')}</p></div><button className="danger" disabled={busy||!selected.length} onClick={()=>setConfirm({operation:'cleanup',items:selected,title:t('确认数据清理'),description:cleanup.filter(i=>selected.includes(i.id)).map(i=>`${i.name} · ${bytes(i.bytes)}`).join('\n')+`\n${t('选中的文件将永久删除,无法撤销。')}`})}><IconTrash size={22}/>{t('清理所选')}</button></div>{cleanup.map(i=><article className="apk-cleanup-row" key={i.id}><Switch checked={selected.includes(i.id)} label={`${t('选择')} ${i.name}`} disabled={busy||i.unknown} onChange={checked=>setSelected(s=>checked?[...s,i.id]:s.filter(v=>v!==i.id))}/><div className="apk-copy"><b>{i.name}</b><p>{t(i.kind==='unknown'?'未知归属的数据':i.kind==='data'?'保留的应用数据':i.kind==='backups'?'恢复备份':(i.kind==='reviews'||i.kind==='uploads')?'过期上传文件':'旧 APK')} · {bytes(i.bytes)}</p>{i.unknown?<small>{t('无法确认归属,不提供自动删除。')}</small>:i.sensitive&&<small>{t('删除后不可恢复')}</small>}</div>{i.restorable&&<button disabled={busy} onClick={()=>setConfirm({operation:'restore',container:contextFor(i.context!),backup:i.name,title:t('恢复容器备份'),description:t('恢复将替换该容器内全部应用的状态;先保存恢复前备份,之后需启动验证。')})}>{t('恢复')}</button>}</article>)}{!cleanup.length&&<p className="empty">{t('没有可清理的数据。')}</p>}</>}
|
||||
{!!data.warnings.length&&<details className="apk-warnings"><summary>{t('部分容器无法读取')}</summary>{data.warnings.map((w,i)=><p key={i}>{w}</p>)}</details>}
|
||||
|
||||
Reference in new issue
Block a user