Add mobile terminal controls and bilingual workflow coverage

This commit is contained in:
toorux committed 2026-10-09 18:19:50 +08:00
1 parent 423abf3007
commit a72cf7b3c1
6 files changed
+80 -4

No files matched your search

+9 -1
View File
@@ -6,10 +6,18 @@
## Manager navigation
The top switcher contains **Plugin / APK / Terminal / Files / Settings**. Opening the device IP without a fragment starts in Plugin. Each section remembers its last subpage in the browser. Existing `#settings`, `#launcher-settings`, `#about` and `#updates` links continue to work.
On desktop, the top switcher contains **Plugin / APK / Terminal / Files / Settings**. Opening the device IP without a fragment starts in Plugin. Each section remembers its last subpage in the browser. Existing `#settings`, `#launcher-settings`, `#about` and `#updates` links continue to work.
Plugin contains installed plugins, the plugin library, sources and notification settings, including Framely's notifications. Plugin-specific settings remain in each plugin's management dialog. APK keeps its apps, containers and retained-data cleanup. Settings contains General, Launcher, Updates and About.
## Mobile controls
On phones, Plugin, APK, Terminal, Files and Settings use a bottom navigation bar. Tap the current page name in the header to choose a subpage. Dialogs open as bottom sheets with touch-sized controls. The layout reserves device safe areas and adjusts to the on-screen keyboard; the bottom navigation hides while the keyboard is open.
In Files, **Browse folders** opens Quick access and the folder tree in a drawer. Selecting a folder closes the drawer. **Create and upload** and **Filters and view** open task sheets. Tap **Select**, select files, then use **Actions** in the selection bar for copying, moving, downloading or deleting. No right-click is needed. The upload queue continues while browsing or changing sections.
Terminal provides Ctrl+C, Esc, Tab and arrow buttons above the keyboard. Ctrl+C interrupts the running program. **Paste** uses the browser clipboard when permitted; otherwise it opens a text field for the phone's native paste action and requires **Send to terminal**. The bar disables input while disconnected or read-only.
## Interaction feedback
In **Settings → General → Interaction feedback**, Haptic feedback is enabled by default; Sound feedback is disabled by default. Both preferences are saved across restarts. Haptic feedback controls laser hover and launcher gaze-focus vibration across Framely and plugin pages; it does not change APK game rumble. Sound feedback uses Steam’s installed keyboard sound at reduced volume for focus and clicks. It requires the local Steam sound files and audio runtime; no sound files are bundled or downloaded.
@@ -10,6 +10,14 @@
插件区域包含已安装插件、插件库、来源和通知设置,通知仍包含 Framely 本体;插件自身设置位于对应插件的管理弹窗。APK 保留应用、容器及数据清理。设置集中放置通用、启动台、更新和关于。
## 手机操作
手机端底部导航包含插件、APK、终端、文件和设置。点击顶部当前页面名称选择子页面,确认和安装弹窗以底部面板展示。界面为安全区和软键盘保留空间,键盘展开时隐藏底部导航。
文件页点击“浏览文件夹”打开快速访问与目录树抽屉,选择目录后自动收起。“新建与上传”和“筛选与显示”打开操作面板;点击“选择”勾选文件,再从底部选择栏的“操作”进入复制、移动、下载或删除,无需右键。浏览目录和切换区域不停止上传队列。
终端提供 Ctrl+C、Esc、Tab 和方向快捷键,其中 Ctrl+C 用于中断程序。“粘贴”在浏览器允许时读取剪贴板,否则打开文本框,使用手机原生粘贴后点击“发送到终端”。断连或只读时禁用输入快捷键。
## 交互反馈
在 **设置 → 通用 → 交互反馈** 中,振动反馈默认开启,提示音反馈默认关闭,修改后会保存。振动开关统一控制 Framely 和插件页面的激光悬停、启动台眼动聚焦振动,不影响 APK 游戏振动。提示音开关使用 Steam 本机键盘音效,以较低音量反馈聚焦和点击,需要本机 Steam 音效文件与音频运行库,不打包或下载这些音效。
+1 -1
View File
@@ -42,7 +42,7 @@ int main(int argc,char** argv){
cef_main_args_t args{argc,argv};if(std::strcmp(cef_api_hash(CEF_API_VERSION,0),CEF_API_HASH_PLATFORM))return 2;int child=cef_execute_process(&args,nullptr,nullptr);if(child>=0)return child;if(url.empty()||runtime.empty()||cef.empty())return 2;
cef_settings_t settings{};settings.size=sizeof(settings);settings.windowless_rendering_enabled=1;str(settings.root_cache_path,runtime+"/cache");str(settings.log_file,runtime+"/cef.log");str(settings.resources_dir_path,cef+"/Resources");str(settings.locales_dir_path,cef+"/Resources/locales");
if(!cef_initialize(&args,&settings,nullptr,nullptr))return 3;
render.api.get_view_rect=[](cef_render_handler_t*,cef_browser_t*,cef_rect_t* r){*r=launcher_mode?cef_rect_t{0,0,1440,800}:std::getenv("FRAMELY_STORE_TEST")?cef_rect_t{0,0,1440,810}:cef_rect_t{0,0,600,notification_mode?360:840};};render.api.get_screen_info=[](cef_render_handler_t*,cef_browser_t*,cef_screen_info_t* info){if(launcher_mode)return render_screen_info(info,1440,800,1.5f);return render_screen_info(info,std::getenv("FRAMELY_STORE_TEST")?1440:600,std::getenv("FRAMELY_STORE_TEST")?810:(notification_mode?360:840),std::getenv("FRAMELY_STORE_TEST")?4:2);};render.api.on_paint=[](cef_render_handler_t*,cef_browser_t*,cef_paint_element_type_t type,size_t dirty_count,const cef_rect_t* dirty,const void* data,int w,int h){if(type!=PET_VIEW)return;++paints;if(w==(launcher_mode?2160:std::getenv("FRAMELY_STORE_TEST")?5760:1200)&&h==(launcher_mode?1200:std::getenv("FRAMELY_STORE_TEST")?3240:(notification_mode?720:1680)))scaled_paint=true;pixel_width=w;pixel_height=h;merge_paint(pixels,data,w,h,dirty_count,dirty);// Semi-transparent charcoal panels still need a readable surface and clear rounded corners.
render.api.get_view_rect=[](cef_render_handler_t*,cef_browser_t*,cef_rect_t* r){*r=launcher_mode?cef_rect_t{0,0,1440,800}:std::getenv("FRAMELY_STORE_TEST")?cef_rect_t{0,0,1440,810}:cef_rect_t{0,0,std::getenv("FRAMELY_TEST_VIEW_WIDTH")?std::atoi(std::getenv("FRAMELY_TEST_VIEW_WIDTH")):600,notification_mode?360:840};};render.api.get_screen_info=[](cef_render_handler_t*,cef_browser_t*,cef_screen_info_t* info){if(launcher_mode)return render_screen_info(info,1440,800,1.5f);return render_screen_info(info,std::getenv("FRAMELY_STORE_TEST")?1440:(std::getenv("FRAMELY_TEST_VIEW_WIDTH")?std::atoi(std::getenv("FRAMELY_TEST_VIEW_WIDTH")):600),std::getenv("FRAMELY_STORE_TEST")?810:(notification_mode?360:840),std::getenv("FRAMELY_STORE_TEST")?4:2);};render.api.on_paint=[](cef_render_handler_t*,cef_browser_t*,cef_paint_element_type_t type,size_t dirty_count,const cef_rect_t* dirty,const void* data,int w,int h){if(type!=PET_VIEW)return;++paints;if(w==(launcher_mode?2160:std::getenv("FRAMELY_STORE_TEST")?5760:(std::getenv("FRAMELY_TEST_VIEW_WIDTH")?std::atoi(std::getenv("FRAMELY_TEST_VIEW_WIDTH"))*2:1200))&&h==(launcher_mode?1200:std::getenv("FRAMELY_STORE_TEST")?3240:(notification_mode?720:1680)))scaled_paint=true;pixel_width=w;pixel_height=h;merge_paint(pixels,data,w,h,dirty_count,dirty);// Semi-transparent charcoal panels still need a readable surface and clear rounded corners.
if(!notification_mode&&w>0&&h>0){auto alpha=[&](int x,int y){return pixels[(y*w+x)*4+3];};if(alpha(0,0)==0&&alpha(w-1,0)==0&&alpha(0,h-1)==0&&alpha(w-1,h-1)==0&&alpha(w/2,40)>200)rounded_paint=true;}};
display.api.on_console_message=[](cef_display_handler_t*,cef_browser_t* b,cef_log_severity_t,const cef_string_t* m,const cef_string_t*,int){auto s=text(m);std::cout<<s<<"\n";if(s.rfind("FRAMELY_PAGE_WHEEL_",0)==0){int x=0,y=0,dx=0,dy=0;if(std::sscanf(s.c_str(),"FRAMELY_PAGE_WHEEL_%d_%d_%d_%d",&x,&y,&dx,&dy)==4){auto* host=b->get_host(b);cef_mouse_event_t mouse{};mouse.x=x;mouse.y=y;host->send_mouse_move_event(host,&mouse,0);host->send_mouse_wheel_event(host,&mouse,dx,dy);host->base.release(&host->base);}}if(s.rfind("FRAMELY_NATIVE_KEYBOARD_",0)==0){auto* frame=b->get_focused_frame(b);auto command=s.substr(24);if(command=="TEXT")keyboard_edit(frame,"汉😃");else if(command=="BACKSPACE")keyboard_edit(frame,"\b");else if(command=="ENTER")keyboard_edit(frame,"\n");else if(command=="X")keyboard_edit(frame,"x");else if(command=="Y")keyboard_edit(frame,"y");else if(command=="LEFT")keyboard_edit(frame,"\x1b[D");else if(command=="RIGHT")keyboard_edit(frame,"\x1b[C");frame->base.release(&frame->base);}if(s=="FRAMELY_LAUNCHER_CANCEL_PRESS"){auto* pointer_host=b->get_host(b);cef_mouse_event_t outside{};outside.modifiers=EVENTFLAG_LEFT_MOUSE_BUTTON;pointer_host->send_mouse_move_event(pointer_host,&outside,0);outside.modifiers=0;pointer_host->send_mouse_click_event(pointer_host,&outside,MBT_LEFT,1,1);test_pointer.clear();press_pending=false;auto* frame=b->get_main_frame(b);cef_string_t code{},source{};str(code,"window.dispatchEvent(new Event('framely.launcher.cancelInput'))");frame->execute_java_script(frame,&code,&source,1);cef_string_utf16_clear(&code);frame->base.release(&frame->base);pointer_host->base.release(&pointer_host->base);}if(s.rfind("FRAMELY_LAUNCHER_DRAG_",0)==0){int x=0,y=0;if(press_pending&&std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_DRAG_%d_%d",&x,&y)==2){press_dx=x-test_pointer.x;press_dy=y-test_pointer.y;}}if(s.rfind("FRAMELY_LAUNCHER_PRESS_",0)==0){int x=0,y=0;if(std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_PRESS_%d_%d_%d_%d_%d",&x,&y,&press_dx,&press_dy,&press_duration)==5){auto* pointer_host=b->get_host(b);test_pointer.begin(x,y,std::chrono::steady_clock::now());press_pending=true;cef_mouse_event_t mouse{};mouse.x=x;mouse.y=y;pointer_host->set_focus(pointer_host,1);pointer_host->send_mouse_move_event(pointer_host,&mouse,0);mouse.modifiers=EVENTFLAG_LEFT_MOUSE_BUTTON;pointer_host->send_mouse_click_event(pointer_host,&mouse,MBT_LEFT,0,1);pointer_host->base.release(&pointer_host->base);}}if(s.rfind("FRAMELY_LAUNCHER_HOVER_",0)==0){int x=0,y=0;if(std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_HOVER_%d_%d",&x,&y)==2){auto* hover_host=b->get_host(b);cef_mouse_event_t mouse{};mouse.x=x;mouse.y=y;hover_host->send_mouse_move_event(hover_host,&mouse,0);hover_host->base.release(&hover_host->base);}}if(s.rfind("FRAMELY_LAUNCHER_REGION_",0)==0){int x=0,y=0,w=0,h=0;if(std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_REGION_%d_%d_%d_%d",&x,&y,&w,&h)==4){const float scale=launcher_mode?1.5f:4.f;int ink=0;for(int py=std::max(0,int(y*scale));py<std::min(pixel_height,int((y+h)*scale));py++)for(int px=std::max(0,int(x*scale));px<std::min(pixel_width,int((x+w)*scale));px++)if(pixels[(size_t(py)*pixel_width+px)*4+3]>128)ink++;if(ink<30){failed=true;std::cerr<<"FRAMELY_BRIDGE_FAIL missing painted control "<<x<<","<<y<<"\n";}}}if(s.rfind("FRAMELY_LAUNCHER_INK_",0)==0){int x=0,y=0,w=0,h=0;if(std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_INK_%d_%d_%d_%d",&x,&y,&w,&h)==4){const float scale=launcher_mode?1.5f:4.f;int ink=0;for(int py=std::max(0,int(y*scale));py<std::min(pixel_height,int((y+h)*scale));py++)for(int px=std::max(0,int(x*scale));px<std::min(pixel_width,int((x+w)*scale));px++){size_t i=(size_t(py)*pixel_width+px)*4;if(pixels[i+3]>128&&pixels[i]>180&&pixels[i+1]>180&&pixels[i+2]>180)ink++;}if(ink<30){failed=true;std::cerr<<"FRAMELY_BRIDGE_FAIL missing painted label "<<x<<","<<y<<" ink="<<ink<<"\n";}}}
if(s=="FRAMELY_DPI_CLICK_REQUEST"){auto* host=b->get_host(b);cef_mouse_event_t mouse{};mouse.x=50;mouse.y=45;host->set_focus(host,1);host->send_mouse_move_event(host,&mouse,0);host->send_mouse_click_event(host,&mouse,MBT_LEFT,0,1);host->send_mouse_click_event(host,&mouse,MBT_LEFT,1,1);host->base.release(&host->base);}if(s=="FRAMELY_DPI_CLICK_PASS")dpi_click=true;if(s.rfind("FRAMELY_PREVIEW_INSTALL_",0)==0)snapshot(s.substr(16));if(s=="FRAMELY_PREVIEW_STORE")snapshot("store");if(s=="FRAMELY_PREVIEW_FAVORITES")snapshot("favorites");if(s=="FRAMELY_PREVIEW_INSTALLED")snapshot("installed");if(s=="FRAMELY_PREVIEW_SETTINGS")snapshot("settings");if(s=="FRAMELY_VIEW_READY")ready=true;if(s=="FRAMELY_BRIDGE_PASS")passed=true;if(s.rfind("FRAMELY_BRIDGE_FAIL",0)==0)failed=true;return 1;};life.api.on_before_close=[](cef_life_span_handler_t*,cef_browser_t*){closed=true;};
+39
View File
@@ -0,0 +1,39 @@
const wait=(ms=50)=>new Promise(r=>setTimeout(r,ms));const until=async(f:()=>unknown)=>{for(let i=0;i<220;i++){if(f())return;await wait();}throw Error('Timed out '+document.body.innerText.slice(-600));};
history.replaceState(null,'','/manager');let language='en-US',created=false;const home='/home/steamos',input:string[]=[];
const plugin={manifest:{id:'demo',name:'Frame Mic Tuner',version:'1.0.0',description:'Adjust microphone gain on your Frame.',author:'Community',ui:{windows:{}}},enabled:true,favorite:false,order:0};
const database:any={language,plugins:{demo:plugin},sources:[],safeMode:false,launcher:{primaryTrigger:false,menuAutoClose:true,menuTimeoutSeconds:10,allCategories:['plugin','steam','lepton','desktop']},networkPanel:{enabled:true,port:15915,passwordEnabled:true},proxy:{http:'',github:''}};
const files=[{name:'Downloads',path:home+'/Downloads',directory:true,size:0,mode:493,uid:1000,gid:1000},{name:'notes.txt',path:home+'/notes.txt',directory:false,size:123,mode:420,uid:1000,gid:1000}];
class Socket {static OPEN=1;readyState=0;onopen:any;onmessage:any;onclose:any;onerror:any;constructor(){setTimeout(()=>{this.readyState=1;this.onopen?.();this.reply(btoa('steamos@frame:~$ '));},50);}reply(data=''){this.onmessage?.({data:JSON.stringify({writable:true,exited:false,data})});}send(value:string){const v=JSON.parse(value);if(v.input)input.push(atob(v.input));this.reply();}close(){this.readyState=3;this.onclose?.();}}
(window as any).WebSocket=Socket;
window.fetch=async(url,init)=>{let result:any=true;const u=String(url),p=JSON.parse(String(init?.body??'{}'));
if(u==='/manager-api/files'){
if(p.operation==='preferences')result=[];else if(p.operation==='view.preferences')result={view:'list',folders:{},uploadConcurrency:3};
else if(p.operation==='list')result={path:p.path||home,parent:'/',total:p.hidden?1:2,roots:[{name:'Home',path:home},{name:'Downloads',path:home+'/Downloads'}],entries:p.hidden?files.filter(f=>f.directory):files};else throw Error('files '+p.operation);
}else if(u==='/manager-api/terminal'){if(p.operation==='create')created=true;result=p.operation==='create'?{id:'session',name:'steamos'}:created?[{id:'session',name:'steamos',controlled:true,exited:false}]:[];
}else{const {method}=p;
if(method==='status'){database.language=language;result={version:'0.6.0',agreement:{version:1,accepted:true},database,running:[],notifications:[],inbox:[]};}
else if(method==='language.list')result={packs:[],invalidFiles:[]};else if(method==='ui.events')result={cursor:0,events:[]};else if(method==='network.status')result={passwordConfigured:true,localOrigin:'http://localhost:15915',ips:[],listener:{}};
else if(method==='catalog.start')result={job:'catalog'};else if(method==='apk.cleanup.list')result=[];
else if(method==='apk.list.start')result={job:'apk'};else if(method==='job.status')result=p.params?.job==='catalog'?{phase:'done',result:[]}:{phase:'done',result:{apps:[{id:'wolvic/com.igalia.wolvic',context:'wolvic',metadata:{name:'Wolvic',package:'com.igalia.wolvic',version:'1.0',activities:['Main'],declaredActivities:['Main']},installed:true,size:1024}],containers:[],warnings:[],roots:[],available:true}};
}return new Response(JSON.stringify({result}));};
const button=(text:string,parent:ParentNode=document)=>{const node=Array.from(parent.querySelectorAll<HTMLButtonElement>('button')).find(b=>b.textContent?.trim()===text);if(!node||!node.getClientRects().length)throw Error('Missing visible button '+text);return node;};
const section=(key:string)=>document.querySelector<HTMLButtonElement>(`.manager-switcher button:nth-child(${({plugins:1,apk:2,terminal:3,files:4,settings:5} as any)[key]})`)!.click();
const shot=async(name:string)=>{await wait(1200);console.log('FRAMELY_PREVIEW_INSTALL_MOBILE_'+name);await wait(200);};
const noOverflow=()=>{if(document.documentElement.scrollWidth>innerWidth+1)throw Error('Page horizontal overflow');const content=document.querySelector<HTMLElement>('.content');if(content&&content.scrollWidth>content.clientWidth+2)throw Error('Content overflow '+location.hash+' '+content.scrollWidth+'/'+content.clientWidth);};
(window as any).runInstallReviewChecks=async()=>{try{
await until(()=>document.querySelector('.plugin-row'));const mobile=innerWidth<768;
if(mobile){const nav=document.querySelector('.manager-switcher')!.getBoundingClientRect();if(nav.bottom<innerHeight-4||nav.top<innerHeight-120)throw Error('Navigation not at thumb reach');if(document.querySelector('.workspace>nav')!.getBoundingClientRect().height)throw Error('Desktop subnav still visible');}
await shot('PLUGINS_EN');noOverflow();
if(mobile){document.querySelector<HTMLButtonElement>('[aria-label="Manage Frame Mic Tuner"]')!.click();await until(()=>document.querySelector('.manage-dialog'));const rect=document.querySelector('.manage-dialog')!.getBoundingClientRect();if(Math.abs(rect.bottom-innerHeight)>2)throw Error('Dialog is not a bottom sheet');await shot('PLUGIN_ACTIONS_EN');document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));await until(()=>!document.querySelector('.manage-dialog'));}
section('settings');await until(()=>document.querySelector('.network-password'));
if(mobile){document.querySelector<HTMLButtonElement>('.mobile-page-picker>button')!.click();await until(()=>document.querySelector('.mobile-page-options'));button('Launcher',document.querySelector('.mobile-page-options')!).click();}else location.hash='launcher-settings';
await until(()=>document.querySelector('.launcher-settings'));await shot('SETTINGS_EN');section('files');await until(()=>document.querySelector('.file-list-scroll')?.getAttribute('aria-busy')==='false');
if(mobile){document.querySelector<HTMLButtonElement>('[aria-label="Browse folders"]')!.click();await until(()=>document.querySelector('.file-navigation-open'));await shot('FOLDERS_EN');button('Downloads',document.querySelector('.file-sidebar')!).click();await until(()=>!document.querySelector('.file-navigation-open'));await until(()=>document.querySelector('.file-table input[aria-label="notes.txt"]'));button('Filters and view').click();await until(()=>document.querySelector('.mobile-sheet'));if(!document.querySelector('.mobile-sheet .file-sort'))throw Error('Missing sort controls');document.querySelector<HTMLButtonElement>('.mobile-sheet header button')!.click();button('Create and upload').click();await until(()=>document.querySelector('.mobile-sheet'));if(!button('Upload folder',document.querySelector('.mobile-sheet')!))throw Error('Missing folder upload');document.querySelector<HTMLButtonElement>('.mobile-sheet header button')!.click();button('Select').click();document.querySelector<HTMLInputElement>('.file-table input[aria-label="notes.txt"]')!.click();await until(()=>document.querySelector('.file-mobile-selection'));button('Actions',document.querySelector('.file-mobile-selection')!).click();await until(()=>document.querySelector('.mobile-sheet'));await shot('FILE_ACTIONS_EN');document.querySelector<HTMLButtonElement>('.mobile-sheet header button')!.click();}
await shot('FILES_EN');noOverflow();section('terminal');await until(()=>document.querySelector('.terminal-manager'));document.querySelector<HTMLButtonElement>('.terminal-tabbar>button')!.click();await until(()=>document.querySelector('.terminal-connection')?.textContent==='Interactive');
if(mobile){button('Ctrl+C').click();button('Tab').click();await until(()=>input.includes('\x03')&&input.includes('\t'));}await shot('TERMINAL_EN');section('apk');await until(()=>document.querySelector('.apk-row'));await shot('APK_EN');noOverflow();
language='zh-CN';await until(()=>document.documentElement.lang==='zh-CN');await shot('APK_ZH');section('files');await until(()=>document.querySelector('.file-list-scroll'));await shot('FILES_ZH');section('settings');await until(()=>document.querySelector('.launcher-settings'));await shot('SETTINGS_ZH');noOverflow();
for(const page of ['settings','about','updates','notification-settings','sources','apk-settings','apk-containers','apk-cleanup']){location.hash=page;await wait(350);noOverflow();if(!document.querySelector('.content'))throw Error('Missing page '+page);}
if(mobile&&visualViewport){Object.defineProperty(visualViewport,'height',{value:innerHeight-300,configurable:true});visualViewport.dispatchEvent(new Event('resize'));await wait();if(getComputedStyle(document.querySelector('.manager-switcher')!).display!=='none')throw Error('Keyboard did not hide navigation');delete (visualViewport as any).height;visualViewport.dispatchEvent(new Event('resize'));await wait();}
console.log('FRAMELY_BRIDGE_PASS');
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e+' '+(e as Error).stack);}};
void import('../ui/src/main');console.log('FRAMELY_VIEW_READY');
+15
View File
@@ -50,3 +50,18 @@
.terminal-manager .terminal-shortcuts{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:11px;color:#c3c3cb}
@media(max-width:900px){.terminal-manager .terminal-status{flex-wrap:wrap;gap:4px 12px}.terminal-manager .terminal-shortcuts{order:3;flex-basis:100%;font-size:10px}}
@media(max-width:767px){
.manager .terminal-manager .terminal-canvas{height:auto;min-height:0;padding:8px 6px 4px;flex:1;border-radius:0;border:0}
.manager .terminal-manager .terminal-touch-keys{display:flex;align-items:center;gap:4px;overflow-x:auto;flex:none;padding:6px;background:var(--manager-chrome-tint);border-top:1px solid #ffffff14}
.manager .terminal-touch-keys button{flex:none;min-height:44px;min-width:40px;padding:8px;background:#ffffff08;font-size:12px}
.manager .terminal-manager .terminal-status{min-height:24px;padding:4px 10px}
.manager .terminal-manager .terminal-shortcuts{display:none}
.manager .terminal-manager .terminal-toolbar{gap:4px;flex-wrap:nowrap;padding:4px 8px;min-height:48px}
.manager .terminal-manager .terminal-controls button{min-width:40px;padding:8px}
.manager .terminal-manager .terminal-controls button span{display:none}
.manager .terminal-manager .terminal-tab{max-width:170px}
.manager .terminal-manager .terminal-tabbar{gap:4px;min-height:48px}
.manager .terminal-manager .terminal-tabbar>button{font-size:0;min-width:44px;min-height:44px}
.terminal-paste-label{display:flex;flex-direction:column;gap:10px;font-size:14px}
.terminal-paste-label textarea{width:100%;min-height:120px;margin:0 0 12px;padding:12px;font-size:16px;background:#171719;color:#ededf0;border:1px solid #55555c;border-radius:12px}
}
+8 -2
View File
@@ -1,3 +1,4 @@
import {MobileSheet,useMobileManager} from './mobile-manager';
import React,{useEffect,useRef,useState} from 'react';
import {Terminal} from '@xterm/xterm';
import {IconTerminal2,IconPlus,IconX,IconRefresh,IconPencil,IconLock,IconPlugConnected,IconPlugConnectedX,IconInfoCircle} from '@tabler/icons-react';
@@ -9,6 +10,10 @@ import './manager-tools.css';
import './terminal.css';
type Session={id:string;name:string;exited:boolean;controlled:boolean};
export function TerminalManager(){
const mobile=useMobileManager(),[pasteOpen,setPasteOpen]=useState(false),[pasteText,setPasteText]=useState('');
function touchInput(value:string){if(!connected||!writable||exited||!reachable())return;term.current?.input(value,true);term.current?.focus();}
async function paste(){if(navigator.clipboard?.readText){try{const text=await navigator.clipboard.readText();if(text&&connected&&writable&&!exited)term.current?.paste(text);return;}catch{}}setPasteText('');setPasteOpen(true);}
function copy(){const value=term.current?.getSelection();if(!value){setError(t('请先选择要复制的终端文本。'));return;}if(navigator.clipboard?.writeText)void navigator.clipboard.writeText(value).catch(()=>setError(t('无法复制,请使用浏览器的复制功能。')));else setError(t('无法复制,请使用浏览器的复制功能。'));}
const[sessions,setSessions]=useState<Session[]>([]),[selected,setSelected]=useState(()=>sessionStorage.getItem('framely-terminal-session')??''),[error,setError]=useState(''),[connected,setConnected]=useState(false),[writable,setWritable]=useState(false),[exited,setExited]=useState(false),[truncated,setTruncated]=useState(false),[close,setClose]=useState<Session|null>(null),[name,setName]=useState(''),[renaming,setRenaming]=useState(false),[busy,setBusy]=useState(false),[loading,setLoading]=useState(true),[dimensions,setDimensions]=useState({cols:80,rows:24});
const localPanel=['localhost','127.0.0.1','[::1]'].includes(location.hostname),reachable=()=>navigator.onLine!==false||localPanel;
const host=useRef<HTMLDivElement>(null),socket=useRef<WebSocket|null>(null),term=useRef<Terminal|null>(null),fit=useRef<FitAddon|null>(null),reconnect=useRef(()=>{});
@@ -18,7 +23,7 @@ export function TerminalManager(){
useEffect(()=>{sessionStorage.setItem('framely-terminal-session',selected);},[selected]);
useEffect(()=>{void refresh();const timer=setInterval(()=>void refresh(),3000);return()=>clearInterval(timer);},[]);
async function create(){if(busy)return;setBusy(true);try{const s=await managerApi<Session>('terminal',{operation:'create',name:t('终端')});setSelected(s.id);setName(s.name);await refresh();term.current?.focus();}catch(e){setError(String(e));}finally{setBusy(false);}}
useEffect(()=>{if(!host.current||!selected)return;const terminal=new Terminal({fontSize:16,scrollback:10000,allowTransparency:true,cursorBlink:!matchMedia('(prefers-reduced-motion: reduce)').matches,disableStdin:true,theme:{background:'#00000000',foreground:'#e8e8ed',selectionBackground:'#555555'}}),fitter=new FitAddon();terminal.loadAddon(fitter);terminal.open(host.current);term.current=terminal;fit.current=fitter;terminal.parser.registerOscHandler(52,()=>true);
useEffect(()=>{if(!host.current||!selected)return;const terminal=new Terminal({fontSize:matchMedia('(max-width: 767px)').matches?13:16,scrollback:10000,allowTransparency:true,cursorBlink:!matchMedia('(prefers-reduced-motion: reduce)').matches,disableStdin:true,theme:{background:'#00000000',foreground:'#e8e8ed',selectionBackground:'#555555'}}),fitter=new FitAddon();terminal.loadAddon(fitter);terminal.open(host.current);term.current=terminal;fit.current=fitter;terminal.parser.registerOscHandler(52,()=>true);
terminal.attachCustomKeyEventHandler(event=>{
if(event.type!=='keydown'||!event.ctrlKey||!event.shiftKey||event.altKey||event.metaKey)return true;
if(event.code==='KeyV')return false; // Native paste supplies the clipboard to xterm; do not emit Ctrl-V.
@@ -48,7 +53,8 @@ export function TerminalManager(){
<div className="terminal-tabbar"><div className="terminal-sessions" role="tablist" aria-label={t('终端会话')}>{sessions.map(session=><div className={`terminal-tab ${selected===session.id?'active':''}`} key={session.id}><button role="tab" aria-selected={selected===session.id} onClick={()=>{setSelected(session.id);setName(session.name);}}><IconTerminal2 size={17}/><span>{session.name}</span>{session.exited&&<small>{t('已结束')}</small>}</button><button className="terminal-tab-close" aria-label={t('关闭会话 {0}',{0:session.name})} onClick={()=>{setError('');setClose(session);}}><IconX size={15}/></button></div>)}</div><button className="terminal-new" disabled={busy} onClick={()=>void create()}><IconPlus size={18}/><span>{t('新建终端')}</span></button></div>
{error&&<div className="terminal-notice error" role="alert"><IconInfoCircle size={18}/><span>{error}</span><button aria-label={t('关闭')} onClick={()=>setError('')}><IconX size={16}/></button></div>}
{selected?<><div className="terminal-toolbar"><span className="terminal-connection" role="status">{exited?<IconTerminal2 size={16}/>:connected?(writable?<IconPlugConnected size={16}/>:<IconLock size={16}/>):<IconPlugConnectedX size={16}/>}<span>{t(exited?'已结束':connected?(writable?'可输入':'只读'):'正在连接')}</span></span><div className="terminal-controls"><button aria-label={t('重命名会话')} disabled={busy} onClick={()=>{setName(current?.name??name);setError('');setRenaming(true);}}><IconPencil size={17}/><span>{t('重命名')}</span></button><button onClick={()=>reconnect.current()}><IconRefresh size={17}/><span>{t('重新连接')}</span></button>{!writable&&connected&&!exited&&<button className="terminal-takeover" onClick={()=>socket.current?.send('{"claim":true}')}><IconLock size={17}/>{t('接管控制')}</button>}</div></div>
{truncated&&<div className="terminal-notice"><IconInfoCircle size={18}/><span>{t('较早的终端输出已超出缓存,仅恢复保留的内容。')}</span></div>}<div className="terminal-canvas" ref={host}/><footer className="terminal-status"><span>{t('Steam 会话用户')}<small>{t('可使用 sudo 提权')}</small></span><span className="terminal-shortcuts">{t('Ctrl+Shift+C 复制 · Ctrl+Shift+V 粘贴 · Ctrl+C 中断程序')}</span><span>{dimensions.cols} × {dimensions.rows}</span></footer></>:<div className="terminal-empty"><IconTerminal2 size={42}/><h2>{t(loading?'正在读取':'选择或新建终端')}</h2><p>{t('离开此页面不会结束终端会话。')}</p></div>}
{truncated&&<div className="terminal-notice"><IconInfoCircle size={18}/><span>{t('较早的终端输出已超出缓存,仅恢复保留的内容。')}</span></div>}<div className="terminal-canvas" ref={host}/>{mobile&&<div className="terminal-touch-keys" aria-label={t('终端快捷键')}><button disabled={!connected||!writable||exited} onPointerDown={e=>e.preventDefault()} onClick={()=>touchInput('\x03')}>Ctrl+C</button><button disabled={!connected||!writable||exited} onPointerDown={e=>e.preventDefault()} onClick={()=>touchInput('\x1b')}>Esc</button><button disabled={!connected||!writable||exited} onPointerDown={e=>e.preventDefault()} onClick={()=>touchInput('\t')}>Tab</button>{(['A','B','D','C'] as const).map((key,i)=><button key={key} aria-label={t(['向上','向下','向左','向右'][i])} disabled={!connected||!writable||exited} onPointerDown={e=>e.preventDefault()} onClick={()=>touchInput('\x1b['+key)}>{['↑','↓','←','→'][i]}</button>)}<button onClick={copy}>{t('复制')}</button><button disabled={!connected||!writable||exited} onClick={()=>void paste()}>{t('粘贴')}</button></div>}<footer className="terminal-status"><span>{t('Steam 会话用户')}<small>{t('可使用 sudo 提权')}</small></span><span className="terminal-shortcuts">{t('Ctrl+Shift+C 复制 · Ctrl+Shift+V 粘贴 · Ctrl+C 中断程序')}</span><span>{dimensions.cols} × {dimensions.rows}</span></footer></>:<div className="terminal-empty"><IconTerminal2 size={42}/><h2>{t(loading?'正在读取':'选择或新建终端')}</h2><p>{t('离开此页面不会结束终端会话。')}</p></div>}
{pasteOpen&&<MobileSheet title={t('粘贴到终端')} onClose={()=>setPasteOpen(false)}><label className="terminal-paste-label">{t('粘贴文本')}<textarea autoFocus value={pasteText} onChange={e=>setPasteText(e.target.value)} placeholder={t('在这里使用手机的粘贴功能')}/></label><button disabled={!pasteText||!connected||!writable||exited} onClick={()=>{term.current?.paste(pasteText);setPasteOpen(false);term.current?.focus();}}>{t('发送到终端')}</button></MobileSheet>}
{renaming&&<div className="modal terminal-dialog" role="dialog" aria-modal="true" aria-label={t('重命名会话')}><h2>{t('重命名会话')}</h2><label>{t('会话名称')}<input autoFocus value={name} onChange={event=>setName(event.target.value)} onKeyDown={event=>{if(event.key==='Enter')void rename();}}/></label>{error&&<p className="error">{error}</p>}<footer><button disabled={busy} onClick={()=>setRenaming(false)}>{t('取消')}</button><button className="primary" disabled={busy||!name.trim()} onClick={()=>void rename()}>{t('保存')}</button></footer></div>}
{close&&<div className="modal terminal-dialog" role="dialog" aria-modal="true" aria-label={t('关闭终端')}><h2>{t('关闭终端')}</h2><b>{close.name}</b><p>{t('关闭会话将结束其中运行的程序。')}</p>{error&&<p className="error">{error}</p>}<footer><button disabled={busy} onClick={()=>setClose(null)}>{t('取消')}</button><button className="danger" disabled={busy} onClick={()=>void closeSession()}>{t('确认关闭')}</button></footer></div>}
</section>;