From a72cf7b3c10d026e7b1c4182a5dcb93cdda80fca Mon Sep 17 00:00:00 2001 From: toorux Date: Fri, 9 Oct 2026 18:19:50 +0800 Subject: [PATCH] Add mobile terminal controls and bilingual workflow coverage --- docs/user-guide/terminal-and-files.md | 10 +++++- docs/zh-CN/user-guide/terminal-and-files.md | 8 +++++ tests/browser_probe.cpp | 2 +- tests/mobile_manager_fixture.tsx | 39 +++++++++++++++++++++ ui/src/terminal.css | 15 ++++++++ ui/src/terminal.tsx | 10 ++++-- 6 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 tests/mobile_manager_fixture.tsx diff --git a/docs/user-guide/terminal-and-files.md b/docs/user-guide/terminal-and-files.md index b24214f..a2c1c3d 100644 --- a/docs/user-guide/terminal-and-files.md +++ b/docs/user-guide/terminal-and-files.md @@ -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. diff --git a/docs/zh-CN/user-guide/terminal-and-files.md b/docs/zh-CN/user-guide/terminal-and-files.md index 1b4ef66..4ae89c3 100644 --- a/docs/zh-CN/user-guide/terminal-and-files.md +++ b/docs/zh-CN/user-guide/terminal-and-files.md @@ -10,6 +10,14 @@ 插件区域包含已安装插件、插件库、来源和通知设置,通知仍包含 Framely 本体;插件自身设置位于对应插件的管理弹窗。APK 保留应用、容器及数据清理。设置集中放置通用、启动台、更新和关于。 +## 手机操作 + +手机端底部导航包含插件、APK、终端、文件和设置。点击顶部当前页面名称选择子页面,确认和安装弹窗以底部面板展示。界面为安全区和软键盘保留空间,键盘展开时隐藏底部导航。 + +文件页点击“浏览文件夹”打开快速访问与目录树抽屉,选择目录后自动收起。“新建与上传”和“筛选与显示”打开操作面板;点击“选择”勾选文件,再从底部选择栏的“操作”进入复制、移动、下载或删除,无需右键。浏览目录和切换区域不停止上传队列。 + +终端提供 Ctrl+C、Esc、Tab 和方向快捷键,其中 Ctrl+C 用于中断程序。“粘贴”在浏览器允许时读取剪贴板,否则打开文本框,使用手机原生粘贴后点击“发送到终端”。断连或只读时禁用输入快捷键。 + ## 交互反馈 在 **设置 → 通用 → 交互反馈** 中,振动反馈默认开启,提示音反馈默认关闭,修改后会保存。振动开关统一控制 Framely 和插件页面的激光悬停、启动台眼动聚焦振动,不影响 APK 游戏振动。提示音开关使用 Steam 本机键盘音效,以较低音量反馈聚焦和点击,需要本机 Steam 音效文件与音频运行库,不打包或下载这些音效。 diff --git a/tests/browser_probe.cpp b/tests/browser_probe.cpp index e925e23..eca644f 100644 --- a/tests/browser_probe.cpp +++ b/tests/browser_probe.cpp @@ -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<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));py128)ink++;if(ink<30){failed=true;std::cerr<<"FRAMELY_BRIDGE_FAIL missing painted control "<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 "<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;}; diff --git a/tests/mobile_manager_fixture.tsx b/tests/mobile_manager_fixture.tsx new file mode 100644 index 0000000..1471d4d --- /dev/null +++ b/tests/mobile_manager_fixture.tsx @@ -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('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(`.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('.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.bottomnav')!.getBoundingClientRect().height)throw Error('Desktop subnav still visible');} + await shot('PLUGINS_EN');noOverflow(); + if(mobile){document.querySelector('[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('.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('[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('.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('.mobile-sheet header button')!.click();button('Select').click();document.querySelector('.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('.mobile-sheet header button')!.click();} + await shot('FILES_EN');noOverflow();section('terminal');await until(()=>document.querySelector('.terminal-manager'));document.querySelector('.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'); diff --git a/ui/src/terminal.css b/ui/src/terminal.css index 4341b97..388c1da 100644 --- a/ui/src/terminal.css +++ b/ui/src/terminal.css @@ -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} +} diff --git a/ui/src/terminal.tsx b/ui/src/terminal.tsx index ef541e0..a092040 100644 --- a/ui/src/terminal.tsx +++ b/ui/src/terminal.tsx @@ -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([]),[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(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(null),socket=useRef(null),term=useRef(null),fit=useRef(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('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(){
{sessions.map(session=>
)}
{error&&
{error}
} {selected?<>
{exited?:connected?(writable?:):}{t(exited?'已结束':connected?(writable?'可输入':'只读'):'正在连接')}
{!writable&&connected&&!exited&&}
- {truncated&&
{t('较早的终端输出已超出缓存,仅恢复保留的内容。')}
}
{t('Steam 会话用户')}{t('可使用 sudo 提权')}{t('Ctrl+Shift+C 复制 · Ctrl+Shift+V 粘贴 · Ctrl+C 中断程序')}{dimensions.cols} × {dimensions.rows}
:

{t(loading?'正在读取':'选择或新建终端')}

{t('离开此页面不会结束终端会话。')}

} + {truncated&&
{t('较早的终端输出已超出缓存,仅恢复保留的内容。')}
}
{mobile&&
{(['A','B','D','C'] as const).map((key,i)=>)}
}
{t('Steam 会话用户')}{t('可使用 sudo 提权')}{t('Ctrl+Shift+C 复制 · Ctrl+Shift+V 粘贴 · Ctrl+C 中断程序')}{dimensions.cols} × {dimensions.rows}
:

{t(loading?'正在读取':'选择或新建终端')}

{t('离开此页面不会结束终端会话。')}

} + {pasteOpen&&setPasteOpen(false)}>